Compare commits
2 Commits
6531254715
...
a4afa3a8f5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a4afa3a8f5 | ||
|
|
f3fa1a0e1b |
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
45
src/app/record-detail-modal.tsx
Normal file
45
src/app/record-detail-modal.tsx
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
"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>
|
||||||
|
);
|
||||||
|
}
|
||||||
41
src/app/record-detail.test.ts
Normal file
41
src/app/record-detail.test.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
26
src/app/record-detail.ts
Normal file
26
src/app/record-detail.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user