Forráskód Böngészése

docs: add remaining template planning docs

ChenYu 4 hónapja
szülő
commit
f14bd15dca

+ 879 - 0
docs/superpowers/plans/2026-06-28-template-edit-page-basic-info.md

@@ -0,0 +1,879 @@
+# Template Edit Page Basic Info Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Add a dedicated template edit page for basic info only, switch the task success CTA to that page, and add backend read/update APIs for template basic info.
+
+**Architecture:** Keep template creation and task orchestration unchanged. Split responsibilities clearly: `TemplateMakeTaskPage` handles task completion and redirects into a new `TemplateEditPage`, `TemplateDetailPage` remains the detail-view entry, and the backend `templates` module exposes focused `GET /templates/:templateId` and `PATCH /templates/:templateId` capabilities scoped to the current stylist.
+
+**Tech Stack:** React + React Router + Vitest + Testing Library, NestJS + Prisma + Jest
+
+## Global Constraints
+
+- Keep `TemplateMakeTaskPage` success CTA text as “编辑模板信息” and route it to `/:locale/templates/:templateId/edit`.
+- The new edit page may update only `templateName`, `status`, `category`, `tags`, and `description`.
+- Do not allow editing `coverUrl`, `templateUrl`, `refHairUrl`, `modelUrl`, `clothesUrl`, `backgroundUrl`, or any `hair_template_make_task` fields.
+- Keep `TemplateDetailPage` as the template detail entry from list cards.
+- Follow existing workbench page layout with `stylist-workbench-top ipad-top-space-margin mb-2`.
+- Preserve zero-mock product behavior; only tests may mock dependencies.
+
+---
+
+## File Structure
+
+- `backend/src/modules/templates/dto/template-basic-info.dto.ts`
+  - Read model returned by `GET /templates/:templateId`
+- `backend/src/modules/templates/dto/update-template-basic-info.dto.ts`
+  - Validated PATCH payload for editable fields only
+- `backend/src/modules/templates/templates.controller.ts`
+  - Add `GET /templates/:templateId` and `PATCH /templates/:templateId`
+- `backend/src/modules/templates/templates.service.ts`
+  - Add scoped fetch/update methods for `hair_template`
+- `backend/src/modules/templates/templates.service.spec.ts`
+  - Unit coverage for read/update behavior and field boundaries
+- `backend/test/templates.e2e-spec.ts`
+  - Endpoint coverage for GET/PATCH template basic info
+- `frontend/src/config/routes.ts`
+  - Add `templateEdit` route
+- `frontend/src/App.tsx`
+  - Register `TemplateEditPage`
+- `frontend/src/services/api/contracts.ts`
+  - Add `TemplateBasicInfo` and `UpdateTemplateBasicInfoInput`
+- `frontend/src/services/api/templates.ts`
+  - Add `getTemplateBasicInfo()` and `updateTemplateBasicInfo()`
+- `frontend/src/pages/dashboard/templates/TemplateEditPage.tsx`
+  - New basic-info-only editor page
+- `frontend/src/pages/dashboard/templates/TemplateMakeTaskPage.tsx`
+  - Change success CTA copy and target route
+- `frontend/src/pages/dashboard/templates/TemplateDetailPage.tsx`
+  - Remove inline editing actions so it stays detail-oriented
+- `frontend/src/pages/dashboard/templates/__tests__/TemplateEditPage.test.tsx`
+  - New page test coverage
+- `frontend/src/pages/dashboard/templates/__tests__/TemplateMakeTaskPage.test.tsx`
+  - Update success CTA expectation
+- `frontend/src/pages/dashboard/templates/__tests__/TemplateDetailPage.test.tsx`
+  - Lock detail page as display-oriented if needed
+- `frontend/src/__tests__/App.test.tsx`
+  - Route registration test for edit page
+- `frontend/src/i18n/locales/zh/translation.json`
+  - Add `templateEditPage` and task CTA copy
+- `frontend/src/i18n/locales/ja/translation.json`
+  - Add `templateEditPage` and task CTA copy
+
+### Task 1: Backend Basic Info DTOs And Service Methods
+
+**Files:**
+- Create: `backend/src/modules/templates/dto/template-basic-info.dto.ts`
+- Create: `backend/src/modules/templates/dto/update-template-basic-info.dto.ts`
+- Modify: `backend/src/modules/templates/templates.service.ts`
+- Test: `backend/src/modules/templates/templates.service.spec.ts`
+
+**Interfaces:**
+- Consumes:
+  - Existing Prisma `hairTemplate` model fields: `templateName`, `status`, `category`, `tags`, `description`, `coverUrl`, `templateUrl`, `hairstylistId`
+- Produces:
+  - `TemplatesService.getBasicInfo(stylistId: bigint, templateId: string): Promise<TemplateBasicInfoDto>`
+  - `TemplatesService.updateBasicInfo(stylistId: bigint, templateId: string, dto: UpdateTemplateBasicInfoDto): Promise<TemplateBasicInfoDto>`
+  - `TemplateBasicInfoDto`
+  - `UpdateTemplateBasicInfoDto`
+
+- [ ] **Step 1: Write the failing service tests**
+
+```ts
+it('returns template basic info for the owning stylist', async () => {
+  prisma.hairTemplate.findFirst.mockResolvedValue({
+    id: BigInt(43),
+    templateName: '柔和层次长发',
+    status: 'DRAFT',
+    category: 'LONG',
+    tags: '层次,首次建议',
+    description: '适合轻盈感修饰',
+    coverUrl: 'https://cdn.example.com/cover.jpg',
+    templateUrl: 'https://cdn.example.com/template.zip',
+    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 basic info fields', async () => {
+  prisma.hairTemplate.findFirst.mockResolvedValue({
+    id: BigInt(43),
+    templateName: '旧模板名',
+    status: 'DRAFT',
+    category: 'LONG',
+    tags: '旧标签',
+    description: '旧描述',
+    coverUrl: 'https://cdn.example.com/cover.jpg',
+    templateUrl: 'https://cdn.example.com/template.zip',
+    hairstylistId: BigInt(1),
+    isDeleted: 0,
+  })
+  prisma.hairTemplate.update.mockResolvedValue({
+    id: BigInt(43),
+    templateName: '新模板名',
+    status: 'PUBLIC',
+    category: 'MEDIUM',
+    tags: '空气感,层次',
+    description: '新描述',
+    coverUrl: 'https://cdn.example.com/cover.jpg',
+    templateUrl: 'https://cdn.example.com/template.zip',
+    hairstylistId: BigInt(1),
+    isDeleted: 0,
+  })
+
+  await service.updateBasicInfo(BigInt(1), '43', {
+    templateName: '新模板名',
+    status: 'PUBLIC',
+    category: 'MEDIUM',
+    tags: ['空气感', '层次'],
+    description: '新描述',
+  })
+
+  expect(prisma.hairTemplate.update).toHaveBeenCalledWith({
+    where: { id: BigInt(43) },
+    data: {
+      templateName: '新模板名',
+      status: 'PUBLIC',
+      category: 'MEDIUM',
+      tags: '空气感,层次',
+      description: '新描述',
+    },
+  })
+})
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run:
+
+```bash
+cd /Users/yuchen/Documents/hairstylist/backend
+npm test -- --runInBand src/modules/templates/templates.service.spec.ts
+```
+
+Expected: FAIL because `getBasicInfo` / `updateBasicInfo` and the new DTO behavior do not exist yet.
+
+- [ ] **Step 3: Add the DTO files**
+
+```ts
+// backend/src/modules/templates/dto/template-basic-info.dto.ts
+export class TemplateBasicInfoDto {
+  id!: string
+  templateName!: string
+  status!: 'DRAFT' | 'PUBLIC'
+  category!: 'LONG' | 'MEDIUM' | 'SHORT' | 'DYE'
+  tags!: string[]
+  description!: string
+  coverUrl!: string
+  templateUrl!: string
+}
+```
+
+```ts
+// backend/src/modules/templates/dto/update-template-basic-info.dto.ts
+import { IsArray, IsIn, IsOptional, IsString } from 'class-validator'
+
+export class UpdateTemplateBasicInfoDto {
+  @IsString()
+  templateName!: string
+
+  @IsIn(['DRAFT', 'PUBLIC'])
+  status!: 'DRAFT' | 'PUBLIC'
+
+  @IsIn(['LONG', 'MEDIUM', 'SHORT', 'DYE'])
+  category!: 'LONG' | 'MEDIUM' | 'SHORT' | 'DYE'
+
+  @IsOptional()
+  @IsArray()
+  @IsString({ each: true })
+  tags?: string[]
+
+  @IsOptional()
+  @IsString()
+  description?: string
+}
+```
+
+- [ ] **Step 4: Implement minimal service methods**
+
+```ts
+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
+}
+
+async getBasicInfo(stylistId: bigint, templateId: string): Promise<TemplateBasicInfoDto> {
+  const template = await this.getOwnedTemplate(stylistId, templateId)
+  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,
+  }
+}
+
+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 {
+    id: updated.id.toString(),
+    templateName: updated.templateName,
+    status: updated.status,
+    category: updated.category,
+    tags: updated.tags ? updated.tags.split(',').map((tag) => tag.trim()).filter(Boolean) : [],
+    description: updated.description ?? '',
+    coverUrl: updated.coverUrl,
+    templateUrl: updated.templateUrl,
+  }
+}
+```
+
+- [ ] **Step 5: Run test to verify it passes**
+
+Run:
+
+```bash
+cd /Users/yuchen/Documents/hairstylist/backend
+npm test -- --runInBand src/modules/templates/templates.service.spec.ts
+```
+
+Expected: PASS with the new read/update service tests included.
+
+- [ ] **Step 6: Commit**
+
+```bash
+cd /Users/yuchen/Documents/hairstylist
+git add backend/src/modules/templates/dto/template-basic-info.dto.ts \
+  backend/src/modules/templates/dto/update-template-basic-info.dto.ts \
+  backend/src/modules/templates/templates.service.ts \
+  backend/src/modules/templates/templates.service.spec.ts
+git commit -m "feat: add template basic info service methods"
+```
+
+### Task 2: Backend GET And PATCH Template Endpoints
+
+**Files:**
+- Modify: `backend/src/modules/templates/templates.controller.ts`
+- Test: `backend/test/templates.e2e-spec.ts`
+
+**Interfaces:**
+- Consumes:
+  - `TemplatesService.getBasicInfo(stylistId, templateId)`
+  - `TemplatesService.updateBasicInfo(stylistId, templateId, dto)`
+  - `UpdateTemplateBasicInfoDto`
+- Produces:
+  - `GET /api/v1/templates/:templateId`
+  - `PATCH /api/v1/templates/:templateId`
+
+- [ ] **Step 1: Write failing e2e coverage**
+
+```ts
+it('GET /api/v1/templates/:templateId returns template basic info', async () => {
+  await request(app.getHttpServer())
+    .get('/api/v1/templates/43')
+    .set('Authorization', token)
+    .expect(200)
+    .expect(({ body }) => {
+      expect(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('PATCH /api/v1/templates/:templateId updates only basic info', async () => {
+  await request(app.getHttpServer())
+    .patch('/api/v1/templates/43')
+    .set('Authorization', token)
+    .send({
+      templateName: '空气感中长发',
+      status: 'PUBLIC',
+      category: 'MEDIUM',
+      tags: ['空气感', '层次'],
+      description: '新描述',
+    })
+    .expect(200)
+})
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run:
+
+```bash
+cd /Users/yuchen/Documents/hairstylist/backend
+npm run test:e2e -- templates.e2e-spec.ts
+```
+
+Expected: FAIL because controller routes for GET/PATCH template basic info are missing.
+
+- [ ] **Step 3: Add controller endpoints**
+
+```ts
+@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,
+  )
+}
+```
+
+- [ ] **Step 4: Update e2e in-memory store behavior**
+
+```ts
+hairTemplates = [
+  {
+    id: BigInt(43),
+    templateName: '柔和层次长发',
+    status: 'DRAFT',
+    category: 'LONG',
+    tags: '层次,首次建议',
+    description: '适合轻盈感修饰',
+    coverUrl: 'https://cdn.example.com/cover.jpg',
+    templateUrl: 'https://cdn.example.com/template.zip',
+    hairstylistId: BigInt(1),
+    isDeleted: 0,
+  },
+]
+
+hairTemplate.findFirst = vi.fn(async ({ where }) => {
+  return hairTemplates.find((item) =>
+    item.id === where.id &&
+    item.hairstylistId === where.hairstylistId &&
+    item.isDeleted === where.isDeleted,
+  ) ?? null
+})
+
+hairTemplate.update = vi.fn(async ({ where, data }) => {
+  const current = hairTemplates.find((item) => item.id === where.id)
+  Object.assign(current, data)
+  return current
+})
+```
+
+- [ ] **Step 5: Run test to verify it passes**
+
+Run:
+
+```bash
+cd /Users/yuchen/Documents/hairstylist/backend
+npm run test:e2e -- templates.e2e-spec.ts
+```
+
+Expected: PASS with GET/PATCH template basic info endpoint coverage.
+
+- [ ] **Step 6: Commit**
+
+```bash
+cd /Users/yuchen/Documents/hairstylist
+git add backend/src/modules/templates/templates.controller.ts \
+  backend/test/templates.e2e-spec.ts
+git commit -m "feat: add template basic info endpoints"
+```
+
+### Task 3: Frontend Route, API Contracts, And Task Success CTA
+
+**Files:**
+- Modify: `frontend/src/config/routes.ts`
+- Modify: `frontend/src/App.tsx`
+- Modify: `frontend/src/services/api/contracts.ts`
+- Modify: `frontend/src/services/api/templates.ts`
+- Modify: `frontend/src/pages/dashboard/templates/TemplateMakeTaskPage.tsx`
+- Modify: `frontend/src/pages/dashboard/templates/__tests__/TemplateMakeTaskPage.test.tsx`
+- Modify: `frontend/src/__tests__/App.test.tsx`
+- Modify: `frontend/src/i18n/locales/zh/translation.json`
+- Modify: `frontend/src/i18n/locales/ja/translation.json`
+
+**Interfaces:**
+- Consumes:
+  - Backend `GET /templates/:templateId`
+  - Backend `PATCH /templates/:templateId`
+- Produces:
+  - `routes.templateEdit = '/:locale/templates/:templateId/edit'`
+  - `getTemplateBasicInfo(templateId: string): Promise<TemplateBasicInfo>`
+  - `updateTemplateBasicInfo(templateId: string, input: UpdateTemplateBasicInfoInput): Promise<TemplateBasicInfo>`
+
+- [ ] **Step 1: Write failing frontend tests**
+
+```tsx
+it('shows edit template info CTA after task success', async () => {
+  expect(screen.getByRole('link', { name: '编辑模板信息' })).toHaveAttribute(
+    'href',
+    '/zh/templates/101/edit',
+  )
+})
+
+it('renders template edit route', async () => {
+  window.history.replaceState({}, '', '/zh/templates/101/edit')
+  render(<App />)
+  await waitFor(() => {
+    expect(screen.getByText('template-edit-page')).toBeInTheDocument()
+  })
+})
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run:
+
+```bash
+cd /Users/yuchen/Documents/hairstylist/frontend
+npm test -- src/pages/dashboard/templates/__tests__/TemplateMakeTaskPage.test.tsx src/__tests__/App.test.tsx
+```
+
+Expected: FAIL because the button still points to detail and the edit route does not exist.
+
+- [ ] **Step 3: Add route and API contract changes**
+
+```ts
+// routes.ts
+templateEdit: '/:locale/templates/:templateId/edit',
+```
+
+```ts
+// contracts.ts
+export type TemplateBasicInfo = {
+  id: string
+  templateName: string
+  status: 'DRAFT' | 'PUBLIC'
+  category: 'LONG' | 'MEDIUM' | 'SHORT' | 'DYE'
+  tags: string[]
+  description: string
+  coverUrl: string
+  templateUrl: string
+}
+
+export type UpdateTemplateBasicInfoInput = {
+  templateName: string
+  status: 'DRAFT' | 'PUBLIC'
+  category: 'LONG' | 'MEDIUM' | 'SHORT' | 'DYE'
+  tags?: string[]
+  description?: string
+}
+```
+
+```ts
+// templates.ts
+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)
+}
+```
+
+- [ ] **Step 4: Change task success CTA copy and target**
+
+```tsx
+{task?.taskStatus === 'STEP2_SUCCESS' ? (
+  <Link
+    to={path(`/templates/${task.templateId}/edit`)}
+    className="btn btn-large btn-rounded btn-base-color btn-box-shadow fw-700 mt-20px"
+  >
+    {actions.editTemplate}
+  </Link>
+) : null}
+```
+
+```json
+"actions": {
+  "editTemplate": "编辑模板信息",
+  "backToList": "返回模板列表"
+}
+```
+
+- [ ] **Step 5: Register the route in App**
+
+```tsx
+<Route
+  path={routes.templateEdit}
+  element={
+    <RequireAuth>
+      <TemplateEditPage />
+    </RequireAuth>
+  }
+/>
+```
+
+- [ ] **Step 6: Run test to verify it passes**
+
+Run:
+
+```bash
+cd /Users/yuchen/Documents/hairstylist/frontend
+npm test -- src/pages/dashboard/templates/__tests__/TemplateMakeTaskPage.test.tsx src/__tests__/App.test.tsx
+```
+
+Expected: PASS with the new edit CTA and route registration.
+
+- [ ] **Step 7: Commit**
+
+```bash
+cd /Users/yuchen/Documents/hairstylist
+git add frontend/src/config/routes.ts \
+  frontend/src/App.tsx \
+  frontend/src/services/api/contracts.ts \
+  frontend/src/services/api/templates.ts \
+  frontend/src/pages/dashboard/templates/TemplateMakeTaskPage.tsx \
+  frontend/src/pages/dashboard/templates/__tests__/TemplateMakeTaskPage.test.tsx \
+  frontend/src/__tests__/App.test.tsx \
+  frontend/src/i18n/locales/zh/translation.json \
+  frontend/src/i18n/locales/ja/translation.json
+git commit -m "feat: route task success to template edit page"
+```
+
+### Task 4: Build The Dedicated Template Edit Page
+
+**Files:**
+- Create: `frontend/src/pages/dashboard/templates/TemplateEditPage.tsx`
+- Create: `frontend/src/pages/dashboard/templates/__tests__/TemplateEditPage.test.tsx`
+- Modify: `frontend/src/i18n/locales/zh/translation.json`
+- Modify: `frontend/src/i18n/locales/ja/translation.json`
+
+**Interfaces:**
+- Consumes:
+  - `getTemplateBasicInfo(templateId: string): Promise<TemplateBasicInfo>`
+  - `updateTemplateBasicInfo(templateId: string, input: UpdateTemplateBasicInfoInput): Promise<TemplateBasicInfo>`
+  - `routes.templateEdit`
+- Produces:
+  - `TemplateEditPage`
+
+- [ ] **Step 1: Write the failing page tests**
+
+```tsx
+it('loads template basic info into the edit form', async () => {
+  getTemplateBasicInfo.mockResolvedValue({
+    id: '43',
+    templateName: '柔和层次长发',
+    status: 'DRAFT',
+    category: 'LONG',
+    tags: ['层次', '首次建议'],
+    description: '适合轻盈感修饰',
+    coverUrl: 'https://cdn.example.com/cover.jpg',
+    templateUrl: 'https://cdn.example.com/template.zip',
+  })
+
+  renderPage('/zh/templates/43/edit')
+
+  expect(await screen.findByDisplayValue('柔和层次长发')).toBeInTheDocument()
+  expect(screen.getByDisplayValue('层次, 首次建议')).toBeInTheDocument()
+})
+
+it('submits only editable basic info fields', async () => {
+  updateTemplateBasicInfo.mockResolvedValue({
+    id: '43',
+    templateName: '空气感中长发',
+    status: 'PUBLIC',
+    category: 'MEDIUM',
+    tags: ['空气感', '层次'],
+    description: '新描述',
+    coverUrl: 'https://cdn.example.com/cover.jpg',
+    templateUrl: 'https://cdn.example.com/template.zip',
+  })
+
+  fireEvent.change(screen.getByLabelText('模板名称 *'), { target: { value: '空气感中长发' } })
+  fireEvent.click(screen.getByRole('button', { name: '保存' }))
+
+  await waitFor(() => {
+    expect(updateTemplateBasicInfo).toHaveBeenCalledWith('43', {
+      templateName: '空气感中长发',
+      status: 'DRAFT',
+      category: 'LONG',
+      tags: ['层次', '首次建议'],
+      description: '适合轻盈感修饰',
+    })
+  })
+})
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run:
+
+```bash
+cd /Users/yuchen/Documents/hairstylist/frontend
+npm test -- src/pages/dashboard/templates/__tests__/TemplateEditPage.test.tsx
+```
+
+Expected: FAIL because `TemplateEditPage` does not exist yet.
+
+- [ ] **Step 3: Implement the new edit page**
+
+```tsx
+export function TemplateEditPage() {
+  const { locale, path, copy } = usePageContext()
+  const { templateId } = useParams()
+  const [form, setForm] = useState({
+    templateName: '',
+    status: 'DRAFT' as const,
+    category: 'LONG' as const,
+    tags: '',
+    description: '',
+  })
+  const [loading, setLoading] = useState(true)
+  const [saving, setSaving] = useState(false)
+  const [error, setError] = useState('')
+  const pageCopy = copy.templateEditPage
+
+  useEffect(() => {
+    if (!templateId) return
+    getTemplateBasicInfo(templateId)
+      .then((item) => {
+        setForm({
+          templateName: item.templateName,
+          status: item.status,
+          category: item.category,
+          tags: item.tags.join(', '),
+          description: item.description,
+        })
+      })
+      .catch(() => setError(pageCopy.states.notFound))
+      .finally(() => setLoading(false))
+  }, [templateId, pageCopy.states.notFound])
+
+  const handleSave = async () => {
+    if (!templateId || saving) return
+    setSaving(true)
+    setError('')
+    try {
+      await updateTemplateBasicInfo(templateId, {
+        templateName: form.templateName,
+        status: form.status,
+        category: form.category,
+        tags: form.tags.split(',').map((tag) => tag.trim()).filter(Boolean),
+        description: form.description,
+      })
+    } catch (err) {
+      setError(err instanceof Error ? err.message : pageCopy.errors.saveFailed)
+    } finally {
+      setSaving(false)
+    }
+  }
+
+  return <div>template-edit-page</div>
+}
+```
+
+- [ ] **Step 4: Flesh out the page with the existing workbench form skeleton**
+
+```tsx
+<section className="stylist-workbench-top ipad-top-space-margin mb-2">
+  <div className="container">
+    <div className="d-flex align-items-center justify-content-between flex-wrap gap-3 mb-4">
+      <Link to={path(`/templates/${templateId}`)} className="btn btn-small btn-rounded btn-transparent-light-gray border border-1 border-color-dark-gray fw-700">
+        <i className="feather icon-feather-arrow-left me-6px"></i>{pageCopy.back}
+      </Link>
+      <div className="d-flex flex-wrap gap-2">
+        <Link to={path(`/templates/${templateId}`)} className="btn btn-large btn-rounded btn-transparent-light-gray border border-1 border-color-dark-gray fw-700">
+          {copy.common.cancel}
+        </Link>
+        <button type="button" onClick={handleSave} disabled={saving} className="btn btn-large btn-rounded btn-base-color btn-box-shadow fw-700">
+          <i className="feather icon-feather-save me-10px"></i>{copy.common.save}
+        </button>
+      </div>
+    </div>
+  </div>
+</section>
+```
+
+- [ ] **Step 5: Run test to verify it passes**
+
+Run:
+
+```bash
+cd /Users/yuchen/Documents/hairstylist/frontend
+npm test -- src/pages/dashboard/templates/__tests__/TemplateEditPage.test.tsx
+```
+
+Expected: PASS with successful load and save coverage.
+
+- [ ] **Step 6: Commit**
+
+```bash
+cd /Users/yuchen/Documents/hairstylist
+git add frontend/src/pages/dashboard/templates/TemplateEditPage.tsx \
+  frontend/src/pages/dashboard/templates/__tests__/TemplateEditPage.test.tsx \
+  frontend/src/i18n/locales/zh/translation.json \
+  frontend/src/i18n/locales/ja/translation.json
+git commit -m "feat: add template basic info edit page"
+```
+
+### Task 5: Restore Template Detail Page To Display Role And Run Verification
+
+**Files:**
+- Modify: `frontend/src/pages/dashboard/templates/TemplateDetailPage.tsx`
+- Test: `frontend/src/pages/dashboard/templates/__tests__/TemplateDetailPage.test.tsx`
+- Test: `frontend/src/pages/dashboard/templates/__tests__/TemplateMakeTaskPage.test.tsx`
+- Test: `frontend/src/pages/dashboard/templates/__tests__/TemplateEditPage.test.tsx`
+- Test: `frontend/src/__tests__/App.test.tsx`
+- Test: `backend/src/modules/templates/templates.service.spec.ts`
+- Test: `backend/test/templates.e2e-spec.ts`
+
+**Interfaces:**
+- Consumes:
+  - Existing detail page data loaders
+  - New edit page route
+- Produces:
+  - Detail page remains display-first, no inline save path
+  - End-to-end validated feature set
+
+- [ ] **Step 1: Write a failing detail-page regression test**
+
+```tsx
+it('does not render inline save controls on the detail page', async () => {
+  renderPage('/zh/templates/43')
+  await waitFor(() => {
+    expect(screen.queryByRole('button', { name: '保存' })).not.toBeInTheDocument()
+  })
+})
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run:
+
+```bash
+cd /Users/yuchen/Documents/hairstylist/frontend
+npm test -- src/pages/dashboard/templates/__tests__/TemplateDetailPage.test.tsx
+```
+
+Expected: FAIL because the current detail page still renders save actions and form controls.
+
+- [ ] **Step 3: Remove inline edit actions from the detail page**
+
+```tsx
+// Replace the top-right save button group with a single read-only action group.
+<div className="d-flex flex-wrap gap-2">
+  <Link
+    to={path(`/templates/${template.id}/edit`)}
+    className="btn btn-large btn-rounded btn-base-color btn-box-shadow fw-700"
+  >
+    {pageCopy.edit}
+  </Link>
+</div>
+```
+
+```tsx
+// Replace editable form controls with read-only display blocks.
+<div className="template-detail-summary-item">
+  <span>{pageCopy.info.name}</span>
+  <strong>{template.name}</strong>
+</div>
+```
+
+- [ ] **Step 4: Run the focused frontend and backend verification set**
+
+Run:
+
+```bash
+cd /Users/yuchen/Documents/hairstylist/frontend
+npm test -- src/pages/dashboard/templates/__tests__/TemplateMakeTaskPage.test.tsx \
+  src/pages/dashboard/templates/__tests__/TemplateEditPage.test.tsx \
+  src/pages/dashboard/templates/__tests__/TemplateDetailPage.test.tsx \
+  src/__tests__/App.test.tsx
+npm run typecheck
+```
+
+```bash
+cd /Users/yuchen/Documents/hairstylist/backend
+npm test -- --runInBand src/modules/templates/templates.service.spec.ts
+npm run test:e2e -- templates.e2e-spec.ts
+```
+
+Expected:
+
+- Frontend tests PASS
+- `npm run typecheck` PASS
+- Backend unit tests PASS
+- Backend e2e PASS
+
+- [ ] **Step 5: Commit**
+
+```bash
+cd /Users/yuchen/Documents/hairstylist
+git add frontend/src/pages/dashboard/templates/TemplateDetailPage.tsx \
+  frontend/src/pages/dashboard/templates/__tests__/TemplateDetailPage.test.tsx
+git commit -m "refactor: separate template detail and edit flows"
+```
+
+## Self-Review
+
+- Spec coverage:
+  - New edit page: covered by Task 3 and Task 4
+  - Task-page CTA rename and reroute: covered by Task 3
+  - Backend GET/PATCH template basic info APIs: covered by Task 1 and Task 2
+  - Basic-info-only write boundary: covered by Task 1 and Task 2
+  - Detail page retained as display entry: covered by Task 5
+- Placeholder scan:
+  - No `TBD`, `TODO`, or deferred implementation text remains
+- Type consistency:
+  - Uses one shared `TemplateBasicInfo` / `TemplateBasicInfoDto` shape
+  - Uses one shared `UpdateTemplateBasicInfoInput` / `UpdateTemplateBasicInfoDto` write contract
+

+ 1001 - 0
docs/superpowers/plans/2026-06-28-template-list-query-pagination.md

@@ -0,0 +1,1001 @@
+# 模板列表真实查询与分页 Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** 让“我的发型模板”页面接入真实后端列表数据,支持真实查询与真实分页,并删除“用途”筛选。
+
+**Architecture:** 后端在 `GET /templates` 上补齐美发师维度的真实查询与分页能力,前端将 `TemplateListPage` 改为受控查询状态并按真实分页结果渲染。继续保留当前模板卡片 UI、任务状态分流和删除交互,只替换静态列表与假分页实现。
+
+**Tech Stack:** NestJS, Prisma, Jest, Supertest, React, TypeScript, Vitest, Testing Library
+
+## Global Constraints
+
+- 模板列表页必须使用真实后端数据展示模板列表。
+- 查询项只保留 `搜索 / 分类 / 状态`,必须完全移除 `用途` 前后端查询链路。
+- 分页必须是真实分页,且即使只有一页也保留分页控件区域。
+- 列表卡片现有视觉结构不重构,继续保留任务状态徽标、批量删除和单项删除。
+- 模板分流规则保持不变:未完成任务跳任务页,已完成模板跳详情页。
+- 本轮不实现真实排序,`排序:最近使用` 继续保持占位态。
+- 默认分页参数使用 `page = 1`、`pageSize = 8`。
+
+---
+
+## 文件结构
+
+- `backend/src/modules/templates/dto/list-templates-query.dto.ts`
+  - 模板列表查询 DTO,移除 `purpose`,新增 `page`、`pageSize`。
+- `backend/src/modules/templates/templates.service.ts`
+  - 实现真实列表查询、筛选、分页和列表项映射。
+- `backend/src/modules/templates/templates.service.spec.ts`
+  - 覆盖服务层筛选、分页、映射逻辑。
+- `backend/test/templates.e2e-spec.ts`
+  - 覆盖 `GET /api/v1/templates` 的真实筛选与分页行为。
+- `frontend/src/services/api/contracts.ts`
+  - 定义前端分页列表返回类型,统一模板列表契约。
+- `frontend/src/services/api/templates.ts`
+  - 更新 `listTemplates()` 参数和返回值,移除 `purpose`。
+- `frontend/src/pages/dashboard/templates/TemplateListPage.tsx`
+  - 接入受控查询状态、真实请求和真实分页渲染。
+- `frontend/src/pages/dashboard/templates/__tests__/TemplateListPage.test.tsx`
+  - 覆盖删除用途、查询重置分页、真实分页展示和分流逻辑。
+- `frontend/src/i18n/locales/zh/translation.json`
+  - 删除模板列表用途筛选文案,补充分页/状态映射缺失项。
+- `frontend/src/i18n/locales/ja/translation.json`
+  - 删除模板列表用途筛选文案,补充分页/状态映射缺失项。
+
+## Task 1: 后端模板列表查询 DTO 与服务层真实分页
+
+**Files:**
+- Modify: `backend/src/modules/templates/dto/list-templates-query.dto.ts`
+- Modify: `backend/src/modules/templates/templates.service.ts`
+- Test: `backend/src/modules/templates/templates.service.spec.ts`
+
+**Interfaces:**
+- Consumes: 现有 `TemplatesController.list(@Query() query: ListTemplatesQueryDto)`
+- Produces:
+  - `ListTemplatesQueryDto`:
+    - `q?: string`
+    - `category?: string`
+    - `status?: string`
+    - `page?: number`
+    - `pageSize?: number`
+  - `TemplatesService.list(query): Promise<{ items: TemplateEntity[]; total: number; page: number; pageSize: number }>`
+
+- [ ] **Step 1: 先写服务层失败测试,锁定查询参数与返回结构**
+
+```ts
+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',
+      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: expect.any(Object),
+    orderBy: [{ id: 'desc' }],
+    skip: 8,
+    take: 8,
+  });
+
+  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: 0,
+      },
+    ],
+    total: 1,
+    page: 2,
+    pageSize: 8,
+  });
+});
+```
+
+- [ ] **Step 2: 运行失败测试,确认当前实现还是空列表**
+
+Run: `npm test -- src/modules/templates/templates.service.spec.ts --runInBand`  
+Expected: FAIL,报 `service.list()` 返回空列表或缺少 `page/pageSize`。
+
+- [ ] **Step 3: 扩展 Prisma mock,补 count/findMany 依赖**
+
+在 `backend/src/modules/templates/templates.service.spec.ts` 的 `prisma` mock 里加上:
+
+```ts
+const prisma = {
+  systemConfig: {
+    findMany: jest.fn(),
+  },
+  hairTemplate: {
+    findFirst: jest.fn(),
+    findMany: jest.fn(),
+    count: jest.fn(),
+    update: jest.fn(),
+  },
+  hairTemplateMakeTask: {
+    findFirst: jest.fn(),
+    findMany: jest.fn(),
+    update: jest.fn(),
+  },
+  $transaction: jest.fn(),
+};
+```
+
+- [ ] **Step 4: 调整查询 DTO,移除用途并新增分页参数**
+
+将 `backend/src/modules/templates/dto/list-templates-query.dto.ts` 改成:
+
+```ts
+import { Type } from 'class-transformer';
+import { IsInt, IsOptional, IsString, Min } from 'class-validator';
+
+export class ListTemplatesQueryDto {
+  @IsOptional()
+  @IsString()
+  q?: string;
+
+  @IsOptional()
+  @IsString()
+  category?: string;
+
+  @IsOptional()
+  @IsString()
+  status?: string;
+
+  @IsOptional()
+  @Type(() => Number)
+  @IsInt()
+  @Min(1)
+  page?: number;
+
+  @IsOptional()
+  @Type(() => Number)
+  @IsInt()
+  @Min(1)
+  pageSize?: number;
+}
+```
+
+- [ ] **Step 5: 在服务层实现真实查询与映射**
+
+在 `backend/src/modules/templates/templates.service.ts` 新增列表项映射辅助函数,并改造 `list()`:
+
+```ts
+type ListTemplatesQuery = {
+  stylistId: bigint;
+  q?: string;
+  category?: string;
+  status?: string;
+  page?: number;
+  pageSize?: number;
+};
+
+private toListItem(
+  template: {
+    id: bigint;
+    templateName: string;
+    category: 'LONG' | 'MEDIUM' | 'SHORT' | 'DYE';
+    status: 'DRAFT' | 'PUBLIC';
+    description: string | null;
+    tags: string;
+    coverUrl: string;
+  },
+  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: 0,
+  };
+}
+
+async list(query: ListTemplatesQuery) {
+  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 where = {
+    hairstylistId: query.stylistId,
+    isDeleted: 0,
+    ...(query.category ? { category: query.category } : {}),
+    ...(query.status ? { status: query.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,
+    }),
+  ]);
+
+  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) {
+    const key = task.templateId.toString();
+    if (!taskMap.has(key)) {
+      taskMap.set(key, { id: task.id, taskStatus: task.taskStatus });
+    }
+  }
+
+  return {
+    items: templates.map((item) => this.toListItem(item, taskMap)),
+    total,
+    page,
+    pageSize,
+  };
+}
+```
+
+- [ ] **Step 6: 让控制器把登录美发师 ID 传进列表服务**
+
+把 `backend/src/modules/templates/templates.controller.ts` 的列表入口改成:
+
+```ts
+  @Get()
+  list(
+    @Req() request: AuthenticatedRequest,
+    @Query() query: ListTemplatesQueryDto,
+  ) {
+    return this.templatesService.list({
+      ...query,
+      stylistId: BigInt(request.user.stylistId),
+    });
+  }
+```
+
+- [ ] **Step 7: 运行服务层测试,确认通过**
+
+Run: `npm test -- src/modules/templates/templates.service.spec.ts --runInBand`  
+Expected: PASS,新增列表筛选与分页测试通过,旧模板任务相关测试不回退。
+
+- [ ] **Step 8: 提交当前任务**
+
+```bash
+git add backend/src/modules/templates/dto/list-templates-query.dto.ts \
+  backend/src/modules/templates/templates.service.ts \
+  backend/src/modules/templates/templates.controller.ts \
+  backend/src/modules/templates/templates.service.spec.ts
+git commit -m "feat: add template list query and pagination service"
+```
+
+## Task 2: 后端模板列表 e2e 验证
+
+**Files:**
+- Modify: `backend/test/templates.e2e-spec.ts`
+
+**Interfaces:**
+- Consumes:
+  - `GET /api/v1/templates?q=&category=&status=&page=&pageSize=`
+  - 返回 `{ items, total, page, pageSize }`
+- Produces:
+  - e2e 回归覆盖筛选、分页与当前登录美发师隔离
+
+- [ ] **Step 1: 先补 e2e 红灯测试**
+
+在 `backend/test/templates.e2e-spec.ts` 里新增:
+
+```ts
+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',
+      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,
+    },
+  );
+  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: [
+      expect.objectContaining({
+        id: '101',
+        name: '空气感中长发',
+        category: 'MEDIUM',
+        status: 'PUBLIC',
+        makeTaskId: '88',
+        makeTaskStatus: 'STEP2_SUCCESS',
+      }),
+    ],
+    total: 1,
+    page: 1,
+    pageSize: 1,
+  });
+});
+```
+
+- [ ] **Step 2: 扩展 e2e Prisma stub,支持列表查询依赖**
+
+在 `backend/test/templates.e2e-spec.ts` 的 Prisma stub 里补齐:
+
+```ts
+hairTemplate: {
+  findFirst: jest.fn(...),
+  findMany: jest.fn(async ({ where, orderBy, skip = 0, take }: Record<string, unknown>) => {
+    let items = [...hairTemplates];
+    const typedWhere = where as Record<string, unknown>;
+
+    items = items.filter((item) => item.isDeleted === typedWhere.isDeleted);
+    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)) {
+      const keyword = String((typedWhere.OR[0] as { templateName: { contains: string } }).templateName.contains);
+      items = items.filter((item) =>
+        String(item.templateName).includes(keyword) ||
+        String(item.tags ?? '').includes(keyword) ||
+        String(item.description ?? '').includes(keyword),
+      );
+    }
+
+    if (Array.isArray(orderBy) && orderBy[0]?.id === 'desc') {
+      items.sort((a, b) => Number(b.id) - Number(a.id));
+    }
+
+    const sliced = typeof take === 'number' ? items.slice(Number(skip), Number(skip) + take) : items;
+    return sliced;
+  }),
+  count: jest.fn(async ({ where }: { where: Record<string, unknown> }) => {
+    return (await prisma.hairTemplate.findMany({ where })).length;
+  }),
+  update: jest.fn(...),
+  deleteMany: jest.fn(...),
+},
+hairTemplateMakeTask: {
+  findFirst: jest.fn(...),
+  findMany: jest.fn(async ({ where, orderBy }: Record<string, unknown>) => {
+    let items = [...hairTemplateMakeTasks].filter((item) => item.isDeleted === where.isDeleted);
+    const templateIdFilter = where.templateId as { in?: bigint[] };
+    if (templateIdFilter?.in) {
+      items = items.filter((item) => templateIdFilter.in?.includes(item.templateId as bigint));
+    }
+    if (Array.isArray(orderBy) && orderBy[0]?.id === 'desc') {
+      items.sort((a, b) => Number(b.id) - Number(a.id));
+    }
+    return items;
+  }),
+  update: jest.fn(...),
+  deleteMany: jest.fn(...),
+},
+```
+
+- [ ] **Step 3: 跑 e2e 红灯,确认当前接口还没满足分页结构**
+
+Run: `npm test -- test/templates.e2e-spec.ts --runInBand`  
+Expected: FAIL,报 `items/total/page/pageSize` 结构或筛选行为不匹配。
+
+- [ ] **Step 4: 根据红灯补齐 stub 与断言细节,直到 e2e 通过**
+
+关键断言保持如下:
+
+```ts
+expect(response.body.data.page).toBe(1);
+expect(response.body.data.pageSize).toBe(1);
+expect(response.body.data.total).toBe(1);
+expect(response.body.data.items).toHaveLength(1);
+```
+
+- [ ] **Step 5: 运行 e2e 测试验证通过**
+
+Run: `npm test -- test/templates.e2e-spec.ts --runInBand`  
+Expected: PASS,原有模板素材/任务测试继续通过,新列表测试通过。
+
+- [ ] **Step 6: 提交当前任务**
+
+```bash
+git add backend/test/templates.e2e-spec.ts
+git commit -m "test: cover template list query pagination e2e"
+```
+
+## Task 3: 前端 API 契约与模板列表红灯测试
+
+**Files:**
+- Modify: `frontend/src/services/api/contracts.ts`
+- Modify: `frontend/src/services/api/templates.ts`
+- Modify: `frontend/src/pages/dashboard/templates/__tests__/TemplateListPage.test.tsx`
+
+**Interfaces:**
+- Consumes:
+  - 后端 `GET /templates` 返回 `{ items, total, page, pageSize }`
+- Produces:
+  - `PaginatedTemplateList`
+  - `listTemplates(params): Promise<PaginatedTemplateList>`
+  - 前端测试锁定:删除用途、真实请求参数、真实分页渲染与分流
+
+- [ ] **Step 1: 先把模板列表测试改成红灯**
+
+把 `frontend/src/pages/dashboard/templates/__tests__/TemplateListPage.test.tsx` 改成使用分页返回值:
+
+```tsx
+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,
+      },
+    ],
+    total: 2,
+    page: 1,
+    pageSize: 8,
+  });
+});
+
+it('requests first page on load and keeps pagination visible without purpose filter', async () => {
+  render(...);
+
+  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.getByText('1')).toBeInTheDocument();
+});
+```
+
+再新增查询变化用例:
+
+```tsx
+it('resets to page 1 when search changes', async () => {
+  render(...);
+
+  await screen.findByRole('link', { name: /处理中模板/ });
+  fireEvent.change(screen.getByLabelText('发型模板搜索'), {
+    target: { value: '空气感' },
+  });
+
+  await waitFor(() => {
+    expect(listTemplates).toHaveBeenLastCalledWith({
+      q: '空气感',
+      category: '',
+      status: '',
+      page: 1,
+      pageSize: 8,
+    });
+  });
+});
+```
+
+- [ ] **Step 2: 运行前端红灯测试**
+
+Run: `npm test -- src/pages/dashboard/templates/__tests__/TemplateListPage.test.tsx`  
+Expected: FAIL,当前 `TemplateListPage` 仍然:
+- 使用旧 `TemplateEntity[]`
+- 仍渲染用途筛选
+- 不会发真实分页请求
+
+- [ ] **Step 3: 新增前端分页契约类型**
+
+在 `frontend/src/services/api/contracts.ts` 新增:
+
+```ts
+export type PaginatedTemplateList = {
+  items: TemplateEntity[]
+  total: number
+  page: number
+  pageSize: number
+}
+```
+
+- [ ] **Step 4: 更新 `listTemplates()` 返回值和参数**
+
+把 `frontend/src/services/api/templates.ts` 的接口改成:
+
+```ts
+export async function listTemplates(
+  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.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<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,
+    })),
+  }
+}
+```
+
+- [ ] **Step 5: 运行测试,确认仍然红灯但契约已对齐**
+
+Run: `npm test -- src/pages/dashboard/templates/__tests__/TemplateListPage.test.tsx`  
+Expected: 仍 FAIL,但错误集中在 `TemplateListPage` UI 与交互实现,而不是 API mock 结构不匹配。
+
+- [ ] **Step 6: 提交当前任务**
+
+```bash
+git add frontend/src/services/api/contracts.ts \
+  frontend/src/services/api/templates.ts \
+  frontend/src/pages/dashboard/templates/__tests__/TemplateListPage.test.tsx
+git commit -m "test: define template list pagination contract"
+```
+
+## Task 4: 前端模板列表真实查询与分页实现
+
+**Files:**
+- Modify: `frontend/src/pages/dashboard/templates/TemplateListPage.tsx`
+- Modify: `frontend/src/i18n/locales/zh/translation.json`
+- Modify: `frontend/src/i18n/locales/ja/translation.json`
+- Test: `frontend/src/pages/dashboard/templates/__tests__/TemplateListPage.test.tsx`
+
+**Interfaces:**
+- Consumes:
+  - `listTemplates({ q, category, status, page, pageSize })`
+  - `PaginatedTemplateList { items, total, page, pageSize }`
+- Produces:
+  - 受控模板列表查询页
+  - 删除用途筛选后的中日文案
+  - 真实分页控件
+
+- [ ] **Step 1: 在页面里引入受控查询状态**
+
+将 `frontend/src/pages/dashboard/templates/TemplateListPage.tsx` 的状态起点改成:
+
+```tsx
+const PAGE_SIZE = 8
+
+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)
+```
+
+- [ ] **Step 2: 改造加载逻辑,按查询条件请求真实列表**
+
+在 `useEffect` 中替换原来的 `listTemplates().then(setItems)`:
+
+```tsx
+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)
+    } catch {
+      if (cancelled) return
+      setItems([])
+      setTotal(0)
+    } finally {
+      if (!cancelled) {
+        setLoading(false)
+      }
+    }
+  }
+
+  void load()
+
+  return () => {
+    cancelled = true
+  }
+}, [query, category, status, page, pageSize])
+```
+
+- [ ] **Step 3: 删除用途筛选 UI,并把搜索/分类/状态做成受控组件**
+
+把筛选区改成:
+
+```tsx
+<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}
+      value={query}
+      onChange={(event) => {
+        setQuery(event.target.value)
+        setPage(1)
+      }}
+    />
+  </div>
+</div>
+<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"
+    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-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"
+    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>
+```
+
+- [ ] **Step 4: 接入真实分页渲染**
+
+在 `TemplateListPage.tsx` 中增加:
+
+```tsx
+const totalPages = Math.max(1, Math.ceil(total / pageSize))
+const pages = Array.from({ length: totalPages }, (_, index) => index + 1)
+```
+
+并替换原静态分页区:
+
+```tsx
+<div className="customer-pager">
+  <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>
+```
+
+- [ ] **Step 5: 同步 i18n,彻底移除用途筛选**
+
+将 `frontend/src/i18n/locales/zh/translation.json` 的模板列表筛选改成:
+
+```json
+"filters": {
+  "searchLabel": "发型模板搜索",
+  "searchPlaceholder": "按名称、分类、标签或建议备注搜索",
+  "categoryLabel": "分类",
+  "categoryOptions": [
+    { "value": "", "label": "全部" },
+    { "value": "LONG", "label": "长发" },
+    { "value": "MEDIUM", "label": "中长发" },
+    { "value": "SHORT", "label": "短发" },
+    { "value": "DYE", "label": "染发" }
+  ],
+  "statusLabel": "状态",
+  "statusOptions": [
+    { "value": "", "label": "全部" },
+    { "value": "PUBLIC", "label": "公开中" },
+    { "value": "DRAFT", "label": "草稿" }
+  ]
+}
+```
+
+日文同步改成:
+
+```json
+"filters": {
+  "searchLabel": "テンプレート検索",
+  "searchPlaceholder": "名称・カテゴリ・タグ・メモで検索",
+  "categoryLabel": "カテゴリ",
+  "categoryOptions": [
+    { "value": "", "label": "すべて" },
+    { "value": "LONG", "label": "ロング" },
+    { "value": "MEDIUM", "label": "ミディアム" },
+    { "value": "SHORT", "label": "ショート" },
+    { "value": "DYE", "label": "カラー" }
+  ],
+  "statusLabel": "状態",
+  "statusOptions": [
+    { "value": "", "label": "すべて" },
+    { "value": "PUBLIC", "label": "公開中" },
+    { "value": "DRAFT", "label": "下書き" }
+  ]
+}
+```
+
+并把 `categoryMap/statusMap` 也同步改成 `LONG/MEDIUM/SHORT/DYE` 与 `DRAFT/PUBLIC`。
+
+- [ ] **Step 6: 跑前端测试直到变绿**
+
+Run: `npm test -- src/pages/dashboard/templates/__tests__/TemplateListPage.test.tsx`  
+Expected: PASS,确认:
+- `用途` 已删除
+- 初次请求带 `page=1&pageSize=8`
+- 条件变化重置到第一页
+- 分页区域始终显示
+- 任务分流逻辑保持正确
+
+- [ ] **Step 7: 跑模板列表相关类型检查与回归**
+
+Run: `npm run typecheck`  
+Expected: PASS,无新增 TS 错误。
+
+Run: `npm test -- src/pages/dashboard/templates/__tests__/TemplateListPage.test.tsx src/__tests__/App.test.tsx`  
+Expected: PASS,列表页和已有模板路由不回退。
+
+- [ ] **Step 8: 提交当前任务**
+
+```bash
+git add frontend/src/pages/dashboard/templates/TemplateListPage.tsx \
+  frontend/src/pages/dashboard/templates/__tests__/TemplateListPage.test.tsx \
+  frontend/src/i18n/locales/zh/translation.json \
+  frontend/src/i18n/locales/ja/translation.json
+git commit -m "feat: wire template list filters and pagination"
+```
+
+## Task 5: 全量验证与收尾
+
+**Files:**
+- Modify: 无代码新增,必要时只做小修正
+
+**Interfaces:**
+- Consumes: 前 4 个任务的全部代码与测试
+- Produces: 最终可联调的模板列表真实查询与分页能力
+
+- [ ] **Step 1: 跑后端模板模块验证**
+
+Run: `npm test -- src/modules/templates/templates.service.spec.ts test/templates.e2e-spec.ts --runInBand`  
+Expected: PASS,模板素材、任务流、编辑页接口、列表查询分页全部通过。
+
+- [ ] **Step 2: 跑前端模板模块验证**
+
+Run: `npm test -- src/pages/dashboard/templates/__tests__/TemplateFormPage.test.tsx src/pages/dashboard/templates/__tests__/TemplateListPage.test.tsx src/pages/dashboard/templates/__tests__/TemplateMakeTaskPage.test.tsx src/pages/dashboard/templates/__tests__/TemplateEditPage.test.tsx src/pages/dashboard/templates/__tests__/TemplateDetailPage.test.tsx src/__tests__/App.test.tsx`  
+Expected: PASS,模板表单、列表、任务页、编辑页、详情页和路由测试全部通过。
+
+- [ ] **Step 3: 跑前端类型检查**
+
+Run: `npm run typecheck`  
+Expected: PASS
+
+- [ ] **Step 4: 检查最近改动文件诊断**
+
+使用 IDE diagnostics 检查:
+
+```text
+frontend/src/pages/dashboard/templates/TemplateListPage.tsx
+frontend/src/services/api/templates.ts
+frontend/src/services/api/contracts.ts
+backend/src/modules/templates/templates.service.ts
+backend/src/modules/templates/dto/list-templates-query.dto.ts
+```
+
+Expected: 无新增 error 级问题。
+
+- [ ] **Step 5: 提交最终收尾**
+
+```bash
+git add backend/src/modules/templates/dto/list-templates-query.dto.ts \
+  backend/src/modules/templates/templates.controller.ts \
+  backend/src/modules/templates/templates.service.ts \
+  backend/src/modules/templates/templates.service.spec.ts \
+  backend/test/templates.e2e-spec.ts \
+  frontend/src/services/api/contracts.ts \
+  frontend/src/services/api/templates.ts \
+  frontend/src/pages/dashboard/templates/TemplateListPage.tsx \
+  frontend/src/pages/dashboard/templates/__tests__/TemplateListPage.test.tsx \
+  frontend/src/i18n/locales/zh/translation.json \
+  frontend/src/i18n/locales/ja/translation.json
+git commit -m "feat: add real template list query and pagination"
+```
+
+## Self-Review
+
+- Spec coverage:
+  - 真实后端列表:Task 1、Task 2
+  - 删除用途筛选:Task 1 DTO、Task 3/4 前端契约与 UI
+  - 真实分页:Task 1 后端分页、Task 4 前端分页
+  - 即使单页也显示分页:Task 4 分页渲染与测试
+  - 保持任务页/详情页分流:Task 3/4 测试与实现
+- Placeholder scan:
+  - 已去除 `TODO/TBD/类似 Task N` 之类占位描述
+  - 每个代码步骤都给出具体代码或命令
+- Type consistency:
+  - 后端列表统一输出 `category: LONG|MEDIUM|SHORT|DYE`、`status: DRAFT|PUBLIC`
+  - 前端 `TemplateListPage` 与 i18n `categoryMap/statusMap` 同步到同一枚举值
+

+ 1132 - 0
docs/superpowers/plans/2026-06-28-template-make-task.md

@@ -0,0 +1,1132 @@
+# 发型模板制作任务 Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** 构建真实可用的发型模板制作闭环,支持动态素材、模板制作任务页、step1/step2 AI 编排,以及成功后回写 `hair_template`。
+
+**Architecture:** 后端以 `hair_template` 作为模板实体、以 `hair_template_make_task` 作为制作过程状态源;前端创建模板后进入新的任务详情页,持续轮询本系统后端;后端在任务详情查询时刷新 step1/step2 外部 AI 任务状态并推进下一阶段,避免引入额外的后台 worker。
+
+**Tech Stack:** NestJS 11、Prisma 6、Jest、React 19、React Router 7、Vitest、Testing Library
+
+## Global Constraints
+
+- 数据库语义必须严格遵循 `backend/docs/数据库设计.md` 中的 `hair_template`、`hair_template_make_task`、`system_config` 定义。
+- 本次不新增后台常驻 worker、消息队列或定时补偿任务。
+- 模板新建页沿用现有 UI 骨架,不做大重构。
+- 参考发型图片上传类型固定为 `ai-template-origin-image`。
+- 模特、服装、背景都采用“预设或上传二选一”,最终统一提交 URL。
+- 模特来自 `system_config.config_group = 'ai-model'`,服装来自 `clothing`,背景来自 `background`。
+- step1 模板地址来自 `system_config.config_group = 'template-step1'` 的 `config_value`。
+- `backend/src/step2` 只保存静态 `config.json` / `content.json`,运行时文件必须写入系统临时目录。
+- step2 成功后才回写 `hair_template.cover_url` 与 `hair_template.template_url`,此前模板不可视为可用模板。
+- 前端只轮询本系统后端,不能直连 AI 服务。
+- 测试优先覆盖真实回归风险:创建双写、详情推进、前端轮询、动态素材提交。
+
+---
+
+## File Structure
+
+### Backend
+
+- Modify: `backend/prisma/schema.prisma`
+  - 补 `SystemConfig` Prisma model,并为模板任务链路补必要的 enum/字段映射引用。
+- Modify: `backend/src/modules/templates/templates.module.ts`
+  - 注入 `ConfigModule`、`StorageModule`、`PrismaModule`、AI client 和任务 runner。
+- Modify: `backend/src/modules/templates/templates.controller.ts`
+  - 承载 `GET /templates/assets`、`POST /templates/make-tasks`、`GET /templates/make-tasks/:taskId`、模板列表/详情接口入口。
+- Modify: `backend/src/modules/templates/templates.service.ts`
+  - 承载素材查询、创建任务、查询任务详情、模板列表/详情查询。
+- Create: `backend/src/modules/templates/template-ai.client.ts`
+  - 抽象可复用的 `executeTemplate()` 和 `getResult()`。
+- Create: `backend/src/modules/templates/template-task-runner.ts`
+  - 负责 step1 发起、step1 刷新、step2 组包发起、step2 刷新与回写。
+- Create: `backend/src/modules/templates/template-task-files.ts`
+  - 负责临时目录、下载文件、复制 step2 静态文件、打 zip、清理目录。
+- Create: `backend/src/modules/templates/dto/get-template-assets.dto.ts`
+  - 定义素材列表返回 DTO。
+- Create: `backend/src/modules/templates/dto/create-template-make-task.dto.ts`
+  - 定义创建任务请求 DTO。
+- Create: `backend/src/modules/templates/dto/template-make-task-detail.dto.ts`
+  - 定义任务详情返回 DTO。
+- Create: `backend/src/modules/templates/dto/list-templates-item.dto.ts`
+  - 收敛模板列表项含任务状态的返回结构。
+- Create: `backend/src/modules/templates/templates.service.spec.ts`
+  - 单元测试模板创建、状态推进与失败路径。
+- Create: `backend/test/templates.e2e-spec.ts`
+  - E2E 覆盖素材接口、创建任务、查询任务详情。
+
+### Frontend
+
+- Modify: `frontend/src/config/routes.ts`
+  - 增加模板任务页路由。
+- Modify: `frontend/src/App.tsx`
+  - 注册新路由。
+- Modify: `frontend/src/services/api/contracts.ts`
+  - 增加模板素材、创建任务、任务详情相关类型,并为模板列表增加 `makeTaskId` / `makeTaskStatus`。
+- Modify: `frontend/src/services/api/templates.ts`
+  - 增加 `getTemplateAssets()`、`createTemplateMakeTask()`、`getTemplateMakeTaskById()`。
+- Modify: `frontend/src/pages/dashboard/templates/TemplateFormPage.tsx`
+  - 改为动态素材与上传切换,提交后跳任务页。
+- Create: `frontend/src/pages/dashboard/templates/TemplateMakeTaskPage.tsx`
+  - 展示任务进度并轮询。
+- Create: `frontend/src/pages/dashboard/templates/__tests__/TemplateFormPage.test.tsx`
+  - 覆盖动态素材、上传切换、提交跳转。
+- Create: `frontend/src/pages/dashboard/templates/__tests__/TemplateMakeTaskPage.test.tsx`
+  - 覆盖处理中轮询、成功停止轮询、失败展示。
+- Modify: `frontend/src/pages/dashboard/templates/TemplateListPage.tsx`
+  - 根据模板任务状态决定跳任务页或模板详情页。
+- Modify: `frontend/src/__tests__/App.test.tsx`
+  - 覆盖模板任务页路由。
+- Modify: `frontend/src/i18n/locales/zh/translation.json`
+- Modify: `frontend/src/i18n/locales/ja/translation.json`
+  - 增加任务页、动态素材、失败文案。
+- Modify: `frontend/public/css/aitori.css`
+  - 追加模板任务页与素材来源切换样式。
+
+## Task 1: 搭好后端模板任务基础设施
+
+**Files:**
+- Create: `backend/src/modules/templates/template-ai.client.ts`
+- Create: `backend/src/modules/templates/dto/get-template-assets.dto.ts`
+- Create: `backend/src/modules/templates/dto/create-template-make-task.dto.ts`
+- Create: `backend/src/modules/templates/dto/template-make-task-detail.dto.ts`
+- Modify: `backend/prisma/schema.prisma`
+- Modify: `backend/src/modules/templates/templates.module.ts`
+- Test: `backend/src/modules/templates/templates.service.spec.ts`
+
+**Interfaces:**
+- Consumes: `PrismaService`, `ConfigService`, `StorageService`
+- Produces: `TemplateAiClient.executeTemplate(template: string, inputs: Record<string, unknown>): Promise<{ taskId: string }>`
+- Produces: `TemplateAiClient.getResult(taskId: string): Promise<{ status: 'processing' | 'successes' | 'failed'; message: string; images: string[] }>`
+- Produces: `CreateTemplateMakeTaskDto`
+- Produces: `TemplateAssetsDto`
+- Produces: Prisma model `SystemConfig`
+
+- [ ] **Step 1: 写后端单元测试骨架,锁定将来依赖的接口名**
+
+```ts
+describe('TemplatesService', () => {
+  it('lists grouped template assets from system_config', async () => {
+    const result = await service.getTemplateAssets();
+
+    expect(result).toEqual({
+      models: expect.any(Array),
+      clothes: expect.any(Array),
+      backgrounds: expect.any(Array),
+    });
+  });
+});
+```
+
+Run: `cd /Users/yuchen/Documents/hairstylist/backend && npm test -- --runInBand src/modules/templates/templates.service.spec.ts`
+Expected: FAIL with `Cannot find module './templates.service.spec.ts'` or missing method/type errors.
+
+- [ ] **Step 2: 在 Prisma schema 中补 `SystemConfig` model**
+
+```prisma
+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")
+}
+```
+
+Run: `cd /Users/yuchen/Documents/hairstylist/backend && npm run prisma:validate`
+Expected: PASS with `The schema at prisma/schema.prisma is valid`.
+
+- [ ] **Step 3: 创建模板任务 DTO 与通用 AI client**
+
+```ts
+export class CreateTemplateMakeTaskDto {
+  @IsString()
+  templateName!: string;
+
+  @IsIn(['LONG', 'MEDIUM', 'SHORT', 'DYE'])
+  category!: 'LONG' | 'MEDIUM' | 'SHORT' | 'DYE';
+
+  @IsIn(['DRAFT', 'PUBLIC'])
+  status!: 'DRAFT' | 'PUBLIC';
+
+  @IsOptional()
+  @IsString()
+  description?: string;
+
+  @IsOptional()
+  @IsArray()
+  @IsString({ each: true })
+  tags?: string[];
+
+  @IsUrl()
+  refHairUrl!: string;
+
+  @IsUrl()
+  modelUrl!: string;
+
+  @IsUrl()
+  clothesUrl!: string;
+
+  @IsUrl()
+  backgroundUrl!: string;
+}
+```
+
+```ts
+@Injectable()
+export class TemplateAiClient {
+  constructor(private readonly configService: ConfigService) {}
+
+  async executeTemplate(template: string, inputs: Record<string, unknown>) {
+    const response = await fetch(`${this.configService.getOrThrow<string>('aiSimulation.baseUrl')}/execute`, {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({ template, inputs }),
+    });
+
+    if (!response.ok) {
+      throw new ServiceUnavailableException('AI 模板任务创建失败');
+    }
+
+    const data = (await response.json()) as { taskId?: string };
+    if (!data.taskId) {
+      throw new ServiceUnavailableException('AI 模板任务创建失败');
+    }
+
+    return { taskId: data.taskId };
+  }
+}
+```
+
+- [ ] **Step 4: 把新依赖接入 `TemplatesModule`**
+
+```ts
+@Module({
+  imports: [AuthModule, PrismaModule, ConfigModule, StorageModule],
+  controllers: [TemplatesController],
+  providers: [TemplatesService, TemplateAiClient, TemplateTaskRunner],
+})
+export class TemplatesModule {}
+```
+
+- [ ] **Step 5: 让测试通过到“素材接口可被定义”的程度**
+
+```ts
+async getTemplateAssets() {
+  return {
+    models: [],
+    clothes: [],
+    backgrounds: [],
+  };
+}
+```
+
+Run: `cd /Users/yuchen/Documents/hairstylist/backend && npm test -- --runInBand src/modules/templates/templates.service.spec.ts`
+Expected: PASS for the new skeleton test.
+
+- [ ] **Step 6: 提交本任务**
+
+```bash
+cd /Users/yuchen/Documents/hairstylist
+git add backend/prisma/schema.prisma backend/src/modules/templates
+git commit -m "feat: scaffold template make task backend"
+```
+
+## Task 2: 实现后端素材接口与创建任务双写
+
+**Files:**
+- Modify: `backend/src/modules/templates/templates.controller.ts`
+- Modify: `backend/src/modules/templates/templates.service.ts`
+- Modify: `backend/src/modules/templates/templates.service.spec.ts`
+- Create: `backend/src/modules/templates/dto/list-templates-item.dto.ts`
+- Test: `backend/test/templates.e2e-spec.ts`
+
+**Interfaces:**
+- Consumes: `CreateTemplateMakeTaskDto`, `TemplateAiClient.executeTemplate()`
+- Produces: `TemplatesService.getTemplateAssets(): Promise<{ models: AssetOptionDto[]; clothes: AssetOptionDto[]; backgrounds: AssetOptionDto[] }>`
+- Produces: `TemplatesService.createTemplateMakeTask(stylistId: bigint, dto: CreateTemplateMakeTaskDto): Promise<{ templateId: string; taskId: string; totalTaskId: string; taskStatus: 'WAIT' | 'PROCESSING' | 'FAILED' }>`
+- Produces: `GET /api/v1/templates/assets`
+- Produces: `POST /api/v1/templates/make-tasks`
+
+- [ ] **Step 1: 先写创建任务单元测试**
+
+```ts
+it('creates hair_template and hair_template_make_task, then starts step1', async () => {
+  prisma.systemConfig.findMany.mockResolvedValue([
+    { id: BigInt(9), configGroup: 'template-step1', configValue: 'https://cdn.example.com/step1.json', isDeleted: 0 },
+  ]);
+  aiClient.executeTemplate.mockResolvedValue({ taskId: 'step1-task-1' });
+  prisma.$transaction.mockImplementation(async (callback) =>
+    callback({
+      hairTemplate: { create: jest.fn().mockResolvedValue({ id: BigInt(101) }) },
+      hairTemplateMakeTask: { create: jest.fn().mockResolvedValue({ id: BigInt(88) }) },
+    }),
+  );
+
+  const result = await service.createTemplateMakeTask(BigInt(1), dto);
+
+  expect(result).toEqual({
+    templateId: '101',
+    taskId: '88',
+    totalTaskId: expect.stringContaining('tmpl_'),
+    taskStatus: 'PROCESSING',
+  });
+});
+```
+
+Run: `cd /Users/yuchen/Documents/hairstylist/backend && npm test -- --runInBand src/modules/templates/templates.service.spec.ts`
+Expected: FAIL with `createTemplateMakeTask is not a function` or unmet mock expectations.
+
+- [ ] **Step 2: 实现素材接口**
+
+```ts
+async getTemplateAssets() {
+  const rows = await this.prisma.systemConfig.findMany({
+    where: {
+      isDeleted: 0,
+      configGroup: { in: ['ai-model', 'clothing', 'background'] },
+    },
+    orderBy: [{ sort: 'asc' }, { id: 'asc' }],
+  });
+
+  const mapRow = (row: SystemConfig) => ({
+    id: row.id.toString(),
+    name: row.configName,
+    url: row.configValue ?? '',
+    configKey: row.configKey,
+  });
+
+  return {
+    models: rows.filter((row) => row.configGroup === 'ai-model').map(mapRow),
+    clothes: rows.filter((row) => row.configGroup === 'clothing').map(mapRow),
+    backgrounds: rows.filter((row) => row.configGroup === 'background').map(mapRow),
+  };
+}
+```
+
+- [ ] **Step 3: 实现创建任务双写与 step1 发起**
+
+```ts
+async createTemplateMakeTask(stylistId: bigint, dto: CreateTemplateMakeTaskDto) {
+  const step1Template = 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: step1Template, 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(step1Template, {
+    image: dto.refHairUrl,
+  });
+
+  await this.prisma.hairTemplateMakeTask.update({
+    where: { id: created.task.id },
+    data: {
+      step1TaskId: step1.taskId,
+      taskStatus: 'PROCESSING',
+      step1Resource: JSON.stringify({ templateUrl: step1Template, lastError: null }),
+    },
+  });
+
+  return {
+    templateId: created.template.id.toString(),
+    taskId: created.task.id.toString(),
+    totalTaskId,
+    taskStatus: 'PROCESSING' as const,
+  };
+}
+```
+
+- [ ] **Step 4: 暴露控制器接口**
+
+```ts
+@UseGuards(JwtAuthGuard)
+@Controller('templates')
+export class TemplatesController {
+  @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);
+  }
+}
+```
+
+- [ ] **Step 5: 补 E2E 测试**
+
+```ts
+it('/api/v1/templates/assets (GET) returns grouped asset options', async () => {
+  const token = await signToken();
+
+  await prisma.systemConfig.createMany({
+    data: [
+      { configKey: 'e2e-model', configName: '模特', configGroup: 'ai-model', configValue: 'https://cdn.example.com/model.jpg' },
+      { configKey: 'e2e-clothes', configName: '服装', configGroup: 'clothing', configValue: 'https://cdn.example.com/clothes.jpg' },
+      { configKey: 'e2e-background', configName: '背景', configGroup: 'background', configValue: 'https://cdn.example.com/background.jpg' },
+    ] as never,
+  });
+
+  const response = await request(app.getHttpServer())
+    .get('/api/v1/templates/assets')
+    .set('Authorization', `Bearer ${token}`)
+    .expect(200);
+
+  expect(response.body.data.models[0]).toMatchObject({ name: '模特' });
+});
+```
+
+```ts
+it('/api/v1/templates/make-tasks (POST) creates template and task rows', async () => {
+  const token = await signToken();
+  executeTemplate.mockResolvedValueOnce({ taskId: 'template-e2e-step1' });
+
+  const response = await request(app.getHttpServer())
+    .post('/api/v1/templates/make-tasks')
+    .set('Authorization', `Bearer ${token}`)
+    .send({
+      templateName: 'e2e-template',
+      category: 'LONG',
+      status: 'DRAFT',
+      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.taskStatus).toBe('PROCESSING');
+});
+```
+
+Run: `cd /Users/yuchen/Documents/hairstylist/backend && npm test -- --runInBand src/modules/templates/templates.service.spec.ts && npm run test:e2e -- templates.e2e-spec.ts`
+Expected: PASS for assets/create task tests.
+
+- [ ] **Step 6: 提交本任务**
+
+```bash
+cd /Users/yuchen/Documents/hairstylist
+git add backend/src/modules/templates backend/test/templates.e2e-spec.ts
+git commit -m "feat: add template make task creation APIs"
+```
+
+## Task 3: 实现 step1/step2 推进、zip 组装与任务详情接口
+
+**Files:**
+- Create: `backend/src/modules/templates/template-task-files.ts`
+- Create: `backend/src/modules/templates/template-task-runner.ts`
+- Modify: `backend/src/modules/templates/templates.service.ts`
+- Modify: `backend/src/modules/templates/templates.controller.ts`
+- Modify: `backend/src/modules/templates/templates.service.spec.ts`
+- Modify: `backend/test/templates.e2e-spec.ts`
+
+**Interfaces:**
+- Consumes: `TemplateAiClient.getResult()`, `StorageService.uploadBuffer()`
+- Produces: `TemplateTaskRunner.refreshTask(stylistId: bigint, taskId: string): Promise<TemplateMakeTaskDetailDto>`
+- Produces: `GET /api/v1/templates/make-tasks/:taskId`
+- Produces: helper `prepareStep2Bundle(input: PrepareStep2BundleInput): Promise<{ zipUrl: string; localFiles: Record<string, string> }>`
+
+- [ ] **Step 1: 先写任务推进单元测试**
+
+```ts
+it('promotes a task from PROCESSING to STEP1_SUCCESS when step1 finishes', async () => {
+  prisma.hairTemplateMakeTask.findFirst.mockResolvedValue({
+    id: BigInt(88),
+    templateId: BigInt(101),
+    taskStatus: 'PROCESSING',
+    step1TaskId: 'step1-task-1',
+    step2TaskId: '',
+    step1ResultImg: '',
+    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',
+    step1Resource: JSON.stringify({ templateUrl: 'https://cdn.example.com/step1.json' }),
+    step2Resource: JSON.stringify({}),
+    updateTime: new Date(Date.now() - 20_000),
+  });
+  aiClient.getResult.mockResolvedValue({
+    status: 'successes',
+    message: '',
+    images: ['https://cdn.example.com/step1-result.jpg'],
+  });
+  fileRunner.downloadToTempFile.mockResolvedValue('/tmp/task-1/hairstyle.jpg');
+
+  const detail = await service.getTemplateMakeTaskDetail(BigInt(1), '88');
+
+  expect(prisma.hairTemplateMakeTask.update).toHaveBeenCalledWith(
+    expect.objectContaining({
+      data: expect.objectContaining({
+        taskStatus: 'STEP1_SUCCESS',
+        step1ResultImg: 'https://cdn.example.com/step1-result.jpg',
+      }),
+    }),
+  );
+  expect(detail.step1.resultImage).toBe('https://cdn.example.com/step1-result.jpg');
+});
+```
+
+Run: `cd /Users/yuchen/Documents/hairstylist/backend && npm test -- --runInBand src/modules/templates/templates.service.spec.ts`
+Expected: FAIL because `getTemplateMakeTaskDetail` and file helpers are missing.
+
+- [ ] **Step 2: 实现临时文件和 zip 组装 helper**
+
+```ts
+export async function prepareStep2Bundle(input: PrepareStep2BundleInput) {
+  const workDir = await mkdtemp(join(tmpdir(), `template-make-${input.totalTaskId}-`));
+
+  await copyFile(input.step2ConfigPath, join(workDir, 'config.json'));
+  await copyFile(input.step2ContentPath, join(workDir, 'content.json'));
+  await downloadFile(input.step1ResultImg, join(workDir, 'hairstyle.jpg'));
+  await downloadFile(input.clothesUrl, join(workDir, 'input-outfit.jpg'));
+  await downloadFile(input.backgroundUrl, join(workDir, 'input-background.jpg'));
+
+  const zipPath = join(workDir, 'template.zip');
+  await zipDirectory(workDir, zipPath, ['template.zip']);
+
+  return {
+    workDir,
+    zipPath,
+    localFiles: {
+      step1Image: join(workDir, 'hairstyle.jpg'),
+      clothes: join(workDir, 'input-outfit.jpg'),
+      background: join(workDir, 'input-background.jpg'),
+      zip: zipPath,
+    },
+  };
+}
+```
+
+- [ ] **Step 3: 实现 `TemplateTaskRunner.refreshTask()`**
+
+```ts
+async refreshTask(stylistId: bigint, taskId: string) {
+  const task = await this.getOwnedTask(stylistId, taskId);
+
+  if (task.taskStatus === 'WAIT') {
+    return this.tryStartStep1(task);
+  }
+
+  if (task.taskStatus === 'PROCESSING' && task.step1TaskId && !task.step1ResultImg) {
+    return this.refreshStep1(task);
+  }
+
+  if (task.taskStatus === 'STEP1_SUCCESS' && !task.step2TaskId) {
+    return this.startStep2(task);
+  }
+
+  if (task.taskStatus === 'PROCESSING' && task.step2TaskId) {
+    return this.refreshStep2(task);
+  }
+
+  return this.buildDetail(task);
+}
+```
+
+- [ ] **Step 4: 在 step2 成功时回写 `hair_template`**
+
+```ts
+await this.prisma.$transaction([
+  this.prisma.hairTemplate.update({
+    where: { id: task.templateId! },
+    data: {
+      coverUrl: resultImage,
+      templateUrl: uploadedZipUrl,
+    },
+  }),
+  this.prisma.hairTemplateMakeTask.update({
+    where: { id: task.id },
+    data: {
+      taskStatus: 'STEP2_SUCCESS',
+      step2ResultImg: resultImage,
+      step2Resource: JSON.stringify({
+        uploadedZipUrl,
+        resultImage,
+        lastError: null,
+      }),
+    },
+  }),
+]);
+```
+
+- [ ] **Step 5: 暴露详情接口并补 e2e**
+
+```ts
+@Get('make-tasks/:taskId')
+detail(@Req() request: AuthenticatedRequest, @Param('taskId') taskId: string) {
+  return this.templatesService.getTemplateMakeTaskDetail(BigInt(request.user.stylistId), taskId);
+}
+```
+
+```ts
+it('/api/v1/templates/make-tasks/:taskId (GET) promotes step1 and step2 states', async () => {
+  getResult
+    .mockResolvedValueOnce({ status: 'successes', message: '', images: ['https://cdn.example.com/step1.jpg'] })
+    .mockResolvedValueOnce({ status: 'successes', message: '', images: ['https://cdn.example.com/final-cover.jpg'] });
+
+  const response = await request(app.getHttpServer())
+    .get(`/api/v1/templates/make-tasks/${taskId}`)
+    .set('Authorization', `Bearer ${token}`)
+    .expect(200);
+
+  expect(response.body.data.taskStatus).toMatch(/STEP1_SUCCESS|PROCESSING|STEP2_SUCCESS/);
+});
+```
+
+Run: `cd /Users/yuchen/Documents/hairstylist/backend && npm test -- --runInBand src/modules/templates/templates.service.spec.ts && npm run test:e2e -- templates.e2e-spec.ts`
+Expected: PASS for step promotion, bundle upload, and detail endpoint tests.
+
+- [ ] **Step 6: 提交本任务**
+
+```bash
+cd /Users/yuchen/Documents/hairstylist
+git add backend/src/modules/templates backend/test/templates.e2e-spec.ts
+git commit -m "feat: add template make task polling workflow"
+```
+
+## Task 4: 接入前端 API、路由与任务页骨架
+
+**Files:**
+- Modify: `frontend/src/config/routes.ts`
+- Modify: `frontend/src/App.tsx`
+- Modify: `frontend/src/services/api/contracts.ts`
+- Modify: `frontend/src/services/api/templates.ts`
+- Create: `frontend/src/pages/dashboard/templates/TemplateMakeTaskPage.tsx`
+- Modify: `frontend/src/__tests__/App.test.tsx`
+
+**Interfaces:**
+- Consumes: `GET /templates/assets`, `POST /templates/make-tasks`, `GET /templates/make-tasks/:taskId`
+- Produces: `TemplateAssetOption`, `TemplateMakeTaskCreateResult`, `TemplateMakeTaskDetail`
+- Produces: route `/:locale/templates/tasks/:taskId`
+
+- [ ] **Step 1: 先写前端路由测试**
+
+```tsx
+it('renders template make task route', async () => {
+  window.history.replaceState({}, '', '/zh/templates/tasks/88');
+
+  render(<App />);
+
+  await waitFor(() => {
+    expect(screen.getByText('template-make-task-page')).toBeInTheDocument();
+  });
+});
+```
+
+Run: `cd /Users/yuchen/Documents/hairstylist/frontend && npm test -- src/__tests__/App.test.tsx`
+Expected: FAIL because the new page route is not registered.
+
+- [ ] **Step 2: 扩展 contracts 与 API 方法**
+
+```ts
+export type TemplateAssetOption = {
+  id: string;
+  name: string;
+  url: string;
+  configKey: string;
+};
+
+export type TemplateMakeTaskDetail = {
+  id: string;
+  templateId: string;
+  templateName: string;
+  taskStatus: 'WAIT' | 'PROCESSING' | 'STEP1_SUCCESS' | 'STEP2_SUCCESS' | 'FAILED';
+  errorMessage: string | null;
+  createTime: string;
+  refHairUrl: string;
+  modelUrl: string;
+  clothesUrl: string;
+  backgroundUrl: string;
+  step1: {
+    taskId: string;
+    status: string;
+    templateUrl: string | null;
+    resultImage: string | null;
+  };
+  step2: {
+    taskId: string;
+    status: string;
+    bundleUrl: string | null;
+    resultImage: string | null;
+  };
+  template: {
+    coverUrl: string;
+    templateUrl: string;
+    status: 'DRAFT' | 'PUBLIC';
+  };
+};
+
+export type TemplateEntity = {
+  id: string;
+  name: string;
+  category: string;
+  status: string;
+  description: string;
+  tags: string[];
+  image?: string;
+  makeTaskId?: string;
+  makeTaskStatus?: 'WAIT' | 'PROCESSING' | 'STEP1_SUCCESS' | 'STEP2_SUCCESS' | 'FAILED';
+  templateUrl?: string;
+};
+```
+
+```ts
+export async function getTemplateAssets() {
+  return http.get<{ models: TemplateAssetOption[]; clothes: TemplateAssetOption[]; backgrounds: TemplateAssetOption[] }>('/templates/assets');
+}
+
+export async function createTemplateMakeTask(input: CreateTemplateMakeTaskInput) {
+  return http.post<TemplateMakeTaskCreateResult>('/templates/make-tasks', input);
+}
+
+export async function getTemplateMakeTaskById(taskId: string) {
+  return http.get<TemplateMakeTaskDetail>(`/templates/make-tasks/${encodeURIComponent(taskId)}`);
+}
+```
+
+- [ ] **Step 3: 注册路由与占位页面**
+
+```tsx
+export function TemplateMakeTaskPage() {
+  return <div>template-make-task-page</div>;
+}
+```
+
+```tsx
+<Route
+  path={routes.templateTaskDetail}
+  element={
+    <RequireAuth>
+      <TemplateMakeTaskPage />
+    </RequireAuth>
+  }
+/>
+```
+
+```ts
+templateTaskDetail: '/:locale/templates/tasks/:taskId',
+```
+
+- [ ] **Step 4: 让路由测试通过**
+
+Run: `cd /Users/yuchen/Documents/hairstylist/frontend && npm test -- src/__tests__/App.test.tsx`
+Expected: PASS with the new route rendered.
+
+- [ ] **Step 5: 提交本任务**
+
+```bash
+cd /Users/yuchen/Documents/hairstylist
+git add frontend/src/config/routes.ts frontend/src/App.tsx frontend/src/services/api frontend/src/pages/dashboard/templates/TemplateMakeTaskPage.tsx frontend/src/__tests__/App.test.tsx
+git commit -m "feat: scaffold template make task frontend route"
+```
+
+## Task 5: 改造模板新建页为动态素材与任务跳转
+
+**Files:**
+- Modify: `frontend/src/pages/dashboard/templates/TemplateFormPage.tsx`
+- Create: `frontend/src/pages/dashboard/templates/__tests__/TemplateFormPage.test.tsx`
+- Modify: `frontend/src/services/api/media.ts`
+- Modify: `frontend/src/i18n/locales/zh/translation.json`
+- Modify: `frontend/src/i18n/locales/ja/translation.json`
+- Modify: `frontend/public/css/aitori.css`
+
+**Interfaces:**
+- Consumes: `uploadMedia(file, type)`, `getTemplateAssets()`, `createTemplateMakeTask()`
+- Produces: form submit payload `{ templateName, category, status, description, tags, refHairUrl, modelUrl, clothesUrl, backgroundUrl }`
+- Produces: custom upload media type `ai-template-origin-image`
+
+- [ ] **Step 1: 先写模板新建页测试**
+
+```tsx
+it('loads dynamic assets, uploads custom files, and redirects to the task page', async () => {
+  getTemplateAssets.mockResolvedValue({
+    models: [{ id: 'm1', name: '模特A', url: 'https://cdn.example.com/model-a.jpg', configKey: 'm1' }],
+    clothes: [{ id: 'c1', name: '服装A', url: 'https://cdn.example.com/clothes-a.jpg', configKey: 'c1' }],
+    backgrounds: [{ id: 'b1', name: '背景A', url: 'https://cdn.example.com/background-a.jpg', configKey: 'b1' }],
+  });
+  uploadMedia.mockResolvedValue({ url: 'https://cdn.example.com/uploaded-model.jpg' });
+  createTemplateMakeTask.mockResolvedValue({ taskId: '88', templateId: '101', totalTaskId: 'tmpl_x', taskStatus: 'PROCESSING' });
+
+  render(<TemplateFormPage />);
+
+  expect(await screen.findByText('模特A')).toBeInTheDocument();
+  await userEvent.click(screen.getByRole('radio', { name: '上传自定义模特' }));
+  await userEvent.upload(screen.getByLabelText('上传模特图片'), new File(['x'], 'model.jpg', { type: 'image/jpeg' }));
+  await userEvent.click(screen.getByRole('button', { name: 'AI生成' }));
+
+  await waitFor(() => {
+    expect(createTemplateMakeTask).toHaveBeenCalledWith(expect.objectContaining({
+      modelUrl: 'https://cdn.example.com/uploaded-model.jpg',
+    }));
+    expect(navigate).toHaveBeenCalledWith('/zh/templates/tasks/88');
+  });
+});
+```
+
+Run: `cd /Users/yuchen/Documents/hairstylist/frontend && npm test -- src/pages/dashboard/templates/__tests__/TemplateFormPage.test.tsx`
+Expected: FAIL because `getTemplateAssets` and task redirect behavior do not exist.
+
+- [ ] **Step 2: 调整上传类型和素材加载逻辑**
+
+```ts
+const [assets, setAssets] = useState<{ models: TemplateAssetOption[]; clothes: TemplateAssetOption[]; backgrounds: TemplateAssetOption[] }>({
+  models: [],
+  clothes: [],
+  backgrounds: [],
+});
+
+useEffect(() => {
+  getTemplateAssets().then(setAssets).catch(() => setAssets({ models: [], clothes: [], backgrounds: [] }));
+}, []);
+
+const referenceImage = (await uploadMedia(referenceFile, 'ai-template-origin-image')).url;
+```
+
+- [ ] **Step 3: 为模特、服装、背景增加“预设/上传”切换**
+
+```tsx
+type AssetSource = 'preset' | 'upload';
+
+const [modelSource, setModelSource] = useState<AssetSource>('preset');
+const [uploadedModelUrl, setUploadedModelUrl] = useState('');
+
+const finalModelUrl =
+  modelSource === 'upload'
+    ? uploadedModelUrl
+    : assets.models.find((item) => item.id === selectedModelId)?.url ?? '';
+```
+
+```tsx
+<button type="button" onClick={() => setModelSource('preset')}>使用预设</button>
+<button type="button" onClick={() => setModelSource('upload')}>上传自定义模特</button>
+```
+
+- [ ] **Step 4: 改为调用创建任务接口并跳任务页**
+
+```ts
+const task = await createTemplateMakeTask({
+  templateName: name,
+  category:
+    category === 'Long'
+      ? 'LONG'
+      : category === 'Medium'
+        ? 'MEDIUM'
+        : category === 'Short'
+          ? 'SHORT'
+          : 'DYE',
+  status: status === 'Published' ? 'PUBLIC' : 'DRAFT',
+  description,
+  tags: tags.split(',').map((tag) => tag.trim()).filter(Boolean),
+  refHairUrl: referenceImage,
+  modelUrl: finalModelUrl,
+  clothesUrl: finalClothesUrl,
+  backgroundUrl: finalBackgroundUrl,
+});
+
+navigate(path(`/templates/tasks/${task.taskId}`));
+```
+
+- [ ] **Step 5: 更新文案与样式**
+
+```json
+"assetSource": {
+  "preset": "使用预设",
+  "upload": "上传自定义"
+}
+```
+
+```css
+.template-source-toggle {
+  display: flex;
+  gap: 12px;
+  margin-bottom: 16px;
+}
+```
+
+- [ ] **Step 6: 运行测试**
+
+Run: `cd /Users/yuchen/Documents/hairstylist/frontend && npm test -- src/pages/dashboard/templates/__tests__/TemplateFormPage.test.tsx && npm run typecheck`
+Expected: PASS with dynamic asset and redirect behavior verified.
+
+- [ ] **Step 7: 提交本任务**
+
+```bash
+cd /Users/yuchen/Documents/hairstylist
+git add frontend/src/pages/dashboard/templates/TemplateFormPage.tsx frontend/src/pages/dashboard/templates/__tests__/TemplateFormPage.test.tsx frontend/src/services/api/media.ts frontend/src/i18n/locales frontend/public/css/aitori.css
+git commit -m "feat: wire template form to make task flow"
+```
+
+## Task 6: 完成模板任务页轮询与模板列表跳转分流
+
+**Files:**
+- Modify: `frontend/src/pages/dashboard/templates/TemplateMakeTaskPage.tsx`
+- Create: `frontend/src/pages/dashboard/templates/__tests__/TemplateMakeTaskPage.test.tsx`
+- Modify: `frontend/src/pages/dashboard/templates/TemplateListPage.tsx`
+- Modify: `frontend/src/i18n/locales/zh/translation.json`
+- Modify: `frontend/src/i18n/locales/ja/translation.json`
+- Modify: `frontend/public/css/aitori.css`
+
+**Interfaces:**
+- Consumes: `getTemplateMakeTaskById(taskId: string): Promise<TemplateMakeTaskDetail>`
+- Consumes: `TemplateEntity.makeTaskId?: string`
+- Consumes: `TemplateEntity.makeTaskStatus?: 'WAIT' | 'PROCESSING' | 'STEP1_SUCCESS' | 'STEP2_SUCCESS' | 'FAILED'`
+- Produces: processing poll interval `1500ms`
+- Produces: list item navigation rule `makeTaskStatus in ('WAIT','PROCESSING','STEP1_SUCCESS','FAILED') -> /templates/tasks/:makeTaskId`
+
+- [ ] **Step 1: 先写任务页轮询测试**
+
+```tsx
+it('polls until the task reaches STEP2_SUCCESS and then shows the detail CTA', async () => {
+  vi.useFakeTimers();
+  getTemplateMakeTaskById
+    .mockResolvedValueOnce({
+      id: '88',
+      templateId: '101',
+      templateName: '柔和层次长发',
+      taskStatus: 'PROCESSING',
+      errorMessage: null,
+      createTime: '2026-06-28T12:00:00.000Z',
+      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',
+      step1: { taskId: 'step1', status: 'PROCESSING', templateUrl: 'https://cdn.example.com/step1.json', resultImage: null },
+      step2: { taskId: '', status: 'WAIT', bundleUrl: null, resultImage: null },
+      template: { coverUrl: '', templateUrl: '', status: 'DRAFT' },
+    })
+    .mockResolvedValueOnce({
+      id: '88',
+      templateId: '101',
+      templateName: '柔和层次长发',
+      taskStatus: 'STEP2_SUCCESS',
+      errorMessage: null,
+      createTime: '2026-06-28T12:00:00.000Z',
+      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',
+      step1: { taskId: 'step1', status: 'STEP1_SUCCESS', templateUrl: 'https://cdn.example.com/step1.json', resultImage: 'https://cdn.example.com/step1.jpg' },
+      step2: { taskId: 'step2', status: 'STEP2_SUCCESS', bundleUrl: 'https://cdn.example.com/template.zip', resultImage: 'https://cdn.example.com/final.jpg' },
+      template: { coverUrl: 'https://cdn.example.com/final.jpg', templateUrl: 'https://cdn.example.com/template.zip', status: 'DRAFT' },
+    });
+
+  render(<TemplateMakeTaskPage />);
+
+  await act(async () => {
+    await Promise.resolve();
+  });
+
+  await act(async () => {
+    await vi.advanceTimersByTimeAsync(1500);
+  });
+
+  expect(getTemplateMakeTaskById).toHaveBeenCalledTimes(2);
+  expect(screen.getByRole('link', { name: '查看模板详情' })).toBeInTheDocument();
+});
+```
+
+Run: `cd /Users/yuchen/Documents/hairstylist/frontend && npm test -- src/pages/dashboard/templates/__tests__/TemplateMakeTaskPage.test.tsx`
+Expected: FAIL because the page is still a stub.
+
+- [ ] **Step 2: 实现任务页轮询和状态渲染**
+
+```tsx
+useEffect(() => {
+  let cancelled = false;
+  let timer: number | undefined;
+
+  const load = async () => {
+    const data = await getTemplateMakeTaskById(params.taskId!);
+    if (cancelled) return;
+    setTask(data);
+
+    if (['WAIT', 'PROCESSING', 'STEP1_SUCCESS'].includes(data.taskStatus)) {
+      timer = window.setTimeout(() => {
+        void load();
+      }, 1500);
+    }
+  };
+
+  void load();
+
+  return () => {
+    cancelled = true;
+    if (timer) window.clearTimeout(timer);
+  };
+}, [params.taskId]);
+```
+
+```tsx
+{task.taskStatus === 'STEP2_SUCCESS' ? (
+  <Link to={path(`/templates/${task.templateId}`)} className="btn btn-large btn-rounded btn-base-color">
+    {pageCopy.actions.viewTemplate}
+  </Link>
+) : null}
+```
+
+- [ ] **Step 3: 为模板列表增加跳转分流**
+
+```tsx
+const getTemplateTarget = (template: TemplateEntity) => {
+  if (
+    template.makeTaskId &&
+    ['WAIT', 'PROCESSING', 'STEP1_SUCCESS', 'FAILED'].includes(template.makeTaskStatus ?? '')
+  ) {
+    return path(`/templates/tasks/${template.makeTaskId}`);
+  }
+
+  return path(`/templates/${template.id}`);
+};
+```
+
+```tsx
+<Link to={getTemplateTarget(template)} className="d-block text-decoration-none">
+```
+
+- [ ] **Step 4: 更新任务页文案与样式**
+
+```json
+"templateTaskPage": {
+  "title": "模板制作任务",
+  "actions": {
+    "viewTemplate": "查看模板详情"
+  }
+}
+```
+
+```css
+.template-task-layout {
+  display: grid;
+  grid-template-columns: minmax(0, 380px) minmax(0, 1fr);
+  gap: 24px;
+}
+
+.template-task-step-card.is-success {
+  border-color: rgba(82, 196, 26, 0.28);
+}
+```
+
+- [ ] **Step 5: 跑前端回归测试**
+
+Run: `cd /Users/yuchen/Documents/hairstylist/frontend && npm test -- src/pages/dashboard/templates/__tests__/TemplateMakeTaskPage.test.tsx src/__tests__/App.test.tsx && npm run typecheck`
+Expected: PASS with polling and routing behavior verified.
+
+- [ ] **Step 6: 提交本任务**
+
+```bash
+cd /Users/yuchen/Documents/hairstylist
+git add frontend/src/pages/dashboard/templates/TemplateMakeTaskPage.tsx frontend/src/pages/dashboard/templates/TemplateListPage.tsx frontend/src/pages/dashboard/templates/__tests__/TemplateMakeTaskPage.test.tsx frontend/src/i18n/locales frontend/public/css/aitori.css
+git commit -m "feat: add template make task page"
+```
+
+## Task 7: 全量验证、文档同步与收尾
+
+**Files:**
+- Modify: `docs/superpowers/specs/2026-06-28-template-make-task-design.md` (only if implementation diverges)
+- Modify: `backend/docs/数据库设计.md` (only if doc drift is found and user-approved)
+
+**Interfaces:**
+- Consumes: all prior tasks
+- Produces: verified working feature branch state
+
+- [ ] **Step 1: 运行后端验证**
+
+Run: `cd /Users/yuchen/Documents/hairstylist/backend && npm run lint && npm test -- --runInBand src/modules/templates/templates.service.spec.ts && npm run test:e2e -- templates.e2e-spec.ts`
+Expected: PASS for lint, unit tests, and e2e tests.
+
+- [ ] **Step 2: 运行前端验证**
+
+Run: `cd /Users/yuchen/Documents/hairstylist/frontend && npm run typecheck && npm test -- src/pages/dashboard/templates/__tests__/TemplateFormPage.test.tsx src/pages/dashboard/templates/__tests__/TemplateMakeTaskPage.test.tsx src/__tests__/App.test.tsx`
+Expected: PASS for typecheck and targeted template flow tests.
+
+- [ ] **Step 3: 手动冒烟检查**
+
+```text
+1. 登录后台并打开 /zh/templates/new
+2. 选择预设模特,上传自定义服装与背景,提交任务
+3. 确认页面跳转到 /zh/templates/tasks/:taskId
+4. 观察 step1 -> step2 状态推进
+5. 成功后点击“查看模板详情”进入模板详情页
+6. 在模板列表里确认该模板卡片可进入正确页面
+```
+
+Expected: 整条链路无阻塞,成功产出封面图与模板包 URL。
+
+- [ ] **Step 4: 如实现偏离 spec,则同步更新文档**
+
+```md
+- 若最终接口字段或任务页文案与 spec 不一致,立即更新 `docs/superpowers/specs/2026-06-28-template-make-task-design.md`
+- 若数据库文档存在新增事实字段或语义修正,先确认后再更新 `backend/docs/数据库设计.md`
+```
+
+- [ ] **Step 5: 提交收尾**
+
+```bash
+cd /Users/yuchen/Documents/hairstylist
+git add .
+git commit -m "feat: finish template make task flow"
+```
+
+## Self-Review Checklist
+
+- Spec coverage:
+  - 动态素材接口 -> Task 2
+  - 创建模板双写 -> Task 2
+  - step1/step2 推进、zip 上传、回写模板 -> Task 3
+  - 前端新任务页、轮询 -> Task 4 / Task 6
+  - 模板新建页动态素材与跳转 -> Task 5
+  - 模板列表跳转分流 -> Task 6
+  - 验证与文档同步 -> Task 7
+- Placeholder scan:
+  - 未使用 `TBD`、`TODO`、`later`
+  - 每个任务包含明确文件、接口、命令和预期结果
+- Type consistency:
+  - 统一使用 `CreateTemplateMakeTaskDto` / `TemplateMakeTaskDetail`
+  - 统一使用 `TemplateAiClient.executeTemplate()` / `getResult()`
+  - 路由统一为 `/:locale/templates/tasks/:taskId`

+ 679 - 0
docs/superpowers/plans/2026-06-28-template-usage-summary.md

@@ -0,0 +1,679 @@
+# 模板使用量与顶部统计 Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** 为“我的发型模板”页面补齐真实的模板使用量与顶部 4 个统计,并在 AI 发型模拟成功时自动累计模板使用次数。
+
+**Architecture:** 以 `hair_template.used_count` 保存模板累计使用量,列表接口直接读取该字段;顶部 `weeklyUsed` 保持周增量语义,单独从 `ai_hair_simulation_task` 统计本周成功任务数。后端在模板模块新增 `GET /templates/summary`,前端移除假默认统计并接入真实数据;AI 模拟成功刷新链路中补充一次性 `used_count + 1`。
+
+**Tech Stack:** NestJS, Prisma, Jest, Supertest, React, TypeScript, Vitest, Testing Library
+
+## Global Constraints
+
+- 模板列表 UI 结构不变,只替换数据来源。
+- “使用量”统一定义为“成功用于 AI 发型模拟的次数”。
+- “本周使用”统一定义为当前自然周内的 AI 发型模拟成功任务数,不是累计值。
+- 当前项目没有历史数据,本轮不做历史数据回填。
+- 数据库新增字段必须保留注释。
+- 前端 summary 请求失败时回退到 `0`,不再展示假默认值。
+
+---
+
+### Task 1: 数据模型与模板统计接口骨架
+
+**Files:**
+- Modify: `backend/prisma/schema.prisma`
+- Modify: `backend/docs/数据库设计.md`
+- Modify: `backend/src/modules/templates/templates.controller.ts`
+- Modify: `backend/src/modules/templates/templates.service.ts`
+- Modify: `frontend/src/services/api/templates.ts`
+- Test: `backend/src/modules/templates/templates.service.spec.ts`
+- Test: `backend/test/templates.e2e-spec.ts`
+
+**Interfaces:**
+- Consumes: `TemplatesService.list(query: ListTemplatesQueryDto & { stylistId: bigint })`
+- Produces: `TemplatesService.getSummary(stylistId: bigint): Promise<{ total: number; published: number; weeklyUsed: number; draft: number }>`
+- Produces: `GET /api/v1/templates/summary`
+- Produces: `getTemplateDashboardSummary(): Promise<TemplateDashboardSummary>`
+
+- [ ] **Step 1: 在后端服务单测里写出 summary 接口的失败测试**
+
+```ts
+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) },
+  ]);
+
+  await expect(service.getSummary(BigInt(1))).resolves.toEqual({
+    total: 6,
+    published: 4,
+    weeklyUsed: 9,
+    draft: 2,
+  });
+});
+```
+
+- [ ] **Step 2: 运行单测,确认 `getSummary` 还不存在**
+
+Run: `npm test -- --runInBand backend/src/modules/templates/templates.service.spec.ts`
+
+Expected: FAIL,报错包含 `service.getSummary is not a function` 或 `Property 'getSummary' does not exist`
+
+- [ ] **Step 3: 更新 Prisma schema 与数据库设计文档**
+
+```prisma
+model HairTemplate {
+  id               BigInt                 @id @default(autoincrement()) @db.UnsignedBigInt
+  templateName     String                 @map("template_name") @db.VarChar(100)
+  coverUrl         String                 @default("") @map("cover_url") @db.VarChar(500)
+  status           HairTemplateStatus
+  category         HairTemplateCategory
+  tags             String                 @default("") @db.VarChar(500)
+  description      String?                @db.Text
+  modelUrl         String                 @default("") @map("model_url") @db.VarChar(500)
+  clothesUrl       String                 @default("") @map("clothes_url") @db.VarChar(500)
+  backgroundUrl    String                 @default("") @map("background_url") @db.VarChar(500)
+  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")
+  isDeleted        Int                    @default(0) @map("is_deleted") @db.UnsignedTinyInt
+}
+```
+
+```sql
+used_count         int unsigned default 0 not null comment '模板累计使用次数',
+```
+
+- [ ] **Step 4: 生成 Prisma migration**
+
+Run: `npx prisma migrate dev --name add_template_used_count`
+
+Expected: PASS,输出包含 `Applying migration` 与 `Generated Prisma Client`
+
+- [ ] **Step 5: 在模板控制器中新增 summary 路由**
+
+```ts
+@Get('summary')
+summary(@Req() request: AuthenticatedRequest) {
+  return this.templatesService.getSummary(BigInt(request.user.stylistId));
+}
+```
+
+- [ ] **Step 6: 在模板服务中实现 summary 方法骨架**
+
+```ts
+async getSummary(stylistId: bigint): Promise<{
+  total: number;
+  published: number;
+  weeklyUsed: number;
+  draft: number;
+}> {
+  const [total, published, draft] = 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' },
+    }),
+  ]);
+
+  return {
+    total,
+    published,
+    weeklyUsed: 0,
+    draft,
+  };
+}
+```
+
+- [ ] **Step 7: 更新前端 API 路径到真实 summary 接口**
+
+```ts
+export async function getTemplateDashboardSummary(): Promise<TemplateDashboardSummary> {
+  return http.get<TemplateDashboardSummary>('/templates/summary')
+}
+```
+
+- [ ] **Step 8: 再次运行后端模板单测,确认 `getSummary` 测试变成 weeklyUsed 逻辑失败**
+
+Run: `npm test -- --runInBand backend/src/modules/templates/templates.service.spec.ts`
+
+Expected: FAIL,`weeklyUsed` 断言不通过,当前实际值为 `0`
+
+- [ ] **Step 9: 提交第一段骨架改动**
+
+```bash
+git add backend/prisma/schema.prisma "backend/docs/数据库设计.md" backend/src/modules/templates/templates.controller.ts backend/src/modules/templates/templates.service.ts frontend/src/services/api/templates.ts
+git commit -m "feat: add template usage summary endpoint skeleton"
+```
+
+### Task 2: 完成模板列表真实使用量与 summary 统计
+
+**Files:**
+- Modify: `backend/src/modules/templates/templates.service.ts`
+- Modify: `backend/src/modules/templates/templates.service.spec.ts`
+- Modify: `backend/test/templates.e2e-spec.ts`
+
+**Interfaces:**
+- Consumes: `TemplatesService.getSummary(stylistId)`
+- Consumes: `TemplatesService.toTemplateListItem(template, taskMap)`
+- Produces: `list()` 返回的 `usedCount` 来自 `hair_template.used_count`
+- Produces: `getSummary()` 真实返回 `weeklyUsed`
+
+- [ ] **Step 1: 扩展模板服务单测,锁定列表项读取真实 `usedCount`**
+
+```ts
+it('maps usedCount from hair_template.used_count when listing templates', 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,
+      isDeleted: 0,
+    },
+  ]);
+  prisma.hairTemplate.count.mockResolvedValue(1);
+  prisma.hairTemplateMakeTask.findMany.mockResolvedValue([]);
+
+  const result = await service.list({
+    stylistId: BigInt(1),
+    q: '',
+    category: '',
+    status: '',
+    page: 1,
+    pageSize: 8,
+  });
+
+  expect(result.items[0]?.usedCount).toBe(7);
+});
+```
+
+- [ ] **Step 2: 扩展 summary 单测,锁定本周模板 ID 过滤与时间范围过滤**
+
+```ts
+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'] },
+  }),
+});
+```
+
+- [ ] **Step 3: 运行模板服务单测,确认列表与 summary 统计还未通过**
+
+Run: `npm test -- --runInBand backend/src/modules/templates/templates.service.spec.ts`
+
+Expected: FAIL,`usedCount` 仍为 `0`,或 `weeklyUsed` 统计调用参数不匹配
+
+- [ ] **Step 4: 在 `toTemplateListItem()` 中返回真实 `usedCount`**
+
+```ts
+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,
+  };
+}
+```
+
+- [ ] **Step 5: 在 `getSummary()` 中实现本周成功任务统计**
+
+```ts
+async getSummary(stylistId: bigint): Promise<{
+  total: number;
+  published: number;
+  weeklyUsed: number;
+  draft: number;
+}> {
+  const now = new Date();
+  const day = now.getUTCDay();
+  const diffToMonday = day === 0 ? 6 : day - 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, templateRows] = 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 = templateRows.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 };
+}
+```
+
+- [ ] **Step 6: 在 E2E 中新增真实 summary 接口断言**
+
+```ts
+it('/api/v1/templates/summary (GET) returns real template dashboard summary', async () => {
+  const token = await signToken();
+
+  hairTemplates.push(
+    { id: 101n, templateName: '空气感中长发', status: 'PUBLIC', hairstylistId: 1n, usedCount: 5, isDeleted: 0 },
+    { id: 102n, templateName: '厚重短发', status: 'DRAFT', hairstylistId: 1n, usedCount: 1, isDeleted: 0 },
+  );
+  aiHairSimulationTasks.push(
+    {
+      id: 501n,
+      templateId: '101',
+      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,
+  });
+});
+```
+
+- [ ] **Step 7: 更新现有列表 E2E 断言为真实 `usedCount`**
+
+```ts
+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,
+});
+```
+
+- [ ] **Step 8: 运行后端模板定向测试**
+
+Run: `npm test -- --runInBand backend/src/modules/templates/templates.service.spec.ts backend/test/templates.e2e-spec.ts`
+
+Expected: PASS,输出包含 `PASS backend/src/modules/templates/templates.service.spec.ts` 和 `PASS backend/test/templates.e2e-spec.ts`
+
+- [ ] **Step 9: 提交模板列表与 summary 统计逻辑**
+
+```bash
+git add backend/src/modules/templates/templates.service.ts backend/src/modules/templates/templates.service.spec.ts backend/test/templates.e2e-spec.ts
+git commit -m "feat: add real template usage summary data"
+```
+
+### Task 3: 在 AI 模拟成功链路中累计模板使用量
+
+**Files:**
+- Modify: `backend/src/modules/simulations/simulations.service.ts`
+- Modify: `backend/src/modules/simulations/simulations.service.spec.ts`
+
+**Interfaces:**
+- Consumes: `this.aiSimulationClient.getResult(task.taskId)`
+- Consumes: `this.prisma.aiHairSimulationTask.update(...)`
+- Produces: 首次成功时 `this.prisma.hairTemplate.update({ data: { usedCount: { increment: 1 } } })`
+
+- [ ] **Step 1: 在模拟服务单测中写出首次成功累加使用量的失败测试**
+
+```ts
+it('increments template usedCount when a processing task first becomes successes', async () => {
+  prisma.aiHairSimulation.findFirst.mockResolvedValue({
+    id: BigInt(201),
+    hairstylistId: BigInt(1),
+    customerId: null,
+    originImage: 'https://cdn.example.com/origin.jpg',
+    simulateRemark: null,
+    createTime: new Date('2026-06-28T12:00:00.000Z'),
+    isDeleted: 0,
+  });
+  prisma.aiHairSimulationTask.findMany
+    .mockResolvedValueOnce([
+      {
+        id: BigInt(301),
+        simulationId: BigInt(201),
+        templateId: '101',
+        taskId: 'task-101',
+        taskStatus: 'PROCESSING',
+        updateTime: new Date(Date.now() - 20_000),
+        isDeleted: 0,
+      },
+    ])
+    .mockResolvedValueOnce([
+      {
+        id: BigInt(301),
+        simulationId: BigInt(201),
+        templateId: '101',
+        taskId: 'task-101',
+        taskStatus: 'SUCCESSES',
+        taskFinishTime: new Date(),
+        resultImages: '[]',
+        isDeleted: 0,
+      },
+    ]);
+  aiSimulationClient.getResult.mockResolvedValue({
+    status: 'successes',
+    images: ['https://cdn.example.com/result-1.jpg'],
+    message: '',
+  });
+
+  await service.getSimulationDetail(BigInt(1), '201');
+
+  expect(prisma.hairTemplate.update).toHaveBeenCalledWith({
+    where: { id: BigInt(101) },
+    data: { usedCount: { increment: 1 } },
+  });
+});
+```
+
+- [ ] **Step 2: 写出重复成功不重复累加的失败测试**
+
+```ts
+it('does not increment template usedCount for tasks already marked as successes', async () => {
+  prisma.aiHairSimulation.findFirst.mockResolvedValue({
+    id: BigInt(201),
+    hairstylistId: BigInt(1),
+    customerId: null,
+    originImage: 'https://cdn.example.com/origin.jpg',
+    simulateRemark: null,
+    createTime: new Date('2026-06-28T12:00:00.000Z'),
+    isDeleted: 0,
+  });
+  prisma.aiHairSimulationTask.findMany.mockResolvedValue([
+    {
+      id: BigInt(301),
+      simulationId: BigInt(201),
+      templateId: '101',
+      taskId: 'task-101',
+      taskStatus: 'SUCCESSES',
+      updateTime: new Date(),
+      taskFinishTime: new Date(),
+      resultImages: '[]',
+      isDeleted: 0,
+    },
+  ]);
+
+  await service.getSimulationDetail(BigInt(1), '201');
+
+  expect(prisma.hairTemplate.update).not.toHaveBeenCalled();
+});
+```
+
+- [ ] **Step 3: 运行模拟服务单测,确认当前不会触发 `hairTemplate.update`**
+
+Run: `npm test -- --runInBand backend/src/modules/simulations/simulations.service.spec.ts`
+
+Expected: FAIL,报错包含 `Expected spy to have been called`
+
+- [ ] **Step 4: 在任务成功刷新分支中补充一次性递增逻辑**
+
+```ts
+if (result.status === 'successes') {
+  const finishTime = new Date();
+
+  await this.prisma.aiHairSimulationTask.update({
+    where: { id: task.id },
+    data: {
+      taskStatus: 'SUCCESSES',
+      resultImages: JSON.stringify(result.images),
+      taskFinishTime: finishTime,
+      errorMsg: null,
+    },
+  });
+
+  try {
+    const numericTemplateId = BigInt(task.templateId);
+    await this.prisma.hairTemplate.update({
+      where: { id: numericTemplateId },
+      data: {
+        usedCount: {
+          increment: 1,
+        },
+      },
+    });
+  } catch {
+    this.logger.warn(`Failed to increment template usage for task ${task.taskId}`);
+  }
+
+  continue;
+}
+```
+
+- [ ] **Step 5: 保持“只在首次成功时累加一次”的边界**
+
+```ts
+for (const task of tasks) {
+  if (task.taskStatus !== 'PROCESSING') {
+    continue;
+  }
+
+  if (!this.shouldRefreshTask(task.updateTime)) {
+    continue;
+  }
+
+  const result = await this.aiSimulationClient.getResult(task.taskId);
+  if (result.status === 'processing') {
+    continue;
+  }
+
+  // 只有 PROCESSING -> SUCCESSES 的这条路径才会进入 usedCount 自增
+}
+```
+
+- [ ] **Step 6: 运行模拟服务单测**
+
+Run: `npm test -- --runInBand backend/src/modules/simulations/simulations.service.spec.ts`
+
+Expected: PASS,包含新增 2 个测试通过
+
+- [ ] **Step 7: 提交 AI 模拟成功累计逻辑**
+
+```bash
+git add backend/src/modules/simulations/simulations.service.ts backend/src/modules/simulations/simulations.service.spec.ts
+git commit -m "feat: increment template usage after successful simulation"
+```
+
+### Task 4: 前端接入真实顶部统计与使用量展示
+
+**Files:**
+- Modify: `frontend/src/pages/dashboard/templates/TemplateListPage.tsx`
+- Modify: `frontend/src/pages/dashboard/templates/__tests__/TemplateListPage.test.tsx`
+- Modify: `frontend/src/services/api/templates.ts`
+- Modify: `frontend/src/services/api/contracts.ts`
+
+**Interfaces:**
+- Consumes: `getTemplateDashboardSummary(): Promise<TemplateDashboardSummary>`
+- Consumes: `listTemplates(): Promise<PaginatedTemplateList>`
+- Produces: `TemplateListPage` 使用真实 summary,失败回退为 `0`
+
+- [ ] **Step 1: 在前端列表页测试中写出失败断言,要求初始值为 0 而不是假默认值**
+
+```tsx
+it('renders zero summary values when the summary request fails', async () => {
+  getTemplateDashboardSummary.mockRejectedValueOnce(new Error('boom'))
+
+  render(
+    <HelmetProvider>
+      <MemoryRouter>
+        <TemplateListPage />
+      </MemoryRouter>
+    </HelmetProvider>,
+  )
+
+  expect(await screen.findByText('已登记模板')).toBeInTheDocument()
+  expect(screen.getAllByText('0').length).toBeGreaterThanOrEqual(4)
+})
+```
+
+- [ ] **Step 2: 在前端列表页测试中锁定真实 summary 与真实使用量文案**
+
+```tsx
+it('renders real summary values and template usedCount from the API', async () => {
+  render(
+    <HelmetProvider>
+      <MemoryRouter>
+        <TemplateListPage />
+      </MemoryRouter>
+    </HelmetProvider>,
+  )
+
+  expect(await screen.findByText('3')).toBeInTheDocument()
+  expect(screen.getByText('2')).toBeInTheDocument()
+  expect(screen.getByText('使用 2次')).toBeInTheDocument()
+})
+```
+
+- [ ] **Step 3: 运行前端定向测试,确认当前仍依赖假默认值**
+
+Run: `npm test -- --runInBand frontend/src/pages/dashboard/templates/__tests__/TemplateListPage.test.tsx`
+
+Expected: FAIL,断言 `0` summary 或接口路径相关预期不通过
+
+- [ ] **Step 4: 将列表页 summary 初始值改为全 0**
+
+```ts
+const initialSummary: TemplateDashboardSummary = {
+  total: 0,
+  published: 0,
+  weeklyUsed: 0,
+  draft: 0,
+}
+```
+
+- [ ] **Step 5: 保持失败时回退到全 0,继续使用真实接口结果**
+
+```ts
+useEffect(() => {
+  getTemplateDashboardSummary()
+    .then(setSummary)
+    .catch(() => setSummary(initialSummary))
+}, [])
+```
+
+- [ ] **Step 6: 确认前端 API 适配器仍对 `usedCount` 做数字兜底**
+
+```ts
+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,
+  })),
+}
+```
+
+- [ ] **Step 7: 运行前端模板列表测试**
+
+Run: `npm test -- --runInBand frontend/src/pages/dashboard/templates/__tests__/TemplateListPage.test.tsx`
+
+Expected: PASS,包含 summary 成功与失败两类断言
+
+- [ ] **Step 8: 做一轮相关文件诊断检查**
+
+Run: `npm run lint -- src/pages/dashboard/templates/TemplateListPage.tsx src/services/api/templates.ts`
+
+Expected: PASS,无新增 lint 错误
+
+- [ ] **Step 9: 提交前端接线改动**
+
+```bash
+git add frontend/src/pages/dashboard/templates/TemplateListPage.tsx frontend/src/pages/dashboard/templates/__tests__/TemplateListPage.test.tsx frontend/src/services/api/templates.ts
+git commit -m "feat: show real template usage summary in list page"
+```
+
+## Self-Review
+
+- **Spec coverage:** 已覆盖数据库字段、模板列表真实 `usedCount`、顶部 4 统计、`GET /templates/summary`、AI 模拟成功自增、前后端测试与失败回退行为。
+- **Placeholder scan:** 计划中未使用 `TBD`、`TODO`、`类似 Task N` 等占位描述;代码步骤均给出明确片段和命令。
+- **Type consistency:** 统一使用 `usedCount` 作为前后端字段名、`getSummary(stylistId)` 作为服务方法名、`GET /api/v1/templates/summary` 作为统计接口路径。

+ 275 - 0
docs/superpowers/specs/2026-06-28-template-edit-page-basic-info-design.md

@@ -0,0 +1,275 @@
+# 模板编辑页与任务完成入口调整设计
+
+## 1. 背景
+
+当前模板模块存在两个问题:
+
+1. 模板制作任务完成后,`TemplateMakeTaskPage` 提供的是“查看模板详情”入口,但用户当前真实后续动作是补充和确认模板基本信息。
+2. 现有 `TemplateDetailPage` 同时承担了详情展示和信息编辑职责,页面边界不清晰,不符合“新加一个编辑页面,并且只能编辑模板基本信息”的目标。
+
+本次设计将模板详情与模板编辑拆分:
+
+- 保留现有模板详情页作为展示页。
+- 新增独立模板编辑页,只允许编辑模板基本信息。
+- 模板制作任务完成后的主按钮改为“编辑模板信息”,引导用户进入新编辑页。
+
+## 2. 目标
+
+本次改动完成后,应满足以下目标:
+
+1. 模板制作任务成功后,用户可直接进入独立的“编辑模板信息”页面。
+2. 模板详情页继续保留为展示用途,不再承担任务完成后的主编辑入口。
+3. 编辑页只能修改模板基本信息,不允许修改素材、封面、模板包和任务相关数据。
+4. 前后端都要建立独立的“读取模板基本信息 / 更新模板基本信息”链路。
+
+## 3. 非目标
+
+以下内容不在本次范围内:
+
+1. 不重做模板详情页整体视觉结构。
+2. 不允许在编辑页重新上传或替换参考图、AI 模特、服装、背景。
+3. 不允许在编辑页修改模板封面图或模板包 URL。
+4. 不处理模板重新生成、任务重试、任务取消等能力。
+5. 不改造模板列表的整体交互模式,列表仍然以详情页为主要查看入口。
+
+## 4. 目标交互
+
+### 4.1 任务页
+
+当模板制作任务状态为 `STEP2_SUCCESS` 时:
+
+- 当前按钮文案从“查看模板详情”改为“编辑模板信息”。
+- 跳转目标从模板详情页改为模板编辑页。
+
+任务页中的“返回模板列表”按钮保持不变。
+
+### 4.2 模板详情页
+
+模板详情页继续保留,作为展示页存在:
+
+- 模板列表点击卡片仍然进入详情页。
+- 详情页展示模板封面、生成状态、统计信息、使用结果等现有内容。
+- 本次不要求将详情页完全去编辑化,但任务完成态不再把它作为首要后续入口。
+
+### 4.3 模板编辑页
+
+新增独立模板编辑页,路径与职责如下:
+
+- 路由:`/:locale/templates/:templateId/edit`
+- 职责:仅编辑模板基本信息
+
+允许编辑的字段:
+
+1. 模板名 `templateName`
+2. 状态 `status`
+3. 分类 `category`
+4. 标签 `tags`
+5. 描述 `description`
+
+不允许编辑的字段:
+
+1. 封面图 `coverUrl`
+2. 模板包 `templateUrl`
+3. 参考图 `refHairUrl`
+4. 模特图 `modelUrl`
+5. 服装图 `clothesUrl`
+6. 背景图 `backgroundUrl`
+7. 任意任务状态与任务资源字段
+
+页面布局采用现有工作台编辑页骨架:
+
+- 顶部使用统一的 `stylist-workbench-top ipad-top-space-margin mb-2`
+- 提供返回、取消、保存按钮
+- 主体使用标准表单布局
+
+## 5. 路由与页面结构
+
+### 5.1 前端路由
+
+新增路由:
+
+```ts
+templateEdit: '/:locale/templates/:templateId/edit'
+```
+
+路由注册到 `App.tsx`,使用登录保护,与其他工作台页面一致。
+
+### 5.2 页面职责划分
+
+- `TemplateMakeTaskPage`
+  - 任务状态轮询
+  - 成功后引导进入编辑页
+- `TemplateDetailPage`
+  - 模板展示页
+- `TemplateEditPage`
+  - 模板基本信息编辑页
+
+## 6. 前端数据流
+
+### 6.1 任务成功后的跳转
+
+1. 用户在 `TemplateMakeTaskPage` 轮询到 `STEP2_SUCCESS`
+2. 页面显示“编辑模板信息”按钮
+3. 点击后跳转到 `/:locale/templates/:templateId/edit`
+
+### 6.2 编辑页加载
+
+1. 页面根据 `templateId` 请求模板基本信息
+2. 请求成功后回填表单
+3. 请求失败则显示错误或未找到状态
+
+### 6.3 编辑页保存
+
+1. 用户修改模板基本信息
+2. 点击保存按钮
+3. 前端调用更新接口
+4. 更新成功后保留在当前页,并提示保存成功
+5. 更新失败时显示错误提示,不清空现有表单内容
+
+## 7. 后端接口设计
+
+### 7.1 获取模板基本信息
+
+新增接口:
+
+```http
+GET /api/v1/templates/:templateId
+```
+
+职责:
+
+- 按模板 ID 返回模板基本信息
+- 校验模板存在
+- 校验模板属于当前登录发型师
+
+返回字段最少包含:
+
+```json
+{
+  "id": "43",
+  "templateName": "柔和层次长发",
+  "status": "DRAFT",
+  "category": "LONG",
+  "tags": ["层次", "首次建议"],
+  "description": "适合轻盈感修饰",
+  "coverUrl": "https://example.com/cover.jpg",
+  "templateUrl": "https://example.com/template.zip"
+}
+```
+
+说明:
+
+- 编辑页虽然不允许修改 `coverUrl` / `templateUrl`,但允许后端返回,以便页面按需展示只读信息。
+
+### 7.2 更新模板基本信息
+
+新增接口:
+
+```http
+PATCH /api/v1/templates/:templateId
+```
+
+仅允许更新以下字段:
+
+```json
+{
+  "templateName": "空气感中长发",
+  "status": "PUBLIC",
+  "category": "MEDIUM",
+  "tags": ["空气感", "层次"],
+  "description": "适合脸部轮廓柔化"
+}
+```
+
+后端必须保证:
+
+1. 只能更新允许字段
+2. 不得覆盖模板封面、模板包 URL、素材 URL、任务状态等字段
+3. 模板不存在时返回未找到错误
+4. 模板不属于当前发型师时返回未找到或无权限错误
+
+## 8. 后端服务设计
+
+模板模块新增两类能力:
+
+1. `getById(stylistId, templateId)`
+2. `updateBasicInfo(stylistId, templateId, dto)`
+
+实现原则:
+
+- 以 `hair_template` 为唯一事实来源
+- 查询和更新都按 `hairstylist_id + id + is_deleted = 0` 做范围限制
+- `tags` 在数据库中仍按现有约定以逗号串存储,接口层负责数组与字符串之间的转换
+
+## 9. 数据边界
+
+### 9.1 可写字段边界
+
+本次编辑页只能修改:
+
+- `template_name`
+- `status`
+- `category`
+- `tags`
+- `description`
+
+### 9.2 不可写字段边界
+
+以下字段必须保持只读:
+
+- `cover_url`
+- `template_url`
+- `ref_hair_url`
+- `model_url`
+- `clothes_url`
+- `background_url`
+- `is_system_template`
+- 任务表 `hair_template_make_task` 的全部字段
+
+## 10. 错误处理
+
+### 10.1 前端
+
+- 加载失败:显示加载失败提示
+- 未找到:显示模板不存在提示
+- 保存失败:显示后端返回错误,不清空表单
+- 保存中:按钮禁用,避免重复提交
+
+### 10.2 后端
+
+- 模板不存在:返回 404
+- 越权访问:返回 404 或统一业务未找到错误
+- DTO 校验失败:返回 400
+
+## 11. 测试策略
+
+### 11.1 前端
+
+至少覆盖以下场景:
+
+1. 任务页成功按钮文案变为“编辑模板信息”
+2. 任务页成功按钮跳转到 `/templates/:templateId/edit`
+3. 编辑页路由可访问
+4. 编辑页可加载模板基本信息并完成回填
+5. 编辑页保存时只提交允许字段
+
+### 11.2 后端
+
+至少覆盖以下场景:
+
+1. `GET /templates/:templateId` 成功返回模板基本信息
+2. `GET /templates/:templateId` 对不存在模板返回 404
+3. `PATCH /templates/:templateId` 可更新基本信息字段
+4. `PATCH /templates/:templateId` 不会改动非允许字段
+5. 越权访问返回未找到或拒绝访问
+
+## 12. 验收标准
+
+满足以下条件即视为本次需求完成:
+
+1. 模板制作任务完成页主按钮改为“编辑模板信息”
+2. 点击后进入新建的模板编辑页
+3. 编辑页只允许修改模板名、状态、分类、标签、描述
+4. 后端存在对应的读取与更新接口
+5. 模板详情页仍然保留为展示入口
+6. 前后端相关测试通过

+ 367 - 0
docs/superpowers/specs/2026-06-28-template-list-query-pagination-design.md

@@ -0,0 +1,367 @@
+# 模板列表真实查询与分页设计
+
+## 1. 背景
+
+当前“我的发型模板”页面已经具备列表卡片、任务状态分流、批量删除和基础筛选 UI 骨架,但核心列表能力仍未落地:
+
+- 前端 `TemplateListPage` 目前仅在页面加载时调用一次 `listTemplates()`,查询项没有真正参与请求。
+- 后端 `TemplatesService.list()` 目前返回空列表,尚未提供真实数据。
+- 页面中仍保留“用途”筛选,但本轮需求明确要求先删除该项。
+- 分页控件仍是静态占位,不会随着真实数据变化。
+
+本次设计目标是把模板列表补成可用的真实列表页,同时尽量保持现有卡片布局、任务分流和批量删除交互不变。
+
+## 2. 目标
+
+本轮交付后,模板列表页需要满足以下要求:
+
+- 使用真实后端数据展示模板列表。
+- 支持真实查询条件:
+  - 搜索 `q`
+  - 分类 `category`
+  - 状态 `status`
+- 删除“用途”筛选,不再在前后端查询链路中使用该字段。
+- 支持真实分页:
+  - 前端根据后端 `total/page/pageSize` 渲染页码
+  - 查询条件变化时自动回到第一页
+  - 即使只有一页,也保留分页控件区域
+- 保持现有模板跳转逻辑不变:
+  - 未完成任务跳任务页
+  - 已完成模板跳详情页
+
+## 3. 非目标
+
+本轮不处理以下内容:
+
+- 不新增“排序”真实能力,当前“排序:最近使用”继续保持占位态。
+- 不改造模板卡片视觉样式。
+- 不重构批量删除交互。
+- 不扩展“系统模板”或跨美发师共享列表。
+- 不修改模板详情页、模板编辑页和模板制作任务页的既有职责。
+
+## 4. 方案选择
+
+### 方案 A:最小闭环真实列表
+
+做法:
+
+- 后端补齐 `GET /templates` 的真实查询与分页。
+- 前端将筛选项接入真实请求。
+- 删除“用途”筛选。
+- 保持现有 UI 结构和分页展示风格,仅把数据改为真实驱动。
+
+优点:
+
+- 改动范围最小,能最快形成可联调的真实列表页。
+- 与当前模板任务流、详情页、编辑页职责兼容。
+- 风险低,适合当前 Case 节奏。
+
+缺点:
+
+- “排序”仍然是占位态,列表能力不是最终形态。
+
+### 方案 B:顺手一起做排序
+
+做法:
+
+- 在方案 A 基础上,把排序也接成真实参数。
+
+优点:
+
+- 列表能力更完整。
+
+缺点:
+
+- 会扩大本轮范围。
+- 需要额外确认排序字段、默认顺序和文案。
+
+### 方案 C:仅前端本地筛选
+
+做法:
+
+- 后端仍返回全量列表,由前端本地做筛选与假分页。
+
+优点:
+
+- 前端改动更快。
+
+缺点:
+
+- 不是真实分页,不满足本轮要求。
+- 数据量变大后性能和一致性都会出问题。
+
+### 结论
+
+采用 **方案 A:最小闭环真实列表**。
+
+## 5. 后端设计
+
+### 5.1 接口
+
+继续使用现有接口:
+
+- `GET /api/v1/templates`
+
+查询参数调整为:
+
+- `q?: string`
+- `category?: string`
+- `status?: string`
+- `page?: number`
+- `pageSize?: number`
+
+移除:
+
+- `purpose`
+
+### 5.2 查询范围
+
+只查询当前登录美发师自己的模板,并强制附加以下条件:
+
+- `hairstylist_id = 当前登录用户`
+- `is_deleted = 0`
+
+### 5.3 筛选规则
+
+- `q`
+  - 模糊匹配 `template_name`
+  - 模糊匹配 `tags`
+  - 模糊匹配 `description`
+- `category`
+  - 精确匹配模板分类
+- `status`
+  - 精确匹配模板状态
+
+### 5.4 分页规则
+
+- 默认 `page = 1`
+- 默认 `pageSize = 8`
+  - 选择 8 的原因:当前卡片网格在桌面端更容易形成稳定的两行展示,改动小
+- `skip = (page - 1) * pageSize`
+- `take = pageSize`
+
+返回结构调整为:
+
+```ts
+{
+  items: TemplateEntity[]
+  total: number
+  page: number
+  pageSize: number
+}
+```
+
+### 5.5 列表项字段
+
+列表项继续返回前端当前卡片和分流逻辑所需字段,至少包括:
+
+- `id`
+- `name`
+- `category`
+- `status`
+- `description`
+- `tags`
+- `image`
+- `usedCount`
+- `makeTaskId`
+- `makeTaskStatus`
+- `generationStatus`
+
+其中:
+
+- `name` 由 `template_name` 映射
+- `image` 优先取模板封面 `cover_url`
+- `makeTaskId / makeTaskStatus` 用于保持当前任务分流逻辑
+
+### 5.6 排序规则
+
+本轮不开放前端排序选择,但后端列表需要给出稳定默认顺序,避免翻页结果抖动:
+
+- 优先按 `id desc`
+
+这能保证新建模板优先显示,且在当前阶段实现最简单、最稳定。
+
+## 6. 前端设计
+
+### 6.1 查询区调整
+
+保留并接入真实请求的筛选项:
+
+- 搜索框
+- 分类
+- 状态
+
+删除:
+
+- 用途筛选 UI
+- 前端文案中的 `purposeLabel` 和 `purposeOptions`
+- `listTemplates()` 的 `purpose` 参数
+
+### 6.2 页面状态
+
+`TemplateListPage` 需要新增受控状态:
+
+- `query`
+- `category`
+- `status`
+- `page`
+- `pageSize`
+- `total`
+- `loading`
+
+页面行为:
+
+- 首次进入时请求第一页。
+- 任一查询条件变化时:
+  - 重置 `page = 1`
+  - 重新发起请求
+- 点击分页按钮时:
+  - 保留当前查询条件
+  - 请求对应页码
+
+### 6.3 列表展示
+
+保留现有卡片样式与结构,不做视觉重构。
+
+继续保留:
+
+- 批量删除按钮
+- 单项删除按钮
+- 任务状态徽标
+- 卡片点击跳转逻辑
+
+### 6.4 分流逻辑
+
+保持当前规则不变:
+
+- `makeTaskId` 存在,且 `makeTaskStatus` 属于 `WAIT / PROCESSING / STEP1_SUCCESS / FAILED`
+  - 跳任务页 `/templates/tasks/:taskId`
+- 其他情况
+  - 跳详情页 `/templates/:templateId`
+
+### 6.5 分页展示
+
+分页控件由真实分页数据驱动:
+
+- 总页数 `totalPages = max(1, ceil(total / pageSize))`
+- 即使 `totalPages = 1`,分页区域仍然显示
+- “上一页”在第一页禁用
+- “下一页”在最后一页禁用
+- 中间页码使用真实页码,不再硬编码 `1 / 2`
+
+## 7. API 契约调整
+
+### 7.1 前端 `listTemplates`
+
+由:
+
+```ts
+listTemplates(params?: { q?: string; category?: string; purpose?: string; status?: string }): Promise<TemplateEntity[]>
+```
+
+调整为:
+
+```ts
+listTemplates(params?: {
+  q?: string
+  category?: string
+  status?: string
+  page?: number
+  pageSize?: number
+}): Promise<{
+  items: TemplateEntity[]
+  total: number
+  page: number
+  pageSize: number
+}>
+```
+
+### 7.2 后端 DTO
+
+`ListTemplatesQueryDto` 调整为:
+
+- 保留 `q`
+- 保留 `category`
+- 删除 `purpose`
+- 保留 `status`
+- 新增 `page`
+- 新增 `pageSize`
+
+## 8. 错误处理
+
+### 8.1 前端
+
+- 列表请求失败时,页面回退为空列表展示。
+- 分页区域仍保留,按 `total = 0` 处理。
+- 不新增复杂 toast,本轮保持当前页面风格。
+
+### 8.2 后端
+
+- 非法分页参数使用默认值兜底:
+  - `page < 1` 视为 `1`
+  - `pageSize < 1` 使用默认值 `8`
+- 空结果返回:
+
+```ts
+{
+  items: [],
+  total: 0,
+  page,
+  pageSize
+}
+```
+
+## 9. 测试设计
+
+### 9.1 后端
+
+补充或更新:
+
+- `templates.service.spec.ts`
+  - 按 `q/category/status` 生成正确查询条件
+  - 按 `page/pageSize` 生成正确分页参数
+  - 返回 `items/total/page/pageSize`
+- `templates.e2e-spec.ts`
+  - `GET /templates` 支持真实筛选
+  - `GET /templates` 支持真实分页
+  - 不再接受 `purpose` 作为有效查询链路的一部分
+
+### 9.2 前端
+
+更新 `TemplateListPage.test.tsx`,覆盖:
+
+- 删除“用途”筛选
+- 首次加载时携带真实分页参数请求第一页
+- 搜索、分类、状态变化时重新请求并重置页码
+- 分页按钮始终显示
+- 点击不同页码后请求对应页
+- 未完成模板仍跳任务页,已完成模板仍跳详情页
+
+## 10. 实施边界
+
+本次实现预计涉及:
+
+- 后端
+  - `templates.service.ts`
+  - `templates.controller.ts`
+  - `dto/list-templates-query.dto.ts`
+  - `templates.service.spec.ts`
+  - `templates.e2e-spec.ts`
+- 前端
+  - `services/api/templates.ts`
+  - `services/api/contracts.ts`
+  - `pages/dashboard/templates/TemplateListPage.tsx`
+  - `pages/dashboard/templates/__tests__/TemplateListPage.test.tsx`
+  - `i18n/locales/zh/translation.json`
+  - `i18n/locales/ja/translation.json`
+
+## 11. 验收标准
+
+完成后需要满足:
+
+- “我的发型模板”页面展示真实后端数据
+- 查询项只保留:搜索、分类、状态
+- “用途”完全从页面和查询链路中移除
+- 分页为真实分页,且即使只有一页也显示分页控件
+- 列表卡片跳转分流逻辑保持正确
+- 前后端针对列表查询与分页的测试通过