Add implementation plan: record detail context menu
This commit is contained in:
372
docs/superpowers/plans/2026-06-24-record-detail-context-menu.md
Normal file
372
docs/superpowers/plans/2026-06-24-record-detail-context-menu.md
Normal file
@@ -0,0 +1,372 @@
|
|||||||
|
# 右键查看完整记录(记录详情 Modal)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:** 在生产数据表的数据行上右键,弹出自定义菜单「查看完整记录」,点击后用 Modal 完整展示该记录的全部字段。
|
||||||
|
|
||||||
|
**Architecture:** 纯逻辑(取标题、格式化单元格值)抽到 `record-detail.ts` 并单测;展示用 `RecordDetailModal`(antd `Modal` + `Descriptions`);`page.tsx` 增加右键菜单浮层(fixed 定位 + 透明 backdrop 关闭)与 modal 状态。内联表格保持 `ellipsis` 不变。
|
||||||
|
|
||||||
|
**Tech Stack:** Next 16.2.9(已存在 `"use client"` 组件内改动,无路由/server/API 变更)、React 19.2.4、antd 6.4.3、vitest(node 环境,仅测纯逻辑)。
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- antd **6.4.3**:`Modal` 不传 `destroyOnClose`/`destroyOnHidden`(v6 中前者已弃用,本逻辑无需销毁);`Descriptions`/`Modal`/`Empty` 已确认存在。遵 `AGENTS.md`:留意弃用提示。
|
||||||
|
- 改动全部落在现有 `"use client"` 组件 `src/app/page.tsx` 及同目录新文件,**无 Next.js 路由/server/API 变更**。
|
||||||
|
- 测试:vitest,`environment: node`,仅匹配 `src/**/*.test.ts`(不渲染 React 组件);UI 交互以浏览器手验为准。
|
||||||
|
- UI 文案用中文(与现有标签一致);commit message 用英文。
|
||||||
|
- **完成后不部署**,仅启动本地 `next dev` 供用户验证,验证通过后再部署。
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
| 文件 | 责任 | 动作 |
|
||||||
|
|------|------|------|
|
||||||
|
| `src/app/record-detail.ts` | 纯函数:`pickRecordTitle`、`formatCellValue` | 新增 |
|
||||||
|
| `src/app/record-detail.test.ts` | 上述纯函数的 vitest 单测 | 新增 |
|
||||||
|
| `src/app/record-detail-modal.tsx` | `RecordDetailModal` 展示组件(Modal + Descriptions) | 新增 |
|
||||||
|
| `src/app/page.tsx` | 右键菜单浮层 + onRow.onContextMenu + 状态 + 挂载 Modal | 修改 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: 纯函数 `pickRecordTitle` / `formatCellValue`(TDD)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/app/record-detail.ts`
|
||||||
|
- Test: `src/app/record-detail.test.ts`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces:
|
||||||
|
- `pickRecordTitle(record: DataRow): string` — 按 `生产订单号 → 总排号 → ID` 取首个非空字段字符串;全空返回 `"(未命名)"`。
|
||||||
|
- `formatCellValue(value: string | number | null): string` — `null`/空串/纯空白 → `"—"`;其余 `String(value)`(`0` 视为非空)。
|
||||||
|
|
||||||
|
- [ ] **Step 1: 写失败测试**
|
||||||
|
|
||||||
|
Create `src/app/record-detail.test.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { pickRecordTitle, formatCellValue } from "./record-detail";
|
||||||
|
import type { DataRow } from "./table-filters";
|
||||||
|
|
||||||
|
describe("pickRecordTitle", () => {
|
||||||
|
it("prefers 生产订单号", () => {
|
||||||
|
const row: DataRow = { 生产订单号: "PO-1", 总排号: "X", ID: 1 };
|
||||||
|
expect(pickRecordTitle(row)).toBe("PO-1");
|
||||||
|
});
|
||||||
|
it("falls back to 总排号 when 生产订单号 blank", () => {
|
||||||
|
const row: DataRow = { 生产订单号: " ", 总排号: "X-9", ID: 1 };
|
||||||
|
expect(pickRecordTitle(row)).toBe("X-9");
|
||||||
|
});
|
||||||
|
it("falls back to ID when earlier are null/empty", () => {
|
||||||
|
const row: DataRow = { 生产订单号: null, 总排号: "", ID: 42 };
|
||||||
|
expect(pickRecordTitle(row)).toBe("42");
|
||||||
|
});
|
||||||
|
it("returns (未命名) when all blank", () => {
|
||||||
|
const row: DataRow = { 生产订单号: null, 总排号: null, ID: null };
|
||||||
|
expect(pickRecordTitle(row)).toBe("(未命名)");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("formatCellValue", () => {
|
||||||
|
it("null -> em dash", () => {
|
||||||
|
expect(formatCellValue(null)).toBe("—");
|
||||||
|
});
|
||||||
|
it("empty / whitespace -> em dash", () => {
|
||||||
|
expect(formatCellValue("")).toBe("—");
|
||||||
|
expect(formatCellValue(" ")).toBe("—");
|
||||||
|
});
|
||||||
|
it("0 is NOT blank", () => {
|
||||||
|
expect(formatCellValue(0)).toBe("0");
|
||||||
|
});
|
||||||
|
it("number stringified", () => {
|
||||||
|
expect(formatCellValue(123)).toBe("123");
|
||||||
|
});
|
||||||
|
it("text preserved", () => {
|
||||||
|
expect(formatCellValue("量程0-1.6MPa")).toBe("量程0-1.6MPa");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: 运行测试确认失败**
|
||||||
|
|
||||||
|
Run: `npx vitest run src/app/record-detail.test.ts`
|
||||||
|
Expected: FAIL — `pickRecordTitle` / `formatCellValue` 未导出(模块不存在)。
|
||||||
|
|
||||||
|
- [ ] **Step 3: 写最小实现**
|
||||||
|
|
||||||
|
Create `src/app/record-detail.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import type { DataRow } from "./table-filters";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取记录的友好标识(用于详情 Modal 标题)。
|
||||||
|
* 优先级:生产订单号 > 总排号 > ID > (未命名)。
|
||||||
|
*/
|
||||||
|
export function pickRecordTitle(record: DataRow): string {
|
||||||
|
for (const key of ["生产订单号", "总排号", "ID"]) {
|
||||||
|
const v = record[key];
|
||||||
|
if (v !== null && v !== undefined && String(v).trim() !== "") {
|
||||||
|
return String(v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "(未命名)";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 格式化单元格值用于详情 Modal 完整展示。
|
||||||
|
* null / 空串 / 纯空白 -> "—";其余 String(value)。0 视为非空(与 isBlank 一致)。
|
||||||
|
*/
|
||||||
|
export function formatCellValue(value: string | number | null): string {
|
||||||
|
if (value === null || value === undefined) return "—";
|
||||||
|
const s = String(value);
|
||||||
|
if (s.trim() === "") return "—";
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: 运行测试确认通过**
|
||||||
|
|
||||||
|
Run: `npx vitest run src/app/record-detail.test.ts`
|
||||||
|
Expected: PASS(全部用例)。
|
||||||
|
|
||||||
|
- [ ] **Step 5: 提交**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/app/record-detail.ts src/app/record-detail.test.ts
|
||||||
|
git commit -m "Add record-detail helpers (title picker, cell formatter)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: RecordDetailModal 组件 + page.tsx 右键菜单与挂载
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/app/record-detail-modal.tsx`
|
||||||
|
- Modify: `src/app/page.tsx`(imports ~L4-16、state ~L503、onRow ~L657-660、render ~L689)
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: Task 1 的 `pickRecordTitle`、`formatCellValue`;现有 `DataRow`(来自 `./table-filters`)、`columns`(`string[]`,全量数据列名)。
|
||||||
|
- Produces: 页面右键 → 菜单 → Modal 完整展示该行所有字段。
|
||||||
|
|
||||||
|
- [ ] **Step 1: 新建 `RecordDetailModal` 组件**
|
||||||
|
|
||||||
|
Create `src/app/record-detail-modal.tsx`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { CSSProperties } from "react";
|
||||||
|
import { Modal, Descriptions, Empty } from "antd";
|
||||||
|
import type { DataRow } from "./table-filters";
|
||||||
|
import { pickRecordTitle, formatCellValue } from "./record-detail";
|
||||||
|
|
||||||
|
const VALUE_STYLE: CSSProperties = {
|
||||||
|
whiteSpace: "pre-wrap",
|
||||||
|
wordBreak: "break-all",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function RecordDetailModal({
|
||||||
|
record,
|
||||||
|
columns,
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
record: DataRow | null;
|
||||||
|
columns: string[];
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
title={`记录详情:${record ? pickRecordTitle(record) : ""}`}
|
||||||
|
open={open}
|
||||||
|
onCancel={onClose}
|
||||||
|
footer={null}
|
||||||
|
width={900}
|
||||||
|
>
|
||||||
|
{record && columns.length > 0 ? (
|
||||||
|
<Descriptions column={2} bordered size="small">
|
||||||
|
{columns.map((col) => (
|
||||||
|
<Descriptions.Item key={col} label={col}>
|
||||||
|
<span style={VALUE_STYLE}>{formatCellValue(record[col])}</span>
|
||||||
|
</Descriptions.Item>
|
||||||
|
))}
|
||||||
|
</Descriptions>
|
||||||
|
) : (
|
||||||
|
<Empty description="无数据" />
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: 在 `page.tsx` 增加 import**
|
||||||
|
|
||||||
|
在 `src/app/page.tsx` 顶部 import 区(`import { nextLockedRow, type DataRow } from "./table-filters";` 之后)追加一行:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { RecordDetailModal } from "./record-detail-modal";
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: 在 `page.tsx` 增加状态**
|
||||||
|
|
||||||
|
在 `Home()` 内现有 `const [filters, setFilters] = ...`(约 L503)之后追加:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const [contextMenu, setContextMenu] = useState<{
|
||||||
|
record: DataRow;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
} | null>(null);
|
||||||
|
const [detailRecord, setDetailRecord] = useState<DataRow | null>(null);
|
||||||
|
```
|
||||||
|
|
||||||
|
并在 `handleClear` 与 `handleSearch` 中已有的 `setFilters({})` 旁,分别追加关闭菜单/弹窗(搜索或清除后重置):
|
||||||
|
|
||||||
|
- `handleSearch` 成功分支(`setFilters({});` 之后)追加:
|
||||||
|
```ts
|
||||||
|
setContextMenu(null);
|
||||||
|
setDetailRecord(null);
|
||||||
|
```
|
||||||
|
- `handleClear`(`setFilters({});` 之后)追加:
|
||||||
|
```ts
|
||||||
|
setContextMenu(null);
|
||||||
|
setDetailRecord(null);
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: 扩展 `onRow` 捕获右键**
|
||||||
|
|
||||||
|
将 `<Table>` 的 `onRow`(约 L657-660)由:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
onRow={(record) => ({
|
||||||
|
onClick: () =>
|
||||||
|
setLockedRowKey((prev) => nextLockedRow(prev, String(record.ID))),
|
||||||
|
})}
|
||||||
|
```
|
||||||
|
|
||||||
|
改为:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
onRow={(record) => ({
|
||||||
|
onClick: () =>
|
||||||
|
setLockedRowKey((prev) => nextLockedRow(prev, String(record.ID))),
|
||||||
|
onContextMenu: (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setContextMenu({ record, x: e.clientX, y: e.clientY });
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: 渲染右键菜单浮层 + 挂载 Modal**
|
||||||
|
|
||||||
|
在 `<ColumnSettingsModal ... />`(约 L689-694)**之后**、根 `</div>`(约 L695)**之前**插入:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
{contextMenu && (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
style={{ position: "fixed", inset: 0, zIndex: 1049 }}
|
||||||
|
onClick={() => setContextMenu(null)}
|
||||||
|
onContextMenu={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setContextMenu(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "fixed",
|
||||||
|
left: contextMenu.x,
|
||||||
|
top: contextMenu.y,
|
||||||
|
zIndex: 1050,
|
||||||
|
background: "#fff",
|
||||||
|
borderRadius: 8,
|
||||||
|
boxShadow: "0 6px 16px rgba(0,0,0,0.12)",
|
||||||
|
border: "1px solid #f0f0f0",
|
||||||
|
padding: 4,
|
||||||
|
minWidth: 140,
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
onContextMenu={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
onClick={() => {
|
||||||
|
setDetailRecord(contextMenu.record);
|
||||||
|
setContextMenu(null);
|
||||||
|
}}
|
||||||
|
style={{ padding: "6px 14px", cursor: "pointer", fontSize: 14 }}
|
||||||
|
onMouseEnter={(e) =>
|
||||||
|
(e.currentTarget.style.background = "#f0f7ff")
|
||||||
|
}
|
||||||
|
onMouseLeave={(e) =>
|
||||||
|
(e.currentTarget.style.background = "transparent")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
查看完整记录
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<RecordDetailModal
|
||||||
|
record={detailRecord}
|
||||||
|
columns={columns}
|
||||||
|
open={!!detailRecord}
|
||||||
|
onClose={() => setDetailRecord(null)}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
> 关闭逻辑说明:浮层背后有透明 backdrop(z-1049),任意左键/右键点击其区域即关闭菜单并屏蔽浏览器默认右键菜单;右键菜单本体(z-1050)`stopPropagation` 防止误关。无 `useEffect`/全局监听,无竞态。
|
||||||
|
|
||||||
|
- [ ] **Step 6: 运行全部测试 + lint,确保无回归**
|
||||||
|
|
||||||
|
Run: `npm test`
|
||||||
|
Expected: 全部 PASS(含原有 `table-filters.test.ts` 与新 `record-detail.test.ts`)。
|
||||||
|
|
||||||
|
Run: `npm run lint`
|
||||||
|
Expected: 无 error(新增 `.tsx` 文件被 eslint 覆盖)。
|
||||||
|
|
||||||
|
- [ ] **Step 7: 浏览器手验(启动本地服务,不部署)**
|
||||||
|
|
||||||
|
Run(后台): `npm run dev`
|
||||||
|
打开: http://localhost:3000
|
||||||
|
|
||||||
|
验证清单:
|
||||||
|
- [ ] 输入车间号查询,出现数据表。
|
||||||
|
- [ ] 在**数据行**上右键 → 出现自定义菜单「查看完整记录」,且**不**弹出浏览器默认右键菜单。
|
||||||
|
- [ ] 表头 / 空白处右键 → 不出现自定义菜单(浏览器默认菜单照常)。
|
||||||
|
- [ ] 点「查看完整记录」→ 弹出 Modal,标题为 `记录详情:<生产订单号/总排号/ID>`;内容为该行**全部字段**(含已隐藏列),按数据列顺序排列。
|
||||||
|
- [ ] 长内容字段(如 技术参数 / 缺件明细)在 Modal 内**自动换行、完整可见、不截断**;字段很多时 Modal 内可纵向滚动。
|
||||||
|
- [ ] 空值字段显示为 `—`。
|
||||||
|
- [ ] 左键点击行 → 仍可锁定/解锁高亮(橙底);右键与左键互不干扰。
|
||||||
|
- [ ] Esc / 点击 Modal 外 / 点 backdrop → 分别能关闭 Modal / 菜单。
|
||||||
|
- [ ] 点「清除」或重新查询 → 菜单与 Modal 被重置。
|
||||||
|
|
||||||
|
- [ ] **Step 8: 提交**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/app/record-detail-modal.tsx src/app/page.tsx
|
||||||
|
git commit -m "Add right-click context menu to view full record in modal"
|
||||||
|
git push
|
||||||
|
```
|
||||||
|
|
||||||
|
> 推送但不部署;等用户本地验证通过后,再按 `CLAUDE.local.md` 流程部署到 114。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-Review
|
||||||
|
|
||||||
|
**Spec coverage:**
|
||||||
|
- 5.1 交互流程 → Task 2 Step 4/5(onRow.onContextMenu + backdrop 关闭 + 表头不弹)✅
|
||||||
|
- 5.2 受控行级菜单 → Task 2 Step 5(backdrop + fixed 浮层,采用兜底自定义浮层方案,避开 antd Dropdown 受控定位风险)✅
|
||||||
|
- 5.3 Modal(标题主标识、Descriptions 多列、全字段、滚动)→ Task 2 Step 1 ✅
|
||||||
|
- 5.4 值渲染(pre-wrap/break-all、空→—)→ Task 1 `formatCellValue` + Step 1 VALUE_STYLE ✅
|
||||||
|
- 5.5 状态与 onRow → Task 2 Step 3/4 ✅
|
||||||
|
- 5.6 涉及文件 → File Structure 表 ✅
|
||||||
|
- 6 边界(实时刷新快照、空值、超长、表头、筛选隐藏)→ 设计天然覆盖 + Step 7 验证 ✅
|
||||||
|
- 7 测试 → Task 1 单测 + Task 2 Step 7 手验清单 ✅
|
||||||
|
- 8 待核对(antd v6 API)→ 已核对 antd 6.4.3,Modal 不传 destroy 属性 ✅
|
||||||
|
|
||||||
|
**Placeholder scan:** 无 TBD/TODO;所有步骤含完整代码与命令。
|
||||||
|
|
||||||
|
**Type consistency:** `pickRecordTitle(record: DataRow)`、`formatCellValue(value: string|number|null): string` 在 Task 1 定义,Task 2 调用签名一致;`RecordDetailModal` props(record/columns/open/onClose)定义与 `page.tsx` 挂载处一致。`DataRow` 统一来自 `./table-filters`。
|
||||||
Reference in New Issue
Block a user