# 模板列表真实查询与分页 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, ) { 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(); 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) => { let items = [...hairTemplates]; const typedWhere = where as Record; 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 }) => { return (await prisma.hairTemplate.findMany({ where })).length; }), update: jest.fn(...), deleteMany: jest.fn(...), }, hairTemplateMakeTask: { findFirst: jest.fn(...), findMany: jest.fn(async ({ where, orderBy }: Record) => { 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` - 前端测试锁定:删除用途、真实请求参数、真实分页渲染与分流 - [ ] **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 { 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(`/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([]) const [summary, setSummary] = useState(initialSummary) const [selectedIds, setSelectedIds] = useState([]) 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
{ setQuery(event.target.value) setPage(1) }} />
``` - [ ] **Step 4: 接入真实分页渲染** 在 `TemplateListPage.tsx` 中增加: ```tsx const totalPages = Math.max(1, Math.ceil(total / pageSize)) const pages = Array.from({ length: totalPages }, (_, index) => index + 1) ``` 并替换原静态分页区: ```tsx
{pages.map((pageNumber) => ( ))}
``` - [ ] **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` 同步到同一枚举值