20260722173146

This commit is contained in:
oneao committed 2026-07-22 17:31:48 +08:00
1 parent e9f099f044
commit d04fad24ed
24 files changed
+1309 -422

No files matched your search

@@ -40,6 +40,7 @@ function updateRoute(value) {
<ModuleTranslationEditor
v-model:translations="translations"
:i18n-key="module.b_i18n"
:default-name="module.b_name"
:disabled="!module.b_i18n?.trim()"
/>
</div>
@@ -80,6 +81,7 @@ function updateRoute(value) {
<ModuleTranslationEditor
v-model:translations="translations"
:i18n-key="module.b_i18n"
:default-name="module.b_name"
:disabled="!module.b_i18n?.trim()"
/>
</div>
@@ -1,57 +1,83 @@
<script setup>
import { computed, ref, watch } from "vue";
import { ref } from "vue";
import { App } from "antdv-next";
import { Plus, Trash2 } from "@lucide/vue";
import FmsTable from "@/components/FmsTable.vue";
import { nextIdApi } from "@/services/api";
import { RefreshCw } from "@lucide/vue";
import FmsTable from "@/components/fms-table/FmsTable.vue";
import { describeApi } from "@/services/api";
import { createTempId } from "@/utils/tempId";
import { createI18nCellRenderer } from "./createI18nCellRenderer";
import ModuleTranslationEditor from "./ModuleTranslationEditor.vue";
const props = defineProps({
moduleId: { type: [String, Number], default: "" },
moduleCode: { type: String, default: "" },
// 字段来源:模块配置的查询表或视图,为空时无法同步。
viewTable: { type: String, default: "" },
});
const fields = defineModel("fields", { type: Array, default: () => [] });
const translations = defineModel("translations", { type: Object, default: () => ({}) });
const { message } = App.useApp();
const adding = ref(false);
const selectedRowKeys = ref([]);
// 当前编辑行 key;点击某行进入单行编辑,再次点击退出。
const activeRowKey = ref(null);
const syncing = ref(false);
const translationEditorRef = ref(null);
const rowSelection = computed(() => ({
selectedRowKeys: selectedRowKeys.value,
onChange: (keys) => {
selectedRowKeys.value = keys;
},
}));
const renderI18nCell = createI18nCellRenderer({
activeRowKey,
translationEditorRef,
});
watch(
() => fields.value.map((item) => String(item.b_id)),
(keys) => {
const available = new Set(keys);
selectedRowKeys.value = selectedRowKeys.value.filter((key) => available.has(String(key)));
},
);
function nextFieldCode() {
const existing = new Set(fields.value.map((item) => item.b_field));
let index = 1;
while (existing.has(`b_new_${index}`)) index += 1;
return `b_new_${index}`;
function onRowClick(_ev, row) {
const key = String(row.b_id);
activeRowKey.value = activeRowKey.value === key ? null : key;
}
async function onAdd() {
if (!props.moduleId || adding.value) return;
adding.value = true;
function mapType(type) {
const t = String(type || "").toLowerCase();
if (t.includes("bit")) return "boolean";
if (/(tinyint|smallint|int|bigint|decimal|numeric|float|real|money|smallmoney)/.test(t)) {
return "number";
}
if (/(datetime|smalldatetime|date|time)/.test(t)) return "date";
if (/(text|ntext)/.test(t)) return "text";
return "string";
}
// 从视图同步字段:保留已有配置,追加视图新增列,移除视图已不存在的列。
async function syncFields() {
const source = props.viewTable;
if (!source) {
message.warning("请先在基本信息中配置查询表或视图");
return;
}
syncing.value = true;
try {
const code = nextFieldCode();
const id = String((await nextIdApi()).data);
fields.value = [
...fields.value,
{
b_id: id,
const columns = (await describeApi(source)).data || [];
if (!columns.length) {
message.warning("该视图没有可识别的列");
return;
}
const existingByField = new Map(fields.value.map((item) => [item.b_field, item]));
const merged = [];
let maxXh = 0;
fields.value.forEach((item) => {
if (columns.some((col) => col.name === item.b_field)) {
merged.push(item);
maxXh = Math.max(maxXh, Number(item.b_xh) || 0);
}
});
for (const col of columns) {
if (existingByField.has(col.name)) continue;
maxXh += 10;
merged.push({
b_id: createTempId(),
b_module_id: props.moduleId,
b_field: code,
b_name: "新字段",
b_i18n: props.moduleCode ? `field.${props.moduleCode}.${code}` : null,
b_type: "string",
b_field: col.name,
b_name: col.name,
b_i18n: props.moduleCode ? `field.${props.moduleCode}.${col.name}` : null,
b_type: mapType(col.type),
b_canlist: 1,
b_canform: 1,
b_must: 0,
@@ -59,27 +85,28 @@ async function onAdd() {
b_readonly: 0,
b_width: 120,
b_value: null,
b_xh: (fields.value.length + 1) * 10,
b_xh: maxXh,
b_canuse: 1,
},
];
});
}
fields.value = merged;
message.success(`已同步 ${columns.length} 个字段`);
} catch (error) {
message.error(error.message || "字段创建失败");
message.error(error.message || "字段同步失败");
} finally {
adding.value = false;
syncing.value = false;
}
}
function onBatchDelete() {
const selected = new Set(selectedRowKeys.value.map(String));
fields.value = fields.value.filter((item) => !selected.has(String(item.b_id)));
selectedRowKeys.value = [];
}
const columns = ref([
{
dataIndex: "b_i18n",
title: "多语言键",
width: 250,
customCell: renderI18nCell,
},
{ dataIndex: "b_field", title: "字段", width: 150, editor: { maxlength: 50 } },
{ dataIndex: "b_name", title: "名称", width: 150, editor: { maxlength: 100 } },
{ dataIndex: "b_i18n", title: "多语言键", width: 230, editor: { maxlength: 150 } },
{
dataIndex: "b_type",
title: "类型",
@@ -145,13 +172,9 @@ const columns = ref([
<template>
<section class="table-pane">
<div class="toolbar">
<a-button type="primary" :loading="adding" @click="onAdd">
<template #icon><Plus /></template>
新增
</a-button>
<a-button danger :disabled="!selectedRowKeys.length" @click="onBatchDelete">
<template #icon><Trash2 /></template>
批量删除
<a-button type="primary" :loading="syncing" @click="syncFields">
<template #icon><RefreshCw /></template>
同步字段
</a-button>
</div>
<FmsTable
@@ -159,8 +182,10 @@ const columns = ref([
row-key="b_id"
v-model:columns="columns"
:data-source="fields"
:row-selection="rowSelection"
:active-row-key="activeRowKey"
@row-click="onRowClick"
/>
<ModuleTranslationEditor ref="translationEditorRef" v-model:translations="translations" />
</section>
</template>
@@ -178,6 +203,7 @@ const columns = ref([
gap: 8px;
flex: none;
padding: 10px 0;
align-items: center;
}
.sub-table {
@@ -1,9 +1,10 @@
<script setup>
import { computed, ref, watch } from "vue";
import { App } from "antdv-next";
import { Plus, Trash2 } from "@lucide/vue";
import FmsTable from "@/components/FmsTable.vue";
import { nextIdApi } from "@/services/api";
import FmsTable from "@/components/fms-table/FmsTable.vue";
import { createTempId } from "@/utils/tempId";
import { createI18nCellRenderer } from "./createI18nCellRenderer";
import ModuleTranslationEditor from "./ModuleTranslationEditor.vue";
const props = defineProps({
moduleId: { type: [String, Number], default: "" },
@@ -11,9 +12,21 @@ const props = defineProps({
});
const powers = defineModel("powers", { type: Array, default: () => [] });
const { message } = App.useApp();
const adding = ref(false);
const translations = defineModel("translations", { type: Object, default: () => ({}) });
const selectedRowKeys = ref([]);
// 当前编辑行 key;点击某行进入单行编辑,再次点击退出。
const activeRowKey = ref(null);
const translationEditorRef = ref(null);
const renderI18nCell = createI18nCellRenderer({
activeRowKey,
translationEditorRef,
});
function onRowClick(_ev, row) {
const key = String(row.b_id);
activeRowKey.value = activeRowKey.value === key ? null : key;
}
const rowSelection = computed(() => ({
selectedRowKeys: selectedRowKeys.value,
@@ -37,41 +50,42 @@ function nextPowerCode() {
return `new_${index}`;
}
async function onAdd() {
if (!props.moduleId || adding.value) return;
adding.value = true;
try {
const code = nextPowerCode();
const id = String((await nextIdApi()).data);
powers.value = [
...powers.value,
{
b_id: id,
b_module_id: props.moduleId,
b_code: code,
b_name: "新权限",
b_i18n: props.moduleCode ? `power.${props.moduleCode}.${code}` : null,
b_canuse: 1,
b_xh: (powers.value.length + 1) * 10,
},
];
} catch (error) {
message.error(error.message || "权限创建失败");
} finally {
adding.value = false;
}
function onAdd() {
if (!props.moduleId) return;
const code = nextPowerCode();
powers.value = [
...powers.value,
{
b_id: createTempId(),
b_module_id: props.moduleId,
b_code: code,
b_name: "新权限",
b_i18n: props.moduleCode ? `power.${props.moduleCode}.${code}` : null,
b_canuse: 1,
b_xh: (powers.value.length + 1) * 10,
},
];
activeRowKey.value = powers.value[powers.value.length - 1].b_id;
}
function onBatchDelete() {
const selected = new Set(selectedRowKeys.value.map(String));
powers.value = powers.value.filter((item) => !selected.has(String(item.b_id)));
selectedRowKeys.value = [];
if (activeRowKey.value && selected.has(String(activeRowKey.value))) {
activeRowKey.value = null;
}
}
const columns = ref([
{
dataIndex: "b_i18n",
title: "多语言键",
width: 250,
customCell: renderI18nCell,
},
{ dataIndex: "b_code", title: "权限代码", width: 180, editor: { maxlength: 50 } },
{ dataIndex: "b_name", title: "名称", width: 180, editor: { maxlength: 100 } },
{ dataIndex: "b_i18n", title: "多语言键", width: 240, editor: { maxlength: 150 } },
{
dataIndex: "b_canuse",
title: "启用",
@@ -87,7 +101,7 @@ const columns = ref([
<template>
<section class="table-pane">
<div class="toolbar">
<a-button type="primary" :loading="adding" @click="onAdd">
<a-button type="primary" @click="onAdd">
<template #icon><Plus /></template>
新增
</a-button>
@@ -102,7 +116,10 @@ const columns = ref([
v-model:columns="columns"
:data-source="powers"
:row-selection="rowSelection"
:active-row-key="activeRowKey"
@row-click="onRowClick"
/>
<ModuleTranslationEditor ref="translationEditorRef" v-model:translations="translations" />
</section>
</template>
@@ -1,5 +1,5 @@
<script setup>
import { reactive } from "vue";
import { computed, reactive } from "vue";
import { App } from "antdv-next";
import { Languages } from "@lucide/vue";
import { I18N_LOCALES, isValidI18nKey } from "@/utils/i18n";
@@ -7,25 +7,50 @@ import { I18N_LOCALES, isValidI18nKey } from "@/utils/i18n";
const props = defineProps({
i18nKey: { type: String, default: "" },
disabled: { type: Boolean, default: false },
// 默认语言(由 b_name 承载),不在此编辑器中维护,避免两份中文名互相脱节。
defaultLocale: { type: String, default: "zh-CN" },
// 默认语言对应的名称(如 b_name 的中文名),仅展示用,帮助辨识所属对象。
defaultName: { type: String, default: "" },
});
const translations = defineModel("translations", { type: Object, default: () => ({}) });
const { message } = App.useApp();
const dialog = reactive({ open: false, key: "", values: {} });
const dialog = reactive({ open: false, key: "", name: "", values: {} });
function openEditor() {
const key = props.i18nKey.trim();
// 仅维护非默认语言;默认语言(zh-CN)由对应实体的 b_name 字段承载。
const editableLocales = computed(() =>
I18N_LOCALES.filter((locale) => locale.value !== props.defaultLocale),
);
const defaultLocaleLabel = computed(
() =>
I18N_LOCALES.find((locale) => locale.value === props.defaultLocale)?.label ||
props.defaultLocale,
);
// 支持两种打开方式:
// 1) 自带按钮点击:使用 props.i18nKey / props.defaultName
// 2) 表格内按行打开:传入 key / name(如字段、权限的某行)
function openEditor(forceKey, forceName) {
const key = String(forceKey ?? props.i18nKey).trim();
const name = forceName ?? props.defaultName;
if (!isValidI18nKey(key)) {
message.warning("请先填写正确的多语言键");
return;
}
dialog.key = key;
dialog.name = name || "";
dialog.values = Object.fromEntries(
I18N_LOCALES.map((locale) => [locale.value, translations.value[key]?.[locale.value] || ""]),
editableLocales.value.map((locale) => [
locale.value,
translations.value[key]?.[locale.value] || "",
]),
);
dialog.open = true;
}
defineExpose({ open: openEditor });
function saveTranslations() {
// 整条 key 只写非默认语言,顺带清掉历史残留的默认语言翻译,杜绝覆盖 b_name。
translations.value = {
...translations.value,
[dialog.key]: { ...dialog.values },
@@ -35,7 +60,7 @@ function saveTranslations() {
</script>
<template>
<a-button :disabled="disabled" @click="openEditor">
<a-button v-if="i18nKey" :disabled="disabled" @click="openEditor">
<template #icon><Languages /></template>
多语言
</a-button>
@@ -49,9 +74,12 @@ function saveTranslations() {
>
<a-form layout="vertical">
<a-form-item label="多语言键">
<a-input :value="dialog.key" disabled />
<a-input :value="dialog.key" readonly />
</a-form-item>
<a-form-item v-for="locale in I18N_LOCALES" :key="locale.value" :label="locale.label">
<a-form-item :label="`${defaultLocaleLabel}(默认语言)`">
<a-input :value="dialog.name" readonly />
</a-form-item>
<a-form-item v-for="locale in editableLocales" :key="locale.value" :label="locale.label">
<a-input v-model:value="dialog.values[locale.value]" :maxlength="500" />
</a-form-item>
</a-form>
@@ -0,0 +1,64 @@
import { h } from "vue";
import { Input } from "antdv-next";
import { Languages } from "@lucide/vue";
const cellStyle = {
display: "flex",
alignItems: "center",
gap: "4px",
width: "100%",
};
const inputStyle = { flex: "1 1 auto", minWidth: 0 };
const textStyle = {
flex: "1 1 auto",
minWidth: 0,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
};
const iconStyle = {
display: "inline-flex",
flex: "none",
alignItems: "center",
justifyContent: "center",
width: "24px",
height: "24px",
cursor: "pointer",
color: "var(--ant-color-primary, #1677ff)",
};
export function createI18nCellRenderer({ activeRowKey, translationEditorRef }) {
return function renderI18nCell({ row }) {
const editing = activeRowKey.value != null && String(row.b_id) === String(activeRowKey.value);
return h("div", { style: cellStyle }, [
editing
? h("div", { style: inputStyle }, [
h(Input, {
value: row.b_i18n,
size: "small",
maxlength: 150,
allowClear: true,
style: { width: "100%" },
onClick: (event) => event.stopPropagation(),
"onUpdate:value": (value) => {
row.b_i18n = value;
},
}),
])
: h("span", { style: textStyle }, row.b_i18n ?? ""),
h(
"span",
{
style: iconStyle,
title: "编辑翻译",
onClick: (event) => {
event.stopPropagation();
translationEditorRef.value?.open(row.b_i18n, row.b_name);
},
},
[h(Languages, { size: 16 })],
),
]);
};
}