Compare commits

...

20 Commits

Author SHA1 Message Date
Misaka_Company
cd2f8b75df Ignore runtime logs directory 2026-06-24 13:53:09 +08:00
Misaka_Company
bc0e95157c Log export lifecycle (start/ok/fail/lock) with stack and timing 2026-06-24 13:40:27 +08:00
Misaka_Company
3371a8af2a Log production-data query failures (with stack) and slow queries 2026-06-24 13:39:17 +08:00
Misaka_Company
2a5f5763b4 Add log4js fileSync logger module and formatError helper 2026-06-24 13:35:10 +08:00
Misaka_Company
e495a9b142 Add implementation plan: structured logging (log4js fileSync) 2026-06-24 13:32:18 +08:00
Misaka_Company
2bd177faf6 Add design spec: structured logging for stability diagnosis (log4js fileSync) 2026-06-24 13:28:09 +08:00
Misaka_Company
964dfbb94f Restyle record detail modal as grouped card (match demo design)
- Dark header banner: work-order badge, total-row/workshop, delivery date
- Conditional missing-parts alert banner
- Process-node section with 4 track blocks; base-config section with
  highlighted key fields and full-width long-text fields; catch-all section
- Grey out empty / '-' / blank values
- formatCellValue now treats '-' as empty (added test)
2026-06-24 12:41:33 +08:00
Misaka_Company
a4afa3a8f5 Add right-click context menu to view full record in modal
Right-click a data row shows a custom menu; choosing 'view full record'
opens a Modal displaying all fields (incl. hidden columns) fully wrapped,
so long-content fields are completely visible. Inline table keeps ellipsis.

Also fixes a pre-existing setState-in-effect lint error in
ColumnSettingsModal (render-phase reset) that would fail next build under
eslint-plugin-react-hooks v7.
2026-06-24 11:49:02 +08:00
Misaka_Company
f3fa1a0e1b Add record-detail helpers (title picker, cell formatter) 2026-06-24 11:34:57 +08:00
Misaka_Company
6531254715 Add implementation plan: record detail context menu 2026-06-24 11:23:32 +08:00
Misaka_Company
207bc6a7a1 Add design spec: right-click record detail modal
Design for production dept requirement 3 (long-content full display):
right-click a data row to open a custom context menu -> open a Modal
showing all fields of that record fully. Inline ellipsis unchanged.
2026-06-24 11:09:14 +08:00
Misaka_Company
3212769644 Ignore .claude local config directory 2026-06-24 10:41:26 +08:00
Misaka_Company
e91bc90d0f Serialize exports to prevent OOM spikes from stacking
/api/export-excel now holds a per-process mutex: concurrent callers get an
instant 429 instead of stacking multiple ~1.3GB export spikes that exceed
Node's default ~2GB heap. Frontend handleExport treats 429 as 'another
export running' and auto-retries (3s backoff, up to 7 attempts). A 90s
timeout reclaims a stuck lock. Heap raise + orphan cleanup applied at deploy.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-18 11:05:05 +08:00
Misaka_Company
dca5335f9a Localize antd DatePicker to Chinese via ConfigProvider
Adds a Providers client component that wraps the app in ConfigProvider
locale=zhCN and sets dayjs zh-cn locale, so the date-filter RangePicker
shows Chinese weekdays/months (and other antd components localize too).
Framework-native; no custom control. Not deployed.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-18 10:28:12 +08:00
Misaka_Company
933b779a10 Add blank-value filtering to column filters
Each filter dropdown (text/category/date) gains a 'show blanks only'
checkbox; when on, the column filters to rows where the field is empty
or whitespace-only (0/false are not blank), and the value control is
disabled. Implemented via a BLANK_SENTINEL key handled by matchesFilter,
with isBlank + 4 new unit tests. Not deployed.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-18 10:16:14 +08:00
Misaka_Company
2f3cd9fa06 Wire controlled column filters and clear-filters button
buildTableColumns now attaches antd controlled filter props (via
buildColumnFilterProps) to columns marked filterable; filters state is
controlled via Table onChange. A 'clear filters' button in the search bar
resets all filters and is disabled when none are active. Filters and row
lock reset on search/clear.

