Files
web-table/docs/superpowers/plans/2026-06-24-record-detail-context-menu.md
2026-06-24 11:23:32 +08:00

13 KiB
Raw Blame History

右键查看完整记录(记录详情 ModalImplementation 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 并单测;展示用 RecordDetailModalantd Modal + Descriptionspage.tsx 增加右键菜单浮层fixed 定位 + 透明 backdrop 关闭)与 modal 状态。内联表格保持 ellipsis 不变。

Tech Stack: Next 16.2.9(已存在 "use client" 组件内改动,无路由/server/API 变更、React 19.2.4、antd 6.4.3、vitestnode 环境,仅测纯逻辑)。

Global Constraints

  • antd 6.4.3Modal 不传 destroyOnClose/destroyOnHiddenv6 中前者已弃用,本逻辑无需销毁);Descriptions/Modal/Empty 已确认存在。遵 AGENTS.md:留意弃用提示。
  • 改动全部落在现有 "use client" 组件 src/app/page.tsx 及同目录新文件,无 Next.js 路由/server/API 变更
  • 测试vitestenvironment: node,仅匹配 src/**/*.test.ts(不渲染 React 组件UI 交互以浏览器手验为准。
  • UI 文案用中文与现有标签一致commit message 用英文。
  • 完成后不部署,仅启动本地 next dev 供用户验证,验证通过后再部署。

File Structure

文件 责任 动作
src/app/record-detail.ts 纯函数:pickRecordTitleformatCellValue 新增
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 / formatCellValueTDD

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): stringnull/空串/纯空白 → "—";其余 String(value)0 视为非空)。
  • Step 1: 写失败测试

Create src/app/record-detail.test.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:

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: 提交
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.tsximports ~L4-16、state ~L503、onRow ~L657-660、render ~L689

Interfaces:

  • Consumes: Task 1 的 pickRecordTitleformatCellValue;现有 DataRow(来自 ./table-filters)、columnsstring[],全量数据列名)。

  • Produces: 页面右键 → 菜单 → Modal 完整展示该行所有字段。

  • Step 1: 新建 RecordDetailModal 组件

Create src/app/record-detail-modal.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"; 之后)追加一行:

import { RecordDetailModal } from "./record-detail-modal";
  • Step 3: 在 page.tsx 增加状态

Home() 内现有 const [filters, setFilters] = ...(约 L503之后追加

  const [contextMenu, setContextMenu] = useState<{
    record: DataRow;
    x: number;
    y: number;
  } | null>(null);
  const [detailRecord, setDetailRecord] = useState<DataRow | null>(null);

并在 handleClearhandleSearch 中已有的 setFilters({}) 旁,分别追加关闭菜单/弹窗(搜索或清除后重置):

  • handleSearch 成功分支(setFilters({}); 之后)追加:

        setContextMenu(null);
        setDetailRecord(null);
    
  • handleClearsetFilters({}); 之后)追加:

      setContextMenu(null);
      setDetailRecord(null);
    
  • Step 4: 扩展 onRow 捕获右键

<Table>onRow(约 L657-660

            onRow={(record) => ({
              onClick: () =>
                setLockedRowKey((prev) => nextLockedRow(prev, String(record.ID))),
            })}

改为:

            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之前插入:

      {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)}
      />

关闭逻辑说明:浮层背后有透明 backdropz-1049任意左键/右键点击其区域即关闭菜单并屏蔽浏览器默认右键菜单右键菜单本体z-1050stopPropagation 防止误关。无 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: 提交

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/5onRow.onContextMenu + backdrop 关闭 + 表头不弹)
  • 5.2 受控行级菜单 → Task 2 Step 5backdrop + 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.3Modal 不传 destroy 属性

Placeholder scan: 无 TBD/TODO所有步骤含完整代码与命令。

Type consistency: pickRecordTitle(record: DataRow)formatCellValue(value: string|number|null): string 在 Task 1 定义Task 2 调用签名一致;RecordDetailModal propsrecord/columns/open/onClose定义与 page.tsx 挂载处一致。DataRow 统一来自 ./table-filters