20260728173026
This commit is contained in:
1 parent
63588bd217
commit
5ec7aadef9
25 files changed
+627
-462
No files matched your search
@@ -0,0 +1,70 @@
|
||||
/*
|
||||
为 dbo.b_i18n 增加 b_module_id 列,使多语言数据可按模块级联删除,
|
||||
解决「删除模块后 b_i18n 残留孤儿数据」的问题。
|
||||
|
||||
本脚本可重复执行:
|
||||
- 列不存在时才添加,并建索引;
|
||||
- 对存量数据做一次基于键命名约定的回填(module./menu./field./power. 前缀,
|
||||
取第二段作为模块编码关联 s_module.b_code),已回填或无法匹配的行不受影响。
|
||||
*/
|
||||
SET NOCOUNT ON;
|
||||
|
||||
-- 第 1 批:DDL(列与索引)
|
||||
BEGIN TRANSACTION;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM sys.columns
|
||||
WHERE object_id = OBJECT_ID(N'dbo.b_i18n') AND name = N'b_module_id'
|
||||
)
|
||||
BEGIN
|
||||
ALTER TABLE dbo.b_i18n
|
||||
ADD b_module_id bigint NULL;
|
||||
|
||||
CREATE INDEX IX_b_i18n_module_id ON dbo.b_i18n (b_module_id)
|
||||
INCLUDE (b_key, b_locale, b_value);
|
||||
END
|
||||
|
||||
COMMIT TRANSACTION;
|
||||
GO
|
||||
|
||||
-- 第 2 批:存量回填(依赖第 1 批列已存在,因此必须 GO 分隔)
|
||||
SET NOCOUNT ON;
|
||||
|
||||
BEGIN TRANSACTION;
|
||||
|
||||
-- 键形如 module.<code> / menu.<code> / field.<code>.<item> / power.<code>.<item>,
|
||||
-- 模块编码恒为第二个以 "." 分隔的片段。
|
||||
UPDATE i
|
||||
SET b_module_id = m.b_id
|
||||
FROM dbo.b_i18n i
|
||||
CROSS APPLY (
|
||||
SELECT
|
||||
CASE
|
||||
WHEN CHARINDEX(N'.', i.b_key) > 0 THEN
|
||||
SUBSTRING(
|
||||
i.b_key,
|
||||
CHARINDEX(N'.', i.b_key) + 1,
|
||||
CASE
|
||||
WHEN CHARINDEX(N'.', i.b_key, CHARINDEX(N'.', i.b_key) + 1) > 0
|
||||
THEN CHARINDEX(N'.', i.b_key, CHARINDEX(N'.', i.b_key) + 1)
|
||||
- CHARINDEX(N'.', i.b_key) - 1
|
||||
ELSE LEN(i.b_key) - CHARINDEX(N'.', i.b_key)
|
||||
END
|
||||
)
|
||||
ELSE NULL
|
||||
END AS code
|
||||
) c
|
||||
CROSS APPLY (
|
||||
SELECT TOP (1) sm.b_id
|
||||
FROM dbo.s_module sm
|
||||
WHERE sm.b_code = c.code
|
||||
) m
|
||||
WHERE i.b_module_id IS NULL
|
||||
AND (
|
||||
i.b_key LIKE N'module.%'
|
||||
OR i.b_key LIKE N'menu.%'
|
||||
OR i.b_key LIKE N'field.%'
|
||||
OR i.b_key LIKE N'power.%'
|
||||
);
|
||||
|
||||
COMMIT TRANSACTION;
|
||||
@@ -532,7 +532,26 @@ public class DbUtils {
|
||||
}
|
||||
|
||||
private String buildOrderBy(String orderBy) {
|
||||
String[] parts = orderBy.trim().split("\\s+");
|
||||
String trimmed = orderBy.trim();
|
||||
String lower = trimmed.toLowerCase();
|
||||
|
||||
// CASE 表达式:从末尾剥离可选 ASC/DESC,其余直接返回
|
||||
if (lower.startsWith("case")) {
|
||||
String direction = "";
|
||||
if (lower.endsWith(" asc")) {
|
||||
direction = " ASC";
|
||||
trimmed = trimmed.substring(0, trimmed.length() - 4);
|
||||
} else if (lower.endsWith(" desc")) {
|
||||
direction = " DESC";
|
||||
trimmed = trimmed.substring(0, trimmed.length() - 5);
|
||||
}
|
||||
if (!trimmed.trim().toLowerCase().endsWith("end")) {
|
||||
throw new BusinessException("CASE 表达式必须以 END 结尾");
|
||||
}
|
||||
return trimmed + direction;
|
||||
}
|
||||
|
||||
String[] parts = trimmed.split("\\s+");
|
||||
if (parts.length > 2) {
|
||||
throw new BusinessException("排序字段格式不正确");
|
||||
}
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
+81
-49
@@ -1,64 +1,96 @@
|
||||
# FMS 开发规范
|
||||
|
||||
## 一、构建与测试
|
||||
> 规则等级:[MUST] = 必须遵守,违反即错误 [SHOULD] = 应当遵守,例外需说明 [NEVER] = 禁止
|
||||
|
||||
- **不要过多封装**
|
||||
- 完成功能后不主动执行构建。
|
||||
- 允许执行测试以验证改动(如后端 `./mvnw test`、前端 `pnpm fmt:check` 及页面功能验证)。
|
||||
- 只有用户明确要求时,才执行构建或启动服务。
|
||||
- 交付时说明本次是否执行了测试及测试结果,并说明未执行构建。
|
||||
---
|
||||
|
||||
## 二、后端接口
|
||||
## 1. 代码风格 [MUST — 最高优先级]
|
||||
|
||||
- 优先使用现有通用接口完成查询、分页、保存和取号:
|
||||
- `/data/loaddata`
|
||||
- `/data/page`
|
||||
- `/data/saveobjt`
|
||||
- `/data/nextid`
|
||||
- 不随意修改通用接口的地址、参数、返回结构和处理逻辑。
|
||||
- 不随意新增业务专用接口。
|
||||
- 不新增 services 这类接口或者文件。
|
||||
- 如果现有通用接口确实无法完成需求,必须先说明原因、拟新增或修改的内容及影响范围,并取得用户确认后再实施。
|
||||
- [MUST] **写法统一**:同类场景(API 调用、错误处理、状态管理)使用一致的写法,新增代码模仿现有同类代码的风格,不自创写法。
|
||||
- [MUST] **不滥用语法糖**:能用 `if/else` 就不用三元/`?.短路`;能用 `for` 循环就不用 `reduce`/`flatMap`/解构嵌套。判断标准:不熟悉此代码的人能否一眼看懂?
|
||||
- [MUST] **禁止无用嵌套**:减少不必要的中变量、回调套回调、过度抽象。
|
||||
- [MUST] **可读性 >= 简洁性**:写得短但看不懂 → 不合格;写得稍长但一目了然 → 合格。
|
||||
|
||||
## 三、前端实现
|
||||
## 2. 构建与测试
|
||||
|
||||
- 优先使用项目现有组件和 Antdv Next 组件,不重复实现组件已有的功能。
|
||||
- 页面存在相同或相近功能时,优先抽取为可复用组件、组合函数或服务方法。
|
||||
- 抽取复用应以确实减少重复和降低维护成本为前提,不为简单逻辑增加无必要的封装。
|
||||
- 优先复用现有布局、样式变量和组件能力,以尽量少的 CSS 完成功能。
|
||||
- 不使用 CSS 重复实现组件已有的尺寸、状态、交互和响应式能力。
|
||||
- 新增 CSS 应限制在当前组件作用域内,避免影响其他页面。
|
||||
- 不需要兼容移动端,按桌面端实现即可。
|
||||
- 表格尽量使用 `fms-table` 组件(项目封装的表格组件),不重复从零实现表格能力。
|
||||
- 表单输入控件不需要添加 placeholder(占位提示文字),用字段 label 或说明文字表达意图即可。
|
||||
- [NEVER] 完成功能后**不主动执行构建或启动服务**。
|
||||
- [SHOULD] 可运行测试验证改动:后端 `./mvnw test`,前端 `pnpm fmt:check`。
|
||||
- [MUST] 只有用户明确要求时,才执行构建或启动服务。
|
||||
- [MUST] 交付时说明:是否执行了测试 + 测试结果 + 未执行构建。
|
||||
|
||||
## 四、变更范围
|
||||
## 3. 后端接口
|
||||
|
||||
- 修改应围绕当前需求,避免无关重构和格式化其他文件。
|
||||
- 保留已有功能和用户尚未提交的改动。
|
||||
- 涉及通用接口、公共组件或全局样式的变更,实施前必须先确认影响范围。
|
||||
- [MUST] **只用通用接口**完成所有数据操作:
|
||||
- 查询:`POST /data/loaddata`
|
||||
- 分页:`POST /data/page`
|
||||
- 保存:`POST /data/saveobjt`
|
||||
- 取号:`GET /data/nextid`
|
||||
- [NEVER] 新增业务专用接口、新增 Service 类/文件。
|
||||
- [NEVER] 修改通用接口的 URL、参数、返回值结构或处理逻辑。
|
||||
- [MUST] 如通用接口确实无法满足需求:先说明原因 + 拟修改内容 + 影响范围,取得用户确认后再实施。
|
||||
- [MUST] `bigint` 类型(如 `b_id`)序列化到前端时必须转为**字符串**。
|
||||
|
||||
## 五、CSS 规范
|
||||
## 4. 前端实现
|
||||
|
||||
1. **尽量使用 SCSS**:组件样式统一写在 `<style scoped lang="scss">` 中,利用嵌套、变量、mixin 减少重复;全局/公用样式放在 `src/styles/main.scss`(或在此引入的 partial)中,集中管理主题变量(如 `--fms-*`)。
|
||||
2. **以最少的 CSS 完成任务**:优先复用 Antdv Next 组件能力、设计变量和已有工具类,能用组件属性或已有 token 解决的就不写样式,不重复实现已有的尺寸/状态/交互。
|
||||
3. **公用样式集中管理**:跨页面或同功能重复出现的样式抽到 `src/styles/main.scss`、共用组件或组合(composable)中,做到「改一处、处处变」;组件内只保留该组件独有、无法共享的样式,并保持 `scoped`。
|
||||
4. **不兼容移动端**:按桌面端实现,不写 `@media` 响应式断点;如确有需要,须先与需求方确认。
|
||||
- [MUST] 表格使用项目封装的 **`fms-table`** 组件,不从零实现表格。
|
||||
- [MUST] 表单输入控件**不添加 `placeholder`**,用 `label` 或说明文字表达意图。
|
||||
- [MUST] 优先复用项目现有组件和 Antdv Next 组件能力,不重复造轮子。
|
||||
- [SHOULD] 页面中出现 ≥2 处相同/相近功能时,提取为可复用组件、composable 或 service 方法。
|
||||
- [SHOULD] 抽取复用以确有收益为前提,不为简单逻辑增加无意义的封装。
|
||||
- [MUST] 不添加 `@media` 响应式断点,按桌面端实现。
|
||||
- [MUST] 新增 CSS 加 `scoped`,不影响其他页面。
|
||||
|
||||
## 六、性能优先(重要、重要、重要)
|
||||
## 5. CSS 规范
|
||||
|
||||
- **性能是本项目的一等约束,贯穿全部层面**,优先于代码便利与组件/外观一致性。适用于前端、后端、数据库及一切场景,任何改动都不得引入可感知的性能退化(卡顿、慢查询、高延迟、资源浪费等)。
|
||||
- **前端**:表格单元格、列表项、滚动区域默认使用原生 DOM(`h("button")` / `<input>` / 原生元素)或轻量自绘元素,**避免每行/每项常驻重型组件实例**(如 antdv 的 `Dropdown`、`Select`、`Input`、`Modal` 等)。
|
||||
- 重型组件(如 `Dropdown` 菜单)必须「按需挂载」:列表态只渲染轻量触发器(原生按钮 + 图标),仅在用户真正交互(点击/悬停)时才为「当前那一行/项」临时挂载真正的组件,交互结束(菜单关闭/失焦)即卸载,保证**任意时刻最多只有 1 个该类组件实例存在**。
|
||||
- 虚拟滚动(如 `fms-table` / stk-table)场景下,组件实例的 mount/unmount 会随滚动反复发生;常驻重型组件会显著拖慢帧率(实测每行一个 `Dropdown` 可让滚动从约 55fps 跌到 40–50fps)。务必用轻量元素 + 按需挂载规避。
|
||||
- 含列表/表格滚动的页面,交付或评审前应关注 FPS,必要时用轻量替代或按需挂载优化。
|
||||
- **后端**:优先复用现有通用接口完成查询/分页/保存/取号,不随意新增业务接口或重复封装;查询与保存应合理批量(如用 `/data/saveobjt` 的单事务批量 upsert 而非逐条请求);避免 N+1 查询、在循环内做远程调用或重复 IO;大数据量必须分页,不一次拉全表。
|
||||
- **数据库(SQL Server)**:编写 SQL 时优先走索引(避免对索引列做函数包裹、隐式类型转换、`SELECT *`、不必要的 `ORDER BY`/`DISTINCT`);分页、聚合、联表需评估执行计划与数据量;大表慎用全表扫描与锁表操作;连接池(Druid 多组织路由)配置与慢查询需关注,避免连接泄漏与长事务。
|
||||
- 性能问题应在设计/编码阶段前置考量,而非事后优化;涉及慢查询、重接口、大列表等高风险改动,实施前应说明性能影响与规避方式。
|
||||
- [MUST] 样式写在 `<style scoped lang="scss">` 中。
|
||||
- [MUST] 优先用 Antdv Next 组件属性/设计 token 解决问题,能不写样式就不写。
|
||||
- [MUST] 跨页面复用的样式抽取到 `src/styles/main.scss` 或共享 composable 中。
|
||||
- [MUST] 不兼容移动端,不写 `@media` 断点。
|
||||
|
||||
## 七、表格「更多操作」按钮
|
||||
## 6. 性能 [MUST — 高于其他所有规则]
|
||||
|
||||
- 表格/列表中若需要「更多操作」(⋯)按钮并弹出菜单,**必须采用按需挂载模式**(禁止为每一行常驻一个 `Dropdown` 组件实例,否则会导致滚动卡顿):
|
||||
- **列表态**:单元格只渲染一个轻量原生 `⋯` 图标按钮(无边框、透明背景、hover 显底色),点击后才为当前行挂载真正的 `Dropdown`(`menu` 属性驱动 `items`,`open` 受控,`onOpenChange` 关闭即卸载)。
|
||||
- **任意时刻最多 1 个 `Dropdown` 实例**;菜单项统一使用「图标 + 文字」形式(如 `h(icon, { size: 14 })` 加文本)。
|
||||
- **参考实现**:`src/views/module-management/components/ModulePowersPanel.vue` 中的 `MoreActions` 组件——原生 `⋯` 触发器 + 仅在 `open` 时挂载 `antdv-next` 的 `Dropdown`,菜单含「编辑 / 复制 / 设置多语言」三项,每项带图标与文字。新增类似按钮时直接参照此模式。
|
||||
> 性能是本项目最高约束,优先级高于代码便利性和组件一致性。
|
||||
|
||||
### 6.1 前端性能
|
||||
|
||||
- [MUST] 表格/列表/滚动区域的**每一行/项只渲染轻量原生元素**(`h("button")`、`<input>`、原生 DOM)。禁止每行常驻重型组件实例。
|
||||
- [MUST] 重型组件(`Dropdown`、`Select`、`Input`、`Modal` 等)采用**按需挂载**模式:
|
||||
1. 默认只渲染轻量原生触发器(如按钮+图标)
|
||||
2. 用户交互时(点击/悬停)才为**当前行**挂载真实组件
|
||||
3. 交互结束(菜单关闭/失焦)立即卸载
|
||||
4. 任意时刻最多 1 个该类组件实例存在
|
||||
- [MUST] 交付/评审前确认表格滚动无卡顿。重型组件常驻会导致 FPS 从 ~55 降到 ~40-50。
|
||||
|
||||
### 6.2 后端性能
|
||||
|
||||
- [MUST] 批量操作:一次 `/data/saveobjt` 批量 upsert,不逐条请求。
|
||||
- [MUST] 禁止 N+1 查询、在循环内远程调用或重复 IO。
|
||||
- [MUST] 大数据量必须分页,不一次性拉全表。
|
||||
|
||||
### 6.3 数据库性能
|
||||
|
||||
- [MUST] 禁止对索引列做函数包裹和隐式类型转换。
|
||||
- [MUST] 禁止 `SELECT *`,明确列出需要的列。
|
||||
- [SHOULD] 减少不必要的 `ORDER BY` / `DISTINCT`。
|
||||
- [MUST] 大表禁止全表扫描和锁表操作。
|
||||
- [MUST] 避免连接泄漏和长事务。
|
||||
|
||||
### 6.4 通用原则
|
||||
|
||||
- [MUST] 性能问题在设计/编码阶段前置考量,不做事后优化。
|
||||
- [MUST] 慢查询、重接口、大列表等高风险改动,实施前说明性能影响和规避方式。
|
||||
|
||||
## 7. 变更范围
|
||||
|
||||
- [MUST] 修改仅围绕当前需求,不进行无关重构、代码风格调整或格式化其他文件。
|
||||
- [MUST] 保留已有功能和用户未提交的改动,不做破坏性变更。
|
||||
- [MUST] 涉及通用接口、公共组件、全局样式的变更,实施前必须确认影响范围。
|
||||
|
||||
## 8. 表格「更多操作」按钮 [MUST]
|
||||
|
||||
- [MUST] 采用**按需挂载**模式,禁止每行常驻 `Dropdown` 实例:
|
||||
- 列表态只渲染原生 `⋯` 按钮(透明背景、无边框、hover 显底色)
|
||||
- 点击后为当前行挂载 `Dropdown`(`menu` 驱动 `items`,`open` 受控,`onOpenChange` 关闭时卸载)
|
||||
- 任意时刻最多 1 个 `Dropdown` 实例
|
||||
- 菜单项格式:图标 + 文字
|
||||
- [MUST] 参照 `src/views/module-management/components/ModulePowersPanel.vue` 中的 `MoreActions` 实现。
|
||||
Reference in new issue
Block a user