Also renames the component module table-filters.tsx -> table-filter-ui.tsx
to avoid a same-base-name resolution collision with the logic module
table-filters.ts.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-18 09:52:51 +08:00
Misaka_Company
f8914fb287 Add per-column filterable flag with persistence and settings toggle
ColumnConfig gains filterable (default false), persisted to localStorage
with backward-compat for configs saved before this field existed. The
column-settings modal now shows a funnel toggle per row alongside the
visibility checkbox.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-18 09:47:46 +08:00
Misaka_Company
f78918c3b0 Add click-to-lock row highlighting
Clicking a row locks an orange highlight (#fff7e6) across the whole row
including fixed columns; clicking the same row unlocks, clicking another
switches. Lock resets on search/clear. Highlight survives horizontal scroll
since rowClassName applies to all row fragments.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-18 09:46:25 +08:00
Misaka_Company
b893fd558a Add FilterDropdown component and column filter props factory
src/app/table-filters.tsx renders type-specific filter UIs (text search,
category checkbox list, date range) and exposes buildColumnFilterProps to
attach antd controlled filter props (filterDropdown/onFilter/filteredValue)
to a column. Verified against antd v6.4.3 type defs.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-18 09:44:52 +08:00
Misaka_Company
4faa960591 Add pure filter/lock logic with Vitest tests
Adds src/app/table-filters.ts (DataRow type, FilterType, distinctValues,
inferFilterType, matchesFilter, nextLockedRow) with 16 Vitest tests covering
type inference thresholds, date-range encoding, and row-lock toggling.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-18 09:42:18 +08:00
23 changed files with 3055 additions and 35 deletions

6
.gitignore vendored
View File

@@ -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/

View File

@@ -0,0 +1,372 @@
# 右键查看完整记录(记录详情 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` 并单测;展示用 `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、vitestnode 环境,仅测纯逻辑)。
## 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)}
/>
```
> 关闭逻辑说明:浮层背后有透明 backdropz-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/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`。

View File

@@ -0,0 +1,454 @@
# 结构化日志log4js fileSyncImplementation 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.9route handlersserver-only、log4js 6.9.1新增依赖、vitestnode 环境,仅测纯逻辑)。
## 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 configurefileSync+ 导出 `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: PASS4 用例)。
- [ ] **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`
> 部署到 114pull → `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 6pattern 与字段在路由日志中体现)✅
- 4.4 记什么矩阵production 失败 ERROR / 慢查询 WARN → Task 2export 锁占用/回收 WARN、开始/成功 INFO、失败 ERROR、启动 INFO → Task 3 Step 3`export started`console.* 全替换 → Task 2/3 ✅
- 4.5 formatError + 路由带栈 → Task 1formatError+ 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`(两路由同深,正确)。

View File

@@ -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 的兜底自定义浮层方案。

View 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/stderrNext 启动、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 带栈行;触发一次导出 → 确认 INFOrows/bytes/dur/ 失败 ERROR 行。
- 不引入 log4js 行为的单测(第三方,按项目惯例不测)。
## 7. 部署注意
新增 `log4js` 依赖114 部署须执行 `npm install`CLAUDE.local.md 第 3 步),再 `next build` + 重启。

905
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -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"
}
}

View File

@@ -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();
}
}

View File

@@ -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) {

View File

@@ -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;
}

View File

@@ -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>
);
}

View File

@@ -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
View 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>;
}

View 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>
);
}

View 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
View 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
View 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,
};
}

View 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
View 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;
}

View 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
View 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
View 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
View File

@@ -0,0 +1,8 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "node",
include: ["src/**/*.test.ts"],
},
});