20260813173539

This commit is contained in:
oneao committed 2026-08-13 17:35:39 +08:00
1 parent 4bd730f649
commit ab30604fe4
13 files changed
+1267 -555

No files matched your search

@@ -344,7 +344,7 @@ async function editRow(rowKey, focusCol = null, options = {}) {
}
}
function getSelectedArea() {
function getSelectedArea() {
return tableRef.value?.getSelectedArea?.();
}
@@ -177,7 +177,7 @@ export default { name: "ModuleEditConfigPanel" };
<template>
<div class="flex h-full min-h-0 flex-col gap-4">
<div class="flex items-center justify-between gap-3">
<div class="flex min-w-0 flex-1 items-center rounded-md border border-input shadow-sm">
<div class="flex min-w-0 w-[360px] max-w-full items-center rounded-md border border-input shadow-sm">
<Select v-model="searchField">
<SelectTrigger class="w-[110px] shrink-0 border-0 shadow-none rounded-r-none focus:ring-0"
><SelectValue
@@ -154,7 +154,7 @@ export default { name: "ModuleFieldsPanel" };
<template>
<div class="flex h-full min-h-0 flex-col gap-4">
<div class="flex shrink-0 items-center justify-between gap-3">
<div class="flex min-w-0 flex-1 items-center rounded-md border border-input shadow-sm">
<div class="flex min-w-0 w-[360px] max-w-full items-center rounded-md border border-input shadow-sm">
<Select v-model="searchField">
<SelectTrigger class="w-[110px] shrink-0 border-0 shadow-none rounded-r-none focus:ring-0"
><SelectValue
@@ -284,7 +284,7 @@ export default { name: "ModuleI18nPanel" };
<template>
<div class="flex h-full min-h-0 flex-col gap-4">
<div class="flex shrink-0 items-center justify-between gap-3">
<div class="flex min-w-0 flex-1 items-center rounded-md border border-input shadow-sm">
<div class="flex min-w-0 w-[360px] max-w-full items-center rounded-md border border-input shadow-sm">
<Select v-model="searchField">
<SelectTrigger class="w-[110px] shrink-0 border-0 shadow-none rounded-r-none focus:ring-0"
><SelectValue
@@ -163,7 +163,7 @@ export default { name: "ModuleListConfigPanel" };
<template>
<div class="flex h-full min-h-0 flex-col gap-4">
<div class="flex shrink-0 items-center justify-between gap-3">
<div class="flex min-w-0 flex-1 items-center rounded-md border border-input shadow-sm">
<div class="flex min-w-0 w-[360px] max-w-full items-center rounded-md border border-input shadow-sm">
<Select v-model="searchField">
<SelectTrigger class="w-[110px] shrink-0 border-0 shadow-none rounded-r-none focus:ring-0"
><SelectValue
@@ -188,7 +188,7 @@ export default { name: "ModulePowersPanel" };
<template>
<div class="flex h-full min-h-0 flex-col gap-4">
<div class="flex shrink-0 items-center justify-between gap-3">
<div class="flex min-w-0 flex-1 items-center rounded-md border border-input shadow-sm">
<div class="flex min-w-0 w-[360px] max-w-full items-center rounded-md border border-input shadow-sm">
<Select v-model="searchField">
<SelectTrigger class="w-[110px] shrink-0 border-0 shadow-none rounded-r-none focus:ring-0"
><SelectValue
@@ -168,7 +168,7 @@ export default { name: "ModuleQueryConfigPanel" };
<template>
<div class="flex h-full min-h-0 flex-col gap-4">
<div class="flex shrink-0 items-center justify-between gap-3">
<div class="flex min-w-0 flex-1 items-center rounded-md border border-input shadow-sm">
<div class="flex min-w-0 w-[360px] max-w-full items-center rounded-md border border-input shadow-sm">
<Select v-model="searchField">
<SelectTrigger class="w-[110px] shrink-0 border-0 shadow-none rounded-r-none focus:ring-0"
><SelectValue
+312 -4
View File
@@ -1,8 +1,316 @@
<script setup lang="ts">
<script setup>
import { h, ref, shallowRef } from "vue";
import { message } from "antdv-next";
import { Input, InputNumber, Select } from "antdv-next";
import FmsSelect from "@/components/FmsSelect.vue";
import FmsTable from "@/components/fms-table/FmsTable.vue";
const COLUMN_COUNT = 20;
const ROW_COUNT = 4000;
const STATUS_OPTIONS = [
{ label: "启用", value: 1 },
{ label: "禁用", value: 0 },
];
function randInt(min, max) {
return Math.floor(Math.random() * (max - min + 1)) + min;
}
function randText(len) {
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
let s = "";
for (let i = 0; i < len; i++) s += chars[randInt(0, chars.length - 1)];
return s;
}
function generateValue(c, i) {
switch (c % 5) {
case 0:
return randText(randInt(4, 10));
case 1:
return randInt(0, 99999);
case 2:
return STATUS_OPTIONS[randInt(0, 1)].value;
case 3:
return `名称-${i}-${c}`;
default:
return `${randInt(1000, 9999)}-${randText(4)}`;
}
}
function buildData() {
const rows = [];
for (let i = 0; i < ROW_COUNT; i++) {
const row = { id: `R${i}` };
for (let c = 1; c <= COLUMN_COUNT; c++) row[`col${c}`] = generateValue(c, i);
rows.push(row);
}
return rows;
}
const dataSource = shallowRef(buildData());
const selectedLocale = ref("");
const selectedLocaleLabel = ref("");
const selectedLocaleRecord = shallowRef(null);
// 当前编辑行 key;进入编辑时留存快照,取消可还原
const editingKey = shallowRef(null);
const editSnapshot = shallowRef(null);
function editConfigFor(c) {
switch (c % 5) {
case 1:
return { type: "number" };
case 2:
return { type: "select", options: STATUS_OPTIONS };
default:
return { type: "text", maxlength: 64 };
}
}
// 单元格渲染:编辑行显示控件,其他行直接返回文本值(让 stk-table 用原生 textContent 渲染,零 VNode 开销)
function makeCellEditor(dataIndex, edit) {
const selectOptions = edit.options || [];
return (props) => {
const { row, cellValue } = props;
if (row.id !== editingKey.value) {
// 非编辑态:直接返回文本字符串,不创建 VNode 包装
if (edit.type === "select") {
const opt = selectOptions.find((o) => o.value === cellValue);
return opt ? opt.label : (cellValue ?? "");
}
return cellValue ?? "";
}
// 编辑态:返回编辑组件 VNode
const update = (v) => {
row[dataIndex] = v;
};
const common = {
size: "small",
style: { width: "100%" },
onClick: (e) => e.stopPropagation(),
};
if (edit.type === "number") {
return h(InputNumber, {
...common,
value: cellValue,
min: edit.min,
max: edit.max,
precision: edit.precision,
"onUpdate:value": update,
});
}
if (edit.type === "select") {
return h(Select, {
...common,
value: cellValue,
options: selectOptions,
"onUpdate:value": update,
});
}
return h(Input, {
...common,
value: cellValue,
maxlength: edit.maxlength,
"onUpdate:value": update,
});
};
}
function buildColumns() {
const cols = [
{
dataIndex: "id",
title: "编码",
width: 120,
fixed: "left",
customCell: makeCellEditor("id", { type: "text", maxlength: 32 }),
},
];
for (let c = 1; c <= COLUMN_COUNT; c++) {
const edit = editConfigFor(c);
cols.push({
dataIndex: `col${c}`,
title: `列 ${c}`,
width: 140,
sorter: edit.type === "number" ? true : undefined,
customCell: makeCellEditor(`col${c}`, edit),
});
}
return cols;
}
const columns = shallowRef(buildColumns());
function onRowClick(ev, row) {
if (row.id === editingKey.value) return;
editingKey.value = row.id;
editSnapshot.value = JSON.parse(JSON.stringify(row));
}
function saveEdit() {
if (editingKey.value == null) return;
editingKey.value = null;
editSnapshot.value = null;
message.success("已保存当前行修改");
}
function cancelEdit() {
if (editingKey.value != null && editSnapshot.value) {
const idx = dataSource.value.findIndex((r) => r.id === editingKey.value);
if (idx >= 0) dataSource.value[idx] = editSnapshot.value;
}
editingKey.value = null;
editSnapshot.value = null;
message.info("已取消编辑");
}
function regenerate() {
editingKey.value = null;
editSnapshot.value = null;
dataSource.value = buildData();
message.success("已重新生成随机数据");
}
function rowClassName(row) {
return row.id === editingKey.value ? "fms-row-editing" : "";
}
function onLocaleChange(record) {
selectedLocaleRecord.value = record;
}
</script>
<template>
<div class="flex flex-wrap items-center gap-2 md:flex-row">
123
</div>
<main class="table-demo">
<header class="table-demo__toolbar">
<div class="table-demo__title">
<strong>大数据表格 Demo</strong>
<span class="table-demo__meta">
共 {{ dataSource.length }} 行 · {{ columns.length }} 列 ·
<template v-if="editingKey">正在编辑:{{ editingKey }}</template>
<template v-else>点击任意行进入编辑</template>
</span>
</div>
<div class="table-demo__actions">
<a-button :disabled="editingKey == null" type="primary" @click="saveEdit">保存</a-button>
<a-button :disabled="editingKey == null" @click="cancelEdit">取消</a-button>
<a-button @click="regenerate">重新生成</a-button>
</div>
</header>
<section class="select-demo">
<div class="select-demo__field">
<label class="select-demo__label">FmsSelect 远程选择</label>
<FmsSelect
v-model="selectedLocale"
v-model:label="selectedLocaleLabel"
view-name="b_i18n_type"
label-key="b_name"
value-key="b_code"
order-by="b_code ASC"
:page-size="10"
@change="onLocaleChange"
/>
</div>
<div class="select-demo__value">
<span>当前值:{{ selectedLocale || "-" }}</span>
<span>显示文本:{{ selectedLocaleLabel || "-" }}</span>
<span>记录编码:{{ selectedLocaleRecord?.b_code || "-" }}</span>
</div>
</section>
<section class="table-demo__body">
<FmsTable
v-model:columns="columns"
:data-source="dataSource"
row-key="id"
:row-class-name="rowClassName"
show-overflow
@row-click="onRowClick"
/>
</section>
</main>
</template>
<style scoped lang="scss">
.table-demo {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
min-height: 0;
color: var(--fms-text);
}
.table-demo__toolbar {
display: flex;
flex: none;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 12px 16px;
border-bottom: 1px solid var(--fms-border);
}
.table-demo__title {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.table-demo__title strong {
font-size: 15px;
font-weight: 600;
}
.table-demo__meta {
color: var(--fms-text-secondary);
font-size: 12px;
}
.table-demo__actions {
display: flex;
flex: none;
gap: 8px;
}
.table-demo__body {
position: relative;
flex: 1;
min-height: 0;
padding: 12px 16px 16px;
}
.select-demo {
display: flex;
flex: none;
align-items: flex-end;
gap: 16px;
padding: 12px 16px 0;
}
.select-demo__field {
width: 320px;
}
.select-demo__label {
display: block;
margin-bottom: 6px;
color: var(--fms-text-secondary);
font-size: 12px;
}
.select-demo__value {
display: flex;
min-width: 0;
gap: 16px;
color: var(--fms-text-secondary);
font-size: 12px;
line-height: 32px;
}
:deep(.fms-row-editing) {
background: rgba(22, 119, 255, 0.08) !important;
}
</style>
+191 -124
View File
@@ -3,149 +3,216 @@ import type { ModuleField, FieldGroup, EditMode, FieldDataSource } from "./types
import { defaultFields, defaultGroups, makeField } from "./data/moduleData";
import ModuleSettings from "./components/ModuleSettings";
import FieldConfigList from "./components/FieldConfigList";
import FormPreview from "./components/FormPreview";
import TablePreview from "./components/TablePreview";
import DataSourceDrawer from "./components/DataSourceDrawer";
type PreviewMode = "form" | "table";
export default function App() {
const [fields, setFields] = useState<ModuleField[]>(defaultFields);
const [groups, setGroups] = useState<FieldGroup[]>(defaultGroups);
const [editMode, setEditMode] = useState<EditMode>("form");
const [previewMode, setPreviewMode] = useState<PreviewMode>("form");
const [activeId, setActiveId] = useState<number | null>(null);
const [dsDrawerFieldId, setDsDrawerFieldId] = useState<number | null>(null);
const [dsFieldId, setDsFieldId] = useState<number | null>(null);
const [savedTip, setSavedTip] = useState(false);
const nextGroupId = useRef(10);
const dsDrawerField = dsDrawerFieldId != null ? fields.find((f) => f.id === dsDrawerFieldId) ?? null : null;
// ======== Field mutations ========
const updateField = useCallback((id: number, key: string, value: string | number) => {
setFields((prev) => prev.map((f) => (f.id === id ? { ...f, [key]: value } : f)));
}, []);
const toggleField = useCallback((id: number, key: string) => {
setFields((prev) => prev.map((f) => (f.id === id ? { ...f, [key]: !(f as any)[key] } : f)));
}, []);
const onTypeChange = useCallback((id: number, newType: string) => {
setFields((prev) =>
prev.map((f) => {
if (f.id !== id) return f;
let ds = f.ds;
if (newType === "select" && !ds) {
ds = { type: "view", view: "", vf: "b_id", lf: "b_name" };
}
if (newType !== "select") ds = null;
return { ...f, type: newType, ds };
})
);
}, []);
const addField = useCallback(() => {
const firstGroup = groups[0]?.id ?? null;
const order = (fields.length + 1) * 10;
const f = makeField(firstGroup, "input", "新字段");
f.order = order;
setFields((prev) => [...prev, f]);
setActiveId(f.id);
}, [fields.length, groups]);
const deleteField = useCallback(
(id: number) => {
const f = fields.find((f) => f.id === id);
if (!f || !window.confirm(`删除「${f.label}」(${f.code})?`)) return;
setFields((prev) => prev.filter((f) => f.id !== id));
if (activeId === id) setActiveId(null);
},
[fields, activeId]
);
// ======== Drag reorder ========
const reorderFields = useCallback((activeId: number, overId: number) => {
setFields((prev) => {
const src = prev.find((f) => f.id === activeId);
const tgt = prev.find((f) => f.id === overId);
if (!src || !tgt) return prev;
const tmp = src.order;
return prev.map((f) => {
if (f.id === activeId) return { ...f, order: tgt.order };
if (f.id === overId) return { ...f, order: tmp };
return f;
});
});
}, []);
// ======== Data source ========
const updateDs = useCallback((ds: FieldDataSource | null) => {
if (dsDrawerFieldId == null) return;
setFields((prev) => prev.map((f) => (f.id === dsDrawerFieldId ? { ...f, ds } : f)));
}, [dsDrawerFieldId]);
// ======== Group ========
const addGroup = useCallback(() => {
const name = window.prompt("分组名称:");
if (!name) return;
const id = `g${nextGroupId.current++}`;
setGroups((prev) => [...prev, { id, name }]);
}, []);
const renameGroup = useCallback((id: string) => {
const g = groups.find((g) => g.id === id);
if (!g) return;
const name = window.prompt("新名称:", g.name);
if (name) {
setGroups((prev) => prev.map((g) => (g.id === id ? { ...g, name } : g)));
}
}, [groups]);
const activateField = useCallback(
(id: number) => {
setActiveId((prev) => (prev === id ? null : id));
const updateField = useCallback(
(id: number, key: keyof ModuleField, value: ModuleField[keyof ModuleField]) => {
setFields((prev) =>
prev.map((f) => (f.id === id ? ({ ...f, [key]: value } as ModuleField) : f))
);
},
[]
);
const updateFieldList = useCallback(
(id: number, patch: Partial<ModuleField>) => {
setFields((prev) => prev.map((f) => (f.id === id ? { ...f, ...patch } : f)));
},
[]
);
const addField = useCallback(
(groupId: string) => {
const id = Date.now();
setFields((prev) => [...prev, makeField(groupId)]);
setActiveId(id);
},
[]
);
const deleteField = useCallback((id: number) => {
setFields((prev) => prev.filter((f) => f.id !== id));
setActiveId((cur) => (cur === id ? null : cur));
}, []);
// 配置列表:上/下移
const reorderFields = useCallback((id: number, dir: -1 | 1) => {
setFields((prev) => {
const sorted = [...prev].sort((a, b) => a.order - b.order);
const i = sorted.findIndex((f) => f.id === id);
const j = i + dir;
if (j < 0 || j >= sorted.length) return prev;
const t = sorted[i].order;
sorted[i].order = sorted[j].order;
sorted[j].order = t;
return [...sorted];
});
}, []);
// 预览区:拖拽排序(按 id 重排并顺续编号)
const reorderByDrag = useCallback((activeId: number, overId: number) => {
setFields((prev) => {
const sorted = [...prev].sort((a, b) => a.order - b.order);
const from = sorted.findIndex((f) => f.id === activeId);
const to = sorted.findIndex((f) => f.id === overId);
if (from < 0 || to < 0) return prev;
const [moved] = sorted.splice(from, 1);
sorted.splice(to, 0, moved);
return sorted.map((f, i) => ({ ...f, order: (i + 1) * 10 }));
});
}, []);
const resizeField = useCallback(
(id: number, width: number) => updateField(id, "listW", width),
[updateField]
);
const renameGroup = useCallback((id: string, name: string) => {
setGroups((prev) => prev.map((g) => (g.id === id ? { ...g, name } : g)));
}, []);
const addGroup = useCallback((name: string) => {
const id = `g${nextGroupId.current++}`;
setGroups((prev) => [...prev, { id, name }]);
return id;
}, []);
const openDataSource = useCallback((fieldId: number) => setDsFieldId(fieldId), []);
const dsField =
dsFieldId != null ? fields.find((f) => f.id === dsFieldId) ?? null : null;
const readOnly = editMode === "none";
return (
<div className="min-h-screen flex flex-col">
<div className="flex-1 flex flex-col max-w-[1280px] mx-auto w-full p-5 gap-4">
{/* Module Settings */}
<ModuleSettings editMode={editMode} onEditModeChange={setEditMode} />
{/* Grouped field table (single column) */}
<div className="card flex flex-col min-h-0 flex-1">
<div className="flex items-center justify-between px-4 py-2.5 border-b border-slate-100 shrink-0">
<span className="text-xs font-semibold text-slate-500 uppercase tracking-wide">
字段配置 <span className="text-slate-300 font-normal ml-1">{fields.length}</span>
</span>
<div className="flex items-center gap-1.5">
<button className="btn btn-primary text-[10px] py-1 px-2.5" onClick={addField}>
+ 新增
</button>
<button className="btn btn-ghost text-[10px] py-1 px-2" onClick={addGroup}>
+ 分组
</button>
</div>
<div className="h-screen flex flex-col overflow-hidden">
{/* 顶栏 */}
<header className="shrink-0 flex items-center justify-between px-5 h-14 bg-white border-b border-slate-200 shadow-sm z-10">
<div className="flex items-center gap-3">
<span className="text-lg">🚢</span>
<div className="leading-tight">
<div className="font-semibold text-slate-800">海运主单 · 模块设计器</div>
<div className="text-xs text-slate-400">字段配置所见即所得</div>
</div>
<FieldConfigList
fields={fields}
groups={groups}
activeId={activeId}
onActivate={activateField}
onToggle={toggleField}
onUpdate={updateField}
onTypeChange={onTypeChange}
onReorder={reorderFields}
onGroupRename={renameGroup}
onDelete={deleteField}
onOpenDs={setDsDrawerFieldId}
/>
</div>
</div>
<div className="flex items-center gap-3">
<div className="flex items-center rounded-lg bg-slate-100 p-0.5 text-sm">
<button
className={`px-3 py-1 rounded-md transition ${
previewMode === "form" ? "bg-white shadow text-slate-800" : "text-slate-500"
}`}
onClick={() => setPreviewMode("form")}
>
表单预览
</button>
<button
className={`px-3 py-1 rounded-md transition ${
previewMode === "table" ? "bg-white shadow text-slate-800" : "text-slate-500"
}`}
onClick={() => setPreviewMode("table")}
>
表格预览
</button>
</div>
<button
className="px-4 py-1.5 rounded-lg bg-blue-600 text-white text-sm font-medium hover:bg-blue-700 transition"
onClick={() => {
setSavedTip(true);
setTimeout(() => setSavedTip(false), 1600);
}}
>
保存
</button>
</div>
</header>
<main className="flex-1 min-h-0 grid grid-cols-1 lg:grid-cols-[5fr_7fr] gap-4 p-4">
{/* 左栏:实时预览 */}
<section className="min-h-0 flex flex-col bg-white rounded-xl border border-slate-200 shadow-sm overflow-hidden">
<div className="shrink-0 px-4 py-2.5 border-b border-slate-100 flex items-center justify-between">
<span className="text-sm font-medium text-slate-700">
{previewMode === "form" ? "表单预览" : "表格预览"}
{readOnly && (
<span className="ml-2 text-xs px-1.5 py-0.5 rounded bg-slate-100 text-slate-500">
只读
</span>
)}
</span>
<span className="text-xs text-slate-400">点击字段可在右侧配置</span>
</div>
<div className="flex-1 min-h-0 overflow-auto p-4 bg-slate-50">
{previewMode === "form" ? (
<FormPreview
fields={fields}
groups={groups}
activeId={activeId}
onActivate={setActiveId}
onReorder={reorderByDrag}
onGroupRename={() => {}}
readOnly={readOnly}
/>
) : (
<TablePreview
fields={fields}
activeId={activeId}
onActivate={setActiveId}
onReorder={reorderByDrag}
onResize={resizeField}
readOnly={readOnly}
/>
)}
</div>
</section>
{/* 右栏:模块设置 + 字段配置 */}
<section className="min-h-0 flex flex-col gap-4 overflow-auto pr-0.5">
<ModuleSettings editMode={editMode} onEditModeChange={setEditMode} />
<div className="card">
<FieldConfigList
fields={fields}
groups={groups}
activeId={activeId}
onActivate={setActiveId}
onAddField={addField}
onDeleteField={deleteField}
onUpdateFieldList={updateFieldList}
onReorder={reorderFields}
onOpenDataSource={openDataSource}
onRenameGroup={renameGroup}
onAddGroup={addGroup}
/>
</div>
</section>
</main>
{/* Data Source Drawer */}
<DataSourceDrawer
field={dsDrawerField}
open={dsDrawerFieldId != null}
onClose={() => setDsDrawerFieldId(null)}
onUpdate={updateDs}
field={dsField}
open={dsFieldId != null}
onClose={() => setDsFieldId(null)}
onUpdate={(ds: FieldDataSource | null) => {
if (dsFieldId != null) updateField(dsFieldId, "ds", ds);
}}
/>
{savedTip && (
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 px-4 py-2 rounded-lg bg-slate-800 text-white text-sm shadow-lg z-50">
已保存 ✔
</div>
)}
</div>
);
}
+469 -369
View File
@@ -1,388 +1,488 @@
import { useState, Fragment } from "react";
import type { MouseEvent, ReactNode } from "react";
import { DndContext, closestCenter, PointerSensor, useSensor, useSensors } from "@dnd-kit/core";
import type { DragEndEvent, Modifier } from "@dnd-kit/core";
import { SortableContext, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { useState } from "react";
import type { ModuleField, FieldGroup } from "../types";
import { TYPE_DEFS } from "../types";
export interface FieldListProps {
const TYPE_OPTIONS = [
"input",
"textarea",
"number",
"select",
"date",
"datetime",
"switch",
];
function Toggle({
on,
title,
}: {
on: boolean;
label?: string;
title: string;
onClick?: () => void;
}) {
return <div className={`toggle ${on ? "on" : ""}`} title={title} />;
}
function DetailRow({
field,
onUpdate,
onOpenDataSource,
}: {
field: ModuleField;
onUpdate: (patch: Partial<ModuleField>) => void;
onOpenDataSource: () => void;
}) {
return (
<tr className="detail-row">
<td colSpan={11}>
<div className="detail-grid">
<label>
查询方式
<select
value={field.queryOp}
onChange={(e) => onUpdate({ queryOp: e.target.value })}
>
<option value="=">=</option>
<option value="like">like</option>
<option value=">=">&gt;=</option>
<option value="between">between</option>
<option value="none">none</option>
</select>
</label>
<label>
查询默认值
<input
value={field.queryDef}
onChange={(e) => onUpdate({ queryDef: e.target.value })}
placeholder="无"
/>
</label>
<label>
列宽(px)
<input
type="number"
value={field.listW}
onChange={(e) => onUpdate({ listW: Number(e.target.value) })}
/>
</label>
<label>
对齐
<select
value={field.listAlign}
onChange={(e) =>
onUpdate({ listAlign: e.target.value as ModuleField["listAlign"] })
}
>
<option value="left">左</option>
<option value="center">中</option>
<option value="right">右</option>
</select>
</label>
<label>
数据源
<button className="ds-btn" onClick={onOpenDataSource}>
{field.ds?.type ? `已配置 · ${field.ds.type}` : "配置…"}
</button>
</label>
</div>
</td>
</tr>
);
}
function SortableFieldRow({
field,
active,
confirmDelete,
groupName,
onActivate,
onUpdateFieldList,
onReorder,
onOpenDataSource,
onStartDelete,
onCancelDelete,
onConfirmDelete,
}: {
field: ModuleField;
active: boolean;
confirmDelete: boolean;
groupName: string;
onActivate: () => void;
onUpdateFieldList: (patch: Partial<ModuleField>) => void;
onReorder: (dir: -1 | 1) => void;
onOpenDataSource: () => void;
onStartDelete: () => void;
onCancelDelete: () => void;
onConfirmDelete: () => void;
}) {
return (
<>
<tr className={active ? "active" : ""} onClick={onActivate}>
<td className="text-center">
<div className="move-btns" onClick={(e) => e.stopPropagation()}>
<button onClick={() => onReorder(-1)} title="上移">
↑
</button>
<button onClick={() => onReorder(1)} title="下移">
↓
</button>
</div>
</td>
<td onDoubleClick={(e) => e.stopPropagation()}>
<input
className="cell-input"
value={field.label}
onClick={(e) => e.stopPropagation()}
onChange={(e) => onUpdateFieldList({ label: e.target.value })}
/>
</td>
<td onDoubleClick={(e) => e.stopPropagation()}>
<input
className="cell-input code"
value={field.code}
onClick={(e) => e.stopPropagation()}
onChange={(e) => onUpdateFieldList({ code: e.target.value })}
/>
</td>
<td>
<select
value={field.type}
onClick={(e) => e.stopPropagation()}
onChange={(e) => onUpdateFieldList({ type: e.target.value })}
>
{TYPE_OPTIONS.map((t) => (
<option key={t} value={t}>
{t}
</option>
))}
</select>
</td>
<td>
<span className="cell-group" onClick={(e) => e.stopPropagation()}>
{groupName}
</span>
</td>
<td className="text-center p-0" title="列表显示">
<div
className="flex justify-center"
onClick={(e) => {
e.stopPropagation();
onUpdateFieldList({ listOn: !field.listOn });
}}
>
<Toggle on={field.listOn} label="L" title="列表显示" onClick={() => {}} />
</div>
</td>
<td className="text-center p-0" title="可编辑">
<div
className="flex justify-center"
onClick={(e) => {
e.stopPropagation();
onUpdateFieldList({ editOn: !field.editOn });
}}
>
<Toggle on={field.editOn} label="E" title="可编辑" onClick={() => {}} />
</div>
</td>
<td className="text-center p-0" title="可查询">
<div
className="flex justify-center"
onClick={(e) => {
e.stopPropagation();
onUpdateFieldList({ queryOn: !field.queryOn });
}}
>
<Toggle on={field.queryOn} label="Q" title="可查询" onClick={() => {}} />
</div>
</td>
<td className="text-center p-0" title="必填">
<div
className="flex justify-center"
onClick={(e) => {
e.stopPropagation();
onUpdateFieldList({ editReq: !field.editReq });
}}
>
<Toggle on={field.editReq} label="必" title="必填" onClick={() => {}} />
</div>
</td>
<td className="text-center p-0" title="只读">
<div
className="flex justify-center"
onClick={(e) => {
e.stopPropagation();
onUpdateFieldList({ editRO: !field.editRO });
}}
>
<Toggle on={field.editRO} label="读" title="只读" onClick={() => {}} />
</div>
</td>
<td className="text-center" onClick={(e) => e.stopPropagation()}>
{confirmDelete ? (
<span className="inline-flex items-center gap-1">
<button className="link-danger" onClick={onConfirmDelete}>
确认
</button>
<button className="link-muted" onClick={onCancelDelete}>
取消
</button>
</span>
) : (
<button className="icon-btn" title="删除" onClick={onStartDelete}>
✕
</button>
)}
</td>
</tr>
{active && (
<DetailRow
field={field}
onUpdate={onUpdateFieldList}
onOpenDataSource={onOpenDataSource}
/>
)}
</>
);
}
function GroupSection({
group,
fields,
groupNameMap,
activeId,
editing,
pendingAdd,
confirmDeleteId,
onActivate,
onAddField,
onUpdateFieldList,
onReorder,
onOpenDataSource,
onStartRename,
onCommitRename,
onCancelRename,
onStartDelete,
onCancelDelete,
onConfirmDelete,
}: {
group: FieldGroup;
fields: ModuleField[];
groupNameMap: Map<string, string>;
activeId: number | null;
editing: boolean;
pendingAdd: boolean;
confirmDeleteId: number | null;
onActivate: (id: number) => void;
onAddField: () => void;
onUpdateFieldList: (id: number, patch: Partial<ModuleField>) => void;
onReorder: (id: number, dir: -1 | 1) => void;
onOpenDataSource: (id: number) => void;
onStartRename: () => void;
onCommitRename: (name: string) => void;
onCancelRename: () => void;
onStartDelete: (id: number) => void;
onCancelDelete: () => void;
onConfirmDelete: (id: number) => void;
}) {
return (
<>
<tr className="group-row">
<td colSpan={11}>
{editing ? (
<input
className="group-input"
autoFocus
defaultValue={group.name}
onBlur={(e) => onCommitRename(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter")
onCommitRename((e.target as HTMLInputElement).value);
if (e.key === "Escape") onCancelRename();
}}
/>
) : (
<span className="group-name">
{group.name}
<button className="group-edit" title="改名" onClick={onStartRename}>
✎
</button>
</span>
)}
<span className="group-count">{fields.length}</span>
</td>
</tr>
{fields.map((f) => (
<SortableFieldRow
key={f.id}
field={f}
active={activeId === f.id}
confirmDelete={confirmDeleteId === f.id}
groupName={groupNameMap.get(f.groupId ?? "") ?? "—"}
onActivate={() => onActivate(f.id)}
onUpdateFieldList={(patch) => onUpdateFieldList(f.id, patch)}
onReorder={(dir) => onReorder(f.id, dir)}
onOpenDataSource={() => onOpenDataSource(f.id)}
onStartDelete={() => onStartDelete(f.id)}
onCancelDelete={onCancelDelete}
onConfirmDelete={() => onConfirmDelete(f.id)}
/>
))}
{pendingAdd && (
<tr>
<td colSpan={11}>
<button className="add-row" onClick={onAddField}>
+ 在此分组新增字段
</button>
</td>
</tr>
)}
</>
);
}
type Props = {
fields: ModuleField[];
groups: FieldGroup[];
activeId: number | null;
onActivate: (id: number) => void;
onToggle: (id: number, key: string) => void;
onUpdate: (id: number, key: string, value: any) => void;
onTypeChange: (id: number, type: string) => void;
onReorder: (activeId: number, overId: number) => void;
onGroupRename: (id: string) => void;
onDelete: (id: number) => void;
onOpenDs: (id: number) => void;
}
onAddField: (groupId: string) => void;
onDeleteField: (id: number) => void;
onUpdateFieldList: (id: number, patch: Partial<ModuleField>) => void;
onReorder: (id: number, dir: -1 | 1) => void;
onOpenDataSource: (id: number) => void;
onRenameGroup: (id: string, name: string) => void;
onAddGroup: (name: string) => string;
};
const restrictToVerticalAxis: Modifier = ({ transform }) => ({ ...transform, x: 0 });
type EditKey = "label" | "code";
/* ---- 字段行:单行表格 + 内联编辑/开关,可拖拽 ---- */
function SortableFieldRow({
field,
active,
onActivate,
onToggle,
onUpdate,
onTypeChange,
onDelete,
export default function FieldConfigList({
fields,
groups,
editing,
onEdit,
onStopEdit,
}: {
field: ModuleField;
active: boolean;
onActivate: () => void;
onToggle: (id: number, key: string) => void;
onUpdate: (id: number, key: string, value: any) => void;
onTypeChange: (id: number, type: string) => void;
onDelete: (id: number) => void;
editing: EditKey | null;
onEdit: (key: EditKey) => void;
onStopEdit: () => void;
groups: FieldGroup[];
}) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: field.id });
const style: any = {
transform: CSS.Transform.toString(transform),
transition,
zIndex: isDragging ? 10 : undefined,
opacity: isDragging ? 0.9 : 1,
background: isDragging ? "#fff" : undefined,
boxShadow: isDragging ? "0 4px 12px rgba(0,0,0,0.1)" : undefined,
};
const typeDef = TYPE_DEFS.find((t) => t.v === field.type);
activeId,
onActivate,
onAddField,
onDeleteField,
onUpdateFieldList,
onReorder,
onOpenDataSource,
onRenameGroup,
onAddGroup,
}: Props) {
const [editingGroupId, setEditingGroupId] = useState<string | null>(null);
const [addingGroup, setAddingGroup] = useState(false);
const [newGroupName, setNewGroupName] = useState("");
const [pendingAddGroup, setPendingAddGroup] = useState<string | null>(null);
const [confirmDeleteId, setConfirmDeleteId] = useState<number | null>(null);
const stop = (e: MouseEvent) => e.stopPropagation();
const toggleCell = (key: string, color: string, title: string) => (
<td className="text-center" onClick={stop} title={title}>
<div className="flex items-center justify-center gap-1 cursor-pointer" onClick={() => onToggle(field.id, key)}>
<div className={`toggle ${field[key as keyof ModuleField] ? "on" : ""}`} />
<span className={`text-[9px] select-none ${field[key as keyof ModuleField] ? color : "text-slate-300"}`}>{title[0]}</span>
</div>
</td>
);
return (
<tr
ref={setNodeRef}
style={style}
className={`field-row border-b border-slate-100 hover:bg-slate-50/70 ${active ? "active" : ""}`}
onClick={onActivate}
>
<td className="w-8 text-center" onClick={stop}>
<span className="drag-handle inline-flex" {...attributes} {...listeners}>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 8h16M4 16h16" />
</svg>
</span>
</td>
<td className="min-w-[150px]" onClick={editing === "label" ? stop : undefined} onDoubleClick={() => onEdit("label")}>
{editing === "label" ? (
<input
autoFocus
className="input-sm !py-1"
value={field.label}
onChange={(e) => onUpdate(field.id, "label", e.target.value)}
onBlur={onStopEdit}
onKeyDown={(e) => e.key === "Enter" && onStopEdit()}
/>
) : (
<span className="flex items-center gap-2">
<span className="w-4 h-4 rounded flex items-center justify-center text-[9px] text-white flex-shrink-0" style={{ background: typeDef?.c ?? "#94a3b8" }}>
{typeDef?.icon ?? "T"}
</span>
<span className="text-xs font-medium text-slate-700 truncate">{field.label}</span>
</span>
)}
</td>
<td className="min-w-[120px] text-slate-400" onClick={editing === "code" ? stop : undefined} onDoubleClick={() => onEdit("code")}>
{editing === "code" ? (
<input
autoFocus
className="input-sm !py-1 font-mono"
value={field.code}
onChange={(e) => onUpdate(field.id, "code", e.target.value)}
onBlur={onStopEdit}
onKeyDown={(e) => e.key === "Enter" && onStopEdit()}
/>
) : (
<code className="text-[10px]">{field.code}</code>
)}
</td>
<td className="min-w-[100px]" onClick={stop}>
<select className="select-sm !py-1" value={field.type} onChange={(e) => onTypeChange(field.id, e.target.value)}>
{TYPE_DEFS.map((t) => (
<option key={t.v} value={t.v}>
{t.lbl}
</option>
))}
</select>
</td>
<td className="min-w-[120px]" onClick={stop}>
<select className="select-sm !py-1" value={field.groupId ?? ""} onChange={(e) => onUpdate(field.id, "groupId", e.target.value || null)}>
<option value="">未分组</option>
{groups.map((g) => (
<option key={g.id} value={g.id}>
{g.name}
</option>
))}
</select>
</td>
{toggleCell("listOn", "text-green-600", "列表")}
{toggleCell("editOn", "text-blue-600", "编辑")}
{toggleCell("queryOn", "text-amber-600", "查询")}
{toggleCell("editReq", "text-red-500", "必填")}
{toggleCell("editRO", "text-slate-400", "只读")}
<td className="w-12 text-center" onClick={stop}>
<button className="text-slate-300 hover:text-red-500 transition-colors text-sm leading-none" onClick={() => onDelete(field.id)} title="删除">
×
</button>
</td>
</tr>
);
}
/* ---- 选中字段时就地展开的高级属性行 ---- */
function DetailRow({
field,
onUpdate,
onOpenDs,
}: {
field: ModuleField;
onUpdate: (id: number, key: string, value: any) => void;
onOpenDs: (id: number) => void;
}) {
return (
<tr className="bg-blue-50/40">
<td colSpan={11} className="px-4 py-3 border-b border-slate-100">
<div className="flex flex-wrap items-center gap-x-6 gap-y-3">
<div className="flex items-center gap-2">
<span className="text-[11px] text-slate-500">列宽</span>
<input
type="range"
min={40}
max={400}
value={field.listW}
className="w-32 accent-blue-600 h-1"
onChange={(e) => onUpdate(field.id, "listW", parseInt(e.target.value))}
/>
<span className="text-[11px] text-slate-400 w-10">{field.listW}px</span>
</div>
<div className="flex items-center gap-2">
<span className="text-[11px] text-slate-500">查询默认值</span>
<input className="input-sm !w-36" value={field.queryDef} onChange={(e) => onUpdate(field.id, "queryDef", e.target.value)} />
</div>
{field.queryOn && (
<div className="flex items-center gap-2">
<span className="text-[11px] text-slate-500">查询方式</span>
<select className="select-sm !w-20" value={field.queryOp} onChange={(e) => onUpdate(field.id, "queryOp", e.target.value)}>
<option value="=">=</option>
<option value="like">包含</option>
<option value="in">区间</option>
<option value=">">&gt;</option>
<option value="<">&lt;</option>
</select>
</div>
)}
{field.type === "select" && (
<button className="btn btn-outline text-[11px]" onClick={() => onOpenDs(field.id)}>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 6h16M4 12h16M4 18h16" />
</svg>
数据源
</button>
)}
</div>
</td>
</tr>
);
}
export default function FieldConfigList(props: FieldListProps) {
const { fields, groups, activeId, onActivate, onToggle, onUpdate, onTypeChange, onReorder, onGroupRename, onDelete, onOpenDs } = props;
const [collapsed, setCollapsed] = useState<Record<string, boolean>>({});
const [editing, setEditing] = useState<{ id: number; key: EditKey } | null>(null);
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 5 } }));
const sorted = [...fields].sort((a, b) => a.order - b.order);
const groupNameMap = new Map(groups.map((g) => [g.id, g.name]));
function handleDragEnd(event: DragEndEvent) {
const { active, over } = event;
if (over && active.id !== over.id) {
onReorder(Number(active.id), Number(over.id));
}
}
const commitRename = (id: string, name: string) => {
const v = name.trim();
if (v) onRenameGroup(id, v);
setEditingGroupId(null);
};
const ungrouped = sorted.filter((f) => !groups.some((g) => g.id === f.groupId));
const allIds = [...groups.flatMap((g) => sorted.filter((f) => f.groupId === g.id).map((f) => f.id)), ...ungrouped.map((f) => f.id)];
const commitAddGroup = () => {
const v = newGroupName.trim() || "新分组";
const id = onAddGroup(v);
setAddingGroup(false);
setNewGroupName("");
setPendingAddGroup(id);
};
return (
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd} modifiers={[restrictToVerticalAxis]}>
<SortableContext items={allIds} strategy={verticalListSortingStrategy}>
<div className="flex-1 overflow-auto thin-scroll">
<table className="w-full min-w-[760px] border-collapse text-left">
<thead className="sticky top-0 z-10 bg-white/95 backdrop-blur">
<tr className="text-[10px] font-semibold uppercase tracking-wide text-slate-400 border-b border-slate-200">
<th className="w-8 px-1 py-2 text-center"></th>
<th className="px-2 py-2">名称</th>
<th className="px-2 py-2">编码</th>
<th className="px-2 py-2">类型</th>
<th className="px-2 py-2">分组</th>
<th className="px-1 py-2 text-center">列表</th>
<th className="px-1 py-2 text-center">编辑</th>
<th className="px-1 py-2 text-center">查询</th>
<th className="px-1 py-2 text-center">必填</th>
<th className="px-1 py-2 text-center">只读</th>
<th className="w-12 px-1 py-2 text-center">操作</th>
</tr>
</thead>
<tbody>
{groups.map((g) => {
const gfs = sorted.filter((f) => f.groupId === g.id);
const isCollapsed = collapsed[g.id];
const hasActive = gfs.some((f) => f.id === activeId);
return (
<GroupSection
key={g.id}
name={g.name}
count={gfs.length}
collapsed={isCollapsed}
active={hasActive}
onToggle={() => setCollapsed((c) => ({ ...c, [g.id]: !c[g.id] }))}
onRename={() => onGroupRename(g.id)}
>
{!isCollapsed &&
( gfs.length ? (
gfs.map((f) => (
<Fragment key={f.id}>
<SortableFieldRow
field={f}
active={f.id === activeId}
onActivate={() => onActivate(f.id)}
onToggle={onToggle}
onUpdate={onUpdate}
onTypeChange={onTypeChange}
onDelete={onDelete}
groups={groups}
editing={editing?.id === f.id ? editing.key : null}
onEdit={(key) => setEditing({ id: f.id, key })}
onStopEdit={() => setEditing(null)}
/>
{f.id === activeId && <DetailRow field={f} onUpdate={onUpdate} onOpenDs={onOpenDs} />}
</Fragment>
))
) : (
<tr>
<td colSpan={11} className="px-3 py-2 text-[10px] text-slate-300">
该分组暂无字段
</td>
</tr>
))}
</GroupSection>
);
})}
{ungrouped.length > 0 && (
<GroupSection name="未分组" count={ungrouped.length} collapsed={false} active={false} onToggle={() => {}} onRename={() => {}}>
{ungrouped.map((f) => (
<Fragment key={f.id}>
<SortableFieldRow
field={f}
active={f.id === activeId}
onActivate={() => onActivate(f.id)}
onToggle={onToggle}
onUpdate={onUpdate}
onTypeChange={onTypeChange}
onDelete={onDelete}
groups={groups}
editing={editing?.id === f.id ? editing.key : null}
onEdit={(key) => setEditing({ id: f.id, key })}
onStopEdit={() => setEditing(null)}
/>
{f.id === activeId && <DetailRow field={f} onUpdate={onUpdate} onOpenDs={onOpenDs} />}
</Fragment>
))}
</GroupSection>
)}
{sorted.length === 0 && (
<tr>
<td colSpan={11} className="text-center text-xs text-slate-300 py-16">
暂无字段
</td>
</tr>
)}
</tbody>
</table>
<div className="field-config">
<div className="fc-head">
<h3>
字段配置 <span className="muted">({fields.length})</span>
</h3>
<div className="fc-actions">
<button className="btn-ghost" onClick={() => setAddingGroup(true)}>
+ 分组
</button>
</div>
</SortableContext>
</DndContext>
);
}
</div>
/* ---- 分组表头行 ---- */
function GroupSection({
name,
count,
collapsed,
active,
onToggle,
onRename,
children,
}: {
name: string;
count: number;
collapsed: boolean;
active: boolean;
onToggle: () => void;
onRename: () => void;
children: ReactNode;
}) {
const isUngrouped = name === "未分组";
return (
<>
<tr
className={`group-block ${active ? "highlight-group" : ""} ${isUngrouped ? "bg-amber-50/40" : "bg-slate-50/70"} cursor-pointer select-none`}
onClick={onToggle}
>
<td colSpan={11} className="px-3 py-1.5">
<div className="flex items-center gap-2">
<svg className={`w-3.5 h-3.5 text-slate-400 transition-transform ${collapsed ? "-rotate-90" : ""}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 9l6 6 6-6" />
</svg>
<span className="text-[12px] font-semibold text-slate-600">{name}</span>
<span className="text-[10px] text-slate-300">{count}字段</span>
{!isUngrouped && (
<button
className="ml-auto text-[10px] text-slate-300 hover:text-slate-500 transition-colors"
onClick={(e) => {
e.stopPropagation();
onRename();
}}
>
改名
</button>
<div className="table-scroll">
<table className="field-table">
<thead>
<tr>
<th style={{ width: 48 }} />
<th>名称</th>
<th>编码</th>
<th>类型</th>
<th>分组</th>
<th title="列表">L</th>
<th title="编辑">E</th>
<th title="查询">Q</th>
<th title="必填">必</th>
<th title="只读">读</th>
<th style={{ width: 72 }}>操作</th>
</tr>
</thead>
<tbody>
{groups.map((g) => {
const gf = sorted.filter((f) => f.groupId === g.id);
return (
<GroupSection
key={g.id}
group={g}
fields={gf}
groupNameMap={groupNameMap}
activeId={activeId}
editing={editingGroupId === g.id}
pendingAdd={pendingAddGroup === g.id}
confirmDeleteId={confirmDeleteId}
onActivate={onActivate}
onAddField={() => {
onAddField(g.id);
setPendingAddGroup(null);
}}
onUpdateFieldList={onUpdateFieldList}
onReorder={onReorder}
onOpenDataSource={onOpenDataSource}
onStartRename={() => setEditingGroupId(g.id)}
onCommitRename={(name) => commitRename(g.id, name)}
onCancelRename={() => setEditingGroupId(null)}
onStartDelete={(id) => setConfirmDeleteId(id)}
onCancelDelete={() => setConfirmDeleteId(null)}
onConfirmDelete={(id) => {
onDeleteField(id);
setConfirmDeleteId(null);
}}
/>
);
})}
{addingGroup && (
<tr className="group-row">
<td colSpan={11}>
<span className="group-name">
<input
className="group-input"
autoFocus
value={newGroupName}
placeholder="新分组名称"
onChange={(e) => setNewGroupName(e.target.value)}
onBlur={commitAddGroup}
onKeyDown={(e) => {
if (e.key === "Enter") commitAddGroup();
if (e.key === "Escape") {
setAddingGroup(false);
setNewGroupName("");
}
}}
/>
</span>
<span className="group-edit" onClick={commitAddGroup}>
✓
</span>
</td>
</tr>
)}
</div>
</td>
</tr>
{children}
</>
</tbody>
</table>
</div>
</div>
);
}
+37 -26
View File
@@ -14,16 +14,18 @@ interface Props {
onActivate: (id: number) => void;
onReorder: (activeId: number, overId: number) => void;
onGroupRename: (id: string) => void;
readOnly?: boolean;
}
function PreviewWidget({ field, active }: { field: ModuleField; active: boolean }) {
function PreviewWidget({ field, active, readOnly }: { field: ModuleField; active: boolean; readOnly?: boolean }) {
const ring = active ? "ring-2 ring-blue-300" : "";
const ro = readOnly ? " opacity-60" : "";
switch (field.type) {
case "select":
return (
<div className="flex-1 max-w-[260px]">
<div className={`input-sm bg-white text-slate-400 flex items-center justify-between ${ring}`}>
<div className={`input-sm bg-white text-slate-400 flex items-center justify-between ${ring} ${ro}`}>
<span>请选择...</span>
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 9l-7 7-7-7" />
@@ -32,18 +34,18 @@ function PreviewWidget({ field, active }: { field: ModuleField; active: boolean
</div>
);
case "date":
return <div className={`input-sm bg-white text-slate-300 flex-1 max-w-[260px] ${ring}`}>2024-08-12</div>;
return <div className={`input-sm bg-white text-slate-300 flex-1 max-w-[260px] ${ring} ${ro}`}>2024-08-12</div>;
case "datetime":
return <div className={`input-sm bg-white text-slate-300 flex-1 max-w-[260px] ${ring}`}>2024-08-12 14:30</div>;
return <div className={`input-sm bg-white text-slate-300 flex-1 max-w-[260px] ${ring} ${ro}`}>2024-08-12 14:30</div>;
case "number":
return <div className={`input-sm bg-white text-slate-300 flex-1 max-w-[260px] text-right ${ring}`}>0.00</div>;
return <div className={`input-sm bg-white text-slate-300 flex-1 max-w-[260px] text-right ${ring} ${ro}`}>0.00</div>;
case "switch":
return <div className={`toggle ${ring}`} />;
return <div className={`toggle ${ring} ${ro}`} />;
case "textarea":
return <div className={`input-sm bg-white text-slate-300 flex-1 max-w-[260px] h-16 ${ring}`} />;
return <div className={`input-sm bg-white text-slate-300 flex-1 max-w-[260px] h-16 ${ring} ${ro}`} />;
default:
return (
<div className={`input-sm bg-white text-slate-300 flex-1 max-w-[260px] ${ring}`}>
<div className={`input-sm bg-white text-slate-300 flex-1 max-w-[260px] ${ring} ${ro}`}>
{field.editRO && "只读"}
</div>
);
@@ -54,12 +56,17 @@ function SortableField({
field,
active,
onActivate,
readOnly,
}: {
field: ModuleField;
active: boolean;
onActivate: (id: number) => void;
readOnly?: boolean;
}) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: field.id });
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: field.id,
disabled: readOnly,
});
const style = { transform: CSS.Transform.toString(transform), transition, opacity: isDragging ? 0.4 : 1 };
const typeDef = TYPE_DEFS.find((t) => t.v === field.type);
@@ -74,21 +81,23 @@ function SortableField({
className={`preview-field flex items-center gap-3 px-2 py-1.5 cursor-pointer ${active ? "highlight" : ""}`}
onClick={() => onActivate(field.id)}
>
<span
{...attributes}
{...listeners}
className="text-slate-300 hover:text-slate-500 cursor-grab active:cursor-grabbing shrink-0 px-0.5"
title="拖拽排序"
>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 8h16M4 16h16" />
</svg>
</span>
{!readOnly && (
<span
{...attributes}
{...listeners}
className="text-slate-300 hover:text-slate-500 cursor-grab active:cursor-grabbing shrink-0 px-0.5"
title="拖拽排序"
>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 8h16M4 16h16" />
</svg>
</span>
)}
<span className="w-5 h-5 rounded flex items-center justify-center text-[9px] font-bold text-white shrink-0" style={{ background: typeDef?.c ?? "#94a3b8" }}>
{typeDef?.icon ?? "?"}
</span>
<label className="text-xs text-slate-600 w-20 shrink-0 text-right select-none">{field.label}</label>
<PreviewWidget field={field} active={active} />
<PreviewWidget field={field} active={active} readOnly={readOnly} />
{tags.map((t, i) => (
<span key={i} className="text-[10px] text-slate-400 shrink-0">
{t}
@@ -98,7 +107,7 @@ function SortableField({
);
}
export default function FormPreview({ fields, groups, activeId, onActivate, onReorder, onGroupRename }: Props) {
export default function FormPreview({ fields, groups, activeId, onActivate, onReorder, onGroupRename, readOnly }: Props) {
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 5 } }));
function handleDragEnd(event: DragEndEvent) {
@@ -124,13 +133,15 @@ export default function FormPreview({ fields, groups, activeId, onActivate, onRe
<span className="text-[11px] font-medium text-slate-500">{g.name}</span>
<span className="text-[10px] text-slate-300">{gfs.length}字段</span>
<div className="flex-1" />
<button onClick={() => onGroupRename(g.id)} className="text-[10px] text-slate-300 hover:text-slate-500 transition-colors">
改名
</button>
{!readOnly && (
<button onClick={() => onGroupRename(g.id)} className="text-[10px] text-slate-300 hover:text-slate-500 transition-colors">
改名
</button>
)}
</div>
<div className="p-3 space-y-2">
{gfs.map((f) => (
<SortableField key={f.id} field={f} active={f.id === activeId} onActivate={onActivate} />
<SortableField key={f.id} field={f} active={f.id === activeId} onActivate={onActivate} readOnly={readOnly} />
))}
</div>
</div>
@@ -148,7 +159,7 @@ export default function FormPreview({ fields, groups, activeId, onActivate, onRe
</div>
<div className="p-3 space-y-2">
{ug.map((f) => (
<SortableField key={f.id} field={f} active={f.id === activeId} onActivate={onActivate} />
<SortableField key={f.id} field={f} active={f.id === activeId} onActivate={onActivate} readOnly={readOnly} />
))}
</div>
</div>
+25 -14
View File
@@ -13,6 +13,7 @@ interface Props {
onActivate: (id: number) => void;
onReorder: (activeId: number, overId: number) => void;
onResize: (id: number, width: number) => void;
readOnly?: boolean;
}
function SortableColumn({
@@ -21,14 +22,19 @@ function SortableColumn({
isLast,
onActivate,
onResizeStart,
readOnly,
}: {
field: ModuleField;
active: boolean;
isLast: boolean;
onActivate: (id: number) => void;
onResizeStart: (e: React.MouseEvent, id: number) => void;
readOnly?: boolean;
}) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: field.id });
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: field.id,
disabled: readOnly,
});
const style = { transform: CSS.Transform.toString(transform), transition, opacity: isDragging ? 0.4 : 1 };
return (
@@ -41,20 +47,22 @@ function SortableColumn({
onClick={() => onActivate(field.id)}
>
<div className="flex items-center gap-1.5">
<span
{...attributes}
{...listeners}
className="text-slate-300 hover:text-slate-500 cursor-grab active:cursor-grabbing shrink-0"
title="拖拽排序"
>
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 10h12M6 14h12" />
</svg>
</span>
{!readOnly && (
<span
{...attributes}
{...listeners}
className="text-slate-300 hover:text-slate-500 cursor-grab active:cursor-grabbing shrink-0"
title="拖拽排序"
>
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 10h12M6 14h12" />
</svg>
</span>
)}
<span className="truncate">{field.label}</span>
<span className="text-[10px] text-slate-300 ml-auto">{field.listW}</span>
</div>
{!isLast && (
{!isLast && !readOnly && (
<div
className="col-resize-handle"
onMouseDown={(e) => onResizeStart(e, field.id)}
@@ -65,7 +73,7 @@ function SortableColumn({
);
}
export default function TablePreview({ fields, activeId, onActivate, onReorder, onResize }: Props) {
export default function TablePreview({ fields, activeId, onActivate, onReorder, onResize, readOnly }: Props) {
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 5 } }));
const [resizing, setResizing] = useState<{ id: number; startX: number; startW: number } | null>(null);
const tableRef = useRef<HTMLDivElement>(null);
@@ -134,6 +142,7 @@ export default function TablePreview({ fields, activeId, onActivate, onReorder,
isLast={i === visible.length - 1}
onActivate={onActivate}
onResizeStart={handleResizeStart}
readOnly={readOnly}
/>
))}
</div>
@@ -155,7 +164,9 @@ export default function TablePreview({ fields, activeId, onActivate, onReorder,
</SortableContext>
</DndContext>
<div className="mt-2 text-[11px] text-slate-400 text-center">
拖拽列头排序 · 拖拽右边缘调整宽度 · 点击列头选中字段
{readOnly
? "只读预览 · 点击列头选中字段"
: "拖拽列头排序 · 拖拽右边缘调整宽度 · 点击列头选中字段"}
</div>
</div>
);
+226 -11
View File
@@ -175,14 +175,11 @@ body {
background: transparent;
}
/* ===== Drawer ===== */
/* ===== Drawer(桌面端右侧抽屉) ===== */
.drawer-mask {
position: fixed;
inset: 0;
z-index: 50;
display: flex;
align-items: flex-end;
justify-content: center;
background: rgba(15, 23, 42, 0.45);
opacity: 0;
visibility: hidden;
@@ -193,17 +190,24 @@ body {
visibility: visible;
}
.drawer {
width: 100%;
max-width: 520px;
margin: 0 12px;
padding: 18px;
position: absolute;
top: 0;
right: 0;
height: 100%;
width: 420px;
max-width: 92vw;
margin: 0;
padding: 20px;
background: #fff;
border-radius: 16px 16px 0 0;
transform: translateY(100%);
border-radius: 0;
box-shadow: -8px 0 24px rgba(15, 23, 42, 0.12);
display: flex;
flex-direction: column;
transform: translateX(100%);
transition: transform 0.25s;
}
.drawer-mask.open .drawer {
transform: translateY(0);
transform: translateX(0);
}
.drawer-head {
display: flex;
@@ -311,3 +315,214 @@ body {
.col-resize-handle.active {
background: var(--color-primary);
}
/* ===== Field config table(右侧字段配置) ===== */
.field-config {
padding: 4px 4px 8px;
}
.fc-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 4px 8px 12px;
}
.fc-head h3 {
font-size: 14px;
font-weight: 600;
color: #334155;
margin: 0;
}
.fc-head .muted {
color: #94a3b8;
font-weight: 400;
font-size: 12px;
}
.table-scroll {
overflow: auto;
max-height: 100%;
}
.field-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.field-table th {
position: sticky;
top: 0;
background: #fff;
z-index: 2;
text-align: center;
font-weight: 500;
color: #94a3b8;
padding: 8px 6px;
border-bottom: 1px solid #f1f5f9;
white-space: nowrap;
}
.field-table td {
padding: 6px 6px;
border-bottom: 1px solid #f8fafc;
vertical-align: middle;
}
.field-table tr.active > td {
background: #eff6ff;
}
.field-table select,
.field-table .cell-input {
border: 1px solid #e2e8f0;
border-radius: 6px;
padding: 4px 6px;
font-size: 12px;
width: 100%;
background: #fff;
color: #334155;
outline: none;
}
.cell-input:focus {
border-color: var(--color-primary);
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.15);
}
.cell-input.code {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
color: #64748b;
}
.cell-group {
display: inline-block;
padding: 2px 8px;
border-radius: 9999px;
background: #f1f5f9;
color: #64748b;
font-size: 11px;
}
.move-btns {
display: inline-flex;
flex-direction: column;
gap: 2px;
}
.move-btns button {
border: none;
background: transparent;
color: #94a3b8;
cursor: pointer;
font-size: 11px;
line-height: 1;
padding: 0 2px;
}
.move-btns button:hover {
color: var(--color-primary);
}
.group-row > td {
background: #f8fafc;
padding: 8px 10px;
border-bottom: 1px solid #f1f5f9;
}
.group-name {
font-weight: 600;
color: #475569;
font-size: 13px;
}
.group-edit {
margin-left: 6px;
border: none;
background: transparent;
color: #94a3b8;
cursor: pointer;
}
.group-edit:hover {
color: var(--color-primary);
}
.group-count {
margin-left: 8px;
font-size: 11px;
color: #94a3b8;
}
.group-input {
border: 1px solid var(--color-primary);
border-radius: 6px;
padding: 4px 8px;
font-size: 13px;
width: 220px;
outline: none;
}
.add-row {
width: 100%;
text-align: center;
border: 1px dashed #e2e8f0;
background: transparent;
color: #94a3b8;
padding: 8px;
border-radius: 8px;
cursor: pointer;
font-size: 12px;
}
.add-row:hover {
border-color: var(--color-primary);
color: var(--color-primary);
}
.detail-row > td {
background: #fafbff;
padding: 12px;
border-bottom: 1px solid #f1f5f9;
}
.detail-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 10px;
}
.detail-grid label {
display: block;
font-size: 11px;
color: #94a3b8;
margin-bottom: 4px;
}
.detail-grid input,
.detail-grid select {
width: 100%;
border: 1px solid #e2e8f0;
border-radius: 6px;
padding: 6px 8px;
font-size: 12px;
color: #334155;
outline: none;
}
.detail-grid input:focus,
.detail-grid select:focus {
border-color: var(--color-primary);
}
.ds-btn {
width: 100%;
border: 1px solid #e2e8f0;
background: #fff;
border-radius: 6px;
padding: 6px 8px;
font-size: 12px;
color: #475569;
cursor: pointer;
}
.ds-btn:hover {
border-color: var(--color-primary);
color: var(--color-primary);
}
.icon-btn {
border: none;
background: transparent;
color: #cbd5e1;
cursor: pointer;
font-size: 13px;
}
.icon-btn:hover {
color: #ef4444;
}
.link-danger {
border: none;
background: transparent;
color: #ef4444;
cursor: pointer;
font-size: 12px;
}
.link-muted {
border: none;
background: transparent;
color: #94a3b8;
cursor: pointer;
font-size: 12px;
}