diff --git a/docs/superpowers/plans/2026-06-18-row-highlight-and-column-filters.md b/docs/superpowers/plans/2026-06-18-row-highlight-and-column-filters.md new file mode 100644 index 0000000..802cbce --- /dev/null +++ b/docs/superpowers/plans/2026-06-18-row-highlight-and-column-filters.md @@ -0,0 +1,879 @@ +# 行高亮与表头列筛选 — 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:** 为生产数据查询页新增「行高亮(悬停 + 点击锁定)」与「表头列筛选(用户勾选可筛选列,按字段类型自动推断筛选 UI)」两个功能;「长内容显示」本次不做。 + +**Architecture:** 纯逻辑(类型推断 / 筛选谓词 / 锁定切换)抽到 `src/app/table-filters.ts` 并用 Vitest 单测;antd 渲染(`FilterDropdown` 组件、列筛选属性工厂)放 `src/app/table-filters.tsx`;`page.tsx` 接管 `lockedRowKey` / `filters` 状态并通过 antd 受控筛选接入表格;行高亮用 `onRow` + `rowClassName` + 一条 CSS。筛选纯前端、作用于已加载数据,不新增接口。 + +**Tech Stack:** Next.js 16.2.9, React 19.2.4, Ant Design 6.4.3, TypeScript 5 (strict), Tailwind v4, Vitest(本计划新增,仅用于纯逻辑单测)。 + +## Global Constraints + +- **antd v6 / Next 16 与训练数据可能不同**(见 `AGENTS.md`):实现任何涉及 antd Table 的任务前,先用 `node_modules/antd` 的 `.d.ts` 核对实际签名 —— 重点:`filterDropdown` 的 `FilterDropdownProps`(导入路径与字段名)、`onFilter` 签名、`Table` 的 `onChange(pagination, filters, ...)` 中 `filters` 的类型、`onRow` / `rowClassName`、固定列 DOM 结构、`DatePicker` 使用 dayjs。若 v6 与本计划代码不符,以 v6 实际 API 为准。Next 相关用法查阅 `node_modules/next/dist/docs/`。 +- TypeScript `strict: true`;路径别名 `@/*` → `./src/*`(本计划用相对路径 `./table-filters`,二者皆可)。 +- 所有 commit message 用英文;按项目 `CLAUDE.md` 规范,提交后如有远程则推送。 +- 日期值在数据层已是 `YYYY-MM-DD` 字符串,日期筛选按字符串字典序比较(ISO 格式可行)。 +- 不要改动与本计划无关的代码(如 `src/app/page.tsx` 现有的未提交改动保持原样,除非该任务明确要求)。 + +## File Structure + +- `src/app/table-filters.ts`(新建):纯逻辑核心。导出 `DataRow`(从 page.tsx 迁移,作为单一来源)、`FilterType`、`CATEGORY_THRESHOLD`、`distinctValues`、`inferFilterType`、`matchesFilter`、`nextLockedRow`。无 React 依赖,可单测。 +- `src/app/table-filters.test.ts`(新建):Vitest 单测,覆盖上述纯函数。 +- `src/app/table-filters.tsx`(新建):`FilterDropdown` 组件(按类型渲染 文本搜索 / 分类勾选 / 日期范围)+ `buildColumnFilterProps(key, data, filteredValue)` 工厂(返回 antd 列筛选属性)。 +- `src/app/page.tsx`(修改):`ColumnConfig` 增 `filterable`;`loadConfig` 向前兼容;`buildTableColumns` 接筛选属性;`Home` 增 `lockedRowKey`/`filters` 状态与 `onRow`/`rowClassName`/`onChange`/重置;`SearchBar` 增「清除筛选」按钮;`ColumnSettingsModal` + `SortableRow` 增漏斗开关。`DataRow` 改为从 `./table-filters` 导入。 +- `src/app/globals.css`(修改):追加 `.row-locked` 规则。 +- `package.json`(修改):加 `vitest` devDep 与 `test` script。 +- `vitest.config.ts`(新建):最小配置(node 环境)。 + +--- + +## Task 1: 纯逻辑核心 + Vitest 单测 + +**Files:** +- Create: `src/app/table-filters.ts` +- Create: `src/app/table-filters.test.ts` +- Create: `vitest.config.ts` +- Modify: `package.json`(devDeps + scripts) + +**Interfaces:** +- Produces(后续任务依赖): + - `export interface DataRow { [key: string]: string | number | null }` + - `export type FilterType = "date" | "category" | "text"` + - `export const CATEGORY_THRESHOLD = 50` + - `export function distinctValues(key: string, data: Record[]): string[]` + - `export function inferFilterType(key: string, data: Record[]): FilterType` + - `export function matchesFilter(type: FilterType, cellValue: unknown, filterValue: string): boolean` + - `export function nextLockedRow(prev: string | null, clickedKey: string): string | null` + +- [ ] **Step 1: 安装 Vitest 并加 script/config** + +Run: +```bash +npm install -D vitest@^2 +``` + +Modify `package.json` —— 在 `"scripts"` 内新增(保留现有项): +```json +"test": "vitest run", +"test:watch": "vitest" +``` +(`vitest@^2` 为 ESM/Node 友好版本;若安装失败,改用与本地 Node 兼容的最新 v2/v3,记录实际版本。) + +Create `vitest.config.ts`: +```ts +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + include: ["src/**/*.test.ts"], + }, +}); +``` + +- [ ] **Step 2: 写失败测试(先全量写好)** + +Create `src/app/table-filters.test.ts`: +```ts +import { describe, it, expect } from "vitest"; +import { + CATEGORY_THRESHOLD, + distinctValues, + inferFilterType, + matchesFilter, + nextLockedRow, +} from "./table-filters"; + +const D = (rows: Record[]) => rows; + +describe("distinctValues", () => { + it("returns [] for empty data", () => { + expect(distinctValues("x", [])).toEqual([]); + }); + it("skips null/undefined/empty and dedupes", () => { + const data = D([{ x: "a" }, { x: "b" }, { x: "a" }, { x: null }, { x: "" }, { x: undefined }]); + expect(distinctValues("x", data).sort()).toEqual(["a", "b"]); + }); + it("stringifies numbers", () => { + expect(distinctValues("x", D([{ x: 1 }, { x: 2 }, { x: 1 }])).sort()).toEqual(["1", "2"]); + }); +}); + +describe("inferFilterType", () => { + it("date when all non-empty values are YYYY-MM-DD", () => { + const data = D([{ d: "2024-01-01" }, { d: "2025-12-31" }, { d: null }]); + expect(inferFilterType("d", data)).toBe("date"); + }); + it("category when distinct count <= threshold", () => { + const data = D([{ x: "A" }, { x: "B" }, { x: "C" }]); + expect(inferFilterType("x", data)).toBe("category"); + }); + it("text when distinct count exceeds threshold", () => { + const data = D(Array.from({ length: CATEGORY_THRESHOLD + 1 }, (_, i) => ({ x: String(i) }))); + expect(inferFilterType("x", data)).toBe("text"); + }); + it("text fallback when no data", () => { + expect(inferFilterType("x", [])).toBe("text"); + }); +}); + +describe("matchesFilter", () => { + it("text: case-insensitive substring", () => { + expect(matchesFilter("text", "Pressure Gauge", "press")).toBe(true); + expect(matchesFilter("text", "Pressure Gauge", "xyz")).toBe(false); + }); + it("text: treats null/undefined as empty", () => { + expect(matchesFilter("text", null, "anything")).toBe(false); + expect(matchesFilter("text", null, "")).toBe(true); + }); + it("category: exact equality", () => { + expect(matchesFilter("category", "A", "A")).toBe(true); + expect(matchesFilter("category", "A", "B")).toBe(false); + }); + it("date: within closed range 'start|end'", () => { + expect(matchesFilter("date", "2024-06-15", "2024-01-01|2024-12-31")).toBe(true); + expect(matchesFilter("date", "2023-06-15", "2024-01-01|2024-12-31")).toBe(false); + }); + it("date: open-ended range (empty side allowed)", () => { + expect(matchesFilter("date", "2030-01-01", "|2024-12-31")).toBe(false); + expect(matchesFilter("date", "2020-01-01", "2024-01-01|")).toBe(false); + expect(matchesFilter("date", "2024-06-15", "2024-01-01|")).toBe(true); + }); + it("date: empty cell never matches a non-empty filter", () => { + expect(matchesFilter("date", "", "2024-01-01|2024-12-31")).toBe(false); + expect(matchesFilter("date", null, "|")).toBe(false); + }); +}); + +describe("nextLockedRow", () => { + it("locks when nothing locked", () => { + expect(nextLockedRow(null, "5")).toBe("5"); + }); + it("unlocks when clicking the locked row", () => { + expect(nextLockedRow("5", "5")).toBe(null); + }); + it("switches when clicking a different row", () => { + expect(nextLockedRow("5", "7")).toBe("7"); + }); +}); +``` + +- [ ] **Step 3: 运行测试,确认失败(模块不存在)** + +Run: `npm test` +Expected: FAIL —— `Cannot find module './table-filters'`(或导入报错)。 + +- [ ] **Step 4: 实现纯逻辑** + +Create `src/app/table-filters.ts`: +```ts +export interface DataRow { + [key: string]: string | number | null; +} + +export type FilterType = "date" | "category" | "text"; + +export const CATEGORY_THRESHOLD = 50; + +const DATE_RE = /^\d{4}-\d{2}-\d{2}$/; + +/** Non-empty distinct stringified values of a column. */ +export function distinctValues( + key: string, + data: Record[] +): string[] { + const set = new Set(); + for (const row of data) { + const v = row[key]; + if (v === null || v === undefined) continue; + const s = String(v); + if (s !== "") set.add(s); + } + return [...set]; +} + +/** Infer filter UI type from the column's actual data. */ +export function inferFilterType( + key: string, + data: Record[] +): FilterType { + const vals = distinctValues(key, data); + if (vals.length === 0) return "text"; + if (vals.every((v) => DATE_RE.test(v))) return "date"; + if (vals.length <= CATEGORY_THRESHOLD) return "category"; + return "text"; +} + +/** + * Does a cell match a single filter key? + * - text: case-insensitive substring + * - category: exact equality + * - date: range encoded as "start|end" (either side may be ""), ISO string compare + */ +export function matchesFilter( + type: FilterType, + cellValue: unknown, + filterValue: string +): boolean { + const cell = + cellValue === null || cellValue === undefined ? "" : String(cellValue); + + if (type === "text") { + return cell.toLowerCase().includes(filterValue.toLowerCase()); + } + if (type === "category") { + return cell === filterValue; + } + // date + if (cell === "") return false; + const sep = filterValue.indexOf("|"); + if (sep < 0) return cell === filterValue; // defensive: single date exact + const start = filterValue.slice(0, sep); + const end = filterValue.slice(sep + 1); + if (start && cell < start) return false; + if (end && cell > end) return false; + return true; +} + +/** Toggle row-lock: click locked row -> unlock; click other -> switch. */ +export function nextLockedRow( + prev: string | null, + clickedKey: string +): string | null { + return prev === clickedKey ? null : clickedKey; +} +``` + +- [ ] **Step 5: 运行测试,确认通过** + +Run: `npm test` +Expected: PASS(全部 describe/it 通过)。 + +- [ ] **Step 6: Commit** + +```bash +git add src/app/table-filters.ts src/app/table-filters.test.ts vitest.config.ts package.json package-lock.json +git commit -m "Add pure filter/lock logic with Vitest tests" +``` + +--- + +## Task 2: FilterDropdown 组件 + 列筛选属性工厂 + +**Files:** +- Create: `src/app/table-filters.tsx` + +**Interfaces:** +- Consumes: `inferFilterType`, `distinctValues`, `matchesFilter`, `FilterType`, `DataRow`(来自 Task 1 的 `./table-filters`)。 +- Produces: + - `export function FilterDropdown(props: FilterDropdownProps & { type: FilterType; distinct: string[] }): React.ReactElement` + - `export function buildColumnFilterProps(key: string, data: DataRow[], filteredValue: React.Key[] | null): Partial>` + +- [ ] **Step 1: 实现前核对 antd v6 API(必做)** + +打开 `node_modules/antd/es/table/interface.d.ts`(或对应类型文件),确认: +- `FilterDropdownProps` 的导入路径与字段(`selectedKeys: React.Key[]`、`setSelectedKeys: (keys: React.Key[]) => void`、`confirm: (config?) => void`、`clearFilters?: () => void`、`visible`、`prefixCls`)。 +- `ColumnType` 的导入路径(`antd/es/table`)及 `filterDropdown`/`onFilter`/`filteredValue` 字段签名。 +- `DatePicker` 在 v6 仍基于 dayjs。 + +若路径/字段与下方代码不符,按实际调整。记录差异到 commit message。 + +- [ ] **Step 2: 创建 `src/app/table-filters.tsx`** + +```tsx +"use client"; + +import { Button, Input, Checkbox, DatePicker, Space } from "antd"; +import dayjs from "dayjs"; +import type { ColumnType, FilterDropdownProps } from "antd/es/table"; // 核对 v6 路径 +import { inferFilterType, distinctValues, matchesFilter } from "./table-filters"; +import type { DataRow, FilterType } from "./table-filters"; + +const DATE_FMT = "YYYY-MM-DD"; + +/** 解析受控 selectedKeys 里的 "start|end" 编码为 RangePicker 值。 */ +function decodeRange(keys: React.Key[]): [dayjs.Dayjs | null, dayjs.Dayjs | null] { + const raw = keys[0] != null ? String(keys[0]) : ""; + const sep = raw.indexOf("|"); + if (sep < 0) return [null, null]; + const s = raw.slice(0, sep); + const e = raw.slice(sep + 1); + return [s ? dayjs(s, DATE_FMT) : null, e ? dayjs(e, DATE_FMT) : null]; +} + +export function FilterDropdown({ + type, + distinct, + setSelectedKeys, + selectedKeys, + confirm, + clearFilters, +}: FilterDropdownProps & { + type: FilterType; + distinct: string[]; +}): React.ReactElement { + if (type === "date") { + const [start, end] = decodeRange(selectedKeys); + return ( +
+ { + if (!dates || dates[0] == null || dates[1] == null) { + setSelectedKeys([]); + } else { + const s = dates[0].format(DATE_FMT); + const e = dates[1].format(DATE_FMT); + setSelectedKeys([`${s}|${e}`]); // 单个编码键,供 onFilter 解码 + } + }} + /> + + + + +
+ ); + } + + if (type === "category") { + return ( +
+ setSelectedKeys(vals as React.Key[])} + options={distinct} + /> + + + + +
+ ); + } + + // text + const textValue = selectedKeys[0] != null ? String(selectedKeys[0]) : ""; + return ( +
+ setSelectedKeys(e.target.value ? [e.target.value] : [])} + onPressEnter={() => confirm()} + style={{ marginBottom: 8, display: "block" }} + allowClear + /> + + + + +
+ ); +} + +/** 为某列构造 antd 受控筛选属性(filterDropdown / onFilter / filteredValue)。 */ +export function buildColumnFilterProps( + key: string, + data: DataRow[], + filteredValue: React.Key[] | null +): Partial> { + const type = inferFilterType(key, data); + const distinct = type === "category" ? distinctValues(key, data) : []; + return { + filterDropdown: (props) => ( + + ), + onFilter: (value, record) => matchesFilter(type, record[key], String(value)), + filteredValue: filteredValue ?? null, + }; +} +``` + +> 说明:`FilterDropdownProps`/`ColumnType` 的导入路径务必按 Step 1 核对结果调整。分类下拉暂只做勾选(无选项内搜索),避免过度设计;若后续需要选项过滤再加 state。 + +- [ ] **Step 3: 类型检查** + +Run: `npx tsc --noEmit` +Expected: 无错误(若 antd 类型路径不符,按 Step 1 核对结果修正导入)。 + +- [ ] **Step 4: 手动联调占位(本任务尚未接入表格,仅确保编译通过)** + +无需手动验证行为(接入在 Task 5);确认 `npm run dev` 可正常启动无编译错误即可。 + +- [ ] **Step 5: Commit** + +```bash +git add src/app/table-filters.tsx +git commit -m "Add FilterDropdown component and column filter props factory" +``` + +--- + +## Task 3: 行高亮(悬停 + 点击锁定) + +**Files:** +- Modify: `src/app/globals.css` +- Modify: `src/app/page.tsx`(`DataRow` 改为导入;新增 `lockedRowKey` 状态、`onRow`、`rowClassName`;重置逻辑) + +**Interfaces:** +- Consumes: `nextLockedRow`, `DataRow`(来自 `./table-filters`)。 + +- [ ] **Step 1: 追加行高亮 CSS** + +Modify `src/app/globals.css` —— 在文件末尾追加: +```css +/* 行高亮:点击锁定的整行(覆盖 antd 单元格背景,含固定列) */ +.ant-table-tbody > tr.row-locked > td { + background: #fff7e6 !important; +} +``` + +- [ ] **Step 2: `page.tsx` 顶部导入调整** + +在 `page.tsx` 中,删除本地 `interface DataRow { ... }` 定义(第 37–39 行附近),改为从 `./table-filters` 导入 `DataRow` 与 `nextLockedRow`: + +在已有 import 区追加: +```ts +import { nextLockedRow, type DataRow } from "./table-filters"; +``` +(移除原文件内的 `interface DataRow { ... }` 三行。) + +- [ ] **Step 3: 新增 `lockedRowKey` 状态** + +在 `Home` 组件内(与其它 `useState` 并列,约第 445–452 行附近)新增: +```ts +const [lockedRowKey, setLockedRowKey] = useState(null); +``` + +- [ ] **Step 4: 在 `` 上加 `onRow` + `rowClassName`** + +定位 ``(约第 581 行),在 `rowKey="ID"` 之后追加: +```tsx + rowKey="ID" + onRow={(record) => ({ + onClick: () => + setLockedRowKey((prev) => nextLockedRow(prev, String(record.ID))), + })} + rowClassName={(record) => + String(record.ID) === lockedRowKey ? "row-locked" : "" + } +``` + +- [ ] **Step 5: 查询/清除时重置锁定** + +在 `handleSearch` 成功分支(`setData(json.data)` 之后、或 `finally` 前合适处)与 `handleClear` 内,清空锁定: +- `handleClear` 内(约第 481–486 行)追加:`setLockedRowKey(null);` +- `handleSearch` 内 `setSearched(true);` 之后追加:`setLockedRowKey(null);` + +- [ ] **Step 6: 手动验证** + +Run: `npm run dev`,浏览器打开,查询某车间号后: +1. 鼠标悬停行 → 默认 hover 高亮跟随;点击某行 → 整行橙底(`#fff7e6`),固定列与滚动列同步高亮。 +2. 左右横向滚动 → 该行高亮保持。 +3. 再点该行 → 取消高亮;点其他行 → 切换到新行。 +4. 点「清除」或重新查询 → 锁定重置。 + +Expected: 全部符合。 + +- [ ] **Step 7: Commit** + +```bash +git add src/app/globals.css src/app/page.tsx +git commit -m "Add click-to-lock row highlighting" +``` + +--- + +## Task 4: 可筛选列配置 + 持久化 + 列设置漏斗开关 + +**Files:** +- Modify: `src/app/page.tsx`(`ColumnConfig`、`DEFAULT_CONFIG`、`loadConfig`、`SortableRow`、`ColumnSettingsModal`) + +**Interfaces:** +- Produces: `ColumnConfig.filterable: boolean`;`loadConfig` 向前兼容旧配置。 + +- [ ] **Step 1: 扩展 `ColumnConfig` 与默认配置** + +修改 `interface ColumnConfig`(约第 41–44 行): +```ts +interface ColumnConfig { + key: string; + visible: boolean; + filterable: boolean; +} +``` + +修改 `DEFAULT_CONFIG`(约第 105–108 行): +```ts +const DEFAULT_CONFIG: ColumnConfig[] = COLUMN_DEFS.map((d) => ({ + key: d.key, + visible: true, + filterable: false, +})); +``` + +- [ ] **Step 2: `loadConfig` 向前兼容** + +修改 `loadConfig`(约第 112–124 行)—— 解析后补齐可能缺失的 `filterable`: +```ts +function loadConfig(): ColumnConfig[] { + try { + const saved = localStorage.getItem(STORAGE_KEY); + if (saved) { + const parsed = JSON.parse(saved) as Partial[]; + const savedKeys = new Set(parsed.map((c) => c.key)); + if (COLUMN_DEFS.every((d) => savedKeys.has(d.key))) { + return parsed.map((c) => ({ + key: c.key!, + visible: !!c.visible, + filterable: !!c.filterable, // 旧配置缺该字段时按 false + })); + } + } + } catch { + /* ignore */ + } + return DEFAULT_CONFIG.map((c) => ({ ...c })); +} +``` + +- [ ] **Step 3: 导入漏斗图标** + +修改顶部图标导入(约第 17 行),加入 `FilterOutlined`: +```ts +import { + SearchOutlined, + SettingOutlined, + HolderOutlined, + ClockCircleOutlined, + DownloadOutlined, + FilterOutlined, +} from "@ant-design/icons"; +``` + +- [ ] **Step 4: `SortableRow` 增加漏斗开关** + +修改 `SortableRow` 组件 props 与渲染(约第 153–197 行)。在 props 增加 `filterable` 与 `onToggleFilter`;在 Checkbox 旁加一个图标按钮: +```tsx +function SortableRow({ + id, + label, + visible, + filterable, + onToggle, + onToggleFilter, +}: { + id: string; + label: string; + visible: boolean; + filterable: boolean; + onToggle: () => void; + onToggleFilter: () => void; +}) { + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id }); + const style: React.CSSProperties = { + transform: CSS.Transform.toString(transform), + transition, + display: "flex", + alignItems: "center", + gap: 8, + padding: "6px 8px", + borderBottom: "1px solid #f5f5f5", + background: isDragging ? "#e6f4ff" : "#fff", + cursor: "default", + }; + + return ( +
+ + + + +
+ ); +} +``` + +- [ ] **Step 5: `ColumnSettingsModal` 支持 filterable 切换** + +在 `ColumnSettingsModal` 内(约第 201–302 行)新增 `toggleFilter`,并在渲染 `SortableRow` 时传入: +```ts +const toggleFilter = (key: string) => { + setLocal((prev) => + prev.map((c) => (c.key === key ? { ...c, filterable: !c.filterable } : c)) + ); +}; +``` + +把 `SortableRow` 调用处(约第 288–296 行)改为: +```tsx + toggle(c.key)} + onToggleFilter={() => toggleFilter(c.key)} +/> +``` + +「确定」时 `onApply(local)` 已携带含 `filterable` 的完整配置,无需改动 footer。 + +- [ ] **Step 6: 手动验证** + +Run: `npm run dev`,查询后点「列设置」: +1. 每行出现漏斗按钮;点一下变 primary(蓝),再点复原。 +2. 勾选若干列为可筛选 → 确定 → 刷新页面 → 再次打开「列设置」,可筛选标记仍在(持久化生效)。 +3. 确认旧用户(localStorage 中无 `filterable` 的旧配置)刷新后不报错、漏斗默认未选中。 + +Expected: 全部符合(本任务尚不影响表格筛选行为,接入在 Task 5)。 + +- [ ] **Step 7: Commit** + +```bash +git add src/app/page.tsx +git commit -m "Add per-column filterable flag with persistence and settings toggle" +``` + +--- + +## Task 5: 接入表格筛选 + 清除筛选按钮 + 重置 + +**Files:** +- Modify: `src/app/page.tsx`(`buildTableColumns`、`Home` 的 `filters` 状态与 `onChange`、`SearchBar`) + +**Interfaces:** +- Consumes: `buildColumnFilterProps`(来自 `./table-filters.tsx`,Task 2);`ColumnConfig.filterable`(Task 4)。 + +- [ ] **Step 1: 导入筛选工厂** + +在 `page.tsx` 顶部 import 区追加: +```ts +import { buildColumnFilterProps } from "./table-filters"; +``` + +- [ ] **Step 2: `buildTableColumns` 接入筛选属性** + +修改 `buildTableColumns`(约第 130–149 行),增加 `data` 与 `filters` 参数,对 `filterable` 列展开筛选属性: +```ts +function buildTableColumns( + config: ColumnConfig[], + dataColumns: string[], + data: DataRow[], + filters: Record +): ColumnsType { + const available = new Set(dataColumns); + return config + .filter((c) => c.visible && available.has(c.key)) + .map((c) => { + const def = COLUMN_DEFS.find((d) => d.key === c.key); + const base = { + title: c.key, + dataIndex: c.key, + key: c.key, + width: def?.width ?? 120, + ellipsis: true, + render: (val: string | number | null) => + val === null || val === undefined ? "" : String(val), + }; + return c.filterable + ? { ...base, ...buildColumnFilterProps(c.key, data, filters[c.key] ?? null) } + : base; + }); +} +``` + +- [ ] **Step 3: `Home` 增加 `filters` 状态与 `onChange`** + +在 `Home` 内新增状态(与 `lockedRowKey` 并列): +```ts +const [filters, setFilters] = useState>({}); +``` + +更新 `tableColumns` 的 `useMemo`(约第 518–521 行),传入 `data` 与 `filters`: +```ts +const tableColumns = useMemo( + () => buildTableColumns(columnConfig, columns, data, filters), + [columnConfig, columns, data, filters] +); +``` + +在 `
` 上加 `onChange`(与 Task 3 的 `onRow`/`rowClassName` 并列,约第 581 行附近): +```tsx + onChange={(_pagination, tableFilters) => + setFilters(tableFilters as Record) + } +``` + +- [ ] **Step 4: 查询/清除时重置筛选** + +- `handleClear` 内追加:`setFilters({});` +- `handleSearch` 内 `setSearched(true);` 之后追加:`setFilters({});` + +- [ ] **Step 5: 计算「是否有活跃筛选」并提供清除** + +在 `Home` 内(`tableColumns` 附近)新增: +```ts +const hasActiveFilters = useMemo( + () => Object.values(filters).some((arr) => arr && arr.length > 0), + [filters] +); +const clearFilters = useCallback(() => setFilters({}), []); +``` + +- [ ] **Step 6: `SearchBar` 增加 props 与「清除筛选」按钮** + +修改 `SearchBar` 的 props 类型(约第 329–347 行),追加: +```ts + hasActiveFilters: boolean; + onClearFilters: () => void; +``` + +在按钮区(「导出全部」按钮之后,约第 432 行前)加入按钮: +```tsx + +``` + +在 `Home` 的 `` 调用处(约第 533–542 行)传入新 props: +```tsx + hasActiveFilters={hasActiveFilters} + onClearFilters={clearFilters} +``` + +- [ ] **Step 7: 手动验证** + +Run: `npm run dev`,查询后: +1. 「列设置」里把若干列设为可筛选(含一个分类列如「车间」、一个日期列如「签订日期」、一个文本列如「客户名称」)→ 确定 → 对应表头出现漏斗图标。 +2. 分类列:下拉勾选若干值 → 确定 → 仅显示匹配行;多列同时筛选为 AND。 +3. 文本列:输入关键字 → 确定 → 子串匹配(不区分大小写)。 +4. 日期列:选范围 → 确定 → 区间内行显示。 +5. 点「清除筛选」→ 全部筛选复位、按钮变灰。 +6. 锁定某行(Task 3)后用筛选将其筛掉 → 高亮消失;清除筛选 → 该行重新高亮。 +7. 重新查询或点「清除」→ 筛选与锁定均重置。 + +Expected: 全部符合。 + +- [ ] **Step 8: 运行单测与类型检查** + +Run: `npm test && npx tsc --noEmit` +Expected: 单测 PASS;类型检查无错误。 + +- [ ] **Step 9: Commit** + +```bash +git add src/app/page.tsx +git commit -m "Wire controlled column filters and clear-filters button" +``` + +--- + +## Task 6: 生产构建 + 部署到 114 + 验证 + +**Files:** 无源码改动(构建与部署)。 + +**Reference:** `CLAUDE.local.md` 部署流程。 + +- [ ] **Step 1: 本地构建校验** + +Run: `npm run build` +Expected: 构建成功、无类型错误。若失败,按报错回修对应任务。 + +- [ ] **Step 2: 推送代码** + +```bash +git push +``` + +- [ ] **Step 3: 114 拉取代码** + +```bash +ssh 114 "cd C:\\Users\\peng\\projects\\ProductionDatabase\\web-table && git -c http.proxy=http://192.168.110.146:8080 -c https.proxy=http://192.168.110.146:8080 -c http.sslVerify=false -c credential.helper=\"\" pull" +``` + +- [ ] **Step 4: 114 安装依赖(新增了 vitest devDep)** + +```bash +ssh 114 "cd C:\\Users\\peng\\projects\\ProductionDatabase\\web-table && npm install" +``` + +- [ ] **Step 5: 114 重新构建** + +```bash +ssh 114 "cd C:\\Users\\peng\\projects\\ProductionDatabase\\web-table && npx next build" +``` +Expected: 构建成功。 + +- [ ] **Step 6: 重启服务** + +```bash +ssh 114 "nssm restart WebTable" +``` + +- [ ] **Step 7: 线上验证** + +浏览器访问 http://192.168.110.114:3081/ ,重复 Task 3 / Task 5 的全部手动验证项。 +Expected: 行高亮、列设置漏斗、表头三类筛选、清除筛选、锁定×筛选交互均正常。 + +- [ ] **Step 8: 收尾** + +确认无误后无需额外 commit(源码已在 Step 1–5 提交)。如线上发现问题,回到对应任务修复后重复 Step 2–7。