20260813173539
This commit is contained in:
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?.();
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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=">=">>=</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=">">></option>
|
||||
<option value="<"><</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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user