20260728173026

This commit is contained in:
oneao committed 2026-07-28 17:30:27 +08:00
1 parent 63588bd217
commit 5ec7aadef9
25 files changed
+627 -462

No files matched your search

+57 -2
View File
@@ -1,16 +1,71 @@
<script setup>
import { computed, ref } from "vue";
defineOptions({ inheritAttrs: false });
defineProps({
const props = defineProps({
blockNode: { type: Boolean, default: true },
virtual: { type: Boolean, default: true },
// 右键菜单生成函数:(node) => { items: [...] }
contextMenu: { type: Function, default: null },
contextMenuDisabled: { type: Boolean, default: false },
});
const emit = defineEmits(["menuClick"]);
const contextNode = ref(null);
function onTreeRightClick({ node }) {
contextNode.value = node;
}
const contextMenuItems = computed(() => {
if (!props.contextMenu || !contextNode.value) return undefined;
const result = props.contextMenu(contextNode.value);
if (!result) return undefined;
return { ...result, onClick: onMenuClick };
});
function onMenuClick({ key }) {
const node = contextNode.value;
contextNode.value = null;
if (node) emit("menuClick", { key, node });
}
</script>
<template>
<a-tree v-bind="$attrs" :block-node="blockNode" :virtual="virtual">
<a-dropdown
v-if="contextMenu"
:trigger="['contextmenu']"
:menu="contextMenuItems"
:disabled="contextMenuDisabled"
>
<div class="fms-tree-wrapper">
<a-tree
v-bind="$attrs"
:block-node="blockNode"
:virtual="virtual"
@right-click="onTreeRightClick"
>
<template v-for="(_, name) in $slots" :key="name" #[name]="slotProps">
<slot :name="name" v-bind="slotProps || {}" />
</template>
</a-tree>
</div>
</a-dropdown>
<a-tree v-else v-bind="$attrs" :block-node="blockNode" :virtual="virtual">
<template v-for="(_, name) in $slots" :key="name" #[name]="slotProps">
<slot :name="name" v-bind="slotProps || {}" />
</template>
</a-tree>
</template>
<style scoped lang="scss">
.fms-tree-wrapper {
display: flex;
flex: 1;
min-height: 0;
overflow: hidden;
flex-direction: column;
}
</style>
@@ -1,6 +1,7 @@
<script setup>
import { computed, h, ref, shallowRef, useSlots, watch } from "vue";
import FmsTable from "@/components/fms-table/FmsTable.vue";
import { formatDateValue, parseOptions } from "./moduleListUtils";
const props = defineProps({
fields: { type: Array, default: () => [] },
@@ -31,52 +32,14 @@ const expansionOptions = computed(() =>
);
const expandConfig = computed(() => ({ height: Number(expansionOptions.value.height) || 180 }));
function parseOptions(field) {
if (!field?.b_options) return { config: {}, options: [] };
try {
const parsed = JSON.parse(field.b_options);
const config = Array.isArray(parsed) ? {} : parsed || {};
const source = Array.isArray(parsed)
? parsed
: Array.isArray(parsed?.options)
? parsed.options
: Array.isArray(parsed?.items)
? parsed.items
: [];
const options = source.map((option) => {
if (option && typeof option === "object") {
const value = option.value ?? option.key ?? option.id;
return { ...option, value, label: String(option.label ?? option.name ?? value ?? "") };
}
return { value: option, label: String(option ?? "") };
});
return { config, options };
} catch {
return { config: {}, options: [] };
}
}
function formatDateValue(value, withTime) {
if (value === null || value === undefined || value === "") return "-";
const text = String(value).replace("T", " ");
if (/^\d{4}-\d{2}-\d{2}/.test(text)) return withTime ? text.slice(0, 19) : text.slice(0, 10);
const date = new Date(value);
if (Number.isNaN(date.getTime())) return text;
const pad = (number) => String(number).padStart(2, "0");
const datePart = `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
return withTime
? `${datePart} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
: datePart;
}
function formatCellValue(value, field, optionInfo) {
if (value === null || value === undefined || value === "") return "-";
if (field.b_type === "date") return formatDateValue(value, false);
if (field.b_type === "datetime") return formatDateValue(value, true);
if (field.b_type === "switch") {
return value === true || Number(value) === 1
? (optionInfo.config.trueLabel ?? "是")
: (optionInfo.config.falseLabel ?? "否");
? (optionInfo.optionConfig.trueLabel ?? "是")
: (optionInfo.optionConfig.falseLabel ?? "否");
}
if (field.b_type === "select") {
const option = optionInfo.options.find((item) => String(item.value) === String(value));
@@ -85,8 +48,8 @@ function formatCellValue(value, field, optionInfo) {
if (field.b_type === "money") {
const number = Number(value);
if (!Number.isFinite(number)) return String(value);
const precision = Number.isInteger(optionInfo.config.precision)
? optionInfo.config.precision
const precision = Number.isInteger(optionInfo.optionConfig.precision)
? optionInfo.optionConfig.precision
: 2;
return number.toLocaleString("zh-CN", {
minimumFractionDigits: precision,
@@ -1,5 +1,6 @@
<script setup>
import { computed } from "vue";
import { isDateQuery, isEmptyValue, isNumericField, isRangeQuery } from "./moduleListUtils";
const props = defineProps({
item: { type: Object, required: true },
@@ -9,21 +10,9 @@ const props = defineProps({
const emit = defineEmits(["update:modelValue", "enter"]);
const isRange = computed(
() =>
props.item.config.b_operator === "between" ||
props.item.config.b_component === "number-range" ||
props.item.field.b_type === "datetime",
);
const isNumeric = computed(
() => props.item.field.b_type === "number" || props.item.field.b_type === "money",
);
const isDate = computed(
() =>
props.item.config.b_component === "date-picker" ||
props.item.field.b_type === "date" ||
props.item.field.b_type === "datetime",
);
const isRange = computed(() => isRangeQuery(props.item));
const isNumeric = computed(() => isNumericField(props.item));
const isDate = computed(() => isDateQuery(props.item));
const value = computed({
get: () => props.modelValue,
set: (nextValue) => emit("update:modelValue", nextValue),
@@ -45,10 +34,6 @@ const endValue = computed({
},
});
function isEmptyValue(current) {
return current === undefined || current === null || current === "";
}
function comparableDate(current) {
if (isEmptyValue(current)) return null;
if (current && typeof current.format === "function") {
@@ -2,6 +2,13 @@
import { computed, reactive, ref, watch } from "vue";
import { ChevronDown, ChevronUp, Ellipsis, RotateCcw, Search } from "@lucide/vue";
import FmsQueryControl from "./FmsQueryControl.vue";
import {
defaultQueryValue,
emptyQueryValue,
isDateQuery,
isNumericField,
isRangeQuery,
} from "./moduleListUtils";
const props = defineProps({
queryItems: { type: Array, default: () => [] },
@@ -35,55 +42,6 @@ const visibleAdvancedQueryItems = computed(() =>
advancedMoreOpen.value ? props.queryItems : props.queryItems.slice(0, props.defaultAdvancedCount),
);
function isRangeQuery(item) {
return (
item.config.b_operator === "between" ||
item.config.b_component === "number-range" ||
item.field.b_type === "datetime"
);
}
function isNumericField(item) {
return item.field.b_type === "number" || item.field.b_type === "money";
}
function isDateQuery(item) {
return (
item.config.b_component === "date-picker" ||
item.field.b_type === "date" ||
item.field.b_type === "datetime"
);
}
function emptyQueryValue(item) {
if (isRangeQuery(item) || item.config.b_operator === "in") return [];
return undefined;
}
function defaultQueryValue(item) {
const configuredValue = item.config.b_default_value;
if (configuredValue === null || configuredValue === undefined || configuredValue === "") {
return emptyQueryValue(item);
}
if (isRangeQuery(item) || item.config.b_operator === "in") {
try {
const parsed = JSON.parse(configuredValue);
if (Array.isArray(parsed)) return parsed;
} catch {
// 非 JSON 默认值继续按逗号分隔处理。
}
return String(configuredValue)
.split(",")
.map((part) => part.trim())
.filter(Boolean);
}
if (isNumericField(item)) {
const number = Number(configuredValue);
return Number.isFinite(number) ? number : undefined;
}
return configuredValue;
}
function cloneQueryValue(current) {
return Array.isArray(current) ? [...current] : current;
}
@@ -0,0 +1,105 @@
// 数据驱动列表的共享工具函数,避免在各组件间重复实现查询/格式化逻辑。
export const IDENTIFIER_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
export function escapeSqlText(value) {
return String(value).replaceAll("'", "''");
}
export function requireModuleId(value) {
const id = String(value ?? "");
if (!/^\d+$/.test(id)) throw new Error("模块 ID 格式不正确");
return id;
}
export function isEmptyValue(value) {
return value === undefined || value === null || value === "";
}
export function parseOptions(field) {
if (!field?.b_options) return { optionConfig: {}, options: [] };
try {
const parsed = JSON.parse(field.b_options);
const optionConfig = Array.isArray(parsed) ? {} : parsed || {};
const source = Array.isArray(parsed)
? parsed
: Array.isArray(parsed?.options)
? parsed.options
: Array.isArray(parsed?.items)
? parsed.items
: [];
const options = source.map((option) => {
if (option && typeof option === "object") {
const value = option.value ?? option.key ?? option.id;
return { ...option, value, label: String(option.label ?? option.name ?? value ?? "") };
}
return { value: option, label: String(option ?? "") };
});
return { optionConfig, options };
} catch {
return { optionConfig: {}, options: [] };
}
}
export function isRangeQuery(item) {
return (
item.config.b_operator === "between" ||
item.config.b_component === "number-range" ||
item.field.b_type === "datetime"
);
}
export function isNumericField(item) {
return item.field.b_type === "number" || item.field.b_type === "money";
}
export function isDateQuery(item) {
return (
item.config.b_component === "date-picker" ||
item.field.b_type === "date" ||
item.field.b_type === "datetime"
);
}
export function emptyQueryValue(item) {
if (isRangeQuery(item) || item.config.b_operator === "in") return [];
return undefined;
}
export function defaultQueryValue(item) {
const value = item.config.b_default_value;
if (value === null || value === undefined || value === "") return emptyQueryValue(item);
if (isRangeQuery(item) || item.config.b_operator === "in") {
try {
const parsed = JSON.parse(value);
if (Array.isArray(parsed)) return parsed;
} catch {
// 非 JSON 默认值继续按逗号分隔处理。
}
return String(value)
.split(",")
.map((part) => part.trim())
.filter(Boolean);
}
if (isNumericField(item)) {
const number = Number(value);
return Number.isFinite(number) ? number : undefined;
}
return value;
}
export function formatDateValue(value, withTime = false) {
if (isEmptyValue(value)) return "";
if (value && typeof value.format === "function") {
return value.format(withTime ? "YYYY-MM-DD HH:mm:ss" : "YYYY-MM-DD");
}
const text = String(value).replace("T", " ");
if (/^\d{4}-\d{2}-\d{2}/.test(text)) return withTime ? text.slice(0, 19) : text.slice(0, 10);
const date = new Date(value);
if (Number.isNaN(date.getTime())) return text;
const pad = (number) => String(number).padStart(2, "0");
const datePart = `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
return withTime
? `${datePart} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
: datePart;
}
@@ -1,102 +1,16 @@
import { computed, onBeforeUnmount, reactive, ref, shallowRef, unref, watch } from "vue";
import { useRoute } from "vue-router";
import { loadDataApi, pageDataApi } from "@/services/api";
const IDENTIFIER_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
function escapeSqlText(value) {
return String(value).replaceAll("'", "''");
}
function requireModuleId(value) {
const id = String(value ?? "");
if (!/^\d+$/.test(id)) throw new Error("模块 ID 格式不正确");
return id;
}
function parseOptions(field) {
if (!field?.b_options) return { optionConfig: {}, options: [] };
try {
const parsed = JSON.parse(field.b_options);
const optionConfig = Array.isArray(parsed) ? {} : parsed || {};
const source = Array.isArray(parsed)
? parsed
: Array.isArray(parsed?.options)
? parsed.options
: Array.isArray(parsed?.items)
? parsed.items
: [];
const options = source.map((option) => {
if (option && typeof option === "object") {
const value = option.value ?? option.key ?? option.id;
return { ...option, value, label: String(option.label ?? option.name ?? value ?? "") };
}
return { value: option, label: String(option ?? "") };
});
return { optionConfig, options };
} catch {
return { optionConfig: {}, options: [] };
}
}
function isRangeQuery(item) {
return (
item.config.b_operator === "between" ||
item.config.b_component === "number-range" ||
item.field.b_type === "datetime"
);
}
function isNumericField(item) {
return item.field.b_type === "number" || item.field.b_type === "money";
}
function emptyQueryValue(item) {
if (isRangeQuery(item) || item.config.b_operator === "in") return [];
return undefined;
}
function defaultQueryValue(item) {
const value = item.config.b_default_value;
if (value === null || value === undefined || value === "") return emptyQueryValue(item);
if (isRangeQuery(item) || item.config.b_operator === "in") {
try {
const parsed = JSON.parse(value);
if (Array.isArray(parsed)) return parsed;
} catch {
// 非 JSON 默认值继续按逗号分隔处理。
}
return String(value)
.split(",")
.map((part) => part.trim())
.filter(Boolean);
}
if (isNumericField(item)) {
const number = Number(value);
return Number.isFinite(number) ? number : undefined;
}
return value;
}
function isEmptyValue(value) {
return value === undefined || value === null || value === "";
}
function formatDateValue(value, withTime = false) {
if (isEmptyValue(value)) return "";
if (value && typeof value.format === "function") {
return value.format(withTime ? "YYYY-MM-DD HH:mm:ss" : "YYYY-MM-DD");
}
const text = String(value).replace("T", " ");
if (/^\d{4}-\d{2}-\d{2}/.test(text)) return withTime ? text.slice(0, 19) : text.slice(0, 10);
const date = new Date(value);
if (Number.isNaN(date.getTime())) return text;
const pad = (number) => String(number).padStart(2, "0");
const datePart = `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
return withTime
? `${datePart} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
: datePart;
}
import {
IDENTIFIER_RE,
defaultQueryValue,
escapeSqlText,
formatDateValue,
isEmptyValue,
isRangeQuery,
parseOptions,
requireModuleId,
} from "./moduleListUtils";
function formatSqlDate(value, fieldType, endOfRange = false) {
if (value && typeof value.format === "function") {
@@ -427,7 +427,7 @@ defineExpose({
:data-source="dataSource"
:drag-row-config="{ mode: rowDraggable ? 'insert' : 'none' }"
:row-key="rowKey"
:bordered="bordered"
:bordered="props.editable ? true : 'body-h'"
:no-data-full="noDataFull"
:show-overflow="showOverflow"
:area-selection="editable"
@@ -13,15 +13,14 @@ const menuIcon = (icon) => () => h(icon, { size: 16, strokeWidth: 1.8 });
const openKeys = ref(["system"]);
const effectiveCollapsed = computed(() => appStore.sidebarCollapsed);
function canAccessMenu(item) {
if (item.dataCode) return permissionStore.canData(item.dataCode);
if (item.moduleCode) return permissionStore.canModule(item.moduleCode);
if (item.moduleId) return permissionStore.canAccess(item.moduleId);
return true;
}
const menuItems = computed(() => {
const systemPages = [
{ key: "/system/module-management", label: "模块管理", moduleCode: "m_metadata" },
{ key: "/system/i18n-type", label: "多语言类型", moduleCode: "m_i18n_type" },
{ key: "/system/login-log", label: "登录日志", dataCode: "d_login_log" },
{ key: "/system/module-management", label: "模块管理", moduleId: "m_metadata" },
{ key: "/system/i18n-type", label: "多语言类型", moduleId: "b_i18n_type" },
{ key: "/system/login-log", label: "登录日志", moduleId: "d_login_log" },
].filter(canAccessMenu);
const items = [{ key: "/dashboard", icon: menuIcon(House), label: "工作台" }];
if (systemPages.length)
+2 -5
View File
@@ -34,14 +34,11 @@ router.beforeEach(async (to) => {
return getRedirectPath(to.query.redirect);
}
if (to.meta.dataCode || to.meta.moduleCode) {
if (to.meta.moduleId) {
const permissionStore = usePermissionStore();
try {
await permissionStore.load();
const canAccess = to.meta.dataCode
? permissionStore.canData(to.meta.dataCode)
: permissionStore.canModule(to.meta.moduleCode);
if (!canAccess) {
if (!permissionStore.canAccess(to.meta.moduleId)) {
message.warning("没有该模块的访问权限");
return false;
}
+3 -3
View File
@@ -22,7 +22,7 @@ const routes = [
title: "模块管理",
section: "系统管理",
menuKey: "/system/module-management",
moduleCode: "m_metadata",
moduleId: "m_metadata",
},
},
{
@@ -33,7 +33,7 @@ const routes = [
title: "多语言类型",
section: "系统管理",
menuKey: "/system/i18n-type",
moduleCode: "m_i18n_type",
moduleId: "b_i18n_type",
},
},
{
@@ -44,7 +44,7 @@ const routes = [
title: "登录日志",
section: "系统管理",
menuKey: "/system/login-log",
dataCode: "d_login_log",
moduleId: "d_login_log",
},
},
],
+6 -13
View File
@@ -84,23 +84,17 @@ export const usePermissionStore = defineStore("permissions", () => {
return request;
}
function resolvePageId(code, expectedType) {
function resolvePageId(code) {
const module = moduleByCode.value.get(String(code || ""));
if (!module || (expectedType && module.b_module_type !== expectedType)) return "";
if (!module) return "";
if (module.b_module_type === "page") return String(module.b_id || "");
if (module.b_module_type === "data") return String(module.b_parent_id || "");
return "";
}
function canModule(moduleCode) {
if (isG3soft.value || !moduleCode) return true;
const pageId = resolvePageId(moduleCode);
return !!pageId && grantedPageSet.value.has(pageId);
}
function canData(dataCode) {
if (isG3soft.value || !dataCode) return true;
const pageId = resolvePageId(dataCode, "data");
function canAccess(moduleId) {
if (isG3soft.value || !moduleId) return true;
const pageId = resolvePageId(moduleId);
return !!pageId && grantedPageSet.value.has(pageId);
}
@@ -130,8 +124,7 @@ export const usePermissionStore = defineStore("permissions", () => {
return {
load,
canModule,
canData,
canAccess,
canPower,
clear,
grantedPageIds,
+67
View File
@@ -62,3 +62,70 @@ export function groupI18nRows(rows) {
});
return groups;
}
// 将同步字段生成的默认翻译条目并入透视行数组:
// - 已存在同 b_key 的行则补全其空语言值(不覆盖用户已填内容);
// - 否则新增一行;managedId 用于关联字段,使重命名字段时复用同一行(改名而非新增)。
// 返回新的数组,不修改入参。
export function mergeI18nDefaults(rows, entries) {
const next = [...rows];
const byKey = new Map(next.map((r) => [String(r.b_key), r]));
const managedIds = new Set(
next
.map((r) => r._managedId)
.filter(Boolean)
.map(String),
);
for (const e of entries || []) {
if (!e.key) continue;
const existing = byKey.get(String(e.key));
if (existing) {
// 已存在同键行:仅补全空值,保留用户已配置的翻译
if (existing[e.locale] == null || existing[e.locale] === "") existing[e.locale] = e.value;
continue;
}
// 同键不存在,但该受管键已由其它行代表时不再新建,避免重复键
if (e.managedId && managedIds.has(String(e.managedId))) continue;
const row = {
_id: "i18n_" + Math.random().toString(36).slice(2, 10),
b_key: e.key,
_localeIds: {},
_managedId: e.managedId ? String(e.managedId) : undefined,
_syncInjected: e.managedId ? true : undefined,
[e.locale]: e.value,
};
next.push(row);
byKey.set(String(e.key), row);
if (e.managedId) managedIds.add(String(e.managedId));
}
return next;
}
// 合并同 b_key 的重复透视行,保证一个键仅一行:
// 多行同键时保留各自非空的语言值,受管标记(_managedId)、_syncInjected、排序/默认值取优先者。
// 返回新的数组,不修改入参。
export function consolidatePivotRows(rows) {
const byKey = new Map();
const merged = [];
for (const row of rows) {
const key = String(row.b_key || "");
const hit = byKey.get(key);
if (hit) {
for (const [k, v] of Object.entries(row)) {
if (k === "_id" || k === "_localeIds") continue;
// 仅当目标为空(null/undefined/空串)且来源非空时才补全,保留首个非空值
if ((hit[k] == null || hit[k] === "") && v != null) hit[k] = v;
}
Object.assign(hit._localeIds || (hit._localeIds = {}), row._localeIds || {});
if (row._managedId && !hit._managedId) hit._managedId = row._managedId;
if (row._syncInjected && !hit._syncInjected) hit._syncInjected = row._syncInjected;
if (row._defaultValue != null && hit._defaultValue == null)
hit._defaultValue = row._defaultValue;
if (row._sortType && !hit._sortType) hit._sortType = row._sortType;
} else {
byKey.set(key, row);
merged.push(row);
}
}
return merged;
}
+56
View File
@@ -0,0 +1,56 @@
/**
* 将扁平数组构建为树结构
* @param {Array} items - 扁平数据
* @param {object} [options]
* @param {string} [options.idKey='b_id'] - ID 字段名
* @param {string} [options.parentKey='b_parent_id'] - 父级 ID 字段名
* @param {string} [options.childrenKey='children'] - 子节点字段名
* @param {Function} [options.mapNode] - 节点映射函数 (item) => node,用于创建新的树节点对象
* @returns {Array} 树形结构
*/
export function buildTree(items, options = {}) {
const { idKey = "b_id", parentKey = "b_parent_id", childrenKey = "children", mapNode } = options;
const map = new Map();
const roots = [];
for (const item of items) {
const node = mapNode ? mapNode(item) : item;
map.set(String(item[idKey]), node);
}
for (const item of items) {
const node = map.get(String(item[idKey]));
const parentId = item[parentKey] != null ? String(item[parentKey]) : null;
if (parentId && map.has(parentId)) {
const parent = map.get(parentId);
if (!parent[childrenKey]) parent[childrenKey] = [];
parent[childrenKey].push(node);
} else {
roots.push(node);
}
}
return roots;
}
/**
* 递归排序树节点(默认按 b_xh 升序,b_id 升序)
* @param {Array} nodes - 树节点
* @param {object} [options]
* @param {string} [options.sortKey='b_xh'] - 排序字段
* @param {string} [options.idKey='b_id'] - 第二排序字段
* @param {string} [options.childrenKey='children'] - 子节点字段名
*/
export function sortTree(
nodes,
{ sortKey = "b_xh", idKey = "b_id", childrenKey = "children" } = {},
) {
nodes.sort(
(a, b) =>
Number(a[sortKey] || 0) - Number(b[sortKey] || 0) ||
String(a[idKey]).localeCompare(String(b[idKey])),
);
for (const node of nodes) {
if (node[childrenKey]?.length) sortTree(node[childrenKey], { sortKey, idKey, childrenKey });
}
}
@@ -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;