Compare commits
20 Commits
f8dcc268c6
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cd2f8b75df | ||
|
|
bc0e95157c | ||
|
|
3371a8af2a | ||
|
|
2a5f5763b4 | ||
|
|
e495a9b142 | ||
|
|
2bd177faf6 | ||
|
|
964dfbb94f | ||
|
|
a4afa3a8f5 | ||
|
|
f3fa1a0e1b | ||
|
|
6531254715 | ||
|
|
207bc6a7a1 | ||
|
|
3212769644 | ||
|
|
e91bc90d0f | ||
|
|
dca5335f9a | ||
|
|
933b779a10 | ||
|
|
2f3cd9fa06 | ||
|
|
f8914fb287 | ||
|
|
f78918c3b0 | ||
|
|
b893fd558a | ||
|
|
4faa960591 |
6
.gitignore
vendored
6
.gitignore
vendored
@@ -41,4 +41,8 @@ yarn-error.log*
|
||||
next-env.d.ts
|
||||
|
||||
# .gitignore
|
||||
CLAUDE.local.md
|
||||
CLAUDE.local.md
|
||||
.claude/
|
||||
|
||||
# runtime logs (log4js app logs, NSSM stdout/stderr)
|
||||
/logs/
|
||||
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`。
|
||||
454
docs/superpowers/plans/2026-06-24-structured-logging.md
Normal file
454
docs/superpowers/plans/2026-06-24-structured-logging.md
Normal file
@@ -0,0 +1,454 @@
|
||||
# 结构化日志(log4js fileSync)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:** 给生产数据查询工具加上结构化、分级、同步落盘、自带轮转的服务端日志,聚焦错误与崩溃诊断(尤其 export OOM)。
|
||||
|
||||
**Architecture:** log4js 的 `fileSync` appender(同步写 = 崩溃前可靠落盘)写到 `logs/app/app.log`(10MB×5 轮转),由 `src/server/logger.ts` 单例配置;纯函数 `formatError` 抽到 `log-format.ts` 单测;两个 API 路由替换 `console.*` 为带完整堆栈与耗时字段的 logger 调用。NSSM 的 stdout/stderr 保持不动作兜底。
|
||||
|
||||
**Tech Stack:** Next 16.2.9(route handlers,server-only)、log4js 6.9.1(新增依赖)、vitest(node 环境,仅测纯逻辑)。
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- 新增依赖 **log4js@6.9.1**,用其 **`fileSync`** appender(同步写)。部署到 114 时**必须** `npm install`。
|
||||
- logger 仅在服务端(API route)使用,依赖 Node `fs`;不得被客户端组件引入。
|
||||
- 日志文件:项目根下 `logs/app/app.log`,`maxLogSize=10MB`、`backups=5`,pattern 布局 `[%d{ISO8601}] [%p] [%c] %m`,默认级别 `info`。
|
||||
- 测试:vitest(`environment: node`,匹配 `src/**/*.test.ts`)。**仅** `formatError` 做单测;路由日志为副作用,手验。
|
||||
- 不改 `dbConfig` 重复、不改业务返回逻辑、不动 NSSM stdout/stderr。
|
||||
- commit message 用英文;实现后启动本地 `next dev` 供验证,**通过后再部署**。
|
||||
|
||||
## File Structure
|
||||
|
||||
| 文件 | 责任 | 动作 |
|
||||
|------|------|------|
|
||||
| `src/server/log-format.ts` | 纯函数 `formatError(err)`:提取堆栈/字符串 | 新增 |
|
||||
| `src/server/log-format.test.ts` | `formatError` 单测 | 新增 |
|
||||
| `src/server/logger.ts` | log4js configure(fileSync)+ 导出 `apiLogger`/`exportLogger` + 再导出 `formatError` | 新增 |
|
||||
| `src/app/api/production-data/route.ts` | 失败 ERROR(带栈+workshopNo+dur)、慢查询 WARN(>3s) | 修改 |
|
||||
| `src/app/api/export-excel/route.ts` | 锁占用/回收 WARN、开始/成功 INFO(rows/bytes/dur)、失败 ERROR(带栈+dur) | 修改 |
|
||||
| `package.json` | +`log4js` | 修改 |
|
||||
|
||||
---
|
||||
|
||||
## Task 1: log4js 依赖 + `formatError`(TDD)+ logger 模块
|
||||
|
||||
**Files:**
|
||||
- Create: `src/server/log-format.ts`
|
||||
- Test: `src/server/log-format.test.ts`
|
||||
- Create: `src/server/logger.ts`
|
||||
- Modify: `package.json`(`npm install log4js`)
|
||||
|
||||
**Interfaces:**
|
||||
- Produces:
|
||||
- `formatError(err: unknown): string`(来自 `log-format.ts`,`logger.ts` 再导出)。
|
||||
- `apiLogger`、`exportLogger`:log4js Logger 实例(category 分别为 `"api"`、`"export"`)。
|
||||
|
||||
- [ ] **Step 1: 安装依赖**
|
||||
|
||||
Run: `npm install log4js`
|
||||
Expected: `added N packages`,`package.json` 出现 `"log4js": "^6.9.1"`。
|
||||
|
||||
- [ ] **Step 2: 写失败测试**
|
||||
|
||||
Create `src/server/log-format.test.ts`:
|
||||
|
||||
```ts
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { formatError } from "./log-format";
|
||||
|
||||
describe("formatError", () => {
|
||||
it("returns stack for Error", () => {
|
||||
const e = new Error("boom");
|
||||
expect(formatError(e)).toBe(e.stack);
|
||||
});
|
||||
it("falls back to message when stack missing", () => {
|
||||
const e = new Error("boom");
|
||||
e.stack = undefined as unknown as string;
|
||||
expect(formatError(e)).toBe("boom");
|
||||
});
|
||||
it("stringifies non-Error primitives", () => {
|
||||
expect(formatError("oops")).toBe("oops");
|
||||
expect(formatError(42)).toBe("42");
|
||||
});
|
||||
it("handles null / undefined", () => {
|
||||
expect(formatError(null)).toBe("Unknown error");
|
||||
expect(formatError(undefined)).toBe("Unknown error");
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 运行测试确认失败**
|
||||
|
||||
Run: `npx vitest run src/server/log-format.test.ts`
|
||||
Expected: FAIL — `Cannot find module './log-format'`。
|
||||
|
||||
- [ ] **Step 4: 写 `formatError` 实现**
|
||||
|
||||
Create `src/server/log-format.ts`:
|
||||
|
||||
```ts
|
||||
/**
|
||||
* 把任意错误/值格式化为带堆栈的字符串,用于日志记录。
|
||||
* - Error:优先 stack,缺则 message
|
||||
* - null/undefined:'Unknown error'
|
||||
* - 其它:String(value)
|
||||
*/
|
||||
export function formatError(err: unknown): string {
|
||||
if (err instanceof Error) return err.stack || err.message;
|
||||
if (err === null || err === undefined) return "Unknown error";
|
||||
return String(err);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: 运行测试确认通过**
|
||||
|
||||
Run: `npx vitest run src/server/log-format.test.ts`
|
||||
Expected: PASS(4 用例)。
|
||||
|
||||
- [ ] **Step 6: 写 logger 模块**
|
||||
|
||||
Create `src/server/logger.ts`:
|
||||
|
||||
```ts
|
||||
import log4js from "log4js";
|
||||
|
||||
// fileSync = 同步写:进程 OOM/被杀前日志已落盘,满足崩溃诊断刚需。
|
||||
log4js.configure({
|
||||
appenders: {
|
||||
app: {
|
||||
type: "fileSync",
|
||||
filename: "logs/app/app.log",
|
||||
maxLogSize: 10 * 1024 * 1024, // 10MB
|
||||
backups: 5,
|
||||
layout: { type: "pattern", pattern: "[%d{ISO8601}] [%p] [%c] %m" },
|
||||
},
|
||||
},
|
||||
categories: { default: { appenders: ["app"], level: "info" } },
|
||||
});
|
||||
|
||||
export { formatError } from "./log-format";
|
||||
|
||||
export const apiLogger = log4js.getLogger("api");
|
||||
export const exportLogger = log4js.getLogger("export");
|
||||
```
|
||||
|
||||
- [ ] **Step 7: 跑全量测试 + lint**
|
||||
|
||||
Run: `npm test`
|
||||
Expected: 全部 PASS(原 `table-filters`/`record-detail` + 新 `log-format`)。
|
||||
|
||||
Run: `npm run lint`
|
||||
Expected: 0 error(`log-format.ts` 被 eslint 覆盖;`logger.ts` 仅在服务端被路由引用,不进客户端)。
|
||||
|
||||
- [ ] **Step 8: 提交**
|
||||
|
||||
```bash
|
||||
git add package.json package-lock.json src/server/log-format.ts src/server/log-format.test.ts src/server/logger.ts
|
||||
git commit -m "Add log4js fileSync logger module and formatError helper"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: 给 `/api/production-data` 接日志
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/app/api/production-data/route.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: Task 1 的 `apiLogger`、`formatError`(`import { apiLogger, formatError } from "../../../server/logger"`)。
|
||||
|
||||
- [ ] **Step 1: 改 import 与加常量**
|
||||
|
||||
在文件顶部 import 区,`import sql from "mssql";` 之后加:
|
||||
|
||||
```ts
|
||||
import { apiLogger, formatError } from "../../../server/logger";
|
||||
|
||||
const SLOW_QUERY_MS = 3000;
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 计时 + 失败带栈 + 慢查询 WARN**
|
||||
|
||||
把 `export async function GET(request: NextRequest) {` 内的 try/catch/finally 改为(`dbConfig` 不变,中间序列化逻辑不变,仅在外围加计时与日志):
|
||||
|
||||
```ts
|
||||
let pool: sql.ConnectionPool | undefined;
|
||||
const t0 = Date.now();
|
||||
try {
|
||||
pool = await sql.connect(dbConfig);
|
||||
const result = await pool
|
||||
.request()
|
||||
.input("车间号", sql.NVarChar(50), workshopNo)
|
||||
.execute("[productionContractData].[sp_压力表合同生产数据_按车间号]");
|
||||
|
||||
const recordset = result.recordset;
|
||||
if (!recordset || recordset.length === 0) {
|
||||
return NextResponse.json({ columns: [], data: [], total: 0 });
|
||||
}
|
||||
|
||||
const columns = Object.keys(recordset[0]);
|
||||
const data = recordset.map((row: Record<string, unknown>) => {
|
||||
const serialized: Record<string, unknown> = {};
|
||||
for (const key of columns) {
|
||||
const val = row[key];
|
||||
if (val instanceof Date) {
|
||||
serialized[key] = val.toISOString().split("T")[0];
|
||||
} else {
|
||||
serialized[key] = val;
|
||||
}
|
||||
}
|
||||
return serialized;
|
||||
});
|
||||
|
||||
const dur = Date.now() - t0;
|
||||
if (dur > SLOW_QUERY_MS) {
|
||||
apiLogger.warn(`slow query · workshopNo=${workshopNo} · dur=${dur}ms · rows=${data.length}`);
|
||||
}
|
||||
return NextResponse.json({ columns, data, total: data.length });
|
||||
} catch (err) {
|
||||
const dur = Date.now() - t0;
|
||||
const message = err instanceof Error ? err.message : "Unknown error";
|
||||
apiLogger.error(`query failed · workshopNo=${workshopNo} · dur=${dur}ms · ${formatError(err)}`);
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
} finally {
|
||||
if (pool) {
|
||||
await pool.close();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> 注意:删除原 `console.error("DB query error:", message);`,由 `apiLogger.error(...)` 取代。
|
||||
|
||||
- [ ] **Step 3: 测试 + lint**
|
||||
|
||||
Run: `npm test && npm run lint`
|
||||
Expected: 测试全 PASS、lint 0 error。
|
||||
|
||||
- [ ] **Step 4: 提交**
|
||||
|
||||
```bash
|
||||
git add src/app/api/production-data/route.ts
|
||||
git commit -m "Log production-data query failures (with stack) and slow queries"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: 给 `/api/export-excel` 接日志
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/app/api/export-excel/route.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: Task 1 的 `exportLogger`、`formatError`(`import { exportLogger, formatError } from "../../../server/logger"`)。
|
||||
|
||||
- [ ] **Step 1: 加 import**
|
||||
|
||||
`import ExcelJS from "exceljs";` 之后加:
|
||||
|
||||
```ts
|
||||
import { exportLogger, formatError } from "../../../server/logger";
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 锁回收 WARN(替换 console.warn)**
|
||||
|
||||
把 `acquireExportLock` 里的:
|
||||
|
||||
```ts
|
||||
if (exportLocked && Date.now() - exportLockedAt > EXPORT_LOCK_TIMEOUT_MS) {
|
||||
console.warn("Export lock reclaimed after timeout");
|
||||
exportLocked = false;
|
||||
}
|
||||
```
|
||||
|
||||
改为:
|
||||
|
||||
```ts
|
||||
if (exportLocked && Date.now() - exportLockedAt > EXPORT_LOCK_TIMEOUT_MS) {
|
||||
exportLogger.warn("export lock reclaimed after timeout");
|
||||
exportLocked = false;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 429 占用 WARN + 计时 + 成功 INFO + 失败 ERROR**
|
||||
|
||||
把 `export async function GET() {` 改为(返回逻辑/锁释放不变,仅加日志与计时):
|
||||
|
||||
```ts
|
||||
export async function GET() {
|
||||
if (!acquireExportLock()) {
|
||||
exportLogger.warn("export skipped: another export in progress (429)");
|
||||
return NextResponse.json(
|
||||
{ error: "正在导出,请稍候" },
|
||||
{ status: 429, headers: { "Retry-After": "3" } }
|
||||
);
|
||||
}
|
||||
const t0 = Date.now();
|
||||
exportLogger.info("export started");
|
||||
let pool: sql.ConnectionPool | undefined;
|
||||
try {
|
||||
pool = await sql.connect(dbConfig);
|
||||
const result = await pool
|
||||
.request()
|
||||
.execute("[productionContractData].[sp_压力表合同生产数据_全部]");
|
||||
|
||||
const recordset = result.recordset;
|
||||
if (!recordset || recordset.length === 0) {
|
||||
exportLogger.warn("export empty: no data");
|
||||
return NextResponse.json({ error: "没有数据可导出" }, { status: 404 });
|
||||
}
|
||||
|
||||
const columns = Object.keys(recordset[0]);
|
||||
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
const sheet = workbook.addWorksheet("压力表合同生产数据");
|
||||
|
||||
// Header row
|
||||
const headerRow = sheet.addRow(columns);
|
||||
headerRow.eachCell((cell) => {
|
||||
cell.font = { bold: true };
|
||||
cell.fill = {
|
||||
type: "pattern",
|
||||
pattern: "solid",
|
||||
fgColor: { argb: "FFE0EAF6" },
|
||||
};
|
||||
cell.alignment = { horizontal: "center" };
|
||||
});
|
||||
|
||||
// Data rows
|
||||
for (const row of recordset) {
|
||||
const values = columns.map((col) => {
|
||||
const val = row[col];
|
||||
if (val instanceof Date) {
|
||||
return val;
|
||||
}
|
||||
if (val === null || val === undefined) return "";
|
||||
return val;
|
||||
});
|
||||
sheet.addRow(values);
|
||||
}
|
||||
|
||||
// Format date columns and auto-width
|
||||
const dateColIndices: number[] = [];
|
||||
const maxWidths: number[] = columns.map((col) => col.length);
|
||||
|
||||
columns.forEach((col, idx) => {
|
||||
if (DATE_COLUMNS.has(col)) {
|
||||
dateColIndices.push(idx + 1);
|
||||
}
|
||||
});
|
||||
|
||||
sheet.eachRow((row, rowNumber) => {
|
||||
row.eachCell({ includeEmpty: false }, (cell, colNumber) => {
|
||||
if (rowNumber > 1 && dateColIndices.includes(colNumber)) {
|
||||
if (cell.value instanceof Date) {
|
||||
cell.numFmt = "yyyy/mm/dd";
|
||||
}
|
||||
}
|
||||
const text = cell.text || "";
|
||||
const width = Math.max(maxWidths[colNumber - 1] || 0, text.length + 2);
|
||||
maxWidths[colNumber - 1] = width;
|
||||
});
|
||||
});
|
||||
|
||||
columns.forEach((_, idx) => {
|
||||
sheet.getColumn(idx + 1).width = Math.min(Math.max(maxWidths[idx], 8), 50);
|
||||
});
|
||||
|
||||
sheet.views = [{ state: "frozen", ySplit: 1 }];
|
||||
|
||||
const buffer = await workbook.xlsx.writeBuffer();
|
||||
const bytes = typeof buffer === "object" && buffer && "byteLength" in buffer
|
||||
? (buffer as { byteLength: number }).byteLength
|
||||
: 0;
|
||||
const dur = Date.now() - t0;
|
||||
exportLogger.info(`export ok · rows=${recordset.length} · bytes=${bytes} · dur=${dur}ms`);
|
||||
|
||||
return new NextResponse(buffer, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type":
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
"Content-Disposition":
|
||||
"attachment; filename*=UTF-8''" + encodeURIComponent("压力表合同生产数据.xlsx"),
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
const dur = Date.now() - t0;
|
||||
const message = err instanceof Error ? err.message : "Unknown error";
|
||||
exportLogger.error(`export failed · dur=${dur}ms · ${formatError(err)}`);
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
} finally {
|
||||
if (pool) {
|
||||
await pool.close();
|
||||
}
|
||||
releaseExportLock();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> 注意:删除原 `console.error("Excel export error:", message);`,由 `exportLogger.error(...)` 取代。
|
||||
|
||||
- [ ] **Step 4: 测试 + lint**
|
||||
|
||||
Run: `npm test && npm run lint`
|
||||
Expected: 测试全 PASS、lint 0 error。
|
||||
|
||||
- [ ] **Step 5: 提交**
|
||||
|
||||
```bash
|
||||
git add src/app/api/export-excel/route.ts
|
||||
git commit -m "Log export lifecycle (start/ok/fail/lock) with stack and timing"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: 本地验证(不部署)
|
||||
|
||||
- [ ] **Step 1: 启动本地服务**
|
||||
|
||||
Run(后台): `npm run dev`
|
||||
打开: http://localhost:3000
|
||||
|
||||
- [ ] **Step 2: 触发 production-data 失败,确认 ERROR 带栈**
|
||||
|
||||
临时让 DB 查询失败(如临时把 `.env.local` 的 `DB_SERVER` 改成不可达值再重启,或直接断网/停 DB),查询一个车间号。
|
||||
Expected: `logs/app/app.log` 出现一行:
|
||||
`[...] [ERROR] [api] query failed · workshopNo=<值> · dur=<N>ms · <Error 类型>: <msg>\n at ...`(**含完整堆栈**)。
|
||||
|
||||
- [ ] **Step 3: 触发导出,确认 INFO/ERROR**
|
||||
|
||||
恢复 DB,查询出数据后点「导出全部」。
|
||||
Expected: `logs/app/app.log` 依次出现:
|
||||
- `[INFO] [export] export started`
|
||||
- `[INFO] [export] export ok · rows=<N> · bytes=<N> · dur=<N>ms`
|
||||
|
||||
(可选)导出过程中再点一次导出,确认 `[WARN] [export] export skipped: another export in progress (429)`。
|
||||
|
||||
- [ ] **Step 4: 确认轮转目录**
|
||||
|
||||
确认 `logs/app/` 目录被创建,`app.log` 存在;NSSM 的 `logs/stdout.log`/`stderr.log` 不再混入这些应用日志(只有 Next 框架输出)。
|
||||
|
||||
- [ ] **Step 5: 停止本地服务 + 推送**
|
||||
|
||||
Run: 停止 dev;`git push`。
|
||||
|
||||
> 部署到 114:pull → `npm install`(新增 log4js)→ `next build` → 重启 WebTable。验证通过后再执行。
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
**Spec coverage:**
|
||||
- 4.1 log4js fileSync → Task 1 Step 6 ✅
|
||||
- 4.2 分层(不动 NSSM)→ Global Constraints + Task 4 Step 4 ✅
|
||||
- 4.3 logger 模块 + 输出样例 → Task 1 Step 6(pattern 与字段在路由日志中体现)✅
|
||||
- 4.4 记什么矩阵:production 失败 ERROR / 慢查询 WARN → Task 2;export 锁占用/回收 WARN、开始/成功 INFO、失败 ERROR、启动 INFO → Task 3 Step 3(含 `export started`);console.* 全替换 → Task 2/3 ✅
|
||||
- 4.5 formatError + 路由带栈 → Task 1(formatError)+ Task 2/3(带栈)✅
|
||||
- 4.6 涉及文件 → File Structure ✅
|
||||
- 5 边界(OOM 落盘/目录/轮转/dev 与生产)→ Global Constraints + Task 4 ✅
|
||||
- 6 测试(formatError 单测 + 手验)→ Task 1 Step 2-5 + Task 4 ✅
|
||||
- 7 部署注意(npm install)→ Global Constraints + Task 4 Step 5 ✅
|
||||
|
||||
**Placeholder scan:** 无 TBD/TODO;每步含完整代码与命令。
|
||||
|
||||
**Type consistency:** `formatError(err: unknown): string` 在 Task 1 定义,Task 2/3 调用一致;`apiLogger`/`exportLogger` 来自 Task 1 的 `logger.ts`;import 路径 `../../../server/logger`(两路由同深,正确)。
|
||||
@@ -0,0 +1,140 @@
|
||||
# 生产数据表 —— 右键查看完整记录(记录详情 Modal)
|
||||
|
||||
- 日期:2026-06-24
|
||||
- 状态:设计(待评审)
|
||||
- 关联需求:生产部需求 ③「长内容字段完整显示」
|
||||
|
||||
## 1. 背景与问题
|
||||
|
||||
生产数据表字段多(约 60 列)、表格极宽(总宽 6000px+)。当前所有列 `ellipsis: true`
|
||||
(`src/app/page.tsx:159`),长内容被 `...` 截断,仅靠浏览器**原生 title 气泡**显示全文。
|
||||
长规格类字段(技术参数 / 新参数 / 缺件明细 / 特殊要求 / 备注 等)难以完整查看。
|
||||
|
||||
需求 ①(行高亮)与 ②(字段筛选)已在代码中实现,本次只做 ③。
|
||||
|
||||
## 2. 目标
|
||||
|
||||
在不破坏现有「左键点击锁行」「列筛选」交互的前提下,提供一种零冲突、彻底的方式查看
|
||||
某条记录的全部字段完整内容。
|
||||
|
||||
## 3. 方案概述
|
||||
|
||||
接管数据行的右键菜单:在**数据行**上右键 → 弹出自定义菜单(屏蔽浏览器默认菜单)→
|
||||
点「查看完整记录」→ 打开 Modal,将该记录的**所有数据字段**完整铺开展示(含当前被
|
||||
隐藏的列)。表格内联仍保持 `ellipsis`,Modal 作为「完整查看」入口。
|
||||
|
||||
## 4. 非目标(YAGNI)
|
||||
|
||||
- 不改内联单元格的截断行为(仍是 `ellipsis`)。
|
||||
- v1 右键菜单**仅一项**「查看完整记录」;不做「复制整行 / 锁定行」等扩展项(后续可加)。
|
||||
- 不做列宽自适应、不做单元格自动换行(表格已过宽,会破坏紧凑度)。
|
||||
- 不做悬浮 Tooltip 预览(右键 Modal 已覆盖该诉求)。
|
||||
|
||||
## 5. 详细设计
|
||||
|
||||
### 5.1 交互流程
|
||||
|
||||
1. 鼠标在任一**数据行**上右键 → 阻止浏览器默认菜单,在光标处弹出自定义菜单,
|
||||
仅含「查看完整记录」。
|
||||
2. 点击该菜单项 → 打开记录详情 Modal;同时关闭右键菜单。
|
||||
3. 右键菜单在以下情况关闭:选中菜单项、点击页面其它处、滚动、按 Esc、在别处再次右键。
|
||||
4. 表头 / 空白区域右键**不弹**本菜单(无法定位「哪条记录」)。
|
||||
|
||||
### 5.2 右键菜单(受控、行级作用域)
|
||||
|
||||
实现策略:通过 `Table` 的 `onRow.onContextMenu` 在**数据行**上捕获右键事件,记录
|
||||
`{ record, x, y }` 到状态;表头/空白不触发 `onRow`,故天然不会误弹。
|
||||
|
||||
菜单浮层采用「受控定位」渲染:
|
||||
|
||||
- 优先方案:antd `Dropdown` 受控(`open` 由状态驱动,`trigger={[]}` 完全受控,
|
||||
`overlayStyle={{ position: 'fixed', left, top }}`),复用 antd 菜单样式与内置的
|
||||
点击外部 / Esc 关闭逻辑。
|
||||
- 兜底方案:若 antd v6 受控 Dropdown 定位有兼容问题,改用一个 `position: fixed` 的
|
||||
自定义样式浮层 + 全局 click/scroll/keydown(Esc) 监听关闭。
|
||||
- 实现时须按 `AGENTS.md` 要求核对 antd v6 的 `Dropdown` / `Modal` / `Descriptions` API
|
||||
与 `node_modules/antd` 实际版本一致后再编码。
|
||||
|
||||
### 5.3 记录详情 Modal
|
||||
|
||||
- 组件:antd `Modal`,`open={!!detailRecord}`,`onCancel` 关闭,`destroyOnClose`。
|
||||
- 宽度:约 900px,居中。
|
||||
- 标题:`记录详情:${主标识}`。主标识取值优先级:
|
||||
`生产订单号` → `总排号` → `ID`(取第一个非空字段,否则显示「(未命名)」)。
|
||||
- 内容:antd `Descriptions`,`column={2}`、`bordered`、`size="small"`,按数据列原始
|
||||
顺序遍历**全部数据字段**生成 `<Descriptions.Item label={列名}>{值}</...>`。
|
||||
- 当字段数多导致超高时,Modal 内容区纵向滚动。
|
||||
|
||||
### 5.4 字段值渲染
|
||||
|
||||
- 纯函数 `formatCellValue(value)`:`null` / `undefined` / trim 后为空 → 渲染 `—`
|
||||
(全角破折号,便于区分「空值」与「未渲染」);其余 → `String(value)`。
|
||||
- 值样式:`whiteSpace: 'pre-wrap'`、`wordBreak: 'break-all'`,长内容自动换行撑高,
|
||||
完整可见不截断。
|
||||
|
||||
### 5.5 状态变更(`page.tsx`)
|
||||
|
||||
新增两个 state:
|
||||
|
||||
```ts
|
||||
const [contextMenu, setContextMenu] =
|
||||
useState<{ record: DataRow; x: number; y: number } | null>(null);
|
||||
const [detailRecord, setDetailRecord] = useState<DataRow | null>(null);
|
||||
```
|
||||
|
||||
`onRow` 返回值在现有 `onClick`(锁行)基础上新增 `onContextMenu`:
|
||||
|
||||
```ts
|
||||
onRow={(record) => ({
|
||||
onClick: () => setLockedRowKey((prev) => nextLockedRow(prev, String(record.ID))),
|
||||
onContextMenu: (e) => {
|
||||
e.preventDefault();
|
||||
setContextMenu({ record, x: e.clientX, y: e.clientY });
|
||||
},
|
||||
})}
|
||||
```
|
||||
|
||||
菜单项点击 → `setDetailRecord(contextMenu.record); setContextMenu(null);`。
|
||||
Modal 关闭 → `setDetailRecord(null)`。
|
||||
|
||||
> Modal 持有打开时刻的 `record` 快照,即使后台数据实时刷新也不会被冲掉。
|
||||
|
||||
### 5.6 涉及文件
|
||||
|
||||
| 文件 | 改动 |
|
||||
|------|------|
|
||||
| `src/app/page.tsx` | 新增 context-menu / detail-modal 状态与渲染;扩展 `onRow` |
|
||||
| `src/app/record-detail.ts`(新增) | 纯函数:`pickRecordTitle(record, columns)`、`formatCellValue(value)` |
|
||||
| `src/app/record-detail.test.ts`(新增) | 上述纯函数的单元测试(vitest) |
|
||||
|
||||
> 详情 Modal 与右键菜单浮层可直接内联在 `page.tsx`(v1 体量小),或抽成
|
||||
> `record-detail-modal.tsx` —— 视实现时 `page.tsx` 行数决定,避免单文件过大。
|
||||
|
||||
## 6. 边界情况
|
||||
|
||||
- **实时刷新**:Modal/菜单持有快照,不受数据刷新影响。
|
||||
- **空值字段**:渲染 `—`。
|
||||
- **超长单字段**(如多行技术参数):`pre-wrap` + `break-all`,Descriptions 项自动撑高,
|
||||
Modal 内滚动。
|
||||
- **表头 / 空白右键**:不触发本菜单(`onRow` 仅作用于数据行)。
|
||||
- **右键后行被筛选隐藏**:Modal 已持快照,仍可正常查看。
|
||||
- **主标识缺失**:标题回退到「(未命名)」。
|
||||
|
||||
## 7. 测试
|
||||
|
||||
项目已用 vitest(`src/app/table-filters.test.ts`)。沿用该模式,对**可纯函数化**的逻辑
|
||||
写单元测试,UI 行为以浏览器手验为准:
|
||||
|
||||
- `pickRecordTitle`:生产订单号优先 > 总排号 > ID > 「(未命名)」;各字段为空时的回退。
|
||||
- `formatCellValue`:null / undefined / 空串 / 纯空白 → `—`;普通值原样字符串化;
|
||||
数字 `0`、`false` 等非空值不误判为空。
|
||||
|
||||
UI 验证清单(浏览器):数据行右键弹菜单且屏蔽默认菜单;表头/空白右键无菜单;菜单项打开
|
||||
Modal 且内容为该行全部字段完整展示;长内容换行不截断;Esc/点击外部关闭菜单与 Modal;
|
||||
锁行(左键)与右键互不干扰。
|
||||
|
||||
## 8. 待实现时核对
|
||||
|
||||
- antd v6:`Dropdown`(受控 + `trigger=[]` + `overlayStyle` fixed 定位)、`Modal`、
|
||||
`Descriptions` 的实际 API 与类型(`ColumnsType` / `DataRow` 已有)。
|
||||
- 受控 Dropdown 定位若不符合预期,切换到 5.2 的兜底自定义浮层方案。
|
||||
113
docs/superpowers/specs/2026-06-24-structured-logging-design.md
Normal file
113
docs/superpowers/specs/2026-06-24-structured-logging-design.md
Normal file
@@ -0,0 +1,113 @@
|
||||
# 结构化日志系统(稳定性诊断)设计
|
||||
|
||||
- 日期:2026-06-24
|
||||
- 状态:设计(已口头确认,待实现)
|
||||
- 范围:v1 聚焦**稳定性诊断**——捕获错误与崩溃(尤其 `/api/export-excel` 的 OOM 路径)。
|
||||
|
||||
## 1. 背景与问题
|
||||
|
||||
当前服务端只有 3 处 ad-hoc `console.error/warn`,且只记 `err.message`(**丢失堆栈**)。
|
||||
输出被 NSSM 原样灌进 `logs/stdout.log`/`stderr.log`,追加、不轮转——导致崩溃堆栈堆积、不可检索。
|
||||
诊断 export 的 OOM(单次全量导出峰值 ~1.3GB 堆)时,缺少"哪次导出、多少行、耗时多久"的现场。
|
||||
|
||||
约束:114 不通外网 → 自包含文件日志,排除 SaaS;单 Node 进程、内部工具、低流量。
|
||||
|
||||
## 2. 目标
|
||||
|
||||
在不改动业务逻辑的前提下,新增一层**结构化、分级、同步落盘、自带轮转**的应用日志,
|
||||
让"出错/崩溃时能查到完整现场"。
|
||||
|
||||
## 3. 非目标(YAGNI)
|
||||
|
||||
- 不做请求级全量审计 / 用户行为追踪(属"运行可见性/审计",非 v1)。
|
||||
- 不做客户端(浏览器)错误采集。
|
||||
- 不做集中式日志聚合(ELK 等)。
|
||||
- 不重构 `dbConfig` 两处重复(不属于本次范围)。
|
||||
|
||||
## 4. 方案
|
||||
|
||||
### 4.1 选型:log4js `fileSync`
|
||||
|
||||
- **`fileSync` appender 同步写**(`fs.appendFileSync`)→ 进程 OOM/被杀前日志已落盘,满足崩溃诊断刚需。
|
||||
- 内置按大小轮转(`maxLogSize` + `backups`),无需手写。
|
||||
- 分级、category、pattern layout,成熟轻量。
|
||||
- 引入依赖 `log4js`(约 ~3 个小间接依赖)。
|
||||
|
||||
### 4.2 分层
|
||||
|
||||
```
|
||||
应用日志(新增) log4js fileSync → logs/app/app.log(同步 + 10MB×5 轮转) ← 查错误/崩溃
|
||||
框架/致命日志 NSSM 的 stdout/stderr(Next 启动、V8 崩溃栈) ← 兜底,保持不动
|
||||
```
|
||||
|
||||
### 4.3 logger 模块 `src/server/logger.ts`
|
||||
|
||||
```ts
|
||||
import log4js from "log4js";
|
||||
|
||||
log4js.configure({
|
||||
appenders: {
|
||||
app: {
|
||||
type: "fileSync",
|
||||
filename: "logs/app/app.log",
|
||||
maxLogSize: 10 * 1024 * 1024, // 10MB
|
||||
backups: 5,
|
||||
layout: { type: "pattern", pattern: "[%d{ISO8601}] [%p] [%c] %m" },
|
||||
},
|
||||
},
|
||||
categories: { default: { appenders: ["app"], level: "info" } },
|
||||
});
|
||||
|
||||
export const apiLogger = log4js.getLogger("api");
|
||||
export const exportLogger = log4js.getLogger("export");
|
||||
```
|
||||
|
||||
- `filename` 用相对路径,`next start`(NSSM 的 `AppDirectory`)/`next dev` 的 cwd 均为项目根,解析正确。
|
||||
- 输出样例:
|
||||
`[2026-06-24T10:30:01.123] [ERROR] [export] Excel export failed · rows=33142 · dur=18234ms · RangeError: Invalid string length\n at ...`
|
||||
|
||||
### 4.4 记什么(诊断聚焦,不给常规成功查询加噪)
|
||||
|
||||
| 事件 | 级别 | 字段 |
|
||||
|----|----|----|
|
||||
| production-data 查询失败 | ERROR | workshopNo、完整堆栈 |
|
||||
| production-data 慢查询(>3s) | WARN | workshopNo、耗时 |
|
||||
| export 锁被并发占用(429) | WARN | — |
|
||||
| export 锁超时回收 | WARN | 取代现有 `console.warn` |
|
||||
| export 开始 / 成功 | INFO | rows、bytes、耗时(OOM 诊断核心线索) |
|
||||
| export 失败 | ERROR | 完整堆栈、rows、耗时 |
|
||||
| 进程启动 | INFO | logger ready |
|
||||
|
||||
要点:现有 3 处 `console.error/warn` **全部替换**为 logger 调用;`err.message` **升级为完整 `err.stack`**。
|
||||
|
||||
### 4.5 路由改造
|
||||
|
||||
- `production-data/route.ts`:catch 分支用 `apiLogger.error(...)` 带栈 + workshopNo;查询耗时 >3s 时 `apiLogger.warn(...)`。
|
||||
- `export-excel/route.ts`:锁占用/回收 `exportLogger.warn`;开始记 `rows`、成功记 `rows/bytes/dur`、失败 `exportLogger.error` 带栈 + dur;保留现有返回逻辑不变。
|
||||
- 用一个纯函数 `formatError(err): string` 统一提取 `err.stack ?? String(err)`,便于单测。
|
||||
|
||||
### 4.6 涉及文件
|
||||
|
||||
| 文件 | 动作 |
|
||||
|----|----|
|
||||
| `src/server/logger.ts` | 新增(log4js 配置 + 导出 logger + `formatError`) |
|
||||
| `src/app/api/production-data/route.ts` | 改:失败 ERROR(带栈)+ 慢查询 WARN |
|
||||
| `src/app/api/export-excel/route.ts` | 改:锁/开始/成功/失败 全套日志(带栈) |
|
||||
| `package.json` | +`log4js` 依赖 |
|
||||
|
||||
## 5. 边界情况
|
||||
|
||||
- **OOM 落盘**:fileSync 同步写,每行即时 flush,崩溃前已写入。
|
||||
- **首次写入**:log4js 自动创建 `logs/app/` 目录与文件。
|
||||
- **轮转**:达到 `maxLogSize` 自动滚动为 `app.log.1..5`,更早的删除。
|
||||
- **本地 dev vs 114 生产**:均写入项目根下 `logs/app/app.log`;不影响 NSSM 的 stdout/stderr。
|
||||
|
||||
## 6. 测试
|
||||
|
||||
- 纯函数 `formatError(err)`:用 vitest 单测(Error→stack、非 Error→字符串、null/undefined 安全)。
|
||||
- 路由日志为副作用,以**手验**为准:本地断开 DB 触发 production-data 失败 → 确认 `logs/app/app.log` 有 ERROR 带栈行;触发一次导出 → 确认 INFO(rows/bytes/dur)/ 失败 ERROR 行。
|
||||
- 不引入 log4js 行为的单测(第三方,按项目惯例不测)。
|
||||
|
||||
## 7. 部署注意
|
||||
|
||||
新增 `log4js` 依赖,114 部署须执行 `npm install`(CLAUDE.local.md 第 3 步),再 `next build` + 重启。
|
||||
905
package-lock.json
generated
905
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -6,7 +6,9 @@
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint"
|
||||
"lint": "eslint",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "^6.2.5",
|
||||
@@ -15,6 +17,7 @@
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"antd": "^6.4.3",
|
||||
"exceljs": "^4.4.0",
|
||||
"log4js": "^6.9.1",
|
||||
"mssql": "^12.5.5",
|
||||
"next": "16.2.9",
|
||||
"react": "19.2.4",
|
||||
@@ -29,6 +32,7 @@
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.2.9",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5"
|
||||
"typescript": "^5",
|
||||
"vitest": "^4.1.9"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import sql from "mssql";
|
||||
import ExcelJS from "exceljs";
|
||||
import { exportLogger, formatError } from "../../../server/logger";
|
||||
|
||||
const dbConfig = {
|
||||
server: process.env.DB_SERVER!,
|
||||
@@ -41,7 +42,43 @@ const DATE_COLUMNS = new Set([
|
||||
"烘洗",
|
||||
]);
|
||||
|
||||
/*
|
||||
* Serialize exports. A single export of the full dataset (~33k rows) peaks at
|
||||
* ~1.3GB heap; letting two run at once would exceed Node's default ~2GB heap
|
||||
* and OOM the server. All /api/export-excel requests are handled by the one
|
||||
* Next server process (the listener on the port), so a module-level flag
|
||||
* correctly serializes concurrent requests: the 2nd+ callers get an instant
|
||||
* 429 and the frontend retries. Memory peak is capped at one export at a time.
|
||||
*/
|
||||
let exportLocked = false;
|
||||
let exportLockedAt = 0;
|
||||
const EXPORT_LOCK_TIMEOUT_MS = 90_000; // safety: reclaim a stuck lock after 90s
|
||||
|
||||
function acquireExportLock(): boolean {
|
||||
if (exportLocked && Date.now() - exportLockedAt > EXPORT_LOCK_TIMEOUT_MS) {
|
||||
exportLogger.warn("export lock reclaimed after timeout");
|
||||
exportLocked = false;
|
||||
}
|
||||
if (exportLocked) return false;
|
||||
exportLocked = true;
|
||||
exportLockedAt = Date.now();
|
||||
return true;
|
||||
}
|
||||
|
||||
function releaseExportLock() {
|
||||
exportLocked = false;
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
if (!acquireExportLock()) {
|
||||
exportLogger.warn("export skipped: another export in progress (429)");
|
||||
return NextResponse.json(
|
||||
{ error: "正在导出,请稍候" },
|
||||
{ status: 429, headers: { "Retry-After": "3" } }
|
||||
);
|
||||
}
|
||||
const t0 = Date.now();
|
||||
exportLogger.info("export started");
|
||||
let pool: sql.ConnectionPool | undefined;
|
||||
try {
|
||||
pool = await sql.connect(dbConfig);
|
||||
@@ -51,6 +88,7 @@ export async function GET() {
|
||||
|
||||
const recordset = result.recordset;
|
||||
if (!recordset || recordset.length === 0) {
|
||||
exportLogger.warn("export empty: no data");
|
||||
return NextResponse.json({ error: "没有数据可导出" }, { status: 404 });
|
||||
}
|
||||
|
||||
@@ -118,6 +156,12 @@ export async function GET() {
|
||||
|
||||
// Stream to buffer
|
||||
const buffer = await workbook.xlsx.writeBuffer();
|
||||
const bytes =
|
||||
typeof buffer === "object" && buffer && "byteLength" in buffer
|
||||
? (buffer as { byteLength: number }).byteLength
|
||||
: 0;
|
||||
const dur = Date.now() - t0;
|
||||
exportLogger.info(`export ok · rows=${recordset.length} · bytes=${bytes} · dur=${dur}ms`);
|
||||
|
||||
return new NextResponse(buffer, {
|
||||
status: 200,
|
||||
@@ -129,12 +173,14 @@ export async function GET() {
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
const dur = Date.now() - t0;
|
||||
const message = err instanceof Error ? err.message : "Unknown error";
|
||||
console.error("Excel export error:", message);
|
||||
exportLogger.error(`export failed · dur=${dur}ms · ${formatError(err)}`);
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
} finally {
|
||||
if (pool) {
|
||||
await pool.close();
|
||||
}
|
||||
releaseExportLock();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import sql from "mssql";
|
||||
import { apiLogger, formatError } from "../../../server/logger";
|
||||
|
||||
const SLOW_QUERY_MS = 3000;
|
||||
|
||||
const dbConfig = {
|
||||
server: process.env.DB_SERVER!,
|
||||
@@ -25,6 +28,7 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
|
||||
let pool: sql.ConnectionPool | undefined;
|
||||
const t0 = Date.now();
|
||||
try {
|
||||
pool = await sql.connect(dbConfig);
|
||||
const result = await pool
|
||||
@@ -53,10 +57,15 @@ export async function GET(request: NextRequest) {
|
||||
return serialized;
|
||||
});
|
||||
|
||||
const dur = Date.now() - t0;
|
||||
if (dur > SLOW_QUERY_MS) {
|
||||
apiLogger.warn(`slow query · workshopNo=${workshopNo} · dur=${dur}ms · rows=${data.length}`);
|
||||
}
|
||||
return NextResponse.json({ columns, data, total: data.length });
|
||||
} catch (err) {
|
||||
const dur = Date.now() - t0;
|
||||
const message = err instanceof Error ? err.message : "Unknown error";
|
||||
console.error("DB query error:", message);
|
||||
apiLogger.error(`query failed · workshopNo=${workshopNo} · dur=${dur}ms · ${formatError(err)}`);
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
} finally {
|
||||
if (pool) {
|
||||
|
||||
@@ -6,3 +6,8 @@ body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
||||
"Helvetica Neue", Arial, sans-serif;
|
||||
}
|
||||
|
||||
/* 行高亮:点击锁定的整行(覆盖 antd 单元格背景,含固定列) */
|
||||
.ant-table-tbody > tr.row-locked > td {
|
||||
background: #fff7e6 !important;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Metadata } from "next";
|
||||
import "./globals.css";
|
||||
import { Providers } from "./providers";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Web Table - 压力表合同生产数据",
|
||||
@@ -13,7 +14,9 @@ export default function RootLayout({
|
||||
}>) {
|
||||
return (
|
||||
<html lang="zh-CN">
|
||||
<body>{children}</body>
|
||||
<body>
|
||||
<Providers>{children}</Providers>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
205
src/app/page.tsx
205
src/app/page.tsx
@@ -14,7 +14,14 @@ import {
|
||||
Checkbox,
|
||||
AutoComplete,
|
||||
} from "antd";
|
||||
import { SearchOutlined, SettingOutlined, HolderOutlined, ClockCircleOutlined, DownloadOutlined } from "@ant-design/icons";
|
||||
import {
|
||||
SearchOutlined,
|
||||
SettingOutlined,
|
||||
HolderOutlined,
|
||||
ClockCircleOutlined,
|
||||
DownloadOutlined,
|
||||
FilterOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import type { ColumnsType } from "antd/es/table";
|
||||
import {
|
||||
DndContext,
|
||||
@@ -31,16 +38,16 @@ import {
|
||||
arrayMove,
|
||||
} from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { nextLockedRow, type DataRow } from "./table-filters";
|
||||
import { buildColumnFilterProps } from "./table-filter-ui";
|
||||
import { RecordDetailModal } from "./record-detail-modal";
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
interface DataRow {
|
||||
[key: string]: string | number | null;
|
||||
}
|
||||
|
||||
interface ColumnConfig {
|
||||
key: string;
|
||||
visible: boolean;
|
||||
filterable: boolean;
|
||||
}
|
||||
|
||||
const COLUMN_DEFS: { key: string; width: number }[] = [
|
||||
@@ -105,6 +112,7 @@ const COLUMN_DEFS: { key: string; width: number }[] = [
|
||||
const DEFAULT_CONFIG: ColumnConfig[] = COLUMN_DEFS.map((d) => ({
|
||||
key: d.key,
|
||||
visible: true,
|
||||
filterable: false,
|
||||
}));
|
||||
|
||||
const STORAGE_KEY = "web-table-column-config";
|
||||
@@ -113,9 +121,15 @@ function loadConfig(): ColumnConfig[] {
|
||||
try {
|
||||
const saved = localStorage.getItem(STORAGE_KEY);
|
||||
if (saved) {
|
||||
const parsed: ColumnConfig[] = JSON.parse(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;
|
||||
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 */
|
||||
@@ -129,14 +143,16 @@ function saveConfig(config: ColumnConfig[]) {
|
||||
|
||||
function buildTableColumns(
|
||||
config: ColumnConfig[],
|
||||
dataColumns: string[]
|
||||
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);
|
||||
return {
|
||||
const base = {
|
||||
title: c.key,
|
||||
dataIndex: c.key,
|
||||
key: c.key,
|
||||
@@ -145,6 +161,9 @@ function buildTableColumns(
|
||||
render: (val: string | number | null) =>
|
||||
val === null || val === undefined ? "" : String(val),
|
||||
};
|
||||
return c.filterable
|
||||
? { ...base, ...buildColumnFilterProps(c.key, data, filters[c.key] ?? null) }
|
||||
: base;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -154,12 +173,16 @@ function SortableRow({
|
||||
id,
|
||||
label,
|
||||
visible,
|
||||
filterable,
|
||||
onToggle,
|
||||
onToggleFilter,
|
||||
}: {
|
||||
id: string;
|
||||
label: string;
|
||||
visible: boolean;
|
||||
filterable: boolean;
|
||||
onToggle: () => void;
|
||||
onToggleFilter: () => void;
|
||||
}) {
|
||||
const {
|
||||
attributes,
|
||||
@@ -191,6 +214,14 @@ function SortableRow({
|
||||
<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>
|
||||
);
|
||||
@@ -211,15 +242,17 @@ function ColumnSettingsModal({
|
||||
}) {
|
||||
const [local, setLocal] = useState<ColumnConfig[]>([]);
|
||||
const [search, setSearch] = useState("");
|
||||
const [prevOpen, setPrevOpen] = useState(open);
|
||||
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 4 } }));
|
||||
|
||||
// Sync when modal opens
|
||||
useEffect(() => {
|
||||
// Reset local copy whenever the modal opens (render-phase, no setState-in-effect).
|
||||
if (open !== prevOpen) {
|
||||
setPrevOpen(open);
|
||||
if (open) {
|
||||
setLocal(config.map((c) => ({ ...c })));
|
||||
setSearch("");
|
||||
}
|
||||
}, [open, config]);
|
||||
}
|
||||
|
||||
const filtered = search
|
||||
? local.filter((c) => c.key.toLowerCase().includes(search.toLowerCase()))
|
||||
@@ -243,6 +276,12 @@ function ColumnSettingsModal({
|
||||
);
|
||||
};
|
||||
|
||||
const toggleFilter = (key: string) => {
|
||||
setLocal((prev) =>
|
||||
prev.map((c) => (c.key === key ? { ...c, filterable: !c.filterable } : c))
|
||||
);
|
||||
};
|
||||
|
||||
const selectAll = () => setLocal((prev) => prev.map((c) => ({ ...c, visible: true })));
|
||||
const deselectAll = () => setLocal((prev) => prev.map((c) => ({ ...c, visible: false })));
|
||||
const resetDefault = () => setLocal(DEFAULT_CONFIG.map((c) => ({ ...c })));
|
||||
@@ -291,7 +330,9 @@ function ColumnSettingsModal({
|
||||
id={c.key}
|
||||
label={c.key}
|
||||
visible={c.visible}
|
||||
filterable={c.filterable}
|
||||
onToggle={() => toggle(c.key)}
|
||||
onToggleFilter={() => toggleFilter(c.key)}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
@@ -335,6 +376,8 @@ function SearchBar({
|
||||
onOpenColumnSettings,
|
||||
exporting,
|
||||
onExport,
|
||||
hasActiveFilters,
|
||||
onClearFilters,
|
||||
}: {
|
||||
loading: boolean;
|
||||
resultCount: number;
|
||||
@@ -344,6 +387,8 @@ function SearchBar({
|
||||
onOpenColumnSettings: () => void;
|
||||
exporting: boolean;
|
||||
onExport: () => void;
|
||||
hasActiveFilters: boolean;
|
||||
onClearFilters: () => void;
|
||||
}) {
|
||||
const [inputValue, setInputValue] = useState("");
|
||||
const [history, setHistory] = useState<string[]>(() => loadHistory());
|
||||
@@ -429,6 +474,13 @@ function SearchBar({
|
||||
>
|
||||
导出全部
|
||||
</Button>
|
||||
<Button
|
||||
icon={<FilterOutlined />}
|
||||
onClick={onClearFilters}
|
||||
disabled={!hasActiveFilters}
|
||||
>
|
||||
清除筛选
|
||||
</Button>
|
||||
</Space>
|
||||
{searched && !loading && (
|
||||
<Text type="secondary" style={{ marginLeft: 16 }}>
|
||||
@@ -450,6 +502,14 @@ export default function Home() {
|
||||
const [columnConfig, setColumnConfig] = useState<ColumnConfig[]>(loadConfig);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const [lockedRowKey, setLockedRowKey] = useState<string | null>(null);
|
||||
const [filters, setFilters] = useState<Record<string, React.Key[]>>({});
|
||||
const [contextMenu, setContextMenu] = useState<{
|
||||
record: DataRow;
|
||||
x: number;
|
||||
y: number;
|
||||
} | null>(null);
|
||||
const [detailRecord, setDetailRecord] = useState<DataRow | null>(null);
|
||||
|
||||
// Persist config
|
||||
useEffect(() => {
|
||||
@@ -471,6 +531,10 @@ export default function Home() {
|
||||
setColumns(json.columns);
|
||||
setData(json.data);
|
||||
setSearched(true);
|
||||
setLockedRowKey(null);
|
||||
setFilters({});
|
||||
setContextMenu(null);
|
||||
setDetailRecord(null);
|
||||
} catch {
|
||||
setError("网络请求失败");
|
||||
} finally {
|
||||
@@ -483,26 +547,40 @@ export default function Home() {
|
||||
setColumns([]);
|
||||
setSearched(false);
|
||||
setError(null);
|
||||
setLockedRowKey(null);
|
||||
setFilters({});
|
||||
setContextMenu(null);
|
||||
setDetailRecord(null);
|
||||
}, []);
|
||||
|
||||
const handleExport = useCallback(async () => {
|
||||
setExporting(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch("/api/export-excel");
|
||||
if (!res.ok) {
|
||||
const json = await res.json().catch(() => null);
|
||||
setError(json?.error || "导出失败");
|
||||
const MAX_ATTEMPTS = 7; // 429 = 另有人在导出,退避后自动重试
|
||||
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
|
||||
const res = await fetch("/api/export-excel");
|
||||
if (res.status === 429) {
|
||||
await new Promise((r) => setTimeout(r, 3000));
|
||||
continue;
|
||||
}
|
||||
if (!res.ok) {
|
||||
const json = await res.json().catch(() => null);
|
||||
setError(json?.error || "导出失败");
|
||||
return;
|
||||
}
|
||||
const blob = await res.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "压力表合同生产数据.xlsx";
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
return;
|
||||
}
|
||||
const blob = await res.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "压力表合同生产数据.xlsx";
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
setError("导出繁忙,请稍后重试");
|
||||
} catch {
|
||||
setError("导出请求失败");
|
||||
} finally {
|
||||
@@ -516,9 +594,14 @@ export default function Home() {
|
||||
}, []);
|
||||
|
||||
const tableColumns = useMemo(
|
||||
() => buildTableColumns(columnConfig, columns),
|
||||
[columnConfig, columns]
|
||||
() => buildTableColumns(columnConfig, columns, data, filters),
|
||||
[columnConfig, columns, data, filters]
|
||||
);
|
||||
const hasActiveFilters = useMemo(
|
||||
() => Object.values(filters).some((arr) => arr && arr.length > 0),
|
||||
[filters]
|
||||
);
|
||||
const clearFilters = useCallback(() => setFilters({}), []);
|
||||
const totalWidth = useMemo(
|
||||
() => tableColumns.reduce((sum, col) => sum + (col.width as number), 0),
|
||||
[tableColumns]
|
||||
@@ -539,6 +622,8 @@ export default function Home() {
|
||||
onOpenColumnSettings={() => setModalOpen(true)}
|
||||
exporting={exporting}
|
||||
onExport={handleExport}
|
||||
hasActiveFilters={hasActiveFilters}
|
||||
onClearFilters={clearFilters}
|
||||
/>
|
||||
|
||||
<div style={{ flex: 1, minHeight: 0 }}>
|
||||
@@ -582,6 +667,20 @@ export default function Home() {
|
||||
columns={tableColumns}
|
||||
dataSource={data}
|
||||
rowKey="ID"
|
||||
onRow={(record) => ({
|
||||
onClick: () =>
|
||||
setLockedRowKey((prev) => nextLockedRow(prev, String(record.ID))),
|
||||
onContextMenu: (e) => {
|
||||
e.preventDefault();
|
||||
setContextMenu({ record, x: e.clientX, y: e.clientY });
|
||||
},
|
||||
})}
|
||||
rowClassName={(record) =>
|
||||
String(record.ID) === lockedRowKey ? "row-locked" : ""
|
||||
}
|
||||
onChange={(_pagination, tableFilters) =>
|
||||
setFilters(tableFilters as Record<string, React.Key[]>)
|
||||
}
|
||||
bordered
|
||||
size="small"
|
||||
pagination={false}
|
||||
@@ -610,6 +709,58 @@ export default function Home() {
|
||||
onApply={handleApplyConfig}
|
||||
onCancel={() => setModalOpen(false)}
|
||||
/>
|
||||
|
||||
{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)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
13
src/app/providers.tsx
Normal file
13
src/app/providers.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { ConfigProvider } from "antd";
|
||||
import zhCN from "antd/locale/zh_CN";
|
||||
import dayjs from "dayjs";
|
||||
import "dayjs/locale/zh-cn";
|
||||
|
||||
// antd v6 DatePicker 基于 dayjs;设置 dayjs 中文 locale 后,日期面板的月份/星期才会显示中文。
|
||||
dayjs.locale("zh-cn");
|
||||
|
||||
export function Providers({ children }: { children: React.ReactNode }) {
|
||||
return <ConfigProvider locale={zhCN}>{children}</ConfigProvider>;
|
||||
}
|
||||
341
src/app/record-detail-modal.tsx
Normal file
341
src/app/record-detail-modal.tsx
Normal file
@@ -0,0 +1,341 @@
|
||||
"use client";
|
||||
|
||||
import { Modal } from "antd";
|
||||
import {
|
||||
ClockCircleOutlined,
|
||||
AppstoreOutlined,
|
||||
UnorderedListOutlined,
|
||||
WarningOutlined,
|
||||
RightOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import type { ReactNode } from "react";
|
||||
import type { DataRow } from "./table-filters";
|
||||
import { pickRecordTitle, formatCellValue } from "./record-detail";
|
||||
|
||||
/* ── 字段分组定义(键名对应真实数据列;未列出的列自动落入"其他信息")── */
|
||||
|
||||
const BANNER_KEYS = ["工令号", "总排号", "车间", "交货日期"];
|
||||
const MISSING_KEY = "缺件明细";
|
||||
|
||||
const TRACK_GROUPS: { title: string; fields: string[] }[] = [
|
||||
{
|
||||
title: "【1】备料与物料准备",
|
||||
fields: ["执行卡下发日期", "物料类别", "焊接领料日期", "领料单签收日期", "库房发出日期"],
|
||||
},
|
||||
{
|
||||
title: "【2】前序加工(隔膜 / 喷涂)",
|
||||
fields: ["隔膜接收", "隔离膜片接收", "车波纹日期", "烘洗", "喷涂发出", "喷涂回来"],
|
||||
},
|
||||
{
|
||||
title: "【3】焊接与装配",
|
||||
fields: ["焊接接收日期", "膜片焊", "壳焊接员", "表壳焊接日期"],
|
||||
},
|
||||
{
|
||||
title: "【4】校验 / 测试 / 入库",
|
||||
fields: ["超压日期", "退火日期", "氦测日期", "调校人", "调试日期", "检验员", "检验日期", "入库日期"],
|
||||
},
|
||||
];
|
||||
|
||||
const SPEC_HIGHLIGHT = ["客户名称", "产品型号", "量程", "数量"];
|
||||
const SPEC_FIELDS = [
|
||||
"客户名称", "产品型号", "量程", "数量",
|
||||
"隔膜类型", "隔膜大小", "隔膜材质", "膜片尺寸", "膜片材质",
|
||||
"生产订单号", "订单号", "签订日期", "接单日期", "经办人", "盘号", "位号", "标准",
|
||||
];
|
||||
const SPEC_FULL = ["技术参数", "特殊要求", "备注", "新参数"];
|
||||
|
||||
const DEFINED_KEYS = new Set<string>([
|
||||
...BANNER_KEYS,
|
||||
MISSING_KEY,
|
||||
...TRACK_GROUPS.flatMap((g) => g.fields),
|
||||
...SPEC_FIELDS,
|
||||
...SPEC_FULL,
|
||||
]);
|
||||
|
||||
/* ── 卡片风子组件(对标 demo.tsx 的 Field / Section / TrackBlock)── */
|
||||
|
||||
function Field({
|
||||
label,
|
||||
value,
|
||||
highlight = false,
|
||||
full = false,
|
||||
}: {
|
||||
label: string;
|
||||
value: string | number | null;
|
||||
highlight?: boolean;
|
||||
full?: boolean;
|
||||
}) {
|
||||
const display = formatCellValue(value);
|
||||
const isEmpty = display === "—";
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
gridColumn: full ? "1 / -1" : undefined,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 11, color: "#64748b", marginBottom: 2, lineHeight: 1 }}>
|
||||
{label}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 14,
|
||||
lineHeight: 1.3,
|
||||
wordBreak: "break-word",
|
||||
color: isEmpty ? "#cbd5e1" : highlight ? "#2563eb" : "#1e293b",
|
||||
fontWeight: isEmpty ? 400 : highlight ? 700 : 500,
|
||||
}}
|
||||
>
|
||||
{display}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Section({
|
||||
icon,
|
||||
title,
|
||||
gridCols = 3,
|
||||
children,
|
||||
}: {
|
||||
icon: ReactNode;
|
||||
title: string;
|
||||
gridCols?: number;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
marginBottom: 12,
|
||||
borderBottom: "1px solid #f1f5f9",
|
||||
paddingBottom: 8,
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 18, color: "#3b82f6", lineHeight: 1, display: "flex" }}>
|
||||
{icon}
|
||||
</span>
|
||||
<h3 style={{ fontSize: 16, fontWeight: 700, color: "#1e293b", margin: 0 }}>{title}</h3>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: `repeat(${gridCols}, minmax(0, 1fr))`,
|
||||
rowGap: 16,
|
||||
columnGap: 24,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TrackBlock({ title, children }: { title: string; children: ReactNode }) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
background: "#f8fafc",
|
||||
borderRadius: 6,
|
||||
padding: 12,
|
||||
border: "1px solid #e2e8f0",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
fontSize: 12,
|
||||
fontWeight: 700,
|
||||
color: "#334155",
|
||||
borderBottom: "1px solid #e2e8f0",
|
||||
paddingBottom: 6,
|
||||
marginBottom: 8,
|
||||
}}
|
||||
>
|
||||
<RightOutlined style={{ fontSize: 12, color: "#94a3b8" }} />
|
||||
{title}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(3, minmax(0, 1fr))",
|
||||
columnGap: 12,
|
||||
rowGap: 10,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── 主组件 ── */
|
||||
|
||||
export function RecordDetailModal({
|
||||
record,
|
||||
columns,
|
||||
open,
|
||||
onClose,
|
||||
}: {
|
||||
record: DataRow | null;
|
||||
columns: string[];
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const colsSet = new Set(columns);
|
||||
const has = (k: string) => colsSet.has(k);
|
||||
const val = (k: string): string | number | null =>
|
||||
record && has(k) ? (record[k] ?? null) : null;
|
||||
|
||||
const others = columns.filter((c) => !DEFINED_KEYS.has(c));
|
||||
|
||||
const missingVal = val(MISSING_KEY);
|
||||
const missingEmpty = formatCellValue(missingVal) === "—";
|
||||
|
||||
const workOrder = val("工令号");
|
||||
const workOrderDisplay = formatCellValue(workOrder);
|
||||
const showFadedWorkOrder = workOrderDisplay !== "—";
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={record ? `记录详情:${pickRecordTitle(record)}` : "记录详情"}
|
||||
open={open}
|
||||
onCancel={onClose}
|
||||
footer={null}
|
||||
width={960}
|
||||
styles={{ body: { padding: 0 } }}
|
||||
>
|
||||
{record && (
|
||||
<div>
|
||||
{/* 黑底信息头 */}
|
||||
<div
|
||||
style={{
|
||||
position: "relative",
|
||||
background: "#1e293b",
|
||||
color: "#fff",
|
||||
padding: "16px 24px",
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: 16,
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
{showFadedWorkOrder && (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
right: -8,
|
||||
top: -22,
|
||||
fontSize: 120,
|
||||
fontWeight: 900,
|
||||
fontFamily: "monospace",
|
||||
color: "rgba(51,65,85,0.4)",
|
||||
userSelect: "none",
|
||||
pointerEvents: "none",
|
||||
lineHeight: 1,
|
||||
}}
|
||||
>
|
||||
{workOrderDisplay}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 16, position: "relative", zIndex: 1 }}>
|
||||
<div
|
||||
style={{
|
||||
background: "#2563eb",
|
||||
padding: "8px 12px",
|
||||
borderRadius: 8,
|
||||
textAlign: "center",
|
||||
minWidth: 64,
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 10, color: "#bfdbfe", marginBottom: 4, lineHeight: 1 }}>工令号</div>
|
||||
<div style={{ fontSize: 20, fontWeight: 700, fontFamily: "monospace", lineHeight: 1 }}>
|
||||
{workOrderDisplay}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: "flex", flexDirection: "column" }}>
|
||||
<span style={{ fontSize: 12, color: "#94a3b8", marginBottom: 2 }}>总排号 / 车间</span>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8, flexWrap: "wrap" }}>
|
||||
<span style={{ fontSize: 18, fontWeight: 700 }}>{formatCellValue(val("总排号"))}</span>
|
||||
<span style={{ color: "#64748b" }}>|</span>
|
||||
<span style={{ fontSize: 14, fontWeight: 500 }}>{formatCellValue(val("车间"))}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8, position: "relative", zIndex: 1 }}>
|
||||
<span style={{ fontSize: 12, color: "#94a3b8" }}>交货期</span>
|
||||
<span style={{ fontWeight: 700, fontSize: 16, color: "#34d399" }}>
|
||||
{formatCellValue(val("交货日期"))}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 缺件预警 */}
|
||||
{has(MISSING_KEY) && !missingEmpty && (
|
||||
<div
|
||||
style={{
|
||||
background: "#fef2f2",
|
||||
borderBottom: "1px solid #fecaca",
|
||||
padding: "10px 24px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<WarningOutlined style={{ color: "#dc2626" }} />
|
||||
<span style={{ fontSize: 14, color: "#b91c1c", fontWeight: 700 }}>缺件明细:</span>
|
||||
<span style={{ fontSize: 14, color: "#dc2626", wordBreak: "break-word" }}>
|
||||
{missingVal === null || missingVal === undefined ? "" : String(missingVal)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 明细主体 */}
|
||||
<div style={{ padding: 24 }}>
|
||||
{/* 工序节点(提至最前) */}
|
||||
{TRACK_GROUPS.some((g) => g.fields.some(has)) && (
|
||||
<Section icon={<ClockCircleOutlined />} title="工序节点" gridCols={2}>
|
||||
{TRACK_GROUPS.filter((g) => g.fields.some(has)).map((g) => (
|
||||
<TrackBlock key={g.title} title={g.title}>
|
||||
{g.fields.filter(has).map((f) => (
|
||||
<Field key={f} label={f} value={record[f]} />
|
||||
))}
|
||||
</TrackBlock>
|
||||
))}
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* 基础配置与规格 */}
|
||||
{SPEC_FIELDS.some(has) && (
|
||||
<Section icon={<AppstoreOutlined />} title="基础配置与规格" gridCols={3}>
|
||||
{SPEC_FIELDS.filter(has).map((f) => (
|
||||
<Field key={f} label={f} value={record[f]} highlight={SPEC_HIGHLIGHT.includes(f)} />
|
||||
))}
|
||||
{SPEC_FULL.filter(has).map((f) => (
|
||||
<Field key={f} label={f} value={record[f]} full />
|
||||
))}
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* 其他信息(兜底:未被分组的列) */}
|
||||
{others.length > 0 && (
|
||||
<Section icon={<UnorderedListOutlined />} title="其他信息" gridCols={3}>
|
||||
{others.map((f) => (
|
||||
<Field key={f} label={f} value={record[f]} />
|
||||
))}
|
||||
</Section>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
44
src/app/record-detail.test.ts
Normal file
44
src/app/record-detail.test.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
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("hyphen '-' (no-value marker) -> em dash", () => {
|
||||
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");
|
||||
});
|
||||
});
|
||||
27
src/app/record-detail.ts
Normal file
27
src/app/record-detail.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
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)。
|
||||
* 注:生产数据中 "-" 表示"无值",展示时与空白同等弱化(仅展示语义,与筛选用的 isBlank 不同)。
|
||||
*/
|
||||
export function formatCellValue(value: string | number | null): string {
|
||||
if (value === null || value === undefined) return "—";
|
||||
const s = String(value);
|
||||
if (s === "-" || s.trim() === "") return "—";
|
||||
return s;
|
||||
}
|
||||
135
src/app/table-filter-ui.tsx
Normal file
135
src/app/table-filter-ui.tsx
Normal file
@@ -0,0 +1,135 @@
|
||||
"use client";
|
||||
|
||||
import { Button, Input, Checkbox, DatePicker, Space } from "antd";
|
||||
import dayjs from "dayjs";
|
||||
import type { ColumnType } from "antd/es/table";
|
||||
import type { FilterDropdownProps } from "antd/es/table/interface";
|
||||
import {
|
||||
BLANK_SENTINEL,
|
||||
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 {
|
||||
const blankOnly = selectedKeys.includes(BLANK_SENTINEL);
|
||||
|
||||
const footer = (
|
||||
<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>
|
||||
);
|
||||
|
||||
let control: React.ReactNode;
|
||||
if (type === "date") {
|
||||
const [start, end] = decodeRange(selectedKeys);
|
||||
control = (
|
||||
<DatePicker.RangePicker
|
||||
disabled={blankOnly}
|
||||
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 解码
|
||||
}
|
||||
}}
|
||||
style={{ width: "100%" }}
|
||||
/>
|
||||
);
|
||||
} else if (type === "category") {
|
||||
control = (
|
||||
<Checkbox.Group
|
||||
disabled={blankOnly}
|
||||
style={{ display: "flex", flexDirection: "column", maxHeight: 240, overflow: "auto" }}
|
||||
value={(selectedKeys as string[]) ?? []}
|
||||
onChange={(vals) => setSelectedKeys(vals as React.Key[])}
|
||||
options={distinct}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
// text
|
||||
const textValue = blankOnly
|
||||
? ""
|
||||
: selectedKeys[0] != null
|
||||
? String(selectedKeys[0])
|
||||
: "";
|
||||
control = (
|
||||
<Input
|
||||
disabled={blankOnly}
|
||||
placeholder="输入关键字"
|
||||
value={textValue}
|
||||
onChange={(e) => setSelectedKeys(e.target.value ? [e.target.value] : [])}
|
||||
onPressEnter={() => confirm()}
|
||||
allowClear
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ padding: 8, maxWidth: type === "category" ? 260 : 320 }}>
|
||||
<Checkbox
|
||||
checked={blankOnly}
|
||||
onChange={(e) => setSelectedKeys(e.target.checked ? [BLANK_SENTINEL] : [])}
|
||||
style={{ marginBottom: 8 }}
|
||||
>
|
||||
仅显示空白
|
||||
</Checkbox>
|
||||
{control}
|
||||
{footer}
|
||||
</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,
|
||||
};
|
||||
}
|
||||
110
src/app/table-filters.test.ts
Normal file
110
src/app/table-filters.test.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
BLANK_SENTINEL,
|
||||
CATEGORY_THRESHOLD,
|
||||
distinctValues,
|
||||
inferFilterType,
|
||||
isBlank,
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isBlank", () => {
|
||||
it("null/undefined/empty/whitespace are blank", () => {
|
||||
expect(isBlank(null)).toBe(true);
|
||||
expect(isBlank(undefined)).toBe(true);
|
||||
expect(isBlank("")).toBe(true);
|
||||
expect(isBlank(" ")).toBe(true);
|
||||
});
|
||||
it("non-empty values (including 0/false) are not blank", () => {
|
||||
expect(isBlank("x")).toBe(false);
|
||||
expect(isBlank(0)).toBe(false);
|
||||
expect(isBlank(false)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("matchesFilter blank sentinel", () => {
|
||||
it("sentinel matches blank cells for any type", () => {
|
||||
expect(matchesFilter("text", null, BLANK_SENTINEL)).toBe(true);
|
||||
expect(matchesFilter("category", "", BLANK_SENTINEL)).toBe(true);
|
||||
expect(matchesFilter("date", " ", BLANK_SENTINEL)).toBe(true);
|
||||
});
|
||||
it("sentinel does not match non-blank cells", () => {
|
||||
expect(matchesFilter("text", "hello", BLANK_SENTINEL)).toBe(false);
|
||||
expect(matchesFilter("category", "A", BLANK_SENTINEL)).toBe(false);
|
||||
expect(matchesFilter("date", "2024-01-01", BLANK_SENTINEL)).toBe(false);
|
||||
});
|
||||
});
|
||||
87
src/app/table-filters.ts
Normal file
87
src/app/table-filters.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
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";
|
||||
}
|
||||
|
||||
/** Sentinel filter key meaning "match blank/empty cells". */
|
||||
export const BLANK_SENTINEL = "__blank__";
|
||||
|
||||
/** A cell is blank when it is null/undefined or trims to empty. 0/false are NOT blank. */
|
||||
export function isBlank(value: unknown): boolean {
|
||||
if (value === null || value === undefined) return true;
|
||||
return String(value).trim() === "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Does a cell match a single filter key?
|
||||
* - BLANK_SENTINEL: cell is blank (any type)
|
||||
* - 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 {
|
||||
if (filterValue === BLANK_SENTINEL) return isBlank(cellValue);
|
||||
|
||||
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;
|
||||
}
|
||||
22
src/server/log-format.test.ts
Normal file
22
src/server/log-format.test.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { formatError } from "./log-format";
|
||||
|
||||
describe("formatError", () => {
|
||||
it("returns stack for Error", () => {
|
||||
const e = new Error("boom");
|
||||
expect(formatError(e)).toBe(e.stack);
|
||||
});
|
||||
it("falls back to message when stack missing", () => {
|
||||
const e = new Error("boom");
|
||||
e.stack = undefined as unknown as string;
|
||||
expect(formatError(e)).toBe("boom");
|
||||
});
|
||||
it("stringifies non-Error primitives", () => {
|
||||
expect(formatError("oops")).toBe("oops");
|
||||
expect(formatError(42)).toBe("42");
|
||||
});
|
||||
it("handles null / undefined", () => {
|
||||
expect(formatError(null)).toBe("Unknown error");
|
||||
expect(formatError(undefined)).toBe("Unknown error");
|
||||
});
|
||||
});
|
||||
11
src/server/log-format.ts
Normal file
11
src/server/log-format.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* 把任意错误/值格式化为带堆栈的字符串,用于日志记录。
|
||||
* - Error:优先 stack,缺则 message
|
||||
* - null/undefined:'Unknown error'
|
||||
* - 其它:String(value)
|
||||
*/
|
||||
export function formatError(err: unknown): string {
|
||||
if (err instanceof Error) return err.stack || err.message;
|
||||
if (err === null || err === undefined) return "Unknown error";
|
||||
return String(err);
|
||||
}
|
||||
20
src/server/logger.ts
Normal file
20
src/server/logger.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import log4js from "log4js";
|
||||
|
||||
// fileSync = 同步写:进程 OOM/被杀前日志已落盘,满足崩溃诊断刚需。
|
||||
log4js.configure({
|
||||
appenders: {
|
||||
app: {
|
||||
type: "fileSync",
|
||||
filename: "logs/app/app.log",
|
||||
maxLogSize: 10 * 1024 * 1024, // 10MB
|
||||
backups: 5,
|
||||
layout: { type: "pattern", pattern: "[%d{ISO8601}] [%p] [%c] %m" },
|
||||
},
|
||||
},
|
||||
categories: { default: { appenders: ["app"], level: "info" } },
|
||||
});
|
||||
|
||||
export { formatError } from "./log-format";
|
||||
|
||||
export const apiLogger = log4js.getLogger("api");
|
||||
export const exportLogger = log4js.getLogger("export");
|
||||
8
vitest.config.ts
Normal file
8
vitest.config.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: "node",
|
||||
include: ["src/**/*.test.ts"],
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user