import { useEffect, useMemo, useState } from 'react' import type { CSSProperties, ReactNode } from 'react' import { DndContext, DragOverlay, PointerSensor, type DragEndEvent, type DragStartEvent, useDraggable, useSensor, useSensors, } from '@dnd-kit/core' import { SortableContext, arrayMove, horizontalListSortingStrategy, rectSortingStrategy, useSortable, } from '@dnd-kit/sortable' import { CSS } from '@dnd-kit/utilities' import { ArrowLeft, ArrowRight, Box, Check, ChevronDown, ChevronRight, CircleHelp, Columns3, Copy, Database, Eye, EyeOff, FileClock, GripVertical, LayoutDashboard, Layers3, ListFilter, LockKeyhole, MoreHorizontal, PackageCheck, Play, Plus, RotateCcw, Save, Settings2, ShieldCheck, Table2, X, } from 'lucide-react' import './App.css' type FieldType = 'text' | 'reference' | 'date' | 'select' | 'number' type Field = { id: string label: string key: string type: FieldType required?: boolean visible: boolean helper?: string role?: string } type Column = { id: string; label: string; key: string; width: number; visible: boolean } type Toast = { title: string; message: string } const initialFields: Field[] = [ { id: 'booking-no', label: '订舱编号', key: 'booking_no', type: 'text', required: true, visible: true, helper: '系统自动生成', role: '系统字段' }, { id: 'customer', label: '客户', key: 'customer_id', type: 'reference', required: true, visible: true, helper: '选择客户主数据', role: '基础资料' }, { id: 'carrier', label: '船公司', key: 'carrier_id', type: 'reference', required: true, visible: true, helper: '合作伙伴 / 承运人', role: '基础资料' }, { id: 'etd', label: '预计开船日', key: 'etd', type: 'date', required: true, visible: true, role: '业务字段' }, { id: 'pol', label: '起运港', key: 'pol_id', type: 'reference', visible: true, role: '业务字段' }, { id: 'pod', label: '目的港', key: 'pod_id', type: 'reference', visible: true, role: '业务字段' }, { id: 'trade-term', label: '贸易条款', key: 'trade_term', type: 'select', visible: true, role: '字典字段' }, { id: 'notes', label: '操作备注', key: 'notes', type: 'text', visible: true, helper: '内部业务人员可见', role: '扩展字段' }, ] const initialColumns: Column[] = [ { id: 'container-no', label: '箱号', key: 'container_no', width: 156, visible: true }, { id: 'container-type', label: '箱型', key: 'container_type', width: 110, visible: true }, { id: 'weight', label: '毛重 (KG)', key: 'weight', width: 122, visible: true }, { id: 'packages', label: '件数', key: 'packages', width: 90, visible: true }, { id: 'seal-no', label: '封条号', key: 'seal_no', width: 140, visible: true }, ] const fieldTypeLabel: Record = { text: '单行文本', reference: '引用选择', date: '日期选择', select: '固定字典', number: '数值', } const initialRows = [ { id: '1', container_no: 'MSCU 482913 6', container_type: '40HQ', weight: '12,480', packages: '32', seal_no: 'SL-902183' }, { id: '2', container_no: 'MSCU 482917 0', container_type: '40HQ', weight: '10,260', packages: '26', seal_no: 'SL-902184' }, ] function IconButton({ label, children, onClick, active = false }: { label: string; children: ReactNode; onClick?: () => void; active?: boolean }) { return } function SortableField({ field, selected, onSelect }: { field: Field; selected: boolean; onSelect: () => void }) { const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: `field:${field.id}`, data: { kind: 'field', id: field.id } }) const style: CSSProperties = { transform: CSS.Transform.toString(transform), transition } return (
{field.label}{field.required && 必填}
{field.key}{fieldTypeLabel[field.type]}
{field.visible ? : }
) } function SortableColumn({ column, selected, onSelect }: { column: Column; selected: boolean; onSelect: () => void }) { const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: `column:${column.id}`, data: { kind: 'column', id: column.id } }) const style: CSSProperties = { transform: CSS.Transform.toString(transform), transition, width: column.width } return (
{column.label} {column.width}
) } function PaletteField({ field, onAdd }: { field: Field; onAdd: () => void }) { const { attributes, listeners, setNodeRef, isDragging } = useDraggable({ id: `palette:${field.id}`, data: { kind: 'palette', field } }) const onClick = (event: React.MouseEvent) => { event.stopPropagation(); onAdd() } return ( ) } function App() { const [mode, setMode] = useState<'builder' | 'preview'>('builder') const [activeNav, setActiveNav] = useState<'builder' | 'runtime' | 'releases'>('builder') const [fields, setFields] = useState(initialFields) const [columns, setColumns] = useState(initialColumns) const [selected, setSelected] = useState<{ kind: 'field' | 'column'; id: string }>({ kind: 'field', id: 'carrier' }) const [toast, setToast] = useState(null) const [showHelp, setShowHelp] = useState(false) const [showReleases, setShowReleases] = useState(false) const [release, setRelease] = useState(42) const [candidate, setCandidate] = useState(false) const [rows, setRows] = useState(initialRows) const [activeId, setActiveId] = useState(null) const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 6 } })) useEffect(() => { const saved = window.localStorage.getItem('fms-mvp-layout') if (saved) { try { const data = JSON.parse(saved) as { fields?: Field[]; columns?: Column[]; release?: number } if (data.fields) setFields(data.fields) if (data.columns) setColumns(data.columns) if (data.release) setRelease(data.release) } catch { /* Use the stable fixture when local data is incomplete. */ } } }, []) const visibleFields = useMemo(() => fields.filter((field) => field.visible), [fields]) const activeItem = selected.kind === 'field' ? fields.find((field) => field.id === selected.id) : columns.find((column) => column.id === selected.id) function notify(title: string, message: string) { setToast({ title, message }) window.setTimeout(() => setToast(null), 3200) } function saveCandidate() { window.localStorage.setItem('fms-mvp-layout', JSON.stringify({ fields, columns, release })) setCandidate(true) notify('候选版本已保存', '变更已保存到 Release 42 · 候选版本,可进入预览验证。') } function publish() { const next = release + 1 window.localStorage.setItem('fms-mvp-layout', JSON.stringify({ fields, columns, release: next })) setRelease(next) setCandidate(false) notify('配置已发布', `Release ${next} 已激活,当前租户运行时已刷新。`) } function rollback() { setFields(initialFields) setColumns(initialColumns) setCandidate(false) window.localStorage.setItem('fms-mvp-layout', JSON.stringify({ fields: initialFields, columns: initialColumns, release: 41 })) setRelease(41) notify('已回滚配置', '当前运行时已切换回 Release 41。') } function addField(field: Field) { if (fields.some((item) => item.id === field.id)) { setSelected({ kind: 'field', id: field.id }) return } setFields((current) => [...current, { ...field, visible: true }]) setSelected({ kind: 'field', id: field.id }) notify('字段已加入表单', `${field.label} 已添加到主表表单。`) } function handleDragStart(event: DragStartEvent) { setActiveId(String(event.active.id)) } function handleDragCancel() { setActiveId(null) } function handleDragEnd(event: DragEndEvent) { setActiveId(null) const { active, over } = event if (!over || active.id === over.id) return const activeData = active.data.current if (activeData?.kind === 'palette') { addField(activeData.field as Field) return } const activeKind = String(active.id).split(':')[0] const overKind = String(over.id).split(':')[0] if (activeKind !== overKind) return if (activeKind === 'field') { const oldIndex = fields.findIndex((item) => `field:${item.id}` === active.id) const newIndex = fields.findIndex((item) => `field:${item.id}` === over.id) if (oldIndex !== -1 && newIndex !== -1) setFields((current) => arrayMove(current, oldIndex, newIndex)) } if (activeKind === 'column') { const oldIndex = columns.findIndex((item) => `column:${item.id}` === active.id) const newIndex = columns.findIndex((item) => `column:${item.id}` === over.id) if (oldIndex !== -1 && newIndex !== -1) setColumns((current) => arrayMove(current, oldIndex, newIndex)) } } function updateField(patch: Partial) { if (selected.kind !== 'field') return setFields((current) => current.map((field) => field.id === selected.id ? { ...field, ...patch } : field)) } function updateColumn(patch: Partial) { if (selected.kind !== 'column') return setColumns((current) => current.map((column) => column.id === selected.id ? { ...column, ...patch } : column)) } function selectNav(nav: typeof activeNav) { setActiveNav(nav) if (nav === 'builder') { setMode('builder'); return } if (nav === 'runtime') { setMode('preview'); return } setShowReleases(true) } return (
F
FMS / 工作台
元数据工作台
海运订舱/默认编辑表单
本地演示 setShowHelp(true)}>
YL
配置 / 实体

