20260728173026
This commit is contained in:
1 parent
63588bd217
commit
5ec7aadef9
25 files changed
+627
-462
No files matched your search
@@ -12,6 +12,7 @@ import {
|
||||
hasChanges,
|
||||
} from "@/utils/dataChanges";
|
||||
import { createTempId } from "@/utils/tempId";
|
||||
import { buildTree } from "@/utils/tree";
|
||||
|
||||
const props = defineProps({
|
||||
visible: { type: Boolean, default: false },
|
||||
@@ -33,35 +34,15 @@ watch(
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
const treeData = computed(() => {
|
||||
return buildTreeData(localGroups.value);
|
||||
});
|
||||
|
||||
function buildTreeData(items) {
|
||||
const nodes = new Map();
|
||||
const roots = [];
|
||||
|
||||
for (const item of items) {
|
||||
const key = String(item.b_id);
|
||||
nodes.set(key, {
|
||||
key,
|
||||
const treeData = computed(() =>
|
||||
buildTree(localGroups.value, {
|
||||
mapNode: (item) => ({
|
||||
key: String(item.b_id),
|
||||
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(node);
|
||||
}
|
||||
}
|
||||
|
||||
return roots;
|
||||
}
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
const selectedGroup = computed(() =>
|
||||
localGroups.value.find((g) => String(g.b_id) === selectedGroupId.value),
|
||||
@@ -151,7 +132,6 @@ function onClose() {
|
||||
v-if="treeData.length > 0"
|
||||
class="group-tree"
|
||||
:tree-data="treeData"
|
||||
:field-names="{ title: 'title', key: 'key', children: 'children' }"
|
||||
:selected-keys="selectedGroupId ? [selectedGroupId] : []"
|
||||
@select="onTreeSelect"
|
||||
>
|
||||
|
||||
@@ -16,7 +16,11 @@ const langTypes = ref([]);
|
||||
|
||||
async function loadLangTypes() {
|
||||
try {
|
||||
const res = await loadDataApi("b_i18n_type", "b_canuse = 1", "b_xh ASC, b_id ASC");
|
||||
const res = await loadDataApi(
|
||||
"b_i18n_type",
|
||||
"b_canuse = 1",
|
||||
"b_default DESC, b_xh ASC, b_id ASC",
|
||||
);
|
||||
langTypes.value = res.data || [];
|
||||
} catch (e) {
|
||||
message.error("加载语言类型失败:" + (e.message || e));
|
||||
|
||||
@@ -3,6 +3,7 @@ import { computed, nextTick, ref, watch } from "vue";
|
||||
import { message, Modal } from "antdv-next";
|
||||
import FmsTable from "@/components/fms-table/FmsTable.vue";
|
||||
import { loadDataApi } from "@/services/api";
|
||||
import { consolidatePivotRows, mergeI18nDefaults } from "@/utils/i18n";
|
||||
import {
|
||||
cloneData,
|
||||
diffRows,
|
||||
@@ -83,17 +84,25 @@ function reconcileManagedRows(itemsOverride) {
|
||||
}
|
||||
|
||||
// 已从模块配置中移除的条目不再受保护,保留其翻译供管理员自行处理。
|
||||
// 同步字段注入的草稿行带 _syncInjected(即使尚未保存、originals 里还没有该字段),
|
||||
// 也不能在此处被剥离保护,否则重命名字段时会因找不到受管行而新增重复键。
|
||||
for (const row of rows) {
|
||||
if (!row._managedId || currentManagedIds.has(String(row._managedId))) continue;
|
||||
if (!row._managedId || row._syncInjected || currentManagedIds.has(String(row._managedId)))
|
||||
continue;
|
||||
delete row._managedId;
|
||||
delete row._defaultValue;
|
||||
}
|
||||
pivotRows.value = rows;
|
||||
// 合并同 b_key 的重复行(如同步注入行与受管行未命中时产生),保证一个键仅一行。
|
||||
pivotRows.value = consolidatePivotRows(rows);
|
||||
}
|
||||
|
||||
async function loadLangTypes() {
|
||||
try {
|
||||
const res = await loadDataApi("b_i18n_type", "b_canuse = 1", "b_xh ASC, b_id ASC");
|
||||
const res = await loadDataApi(
|
||||
"b_i18n_type",
|
||||
"b_canuse = 1",
|
||||
"b_default DESC, b_xh ASC, b_id ASC",
|
||||
);
|
||||
langOptions.value = (res.data || []).map((t) => ({
|
||||
label: `${t.b_name} (${t.b_code})`,
|
||||
value: t.b_code,
|
||||
@@ -248,34 +257,76 @@ function flatten(rows) {
|
||||
id = createTempId();
|
||||
row._localeIds[locale] = id;
|
||||
}
|
||||
flat.push({ b_id: id, b_key: row.b_key, b_locale: locale, b_value: val });
|
||||
flat.push({
|
||||
b_id: id,
|
||||
b_key: row.b_key,
|
||||
b_locale: locale,
|
||||
b_value: val,
|
||||
b_module_id: props.moduleId,
|
||||
});
|
||||
}
|
||||
}
|
||||
return flat;
|
||||
}
|
||||
|
||||
// 由父级「同步字段」调用:把默认翻译条目({ key, locale, value })并入草稿行。
|
||||
// 仅当面板中尚不存在该 b_key 时才追加,避免覆盖用户在面板里已配置的翻译。
|
||||
// 由父级「同步字段」调用:把默认翻译条目({ key, locale, value, managedId })并入草稿行。
|
||||
// 已存在同 b_key 的行只补全空值(不覆盖用户配置),避免新增重复键;逻辑见 utils/i18n.mergeI18nDefaults。
|
||||
function mergeDefaults(entries) {
|
||||
if (!entries?.length) return;
|
||||
const existingKeys = new Set(pivotRows.value.map((r) => r.b_key));
|
||||
const toAdd = entries.filter((e) => e.key && !existingKeys.has(e.key));
|
||||
if (!toAdd.length) return;
|
||||
pivotRows.value = [
|
||||
...pivotRows.value,
|
||||
...toAdd.map((e) => ({
|
||||
_id: createTempId(),
|
||||
b_key: e.key,
|
||||
_localeIds: {},
|
||||
[e.locale]: e.value,
|
||||
})),
|
||||
];
|
||||
pivotRows.value = mergeI18nDefaults(pivotRows.value, entries);
|
||||
}
|
||||
|
||||
// 按 (b_key, b_locale) 拉取库里已存在的行,键为 "key|locale"
|
||||
async function loadExistingByKeyLocale(keys) {
|
||||
const quoted = keys.map((k) => "'" + String(k).replace(/'/g, "''") + "'");
|
||||
const where = "b_key IN (" + quoted.join(", ") + ")";
|
||||
const res = await loadDataApi("b_i18n", where, "b_key ASC, b_locale ASC");
|
||||
const map = new Map();
|
||||
for (const r of res.data || []) {
|
||||
map.set(r.b_key + "|" + r.b_locale, {
|
||||
b_id: r.b_id,
|
||||
b_key: r.b_key,
|
||||
b_locale: r.b_locale,
|
||||
b_value: r.b_value,
|
||||
});
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
// 由父级统一保存调用:返回 b_i18n 的变更请求(无变更返回 null),保存成功后父级调用 markSaved 同步基线
|
||||
async function buildSaveRequest() {
|
||||
const flat = flatten(pivotRows.value);
|
||||
const changes = diffRows(flat, originalsFlat.value);
|
||||
// 保存涉及的键(受管键可能在新建/改名模块时才生成,打开面板时未被加载)
|
||||
const keys = [...new Set(pivotRows.value.map((r) => String(r.b_key)).filter(Boolean))];
|
||||
if (keys.length) {
|
||||
const existing = await loadExistingByKeyLocale(keys);
|
||||
if (existing.size) {
|
||||
// 用库里已存在的真实 b_id 回填对应语言格,避免把已存在的 (b_key, b_locale)
|
||||
// 当成新增行,触发 UQ_b_i18n 唯一键冲突(如重名重建/孤儿翻译)。
|
||||
for (const row of pivotRows.value) {
|
||||
for (const opt of langOptions.value) {
|
||||
const hit = existing.get(row.b_key + "|" + opt.value);
|
||||
if (hit) row._localeIds[opt.value] = String(hit.b_id);
|
||||
}
|
||||
}
|
||||
// 将库里已存在的行并入基线,使 diffRows 走“更新/不变”而非“新增”
|
||||
const merged = [...originalsFlat.value];
|
||||
for (const hit of existing.values()) {
|
||||
const idx = merged.findIndex((o) => String(o.b_id) === String(hit.b_id));
|
||||
const rec = {
|
||||
b_id: String(hit.b_id),
|
||||
b_key: hit.b_key,
|
||||
b_locale: hit.b_locale,
|
||||
b_value: hit.b_value,
|
||||
};
|
||||
if (idx >= 0) merged[idx] = rec;
|
||||
else merged.push(rec);
|
||||
}
|
||||
originalsFlat.value = merged;
|
||||
}
|
||||
}
|
||||
|
||||
const current = flatten(pivotRows.value);
|
||||
const changes = diffRows(current, originalsFlat.value);
|
||||
const req = tableChange("b_i18n", changes);
|
||||
if (!hasChanges(req)) return null;
|
||||
await allocateTemporaryIds([req.inserts || [], req.updates || [], req.deletes || []]);
|
||||
@@ -310,6 +361,8 @@ const columns = computed(() => {
|
||||
dataIndex: "b_key",
|
||||
title: "键",
|
||||
width: 240,
|
||||
align: "left",
|
||||
headerAlign: "left",
|
||||
editor: { type: "input", disabled: (row) => isManagedRow(row) },
|
||||
},
|
||||
];
|
||||
@@ -318,6 +371,8 @@ const columns = computed(() => {
|
||||
dataIndex: opt.value,
|
||||
title: opt.label,
|
||||
width: 180,
|
||||
align: "left",
|
||||
headerAlign: "left",
|
||||
editor: { type: "input" },
|
||||
});
|
||||
}
|
||||
@@ -347,7 +402,6 @@ const columns = computed(() => {
|
||||
<a-input-search
|
||||
v-model:value="searchKey"
|
||||
:disabled="loading || isLoading"
|
||||
placeholder="搜索键"
|
||||
style="width: 200px"
|
||||
allow-clear
|
||||
/>
|
||||
|
||||
@@ -8,6 +8,7 @@ const COMPONENT_OPTIONS = [
|
||||
{ label: "下拉选择", value: "select" },
|
||||
{ label: "日期选择", value: "date-picker" },
|
||||
{ label: "数字范围", value: "number-range" },
|
||||
{ label: "单选框组", value: "radio-group" },
|
||||
];
|
||||
|
||||
const OPERATOR_OPTIONS = [
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
hasChanges,
|
||||
} from "@/utils/dataChanges";
|
||||
import { createTempId } from "@/utils/tempId";
|
||||
import { buildTree, sortTree } from "@/utils/tree";
|
||||
import {
|
||||
buildModuleI18nKey,
|
||||
buildModuleItemI18nKey,
|
||||
@@ -35,10 +36,7 @@ const expandedTreeKeys = ref([]);
|
||||
const selectedModuleId = ref(null);
|
||||
const skipLoad = ref(false);
|
||||
const activeTab = ref("basic");
|
||||
const contextNode = ref(null);
|
||||
|
||||
const contextMenuItems = computed(() => {
|
||||
const node = contextNode.value;
|
||||
function buildTreeContextMenu(node) {
|
||||
const items = [];
|
||||
if (!node) return { items };
|
||||
if (node.b_module_type === "directory") {
|
||||
@@ -52,8 +50,8 @@ const contextMenuItems = computed(() => {
|
||||
}
|
||||
if (items.length) items.push({ type: "divider" });
|
||||
items.push({ label: "删除", key: "delete", danger: true });
|
||||
return { items, onClick: onContextMenuClick };
|
||||
});
|
||||
return { items };
|
||||
}
|
||||
|
||||
// 原始数据(加载时保存,用于 diff)
|
||||
const originals = ref({
|
||||
@@ -203,35 +201,15 @@ const visibleTabs = computed(() => {
|
||||
});
|
||||
|
||||
// ---- 模块树 ----
|
||||
function buildTree(modules) {
|
||||
const map = new Map();
|
||||
const roots = [];
|
||||
function buildModuleTree(modules) {
|
||||
modules.forEach((m) => {
|
||||
m.key = String(m.b_id);
|
||||
m.title = m.b_name || m.b_code;
|
||||
m.b_parent_id = m.b_parent_id ? String(m.b_parent_id) : null;
|
||||
map.set(m.key, m);
|
||||
m.b_parent_id = m.b_parent_id != null ? String(m.b_parent_id) : null;
|
||||
});
|
||||
modules.forEach((m) => {
|
||||
if (m.b_parent_id && map.has(m.b_parent_id)) {
|
||||
const parent = map.get(m.b_parent_id);
|
||||
if (!parent.children) parent.children = [];
|
||||
parent.children.push(m);
|
||||
} else {
|
||||
roots.push(m);
|
||||
}
|
||||
});
|
||||
return roots;
|
||||
}
|
||||
|
||||
function sortTree(nodes) {
|
||||
nodes.sort(
|
||||
(a, b) =>
|
||||
Number(a.b_xh || 0) - Number(b.b_xh || 0) || String(a.b_id).localeCompare(String(b.b_id)),
|
||||
);
|
||||
for (const node of nodes) {
|
||||
if (node.children?.length) sortTree(node.children);
|
||||
}
|
||||
const tree = buildTree(modules);
|
||||
sortTree(tree);
|
||||
return tree;
|
||||
}
|
||||
|
||||
// 普通保存后直接更新现有节点,避免重新请求并重建整棵树导致展开状态丢失。
|
||||
@@ -260,7 +238,7 @@ async function loadTree() {
|
||||
treeLoading.value = true;
|
||||
try {
|
||||
const res = await loadDataApi("s_module", "b_canuse = 1", "b_xh ASC, b_id ASC");
|
||||
treeData.value = buildTree(res.data || []);
|
||||
treeData.value = buildModuleTree(res.data || []);
|
||||
} finally {
|
||||
treeLoading.value = false;
|
||||
}
|
||||
@@ -427,16 +405,8 @@ const addModuleMenu = {
|
||||
};
|
||||
|
||||
// ---- 右键菜单 ----
|
||||
function onTreeRightClick({ node }) {
|
||||
contextNode.value = node;
|
||||
}
|
||||
|
||||
function onContextMenuClick({ key }) {
|
||||
function onTreeMenuClick({ key, node }) {
|
||||
if (busy.value) return;
|
||||
const node = contextNode.value;
|
||||
contextNode.value = null;
|
||||
if (!node) return;
|
||||
|
||||
if (key.startsWith("add-")) {
|
||||
onAddModule(node.key, key.replace("add-", ""));
|
||||
} else if (key === "delete") {
|
||||
@@ -500,6 +470,7 @@ async function onDeleteNodeById(moduleId) {
|
||||
deleteByModuleId("s_module_power"),
|
||||
deleteByModuleId("s_module_field"),
|
||||
deleteByModuleId("s_module_field_group"),
|
||||
deleteByModuleId("b_i18n"),
|
||||
{ table: "s_module", key_field: "b_id", deletes: [{ b_id: id }] },
|
||||
]);
|
||||
message.success("模块已删除");
|
||||
@@ -619,59 +590,6 @@ async function saveAll() {
|
||||
}
|
||||
|
||||
// ---- 字段同步与默认配置 ----
|
||||
function typeFromSqlType(sqlType) {
|
||||
if (!sqlType) return "input";
|
||||
const t = sqlType.toLowerCase();
|
||||
if (
|
||||
t.includes("char") ||
|
||||
t.includes("text") ||
|
||||
t.includes("nchar") ||
|
||||
t.includes("ntext") ||
|
||||
t.includes("xml")
|
||||
) {
|
||||
if (t.includes("text") || t.includes("ntext") || t.includes("xml")) return "textarea";
|
||||
return "input";
|
||||
}
|
||||
if (t.includes("int") || t.includes("float") || t.includes("double") || t.includes("real"))
|
||||
return "number";
|
||||
if (t.includes("decimal") || t.includes("numeric") || t.includes("money")) {
|
||||
if (t.includes("money")) return "money";
|
||||
return "number";
|
||||
}
|
||||
if (t.includes("date") || t.includes("time")) {
|
||||
if (t.includes("date") && (t.includes("time") || t.includes("smalldatetime")))
|
||||
return "datetime";
|
||||
return "date";
|
||||
}
|
||||
if (t.includes("bit")) return "switch";
|
||||
return "input";
|
||||
}
|
||||
|
||||
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] || [])]
|
||||
@@ -704,7 +622,7 @@ function buildMissingDefaultConfigs(module, fields) {
|
||||
b_i18n: field.b_i18n || null,
|
||||
b_visible: 1,
|
||||
b_xh: order,
|
||||
b_width: guessFieldWidth(field.b_field),
|
||||
b_width: 100,
|
||||
b_sortable: 0,
|
||||
});
|
||||
}
|
||||
@@ -727,8 +645,8 @@ function buildMissingDefaultConfigs(module, fields) {
|
||||
b_id: createTempId(),
|
||||
b_module_id: moduleId,
|
||||
b_field_id: fieldId,
|
||||
b_component: guessQueryComponent(field.b_type),
|
||||
b_operator: guessQueryOperator(field.b_type),
|
||||
b_component: "input",
|
||||
b_operator: "=",
|
||||
b_default_value: null,
|
||||
b_xh: order,
|
||||
b_canuse: 1,
|
||||
@@ -776,7 +694,7 @@ async function syncFields() {
|
||||
b_field: col.name,
|
||||
b_name: col.name,
|
||||
b_i18n: null,
|
||||
b_type: typeFromSqlType(col.type),
|
||||
b_type: "input",
|
||||
b_default_value: null,
|
||||
b_options: null,
|
||||
b_canuse: 1,
|
||||
@@ -809,7 +727,11 @@ async function syncFields() {
|
||||
// 由顶部"保存"统一落库;已存在的键由面板去重,不覆盖用户配置。
|
||||
const candidateI18n = mergedFields
|
||||
.filter((f) => f.b_canuse === 1 && f.b_i18n)
|
||||
.map((f) => ({ key: f.b_i18n, value: f.b_name || f.b_field }));
|
||||
.map((f) => ({
|
||||
key: f.b_i18n,
|
||||
value: f.b_name || f.b_field,
|
||||
managedId: `field:${f.b_id}`,
|
||||
}));
|
||||
let primaryLocale = "zh-CN";
|
||||
try {
|
||||
const langRes = await loadDataApi("b_i18n_type", "b_canuse = 1", "b_xh ASC, b_id ASC");
|
||||
@@ -826,6 +748,7 @@ async function syncFields() {
|
||||
key: e.key,
|
||||
locale: primaryLocale,
|
||||
value: e.value,
|
||||
managedId: e.managedId,
|
||||
}));
|
||||
|
||||
const generated = buildMissingDefaultConfigs(module, mergedFields);
|
||||
@@ -881,26 +804,24 @@ void loadTree().catch((e) => message.error("模块树加载失败:" + (e.messa
|
||||
<div v-else-if="treeData.length === 0" class="mm-tree-empty">
|
||||
<a-empty description="暂无模块" />
|
||||
</div>
|
||||
<a-dropdown v-else :trigger="['contextmenu']" :menu="contextMenuItems">
|
||||
<div class="mm-tree-container">
|
||||
<FmsTree
|
||||
class="mm-tree"
|
||||
:disabled="configSaving"
|
||||
:tree-data="treeData"
|
||||
v-model:expanded-keys="expandedTreeKeys"
|
||||
:selected-keys="selectedModuleId ? [selectedModuleId] : []"
|
||||
:field-names="{ title: 'title', key: 'key', children: 'children' }"
|
||||
@select="onTreeSelect"
|
||||
@right-click="onTreeRightClick"
|
||||
>
|
||||
<template #title="node">
|
||||
<span class="mm-tree-node">
|
||||
<span>{{ node.title }}</span>
|
||||
</span>
|
||||
</template>
|
||||
</FmsTree>
|
||||
</div>
|
||||
</a-dropdown>
|
||||
<FmsTree
|
||||
v-else
|
||||
class="mm-tree"
|
||||
:disabled="configSaving"
|
||||
:tree-data="treeData"
|
||||
v-model:expanded-keys="expandedTreeKeys"
|
||||
:selected-keys="selectedModuleId ? [selectedModuleId] : []"
|
||||
:context-menu="buildTreeContextMenu"
|
||||
:context-menu-disabled="busy"
|
||||
@select="onTreeSelect"
|
||||
@menu-click="onTreeMenuClick"
|
||||
>
|
||||
<template #title="node">
|
||||
<span class="mm-tree-node">
|
||||
<span>{{ node.title }}</span>
|
||||
</span>
|
||||
</template>
|
||||
</FmsTree>
|
||||
</aside>
|
||||
|
||||
<!-- 右侧详情 -->
|
||||
@@ -1072,14 +993,6 @@ void loadTree().catch((e) => message.error("模块树加载失败:" + (e.messa
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
.mm-tree-container {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.mm-tree-empty {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
|
||||
Reference in new issue
Block a user