2026-06-28-template-list-query-pagination.md 30 KB

模板列表真实查询与分页 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: 先写服务层失败测试,锁定查询参数与返回结构

    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 里加上:

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 改成:

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():

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 的列表入口改成:

  @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: 提交当前任务

    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 里新增:

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 里补齐:

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 通过

关键断言保持如下:

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: 提交当前任务

    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 改成使用分页返回值:

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,
    });
  });
});
  • 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 新增:

export type PaginatedTemplateList = {
  items: TemplateEntity[]
  total: number
  page: number
  pageSize: number
}
  • Step 4: 更新 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,
    })),
  }
}
  • Step 5: 运行测试,确认仍然红灯但契约已对齐

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"
    

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 的状态起点改成:

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):

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,并把搜索/分类/状态做成受控组件

把筛选区改成:

<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 中增加:

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>
  • Step 5: 同步 i18n,彻底移除用途筛选

将 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。

  • 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: 提交当前任务

    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 检查:

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"
    

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 同步到同一枚举值