diff --git a/package-lock.json b/package-lock.json index b7aabdb..f9dd4a8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,9 @@ "version": "0.1.0", "dependencies": { "@ant-design/icons": "^6.2.5", + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "antd": "^6.4.3", "mssql": "^12.5.5", "next": "16.2.9", @@ -625,6 +628,60 @@ "node": ">=6.9.0" } }, + "node_modules/@dnd-kit/accessibility": { + "version": "3.1.1", + "resolved": "https://registry.npmmirror.com/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", + "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/core": { + "version": "6.3.1", + "resolved": "https://registry.npmmirror.com/@dnd-kit/core/-/core-6.3.1.tgz", + "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "@dnd-kit/accessibility": "^3.1.1", + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/sortable": { + "version": "10.0.0", + "resolved": "https://registry.npmmirror.com/@dnd-kit/sortable/-/sortable-10.0.0.tgz", + "integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==", + "license": "MIT", + "dependencies": { + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@dnd-kit/core": "^6.3.0", + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/utilities": { + "version": "3.2.2", + "resolved": "https://registry.npmmirror.com/@dnd-kit/utilities/-/utilities-3.2.2.tgz", + "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, "node_modules/@emnapi/wasi-threads": { "version": "1.2.2", "resolved": "https://registry.npmmirror.com/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", diff --git a/package.json b/package.json index 1939daa..50edf66 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,9 @@ }, "dependencies": { "@ant-design/icons": "^6.2.5", + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "antd": "^6.4.3", "mssql": "^12.5.5", "next": "16.2.9", diff --git a/src/app/page.tsx b/src/app/page.tsx index 5b34747..a751b22 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { Button, Input, @@ -10,9 +10,26 @@ import { Alert, Typography, Empty, + Modal, + Checkbox, } from "antd"; -import { SearchOutlined } from "@ant-design/icons"; +import { SearchOutlined, SettingOutlined, HolderOutlined } from "@ant-design/icons"; import type { ColumnsType } from "antd/es/table"; +import { + DndContext, + closestCenter, + PointerSensor, + useSensor, + useSensors, +} from "@dnd-kit/core"; +import type { DragEndEvent } from "@dnd-kit/core"; +import { + SortableContext, + useSortable, + verticalListSortingStrategy, + arrayMove, +} from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; const { Title, Text } = Typography; @@ -20,6 +37,11 @@ interface DataRow { [key: string]: string | number | null; } +interface ColumnConfig { + key: string; + visible: boolean; +} + const COLUMN_DEFS: { key: string; width: number }[] = [ { key: "ID", width: 70 }, { key: "总排号", width: 100 }, @@ -79,39 +101,223 @@ const COLUMN_DEFS: { key: string; width: number }[] = [ { key: "烘洗", width: 110 }, ]; -function buildColumns(keys: string[]): ColumnsType { - return keys.map((key) => { - const def = COLUMN_DEFS.find((d) => d.key === key); - return { - title: key, - dataIndex: key, - key, - width: def?.width ?? 120, - ellipsis: true, - render: (val: string | number | null) => { - if (val === null || val === undefined) return ""; - return String(val); - }, - }; - }); +const DEFAULT_CONFIG: ColumnConfig[] = COLUMN_DEFS.map((d) => ({ + key: d.key, + visible: true, +})); + +const STORAGE_KEY = "web-table-column-config"; + +function loadConfig(): ColumnConfig[] { + try { + const saved = localStorage.getItem(STORAGE_KEY); + if (saved) { + const parsed: ColumnConfig[] = JSON.parse(saved); + const savedKeys = new Set(parsed.map((c) => c.key)); + if (COLUMN_DEFS.every((d) => savedKeys.has(d.key))) return parsed; + } + } catch { + /* ignore */ + } + return DEFAULT_CONFIG.map((c) => ({ ...c })); } -// Search bar: isolated component with its own input state -// Typing only re-renders THIS component, not the parent with the heavy Table +function saveConfig(config: ColumnConfig[]) { + localStorage.setItem(STORAGE_KEY, JSON.stringify(config)); +} + +function buildTableColumns( + config: ColumnConfig[], + dataColumns: string[] +): ColumnsType { + 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 { + title: c.key, + dataIndex: c.key, + key: c.key, + width: def?.width ?? 120, + ellipsis: true, + render: (val: string | number | null) => + val === null || val === undefined ? "" : String(val), + }; + }); +} + +/* ─── Sortable Row in Modal ─── */ + +function SortableRow({ + id, + label, + visible, + onToggle, +}: { + id: string; + label: string; + visible: boolean; + onToggle: () => void; +}) { + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id }); + const style: React.CSSProperties = { + transform: CSS.Transform.toString(transform), + transition, + display: "flex", + alignItems: "center", + gap: 8, + padding: "6px 8px", + borderBottom: "1px solid #f5f5f5", + background: isDragging ? "#e6f4ff" : "#fff", + cursor: "default", + }; + + return ( +
+ + + + + {label} +
+ ); +} + +/* ─── Column Settings Modal ─── */ + +function ColumnSettingsModal({ + open, + config, + onApply, + onCancel, +}: { + open: boolean; + config: ColumnConfig[]; + onApply: (newConfig: ColumnConfig[]) => void; + onCancel: () => void; +}) { + const [local, setLocal] = useState([]); + const [search, setSearch] = useState(""); + const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 4 } })); + + // Sync when modal opens + useEffect(() => { + if (open) { + setLocal(config.map((c) => ({ ...c }))); + setSearch(""); + } + }, [open, config]); + + const filtered = search + ? local.filter((c) => c.key.toLowerCase().includes(search.toLowerCase())) + : local; + + const visibleCount = local.filter((c) => c.visible).length; + + const handleDragEnd = (event: DragEndEvent) => { + const { active, over } = event; + if (!over || active.id === over.id) return; + setLocal((prev) => { + const oldIdx = prev.findIndex((c) => c.key === active.id); + const newIdx = prev.findIndex((c) => c.key === over.id); + return arrayMove(prev, oldIdx, newIdx); + }); + }; + + const toggle = (key: string) => { + setLocal((prev) => + prev.map((c) => (c.key === key ? { ...c, visible: !c.visible } : 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 }))); + + return ( + + 取消 + , + , + ]} + > +
+ setSearch(e.target.value)} + allowClear + style={{ width: 180 }} + size="small" + /> + + + +
+ +
+ + c.key)} strategy={verticalListSortingStrategy}> + {filtered.map((c) => ( + toggle(c.key)} + /> + ))} + + +
+
+ ); +} + +/* ─── Search Bar (isolated) ─── */ + function SearchBar({ loading, resultCount, searched, onSearch, onClear, + onOpenColumnSettings, }: { loading: boolean; resultCount: number; searched: boolean; onSearch: (workshopNo: string) => void; onClear: () => void; + onOpenColumnSettings: () => void; }) { - const [inputValue, setInputValue] = useState("R05697"); + const [inputValue, setInputValue] = useState(""); const handleSearch = useCallback(() => { if (inputValue.trim()) { @@ -131,21 +337,26 @@ function SearchBar({ 压力表合同生产数据 - - } - placeholder="请输入车间号,如 R05697" - value={inputValue} - onChange={(e) => setInputValue(e.target.value)} - onPressEnter={handleSearch} - allowClear - style={{ width: 320 }} - /> - + + + - - + {searched && !loading && ( 共 {resultCount} 条记录 @@ -155,12 +366,21 @@ function SearchBar({ ); } +/* ─── Main Page ─── */ + export default function Home() { const [data, setData] = useState([]); const [columns, setColumns] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [searched, setSearched] = useState(false); + const [columnConfig, setColumnConfig] = useState(loadConfig); + const [modalOpen, setModalOpen] = useState(false); + + // Persist config + useEffect(() => { + saveConfig(columnConfig); + }, [columnConfig]); const handleSearch = useCallback(async (workshopNo: string) => { setLoading(true); @@ -191,7 +411,15 @@ export default function Home() { setError(null); }, []); - const tableColumns = useMemo(() => buildColumns(columns), [columns]); + const handleApplyConfig = useCallback((newConfig: ColumnConfig[]) => { + setColumnConfig(newConfig); + setModalOpen(false); + }, []); + + const tableColumns = useMemo( + () => buildTableColumns(columnConfig, columns), + [columnConfig, columns] + ); const totalWidth = useMemo( () => tableColumns.reduce((sum, col) => sum + (col.width as number), 0), [tableColumns] @@ -209,6 +437,7 @@ export default function Home() { searched={searched} onSearch={handleSearch} onClear={handleClear} + onOpenColumnSettings={() => setModalOpen(true)} />
@@ -273,6 +502,13 @@ export default function Home() {
)} + + setModalOpen(false)} + /> ); }