订舱工作台

配置订舱表单和集装箱子表,无需编写代码。

当前版本Release {release}{candidate ? '候选版本已保存' : '所有变更已发布'}
{mode === 'builder' ?
组件

字段面板

{fields.length} 个字段

将字段拖入画布,或点击字段将其加入表单。

订舱字段 8
{fields.map((field) => addField(field)} />)}
关系 01
表单 / OCEAN.BOOKING

默认编辑表单

刚刚保存
2 个分组 每行 2 个字段 1 个关系表 已接入权限
01
订舱信息拖拽字段调整行和列
2 列布局
`field:${field.id}`)} strategy={rectSortingStrategy}>{fields.map((field) => setSelected({ kind: 'field', id: field.id })} />)}
将字段拖到这里,自动放入当前行
02
集装箱一对多关系 · 可编辑表格
可编辑
ocean.container {columns.filter((column) => column.visible).length} 个可见列
`column:${column.id}`)} strategy={horizontalListSortingStrategy}>{columns.filter((column) => column.visible).map((column) => setSelected({ kind: 'column', id: column.id })} />)}
{columns.filter((column) => column.visible).map((column) => {column.label})}
{initialRows.slice(0, 2).map((row) =>
{columns.filter((column) => column.visible).map((column) => {row[column.key as keyof typeof row]})}
)}
{activeId ?
{activeId.split(':')[1]}
: null}
: column.visible)} rows={rows} setRows={setRows} onBack={() => setMode('builder')} onNotify={notify} />}
{showHelp &&
setShowHelp(false)}>
event.stopPropagation()}>
关于本演示

