Add implementation plan for row highlight and column filters

Six tasks: pure logic + Vitest, FilterDropdown component, click-to-lock
row highlight, filterable column config + persistence, controlled table
filters + clear button, then build/deploy to 114.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Misaka_Company
2026-06-18 09:28:28 +08:00
parent c39c2761d4
commit f8dcc268c6

View File

@@ -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, unknown>[]): string[]`
- `export function inferFilterType(key: string, data: Record<string, unknown>[]): 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<string, unknown>[]) => 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, unknown>[]
): string[] {
const set = new Set<string>();
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<string, unknown>[]
): 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<DataRow> & { type: FilterType; distinct: string[] }): React.ReactElement`
- `export function buildColumnFilterProps(key: string, data: DataRow[], filteredValue: React.Key[] | null): Partial<ColumnType<DataRow>>`
- [ ] **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<T>` 的导入路径(`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<DataRow> & {
type: FilterType;
distinct: string[];
}): React.ReactElement {
if (type === "date") {
const [start, end] = decodeRange(selectedKeys);
return (
<div style={{ padding: 8 }}>
<DatePicker.RangePicker
value={[start, end]}
onChange={(dates) => {
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 解码
}
}}
/>
<Space style={{ marginTop: 8, display: "flex", justifyContent: "flex-end" }}>
<Button
size="small"
onClick={() => {
clearFilters?.();
confirm();
}}
>
</Button>
<Button type="primary" size="small" onClick={() => confirm()}>
</Button>
</Space>
</div>
);
}
if (type === "category") {
return (
<div style={{ padding: 8, maxWidth: 260 }}>
<Checkbox.Group
style={{ display: "flex", flexDirection: "column", maxHeight: 240, overflow: "auto" }}
value={(selectedKeys as string[]) ?? []}
onChange={(vals) => setSelectedKeys(vals as React.Key[])}
options={distinct}
/>
<Space style={{ marginTop: 8, display: "flex", justifyContent: "flex-end" }}>
<Button
size="small"
onClick={() => {
clearFilters?.();
confirm();
}}
>
</Button>
<Button type="primary" size="small" onClick={() => confirm()}>
</Button>
</Space>
</div>
);
}
// text
const textValue = selectedKeys[0] != null ? String(selectedKeys[0]) : "";
return (
<div style={{ padding: 8 }}>
<Input
placeholder="输入关键字"
value={textValue}
onChange={(e) => setSelectedKeys(e.target.value ? [e.target.value] : [])}
onPressEnter={() => confirm()}
style={{ marginBottom: 8, display: "block" }}
allowClear
/>
<Space style={{ display: "flex", justifyContent: "flex-end" }}>
<Button
size="small"
onClick={() => {
clearFilters?.();
confirm();
}}
>
</Button>
<Button type="primary" size="small" onClick={() => confirm()}>
</Button>
</Space>
</div>
);
}
/** 为某列构造 antd 受控筛选属性filterDropdown / onFilter / filteredValue。 */
export function buildColumnFilterProps(
key: string,
data: DataRow[],
filteredValue: React.Key[] | null
): Partial<ColumnType<DataRow>> {
const type = inferFilterType(key, data);
const distinct = type === "category" ? distinctValues(key, data) : [];
return {
filterDropdown: (props) => (
<FilterDropdown type={type} distinct={distinct} {...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 { ... }` 定义(第 3739 行附近),改为从 `./table-filters` 导入 `DataRow``nextLockedRow`
在已有 import 区追加:
```ts
import { nextLockedRow, type DataRow } from "./table-filters";
```
(移除原文件内的 `interface DataRow { ... }` 三行。)
- [ ] **Step 3: 新增 `lockedRowKey` 状态**
`Home` 组件内(与其它 `useState` 并列,约第 445452 行附近)新增:
```ts
const [lockedRowKey, setLockedRowKey] = useState<string | null>(null);
```
- [ ] **Step 4: 在 `<Table>` 上加 `onRow` + `rowClassName`**
定位 `<Table<DataRow>`(约第 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` 内(约第 481486 行)追加:`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`(约第 4144 行):
```ts
interface ColumnConfig {
key: string;
visible: boolean;
filterable: boolean;
}
```
修改 `DEFAULT_CONFIG`(约第 105108 行):
```ts
const DEFAULT_CONFIG: ColumnConfig[] = COLUMN_DEFS.map((d) => ({
key: d.key,
visible: true,
filterable: false,
}));
```
- [ ] **Step 2: `loadConfig` 向前兼容**
修改 `loadConfig`(约第 112124 行)—— 解析后补齐可能缺失的 `filterable`
```ts
function loadConfig(): ColumnConfig[] {
try {
const saved = localStorage.getItem(STORAGE_KEY);
if (saved) {
const parsed = JSON.parse(saved) as Partial<ColumnConfig>[];
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 与渲染(约第 153197 行)。在 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 (
<div ref={setNodeRef} style={style}>
<span
{...attributes}
{...listeners}
style={{ cursor: "grab", color: "#bbb", fontSize: 16, flexShrink: 0 }}
>
<HolderOutlined />
</span>
<Checkbox checked={visible} onChange={onToggle} style={{ flexShrink: 0 }} />
<Button
size="small"
type={filterable ? "primary" : "default"}
icon={<FilterOutlined />}
onClick={onToggleFilter}
title={filterable ? "已设为可筛选,点击取消" : "设为可筛选"}
style={{ flexShrink: 0, padding: "0 6px" }}
/>
<span style={{ userSelect: "none" }}>{label}</span>
</div>
);
}
```
- [ ] **Step 5: `ColumnSettingsModal` 支持 filterable 切换**
`ColumnSettingsModal` 内(约第 201302 行)新增 `toggleFilter`,并在渲染 `SortableRow` 时传入:
```ts
const toggleFilter = (key: string) => {
setLocal((prev) =>
prev.map((c) => (c.key === key ? { ...c, filterable: !c.filterable } : c))
);
};
```
`SortableRow` 调用处(约第 288296 行)改为:
```tsx
<SortableRow
key={c.key}
id={c.key}
label={c.key}
visible={c.visible}
filterable={c.filterable}
onToggle={() => 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`(约第 130149 行),增加 `data``filters` 参数,对 `filterable` 列展开筛选属性:
```ts
function buildTableColumns(
config: ColumnConfig[],
dataColumns: string[],
data: DataRow[],
filters: Record<string, React.Key[]>
): ColumnsType<DataRow> {
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<Record<string, React.Key[]>>({});
```
更新 `tableColumns``useMemo`(约第 518521 行),传入 `data``filters`
```ts
const tableColumns = useMemo(
() => buildTableColumns(columnConfig, columns, data, filters),
[columnConfig, columns, data, filters]
);
```
`<Table>` 上加 `onChange`(与 Task 3 的 `onRow`/`rowClassName` 并列,约第 581 行附近):
```tsx
onChange={(_pagination, tableFilters) =>
setFilters(tableFilters as Record<string, React.Key[]>)
}
```
- [ ] **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 类型(约第 329347 行),追加:
```ts
hasActiveFilters: boolean;
onClearFilters: () => void;
```
在按钮区(「导出全部」按钮之后,约第 432 行前)加入按钮:
```tsx
<Button
icon={<FilterOutlined />}
onClick={onClearFilters}
disabled={!hasActiveFilters}
>
</Button>
```
`Home``<SearchBar ... />` 调用处(约第 533542 行)传入新 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 15 提交)。如线上发现问题,回到对应任务修复后重复 Step 27。