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:
@@ -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 { ... }` 定义(第 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<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` 内(约第 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<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 与渲染(约第 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 (
|
||||
<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` 内(约第 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
|
||||
<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`(约第 130–149 行),增加 `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`(约第 518–521 行),传入 `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 类型(约第 329–347 行),追加:
|
||||
```ts
|
||||
hasActiveFilters: boolean;
|
||||
onClearFilters: () => void;
|
||||
```
|
||||
|
||||
在按钮区(「导出全部」按钮之后,约第 432 行前)加入按钮:
|
||||
```tsx
|
||||
<Button
|
||||
icon={<FilterOutlined />}
|
||||
onClick={onClearFilters}
|
||||
disabled={!hasActiveFilters}
|
||||
>
|
||||
清除筛选
|
||||
</Button>
|
||||
```
|
||||
|
||||
在 `Home` 的 `<SearchBar ... />` 调用处(约第 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。
|
||||
Reference in New Issue
Block a user