Compare commits

...

9 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
14 changed files with 1212 additions and 8 deletions

5
.gitignore vendored
View File

@@ -42,4 +42,7 @@ next-env.d.ts
# .gitignore # .gitignore
CLAUDE.local.md CLAUDE.local.md
.claude/ .claude/
# runtime logs (log4js app logs, NSSM stdout/stderr)
/logs/

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,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` + 重启。

80
package-lock.json generated
View File

@@ -14,6 +14,7 @@
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"antd": "^6.4.3", "antd": "^6.4.3",
"exceljs": "^4.4.0", "exceljs": "^4.4.0",
"log4js": "^6.9.1",
"mssql": "^12.5.5", "mssql": "^12.5.5",
"next": "16.2.9", "next": "16.2.9",
"react": "19.2.4", "react": "19.2.4",
@@ -4894,6 +4895,15 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/date-format": {
"version": "4.0.14",
"resolved": "https://registry.npmjs.org/date-format/-/date-format-4.0.14.tgz",
"integrity": "sha512-39BOQLs9ZjKh0/patS9nrT8wc3ioX3/eA/zgbKNopnF2wCqJEoxywwwElATYvRsXdnOxA/OQeQoFZ3rFjVajhg==",
"license": "MIT",
"engines": {
"node": ">=4.0"
}
},
"node_modules/dayjs": { "node_modules/dayjs": {
"version": "1.11.21", "version": "1.11.21",
"resolved": "https://registry.npmmirror.com/dayjs/-/dayjs-1.11.21.tgz", "resolved": "https://registry.npmmirror.com/dayjs/-/dayjs-1.11.21.tgz",
@@ -5952,7 +5962,6 @@
"version": "3.4.2", "version": "3.4.2",
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz",
"integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==",
"dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/for-each": { "node_modules/for-each": {
@@ -5977,6 +5986,20 @@
"integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/fs-extra": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz",
"integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==",
"license": "MIT",
"dependencies": {
"graceful-fs": "^4.2.0",
"jsonfile": "^4.0.0",
"universalify": "^0.1.0"
},
"engines": {
"node": ">=6 <7 || >=8"
}
},
"node_modules/fs.realpath": { "node_modules/fs.realpath": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
@@ -7079,6 +7102,15 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/jsonfile": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz",
"integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==",
"license": "MIT",
"optionalDependencies": {
"graceful-fs": "^4.1.6"
}
},
"node_modules/jsonwebtoken": { "node_modules/jsonwebtoken": {
"version": "9.0.3", "version": "9.0.3",
"resolved": "https://registry.npmmirror.com/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", "resolved": "https://registry.npmmirror.com/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
@@ -7698,6 +7730,22 @@
"integrity": "sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==", "integrity": "sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/log4js": {
"version": "6.9.1",
"resolved": "https://registry.npmjs.org/log4js/-/log4js-6.9.1.tgz",
"integrity": "sha512-1somDdy9sChrr9/f4UlzhdaGfDR2c/SaD2a4T7qEkG4jTS57/B3qmnjLYePwQ8cqWnUHZI0iAKxMBpCZICiZ2g==",
"license": "Apache-2.0",
"dependencies": {
"date-format": "^4.0.14",
"debug": "^4.3.4",
"flatted": "^3.2.7",
"rfdc": "^1.3.0",
"streamroller": "^3.1.5"
},
"engines": {
"node": ">=8.0"
}
},
"node_modules/loose-envify": { "node_modules/loose-envify": {
"version": "1.4.0", "version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
@@ -8584,6 +8632,12 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/rfdc": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz",
"integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==",
"license": "MIT"
},
"node_modules/rimraf": { "node_modules/rimraf": {
"version": "2.7.1", "version": "2.7.1",
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz",
@@ -9054,6 +9108,20 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/streamroller": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/streamroller/-/streamroller-3.1.5.tgz",
"integrity": "sha512-KFxaM7XT+irxvdqSP1LGLgNWbYN7ay5owZ3r/8t77p+EtSUAfUgtl7be3xtqtOmGUl9K9YPO2ca8133RlTjvKw==",
"license": "MIT",
"dependencies": {
"date-format": "^4.0.14",
"debug": "^4.3.4",
"fs-extra": "^8.1.0"
},
"engines": {
"node": ">=8.0"
}
},
"node_modules/string_decoder": { "node_modules/string_decoder": {
"version": "1.3.0", "version": "1.3.0",
"resolved": "https://registry.npmmirror.com/string_decoder/-/string_decoder-1.3.0.tgz", "resolved": "https://registry.npmmirror.com/string_decoder/-/string_decoder-1.3.0.tgz",
@@ -9693,6 +9761,15 @@
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/universalify": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz",
"integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==",
"license": "MIT",
"engines": {
"node": ">= 4.0.0"
}
},
"node_modules/unrs-resolver": { "node_modules/unrs-resolver": {
"version": "1.12.2", "version": "1.12.2",
"resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.12.2.tgz", "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.12.2.tgz",
@@ -9848,6 +9925,7 @@
"integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==", "integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"lightningcss": "^1.32.0", "lightningcss": "^1.32.0",
"picomatch": "^4.0.4", "picomatch": "^4.0.4",

View File

@@ -17,6 +17,7 @@
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"antd": "^6.4.3", "antd": "^6.4.3",
"exceljs": "^4.4.0", "exceljs": "^4.4.0",
"log4js": "^6.9.1",
"mssql": "^12.5.5", "mssql": "^12.5.5",
"next": "16.2.9", "next": "16.2.9",
"react": "19.2.4", "react": "19.2.4",

View File

@@ -1,6 +1,7 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import sql from "mssql"; import sql from "mssql";
import ExcelJS from "exceljs"; import ExcelJS from "exceljs";
import { exportLogger, formatError } from "../../../server/logger";
const dbConfig = { const dbConfig = {
server: process.env.DB_SERVER!, server: process.env.DB_SERVER!,
@@ -55,7 +56,7 @@ const EXPORT_LOCK_TIMEOUT_MS = 90_000; // safety: reclaim a stuck lock after 90s
function acquireExportLock(): boolean { function acquireExportLock(): boolean {
if (exportLocked && Date.now() - exportLockedAt > EXPORT_LOCK_TIMEOUT_MS) { if (exportLocked && Date.now() - exportLockedAt > EXPORT_LOCK_TIMEOUT_MS) {
console.warn("Export lock reclaimed after timeout"); exportLogger.warn("export lock reclaimed after timeout");
exportLocked = false; exportLocked = false;
} }
if (exportLocked) return false; if (exportLocked) return false;
@@ -70,11 +71,14 @@ function releaseExportLock() {
export async function GET() { export async function GET() {
if (!acquireExportLock()) { if (!acquireExportLock()) {
exportLogger.warn("export skipped: another export in progress (429)");
return NextResponse.json( return NextResponse.json(
{ error: "正在导出,请稍候" }, { error: "正在导出,请稍候" },
{ status: 429, headers: { "Retry-After": "3" } } { status: 429, headers: { "Retry-After": "3" } }
); );
} }
const t0 = Date.now();
exportLogger.info("export started");
let pool: sql.ConnectionPool | undefined; let pool: sql.ConnectionPool | undefined;
try { try {
pool = await sql.connect(dbConfig); pool = await sql.connect(dbConfig);
@@ -84,6 +88,7 @@ export async function GET() {
const recordset = result.recordset; const recordset = result.recordset;
if (!recordset || recordset.length === 0) { if (!recordset || recordset.length === 0) {
exportLogger.warn("export empty: no data");
return NextResponse.json({ error: "没有数据可导出" }, { status: 404 }); return NextResponse.json({ error: "没有数据可导出" }, { status: 404 });
} }
@@ -151,6 +156,12 @@ export async function GET() {
// Stream to buffer // Stream to buffer
const buffer = await workbook.xlsx.writeBuffer(); 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, { return new NextResponse(buffer, {
status: 200, status: 200,
@@ -162,8 +173,9 @@ export async function GET() {
}, },
}); });
} catch (err) { } catch (err) {
const dur = Date.now() - t0;
const message = err instanceof Error ? err.message : "Unknown error"; 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 }); return NextResponse.json({ error: message }, { status: 500 });
} finally { } finally {
if (pool) { if (pool) {

View File

@@ -1,5 +1,8 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import sql from "mssql"; import sql from "mssql";
import { apiLogger, formatError } from "../../../server/logger";
const SLOW_QUERY_MS = 3000;
const dbConfig = { const dbConfig = {
server: process.env.DB_SERVER!, server: process.env.DB_SERVER!,
@@ -25,6 +28,7 @@ export async function GET(request: NextRequest) {
} }
let pool: sql.ConnectionPool | undefined; let pool: sql.ConnectionPool | undefined;
const t0 = Date.now();
try { try {
pool = await sql.connect(dbConfig); pool = await sql.connect(dbConfig);
const result = await pool const result = await pool
@@ -53,10 +57,15 @@ export async function GET(request: NextRequest) {
return serialized; 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 }); return NextResponse.json({ columns, data, total: data.length });
} catch (err) { } catch (err) {
const dur = Date.now() - t0;
const message = err instanceof Error ? err.message : "Unknown error"; 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 }); return NextResponse.json({ error: message }, { status: 500 });
} finally { } finally {
if (pool) { if (pool) {

View File

@@ -40,6 +40,7 @@ import {
import { CSS } from "@dnd-kit/utilities"; import { CSS } from "@dnd-kit/utilities";
import { nextLockedRow, type DataRow } from "./table-filters"; import { nextLockedRow, type DataRow } from "./table-filters";
import { buildColumnFilterProps } from "./table-filter-ui"; import { buildColumnFilterProps } from "./table-filter-ui";
import { RecordDetailModal } from "./record-detail-modal";
const { Title, Text } = Typography; const { Title, Text } = Typography;
@@ -241,15 +242,17 @@ function ColumnSettingsModal({
}) { }) {
const [local, setLocal] = useState<ColumnConfig[]>([]); const [local, setLocal] = useState<ColumnConfig[]>([]);
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [prevOpen, setPrevOpen] = useState(open);
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 4 } })); const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 4 } }));
// Sync when modal opens // Reset local copy whenever the modal opens (render-phase, no setState-in-effect).
useEffect(() => { if (open !== prevOpen) {
setPrevOpen(open);
if (open) { if (open) {
setLocal(config.map((c) => ({ ...c }))); setLocal(config.map((c) => ({ ...c })));
setSearch(""); setSearch("");
} }
}, [open, config]); }
const filtered = search const filtered = search
? local.filter((c) => c.key.toLowerCase().includes(search.toLowerCase())) ? local.filter((c) => c.key.toLowerCase().includes(search.toLowerCase()))
@@ -501,6 +504,12 @@ export default function Home() {
const [exporting, setExporting] = useState(false); const [exporting, setExporting] = useState(false);
const [lockedRowKey, setLockedRowKey] = useState<string | null>(null); const [lockedRowKey, setLockedRowKey] = useState<string | null>(null);
const [filters, setFilters] = useState<Record<string, React.Key[]>>({}); 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 // Persist config
useEffect(() => { useEffect(() => {
@@ -524,6 +533,8 @@ export default function Home() {
setSearched(true); setSearched(true);
setLockedRowKey(null); setLockedRowKey(null);
setFilters({}); setFilters({});
setContextMenu(null);
setDetailRecord(null);
} catch { } catch {
setError("网络请求失败"); setError("网络请求失败");
} finally { } finally {
@@ -538,6 +549,8 @@ export default function Home() {
setError(null); setError(null);
setLockedRowKey(null); setLockedRowKey(null);
setFilters({}); setFilters({});
setContextMenu(null);
setDetailRecord(null);
}, []); }, []);
const handleExport = useCallback(async () => { const handleExport = useCallback(async () => {
@@ -657,6 +670,10 @@ export default function Home() {
onRow={(record) => ({ onRow={(record) => ({
onClick: () => onClick: () =>
setLockedRowKey((prev) => nextLockedRow(prev, String(record.ID))), setLockedRowKey((prev) => nextLockedRow(prev, String(record.ID))),
onContextMenu: (e) => {
e.preventDefault();
setContextMenu({ record, x: e.clientX, y: e.clientY });
},
})} })}
rowClassName={(record) => rowClassName={(record) =>
String(record.ID) === lockedRowKey ? "row-locked" : "" String(record.ID) === lockedRowKey ? "row-locked" : ""
@@ -692,6 +709,58 @@ export default function Home() {
onApply={handleApplyConfig} onApply={handleApplyConfig}
onCancel={() => setModalOpen(false)} 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> </div>
); );
} }

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

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");