20260726225220

This commit is contained in:
oneao committed 2026-07-26 22:52:21 +08:00
1 parent e3c7c6f8da
commit a8478d60ed
170 files changed
+6786 -1678

No files matched your search

@@ -1,259 +0,0 @@
<script setup>
import { h, shallowRef } from "vue";
import { message } from "antdv-next";
import { Input, InputNumber, Select } from "antdv-next";
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());
// 当前编辑行 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" : "";
}
</script>
<template>
<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="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>
.table-demo {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
min-height: 0;
color: var(--fms-text);
background: var(--fms-surface);
}
.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 {
flex: 1;
min-height: 0;
padding: 12px 16px 16px;
}
::deep(.fms-row-editing) {
background: rgba(22, 119, 255, 0.08) !important;
}
</style>
@@ -3,7 +3,6 @@ import { h, shallowRef } from "vue";
import { message } from "antdv-next";
import { Input, InputNumber, Select } from "antdv-next";
import FmsTable from "@/components/fms-table/FmsTable.vue";
import FpsMonitor from "@/components/FpsMonitor.vue";
const COLUMN_COUNT = 20;
const ROW_COUNT = 4000;
@@ -192,7 +191,6 @@ function rowClassName(row) {
</header>
<section class="table-demo__body">
<FpsMonitor />
<FmsTable
v-model:columns="columns"
:data-source="dataSource"
@@ -256,7 +254,7 @@ function rowClassName(row) {
padding: 12px 16px 16px;
}
::deep(.fms-row-editing) {
:deep(.fms-row-editing) {
background: rgba(22, 119, 255, 0.08) !important;
}
</style>
+8 -2
View File
@@ -41,9 +41,15 @@ const form = reactive({
});
onMounted(() => {
if (route.query.reason !== "session-replaced") return;
const reason = route.query.reason;
if (reason !== "session-replaced" && reason !== "expired") return;
if (reason === "session-replaced") {
message.warning("账号已在其他设备登录,请重新登录");
} else {
message.warning("登录已过期或已失效,请重新登录");
}
message.warning("账号已在其他设备登录,请重新登录");
const query = { ...route.query };
delete query.reason;
router.replace({ name: "login", query });
@@ -7,6 +7,7 @@ import {
cloneData,
diffRows,
allocateTemporaryIds,
remapForeignKeys,
tableChange,
hasChanges,
} from "@/utils/dataChanges";
@@ -22,7 +23,6 @@ const emit = defineEmits(["update:visible", "update"]);
const localGroups = ref([]);
const selectedGroupId = ref(null);
const editForm = ref(null);
watch(
() => props.groups,
@@ -37,34 +37,29 @@ const treeData = computed(() => {
});
function buildTreeData(items) {
const map = new Map();
const nodes = new Map();
const roots = [];
items.forEach((item) => {
item.key = String(item.b_id);
map.set(item.key, item);
});
items.forEach((item) => {
if (item.b_parent_id && map.has(String(item.b_parent_id))) {
const parent = map.get(String(item.b_parent_id));
if (!parent.children) parent.children = [];
parent.children.push(item);
for (const item of items) {
const key = String(item.b_id);
nodes.set(key, {
key,
title: item.b_title || `分组-${item.b_id}`,
children: [],
});
}
for (const item of items) {
const node = nodes.get(String(item.b_id));
const parent = item.b_parent_id ? nodes.get(String(item.b_parent_id)) : null;
if (parent && parent !== node) {
parent.children.push(node);
} else {
roots.push(item);
roots.push(node);
}
});
return roots.map((r) => ({
key: r.key,
title: r.b_title || `分组-${r.b_id}`,
children:
r.children?.map((c) => ({
key: c.key,
title: c.b_title || `分组-${c.b_id}`,
children: c.children?.map((gc) => ({
key: gc.key,
title: gc.b_title || `分组-${gc.b_id}`,
})),
})) || [],
}));
}
return roots;
}
const selectedGroup = computed(() =>
@@ -74,11 +69,8 @@ const selectedGroup = computed(() =>
function onTreeSelect(keys) {
if (keys.length > 0) {
selectedGroupId.value = String(keys[0]);
const g = selectedGroup.value;
editForm.value = g ? { ...g } : null;
} else {
selectedGroupId.value = null;
editForm.value = null;
}
}
@@ -94,26 +86,33 @@ function addGroup(parentId) {
};
localGroups.value.push(newGroup);
selectedGroupId.value = String(newGroup.b_id);
editForm.value = { ...newGroup };
}
function deleteGroup() {
if (!selectedGroupId.value) return;
localGroups.value = localGroups.value.filter((g) => String(g.b_id) !== selectedGroupId.value);
selectedGroupId.value = null;
editForm.value = null;
}
async function saveAllGroups() {
if (localGroups.value.some((group) => !String(group.b_title || "").trim())) {
message.warning("请填写分组标题");
return;
}
const original = cloneData(props.groups || []);
const changes = diffRows(localGroups.value, original);
const req = tableChange("b_module_field_group", changes);
const req = tableChange("s_module_field_group", changes);
if (!hasChanges(req)) {
message.info("没有需要保存的变更");
return;
}
try {
await allocateTemporaryIds([req.inserts || [], req.updates || [], req.deletes || []]);
const idMapping = await allocateTemporaryIds([
req.inserts || [],
req.updates || [],
req.deletes || [],
]);
remapForeignKeys(localGroups.value, ["b_parent_id"], idMapping);
await saveObjectApi([req]);
emit("update", cloneData(localGroups.value));
message.success("分组已保存");
@@ -155,28 +154,24 @@ function onClose() {
</FmsTree>
<a-empty v-else description="暂无分组" />
</div>
<div class="group-detail" v-if="editForm">
<div v-if="selectedGroup" class="group-detail">
<a-form layout="vertical">
<a-form-item label="分组标题" required>
<a-input :value="editForm.b_title" @update:value="(v) => (editForm.b_title = v)" />
<a-input v-model:value="selectedGroup.b_title" />
</a-form-item>
<a-form-item label="多语言键">
<a-input
:value="editForm.b_i18n"
@update:value="(v) => (editForm.b_i18n = v || null)"
:value="selectedGroup.b_i18n"
@update:value="(v) => (selectedGroup.b_i18n = v || null)"
/>
</a-form-item>
<a-form-item label="排序号">
<a-input-number
:value="editForm.b_xh"
:min="0"
@update:value="(v) => (editForm.b_xh = v)"
/>
<a-input-number v-model:value="selectedGroup.b_xh" :min="0" />
</a-form-item>
<a-form-item label="启用">
<a-switch
:checked="editForm.b_canuse === 1"
@update:checked="(v) => (editForm.b_canuse = v ? 1 : 0)"
:checked="selectedGroup.b_canuse === 1"
@update:checked="(v) => (selectedGroup.b_canuse = v ? 1 : 0)"
/>
</a-form-item>
<a-form-item>
@@ -17,6 +17,7 @@ const formData = ref(null);
watch(
() => props.data,
(val) => {
if (val === formData.value) return;
formData.value = val ? { ...val } : null;
},
{ immediate: true },
@@ -27,7 +28,7 @@ const isPage = computed(() => formData.value?.b_module_type === "page");
const isData = computed(() => formData.value?.b_module_type === "data");
function emitUpdate() {
if (formData.value) emit("update", { ...formData.value });
if (formData.value) emit("update", formData.value);
}
function setField(field, value) {
@@ -37,9 +38,10 @@ function setField(field, value) {
}
// 多语言键由模块编码自动生成(module.<编码>),无需手动填写。
// 编码变化时始终以完整编码重新派生,避免只生成首字母后不再更新。
function onCodeInput(v) {
setField("b_code", v);
if (!formData.value.b_i18n && v) {
if (v) {
formData.value.b_i18n = `module.${v}`;
emitUpdate();
}
@@ -60,7 +62,7 @@ function onI18nSaved(key) {
<template>
<div class="basic-panel" v-if="formData">
<div class="panel-body">
<a-form layout="vertical" class="basic-form">
<a-form layout="vertical" class="basic-form" :disabled="loading">
<a-row :gutter="16">
<a-col :span="8">
<a-form-item label="模块编码" required>
@@ -1,7 +1,6 @@
<script setup>
import { computed, ref, shallowRef, watch } from "vue";
import FmsTable from "@/components/fms-table/FmsTable.vue";
import { createTempId } from "@/utils/tempId";
import FieldGroupManager from "./FieldGroupManager.vue";
const props = defineProps({
@@ -21,16 +20,22 @@ const activeKey = ref(null);
watch(
() => props.editConfig,
(val) => {
if (val === localEdit.value) return;
localEdit.value = val?.map((r) => ({ ...r })) || [];
},
{ immediate: true },
);
function emitUpdate() {
emit(
"update",
localEdit.value.map((r) => ({ ...r })),
);
emit("update", localEdit.value);
}
function onRowOrderChange(rows) {
localEdit.value = rows.map((row, index) => ({
...row,
b_xh: (index + 1) * 10,
}));
emitUpdate();
}
const availableFields = computed(() =>
@@ -45,21 +50,8 @@ const groupOptions = computed(() =>
.map((g) => ({ label: g.b_title || `分组-${g.b_id}`, value: String(g.b_id) })),
);
function addRow() {
localEdit.value.push({
b_id: createTempId(),
b_module_id: props.moduleId,
b_field_id: null,
b_required: 0,
b_readonly: 0,
b_disabled: 0,
b_group_id: null,
b_xh: localEdit.value.length * 10,
});
emitUpdate();
}
function deleteRow(record) {
if (props.loading) return;
localEdit.value = localEdit.value.filter((r) => r.b_id !== record.b_id);
emitUpdate();
}
@@ -120,17 +112,19 @@ const columns = shallowRef([
<div class="edit-config-panel">
<div class="panel-toolbar">
<a-space>
<a-button type="primary" @click="addRow">新增行</a-button>
<a-button @click="groupManagerVisible = true">分组管理</a-button>
<a-button :disabled="loading" @click="groupManagerVisible = true">分组管理</a-button>
</a-space>
</div>
<div class="panel-body">
<FmsTable
v-model:columns="columns"
:data-source="localEdit"
:editable="!loading"
:row-draggable="!loading"
row-key="b_id"
:active-row-key="activeKey"
@row-click="(ev, row) => (activeKey = row.b_id)"
@row-order-change="onRowOrderChange"
/>
</div>
<FieldGroupManager
@@ -1,5 +1,6 @@
<script setup>
import { ref, shallowRef, watch } from "vue";
import { ref, watch, computed } from "vue";
import { message, Modal } from "antdv-next";
import FmsTable from "@/components/fms-table/FmsTable.vue";
const FIELD_TYPES = [
@@ -25,24 +26,95 @@ const emit = defineEmits(["update", "sync"]);
const localFields = ref([]);
const activeKey = ref(null);
// 同步:未配置查询视图时,手动输入表/视图名
const syncOpen = ref(false);
const syncTable = ref("");
function onSyncClick() {
if (props.viewTable) {
emit("sync", props.viewTable);
return;
}
syncTable.value = "";
syncOpen.value = true;
}
function onSyncConfirm() {
const name = syncTable.value.trim();
if (!name) {
message.warning("请输入表或视图名");
return;
}
syncOpen.value = false;
emit("sync", name);
}
watch(
() => props.fields,
(val) => {
if (val === localFields.value) return;
localFields.value = val?.map((r) => ({ ...r })) || [];
},
{ immediate: true },
);
function emitUpdate() {
emit(
"update",
localFields.value.map((r) => ({ ...r })),
);
emit("update", localFields.value);
}
const columns = shallowRef([
// 编辑器已直接更新当前行,这里只向父级同步草稿。
function onCellChange() {
emitUpdate();
}
// 行多选:selectedRowKeys 由 FmsTable 的选择列回写;
// 传了 rowSelection 时 FmsTable 会在最前列插入复选框列(也是单击进入编辑态所需的机制)。
const selectedRowKeys = ref([]);
const rowSelection = computed(() => ({
selectedRowKeys: selectedRowKeys.value,
onChange: (keys) => {
selectedRowKeys.value = keys;
},
}));
function deleteRow(record) {
if (props.loading) return;
localFields.value = localFields.value.filter((r) => r.b_id !== record.b_id);
const k = String(record.b_id);
if (selectedRowKeys.value.includes(k)) {
selectedRowKeys.value = selectedRowKeys.value.filter((x) => x !== k);
}
emitUpdate();
}
// 批量删除选中行
function batchDelete() {
if (props.loading) return;
const keys = selectedRowKeys.value;
if (!keys.length) return;
Modal.confirm({
title: `确定删除选中的 ${keys.length} 条记录?`,
okText: "删除",
okType: "danger",
cancelText: "取消",
onOk: () => {
const delSet = new Set(keys);
localFields.value = localFields.value.filter((r) => !delSet.has(r.b_id));
selectedRowKeys.value = [];
if (activeKey.value && delSet.has(activeKey.value)) activeKey.value = "";
emitUpdate();
},
});
}
const columns = ref([
{ dataIndex: "b_field", title: "字段名", width: 140 },
{ dataIndex: "b_name", title: "字段名称", width: 140 },
{
dataIndex: "b_name",
title: "字段名称",
width: 140,
editor: { type: "input", onChange: () => emitUpdate() },
},
{
dataIndex: "b_type",
title: "控件类型",
@@ -53,8 +125,18 @@ const columns = shallowRef([
onChange: () => emitUpdate(),
},
},
{ dataIndex: "b_default_value", title: "默认值", width: 120 },
{ dataIndex: "b_i18n", title: "多语言键", width: 140 },
{
dataIndex: "b_default_value",
title: "默认值",
width: 120,
editor: { type: "input", onChange: () => emitUpdate() },
},
{
dataIndex: "b_i18n",
title: "多语言键",
width: 140,
editor: { type: "input", onChange: () => emitUpdate() },
},
{
dataIndex: "b_canuse",
title: "启用",
@@ -62,6 +144,19 @@ const columns = shallowRef([
align: "center",
editor: { type: "checkbox", onChange: () => emitUpdate() },
},
{
dataIndex: "actions",
title: "操作",
width: 60,
actions: [
{
type: "delete",
label: "删除",
confirm: "确定删除该字段?",
onClick: (row) => deleteRow(row),
},
],
},
]);
</script>
@@ -69,18 +164,39 @@ const columns = shallowRef([
<div class="fields-panel">
<div class="panel-toolbar">
<a-space>
<a-button v-if="viewTable" type="primary" @click="emit('sync')">
同步字段 ({{ viewTable }})
<a-button type="primary" :disabled="loading" @click="onSyncClick">
同步字段{{ viewTable ? ` (${viewTable})` : "" }}
</a-button>
<a-button danger :disabled="loading || !selectedRowKeys.length" @click="batchDelete">
批量删除{{ selectedRowKeys.length ? `(${selectedRowKeys.length})` : "" }}
</a-button>
<span v-if="!viewTable" class="panel-hint">未配置查询视图,点击后手动输入表名</span>
</a-space>
</div>
<a-modal
v-model:open="syncOpen"
title="同步字段"
@ok="onSyncConfirm"
:confirm-loading="loading"
>
<p>当前模块未配置查询视图,请填写要同步字段的表或视图名:</p>
<a-input
v-model:value="syncTable"
placeholder="例如 s_log_login 或 v_biz"
@press-enter="onSyncConfirm"
/>
</a-modal>
<div class="panel-body">
<FmsTable
v-model:columns="columns"
:data-source="localFields"
:editable="!loading"
row-key="b_id"
:active-row-key="activeKey"
@row-click="(ev, row) => (activeKey = row.b_id)"
:row-selection="rowSelection"
@row-click="(_, row) => (activeKey = String(row.b_id))"
@change="onCellChange"
/>
</div>
</div>
@@ -2,7 +2,6 @@
import { computed, nextTick, ref, watch } from "vue";
import { message, Modal } from "antdv-next";
import FmsTable from "@/components/fms-table/FmsTable.vue";
import FpsMonitor from "@/components/FpsMonitor.vue";
import { loadDataApi } from "@/services/api";
import {
cloneData,
@@ -86,6 +85,7 @@ async function loadI18n() {
watch(() => props.moduleId, loadI18n, { immediate: true });
async function addRow() {
if (props.loading || isLoading.value) return;
const newId = createTempId();
// 用新引用替换(而非 push),否则 displayedData 这个 computed 因返回同一数组引用而不会触发重渲染
pivotRows.value = [...pivotRows.value, { _id: newId, b_key: "", _localeIds: {} }];
@@ -96,6 +96,7 @@ async function addRow() {
}
function deleteRow(record) {
if (props.loading || isLoading.value) return;
pivotRows.value = pivotRows.value.filter((r) => r._id !== record._id);
// 同步清理选择状态,避免选中项被删后 keys 残留
const k = String(record._id);
@@ -116,6 +117,7 @@ const rowSelection = computed(() => ({
// 批量删除选中行(整条 key,含其全部语言)
function batchDelete() {
if (props.loading || isLoading.value) return;
const keys = selectedRowKeys.value;
if (!keys.length) return;
Modal.confirm({
@@ -189,9 +191,13 @@ const columns = computed(() => {
title: "操作",
width: 60,
align: "center",
type: "actions",
actions: [
{ type: "delete", label: "删除", confirm: "确定删除该记录(含其全部语言)?", onClick: (row) => deleteRow(row) },
{
type: "delete",
label: "删除",
confirm: "确定删除该记录(含其全部语言)?",
onClick: (row) => deleteRow(row),
},
],
});
return cols;
@@ -204,22 +210,29 @@ const columns = computed(() => {
<a-space>
<a-input-search
v-model:value="searchKey"
:disabled="loading || isLoading"
placeholder="搜索键"
style="width: 200px"
allow-clear
/>
<a-button type="primary" @click="addRow">新增翻译</a-button>
<a-button danger :disabled="!selectedRowKeys.length" @click="batchDelete">
<a-button type="primary" :disabled="loading || isLoading" @click="addRow"
>新增翻译</a-button
>
<a-button
danger
:disabled="loading || isLoading || !selectedRowKeys.length"
@click="batchDelete"
>
批量删除{{ selectedRowKeys.length ? `(${selectedRowKeys.length})` : "" }}
</a-button>
</a-space>
</div>
<div class="panel-body">
<FpsMonitor />
<FmsTable
ref="tableRef"
:columns="columns"
:data-source="displayedData"
:editable="!loading && !isLoading"
row-key="_id"
:active-row-key="activeKey"
:row-selection="rowSelection"
@@ -1,7 +1,6 @@
<script setup>
import { computed, ref, shallowRef, watch } from "vue";
import FmsTable from "@/components/fms-table/FmsTable.vue";
import { createTempId } from "@/utils/tempId";
import FieldGroupManager from "./FieldGroupManager.vue";
const props = defineProps({
@@ -21,16 +20,22 @@ const activeKey = ref(null);
watch(
() => props.listConfig,
(val) => {
if (val === localList.value) return;
localList.value = val?.map((r) => ({ ...r })) || [];
},
{ immediate: true },
);
function emitUpdate() {
emit(
"update",
localList.value.map((r) => ({ ...r })),
);
emit("update", localList.value);
}
function onRowOrderChange(rows) {
localList.value = rows.map((row, index) => ({
...row,
b_xh: (index + 1) * 10,
}));
emitUpdate();
}
const availableFields = computed(() =>
@@ -45,23 +50,8 @@ const groupOptions = computed(() =>
.map((g) => ({ label: g.b_title || `分组-${g.b_id}`, value: String(g.b_id) })),
);
function addRow() {
localList.value.push({
b_id: createTempId(),
b_module_id: props.moduleId,
b_field_id: null,
b_group_id: null,
b_title: null,
b_i18n: null,
b_visible: 1,
b_xh: localList.value.length * 10,
b_width: null,
b_sortable: 0,
});
emitUpdate();
}
function deleteRow(record) {
if (props.loading) return;
localList.value = localList.value.filter((r) => r.b_id !== record.b_id);
emitUpdate();
}
@@ -77,7 +67,6 @@ const columns = shallowRef([
onChange: () => emitUpdate(),
},
},
{ dataIndex: "b_title", title: "列标题", width: 120 },
{ dataIndex: "b_width", title: "列宽", width: 80 },
{
dataIndex: "b_group_id",
@@ -96,13 +85,6 @@ const columns = shallowRef([
align: "center",
editor: { type: "checkbox", onChange: () => emitUpdate() },
},
{
dataIndex: "b_sortable",
title: "排序",
width: 60,
align: "center",
editor: { type: "checkbox", onChange: () => emitUpdate() },
},
{ dataIndex: "b_xh", title: "排序号", width: 80 },
{
dataIndex: "actions",
@@ -125,17 +107,19 @@ const columns = shallowRef([
<div class="list-config-panel">
<div class="panel-toolbar">
<a-space>
<a-button type="primary" @click="addRow">新增列</a-button>
<a-button @click="groupManagerVisible = true">分组管理</a-button>
<a-button :disabled="loading" @click="groupManagerVisible = true">分组管理</a-button>
</a-space>
</div>
<div class="panel-body">
<FmsTable
v-model:columns="columns"
:data-source="localList"
:editable="!loading"
:row-draggable="!loading"
row-key="b_id"
:active-row-key="activeKey"
@row-click="(ev, row) => (activeKey = row.b_id)"
@row-order-change="onRowOrderChange"
/>
</div>
<FieldGroupManager
@@ -3,7 +3,6 @@ import { computed, h, nextTick, ref, watch } from "vue";
import { message, Modal } from "antdv-next";
import { Languages } from "@lucide/vue";
import FmsTable from "@/components/fms-table/FmsTable.vue";
import FpsMonitor from "@/components/FpsMonitor.vue";
import { createTempId } from "@/utils/tempId";
import I18nQuickSetModal from "./I18nQuickSetModal.vue";
@@ -23,7 +22,8 @@ const tableRef = ref(null);
// 多语言键按 power.<模块代码>.<操作编码> 自动生成,无需手动填写。
watch(
() => [props.powers, props.moduleCode],
([val]) => {
([val, moduleCode], previous = []) => {
if (val === localPowers.value && moduleCode === previous[1]) return;
localPowers.value = (val || []).map((r) => ({
...r,
b_i18n:
@@ -34,13 +34,11 @@ watch(
);
function emitUpdate() {
emit(
"update",
localPowers.value.map((r) => ({ ...r })),
);
emit("update", localPowers.value);
}
async function addRow() {
if (props.loading) return;
const newId = createTempId();
localPowers.value.push({
b_id: newId,
@@ -59,6 +57,7 @@ async function addRow() {
}
function deleteRow(record) {
if (props.loading) return;
localPowers.value = localPowers.value.filter((r) => r.b_id !== record.b_id);
// 同步清理选择状态,避免选中项被删后 keys 残留
const k = String(record.b_id);
@@ -80,6 +79,7 @@ const rowSelection = computed(() => ({
// 批量删除选中行
function batchDelete() {
if (props.loading) return;
const keys = selectedRowKeys.value;
if (!keys.length) return;
Modal.confirm({
@@ -97,47 +97,9 @@ function batchDelete() {
});
}
// ── 编辑状态管理 ──
// 通过 row._editing = { mode: "cell", col: dataIndex } 控制单元格编辑。
// area-selection-change 统一响应鼠标点击和键盘方向键,无需额外 row-click 处理。
// 编辑状态变更后需触发表格重渲染。
function handleEditEnd() {
let hasEditing = false;
localPowers.value.forEach((r) => {
if (r._editing) {
delete r._editing;
hasEditing = true;
}
});
if (hasEditing) localPowers.value = [...localPowers.value];
}
function handleAreaSelectionChange(ranges) {
if (!ranges?.length) {
handleEditEnd();
return;
}
const begin = ranges[0].index.begin;
const row = localPowers.value[begin.row];
const area = tableRef.value?.getSelectedArea();
if (!row || !area) return;
const col = area.cols[begin.col];
if (!col || col.type !== "input") {
handleEditEnd();
return;
}
const rowId = String(row.b_id);
// 目标未变则跳过,避免方向键连续触发全表重渲染
const cur = row._editing;
if (cur && cur.mode === "cell" && cur.col === col.dataIndex && cur.rowId === rowId) return;
localPowers.value.forEach((r) => {
if (r._editing) delete r._editing;
});
row._editing = { mode: "cell", col: col.dataIndex, rowId };
localPowers.value = [...localPowers.value];
// 编辑器已直接更新当前行,这里只向父级同步草稿。
function onCellChange() {
emitUpdate();
}
// ── 快速设置多语言 ──
@@ -197,14 +159,29 @@ const I18nKeyCell = ({ row, cellValue }) =>
);
const columns = ref([
{ dataIndex: "b_code", title: "操作编码", width: 160, type: "input", onChange: () => emitUpdate(), _onEditEnd: handleEditEnd },
{ dataIndex: "b_name", title: "操作名称", width: 160, type: "input", onChange: () => emitUpdate(), _onEditEnd: handleEditEnd },
{ dataIndex: "b_code", title: "操作编码", width: 160, editor: { type: "input" } },
{ dataIndex: "b_name", title: "操作名称", width: 160, editor: { type: "input" } },
{ dataIndex: "b_i18n", title: "多语言键", width: 160, customCell: I18nKeyCell },
{ dataIndex: "b_xh", title: "排序", width: 80, type: "input", onChange: () => emitUpdate(), _onEditEnd: handleEditEnd },
{ dataIndex: "b_canuse", title: "启用", width: 70, type: "checkbox", onChange: () => emitUpdate() },
{ dataIndex: "actions", title: "操作", width: 60, type: "actions", actions: [
{ type: "delete", label: "删除", confirm: "确定删除该记录?", onClick: (row) => deleteRow(row) },
] },
{ dataIndex: "b_xh", title: "排序", width: 80, editor: { type: "input" } },
{
dataIndex: "b_canuse",
title: "启用",
width: 70,
editor: { type: "checkbox" },
},
{
dataIndex: "actions",
title: "操作",
width: 60,
actions: [
{
type: "delete",
label: "删除",
confirm: "确定删除该记录?",
onClick: (row) => deleteRow(row),
},
],
},
]);
</script>
@@ -212,24 +189,23 @@ const columns = ref([
<div class="powers-panel">
<div class="panel-toolbar">
<a-space>
<a-button type="primary" @click="addRow">新增权限</a-button>
<a-button danger :disabled="!selectedRowKeys.length" @click="batchDelete">
<a-button type="primary" :disabled="loading" @click="addRow">新增权限</a-button>
<a-button danger :disabled="loading || !selectedRowKeys.length" @click="batchDelete">
批量删除{{ selectedRowKeys.length ? `(${selectedRowKeys.length})` : "" }}
</a-button>
</a-space>
</div>
<div class="panel-body">
<FpsMonitor />
<FmsTable
ref="tableRef"
v-model:columns="columns"
:data-source="localPowers"
:editable="!loading"
row-key="b_id"
:active-row-key="activeKey"
:row-selection="rowSelection"
:area-selection="true"
@row-click="(_, row) => (activeKey = String(row.b_id))"
@area-selection-change="handleAreaSelectionChange"
@change="onCellChange"
/>
</div>
<I18nQuickSetModal ref="i18nModalRef" />
@@ -263,22 +239,4 @@ const columns = ref([
min-height: 0;
width: 100%;
}
// 编辑态 input 样式
:deep(.fms-editable-input) {
width: 100%;
height: 100%;
border: 1px solid #1677ff;
border-radius: 4px;
padding: 0 8px;
font-size: 13px;
outline: none;
box-sizing: border-box;
background: #fff;
&:focus {
border-color: #1677ff;
box-shadow: 0 0 0 2px rgba(22, 119, 255, 0.12);
}
}
</style>
@@ -32,16 +32,22 @@ const activeKey = ref(null);
watch(
() => props.queryConfig,
(val) => {
if (val === localQuery.value) return;
localQuery.value = val?.map((r) => ({ ...r })) || [];
},
{ immediate: true },
);
function emitUpdate() {
emit(
"update",
localQuery.value.map((r) => ({ ...r })),
);
emit("update", localQuery.value);
}
function onRowOrderChange(rows) {
localQuery.value = rows.map((row, index) => ({
...row,
b_xh: (index + 1) * 10,
}));
emitUpdate();
}
const availableFields = computed(() =>
@@ -51,6 +57,7 @@ const availableFields = computed(() =>
);
function addRow() {
if (props.loading) return;
localQuery.value.push({
b_id: createTempId(),
b_module_id: props.moduleId,
@@ -65,6 +72,7 @@ function addRow() {
}
function deleteRow(record) {
if (props.loading) return;
localQuery.value = localQuery.value.filter((r) => r.b_id !== record.b_id);
emitUpdate();
}
@@ -130,16 +138,19 @@ const columns = shallowRef([
<div class="query-config-panel">
<div class="panel-toolbar">
<a-space>
<a-button type="primary" @click="addRow">新增查询条件</a-button>
<a-button type="primary" :disabled="loading" @click="addRow">新增查询条件</a-button>
</a-space>
</div>
<div class="panel-body">
<FmsTable
v-model:columns="columns"
:data-source="localQuery"
:editable="!loading"
:row-draggable="!loading"
row-key="b_id"
:active-row-key="activeKey"
@row-click="(ev, row) => (activeKey = row.b_id)"
@row-order-change="onRowOrderChange"
/>
</div>
</div>
@@ -72,6 +72,13 @@ const drafts = ref({
const configLoading = ref(false);
const configSaving = ref(false);
const i18nPanelRef = ref(null);
let configRequestId = 0;
// ref 写在 v-for 内部会被 Vue 收集为数组,这里统一取单个组件实例
function getI18nPanel() {
const r = i18nPanelRef.value;
return Array.isArray(r) ? r[0] : r;
}
// 当前选中模块对象(从 drafts.module 获取)
const selectedModule = computed(() => drafts.value.module);
@@ -138,7 +145,7 @@ function buildTree(modules) {
async function loadTree() {
treeLoading.value = true;
try {
const res = await loadDataApi("b_module", "b_canuse = 1", "b_xh ASC, b_id ASC");
const res = await loadDataApi("s_module", "b_canuse = 1", "b_xh ASC, b_id ASC");
treeData.value = buildTree(res.data || []);
} finally {
treeLoading.value = false;
@@ -146,19 +153,19 @@ async function loadTree() {
}
// ---- 模块配置加载 ----
async function loadModuleConfig(moduleId) {
async function loadModuleConfig(moduleId, requestId) {
configLoading.value = true;
try {
const id = String(moduleId);
const [moduleRes, fieldsRes, groupsRes, listRes, editRes, queryRes, powersRes] =
await Promise.all([
loadDataApi("b_module", `b_id = ${id}`),
loadDataApi("b_module_field", `b_module_id = ${id}`, "b_id ASC"),
loadDataApi("b_module_field_group", `b_module_id = ${id}`, "b_xh ASC, b_id ASC"),
loadDataApi("b_module_field_list", `b_module_id = ${id}`, "b_xh ASC, b_id ASC"),
loadDataApi("b_module_field_edit", `b_module_id = ${id}`, "b_xh ASC, b_id ASC"),
loadDataApi("b_module_field_query", `b_module_id = ${id}`, "b_xh ASC, b_id ASC"),
loadDataApi("b_module_power", `b_module_id = ${id}`, "b_xh ASC, b_id ASC"),
loadDataApi("s_module", `b_id = ${id}`),
loadDataApi("s_module_field", `b_module_id = ${id}`, "b_id ASC"),
loadDataApi("s_module_field_group", `b_module_id = ${id}`, "b_xh ASC, b_id ASC"),
loadDataApi("s_module_field_list", `b_module_id = ${id}`, "b_xh ASC, b_id ASC"),
loadDataApi("s_module_field_edit", `b_module_id = ${id}`, "b_xh ASC, b_id ASC"),
loadDataApi("s_module_field_query", `b_module_id = ${id}`, "b_xh ASC, b_id ASC"),
loadDataApi("s_module_power", `b_module_id = ${id}`, "b_xh ASC, b_id ASC"),
]);
const original = {
@@ -170,11 +177,16 @@ async function loadModuleConfig(moduleId) {
queryConfig: queryRes.data || [],
powers: powersRes.data || [],
};
if (requestId !== configRequestId) return;
originals.value = original;
drafts.value = cloneData(original);
activeTab.value = "basic";
} catch (e) {
if (requestId === configRequestId) {
message.error("模块配置加载失败:" + (e.message || e));
}
} finally {
configLoading.value = false;
if (requestId === configRequestId) configLoading.value = false;
}
}
@@ -186,12 +198,17 @@ function onTreeSelect(keys) {
}
watch(selectedModuleId, (id) => {
if (!id) return;
if (skipLoad.value) {
skipLoad.value = false;
const requestId = ++configRequestId;
if (!id) {
configLoading.value = false;
return;
}
loadModuleConfig(id);
if (skipLoad.value) {
skipLoad.value = false;
configLoading.value = false;
return;
}
void loadModuleConfig(id, requestId);
});
// ---- 子面板数据变更 ----
@@ -314,7 +331,7 @@ async function onDeleteNodeById(moduleId) {
return;
}
try {
await saveObjectApi([{ table: "b_module", key_field: "b_id", deletes: [{ b_id: moduleId }] }]);
await saveObjectApi([{ table: "s_module", key_field: "b_id", deletes: [{ b_id: moduleId }] }]);
message.success("模块已删除");
if (selectedModuleId.value === moduleId) {
selectedModuleId.value = null;
@@ -334,13 +351,13 @@ async function saveAll() {
try {
const saveReqs = [];
// b_module 主表
// s_module 主表
const moduleData = drafts.value.module;
const moduleOriginal = originals.value.module;
if (moduleData && JSON.stringify(moduleData) !== JSON.stringify(moduleOriginal)) {
const isNew = !moduleOriginal?.b_code;
saveReqs.push({
table: "b_module",
table: "s_module",
key_field: "b_id",
[isNew ? "inserts" : "updates"]: [moduleData],
});
@@ -348,11 +365,11 @@ async function saveAll() {
// 子表:fields, listConfig, editConfig, queryConfig, powers
const subTables = [
["b_module_field", "fields"],
["b_module_field_list", "listConfig"],
["b_module_field_edit", "editConfig"],
["b_module_field_query", "queryConfig"],
["b_module_power", "powers"],
["s_module_field", "fields"],
["s_module_field_list", "listConfig"],
["s_module_field_edit", "editConfig"],
["s_module_field_query", "queryConfig"],
["s_module_power", "powers"],
];
for (const [table, section] of subTables) {
const current = drafts.value[section] || [];
@@ -366,8 +383,9 @@ async function saveAll() {
}
// 多语言:由各子面板收集变更,统一在此保存
if (i18nPanelRef.value) {
const i18nReq = await i18nPanelRef.value.buildSaveRequest();
const i18nPanel = getI18nPanel();
if (i18nPanel) {
const i18nReq = await i18nPanel.buildSaveRequest();
if (i18nReq) saveReqs.push(i18nReq);
}
@@ -380,7 +398,7 @@ async function saveAll() {
// 更新 originals
originals.value = cloneData(drafts.value);
i18nPanelRef.value?.markSaved();
getI18nPanel()?.markSaved();
message.success("保存成功");
await loadTree();
} catch (e) {
@@ -390,7 +408,7 @@ async function saveAll() {
}
}
// ---- 字段同步 ----
// ---- 字段同步与默认配置 ----
function typeFromSqlType(sqlType) {
if (!sqlType) return "input";
const t = sqlType.toLowerCase();
@@ -419,16 +437,109 @@ function typeFromSqlType(sqlType) {
return "input";
}
async function syncFields() {
function guessFieldWidth(fieldName) {
const n = (fieldName || "").toLowerCase();
if (n === "b_id" || n.endsWith("_id")) return 100;
if (n.includes("time") || n.includes("date") || n.includes("_at")) return 160;
if (n.includes("name")) return 150;
if (n.includes("code") || n.includes("type")) return 120;
if (n.includes("ip")) return 140;
if (n.includes("status") || n.includes("state") || n.includes("result")) return 90;
return 120;
}
function guessQueryComponent(b_type) {
const t = (b_type || "").toLowerCase();
if (t === "date" || t === "datetime") return "date-picker";
if (t === "select" || t === "switch") return "select";
if (t === "number" || t === "money") return "number-range";
return "input";
}
function guessQueryOperator(b_type) {
const t = (b_type || "").toLowerCase();
if (t === "datetime" || t === "number" || t === "money") return "between";
return "=";
}
function configuredFieldIds(section) {
return new Set(
[...(originals.value[section] || []), ...(drafts.value[section] || [])]
.map((row) => row.b_field_id)
.filter((id) => id != null)
.map(String),
);
}
function buildMissingDefaultConfigs(module, fields) {
const moduleId = String(module.b_id);
const listFieldIds = configuredFieldIds("listConfig");
const editFieldIds = configuredFieldIds("editConfig");
const queryFieldIds = configuredFieldIds("queryConfig");
const generated = { listConfig: [], editConfig: [], queryConfig: [] };
fields
.filter((field) => field.b_canuse === 1)
.forEach((field, index) => {
const fieldId = String(field.b_id);
const order = (index + 1) * 10;
if (!listFieldIds.has(fieldId)) {
generated.listConfig.push({
b_id: createTempId(),
b_module_id: moduleId,
b_field_id: fieldId,
b_group_id: null,
b_title: null,
b_i18n: field.b_i18n || null,
b_visible: 1,
b_xh: order,
b_width: guessFieldWidth(field.b_field),
b_sortable: 0,
});
}
if (!editFieldIds.has(fieldId)) {
generated.editConfig.push({
b_id: createTempId(),
b_module_id: moduleId,
b_field_id: fieldId,
b_required: 0,
b_readonly: 0,
b_disabled: 0,
b_group_id: null,
b_xh: order,
});
}
if (!queryFieldIds.has(fieldId)) {
generated.queryConfig.push({
b_id: createTempId(),
b_module_id: moduleId,
b_field_id: fieldId,
b_component: guessQueryComponent(field.b_type),
b_operator: guessQueryOperator(field.b_type),
b_default_value: null,
b_xh: order,
b_canuse: 1,
});
}
});
return generated;
}
async function syncFields(tableName) {
const module = selectedModule.value;
if (!module?.b_viewtable) {
message.warning("当前模块未配置查询视图");
const view = tableName || module?.b_viewtable;
if (!view) {
message.warning("请先配置查询视图或输入表名");
return;
}
try {
configSaving.value = true;
const res = await describeApi(module.b_viewtable);
const res = await describeApi(view);
const columns = res.data || [];
const existingFields = originals.value.fields || [];
@@ -469,27 +580,55 @@ async function syncFields() {
}
}
// 保存到 b_module_field
// 字段多语言键只补空值,保留已有人工配置。
mergedFields.forEach((field) => {
if (!field.b_i18n) field.b_i18n = `field.${field.b_field}`;
});
// 先为新字段分配真实 ID,后续配置通过外键引用这些 ID。
const changes = diffRows(mergedFields, existingFields);
const req = tableChange("b_module_field", changes);
if (hasChanges(req)) {
await allocateTemporaryIds([req.inserts || [], req.updates || [], req.deletes || []]);
await saveObjectApi([req]);
message.success(
`字段同步完成:${columns.length} 列,新增 ${req.inserts?.length || 0},停用 ${req.deletes?.length || 0}`,
);
} else {
message.info("字段已是最新,无需同步");
const fieldReq = tableChange("s_module_field", changes);
await allocateTemporaryIds([
fieldReq.inserts || [],
fieldReq.updates || [],
fieldReq.deletes || [],
]);
const generated = buildMissingDefaultConfigs(module, mergedFields);
const generatedSections = ["listConfig", "editConfig", "queryConfig"];
const generatedCount = generatedSections.reduce(
(total, section) => total + generated[section].length,
0,
);
if (!hasChanges(fieldReq) && !generatedCount) {
message.info("字段和默认配置已是最新,无需同步");
return;
}
// 重新加载
const fieldsRes = await loadDataApi(
"b_module_field",
`b_module_id = ${module.b_id}`,
"b_id ASC",
if (hasChanges(fieldReq)) {
// 字段先保存以取得真实 ID,自动生成的配置留在草稿中由顶部“保存”统一提交。
await saveObjectApi([fieldReq]);
const fieldsRes = await loadDataApi(
"s_module_field",
`b_module_id = ${module.b_id}`,
"b_id ASC",
);
originals.value.fields = fieldsRes.data || [];
drafts.value.fields = cloneData(originals.value.fields);
}
for (const section of generatedSections) {
if (!generated[section].length) continue;
drafts.value[section] = [...(drafts.value[section] || []), ...cloneData(generated[section])];
}
const disabledCount = existingFields.filter(
(field) => field.b_canuse === 1 && !viewColumnSet.has(field.b_field?.toLowerCase()),
).length;
message.success(
`字段同步完成:${columns.length} 列,新增 ${fieldReq.inserts?.length || 0},停用 ${disabledCount};自动生成列表 ${generated.listConfig.length}、编辑 ${generated.editConfig.length}、查询 ${generated.queryConfig.length} 项配置${generatedCount ? ",请点击保存" : ""}`,
);
originals.value.fields = fieldsRes.data || [];
drafts.value.fields = cloneData(originals.value.fields);
} catch (e) {
message.error("字段同步失败:" + (e.message || e));
} finally {
@@ -498,7 +637,7 @@ async function syncFields() {
}
// ---- 初始化 ----
loadTree();
void loadTree().catch((e) => message.error("模块树加载失败:" + (e.message || e)));
</script>
<template>
@@ -549,13 +688,14 @@ loadTree();
<span>启用</span>
<a-switch
:checked="drafts.module?.b_canuse === 1"
:disabled="configSaving"
@update:checked="(v) => onSetField('b_canuse', v ? 1 : 0)"
/>
<a-divider type="vertical" />
<span>显示菜单</span>
<a-switch
:checked="drafts.module?.b_canmenu === 1"
:disabled="drafts.module?.b_module_type === 'data'"
:disabled="configSaving || drafts.module?.b_module_type === 'data'"
@update:checked="(v) => onSetField('b_canmenu', v ? 1 : 0)"
/>
</a-space>
@@ -0,0 +1,7 @@
<script setup>
import FmsModuleListPage from "@/components/fms-module-list/FmsModuleListPage.vue";
</script>
<template>
<FmsModuleListPage module-code="d_login_log" />
</template>