元数据驱动的配置流程

setShowHelp(false)}>

这是一个不接后端的 FMS 配置运行时演示。左侧字段可以拖入表单,中间区域支持排序,主子表列和运行时预览共用同一份本地元数据。

候选版本 主子表单 字段权限
} {showReleases &&
setShowReleases(false)}>
event.stopPropagation()}>
配置历史

版本历史

setShowReleases(false)}>
Release {release}当前版本 · 刚刚激活
当前
Release 41上一版本 · 2 小时前

回滚会将本地演示切换到上一份元数据快照,不会影响生产数据。

} {toast &&
{toast.title}{toast.message}
}
) } function RuntimePreview({ fields, columns, rows, setRows, onBack, onNotify }: { fields: Field[]; columns: Column[]; rows: typeof initialRows; setRows: React.Dispatch>; onBack: () => void; onNotify: (title: string, message: string) => void }) { const addRow = () => { setRows((current) => [...current, { id: String(current.length + 1), container_no: `MSCU 48292${current.length}`, container_type: '40HQ', weight: '—', packages: '—', seal_no: '—' }]); onNotify('子表行已新增', '新的集装箱行已加入当前草稿。') } return
运行时预览 / 候选版本

新建订舱

当前以操作团队身份预览 Release 42 · 候选版本。

OCEAN.BOOKING

订舱信息

草稿
{fields.slice(0, 6).map((field) => )}
关系 / OCEAN.CONTAINER

集装箱 {rows.length} 行

{columns.map((column) => )}{rows.map((row) => {columns.map((column) => )})}
{column.label}
setRows((current) => current.map((item) => item.id === row.id ? { ...item, [column.key]: event.target.value } : item))} />
字段可见性和导出权限由当前用户的权限快照统一决定。
} function renderRuntimeInput(field: Field) { if (field.type === 'reference') return
if (field.type === 'date') return if (field.type === 'select') return
return } export default App