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
搜索 / 分类 / 状态,必须完全移除 用途 前后端查询链路。排序:最近使用 继续保持占位态。page = 1、pageSize = 8。backend/src/modules/templates/dto/list-templates-query.dto.ts
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
Files:
backend/src/modules/templates/dto/list-templates-query.dto.tsbackend/src/modules/templates/templates.service.tsbackend/src/modules/templates/templates.service.spec.tsInterfaces:
TemplatesController.list(@Query() query: ListTemplatesQueryDto)Produces:
ListTemplatesQueryDto:q?: stringcategory?: stringstatus?: stringpage?: numberpageSize?: numberTemplatesService.list(query): Promise<{ items: TemplateEntity[]; total: number; page: number; pageSize: number }>[ ] Step 1: 先写服务层失败测试,锁定查询参数与返回结构
it('lists templates with q/category/status filters and pagination', async () => {
prisma.hairTemplate.findMany.mockResolvedValue([
{
id: BigInt(102),
templateName: '空气感中长发',
category: 'MEDIUM',
status: 'PUBLIC',
description: '适合脸周轻盈修饰',
tags: '空气感,脸周',
coverUrl: 'https://cdn.example.com/cover-102.jpg',
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。
在 backend/src/modules/templates/templates.service.spec.ts 的 prisma mock 里加上:
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(),
};
将 backend/src/modules/templates/dto/list-templates-query.dto.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;
}
在 backend/src/modules/templates/templates.service.ts 新增列表项映射辅助函数,并改造 list():
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,
};
}
把 backend/src/modules/templates/templates.controller.ts 的列表入口改成:
@Get()
list(
@Req() request: AuthenticatedRequest,
@Query() query: ListTemplatesQueryDto,
) {
return this.templatesService.list({
...query,
stylistId: BigInt(request.user.stylistId),
});
}
Run: npm test -- src/modules/templates/templates.service.spec.ts --runInBand
Expected: PASS,新增列表筛选与分页测试通过,旧模板任务相关测试不回退。
[ ] Step 8: 提交当前任务
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"
Files:
backend/test/templates.e2e-spec.tsInterfaces:
GET /api/v1/templates?q=&category=&status=&page=&pageSize={ items, total, page, pageSize }Produces:
[ ] Step 1: 先补 e2e 红灯测试
在 backend/test/templates.e2e-spec.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,
});
});
在 backend/test/templates.e2e-spec.ts 的 Prisma stub 里补齐:
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(...),
},
Run: npm test -- test/templates.e2e-spec.ts --runInBand
Expected: FAIL,报 items/total/page/pageSize 结构或筛选行为不匹配。
关键断言保持如下:
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);
Run: npm test -- test/templates.e2e-spec.ts --runInBand
Expected: PASS,原有模板素材/任务测试继续通过,新列表测试通过。
[ ] Step 6: 提交当前任务
git add backend/test/templates.e2e-spec.ts
git commit -m "test: cover template list query pagination e2e"
Files:
frontend/src/services/api/contracts.tsfrontend/src/services/api/templates.tsfrontend/src/pages/dashboard/templates/__tests__/TemplateListPage.test.tsxInterfaces:
GET /templates 返回 { items, total, page, pageSize }Produces:
PaginatedTemplateListlistTemplates(params): Promise<PaginatedTemplateList>[ ] Step 1: 先把模板列表测试改成红灯
把 frontend/src/pages/dashboard/templates/__tests__/TemplateListPage.test.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();
});
再新增查询变化用例:
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,
});
});
});
Run: npm test -- src/pages/dashboard/templates/__tests__/TemplateListPage.test.tsx
Expected: FAIL,当前 TemplateListPage 仍然:
TemplateEntity[]不会发真实分页请求
[ ] Step 3: 新增前端分页契约类型
在 frontend/src/services/api/contracts.ts 新增:
export type PaginatedTemplateList = {
items: TemplateEntity[]
total: number
page: number
pageSize: number
}
listTemplates() 返回值和参数把 frontend/src/services/api/templates.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,
})),
}
}
Run: npm test -- src/pages/dashboard/templates/__tests__/TemplateListPage.test.tsx
Expected: 仍 FAIL,但错误集中在 TemplateListPage UI 与交互实现,而不是 API mock 结构不匹配。
[ ] Step 6: 提交当前任务
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"
Files:
frontend/src/pages/dashboard/templates/TemplateListPage.tsxfrontend/src/i18n/locales/zh/translation.jsonfrontend/src/i18n/locales/ja/translation.jsonfrontend/src/pages/dashboard/templates/__tests__/TemplateListPage.test.tsxInterfaces:
listTemplates({ q, category, status, page, pageSize })PaginatedTemplateList { items, total, page, pageSize }Produces:
[ ] Step 1: 在页面里引入受控查询状态
将 frontend/src/pages/dashboard/templates/TemplateListPage.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)
在 useEffect 中替换原来的 listTemplates().then(setItems):
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])
把筛选区改成:
<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>
在 TemplateListPage.tsx 中增加:
const totalPages = Math.max(1, Math.ceil(total / pageSize))
const pages = Array.from({ length: totalPages }, (_, index) => index + 1)
并替换原静态分页区:
<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>
将 frontend/src/i18n/locales/zh/translation.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": "草稿" }
]
}
日文同步改成:
"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。
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: 提交当前任务
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"
Files:
Interfaces:
Produces: 最终可联调的模板列表真实查询与分页能力
[ ] Step 1: 跑后端模板模块验证
Run: npm test -- src/modules/templates/templates.service.spec.ts test/templates.e2e-spec.ts --runInBand
Expected: PASS,模板素材、任务流、编辑页接口、列表查询分页全部通过。
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,模板表单、列表、任务页、编辑页、详情页和路由测试全部通过。
Run: npm run typecheck
Expected: PASS
使用 IDE diagnostics 检查:
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: 提交最终收尾
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"
TODO/TBD/类似 Task N 之类占位描述category: LONG|MEDIUM|SHORT|DYE、status: DRAFT|PUBLICTemplateListPage 与 i18n categoryMap/statusMap 同步到同一枚举值