Răsfoiți Sursa

feat: add template usage summary metrics

ChenYu 3 luni în urmă
părinte
comite
187d636c78

+ 19 - 0
backend/docs/数据库设计.md

@@ -115,6 +115,7 @@ create table hair_template
     ref_hair_url       varchar(500)                            default ''                null comment '参考发型图片URL',
     hairstylist_id     bigint unsigned                                                   null comment '创建发型师ID,系统模板为空',
     template_url       varchar(500)                            default ''                null comment '完整模板预览页面URL',
+    used_count         int unsigned                            default 0                 not null comment '模板累计使用次数',
     is_system_template enum ('YES', 'NO')                      default 'NO'              not null comment '是否系统模板:YES系统模板,NO门店自定义模板',
     create_time        datetime                                default CURRENT_TIMESTAMP not null comment '创建时间',
     update_time        datetime                                default CURRENT_TIMESTAMP not null on update CURRENT_TIMESTAMP comment '更新时间',
@@ -159,3 +160,21 @@ CREATE TABLE `hair_template_make_task` (
   KEY `idx_task_status` (`task_status`,`is_deleted`) COMMENT '筛选待执行、失败任务',
   KEY `idx_template_id` (`template_id`,`is_deleted`) COMMENT '根据模板查询制作任务记录'
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='发型模板制作异步任务表';
+
+
+CREATE TABLE `system_config` (
+  `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '配置主键ID',
+  `config_key` VARCHAR(100) NOT NULL COMMENT '配置唯一键,英文标识',
+  `config_name` VARCHAR(200) NOT NULL COMMENT '配置中文名称',
+  `config_group` VARCHAR(50) NOT NULL DEFAULT 'BASE' COMMENT '配置分组:BASE基础、AI智能、SYSTEM系统、UPLOAD上传等',
+  `config_value` TEXT NULL COMMENT '配置值,文本/数字/JSON都存这里',
+  `value_type` ENUM('STRING','NUMBER','BOOLEAN','JSON') NOT NULL DEFAULT 'STRING' COMMENT '值类型:字符串/数字/布尔/JSON数组对象',
+  `sort` INT NOT NULL DEFAULT 0 COMMENT '排序号,后台展示顺序',
+  `remark` VARCHAR(500) DEFAULT '' NULL COMMENT '配置说明备注',
+  `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+  `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+  `is_deleted` TINYINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '逻辑删除:0正常 1删除',
+  PRIMARY KEY (`id`),
+  UNIQUE KEY `uk_config_key` (`config_key`,`is_deleted`) COMMENT '配置键唯一,删除后可重名',
+  KEY `idx_config_group` (`config_group`,`is_deleted`) COMMENT '按分组筛选配置'
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='系统全局配置表';

+ 3 - 0
backend/prisma/migrations/20260628173000_add_template_used_count/migration.sql

@@ -0,0 +1,3 @@
+ALTER TABLE `hair_template`
+  ADD COLUMN `used_count` INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '模板累计使用次数'
+  AFTER `template_url`;

+ 26 - 0
backend/prisma/schema.prisma

@@ -38,6 +38,13 @@ enum HairTemplateSystemFlag {
   NO
 }
 
+enum SystemConfigValueType {
+  STRING
+  NUMBER
+  BOOLEAN
+  JSON
+}
+
 enum CustomerExpectContact {
   LINE
   PHONE
@@ -184,6 +191,7 @@ model HairTemplate {
   refHairUrl       String                 @default("") @map("ref_hair_url") @db.VarChar(500)
   hairstylistId    BigInt?                @map("hairstylist_id") @db.UnsignedBigInt
   templateUrl      String                 @default("") @map("template_url") @db.VarChar(500)
+  usedCount        Int                    @default(0) @map("used_count") @db.UnsignedInt
   isSystemTemplate HairTemplateSystemFlag @default(NO) @map("is_system_template")
   createTime       DateTime               @default(now()) @map("create_time")
   updateTime       DateTime               @updatedAt @map("update_time")
@@ -216,3 +224,21 @@ model HairTemplateMakeTask {
 
   @@map("hair_template_make_task")
 }
+
+model SystemConfig {
+  id          BigInt                @id @default(autoincrement()) @db.UnsignedBigInt
+  configKey   String                @map("config_key") @db.VarChar(100)
+  configName  String                @map("config_name") @db.VarChar(200)
+  configGroup String                @map("config_group") @db.VarChar(50)
+  configValue String?               @map("config_value") @db.Text
+  valueType   SystemConfigValueType @default(STRING) @map("value_type")
+  sort        Int                   @default(0)
+  remark      String?               @default("") @db.VarChar(500)
+  createTime  DateTime              @default(now()) @map("create_time")
+  updateTime  DateTime              @updatedAt @map("update_time")
+  isDeleted   Int                   @default(0) @map("is_deleted") @db.UnsignedTinyInt
+
+  @@unique([configKey, isDeleted], map: "uk_config_key")
+  @@index([configGroup, isDeleted], map: "idx_config_group")
+  @@map("system_config")
+}

+ 109 - 0
backend/src/modules/simulations/simulations.service.spec.ts

@@ -34,6 +34,7 @@ describe('SimulationsService', () => {
     hairTemplate: {
       findMany: jest.fn(),
       count: jest.fn(),
+      update: jest.fn(),
     },
     $transaction: jest.fn(),
   };
@@ -313,6 +314,114 @@ describe('SimulationsService', () => {
     expect(result.overallStatus).toBe('PARTIAL_SUCCESS');
   });
 
+  it('increments template usedCount when a processing task first becomes successes', async () => {
+    prisma.aiHairSimulation.findFirst.mockResolvedValue({
+      id: BigInt(201),
+      customerId: null,
+      hairstylistId: BigInt(7),
+      originImage: 'https://cdn.example.com/origin.jpg',
+      simulateRemark: null,
+      createTime: new Date('2026-06-28T09:00:00Z'),
+      isDeleted: 0,
+    });
+    prisma.aiHairSimulationTask.findMany
+      .mockResolvedValueOnce([
+        {
+          id: BigInt(301),
+          templateId: '101',
+          customerId: null,
+          taskId: 'task-101',
+          taskStatus: 'PROCESSING',
+          resultImages: null,
+          errorMsg: null,
+          taskStartTime: new Date('2026-06-28T10:00:00Z'),
+          taskFinishTime: null,
+          updateTime: new Date(Date.now() - 20_000),
+          isDeleted: 0,
+        },
+      ])
+      .mockResolvedValueOnce([
+        {
+          id: BigInt(301),
+          templateId: '101',
+          customerId: null,
+          taskId: 'task-101',
+          taskStatus: 'SUCCESSES',
+          resultImages: JSON.stringify(['https://cdn.example.com/result.png']),
+          errorMsg: null,
+          taskStartTime: new Date('2026-06-28T10:00:00Z'),
+          taskFinishTime: new Date('2026-06-28T10:01:00Z'),
+          updateTime: new Date('2026-06-28T10:01:00Z'),
+          isDeleted: 0,
+        },
+      ]);
+    prisma.hairTemplate.findMany.mockResolvedValue([
+      {
+        id: BigInt(101),
+        templateName: 'Layer Bob',
+        coverUrl: '',
+        category: 'SHORT',
+        isSystemTemplate: 'NO',
+      },
+    ]);
+    aiClient.getResult.mockResolvedValue({
+      status: 'successes',
+      images: ['https://cdn.example.com/result.png'],
+      message: '',
+    });
+
+    await service.getSimulationDetail(BigInt(7), '201');
+
+    expect(prisma.hairTemplate.update).toHaveBeenCalledWith({
+      where: { id: BigInt(101) },
+      data: {
+        usedCount: {
+          increment: 1,
+        },
+      },
+    });
+  });
+
+  it('does not increment template usedCount for tasks already marked as successes', async () => {
+    prisma.aiHairSimulation.findFirst.mockResolvedValue({
+      id: BigInt(201),
+      customerId: null,
+      hairstylistId: BigInt(7),
+      originImage: 'https://cdn.example.com/origin.jpg',
+      simulateRemark: null,
+      createTime: new Date('2026-06-28T09:00:00Z'),
+      isDeleted: 0,
+    });
+    prisma.aiHairSimulationTask.findMany.mockResolvedValue([
+      {
+        id: BigInt(301),
+        templateId: '101',
+        customerId: null,
+        taskId: 'task-101',
+        taskStatus: 'SUCCESSES',
+        resultImages: JSON.stringify(['https://cdn.example.com/result.png']),
+        errorMsg: null,
+        taskStartTime: new Date('2026-06-28T10:00:00Z'),
+        taskFinishTime: new Date('2026-06-28T10:01:00Z'),
+        updateTime: new Date('2026-06-28T10:01:00Z'),
+        isDeleted: 0,
+      },
+    ]);
+    prisma.hairTemplate.findMany.mockResolvedValue([
+      {
+        id: BigInt(101),
+        templateName: 'Layer Bob',
+        coverUrl: '',
+        category: 'SHORT',
+        isSystemTemplate: 'NO',
+      },
+    ]);
+
+    await service.getSimulationDetail(BigInt(7), '201');
+
+    expect(prisma.hairTemplate.update).not.toHaveBeenCalled();
+  });
+
   it('lists flattened customer gallery cards in descending simulation time order', async () => {
     prisma.customer.findFirst.mockResolvedValue({ id: BigInt(5) });
     prisma.aiHairSimulation.findMany.mockResolvedValue([

+ 36 - 13
backend/src/modules/simulations/simulations.service.ts

@@ -257,21 +257,44 @@ export class SimulationsService {
           continue;
         }
 
+        if (result.status === 'successes') {
+          await this.prisma.aiHairSimulationTask.update({
+            where: { id: task.id },
+            data: {
+              taskStatus: 'SUCCESSES',
+              resultImages: JSON.stringify(result.images),
+              taskFinishTime: new Date(),
+              errorMsg: null,
+            },
+          });
+
+          try {
+            const templateId = BigInt(task.templateId);
+            await this.prisma.hairTemplate.update({
+              where: { id: templateId },
+              data: {
+                usedCount: {
+                  increment: 1,
+                },
+              },
+            });
+          } catch (error) {
+            const reason = error instanceof Error ? error.message : 'unknown error';
+            this.logger.warn(
+              `Failed to increment template usage for task ${task.taskId}: ${reason}`,
+            );
+          }
+
+          continue;
+        }
+
         await this.prisma.aiHairSimulationTask.update({
           where: { id: task.id },
-          data:
-            result.status === 'successes'
-              ? {
-                  taskStatus: 'SUCCESSES',
-                  resultImages: JSON.stringify(result.images),
-                  taskFinishTime: new Date(),
-                  errorMsg: null,
-                }
-              : {
-                  taskStatus: 'FAILED',
-                  errorMsg: result.message || 'AI 模拟失败',
-                  taskFinishTime: new Date(),
-                },
+          data: {
+            taskStatus: 'FAILED',
+            errorMsg: result.message || 'AI 模拟失败',
+            taskFinishTime: new Date(),
+          },
         });
       } catch (error) {
         const reason = error instanceof Error ? error.message : 'unknown error';

+ 107 - 3
backend/src/modules/templates/templates.controller.ts

@@ -1,15 +1,119 @@
-import { Body, Controller, Get, Post, Query } from '@nestjs/common';
+import {
+  Body,
+  Controller,
+  Delete,
+  Get,
+  Patch,
+  Param,
+  Post,
+  Query,
+  Req,
+  UseGuards,
+} from '@nestjs/common';
+import type { Request } from 'express';
+import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
+import { BulkDeleteTemplatesDto } from './dto/bulk-delete-templates.dto';
+import { CreateTemplateMakeTaskDto } from './dto/create-template-make-task.dto';
 import { CreateTemplateDto } from './dto/create-template.dto';
 import { ListTemplatesQueryDto } from './dto/list-templates-query.dto';
+import { UpdateTemplateBasicInfoDto } from './dto/update-template-basic-info.dto';
 import { TemplatesService } from './templates.service';
 
+type AuthenticatedRequest = Request & {
+  user: {
+    stylistId: string | number;
+  };
+};
+
+@UseGuards(JwtAuthGuard)
 @Controller('templates')
 export class TemplatesController {
   constructor(private readonly templatesService: TemplatesService) {}
 
+  @Get('assets')
+  assets() {
+    return this.templatesService.getTemplateAssets();
+  }
+
+  @Post('make-tasks')
+  createMakeTask(
+    @Req() request: AuthenticatedRequest,
+    @Body() body: CreateTemplateMakeTaskDto,
+  ) {
+    return this.templatesService.createTemplateMakeTask(
+      BigInt(request.user.stylistId),
+      body,
+    );
+  }
+
+  @Get('make-tasks/:taskId')
+  detail(@Req() request: AuthenticatedRequest, @Param('taskId') taskId: string) {
+    return this.templatesService.getTemplateMakeTaskDetail(
+      BigInt(request.user.stylistId),
+      taskId,
+    );
+  }
+
+  @Get('summary')
+  summary(@Req() request: AuthenticatedRequest) {
+    return this.templatesService.getSummary(BigInt(request.user.stylistId));
+  }
+
+  @Get(':templateId')
+  getById(
+    @Req() request: AuthenticatedRequest,
+    @Param('templateId') templateId: string,
+  ) {
+    return this.templatesService.getBasicInfo(
+      BigInt(request.user.stylistId),
+      templateId,
+    );
+  }
+
+  @Patch(':templateId')
+  updateById(
+    @Req() request: AuthenticatedRequest,
+    @Param('templateId') templateId: string,
+    @Body() body: UpdateTemplateBasicInfoDto,
+  ) {
+    return this.templatesService.updateBasicInfo(
+      BigInt(request.user.stylistId),
+      templateId,
+      body,
+    );
+  }
+
+  @Delete(':templateId')
+  deleteById(
+    @Req() request: AuthenticatedRequest,
+    @Param('templateId') templateId: string,
+  ) {
+    return this.templatesService.deleteTemplate(
+      BigInt(request.user.stylistId),
+      templateId,
+    );
+  }
+
+  @Post('bulk-delete')
+  bulkDelete(
+    @Req() request: AuthenticatedRequest,
+    @Body() body: BulkDeleteTemplatesDto,
+  ) {
+    return this.templatesService.deleteTemplates(
+      BigInt(request.user.stylistId),
+      body.ids,
+    );
+  }
+
   @Get()
-  list(@Query() query: ListTemplatesQueryDto) {
-    return this.templatesService.list(query);
+  list(
+    @Req() request: AuthenticatedRequest,
+    @Query() query: ListTemplatesQueryDto,
+  ) {
+    return this.templatesService.list({
+      ...query,
+      stylistId: BigInt(request.user.stylistId),
+    });
   }
 
   @Post()

+ 832 - 0
backend/src/modules/templates/templates.service.spec.ts

@@ -0,0 +1,832 @@
+import { Test } from '@nestjs/testing';
+import { ConfigService } from '@nestjs/config';
+import { PrismaService } from '../../prisma/prisma.service';
+import { StorageService } from '../storage/storage.service';
+import { TemplateAiClient } from './template-ai.client';
+import { TemplateTaskFiles } from './template-task-files';
+import { TemplateTaskRunner } from './template-task-runner';
+import { TemplatesService } from './templates.service';
+
+describe('TemplatesService', () => {
+  let service: TemplatesService;
+  const prisma = {
+    systemConfig: {
+      findMany: jest.fn(),
+    },
+    aiHairSimulationTask: {
+      count: jest.fn(),
+    },
+    hairTemplate: {
+      findFirst: jest.fn(),
+      findMany: jest.fn(),
+      count: jest.fn(),
+      update: jest.fn(),
+      updateMany: jest.fn(),
+    },
+    hairTemplateMakeTask: {
+      findFirst: jest.fn(),
+      findMany: jest.fn(),
+      update: jest.fn(),
+      updateMany: jest.fn(),
+    },
+    $transaction: jest.fn(),
+  };
+  const templateAiClient = {
+    executeTemplate: jest.fn(),
+    getResult: jest.fn(),
+  };
+  const storageService = {
+    uploadBuffer: jest.fn(),
+  };
+  const configService = {
+    get: jest.fn().mockReturnValue(0),
+  };
+  const templateTaskFiles = {
+    downloadToTempFile: jest.fn(),
+    prepareStep2Bundle: jest.fn(),
+    cleanup: jest.fn(),
+  };
+
+  beforeEach(async () => {
+    jest.resetAllMocks();
+
+    const moduleRef = await Test.createTestingModule({
+      providers: [
+        TemplatesService,
+        TemplateTaskRunner,
+        {
+          provide: PrismaService,
+          useValue: prisma,
+        },
+        {
+          provide: TemplateAiClient,
+          useValue: templateAiClient,
+        },
+        {
+          provide: StorageService,
+          useValue: storageService,
+        },
+        {
+          provide: TemplateTaskFiles,
+          useValue: templateTaskFiles,
+        },
+        {
+          provide: ConfigService,
+          useValue: configService,
+        },
+      ],
+    }).compile();
+
+    service = moduleRef.get(TemplatesService);
+  });
+
+  it('lists grouped template assets from system_config', async () => {
+    prisma.systemConfig.findMany.mockResolvedValue([
+      {
+        id: BigInt(11),
+        configKey: 'model-natural',
+        configName: '自然模特',
+        configGroup: 'ai-model',
+        configValue: 'https://cdn.example.com/model.jpg',
+      },
+      {
+        id: BigInt(12),
+        configKey: 'clothing-shirt',
+        configName: '白衬衫',
+        configGroup: 'clothing',
+        configValue: 'https://cdn.example.com/clothes.jpg',
+      },
+      {
+        id: BigInt(13),
+        configKey: 'background-studio',
+        configName: '影棚背景',
+        configGroup: 'background',
+        configValue: 'https://cdn.example.com/background.jpg',
+      },
+    ]);
+
+    const result = await service.getTemplateAssets();
+
+    expect(prisma.systemConfig.findMany).toHaveBeenCalledWith({
+      where: {
+        isDeleted: 0,
+        configGroup: {
+          in: ['ai-model', 'clothing', 'background'],
+        },
+      },
+      orderBy: [{ sort: 'asc' }, { id: 'asc' }],
+    });
+    expect(result).toEqual({
+      models: [
+        {
+          id: '11',
+          name: '自然模特',
+          url: 'https://cdn.example.com/model.jpg',
+          configKey: 'model-natural',
+        },
+      ],
+      clothes: [
+        {
+          id: '12',
+          name: '白衬衫',
+          url: 'https://cdn.example.com/clothes.jpg',
+          configKey: 'clothing-shirt',
+        },
+      ],
+      backgrounds: [
+        {
+          id: '13',
+          name: '影棚背景',
+          url: 'https://cdn.example.com/background.jpg',
+          configKey: 'background-studio',
+        },
+      ],
+    });
+  });
+
+  it('creates hair_template and hair_template_make_task, then starts step1', async () => {
+    prisma.systemConfig.findMany.mockResolvedValue([
+      {
+        id: BigInt(21),
+        configKey: 'template-step1-default',
+        configName: 'step1 template',
+        configGroup: 'template-step1',
+        configValue: 'https://cdn.example.com/template-step1.json',
+      },
+    ]);
+    templateAiClient.executeTemplate.mockResolvedValue({ taskId: 'step1-task-1' });
+
+    const createTemplate = jest.fn().mockResolvedValue({ id: BigInt(101) });
+    const createTask = jest.fn().mockResolvedValue({ id: BigInt(88) });
+    prisma.$transaction.mockImplementation(async (callback: (tx: {
+      hairTemplate: { create: typeof createTemplate };
+      hairTemplateMakeTask: { create: typeof createTask };
+    }) => Promise<unknown>) =>
+      callback({
+        hairTemplate: { create: createTemplate },
+        hairTemplateMakeTask: { create: createTask },
+      }),
+    );
+
+    const result = await service.createTemplateMakeTask(BigInt(1), {
+      templateName: '柔和层次长发',
+      category: 'LONG',
+      status: 'DRAFT',
+      description: '适合脸周柔和修饰',
+      tags: ['首次建议', '层次'],
+      refHairUrl: 'https://cdn.example.com/ref.jpg',
+      modelUrl: 'https://cdn.example.com/model.jpg',
+      clothesUrl: 'https://cdn.example.com/clothes.jpg',
+      backgroundUrl: 'https://cdn.example.com/background.jpg',
+    });
+
+    expect(createTemplate).toHaveBeenCalledWith({
+      data: expect.objectContaining({
+        templateName: '柔和层次长发',
+        category: 'LONG',
+        status: 'DRAFT',
+        tags: '首次建议,层次',
+        hairstylistId: BigInt(1),
+        refHairUrl: 'https://cdn.example.com/ref.jpg',
+      }),
+    });
+    expect(createTask).toHaveBeenCalledWith({
+      data: expect.objectContaining({
+        templateId: BigInt(101),
+        taskStatus: 'WAIT',
+        refHairUrl: 'https://cdn.example.com/ref.jpg',
+      }),
+    });
+    expect(templateAiClient.executeTemplate).toHaveBeenCalledWith(
+      'https://cdn.example.com/template-step1.json',
+      {
+        images: ['https://cdn.example.com/ref.jpg'],
+      },
+    );
+    expect(prisma.hairTemplateMakeTask.update).toHaveBeenCalledWith({
+      where: { id: BigInt(88) },
+      data: expect.objectContaining({
+        step1TaskId: 'step1-task-1',
+        taskStatus: 'PROCESSING',
+      }),
+    });
+    expect(result).toEqual({
+      templateId: '101',
+      taskId: '88',
+      totalTaskId: expect.any(String),
+      taskStatus: 'PROCESSING',
+    });
+  });
+
+  it('promotes a task from PROCESSING to STEP1_SUCCESS when step1 finishes', async () => {
+    prisma.hairTemplateMakeTask.findFirst.mockResolvedValue({
+      id: BigInt(88),
+      totalTaskId: 'tmpl_123',
+      taskStatus: 'PROCESSING',
+      templateId: BigInt(101),
+      step1TaskId: 'step1-task-1',
+      step2TaskId: '',
+      refHairUrl: 'https://cdn.example.com/ref.jpg',
+      modelUrl: 'https://cdn.example.com/model.jpg',
+      clothesUrl: 'https://cdn.example.com/clothes.jpg',
+      backgroundUrl: 'https://cdn.example.com/background.jpg',
+      step1ResultImg: '',
+      step2ResultImg: '',
+      step1Resource: JSON.stringify({
+        templateUrl: 'https://cdn.example.com/template-step1.json',
+      }),
+      step2Resource: JSON.stringify({}),
+      createTime: new Date('2026-06-28T12:00:00.000Z'),
+      updateTime: new Date(Date.now() - 20_000),
+      isDeleted: 0,
+    });
+    prisma.hairTemplate.findFirst.mockResolvedValue({
+      id: BigInt(101),
+      templateName: '柔和层次长发',
+      coverUrl: '',
+      templateUrl: '',
+      status: 'DRAFT',
+      hairstylistId: BigInt(1),
+      isDeleted: 0,
+    });
+    templateAiClient.getResult.mockResolvedValue({
+      status: 'successes',
+      message: '',
+      images: ['https://cdn.example.com/step1-result.jpg'],
+    });
+    templateTaskFiles.downloadToTempFile.mockResolvedValue(
+      '/tmp/template-make/tmpl_123/hairstyle.jpg',
+    );
+    prisma.hairTemplateMakeTask.update.mockResolvedValue({
+      id: BigInt(88),
+      totalTaskId: 'tmpl_123',
+      taskStatus: 'STEP1_SUCCESS',
+      templateId: BigInt(101),
+      step1TaskId: 'step1-task-1',
+      step2TaskId: '',
+      refHairUrl: 'https://cdn.example.com/ref.jpg',
+      modelUrl: 'https://cdn.example.com/model.jpg',
+      clothesUrl: 'https://cdn.example.com/clothes.jpg',
+      backgroundUrl: 'https://cdn.example.com/background.jpg',
+      step1ResultImg: 'https://cdn.example.com/step1-result.jpg',
+      step2ResultImg: '',
+      step1Resource: JSON.stringify({
+        templateUrl: 'https://cdn.example.com/template-step1.json',
+        localFile: '/tmp/template-make/tmpl_123/hairstyle.jpg',
+      }),
+      step2Resource: JSON.stringify({}),
+      createTime: new Date('2026-06-28T12:00:00.000Z'),
+      updateTime: new Date('2026-06-28T12:01:00.000Z'),
+      isDeleted: 0,
+    });
+
+    const detail = await service.getTemplateMakeTaskDetail(BigInt(1), '88');
+
+    expect(templateAiClient.getResult).toHaveBeenCalledWith('step1-task-1');
+    expect(prisma.hairTemplateMakeTask.update).toHaveBeenCalledWith({
+      where: { id: BigInt(88) },
+      data: expect.objectContaining({
+        taskStatus: 'STEP1_SUCCESS',
+        step1ResultImg: 'https://cdn.example.com/step1-result.jpg',
+      }),
+    });
+    expect(detail).toEqual(
+      expect.objectContaining({
+        id: '88',
+        templateId: '101',
+        taskStatus: 'STEP1_SUCCESS',
+        step1: expect.objectContaining({
+          taskId: 'step1-task-1',
+          status: 'STEP1_SUCCESS',
+          resultImage: 'https://cdn.example.com/step1-result.jpg',
+        }),
+      }),
+    );
+  });
+
+  it('promotes a task from PROCESSING to STEP2_SUCCESS when step2 finishes', async () => {
+    prisma.hairTemplateMakeTask.findFirst.mockResolvedValue({
+      id: BigInt(89),
+      totalTaskId: 'tmpl_456',
+      taskStatus: 'PROCESSING',
+      templateId: BigInt(102),
+      step1TaskId: 'step1-task-2',
+      step2TaskId: 'step2-task-2',
+      refHairUrl: 'https://cdn.example.com/ref.jpg',
+      modelUrl: 'https://cdn.example.com/model.jpg',
+      clothesUrl: 'https://cdn.example.com/clothes.jpg',
+      backgroundUrl: 'https://cdn.example.com/background.jpg',
+      step1ResultImg: 'https://cdn.example.com/step1-result.jpg',
+      step2ResultImg: '',
+      step1Resource: JSON.stringify({
+        templateUrl: 'https://cdn.example.com/template-step1.json',
+        localFile: '/tmp/template-make/tmpl_456/hairstyle.jpg',
+      }),
+      step2Resource: JSON.stringify({
+        uploadedZipUrl: 'https://cdn.example.com/template.zip',
+        workDir: '/tmp/template-make/tmpl_456',
+      }),
+      createTime: new Date('2026-06-28T12:00:00.000Z'),
+      updateTime: new Date(Date.now() - 20_000),
+      isDeleted: 0,
+    });
+    prisma.hairTemplate.findFirst.mockResolvedValue({
+      id: BigInt(102),
+      templateName: '柔和层次长发',
+      coverUrl: '',
+      templateUrl: '',
+      status: 'DRAFT',
+      hairstylistId: BigInt(1),
+      isDeleted: 0,
+    });
+    templateAiClient.getResult.mockResolvedValue({
+      status: 'successes',
+      message: '',
+      images: ['https://cdn.example.com/final-cover.jpg'],
+    });
+    prisma.hairTemplate.update.mockResolvedValue({
+      id: BigInt(102),
+      templateName: '柔和层次长发',
+      coverUrl: 'https://cdn.example.com/final-cover.jpg',
+      templateUrl: 'https://cdn.example.com/template.zip',
+      status: 'DRAFT',
+      hairstylistId: BigInt(1),
+      isDeleted: 0,
+    });
+    prisma.hairTemplateMakeTask.update.mockResolvedValue({
+      id: BigInt(89),
+      totalTaskId: 'tmpl_456',
+      taskStatus: 'STEP2_SUCCESS',
+      templateId: BigInt(102),
+      step1TaskId: 'step1-task-2',
+      step2TaskId: 'step2-task-2',
+      refHairUrl: 'https://cdn.example.com/ref.jpg',
+      modelUrl: 'https://cdn.example.com/model.jpg',
+      clothesUrl: 'https://cdn.example.com/clothes.jpg',
+      backgroundUrl: 'https://cdn.example.com/background.jpg',
+      step1ResultImg: 'https://cdn.example.com/step1-result.jpg',
+      step2ResultImg: 'https://cdn.example.com/final-cover.jpg',
+      step1Resource: JSON.stringify({}),
+      step2Resource: JSON.stringify({
+        uploadedZipUrl: 'https://cdn.example.com/template.zip',
+        resultImage: 'https://cdn.example.com/final-cover.jpg',
+        workDir: '/tmp/template-make/tmpl_456',
+      }),
+      createTime: new Date('2026-06-28T12:00:00.000Z'),
+      updateTime: new Date('2026-06-28T12:05:00.000Z'),
+      isDeleted: 0,
+    });
+
+    const detail = await service.getTemplateMakeTaskDetail(BigInt(1), '89');
+
+    expect(templateAiClient.getResult).toHaveBeenCalledWith('step2-task-2');
+    expect(prisma.hairTemplate.update).toHaveBeenCalledWith({
+      where: { id: BigInt(102) },
+      data: {
+        coverUrl: 'https://cdn.example.com/final-cover.jpg',
+        templateUrl: 'https://cdn.example.com/template.zip',
+      },
+    });
+    expect(prisma.hairTemplateMakeTask.update).toHaveBeenCalledWith({
+      where: { id: BigInt(89) },
+      data: expect.objectContaining({
+        taskStatus: 'STEP2_SUCCESS',
+        step2ResultImg: 'https://cdn.example.com/final-cover.jpg',
+      }),
+    });
+    expect(templateTaskFiles.cleanup).toHaveBeenCalledWith(
+      '/tmp/template-make/tmpl_456',
+    );
+    expect(detail).toEqual(
+      expect.objectContaining({
+        id: '89',
+        templateId: '102',
+        taskStatus: 'STEP2_SUCCESS',
+        template: expect.objectContaining({
+          coverUrl: 'https://cdn.example.com/final-cover.jpg',
+          templateUrl: 'https://cdn.example.com/template.zip',
+        }),
+      }),
+    );
+  });
+
+  it('starts step2 with modelUrl as inputs.images when step1 has succeeded', async () => {
+    prisma.hairTemplateMakeTask.findFirst.mockResolvedValue({
+      id: BigInt(90),
+      totalTaskId: 'tmpl_789',
+      taskStatus: 'STEP1_SUCCESS',
+      templateId: BigInt(103),
+      step1TaskId: 'step1-task-3',
+      step2TaskId: '',
+      refHairUrl: 'https://cdn.example.com/ref.jpg',
+      modelUrl: 'https://cdn.example.com/model.jpg',
+      clothesUrl: 'https://cdn.example.com/clothes.jpg',
+      backgroundUrl: 'https://cdn.example.com/background.jpg',
+      step1ResultImg: 'https://cdn.example.com/step1-result.jpg',
+      step2ResultImg: '',
+      step1Resource: JSON.stringify({
+        templateUrl: 'https://cdn.example.com/template-step1.json',
+        localFile: '/tmp/template-make/tmpl_789/hairstyle.jpg',
+      }),
+      step2Resource: JSON.stringify({}),
+      createTime: new Date('2026-06-28T12:00:00.000Z'),
+      updateTime: new Date(Date.now() - 20_000),
+      isDeleted: 0,
+    });
+    prisma.hairTemplate.findFirst.mockResolvedValue({
+      id: BigInt(103),
+      templateName: '空气感中长发',
+      coverUrl: '',
+      templateUrl: '',
+      status: 'DRAFT',
+      hairstylistId: BigInt(1),
+      isDeleted: 0,
+    });
+    templateTaskFiles.prepareStep2Bundle.mockResolvedValue({
+      uploadedZipUrl: 'https://cdn.example.com/template-bundle.zip',
+      workDir: '/tmp/template-make/tmpl_789',
+      localFiles: {
+        step1Image: '/tmp/template-make/tmpl_789/hairstyle.jpg',
+        clothes: '/tmp/template-make/tmpl_789/input-outfit.jpg',
+        background: '/tmp/template-make/tmpl_789/input-background.jpg',
+        zip: '/tmp/template-make/tmpl_789/template.zip',
+      },
+    });
+    templateAiClient.executeTemplate.mockResolvedValue({
+      taskId: 'step2-task-3',
+    });
+    prisma.hairTemplateMakeTask.update.mockResolvedValue({
+      id: BigInt(90),
+      totalTaskId: 'tmpl_789',
+      taskStatus: 'PROCESSING',
+      templateId: BigInt(103),
+      step1TaskId: 'step1-task-3',
+      step2TaskId: 'step2-task-3',
+      refHairUrl: 'https://cdn.example.com/ref.jpg',
+      modelUrl: 'https://cdn.example.com/model.jpg',
+      clothesUrl: 'https://cdn.example.com/clothes.jpg',
+      backgroundUrl: 'https://cdn.example.com/background.jpg',
+      step1ResultImg: 'https://cdn.example.com/step1-result.jpg',
+      step2ResultImg: '',
+      step1Resource: JSON.stringify({
+        templateUrl: 'https://cdn.example.com/template-step1.json',
+        localFile: '/tmp/template-make/tmpl_789/hairstyle.jpg',
+      }),
+      step2Resource: JSON.stringify({
+        uploadedZipUrl: 'https://cdn.example.com/template-bundle.zip',
+        workDir: '/tmp/template-make/tmpl_789',
+        localFiles: {
+          step1Image: '/tmp/template-make/tmpl_789/hairstyle.jpg',
+          clothes: '/tmp/template-make/tmpl_789/input-outfit.jpg',
+          background: '/tmp/template-make/tmpl_789/input-background.jpg',
+          zip: '/tmp/template-make/tmpl_789/template.zip',
+        },
+        lastError: null,
+      }),
+      createTime: new Date('2026-06-28T12:00:00.000Z'),
+      updateTime: new Date('2026-06-28T12:01:00.000Z'),
+      isDeleted: 0,
+    });
+
+    await service.getTemplateMakeTaskDetail(BigInt(1), '90');
+
+    expect(templateTaskFiles.prepareStep2Bundle).toHaveBeenCalledWith({
+      totalTaskId: 'tmpl_789',
+      step1ResultImg: 'https://cdn.example.com/step1-result.jpg',
+      clothesUrl: 'https://cdn.example.com/clothes.jpg',
+      backgroundUrl: 'https://cdn.example.com/background.jpg',
+    });
+    expect(templateAiClient.executeTemplate).toHaveBeenCalledWith(
+      'https://cdn.example.com/template-bundle.zip',
+      {
+        images: ['https://cdn.example.com/model.jpg'],
+      },
+    );
+  });
+
+  it('returns template basic info for the owning stylist', async () => {
+    prisma.hairTemplate.findFirst.mockResolvedValue({
+      id: BigInt(43),
+      templateName: '柔和层次长发',
+      coverUrl: 'https://cdn.example.com/cover.jpg',
+      templateUrl: 'https://cdn.example.com/template.zip',
+      status: 'DRAFT',
+      category: 'LONG',
+      tags: '层次,首次建议',
+      description: '适合轻盈感修饰',
+      hairstylistId: BigInt(1),
+      isDeleted: 0,
+    });
+
+    await expect(service.getBasicInfo(BigInt(1), '43')).resolves.toEqual({
+      id: '43',
+      templateName: '柔和层次长发',
+      status: 'DRAFT',
+      category: 'LONG',
+      tags: ['层次', '首次建议'],
+      description: '适合轻盈感修饰',
+      coverUrl: 'https://cdn.example.com/cover.jpg',
+      templateUrl: 'https://cdn.example.com/template.zip',
+    });
+  });
+
+  it('updates only editable template basic info fields', async () => {
+    prisma.hairTemplate.findFirst.mockResolvedValue({
+      id: BigInt(43),
+      templateName: '旧模板名',
+      coverUrl: 'https://cdn.example.com/cover.jpg',
+      templateUrl: 'https://cdn.example.com/template.zip',
+      status: 'DRAFT',
+      category: 'LONG',
+      tags: '旧标签',
+      description: '旧描述',
+      hairstylistId: BigInt(1),
+      isDeleted: 0,
+    });
+    prisma.hairTemplate.update.mockResolvedValue({
+      id: BigInt(43),
+      templateName: '新模板名',
+      coverUrl: 'https://cdn.example.com/cover.jpg',
+      templateUrl: 'https://cdn.example.com/template.zip',
+      status: 'PUBLIC',
+      category: 'MEDIUM',
+      tags: '空气感,层次',
+      description: '新描述',
+      hairstylistId: BigInt(1),
+      isDeleted: 0,
+    });
+
+    await expect(
+      service.updateBasicInfo(BigInt(1), '43', {
+        templateName: '新模板名',
+        status: 'PUBLIC',
+        category: 'MEDIUM',
+        tags: ['空气感', '层次'],
+        description: '新描述',
+      }),
+    ).resolves.toEqual({
+      id: '43',
+      templateName: '新模板名',
+      status: 'PUBLIC',
+      category: 'MEDIUM',
+      tags: ['空气感', '层次'],
+      description: '新描述',
+      coverUrl: 'https://cdn.example.com/cover.jpg',
+      templateUrl: 'https://cdn.example.com/template.zip',
+    });
+
+    expect(prisma.hairTemplate.update).toHaveBeenCalledWith({
+      where: { id: BigInt(43) },
+      data: {
+        templateName: '新模板名',
+        status: 'PUBLIC',
+        category: 'MEDIUM',
+        tags: '空气感,层次',
+        description: '新描述',
+      },
+    });
+  });
+
+  it('soft deletes a single owned template and its tasks', async () => {
+    prisma.hairTemplate.findFirst.mockResolvedValue({
+      id: BigInt(43),
+      templateName: '旧模板名',
+      coverUrl: 'https://cdn.example.com/cover.jpg',
+      templateUrl: 'https://cdn.example.com/template.zip',
+      status: 'DRAFT',
+      category: 'LONG',
+      tags: '旧标签',
+      description: '旧描述',
+      hairstylistId: BigInt(1),
+      isDeleted: 0,
+    });
+    prisma.hairTemplate.updateMany.mockResolvedValue({ count: 1 });
+    prisma.hairTemplateMakeTask.updateMany.mockResolvedValue({ count: 1 });
+
+    await expect(service.deleteTemplate(BigInt(1), '43')).resolves.toEqual({
+      success: true,
+    });
+
+    expect(prisma.hairTemplate.updateMany).toHaveBeenCalledWith({
+      where: {
+        id: BigInt(43),
+        hairstylistId: BigInt(1),
+        isDeleted: 0,
+      },
+      data: {
+        isDeleted: 1,
+      },
+    });
+    expect(prisma.hairTemplateMakeTask.updateMany).toHaveBeenCalledWith({
+      where: {
+        templateId: BigInt(43),
+        isDeleted: 0,
+      },
+      data: {
+        isDeleted: 1,
+      },
+    });
+  });
+
+  it('soft deletes only owned templates in bulk', async () => {
+    prisma.hairTemplate.findMany.mockResolvedValue([
+      {
+        id: BigInt(43),
+        hairstylistId: BigInt(1),
+        isDeleted: 0,
+      },
+      {
+        id: BigInt(44),
+        hairstylistId: BigInt(1),
+        isDeleted: 0,
+      },
+    ]);
+    prisma.hairTemplate.updateMany.mockResolvedValue({ count: 2 });
+    prisma.hairTemplateMakeTask.updateMany.mockResolvedValue({ count: 2 });
+
+    await expect(
+      service.deleteTemplates(BigInt(1), ['43', '44', '99']),
+    ).resolves.toEqual({
+      deletedCount: 2,
+    });
+
+    expect(prisma.hairTemplate.findMany).toHaveBeenCalledWith({
+      where: {
+        id: {
+          in: [BigInt(43), BigInt(44), BigInt(99)],
+        },
+        hairstylistId: BigInt(1),
+        isDeleted: 0,
+      },
+      select: {
+        id: true,
+      },
+    });
+    expect(prisma.hairTemplate.updateMany).toHaveBeenCalledWith({
+      where: {
+        id: {
+          in: [BigInt(43), BigInt(44)],
+        },
+        hairstylistId: BigInt(1),
+        isDeleted: 0,
+      },
+      data: {
+        isDeleted: 1,
+      },
+    });
+    expect(prisma.hairTemplateMakeTask.updateMany).toHaveBeenCalledWith({
+      where: {
+        templateId: {
+          in: [BigInt(43), BigInt(44)],
+        },
+        isDeleted: 0,
+      },
+      data: {
+        isDeleted: 1,
+      },
+    });
+  });
+
+  it('lists templates with q/category/status filters and pagination', async () => {
+    prisma.hairTemplate.findMany.mockResolvedValue([
+      {
+        id: BigInt(102),
+        templateName: '空气感中长发',
+        category: 'MEDIUM',
+        status: 'PUBLIC',
+        description: '适合脸周轻盈修饰',
+        tags: '空气感,脸周',
+        coverUrl: 'https://cdn.example.com/cover-102.jpg',
+        usedCount: 7,
+        templateUrl: 'https://cdn.example.com/template-102.zip',
+        hairstylistId: BigInt(1),
+        isDeleted: 0,
+      },
+    ]);
+    prisma.hairTemplate.count.mockResolvedValue(1);
+    prisma.hairTemplateMakeTask.findMany.mockResolvedValue([
+      {
+        id: BigInt(88),
+        templateId: BigInt(102),
+        taskStatus: 'STEP2_SUCCESS',
+        isDeleted: 0,
+      },
+    ]);
+
+    const result = await service.list({
+      stylistId: BigInt(1),
+      q: '空气感',
+      category: 'MEDIUM',
+      status: 'PUBLIC',
+      page: 2,
+      pageSize: 8,
+    });
+
+    expect(prisma.hairTemplate.count).toHaveBeenCalledWith({
+      where: {
+        hairstylistId: BigInt(1),
+        isDeleted: 0,
+        category: 'MEDIUM',
+        status: 'PUBLIC',
+        OR: [
+          { templateName: { contains: '空气感' } },
+          { tags: { contains: '空气感' } },
+          { description: { contains: '空气感' } },
+        ],
+      },
+    });
+
+    expect(prisma.hairTemplate.findMany).toHaveBeenCalledWith({
+      where: {
+        hairstylistId: BigInt(1),
+        isDeleted: 0,
+        category: 'MEDIUM',
+        status: 'PUBLIC',
+        OR: [
+          { templateName: { contains: '空气感' } },
+          { tags: { contains: '空气感' } },
+          { description: { contains: '空气感' } },
+        ],
+      },
+      orderBy: [{ id: 'desc' }],
+      skip: 8,
+      take: 8,
+      select: {
+        id: true,
+        templateName: true,
+        category: true,
+        status: true,
+        description: true,
+        tags: true,
+        coverUrl: true,
+        usedCount: true,
+      },
+    });
+
+    expect(result).toEqual({
+      items: [
+        {
+          id: '102',
+          name: '空气感中长发',
+          category: 'MEDIUM',
+          status: 'PUBLIC',
+          description: '适合脸周轻盈修饰',
+          tags: ['空气感', '脸周'],
+          image: 'https://cdn.example.com/cover-102.jpg',
+          makeTaskId: '88',
+          makeTaskStatus: 'STEP2_SUCCESS',
+          usedCount: 7,
+        },
+      ],
+      total: 1,
+      page: 2,
+      pageSize: 8,
+    });
+  });
+
+  it('returns template summary for the current stylist', async () => {
+    prisma.hairTemplate.count
+      .mockResolvedValueOnce(6)
+      .mockResolvedValueOnce(4)
+      .mockResolvedValueOnce(2);
+    prisma.aiHairSimulationTask.count.mockResolvedValue(9);
+    prisma.hairTemplate.findMany.mockResolvedValue([
+      { id: BigInt(101) },
+      { id: BigInt(102) },
+    ]);
+
+    const summaryService = service as TemplatesService & {
+      getSummary: (stylistId: bigint) => Promise<{
+        total: number;
+        published: number;
+        weeklyUsed: number;
+        draft: number;
+      }>;
+    };
+
+    await expect(summaryService.getSummary(BigInt(1))).resolves.toEqual({
+      total: 6,
+      published: 4,
+      weeklyUsed: 9,
+      draft: 2,
+    });
+
+    expect(prisma.hairTemplate.findMany).toHaveBeenCalledWith({
+      where: {
+        hairstylistId: BigInt(1),
+        isDeleted: 0,
+      },
+      select: {
+        id: true,
+      },
+    });
+
+    expect(prisma.aiHairSimulationTask.count).toHaveBeenCalledWith({
+      where: expect.objectContaining({
+        taskStatus: 'SUCCESSES',
+        isDeleted: 0,
+        templateId: { in: ['101', '102'] },
+      }),
+    });
+  });
+});

+ 555 - 5
backend/src/modules/templates/templates.service.ts

@@ -1,16 +1,566 @@
-import { Injectable, NotImplementedException } from '@nestjs/common';
+import { Injectable, NotFoundException, NotImplementedException } from '@nestjs/common';
+import { randomUUID } from 'crypto';
+import { PrismaService } from '../../prisma/prisma.service';
 import { CreateTemplateDto } from './dto/create-template.dto';
+import { CreateTemplateMakeTaskDto } from './dto/create-template-make-task.dto';
+import { TemplateAssetsDto } from './dto/get-template-assets.dto';
 import { ListTemplatesQueryDto } from './dto/list-templates-query.dto';
+import { TemplateBasicInfoDto } from './dto/template-basic-info.dto';
+import { UpdateTemplateBasicInfoDto } from './dto/update-template-basic-info.dto';
+import { TemplateAiClient } from './template-ai.client';
+import { TemplateTaskRunner } from './template-task-runner';
+
+type TemplateListQuery = ListTemplatesQueryDto & {
+  stylistId: bigint;
+};
+
+const TEMPLATE_CATEGORIES = ['LONG', 'MEDIUM', 'SHORT', 'DYE'] as const;
+const TEMPLATE_STATUSES = ['DRAFT', 'PUBLIC'] as const;
 
 @Injectable()
 export class TemplatesService {
-  async list(_query: ListTemplatesQueryDto): Promise<{
-    items: never[];
+  private get systemConfigDelegate(): {
+    findMany: (args: Record<string, unknown>) => Promise<
+      Array<{
+        id: bigint;
+        configKey: string;
+        configName: string;
+        configGroup: string;
+        configValue: string | null;
+      }>
+    >;
+  } {
+    return (
+      this.prisma as PrismaService & {
+        systemConfig: {
+          findMany: (args: Record<string, unknown>) => Promise<
+            Array<{
+              id: bigint;
+              configKey: string;
+              configName: string;
+              configGroup: string;
+              configValue: string | null;
+            }>
+          >;
+        };
+      }
+    ).systemConfig;
+  }
+
+  constructor(
+    private readonly prisma: PrismaService,
+    private readonly templateAiClient: TemplateAiClient,
+    private readonly templateTaskRunner: TemplateTaskRunner,
+  ) {}
+
+  private async getOwnedTemplate(stylistId: bigint, templateId: string) {
+    const template = await this.prisma.hairTemplate.findFirst({
+      where: {
+        id: BigInt(templateId),
+        hairstylistId: stylistId,
+        isDeleted: 0,
+      },
+    });
+
+    if (!template) {
+      throw new NotFoundException('模板不存在');
+    }
+
+    return template;
+  }
+
+  private toBasicInfoDto(template: {
+    id: bigint;
+    templateName: string;
+    status: 'DRAFT' | 'PUBLIC';
+    category: 'LONG' | 'MEDIUM' | 'SHORT' | 'DYE';
+    tags: string;
+    description: string | null;
+    coverUrl: string;
+    templateUrl: string;
+  }): TemplateBasicInfoDto {
+    return {
+      id: template.id.toString(),
+      templateName: template.templateName,
+      status: template.status,
+      category: template.category,
+      tags: template.tags
+        ? template.tags
+            .split(',')
+            .map((tag) => tag.trim())
+            .filter(Boolean)
+        : [],
+      description: template.description ?? '',
+      coverUrl: template.coverUrl,
+      templateUrl: template.templateUrl,
+    };
+  }
+
+  private toTemplateListItem(
+    template: {
+      id: bigint;
+      templateName: string;
+      category: 'LONG' | 'MEDIUM' | 'SHORT' | 'DYE';
+      status: 'DRAFT' | 'PUBLIC';
+      description: string | null;
+      tags: string;
+      coverUrl: string;
+      usedCount: number;
+    },
+    taskMap: Map<string, { id: bigint; taskStatus: string }>,
+  ) {
+    const task = taskMap.get(template.id.toString());
+
+    return {
+      id: template.id.toString(),
+      name: template.templateName,
+      category: template.category,
+      status: template.status,
+      description: template.description ?? '',
+      tags: template.tags
+        ? template.tags
+            .split(',')
+            .map((tag) => tag.trim())
+            .filter(Boolean)
+        : [],
+      image: template.coverUrl,
+      makeTaskId: task?.id.toString(),
+      makeTaskStatus: task?.taskStatus,
+      usedCount: template.usedCount ?? 0,
+    };
+  }
+
+  async getTemplateAssets(): Promise<TemplateAssetsDto> {
+    type AssetConfigRow = {
+      id: bigint;
+      configKey: string;
+      configName: string;
+      configGroup: string;
+      configValue: string | null;
+    };
+
+    const rows = await this.systemConfigDelegate.findMany({
+      where: {
+        isDeleted: 0,
+        configGroup: {
+          in: ['ai-model', 'clothing', 'background'],
+        },
+      },
+      orderBy: [{ sort: 'asc' }, { id: 'asc' }],
+    });
+
+    const mapRow = (row: AssetConfigRow) => ({
+      id: row.id.toString(),
+      name: row.configName,
+      url: row.configValue ?? '',
+      configKey: row.configKey,
+    });
+
+    return {
+      models: rows.filter((row: AssetConfigRow) => row.configGroup === 'ai-model').map(mapRow),
+      clothes: rows.filter((row: AssetConfigRow) => row.configGroup === 'clothing').map(mapRow),
+      backgrounds: rows
+        .filter((row: AssetConfigRow) => row.configGroup === 'background')
+        .map(mapRow),
+    };
+  }
+
+  private async getRequiredStep1TemplateUrl(): Promise<string> {
+    const rows = await this.systemConfigDelegate.findMany({
+      where: {
+        isDeleted: 0,
+        configGroup: 'template-step1',
+      },
+      orderBy: [{ sort: 'asc' }, { id: 'asc' }],
+      take: 1,
+    });
+
+    const templateUrl = rows[0]?.configValue?.trim() ?? '';
+    if (!templateUrl) {
+      throw new NotFoundException('缺少 template-step1 配置');
+    }
+
+    return templateUrl;
+  }
+
+  async createTemplateMakeTask(
+    stylistId: bigint,
+    dto: CreateTemplateMakeTaskDto,
+  ): Promise<{
+    templateId: string;
+    taskId: string;
+    totalTaskId: string;
+    taskStatus: 'PROCESSING';
+  }> {
+    const step1TemplateUrl = await this.getRequiredStep1TemplateUrl();
+    const totalTaskId = `tmpl_${Date.now()}_${randomUUID()}`;
+
+    const created = await this.prisma.$transaction(async (tx) => {
+      const template = await tx.hairTemplate.create({
+        data: {
+          templateName: dto.templateName,
+          coverUrl: '',
+          status: dto.status,
+          category: dto.category,
+          tags: (dto.tags ?? []).join(','),
+          description: dto.description ?? null,
+          modelUrl: dto.modelUrl,
+          clothesUrl: dto.clothesUrl,
+          backgroundUrl: dto.backgroundUrl,
+          refHairUrl: dto.refHairUrl,
+          hairstylistId: stylistId,
+          templateUrl: '',
+          isSystemTemplate: 'NO',
+        },
+      });
+
+      const task = await tx.hairTemplateMakeTask.create({
+        data: {
+          totalTaskId,
+          step1TaskId: '',
+          step2TaskId: '',
+          taskStatus: 'WAIT',
+          templateId: template.id,
+          step1Resource: JSON.stringify({
+            templateUrl: step1TemplateUrl,
+            lastError: null,
+          }),
+          step2Resource: JSON.stringify({
+            uploadedZipUrl: null,
+            lastError: null,
+          }),
+          refHairUrl: dto.refHairUrl,
+          modelUrl: dto.modelUrl,
+          clothesUrl: dto.clothesUrl,
+          backgroundUrl: dto.backgroundUrl,
+        },
+      });
+
+      return { template, task };
+    });
+
+    const step1 = await this.templateAiClient.executeTemplate(step1TemplateUrl, {
+      images: [dto.refHairUrl],
+    });
+
+    await this.prisma.hairTemplateMakeTask.update({
+      where: { id: created.task.id },
+      data: {
+        step1TaskId: step1.taskId,
+        taskStatus: 'PROCESSING',
+        step1Resource: JSON.stringify({
+          templateUrl: step1TemplateUrl,
+          lastError: null,
+        }),
+      },
+    });
+
+    return {
+      templateId: created.template.id.toString(),
+      taskId: created.task.id.toString(),
+      totalTaskId,
+      taskStatus: 'PROCESSING',
+    };
+  }
+
+  async getTemplateMakeTaskDetail(
+    hairstylistId: bigint,
+    taskId: string,
+  ) {
+    return this.templateTaskRunner.refreshTask(hairstylistId, taskId);
+  }
+
+  async getBasicInfo(
+    stylistId: bigint,
+    templateId: string,
+  ): Promise<TemplateBasicInfoDto> {
+    const template = await this.getOwnedTemplate(stylistId, templateId);
+    return this.toBasicInfoDto(template);
+  }
+
+  async updateBasicInfo(
+    stylistId: bigint,
+    templateId: string,
+    dto: UpdateTemplateBasicInfoDto,
+  ): Promise<TemplateBasicInfoDto> {
+    const template = await this.getOwnedTemplate(stylistId, templateId);
+    const updated = await this.prisma.hairTemplate.update({
+      where: { id: template.id },
+      data: {
+        templateName: dto.templateName,
+        status: dto.status,
+        category: dto.category,
+        tags: (dto.tags ?? []).join(','),
+        description: dto.description ?? '',
+      },
+    });
+
+    return this.toBasicInfoDto(updated);
+  }
+
+  async deleteTemplate(
+    stylistId: bigint,
+    templateId: string,
+  ): Promise<{ success: true }> {
+    const template = await this.getOwnedTemplate(stylistId, templateId);
+
+    await this.prisma.hairTemplate.updateMany({
+      where: {
+        id: template.id,
+        hairstylistId: stylistId,
+        isDeleted: 0,
+      },
+      data: {
+        isDeleted: 1,
+      },
+    });
+
+    await this.prisma.hairTemplateMakeTask.updateMany({
+      where: {
+        templateId: template.id,
+        isDeleted: 0,
+      },
+      data: {
+        isDeleted: 1,
+      },
+    });
+
+    return { success: true };
+  }
+
+  async deleteTemplates(
+    stylistId: bigint,
+    templateIds: string[],
+  ): Promise<{ deletedCount: number }> {
+    const parsedIds = Array.from(
+      new Set(
+        templateIds
+          .map((id) => id.trim())
+          .filter(Boolean)
+          .map((id) => BigInt(id)),
+      ),
+    );
+
+    if (parsedIds.length === 0) {
+      return { deletedCount: 0 };
+    }
+
+    const ownedTemplates = await this.prisma.hairTemplate.findMany({
+      where: {
+        id: {
+          in: parsedIds,
+        },
+        hairstylistId: stylistId,
+        isDeleted: 0,
+      },
+      select: {
+        id: true,
+      },
+    });
+
+    const ownedIds = ownedTemplates.map((item) => item.id);
+
+    if (ownedIds.length === 0) {
+      return { deletedCount: 0 };
+    }
+
+    const { count } = await this.prisma.hairTemplate.updateMany({
+      where: {
+        id: {
+          in: ownedIds,
+        },
+        hairstylistId: stylistId,
+        isDeleted: 0,
+      },
+      data: {
+        isDeleted: 1,
+      },
+    });
+
+    await this.prisma.hairTemplateMakeTask.updateMany({
+      where: {
+        templateId: {
+          in: ownedIds,
+        },
+        isDeleted: 0,
+      },
+      data: {
+        isDeleted: 1,
+      },
+    });
+
+    return {
+      deletedCount: count,
+    };
+  }
+
+  async getSummary(stylistId: bigint): Promise<{
     total: number;
+    published: number;
+    weeklyUsed: number;
+    draft: number;
   }> {
+    const now = new Date();
+    const utcDay = now.getUTCDay();
+    const diffToMonday = utcDay === 0 ? 6 : utcDay - 1;
+    const startOfWeek = new Date(
+      Date.UTC(
+        now.getUTCFullYear(),
+        now.getUTCMonth(),
+        now.getUTCDate() - diffToMonday,
+      ),
+    );
+    const endOfWeek = new Date(
+      Date.UTC(
+        now.getUTCFullYear(),
+        now.getUTCMonth(),
+        now.getUTCDate() - diffToMonday + 7,
+      ),
+    );
+
+    const [total, published, draft, templates] = await Promise.all([
+      this.prisma.hairTemplate.count({
+        where: {
+          hairstylistId: stylistId,
+          isDeleted: 0,
+        },
+      }),
+      this.prisma.hairTemplate.count({
+        where: {
+          hairstylistId: stylistId,
+          isDeleted: 0,
+          status: 'PUBLIC',
+        },
+      }),
+      this.prisma.hairTemplate.count({
+        where: {
+          hairstylistId: stylistId,
+          isDeleted: 0,
+          status: 'DRAFT',
+        },
+      }),
+      this.prisma.hairTemplate.findMany({
+        where: {
+          hairstylistId: stylistId,
+          isDeleted: 0,
+        },
+        select: {
+          id: true,
+        },
+      }),
+    ]);
+
+    const templateIds = templates.map((item) => item.id.toString());
+    const weeklyUsed = templateIds.length
+      ? await this.prisma.aiHairSimulationTask.count({
+          where: {
+            taskStatus: 'SUCCESSES',
+            isDeleted: 0,
+            templateId: {
+              in: templateIds,
+            },
+            taskFinishTime: {
+              gte: startOfWeek,
+              lt: endOfWeek,
+            },
+          },
+        })
+      : 0;
+
+    return {
+      total,
+      published,
+      weeklyUsed,
+      draft,
+    };
+  }
+
+  async list(query: TemplateListQuery): Promise<{
+    items: Array<{
+      id: string;
+      name: string;
+      category: 'LONG' | 'MEDIUM' | 'SHORT' | 'DYE';
+      status: 'DRAFT' | 'PUBLIC';
+      description: string;
+      tags: string[];
+      image: string;
+      makeTaskId?: string;
+      makeTaskStatus?: string;
+      usedCount: number;
+    }>;
+    total: number;
+    page: number;
+    pageSize: number;
+  }> {
+    const page = query.page && query.page > 0 ? query.page : 1;
+    const pageSize = query.pageSize && query.pageSize > 0 ? query.pageSize : 8;
+    const keyword = query.q?.trim();
+    const category = TEMPLATE_CATEGORIES.find((item) => item === query.category);
+    const status = TEMPLATE_STATUSES.find((item) => item === query.status);
+
+    const where = {
+      hairstylistId: query.stylistId,
+      isDeleted: 0,
+      ...(category ? { category } : {}),
+      ...(status ? { status } : {}),
+      ...(keyword
+        ? {
+            OR: [
+              { templateName: { contains: keyword } },
+              { tags: { contains: keyword } },
+              { description: { contains: keyword } },
+            ],
+          }
+        : {}),
+    };
+
+    const [total, templates] = await Promise.all([
+      this.prisma.hairTemplate.count({ where }),
+      this.prisma.hairTemplate.findMany({
+        where,
+        orderBy: [{ id: 'desc' }],
+        skip: (page - 1) * pageSize,
+        take: pageSize,
+        select: {
+          id: true,
+          templateName: true,
+          category: true,
+          status: true,
+          description: true,
+          tags: true,
+          coverUrl: true,
+          usedCount: true,
+        },
+      }),
+    ]);
+
+    const templateIds = templates.map((item) => item.id);
+    const tasks = templateIds.length
+      ? await this.prisma.hairTemplateMakeTask.findMany({
+          where: {
+            isDeleted: 0,
+            templateId: { in: templateIds },
+          },
+          orderBy: [{ id: 'desc' }],
+        })
+      : [];
+
+    const taskMap = new Map<string, { id: bigint; taskStatus: string }>();
+    for (const task of tasks) {
+      if (!task.templateId) {
+        continue;
+      }
+      const key = task.templateId.toString();
+      if (!taskMap.has(key)) {
+        taskMap.set(key, { id: task.id, taskStatus: task.taskStatus });
+      }
+    }
+
     return {
-      items: [],
-      total: 0,
+      items: templates.map((template) => this.toTemplateListItem(template, taskMap)),
+      total,
+      page,
+      pageSize,
     };
   }
 

+ 953 - 0
backend/test/templates.e2e-spec.ts

@@ -0,0 +1,953 @@
+import { INestApplication } from '@nestjs/common';
+import { JwtService } from '@nestjs/jwt';
+import { Test } from '@nestjs/testing';
+import * as request from 'supertest';
+import { configureApp } from '../src/app.setup';
+import { TemplateAiClient } from '../src/modules/templates/template-ai.client';
+import { TemplateTaskFiles } from '../src/modules/templates/template-task-files';
+import { PrismaService } from '../src/prisma/prisma.service';
+
+process.env.GCS_BUCKET ??= 'test-bucket';
+process.env.GCS_PROJECT_ID ??= 'test-project';
+process.env.GCS_CLIENT_EMAIL ??= 'test@example.com';
+process.env.GCS_PRIVATE_KEY ??= 'test-key';
+
+describe('Templates (e2e)', () => {
+  let app: INestApplication;
+  let jwtService: JwtService;
+  const executeTemplate = jest.fn();
+  const getResult = jest.fn();
+  const templateTaskFiles = {
+    downloadToTempFile: jest.fn(),
+    prepareStep2Bundle: jest.fn(),
+    cleanup: jest.fn(),
+  };
+  const systemConfigs: Array<Record<string, unknown>> = [];
+  const hairTemplates: Array<Record<string, unknown>> = [];
+  const hairTemplateMakeTasks: Array<Record<string, unknown>> = [];
+  const aiHairSimulationTasks: Array<Record<string, unknown>> = [];
+  let nextSystemConfigId = 1n;
+  let nextTemplateId = 101n;
+  let nextTaskId = 88n;
+
+  function filterHairTemplates(params: {
+    where?: Record<string, unknown>;
+    orderBy?: Record<string, unknown>;
+    skip?: number;
+    take?: number;
+  }) {
+    let items = [...hairTemplates];
+    const typedWhere = (params.where ?? {}) as {
+      hairstylistId?: bigint;
+      isDeleted?: number;
+      category?: string;
+      status?: string;
+      OR?: Array<{
+        templateName?: { contains: string };
+        tags?: { contains: string };
+        description?: { contains: string };
+      }>;
+    };
+
+    if (typeof typedWhere.isDeleted === 'number') {
+      items = items.filter((item) => item.isDeleted === typedWhere.isDeleted);
+    }
+
+    if (typeof typedWhere.hairstylistId === 'bigint') {
+      items = items.filter((item) => item.hairstylistId === typedWhere.hairstylistId);
+    }
+
+    if (typedWhere.category) {
+      items = items.filter((item) => item.category === typedWhere.category);
+    }
+
+    if (typedWhere.status) {
+      items = items.filter((item) => item.status === typedWhere.status);
+    }
+
+    if (Array.isArray(typedWhere.OR) && typedWhere.OR.length > 0) {
+      const keyword =
+        typedWhere.OR[0]?.templateName?.contains ??
+        typedWhere.OR[1]?.tags?.contains ??
+        typedWhere.OR[2]?.description?.contains ??
+        '';
+
+      items = items.filter((item) => {
+        return (
+          String(item.templateName ?? '').includes(keyword) ||
+          String(item.tags ?? '').includes(keyword) ||
+          String(item.description ?? '').includes(keyword)
+        );
+      });
+    }
+
+    if (
+      Array.isArray(params.orderBy) &&
+      params.orderBy[0] &&
+      'id' in params.orderBy[0] &&
+      params.orderBy[0].id === 'desc'
+    ) {
+      items.sort((a, b) => Number(b.id) - Number(a.id));
+    }
+
+    return typeof params.take === 'number'
+      ? items.slice(Number(params.skip ?? 0), Number(params.skip ?? 0) + params.take)
+      : items;
+  }
+
+  const prisma = {
+    systemConfig: {
+      createMany: jest.fn(async ({ data }: { data: Array<Record<string, unknown>> }) => {
+        for (const item of data) {
+          systemConfigs.push({
+            id: nextSystemConfigId,
+            isDeleted: 0,
+            createTime: new Date(),
+            updateTime: new Date(),
+            remark: '',
+            ...item,
+          });
+          nextSystemConfigId += 1n;
+        }
+
+        return { count: data.length };
+      }),
+      create: jest.fn(async ({ data }: { data: Record<string, unknown> }) => {
+        const created = {
+          id: nextSystemConfigId,
+          isDeleted: 0,
+          createTime: new Date(),
+          updateTime: new Date(),
+          remark: '',
+          ...data,
+        };
+        systemConfigs.push(created);
+        nextSystemConfigId += 1n;
+        return created;
+      }),
+      findMany: jest.fn(async ({ where, orderBy, take }: Record<string, unknown>) => {
+        let items = [...systemConfigs];
+
+        if (where && typeof where === 'object') {
+          const typedWhere = where as {
+            isDeleted?: number;
+            configGroup?: string | { in: string[] };
+          };
+
+          if (typeof typedWhere.isDeleted === 'number') {
+            items = items.filter((item) => item.isDeleted === typedWhere.isDeleted);
+          }
+
+          if (typeof typedWhere.configGroup === 'string') {
+            items = items.filter((item) => item.configGroup === typedWhere.configGroup);
+          }
+
+          const configGroupFilter = typedWhere.configGroup;
+          if (
+            configGroupFilter &&
+            typeof configGroupFilter === 'object' &&
+            'in' in configGroupFilter &&
+            Array.isArray(configGroupFilter.in)
+          ) {
+            items = items.filter((item) =>
+              configGroupFilter.in.includes(String(item.configGroup)),
+            );
+          }
+        }
+
+        if (Array.isArray(orderBy)) {
+          items.sort((a, b) => {
+            const sortA = Number(a.sort ?? 0);
+            const sortB = Number(b.sort ?? 0);
+            if (sortA !== sortB) {
+              return sortA - sortB;
+            }
+
+            return Number(a.id) - Number(b.id);
+          });
+        }
+
+        if (typeof take === 'number') {
+          items = items.slice(0, take);
+        }
+
+        return items;
+      }),
+      deleteMany: jest.fn(async () => ({ count: 0 })),
+    },
+    hairTemplate: {
+      findFirst: jest.fn(async ({ where }: { where: Record<string, unknown> }) => {
+        return (
+          hairTemplates.find((item) => {
+            return (
+              item.id === where.id &&
+              item.isDeleted === where.isDeleted &&
+              item.hairstylistId === where.hairstylistId
+            );
+          }) ?? null
+        );
+      }),
+      findMany: jest.fn(async ({ where, orderBy, skip = 0, take }: Record<string, unknown>) => {
+        return filterHairTemplates({
+          where: where as Record<string, unknown> | undefined,
+          orderBy: orderBy as Record<string, unknown> | undefined,
+          skip: Number(skip),
+          take: typeof take === 'number' ? Number(take) : undefined,
+        });
+      }),
+      count: jest.fn(async ({ where }: { where: Record<string, unknown> }) => {
+        return filterHairTemplates({ where }).length;
+      }),
+      update: jest.fn(async ({ where, data }: { where: { id: bigint }; data: Record<string, unknown> }) => {
+        const target = hairTemplates.find((item) => item.id === where.id);
+        if (!target) {
+          throw new Error('template not found');
+        }
+        Object.assign(target, data);
+        return target;
+      }),
+      updateMany: jest.fn(async ({
+        where,
+        data,
+      }: {
+        where: {
+          id?: bigint | { in?: bigint[] };
+          hairstylistId?: bigint;
+          isDeleted?: number;
+        };
+        data: Record<string, unknown>;
+      }) => {
+        let items = [...hairTemplates];
+        if (typeof where.isDeleted === 'number') {
+          items = items.filter((item) => item.isDeleted === where.isDeleted);
+        }
+        if (typeof where.hairstylistId === 'bigint') {
+          items = items.filter((item) => item.hairstylistId === where.hairstylistId);
+        }
+        if (typeof where.id === 'bigint') {
+          items = items.filter((item) => item.id === where.id);
+        } else {
+          const idFilter = where.id;
+          if (
+            idFilter &&
+            typeof idFilter === 'object' &&
+            'in' in idFilter &&
+            Array.isArray(idFilter.in)
+          ) {
+            const ids = idFilter.in;
+            items = items.filter((item) => ids.includes(item.id as bigint));
+          }
+        }
+        items.forEach((item) => Object.assign(item, data));
+        return { count: items.length };
+      }),
+      deleteMany: jest.fn(async () => ({ count: 0 })),
+    },
+    hairTemplateMakeTask: {
+      findFirst: jest.fn(async ({ where }: { where: Record<string, unknown> }) => {
+        return (
+          hairTemplateMakeTasks.find((item) => {
+            return item.id === where.id && item.isDeleted === where.isDeleted;
+          }) ?? null
+        );
+      }),
+      findMany: jest.fn(async ({ where, orderBy }: Record<string, unknown>) => {
+        const typedWhere = (where ?? {}) as {
+          isDeleted?: number;
+          templateId?: { in?: bigint[] };
+        };
+        let items = [...hairTemplateMakeTasks];
+
+        if (typeof typedWhere.isDeleted === 'number') {
+          items = items.filter((item) => item.isDeleted === typedWhere.isDeleted);
+        }
+
+        if (typedWhere.templateId?.in) {
+          items = items.filter((item) => typedWhere.templateId?.in?.includes(item.templateId as bigint));
+        }
+
+        if (Array.isArray(orderBy) && orderBy[0] && 'id' in orderBy[0] && orderBy[0].id === 'desc') {
+          items.sort((a, b) => Number(b.id) - Number(a.id));
+        }
+
+        return items;
+      }),
+      update: jest.fn(async ({ where, data }: { where: { id: bigint }; data: Record<string, unknown> }) => {
+        const target = hairTemplateMakeTasks.find((item) => item.id === where.id);
+        if (!target) {
+          throw new Error('task not found');
+        }
+        Object.assign(target, data);
+        return target;
+      }),
+      updateMany: jest.fn(async ({
+        where,
+        data,
+      }: {
+        where: {
+          templateId?: bigint | { in?: bigint[] };
+          isDeleted?: number;
+        };
+        data: Record<string, unknown>;
+      }) => {
+        let items = [...hairTemplateMakeTasks];
+        if (typeof where.isDeleted === 'number') {
+          items = items.filter((item) => item.isDeleted === where.isDeleted);
+        }
+        if (typeof where.templateId === 'bigint') {
+          items = items.filter((item) => item.templateId === where.templateId);
+        } else {
+          const templateIdFilter = where.templateId;
+          if (
+            templateIdFilter &&
+            typeof templateIdFilter === 'object' &&
+            'in' in templateIdFilter &&
+            Array.isArray(templateIdFilter.in)
+          ) {
+            const templateIds = templateIdFilter.in;
+            items = items.filter((item) =>
+              templateIds.includes(item.templateId as bigint),
+            );
+          }
+        }
+        items.forEach((item) => Object.assign(item, data));
+        return { count: items.length };
+      }),
+      deleteMany: jest.fn(async () => ({ count: 0 })),
+    },
+    aiHairSimulationTask: {
+      count: jest.fn(async ({ where }: { where: Record<string, unknown> }) => {
+        const typedWhere = where as {
+          taskStatus?: string;
+          isDeleted?: number;
+          templateId?: { in?: string[] };
+          taskFinishTime?: { gte?: Date; lt?: Date };
+        };
+        let items = [...aiHairSimulationTasks];
+
+        if (typeof typedWhere.taskStatus === 'string') {
+          items = items.filter((item) => item.taskStatus === typedWhere.taskStatus);
+        }
+        if (typeof typedWhere.isDeleted === 'number') {
+          items = items.filter((item) => item.isDeleted === typedWhere.isDeleted);
+        }
+        if (typedWhere.templateId?.in) {
+          items = items.filter((item) =>
+            typedWhere.templateId?.in?.includes(String(item.templateId)),
+          );
+        }
+        if (typedWhere.taskFinishTime?.gte instanceof Date) {
+          items = items.filter(
+            (item) =>
+              item.taskFinishTime instanceof Date &&
+              item.taskFinishTime >= typedWhere.taskFinishTime!.gte!,
+          );
+        }
+        if (typedWhere.taskFinishTime?.lt instanceof Date) {
+          items = items.filter(
+            (item) =>
+              item.taskFinishTime instanceof Date &&
+              item.taskFinishTime < typedWhere.taskFinishTime!.lt!,
+          );
+        }
+
+        return items.length;
+      }),
+    },
+    $transaction: jest.fn(async (callback: (tx: {
+      hairTemplate: { create: (args: { data: Record<string, unknown> }) => Promise<Record<string, unknown>> };
+      hairTemplateMakeTask: { create: (args: { data: Record<string, unknown> }) => Promise<Record<string, unknown>> };
+    }) => Promise<unknown>) =>
+      callback({
+        hairTemplate: {
+          create: async ({ data }) => {
+            const created = {
+              id: nextTemplateId,
+              createTime: new Date(),
+              updateTime: new Date(),
+              isDeleted: 0,
+              ...data,
+            };
+            hairTemplates.push(created);
+            nextTemplateId += 1n;
+            return created;
+          },
+        },
+        hairTemplateMakeTask: {
+          create: async ({ data }) => {
+            const created = {
+              id: nextTaskId,
+              createTime: new Date(),
+              updateTime: new Date(),
+              isDeleted: 0,
+              ...data,
+            };
+            hairTemplateMakeTasks.push(created);
+            nextTaskId += 1n;
+            return created;
+          },
+        },
+      })),
+  };
+
+  async function signToken() {
+    return jwtService.signAsync({
+      sub: 1,
+      stylistId: '1',
+      email: 'stylist@example.com',
+      username: 'Ogawa',
+    });
+  }
+
+  beforeAll(async () => {
+    const { AppModule } = await import('../src/app.module');
+    const moduleRef = await Test.createTestingModule({
+      imports: [AppModule],
+    })
+      .overrideProvider(TemplateAiClient)
+      .useValue({ executeTemplate, getResult })
+      .overrideProvider(TemplateTaskFiles)
+      .useValue(templateTaskFiles)
+      .overrideProvider(PrismaService)
+      .useValue(prisma)
+      .compile();
+
+    app = moduleRef.createNestApplication();
+    configureApp(app);
+    await app.init();
+    jwtService = app.get(JwtService);
+  });
+
+  beforeEach(() => {
+    executeTemplate.mockReset();
+    getResult.mockReset();
+    templateTaskFiles.downloadToTempFile.mockReset();
+    templateTaskFiles.prepareStep2Bundle.mockReset();
+    templateTaskFiles.cleanup.mockReset();
+    systemConfigs.length = 0;
+    hairTemplates.length = 0;
+    hairTemplateMakeTasks.length = 0;
+    aiHairSimulationTasks.length = 0;
+    nextSystemConfigId = 1n;
+    nextTemplateId = 101n;
+    nextTaskId = 88n;
+  });
+
+  afterAll(async () => {
+    await app.close();
+  });
+
+  it('/api/v1/templates/assets (GET) returns grouped asset options', async () => {
+    const token = await signToken();
+    const uniqueSuffix = `${Date.now()}-assets`;
+
+    await prisma.systemConfig.createMany({
+      data: [
+        {
+          configKey: `e2e-template-model-${uniqueSuffix}`,
+          configName: '模特',
+          configGroup: 'ai-model',
+          configValue: 'https://cdn.example.com/model.jpg',
+          valueType: 'STRING',
+          sort: 1,
+        },
+        {
+          configKey: `e2e-template-clothes-${uniqueSuffix}`,
+          configName: '服装',
+          configGroup: 'clothing',
+          configValue: 'https://cdn.example.com/clothes.jpg',
+          valueType: 'STRING',
+          sort: 2,
+        },
+        {
+          configKey: `e2e-template-background-${uniqueSuffix}`,
+          configName: '背景',
+          configGroup: 'background',
+          configValue: 'https://cdn.example.com/background.jpg',
+          valueType: 'STRING',
+          sort: 3,
+        },
+      ] as never,
+    });
+
+    const response = await request(app.getHttpServer())
+      .get('/api/v1/templates/assets')
+      .set('Authorization', `Bearer ${token}`)
+      .expect(200);
+
+    expect(response.body.data.models).toEqual(
+      expect.arrayContaining([
+        expect.objectContaining({
+          name: '模特',
+          url: 'https://cdn.example.com/model.jpg',
+        }),
+      ]),
+    );
+    expect(response.body.data.clothes).toEqual(
+      expect.arrayContaining([
+        expect.objectContaining({
+          name: '服装',
+          url: 'https://cdn.example.com/clothes.jpg',
+        }),
+      ]),
+    );
+    expect(response.body.data.backgrounds).toEqual(
+      expect.arrayContaining([
+        expect.objectContaining({
+          name: '背景',
+          url: 'https://cdn.example.com/background.jpg',
+        }),
+      ]),
+    );
+  });
+
+  it('/api/v1/templates/make-tasks (POST) creates template and task rows', async () => {
+    const token = await signToken();
+    const uniqueSuffix = `${Date.now()}-create`;
+
+    await prisma.systemConfig.create({
+      data: {
+        configKey: `e2e-template-step1-${uniqueSuffix}`,
+        configName: 'step1',
+        configGroup: 'template-step1',
+        configValue: 'https://cdn.example.com/template-step1.json',
+        valueType: 'STRING',
+        sort: 1,
+      } as never,
+    });
+
+    executeTemplate.mockResolvedValueOnce({
+      taskId: `e2e-template-step1-task-${uniqueSuffix}`,
+    });
+
+    const response = await request(app.getHttpServer())
+      .post('/api/v1/templates/make-tasks')
+      .set('Authorization', `Bearer ${token}`)
+      .send({
+        templateName: `e2e-template-${uniqueSuffix}`,
+        category: 'LONG',
+        status: 'DRAFT',
+        description: 'e2e description',
+        tags: ['首次建议', '层次'],
+        refHairUrl: 'https://cdn.example.com/ref.jpg',
+        modelUrl: 'https://cdn.example.com/model.jpg',
+        clothesUrl: 'https://cdn.example.com/clothes.jpg',
+        backgroundUrl: 'https://cdn.example.com/background.jpg',
+      })
+      .expect(201);
+
+    expect(response.body.data).toEqual({
+      templateId: expect.any(String),
+      taskId: expect.any(String),
+      totalTaskId: expect.stringContaining('tmpl_'),
+      taskStatus: 'PROCESSING',
+    });
+    expect(executeTemplate).toHaveBeenCalledWith(
+      'https://cdn.example.com/template-step1.json',
+      {
+        images: ['https://cdn.example.com/ref.jpg'],
+      },
+    );
+  });
+
+  it('/api/v1/templates/:templateId (GET) returns template basic info', async () => {
+    const token = await signToken();
+
+    hairTemplates.push({
+      id: 43n,
+      templateName: '柔和层次长发',
+      coverUrl: 'https://cdn.example.com/cover.jpg',
+      templateUrl: 'https://cdn.example.com/template.zip',
+      status: 'DRAFT',
+      category: 'LONG',
+      tags: '层次,首次建议',
+      description: '适合轻盈感修饰',
+      hairstylistId: 1n,
+      isDeleted: 0,
+    });
+
+    const response = await request(app.getHttpServer())
+      .get('/api/v1/templates/43')
+      .set('Authorization', `Bearer ${token}`)
+      .expect(200);
+
+    expect(response.body.data).toEqual({
+      id: '43',
+      templateName: '柔和层次长发',
+      status: 'DRAFT',
+      category: 'LONG',
+      tags: ['层次', '首次建议'],
+      description: '适合轻盈感修饰',
+      coverUrl: 'https://cdn.example.com/cover.jpg',
+      templateUrl: 'https://cdn.example.com/template.zip',
+    });
+  });
+
+  it('/api/v1/templates/:templateId (PATCH) updates only template basic info', async () => {
+    const token = await signToken();
+
+    hairTemplates.push({
+      id: 43n,
+      templateName: '旧模板名',
+      coverUrl: 'https://cdn.example.com/cover.jpg',
+      templateUrl: 'https://cdn.example.com/template.zip',
+      status: 'DRAFT',
+      category: 'LONG',
+      tags: '旧标签',
+      description: '旧描述',
+      modelUrl: 'https://cdn.example.com/model.jpg',
+      clothesUrl: 'https://cdn.example.com/clothes.jpg',
+      backgroundUrl: 'https://cdn.example.com/background.jpg',
+      refHairUrl: 'https://cdn.example.com/ref.jpg',
+      hairstylistId: 1n,
+      isDeleted: 0,
+    });
+
+    const response = await request(app.getHttpServer())
+      .patch('/api/v1/templates/43')
+      .set('Authorization', `Bearer ${token}`)
+      .send({
+        templateName: '空气感中长发',
+        status: 'PUBLIC',
+        category: 'MEDIUM',
+        tags: ['空气感', '层次'],
+        description: '新描述',
+      })
+      .expect(200);
+
+    expect(response.body.data).toEqual({
+      id: '43',
+      templateName: '空气感中长发',
+      status: 'PUBLIC',
+      category: 'MEDIUM',
+      tags: ['空气感', '层次'],
+      description: '新描述',
+      coverUrl: 'https://cdn.example.com/cover.jpg',
+      templateUrl: 'https://cdn.example.com/template.zip',
+    });
+    expect(hairTemplates[0]).toEqual(
+      expect.objectContaining({
+        templateName: '空气感中长发',
+        status: 'PUBLIC',
+        category: 'MEDIUM',
+        tags: '空气感,层次',
+        description: '新描述',
+        modelUrl: 'https://cdn.example.com/model.jpg',
+        clothesUrl: 'https://cdn.example.com/clothes.jpg',
+        backgroundUrl: 'https://cdn.example.com/background.jpg',
+        refHairUrl: 'https://cdn.example.com/ref.jpg',
+      }),
+    );
+  });
+
+  it('/api/v1/templates (GET) filters and paginates stylist templates', async () => {
+    const token = await signToken();
+
+    hairTemplates.push(
+      {
+        id: 101n,
+        templateName: '空气感中长发',
+        category: 'MEDIUM',
+        status: 'PUBLIC',
+        description: '空气感提案',
+        tags: '空气感,脸周',
+        coverUrl: 'https://cdn.example.com/101.jpg',
+        templateUrl: 'https://cdn.example.com/101.zip',
+        usedCount: 5,
+        hairstylistId: 1n,
+        isDeleted: 0,
+      },
+      {
+        id: 102n,
+        templateName: '厚重短发',
+        category: 'SHORT',
+        status: 'DRAFT',
+        description: '短发提案',
+        tags: '短发',
+        coverUrl: 'https://cdn.example.com/102.jpg',
+        templateUrl: 'https://cdn.example.com/102.zip',
+        usedCount: 1,
+        hairstylistId: 1n,
+        isDeleted: 0,
+      },
+      {
+        id: 103n,
+        templateName: '其他美发师模板',
+        category: 'MEDIUM',
+        status: 'PUBLIC',
+        description: '不应出现',
+        tags: '空气感',
+        coverUrl: 'https://cdn.example.com/103.jpg',
+        templateUrl: 'https://cdn.example.com/103.zip',
+        usedCount: 3,
+        hairstylistId: 2n,
+        isDeleted: 0,
+      },
+    );
+    hairTemplateMakeTasks.push({
+      id: 88n,
+      templateId: 101n,
+      taskStatus: 'STEP2_SUCCESS',
+      isDeleted: 0,
+    });
+
+    const response = await request(app.getHttpServer())
+      .get('/api/v1/templates?q=空气感&category=MEDIUM&status=PUBLIC&page=1&pageSize=1')
+      .set('Authorization', `Bearer ${token}`)
+      .expect(200);
+
+    expect(response.body.data).toEqual({
+      items: [
+        {
+          id: '101',
+          name: '空气感中长发',
+          category: 'MEDIUM',
+          status: 'PUBLIC',
+          description: '空气感提案',
+          tags: ['空气感', '脸周'],
+          image: 'https://cdn.example.com/101.jpg',
+          makeTaskId: '88',
+          makeTaskStatus: 'STEP2_SUCCESS',
+          usedCount: 5,
+        },
+      ],
+      total: 1,
+      page: 1,
+      pageSize: 1,
+    });
+  });
+
+  it('/api/v1/templates/summary (GET) returns real template dashboard summary', async () => {
+    const token = await signToken();
+
+    hairTemplates.push(
+      {
+        id: 101n,
+        templateName: '空气感中长发',
+        category: 'MEDIUM',
+        status: 'PUBLIC',
+        description: '空气感提案',
+        tags: '空气感,脸周',
+        coverUrl: 'https://cdn.example.com/101.jpg',
+        templateUrl: 'https://cdn.example.com/101.zip',
+        usedCount: 5,
+        hairstylistId: 1n,
+        isDeleted: 0,
+      },
+      {
+        id: 102n,
+        templateName: '厚重短发',
+        category: 'SHORT',
+        status: 'DRAFT',
+        description: '短发提案',
+        tags: '短发',
+        coverUrl: 'https://cdn.example.com/102.jpg',
+        templateUrl: 'https://cdn.example.com/102.zip',
+        usedCount: 1,
+        hairstylistId: 1n,
+        isDeleted: 0,
+      },
+    );
+    aiHairSimulationTasks.push(
+      {
+        id: 501n,
+        templateId: '101',
+        taskStatus: 'SUCCESSES',
+        taskFinishTime: new Date(),
+        isDeleted: 0,
+      },
+      {
+        id: 502n,
+        templateId: '999',
+        taskStatus: 'SUCCESSES',
+        taskFinishTime: new Date(),
+        isDeleted: 0,
+      },
+    );
+
+    const response = await request(app.getHttpServer())
+      .get('/api/v1/templates/summary')
+      .set('Authorization', `Bearer ${token}`)
+      .expect(200);
+
+    expect(response.body.data).toEqual({
+      total: 2,
+      published: 1,
+      weeklyUsed: 1,
+      draft: 1,
+    });
+  });
+
+  it('/api/v1/templates/:templateId (DELETE) soft deletes owned template', async () => {
+    const token = await signToken();
+
+    hairTemplates.push({
+      id: 101n,
+      templateName: '空气感中长发',
+      category: 'MEDIUM',
+      status: 'PUBLIC',
+      description: '空气感提案',
+      tags: '空气感,脸周',
+      coverUrl: 'https://cdn.example.com/101.jpg',
+      templateUrl: 'https://cdn.example.com/101.zip',
+      hairstylistId: 1n,
+      isDeleted: 0,
+    });
+    hairTemplateMakeTasks.push({
+      id: 88n,
+      templateId: 101n,
+      taskStatus: 'STEP2_SUCCESS',
+      isDeleted: 0,
+    });
+
+    const response = await request(app.getHttpServer())
+      .delete('/api/v1/templates/101')
+      .set('Authorization', `Bearer ${token}`)
+      .expect(200);
+
+    expect(response.body.data).toEqual({ success: true });
+    expect(hairTemplates.find((item) => item.id === 101n)?.isDeleted).toBe(1);
+    expect(hairTemplateMakeTasks.find((item) => item.id === 88n)?.isDeleted).toBe(1);
+  });
+
+  it('/api/v1/templates/bulk-delete (POST) soft deletes current stylist templates only', async () => {
+    const token = await signToken();
+
+    hairTemplates.push(
+      {
+        id: 101n,
+        templateName: '空气感中长发',
+        category: 'MEDIUM',
+        status: 'PUBLIC',
+        description: '空气感提案',
+        tags: '空气感,脸周',
+        coverUrl: 'https://cdn.example.com/101.jpg',
+        templateUrl: 'https://cdn.example.com/101.zip',
+        hairstylistId: 1n,
+        isDeleted: 0,
+      },
+      {
+        id: 102n,
+        templateName: '厚重短发',
+        category: 'SHORT',
+        status: 'DRAFT',
+        description: '短发提案',
+        tags: '短发',
+        coverUrl: 'https://cdn.example.com/102.jpg',
+        templateUrl: 'https://cdn.example.com/102.zip',
+        hairstylistId: 1n,
+        isDeleted: 0,
+      },
+      {
+        id: 103n,
+        templateName: '其他美发师模板',
+        category: 'MEDIUM',
+        status: 'PUBLIC',
+        description: '不应删除',
+        tags: '空气感',
+        coverUrl: 'https://cdn.example.com/103.jpg',
+        templateUrl: 'https://cdn.example.com/103.zip',
+        hairstylistId: 2n,
+        isDeleted: 0,
+      },
+    );
+    hairTemplateMakeTasks.push(
+      {
+        id: 88n,
+        templateId: 101n,
+        taskStatus: 'STEP2_SUCCESS',
+        isDeleted: 0,
+      },
+      {
+        id: 89n,
+        templateId: 102n,
+        taskStatus: 'PROCESSING',
+        isDeleted: 0,
+      },
+      {
+        id: 90n,
+        templateId: 103n,
+        taskStatus: 'STEP2_SUCCESS',
+        isDeleted: 0,
+      },
+    );
+
+    const response = await request(app.getHttpServer())
+      .post('/api/v1/templates/bulk-delete')
+      .set('Authorization', `Bearer ${token}`)
+      .send({
+        ids: ['101', '102', '103'],
+      })
+      .expect(201);
+
+    expect(response.body.data).toEqual({ deletedCount: 2 });
+    expect(hairTemplates.find((item) => item.id === 101n)?.isDeleted).toBe(1);
+    expect(hairTemplates.find((item) => item.id === 102n)?.isDeleted).toBe(1);
+    expect(hairTemplates.find((item) => item.id === 103n)?.isDeleted).toBe(0);
+    expect(hairTemplateMakeTasks.find((item) => item.id === 88n)?.isDeleted).toBe(1);
+    expect(hairTemplateMakeTasks.find((item) => item.id === 89n)?.isDeleted).toBe(1);
+    expect(hairTemplateMakeTasks.find((item) => item.id === 90n)?.isDeleted).toBe(0);
+  });
+
+  it('/api/v1/templates/make-tasks/:taskId (GET) promotes step1 success and returns detail', async () => {
+    const token = await signToken();
+
+    hairTemplates.push({
+      id: 101n,
+      templateName: '柔和层次长发',
+      coverUrl: '',
+      templateUrl: '',
+      status: 'DRAFT',
+      hairstylistId: 1n,
+      isDeleted: 0,
+    });
+    hairTemplateMakeTasks.push({
+      id: 88n,
+      totalTaskId: 'tmpl_123',
+      taskStatus: 'PROCESSING',
+      templateId: 101n,
+      step1TaskId: 'step1-task-1',
+      step2TaskId: '',
+      refHairUrl: 'https://cdn.example.com/ref.jpg',
+      modelUrl: 'https://cdn.example.com/model.jpg',
+      clothesUrl: 'https://cdn.example.com/clothes.jpg',
+      backgroundUrl: 'https://cdn.example.com/background.jpg',
+      step1ResultImg: '',
+      step2ResultImg: '',
+      step1Resource: JSON.stringify({
+        templateUrl: 'https://cdn.example.com/template-step1.json',
+      }),
+      step2Resource: JSON.stringify({}),
+      createTime: new Date('2026-06-28T12:00:00.000Z'),
+      updateTime: new Date(Date.now() - 20_000),
+      isDeleted: 0,
+    });
+    getResult.mockResolvedValueOnce({
+      status: 'successes',
+      message: '',
+      images: ['https://cdn.example.com/step1-result.jpg'],
+    });
+    templateTaskFiles.downloadToTempFile.mockResolvedValue(
+      '/tmp/template-make/tmpl_123/hairstyle.jpg',
+    );
+
+    const response = await request(app.getHttpServer())
+      .get('/api/v1/templates/make-tasks/88')
+      .set('Authorization', `Bearer ${token}`)
+      .expect(200);
+
+    expect(getResult).toHaveBeenCalledWith('step1-task-1');
+    expect(response.body.data).toEqual(
+      expect.objectContaining({
+        id: '88',
+        templateId: '101',
+        taskStatus: 'STEP1_SUCCESS',
+        step1: expect.objectContaining({
+          taskId: 'step1-task-1',
+          status: 'STEP1_SUCCESS',
+          resultImage: 'https://cdn.example.com/step1-result.jpg',
+        }),
+      }),
+    );
+  });
+});

+ 247 - 68
frontend/src/pages/dashboard/templates/TemplateListPage.tsx

@@ -13,26 +13,70 @@ import { usePageContext } from '../../../shared/pageContext'
 import { DashboardLayout } from '../../../layouts/DashboardLayout'
 
 const initialSummary: TemplateDashboardSummary = {
-  total: 24,
-  published: 18,
-  weeklyUsed: 6,
-  draft: 4,
+  total: 0,
+  published: 0,
+  weeklyUsed: 0,
+  draft: 0,
 }
 
 const FALLBACK_TEMPLATE_IMAGE = '/images/aitori-simulation-result.png'
+const PAGE_SIZE = 8
 
 export function TemplateListPage() {
   const { locale, path, copy } = usePageContext()
   const [items, setItems] = useState<TemplateEntity[]>([])
   const [summary, setSummary] = useState<TemplateDashboardSummary>(initialSummary)
   const [selectedIds, setSelectedIds] = useState<string[]>([])
+  const [query, setQuery] = useState('')
+  const [category, setCategory] = useState('')
+  const [status, setStatus] = useState('')
+  const [page, setPage] = useState(1)
+  const [pageSize] = useState(PAGE_SIZE)
+  const [total, setTotal] = useState(0)
+  const [loading, setLoading] = useState(false)
   const pageCopy = copy.templateListPage
 
   useEffect(() => {
-    listTemplates().then(setItems).catch(() => setItems([]))
     getTemplateDashboardSummary().then(setSummary).catch(() => setSummary(initialSummary))
   }, [])
 
+  useEffect(() => {
+    let cancelled = false
+
+    const load = async () => {
+      setLoading(true)
+      try {
+        const result = await listTemplates({
+          q: query.trim(),
+          category,
+          status,
+          page,
+          pageSize,
+        })
+
+        if (cancelled) return
+        setItems(result.items)
+        setTotal(result.total)
+        setSelectedIds((current) => current.filter((id) => result.items.some((item) => item.id === id)))
+      } catch {
+        if (cancelled) return
+        setItems([])
+        setTotal(0)
+        setSelectedIds([])
+      } finally {
+        if (!cancelled) {
+          setLoading(false)
+        }
+      }
+    }
+
+    void load()
+
+    return () => {
+      cancelled = true
+    }
+  }, [category, page, pageSize, query, status])
+
   const toggleSelect = (templateId: string) => {
     setSelectedIds((current) =>
       current.includes(templateId)
@@ -41,10 +85,26 @@ export function TemplateListPage() {
     )
   }
 
+  const allCurrentPageSelected =
+    items.length > 0 && items.every((item) => selectedIds.includes(item.id))
+
+  const handleToggleSelectAllCurrentPage = () => {
+    const currentPageIds = items.map((item) => item.id)
+
+    setSelectedIds((current) => {
+      if (allCurrentPageSelected) {
+        return current.filter((id) => !currentPageIds.includes(id))
+      }
+
+      return Array.from(new Set([...current, ...currentPageIds]))
+    })
+  }
+
   const handleDelete = async (templateId: string) => {
     await deleteTemplate(templateId)
     setItems((current) => current.filter((item) => item.id !== templateId))
     setSelectedIds((current) => current.filter((id) => id !== templateId))
+    setTotal((current) => Math.max(0, current - 1))
   }
 
   const handleBulkDelete = async () => {
@@ -54,21 +114,37 @@ export function TemplateListPage() {
     await deleteTemplates(deletingIds)
     setItems((current) => current.filter((item) => !deletingIds.includes(item.id)))
     setSelectedIds([])
+    setTotal((current) => Math.max(0, current - deletingIds.length))
   }
 
   const getGenerationBadge = (template: TemplateEntity) => {
-    if (template.generationStatus === 'pending') {
+    if (template.makeTaskStatus === 'WAIT' || template.generationStatus === 'pending') {
       return pageCopy.statusBadgeMap?.pending || '排队中'
     }
-    if (template.generationStatus === 'processing') {
+    if (
+      template.makeTaskStatus === 'PROCESSING' ||
+      template.makeTaskStatus === 'STEP1_SUCCESS' ||
+      template.generationStatus === 'processing'
+    ) {
       return pageCopy.statusBadgeMap?.processing || '生成中'
     }
-    if (template.generationStatus === 'failed') {
+    if (template.makeTaskStatus === 'FAILED' || template.generationStatus === 'failed') {
       return pageCopy.statusBadgeMap?.failed || '生成失败'
     }
     return null
   }
 
+  const getTemplateTarget = (template: TemplateEntity) => {
+    if (template.makeTaskId) {
+      return path(`/templates/tasks/${template.makeTaskId}`)
+    }
+
+    return null
+  }
+
+  const totalPages = Math.max(1, Math.ceil(total / pageSize))
+  const pages = Array.from({ length: totalPages }, (_, index) => index + 1)
+
   return (
     <DashboardLayout>
       <Helmet>
@@ -91,31 +167,55 @@ export function TemplateListPage() {
             </div>
           </div>
 
-          <div className="workbench-panel bg-white border-radius-8px box-shadow-extra-large p-25px mb-4">
-            <div className="row g-3 align-items-end">
+          <div className="workbench-panel bg-white border-radius-8px box-shadow-extra-large p-25px mb-4 template-list-filter-panel">
+            <div className="row g-3 align-items-end template-list-filter-grid">
               <div className="col-lg-5">
                 <label htmlFor="template-search" className="fw-700 text-dark-gray mb-10px">{pageCopy.filters.searchLabel}</label>
                 <div className="customer-search-field">
                   <i className="feather icon-feather-search"></i>
-                  <input id="template-search" type="search" className="form-control" placeholder={pageCopy.filters.searchPlaceholder} />
+                  <input
+                    id="template-search"
+                    type="search"
+                    className="form-control"
+                    placeholder={pageCopy.filters.searchPlaceholder}
+                    value={query}
+                    onChange={(event) => {
+                      setQuery(event.target.value)
+                      setPage(1)
+                    }}
+                  />
                 </div>
               </div>
-              <div className="col-lg-3 col-md-4">
+              <div className="col-lg-3 col-md-6">
                 <label htmlFor="category-filter" className="fw-700 text-dark-gray mb-10px">{pageCopy.filters.categoryLabel}</label>
-                <select id="category-filter" className="form-select customer-filter-control">
-                  {pageCopy.filters.categoryOptions.map((option: string) => <option key={option}>{option}</option>)}
-                </select>
-              </div>
-              <div className="col-lg-2 col-md-4">
-                <label htmlFor="purpose-filter" className="fw-700 text-dark-gray mb-10px">{pageCopy.filters.purposeLabel}</label>
-                <select id="purpose-filter" className="form-select customer-filter-control">
-                  {pageCopy.filters.purposeOptions.map((option: string) => <option key={option}>{option}</option>)}
+                <select
+                  id="category-filter"
+                  className="form-select customer-filter-control"
+                  value={category}
+                  onChange={(event) => {
+                    setCategory(event.target.value)
+                    setPage(1)
+                  }}
+                >
+                  {pageCopy.filters.categoryOptions.map((option: { value: string; label: string }) => (
+                    <option key={option.value} value={option.value}>{option.label}</option>
+                  ))}
                 </select>
               </div>
-              <div className="col-lg-2 col-md-4">
+              <div className="col-lg-3 col-md-6">
                 <label htmlFor="status-filter" className="fw-700 text-dark-gray mb-10px">{pageCopy.filters.statusLabel}</label>
-                <select id="status-filter" className="form-select customer-filter-control">
-                  {pageCopy.filters.statusOptions.map((option: string) => <option key={option}>{option}</option>)}
+                <select
+                  id="status-filter"
+                  className="form-select customer-filter-control"
+                  value={status}
+                  onChange={(event) => {
+                    setStatus(event.target.value)
+                    setPage(1)
+                  }}
+                >
+                  {pageCopy.filters.statusOptions.map((option: { value: string; label: string }) => (
+                    <option key={option.value} value={option.value}>{option.label}</option>
+                  ))}
                 </select>
               </div>
             </div>
@@ -132,13 +232,23 @@ export function TemplateListPage() {
 
       <section className="pt-0">
         <div className="container">
-          <div className="workbench-panel bg-white border-radius-8px box-shadow-extra-large p-30px">
-            <div className="d-flex align-items-center justify-content-between flex-wrap gap-2 mb-25px">
+          <div className="workbench-panel bg-white border-radius-8px box-shadow-extra-large p-30px template-list-results-panel">
+            <div className="d-flex align-items-center justify-content-between flex-wrap gap-2 mb-25px template-list-results-header">
               <div>
                 <span className="fs-14 fw-700 text-base-color text-uppercase">{pageCopy.resultBadge}</span>
-                <h2 className="fw-700 text-medium-gray fs-26 mb-0">{pageCopy.resultTitle.replace('{{count}}', String(items.length))}</h2>
+                <h2 className="fw-700 text-medium-gray fs-26 mb-0">{pageCopy.resultTitle.replace('{{count}}', String(total))}</h2>
               </div>
-              <div className="d-flex align-items-center gap-2">
+              <div className="d-flex align-items-center gap-2 template-list-results-actions">
+                <button
+                  type="button"
+                  onClick={handleToggleSelectAllCurrentPage}
+                  disabled={items.length === 0}
+                  className="btn btn-small btn-rounded btn-transparent-light-gray border border-1 border-color-dark-gray fw-700"
+                >
+                  {allCurrentPageSelected
+                    ? pageCopy.deselectAll || '取消全选'
+                    : pageCopy.selectAll || '全选'}
+                </button>
                 <button
                   type="button"
                   onClick={handleBulkDelete}
@@ -152,55 +262,124 @@ export function TemplateListPage() {
             </div>
 
             <div className="template-grid">
-              {items.map((template) => (
-                <article key={template.id} className="template-card">
-                  <div className="d-flex align-items-center justify-content-between gap-2 p-15px pb-0">
-                    <label className="d-flex align-items-center gap-2 fs-14 text-medium-gray">
-                      <input
-                        type="checkbox"
-                        checked={selectedIds.includes(template.id)}
-                        aria-label={`${pageCopy.select || '选择'}${template.name}`}
-                        onChange={() => toggleSelect(template.id)}
-                      />
-                      <span>{pageCopy.select || '选择'}</span>
-                    </label>
-                    <button
-                      type="button"
-                      className="btn btn-small btn-rounded btn-transparent-light-gray border border-1 border-color-dark-gray fw-700"
-                      aria-label={`${pageCopy.delete || '删除'}${template.name}`}
-                      onClick={() => void handleDelete(template.id)}
-                    >
-                      {pageCopy.delete || '删除'}
-                    </button>
-                  </div>
-                  <Link to={path(`/templates/${template.id}`)} className="d-block text-decoration-none">
-                    <img src={template.image || FALLBACK_TEMPLATE_IMAGE} alt={template.name} />
-                    <div className="template-card-body">
-                      <div className="template-card-meta">
-                        <span>{pageCopy.categoryMap[template.category as keyof typeof pageCopy.categoryMap] || template.category}</span>
-                        <span>{pageCopy.statusMap[template.status as keyof typeof pageCopy.statusMap] || template.status}</span>
+              {items.map((template) => {
+                const target = getTemplateTarget(template)
+                const generationBadge = getGenerationBadge(template)
+                const cardContent = (
+                  <>
+                    <div className="template-card-image">
+                      {target ? (
+                        <Link
+                          to={target}
+                          className="template-card-image-link"
+                          aria-label={`${pageCopy.detail || '查看任务'}图片`}
+                        >
+                          <img src={template.image || FALLBACK_TEMPLATE_IMAGE} alt={template.name} />
+                        </Link>
+                      ) : (
+                        <div className="template-card-image-link" aria-disabled="true">
+                          <img src={template.image || FALLBACK_TEMPLATE_IMAGE} alt={template.name} />
+                        </div>
+                      )}
+                      <div className="template-card-image-selection">
+                        <input
+                          type="checkbox"
+                          checked={selectedIds.includes(template.id)}
+                          aria-label={`${pageCopy.select || '选择'}${template.name}`}
+                          onChange={() => toggleSelect(template.id)}
+                          className="template-card-checkbox"
+                        />
                       </div>
-                      {getGenerationBadge(template) ? (
-                        <div className="template-card-meta">
-                          <span className="template-status-badge">{getGenerationBadge(template)}</span>
+                      {generationBadge ? (
+                        <div className="template-card-image-overlay">
+                          <span className="template-status-badge">{generationBadge}</span>
                         </div>
                       ) : null}
-                      <h3>{template.name}</h3>
-                      <p>{template.description}</p>
-                      <div className="template-card-footer"><span>{pageCopy.usedCount.replace('{{count}}', String(template.usedCount))}</span><span>{pageCopy.detail}</span></div>
                     </div>
-                  </Link>
-                </article>
-              ))}
+                    <div className="template-card-content">
+                      <div className="template-card-main">
+                        <div className="template-card-meta">
+                          <span>{pageCopy.categoryMap[template.category as keyof typeof pageCopy.categoryMap] || template.category}</span>
+                          <span>{pageCopy.statusMap[template.status as keyof typeof pageCopy.statusMap] || template.status}</span>
+                        </div>
+                        {target ? (
+                          <Link to={target} className="template-card-title-link">
+                            <h3>{template.name}</h3>
+                          </Link>
+                        ) : (
+                          <h3>{template.name}</h3>
+                        )}
+                        <p>{template.description}</p>
+                      </div>
+                      <div className="template-card-footer">
+                        <span>{pageCopy.usedCount.replace('{{count}}', String(template.usedCount))}</span>
+                        <div className="template-card-footer-actions">
+                          {target ? (
+                            <Link to={target} className="template-card-detail-action">
+                              {pageCopy.detail}
+                            </Link>
+                          ) : (
+                            <span className="template-card-detail-action is-disabled">
+                              {pageCopy.detail}
+                            </span>
+                          )}
+                          <button
+                            type="button"
+                            className="template-card-delete-action"
+                            aria-label={`${pageCopy.delete || '删除'}${template.name}`}
+                            onClick={() => void handleDelete(template.id)}
+                          >
+                            {pageCopy.delete || '删除'}
+                          </button>
+                        </div>
+                      </div>
+                    </div>
+                  </>
+                )
+
+                return (
+                  <article
+                    key={template.id}
+                    className={`template-card${target ? '' : ' is-static'}`}
+                  >
+                    <div className="template-card-shell">{cardContent}</div>
+                  </article>
+                )
+              })}
             </div>
 
-            {items.length === 0 ? <p className="fs-15 text-medium-gray mb-0">{copy.common.empty}</p> : null}
+            {!loading && items.length === 0 ? <p className="fs-15 text-medium-gray mb-0">{copy.common.empty}</p> : null}
 
             <div className="customer-pager">
-              <a href="#" className="customer-pager-link is-disabled" aria-label={pageCopy.pagination.prev}>{pageCopy.pagination.prev}</a>
-              <a href="#" className="customer-pager-link is-active" aria-current="page">1</a>
-              <a href="#" className="customer-pager-link">2</a>
-              <a href="#" className="customer-pager-link" aria-label={pageCopy.pagination.next}>{pageCopy.pagination.next}</a>
+              <button
+                type="button"
+                className={`customer-pager-link${page === 1 ? ' is-disabled' : ''}`}
+                aria-label={pageCopy.pagination.prev}
+                disabled={page === 1}
+                onClick={() => setPage((current) => Math.max(1, current - 1))}
+              >
+                {pageCopy.pagination.prev}
+              </button>
+              {pages.map((pageNumber) => (
+                <button
+                  key={pageNumber}
+                  type="button"
+                  className={`customer-pager-link${page === pageNumber ? ' is-active' : ''}`}
+                  aria-current={page === pageNumber ? 'page' : undefined}
+                  onClick={() => setPage(pageNumber)}
+                >
+                  {pageNumber}
+                </button>
+              ))}
+              <button
+                type="button"
+                className={`customer-pager-link${page === totalPages ? ' is-disabled' : ''}`}
+                aria-label={pageCopy.pagination.next}
+                disabled={page === totalPages}
+                onClick={() => setPage((current) => Math.min(totalPages, current + 1))}
+              >
+                {pageCopy.pagination.next}
+              </button>
             </div>
           </div>
         </div>

+ 290 - 0
frontend/src/pages/dashboard/templates/__tests__/TemplateListPage.test.tsx

@@ -0,0 +1,290 @@
+import { fireEvent, render, screen, waitFor } from '@testing-library/react'
+import { HelmetProvider } from 'react-helmet-async'
+import { MemoryRouter } from 'react-router-dom'
+import type { ReactNode } from 'react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { TemplateListPage } from '../TemplateListPage'
+
+const {
+  listTemplates,
+  getTemplateDashboardSummary,
+  deleteTemplate,
+  deleteTemplates,
+} = vi.hoisted(() => ({
+  listTemplates: vi.fn(),
+  getTemplateDashboardSummary: vi.fn(),
+  deleteTemplate: vi.fn(),
+  deleteTemplates: vi.fn(),
+}))
+
+vi.mock('../../../../layouts/DashboardLayout', () => ({
+  DashboardLayout: ({ children }: { children: ReactNode }) => <>{children}</>,
+}))
+
+vi.mock('../../../../services/api/templates', () => ({
+  listTemplates,
+  getTemplateDashboardSummary,
+  deleteTemplate,
+  deleteTemplates,
+}))
+
+vi.mock('../../../../shared/pageContext', () => ({
+  usePageContext: () => ({
+    locale: 'zh',
+    navigate: vi.fn(),
+    path: (path = '') => `/zh${path}`,
+    copy: {
+      common: {
+        empty: '暂无数据',
+      },
+      templateListPage: {
+        title: '我的发型模板',
+        description: 'desc',
+        badge: '发型模板',
+        lead: 'lead',
+        newTemplate: '新建发型模板',
+        filters: {
+          searchLabel: '发型模板搜索',
+          searchPlaceholder: '搜索',
+          categoryLabel: '分类',
+          categoryOptions: [{ value: '', label: '全部' }],
+          statusLabel: '状态',
+          statusOptions: [{ value: '', label: '全部' }],
+        },
+        stats: {
+          total: '已登记模板',
+          published: '公开中',
+          weeklyUsed: '本周使用',
+          draft: '草稿',
+        },
+        resultBadge: '发型模板列表',
+        resultTitle: '搜索结果 {{count}}件',
+        sortRecent: '排序:最近使用',
+        categoryMap: { LONG: '长发', MEDIUM: '中长发', SHORT: '短发', DYE: '染发' },
+        statusMap: { DRAFT: '草稿', PUBLIC: '公开中' },
+        usedCount: '使用 {{count}}次',
+        detail: '查看任务',
+        selectAll: '全选',
+        deselectAll: '取消全选',
+        pagination: { prev: '上一页', next: '下一页' },
+      },
+    },
+  }),
+}))
+
+describe('TemplateListPage', () => {
+  beforeEach(() => {
+    listTemplates.mockResolvedValue({
+      items: [
+        {
+          id: '101',
+          name: '处理中模板',
+          category: 'MEDIUM',
+          status: 'DRAFT',
+          description: '处理中',
+          tags: [],
+          image: '/processing.png',
+          makeTaskId: '88',
+          makeTaskStatus: 'PROCESSING',
+          usedCount: 0,
+        },
+        {
+          id: '102',
+          name: '已完成模板',
+          category: 'SHORT',
+          status: 'PUBLIC',
+          description: '已完成',
+          tags: [],
+          image: '/done.png',
+          makeTaskId: '89',
+          makeTaskStatus: 'STEP2_SUCCESS',
+          usedCount: 2,
+        },
+        {
+          id: '103',
+          name: '异常模板',
+          category: 'LONG',
+          status: 'DRAFT',
+          description: '缺少任务记录',
+          tags: [],
+          image: '/fallback.png',
+          usedCount: 0,
+        },
+      ],
+      total: 3,
+      page: 1,
+      pageSize: 8,
+    })
+    getTemplateDashboardSummary.mockResolvedValue({
+      total: 3,
+      published: 1,
+      weeklyUsed: 2,
+      draft: 2,
+    })
+  })
+
+  it('requests first page on load and keeps pagination visible without purpose filter', async () => {
+    render(
+      <HelmetProvider>
+        <MemoryRouter>
+          <TemplateListPage />
+        </MemoryRouter>
+      </HelmetProvider>,
+    )
+
+    expect(await screen.findByRole('link', { name: /处理中模板/ })).toBeInTheDocument()
+    expect(listTemplates).toHaveBeenCalledWith({
+      q: '',
+      category: '',
+      status: '',
+      page: 1,
+      pageSize: 8,
+    })
+    expect(screen.queryByLabelText('用途')).not.toBeInTheDocument()
+    expect(screen.getByLabelText('上一页')).toBeInTheDocument()
+    expect(screen.getByRole('button', { name: '1' })).toBeInTheDocument()
+    expect(screen.getByText('已登记模板')).toBeInTheDocument()
+    expect(screen.getAllByText('公开中').length).toBeGreaterThanOrEqual(1)
+    expect(screen.getByText('本周使用')).toBeInTheDocument()
+    expect(screen.getAllByText('草稿').length).toBeGreaterThanOrEqual(1)
+    expect(screen.getByText('使用 2次')).toBeInTheDocument()
+  })
+
+  it('resets to page 1 when search changes', async () => {
+    render(
+      <HelmetProvider>
+        <MemoryRouter>
+          <TemplateListPage />
+        </MemoryRouter>
+      </HelmetProvider>,
+    )
+
+    await screen.findByRole('link', { name: /处理中模板/ })
+    fireEvent.change(screen.getByLabelText('发型模板搜索'), {
+      target: { value: '空气感' },
+    })
+
+    await waitFor(() => {
+      expect(listTemplates).toHaveBeenLastCalledWith({
+        q: '空气感',
+        category: '',
+        status: '',
+        page: 1,
+        pageSize: 8,
+      })
+    })
+  })
+
+  it('routes templates with makeTaskId to the task page', async () => {
+    render(
+      <HelmetProvider>
+        <MemoryRouter>
+          <TemplateListPage />
+        </MemoryRouter>
+      </HelmetProvider>,
+    )
+
+    const processingLink = await screen.findByRole('link', { name: /处理中模板/ })
+    const doneLink = await screen.findByRole('link', { name: /已完成模板/ })
+
+    expect(processingLink).toHaveAttribute('href', '/zh/templates/tasks/88')
+    expect(doneLink).toHaveAttribute('href', '/zh/templates/tasks/89')
+  })
+
+  it('renders templates without makeTaskId as non-clickable cards', async () => {
+    render(
+      <HelmetProvider>
+        <MemoryRouter>
+          <TemplateListPage />
+        </MemoryRouter>
+      </HelmetProvider>,
+    )
+
+    expect(await screen.findByText('异常模板')).toBeInTheDocument()
+    expect(screen.queryByRole('link', { name: /异常模板/ })).not.toBeInTheDocument()
+  })
+
+  it('renders cards without the top toolbar and keeps an equal-height content skeleton', async () => {
+    render(
+      <HelmetProvider>
+        <MemoryRouter>
+          <TemplateListPage />
+        </MemoryRouter>
+      </HelmetProvider>,
+    )
+
+    const templateLink = await screen.findByRole('link', { name: /处理中模板/ })
+    const templateCard = templateLink.closest('article')
+
+    expect(templateCard).not.toBeNull()
+    expect(templateCard?.querySelector('.template-card-toolbar')).toBeNull()
+    expect(templateCard?.querySelector('.template-card-content')).not.toBeNull()
+    expect(templateCard?.querySelector('.template-card-main')).not.toBeNull()
+    expect(templateCard?.querySelector('.template-card-footer')).not.toBeNull()
+  })
+
+  it('renders checkbox and generation status inside the image overlay area', async () => {
+    render(
+      <HelmetProvider>
+        <MemoryRouter>
+          <TemplateListPage />
+        </MemoryRouter>
+      </HelmetProvider>,
+    )
+
+    const templateLink = await screen.findByRole('link', { name: /处理中模板/ })
+    const templateCard = templateLink.closest('article')
+    const imageOverlay = templateCard?.querySelector('.template-card-image-overlay')
+    const imageSelection = templateCard?.querySelector('.template-card-image-selection')
+
+    expect(imageOverlay).not.toBeNull()
+    expect(imageSelection).not.toBeNull()
+    expect(imageOverlay?.textContent).toContain('生成中')
+    expect(templateCard?.querySelector('.template-card-main .template-status-badge')).toBeNull()
+    expect(templateCard?.querySelector('.template-card-main .template-card-checkbox')).toBeNull()
+  })
+
+  it('renders footer delete action beside detail action and toggles current-page selection only', async () => {
+    render(
+      <HelmetProvider>
+        <MemoryRouter>
+          <TemplateListPage />
+        </MemoryRouter>
+      </HelmetProvider>,
+    )
+
+    const processingLink = await screen.findByRole('link', { name: /处理中模板/ })
+    const templateCard = processingLink.closest('article')
+
+    expect(templateCard?.querySelector('.template-card-footer-actions')).not.toBeNull()
+    expect(templateCard?.querySelector('.template-card-delete-action')).not.toBeNull()
+
+    fireEvent.click(screen.getByRole('button', { name: '全选' }))
+
+    expect(screen.getByRole('button', { name: '取消全选' })).toBeInTheDocument()
+    expect(screen.getByLabelText('选择处理中模板')).toBeChecked()
+    expect(screen.getByLabelText('选择已完成模板')).toBeChecked()
+    expect(screen.getByLabelText('选择异常模板')).toBeChecked()
+
+    fireEvent.click(screen.getByRole('button', { name: '取消全选' }))
+
+    expect(screen.getByRole('button', { name: '全选' })).toBeInTheDocument()
+    expect(screen.getByLabelText('选择处理中模板')).not.toBeChecked()
+  })
+
+  it('renders zero summary values when the summary request fails', async () => {
+    getTemplateDashboardSummary.mockRejectedValueOnce(new Error('boom'))
+
+    render(
+      <HelmetProvider>
+        <MemoryRouter>
+          <TemplateListPage />
+        </MemoryRouter>
+      </HelmetProvider>,
+    )
+
+    await screen.findByRole('link', { name: /处理中模板/ })
+
+    expect(screen.getAllByText('0').length).toBeGreaterThanOrEqual(4)
+  })
+})

+ 48 - 24
frontend/src/services/api/templates.ts

@@ -1,19 +1,30 @@
 import { http, ApiError, type ListEnvelope } from '../http/client'
 import type {
+  CreateTemplateMakeTaskInput,
   GetTemplateByIdParams,
-  TemplateAssetOption,
+  PaginatedTemplateList,
+  TemplateBasicInfo,
   TemplateAssets,
   TemplateDashboardSummary,
   TemplateDetailStats,
   TemplateEntity,
+  TemplateMakeTaskCreateResult,
+  TemplateMakeTaskDetail,
+  UpdateTemplateBasicInfoInput,
 } from './contracts'
 export type {
+  CreateTemplateMakeTaskInput,
   GetTemplateByIdParams,
+  PaginatedTemplateList,
   TemplateAssetOption,
   TemplateAssets,
+  TemplateBasicInfo,
   TemplateDashboardSummary,
   TemplateDetailStats,
   TemplateEntity,
+  TemplateMakeTaskCreateResult,
+  TemplateMakeTaskDetail,
+  UpdateTemplateBasicInfoInput,
 } from './contracts'
 
 export type CreateTemplateGenerationJobInput = {
@@ -26,7 +37,6 @@ export type CreateTemplateGenerationJobInput = {
 export type CreateTemplateAndGenerateInput = {
   name: string
   category?: string
-  purpose?: string
   status?: string
   description?: string
   tags?: string[]
@@ -64,7 +74,6 @@ export type TemplateGenerationJob = {
 export type TemplateSaveInput = {
   name: string
   category?: string
-  purpose?: string
   status?: string
   description?: string
   tags?: string[]
@@ -81,35 +90,50 @@ export type TemplateUsageResult = {
 }
 
 export async function listTemplates(
-  params: { q?: string; category?: string; purpose?: string; status?: string } = {},
-): Promise<TemplateEntity[]> {
+  params: { q?: string; category?: string; status?: string; page?: number; pageSize?: number } = {},
+): Promise<PaginatedTemplateList> {
   const query = new URLSearchParams()
   if (params.q) query.set('q', params.q)
   if (params.category) query.set('category', params.category)
-  if (params.purpose) query.set('purpose', params.purpose)
   if (params.status) query.set('status', params.status)
+  query.set('page', String(params.page ?? 1))
+  query.set('pageSize', String(params.pageSize ?? 8))
   const qs = query.toString()
-  const data = await http.get<ListEnvelope<TemplateEntity>>(`/templates${qs ? `?${qs}` : ''}`)
-  return data.items.map((item) => ({
-    ...item,
-    tags: item.tags || [],
-    image: item.image || '/images/aitori-simulation-result.png',
-    usedCount: typeof item.usedCount === 'number' ? item.usedCount : 0,
-  }))
+  const data = await http.get<PaginatedTemplateList>(`/templates${qs ? `?${qs}` : ''}`)
+  return {
+    ...data,
+    items: data.items.map((item) => ({
+      ...item,
+      tags: item.tags || [],
+      image: item.image || '/images/aitori-simulation-result.png',
+      usedCount: typeof item.usedCount === 'number' ? item.usedCount : 0,
+    })),
+  }
 }
 
 export async function getTemplateAssets(): Promise<TemplateAssets> {
-  const data = await http.get<{
-    models?: TemplateAssetOption[]
-    clothes?: TemplateAssetOption[]
-    backgrounds?: TemplateAssetOption[]
-  }>('/templates/assets')
+  return http.get<TemplateAssets>('/templates/assets')
+}
 
-  return {
-    models: data.models ?? [],
-    clothes: data.clothes ?? [],
-    backgrounds: data.backgrounds ?? [],
-  }
+export async function createTemplateMakeTask(
+  input: CreateTemplateMakeTaskInput,
+): Promise<TemplateMakeTaskCreateResult> {
+  return http.post<TemplateMakeTaskCreateResult>('/templates/make-tasks', input)
+}
+
+export async function getTemplateMakeTaskById(taskId: string): Promise<TemplateMakeTaskDetail> {
+  return http.get<TemplateMakeTaskDetail>(`/templates/make-tasks/${encodeURIComponent(taskId)}`)
+}
+
+export async function getTemplateBasicInfo(templateId: string): Promise<TemplateBasicInfo> {
+  return http.get<TemplateBasicInfo>(`/templates/${encodeURIComponent(templateId)}`)
+}
+
+export async function updateTemplateBasicInfo(
+  templateId: string,
+  input: UpdateTemplateBasicInfoInput,
+): Promise<TemplateBasicInfo> {
+  return http.patch<TemplateBasicInfo>(`/templates/${encodeURIComponent(templateId)}`, input)
 }
 
 export async function getTemplateById({ templateId }: GetTemplateByIdParams = {}): Promise<TemplateEntity | null> {
@@ -190,5 +214,5 @@ export async function getTemplateStats(templateId: string): Promise<TemplateDeta
 }
 
 export async function getTemplateDashboardSummary(): Promise<TemplateDashboardSummary> {
-  return http.get<TemplateDashboardSummary>('/stylist/dashboard/template-summary')
+  return http.get<TemplateDashboardSummary>('/templates/summary')
 }