Compare commits

...

2 Commits

Author SHA1 Message Date
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
4 changed files with 184 additions and 3 deletions

View File

@@ -40,6 +40,7 @@ import {
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;
@@ -241,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()))
@@ -501,6 +504,12 @@ export default function Home() {
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(() => {
@@ -524,6 +533,8 @@ export default function Home() {
setSearched(true);
setLockedRowKey(null);
setFilters({});
setContextMenu(null);
setDetailRecord(null);
} catch {
setError("网络请求失败");
} finally {
@@ -538,6 +549,8 @@ export default function Home() {
setError(null);
setLockedRowKey(null);
setFilters({});
setContextMenu(null);
setDetailRecord(null);
}, []);
const handleExport = useCallback(async () => {
@@ -657,6 +670,10 @@ export default function Home() {
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" : ""
@@ -692,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>
);
}

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

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