306 lines
29 KiB
TypeScript
306 lines
29 KiB
TypeScript
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<FieldType, string> = {
|
|
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 <button className={`icon-button ${active ? 'is-active' : ''}`} aria-label={label} title={label} onClick={onClick}>{children}</button>
|
|
}
|
|
|
|
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 (
|
|
<div ref={setNodeRef} style={style} className={`field-card ${selected ? 'is-selected' : ''} ${isDragging ? 'is-dragging' : ''}`} onClick={onSelect}>
|
|
<button className="drag-handle" {...attributes} {...listeners} aria-label={`拖动${field.label}`}><GripVertical size={15} /></button>
|
|
<div className="field-card-main">
|
|
<div className="field-card-title"><span>{field.label}</span>{field.required && <span className="required-mark">必填</span>}</div>
|
|
<div className="field-card-meta"><code>{field.key}</code><span>{fieldTypeLabel[field.type]}</span></div>
|
|
</div>
|
|
<span className={`visibility-dot ${field.visible ? 'on' : 'off'}`} title={field.visible ? '可见' : '已隐藏'}>{field.visible ? <Eye size={14} /> : <EyeOff size={14} />}</span>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
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 (
|
|
<div ref={setNodeRef} style={style} className={`column-chip ${selected ? 'is-selected' : ''} ${isDragging ? 'is-dragging' : ''}`} onClick={onSelect}>
|
|
<button className="column-grip" {...attributes} {...listeners} aria-label={`拖动${column.label}`}><GripVertical size={14} /></button>
|
|
<span>{column.label}</span>
|
|
<small>{column.width}</small>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
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 (
|
|
<button ref={setNodeRef} className={`palette-item ${isDragging ? 'is-dragging' : ''}`} {...attributes} {...listeners} onClick={onClick}>
|
|
<span className="palette-icon"><Plus size={14} /></span><span>{field.label}</span><small>{fieldTypeLabel[field.type]}</small>
|
|
</button>
|
|
)
|
|
}
|
|
|
|
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<Toast | null>(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<string | null>(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<Field>) {
|
|
if (selected.kind !== 'field') return
|
|
setFields((current) => current.map((field) => field.id === selected.id ? { ...field, ...patch } : field))
|
|
}
|
|
|
|
function updateColumn(patch: Partial<Column>) {
|
|
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 (
|
|
<div className="app-shell">
|
|
<header className="topbar">
|
|
<div className="brand-block"><div className="brand-mark">F</div><div><div className="brand-name">FMS <span>/</span> 工作台</div><div className="brand-subtitle">元数据工作台</div></div></div>
|
|
<div className="breadcrumb"><span>海运</span><ChevronRight size={14} /><strong>订舱</strong><span className="slash">/</span><span className="muted">默认编辑表单</span></div>
|
|
<div className="topbar-actions"><span className="local-badge"><span className="status-pulse" /> 本地演示</span><IconButton label="帮助" onClick={() => setShowHelp(true)}><CircleHelp size={17} /></IconButton><div className="avatar">YL</div></div>
|
|
</header>
|
|
|
|
<div className="workspace">
|
|
<aside className="sidebar">
|
|
<div className="sidebar-section-label">工作区</div>
|
|
<button className={`nav-item ${activeNav === 'builder' ? 'active' : ''}`} onClick={() => selectNav('builder')}><LayoutDashboard size={17} /><span>表单设计器</span><span className="nav-count">01</span></button>
|
|
<button className={`nav-item ${activeNav === 'runtime' ? 'active' : ''}`} onClick={() => selectNav('runtime')}><Play size={17} /><span>运行时预览</span></button>
|
|
<button className={`nav-item ${activeNav === 'releases' ? 'active' : ''}`} onClick={() => selectNav('releases')}><FileClock size={17} /><span>版本历史</span><span className="nav-dot" /></button>
|
|
<div className="sidebar-divider" />
|
|
<div className="sidebar-section-label with-action"><span>模块</span><IconButton label="添加模块"><Plus size={14} /></IconButton></div>
|
|
<div className="module-tree"><button className="tree-item open"><ChevronDown size={14} /><Box size={16} className="tree-icon ocean" /><span>海运</span></button><button className="tree-item child selected"><Table2 size={15} /><span>订舱</span><span className="tree-state">已发布</span></button><button className="tree-item child"><PackageCheck size={15} /><span>海运单</span></button><button className="tree-item child"><Columns3 size={15} /><span>集装箱</span></button><button className="tree-item"><ChevronRight size={14} /><Box size={16} className="tree-icon finance" /><span>财务</span></button><button className="tree-item"><ChevronRight size={14} /><Box size={16} className="tree-icon master" /><span>基础资料</span></button></div>
|
|
<div className="sidebar-footer"><div className="tenant-row"><div className="tenant-icon"><Database size={16} /></div><div><span className="tenant-label">当前租户</span><strong>北极星物流</strong></div><ChevronDown size={15} /></div><div className="sidebar-footer-links"><span><ShieldCheck size={13} /> 独立业务数据库</span><span>org_northstar</span></div></div>
|
|
</aside>
|
|
|
|
<main className="main-content">
|
|
<div className="page-head"><div><div className="eyebrow"><span className="eyebrow-line" /> 配置 / 实体</div><h1>订舱工作台</h1><p>配置订舱表单和集装箱子表,无需编写代码。</p></div><div className="head-actions"><button className="button subtle" onClick={() => setMode(mode === 'builder' ? 'preview' : 'builder')}><Play size={15} /> {mode === 'builder' ? '预览运行时' : '返回设计器'}</button><button className="button outline" onClick={saveCandidate}><Save size={15} /> 保存候选版本</button><button className="button primary" onClick={publish}><PackageCheck size={15} /> 发布 <ChevronDown size={14} /></button></div></div>
|
|
<div className="mode-strip"><div className="mode-tabs"><button className={mode === 'builder' ? 'active' : ''} onClick={() => setMode('builder')}><Settings2 size={15} /> 设计器</button><button className={mode === 'preview' ? 'active' : ''} onClick={() => setMode('preview')}><Play size={15} /> 运行时预览</button></div><div className="release-summary"><span className="release-label"><span className="release-dot" /> 当前版本</span><strong>Release {release}</strong><span className="release-divider" /><span>{candidate ? '候选版本已保存' : '所有变更已发布'}</span></div></div>
|
|
|
|
{mode === 'builder' ? <DndContext sensors={sensors} onDragStart={handleDragStart} onDragCancel={handleDragCancel} onDragEnd={handleDragEnd}>
|
|
<div className="builder-grid">
|
|
<section className="builder-panel palette-panel"><div className="panel-heading"><div><span className="panel-kicker">组件</span><h2>字段面板</h2></div><span className="count-chip">{fields.length} 个字段</span></div><p className="panel-intro">将字段拖入画布,或点击字段将其加入表单。</p><div className="palette-search"><ListFilter size={15} /><input placeholder="搜索字段" /></div><div className="palette-group"><div className="palette-group-title">订舱字段 <span>8</span></div>{fields.map((field) => <PaletteField key={field.id} field={field} onAdd={() => addField(field)} />)}</div><div className="palette-divider" /><div className="palette-group"><div className="palette-group-title">关系 <span>01</span></div><button className="relation-palette"><span className="relation-icon"><Table2 size={15} /></span><span><strong>集装箱</strong><small>一对多 · 可编辑表格</small></span><Plus size={15} /></button></div><button className="new-field-button" onClick={() => notify('字段设计器', 'MVP 中可从字段面板添加标准字段类型。')}><Plus size={16} /> 新建字段</button></section>
|
|
|
|
<section className="builder-panel canvas-panel"><div className="panel-heading canvas-heading"><div><span className="panel-kicker">表单 / OCEAN.BOOKING</span><h2>默认编辑表单</h2></div><div className="canvas-tools"><span className="saved-indicator"><span className="saved-check"><Check size={12} /></span> 刚刚保存</span><IconButton label="复制表单"><Copy size={16} /></IconButton><IconButton label="更多操作"><MoreHorizontal size={18} /></IconButton></div></div><div className="canvas-meta"><span><Layers3 size={14} /> 2 个分组</span><span><Columns3 size={14} /> 每行 2 个字段</span><span><Table2 size={14} /> 1 个关系表</span><span><LockKeyhole size={14} /> 已接入权限</span></div><div className="form-canvas"><div className="canvas-section"><div className="section-heading"><div><span className="section-number">01</span><div><strong>订舱信息</strong><small>拖拽字段调整行和列</small></div></div><div className="section-heading-actions"><span className="layout-chip"><Columns3 size={13} /> 2 列布局</span><button className="section-menu"><MoreHorizontal size={17} /></button></div></div><div className="drop-zone"><SortableContext items={fields.map((field) => `field:${field.id}`)} strategy={rectSortingStrategy}>{fields.map((field) => <SortableField key={field.id} field={field} selected={selected.kind === 'field' && selected.id === field.id} onSelect={() => setSelected({ kind: 'field', id: field.id })} />)}</SortableContext><div className="drop-hint"><Plus size={15} /><span>将字段拖到这里,自动放入当前行</span></div></div></div><div className="canvas-section relation-section"><div className="section-heading"><div><span className="section-number warm">02</span><div><strong>集装箱</strong><small>一对多关系 · 可编辑表格</small></div></div><div className="relation-actions"><span className="relation-status"><span className="green-dot" /> 可编辑</span><button className="section-menu"><MoreHorizontal size={17} /></button></div></div><div className="relation-config-line"><span><Database size={14} /> ocean.container</span><span><Columns3 size={14} /> {columns.filter((column) => column.visible).length} 个可见列</span><button onClick={() => notify('子表设置', '子表配置已绑定到 ocean.booking.containers。')}>配置关系 <ArrowRight size={14} /></button></div><div className="columns-drop-zone"><SortableContext items={columns.map((column) => `column:${column.id}`)} strategy={horizontalListSortingStrategy}>{columns.filter((column) => column.visible).map((column) => <SortableColumn key={column.id} column={column} selected={selected.kind === 'column' && selected.id === column.id} onSelect={() => setSelected({ kind: 'column', id: column.id })} />)}</SortableContext><div className="column-add"><Plus size={15} /></div></div><div className="relation-preview-table"><div className="preview-row preview-header">{columns.filter((column) => column.visible).map((column) => <span key={column.id} style={{ width: column.width }}>{column.label}</span>)}<span className="row-actions-head" /></div>{initialRows.slice(0, 2).map((row) => <div className="preview-row" key={row.id}>{columns.filter((column) => column.visible).map((column) => <span key={column.id} style={{ width: column.width }}>{row[column.key as keyof typeof row]}</span>)}<span className="row-actions"><MoreHorizontal size={15} /></span></div>)}</div><button className="add-row-button" onClick={() => notify('子表配置', '运行时可以在此关系区域新增集装箱。')}><Plus size={15} /> 新增集装箱行</button></div></div></section>
|
|
|
|
<aside className="builder-panel inspector-panel"><div className="panel-heading"><div><span className="panel-kicker">属性面板</span><h2>配置属性</h2></div><IconButton label="关闭属性"><X size={17} /></IconButton></div>{activeItem ? <>{selected.kind === 'field' ? <><div className="selected-object"><div className="object-icon field-object"><Settings2 size={17} /></div><div><strong>{(activeItem as Field).label}</strong><code>{(activeItem as Field).key}</code></div><span className="object-type">字段</span></div><div className="inspector-form"><label>显示名称<input value={(activeItem as Field).label} onChange={(event) => updateField({ label: event.target.value })} /></label><label>字段类型<div className="select-like"><span>{fieldTypeLabel[(activeItem as Field).type]}</span><ChevronDown size={15} /></div></label><label className="switch-row"><span><strong>在表单中显示</strong><small>仅控制默认布局</small></span><button className={`switch ${((activeItem as Field).visible) ? 'on' : ''}`} onClick={() => updateField({ visible: !(activeItem as Field).visible })}><span /></button></label><label className="switch-row"><span><strong>必填字段</strong><small>保存前校验</small></span><button className={`switch ${((activeItem as Field).required) ? 'on' : ''}`} onClick={() => updateField({ required: !(activeItem as Field).required })}><span /></button></label></div><div className="inspector-divider" /><div className="permission-callout"><div className="callout-icon"><ShieldCheck size={16} /></div><div><strong>已接入权限</strong><p>运行时会按角色检查字段权限。当前字段对<b>操作团队</b>可见。</p><button onClick={() => notify('字段权限', '已打开 auth_field_policy 配置。')}>编辑字段权限 <ArrowRight size={13} /></button></div></div><div className="inspector-note"><LockKeyhole size={14} /><span>后端写入模式:<b>{(activeItem as Field).id === 'booking-no' ? 'system' : 'direct'}</b></span></div></> : <><div className="selected-object"><div className="object-icon table-object"><Table2 size={17} /></div><div><strong>{(activeItem as Column).label}</strong><code>{(activeItem as Column).key}</code></div><span className="object-type">列</span></div><div className="inspector-form"><label>列名称<input value={(activeItem as Column).label} onChange={(event) => updateColumn({ label: event.target.value })} /></label><label>列宽 <div className="number-input"><input type="number" value={(activeItem as Column).width} onChange={(event) => updateColumn({ width: Number(event.target.value) })} /><span>px</span></div></label><label className="switch-row"><span><strong>在表格中显示</strong><small>布局之后仍会检查角色权限</small></span><button className={`switch ${((activeItem as Column).visible) ? 'on' : ''}`} onClick={() => updateColumn({ visible: !(activeItem as Column).visible })}><span /></button></label></div><div className="inspector-divider" /><div className="permission-callout amber"><div className="callout-icon"><Eye size={16} /></div><div><strong>导出权限</strong><p>列可以在表格中显示,同时限制导出权限。</p><button onClick={() => notify('列权限', '导出权限配置入口已准备。')}>配置权限 <ArrowRight size={13} /></button></div></div></>}</> : <div className="empty-inspector"><Settings2 size={22} /><p>选择字段或列查看配置。</p></div>}</aside>
|
|
</div><DragOverlay>{activeId ? <div className="drag-overlay"><GripVertical size={15} /> {activeId.split(':')[1]}</div> : null}</DragOverlay>
|
|
</DndContext> : <RuntimePreview fields={visibleFields} columns={columns.filter((column) => column.visible)} rows={rows} setRows={setRows} onBack={() => setMode('builder')} onNotify={notify} />}
|
|
</main>
|
|
</div>
|
|
{showHelp && <div className="modal-backdrop" onClick={() => setShowHelp(false)}><div className="help-modal" onClick={(event) => event.stopPropagation()}><div className="modal-title"><div><span className="panel-kicker">关于本演示</span><h2>元数据驱动的配置流程</h2></div><IconButton label="关闭" onClick={() => setShowHelp(false)}><X size={17} /></IconButton></div><p>这是一个不接后端的 FMS 配置运行时演示。左侧字段可以拖入表单,中间区域支持排序,主子表列和运行时预览共用同一份本地元数据。</p><div className="help-points"><span><Check size={14} /> 候选版本</span><span><Check size={14} /> 主子表单</span><span><Check size={14} /> 字段权限</span></div><button className="button primary full" onClick={() => setShowHelp(false)}>知道了</button></div></div>}
|
|
{showReleases && <div className="modal-backdrop" onClick={() => setShowReleases(false)}><div className="release-modal" onClick={(event) => event.stopPropagation()}><div className="modal-title"><div><span className="panel-kicker">配置历史</span><h2>版本历史</h2></div><IconButton label="关闭" onClick={() => setShowReleases(false)}><X size={17} /></IconButton></div><div className="release-list"><div className="release-item current"><div className="release-icon"><Check size={15} /></div><div><strong>Release {release}</strong><span>当前版本 · 刚刚激活</span></div><span className="release-pill">当前</span></div><div className="release-item"><div className="release-icon old"><FileClock size={15} /></div><div><strong>Release 41</strong><span>上一版本 · 2 小时前</span></div><button className="text-button" onClick={rollback}><RotateCcw size={13} /> 回滚</button></div></div><p className="release-note">回滚会将本地演示切换到上一份元数据快照,不会影响生产数据。</p></div></div>}
|
|
{toast && <div className="toast"><div className="toast-icon"><Check size={15} /></div><div><strong>{toast.title}</strong><span>{toast.message}</span></div></div>}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function RuntimePreview({ fields, columns, rows, setRows, onBack, onNotify }: { fields: Field[]; columns: Column[]; rows: typeof initialRows; setRows: React.Dispatch<React.SetStateAction<typeof initialRows>>; 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 <section className="runtime-shell"><div className="runtime-topline"><div><span className="eyebrow"><span className="eyebrow-line" /> 运行时预览 / 候选版本</span><h2>新建订舱</h2><p>当前以<b>操作团队</b>身份预览 Release 42 · 候选版本。</p></div><div className="runtime-actions"><button className="button subtle" onClick={onBack}><ArrowLeft size={15} /> 返回设计器</button><button className="button outline" onClick={() => onNotify('草稿已保存', '模拟保存成功,尚未触发业务动作。')}><Save size={15} /> 保存草稿</button><button className="button primary" onClick={() => onNotify('动作已执行', '确认订舱需要满足 status = draft。')}><Check size={15} /> 确认订舱</button></div></div><div className="runtime-card"><div className="runtime-card-head"><div><span className="panel-kicker">OCEAN.BOOKING</span><h3>订舱信息</h3></div><span className="draft-chip">草稿</span></div><div className="runtime-form-grid">{fields.slice(0, 6).map((field) => <label className="runtime-field" key={field.id}><span>{field.label}{field.required && <b>*</b>}</span>{renderRuntimeInput(field)}</label>)}</div><div className="runtime-divider" /><div className="runtime-card-head compact-head"><div><span className="panel-kicker">关系 / OCEAN.CONTAINER</span><h3>集装箱 <small>{rows.length} 行</small></h3></div><button className="button small outline" onClick={addRow}><Plus size={14} /> 新增集装箱</button></div><div className="runtime-table-wrap"><table className="runtime-table"><thead><tr>{columns.map((column) => <th key={column.id} style={{ width: column.width }}>{column.label}</th>)}<th aria-label="操作" /></tr></thead><tbody>{rows.map((row) => <tr key={row.id}>{columns.map((column) => <td key={column.id}><input value={row[column.key as keyof typeof row]} onChange={(event) => setRows((current) => current.map((item) => item.id === row.id ? { ...item, [column.key]: event.target.value } : item))} /></td>)}<td><IconButton label="更多行操作"><MoreHorizontal size={16} /></IconButton></td></tr>)}</tbody></table></div></div><div className="runtime-footnote"><ShieldCheck size={15} /><span>字段可见性和导出权限由当前用户的权限快照统一决定。</span><button onClick={() => onNotify('权限解释', '操作团队可以查看所有订舱字段,导出权限单独控制。')}>解释权限 <ArrowRight size={13} /></button></div></section>
|
|
}
|
|
|
|
function renderRuntimeInput(field: Field) {
|
|
if (field.type === 'reference') return <div className="input-wrap"><input className="runtime-input" value={field.id === 'carrier' ? '长荣海运' : field.id === 'customer' ? '北极星贸易' : '请选择'} readOnly /><ChevronDown size={15} /></div>
|
|
if (field.type === 'date') return <input className="runtime-input" value="2026-09-18" readOnly />
|
|
if (field.type === 'select') return <div className="input-wrap"><input className="runtime-input" value="FOB" readOnly /><ChevronDown size={15} /></div>
|
|
return <input className="runtime-input" value={field.id === 'booking-no' ? 'BK-2026-0918-0084' : ''} placeholder={field.label} readOnly={field.id === 'booking-no'} />
|
|
}
|
|
|
|
export default App
|