20260727225141

This commit is contained in:
oneao committed 2026-07-27 22:51:41 +08:00
1 parent 9dbaef7787
commit 63588bd217
97 files changed
+1309 -145

No files matched your search

@@ -1,6 +1,7 @@
<script setup>
import { computed, ref, watch } from "vue";
import { Languages } from "@lucide/vue";
import { buildModuleI18nKey } from "@/utils/i18n";
import I18nQuickSetModal from "./I18nQuickSetModal.vue";
const props = defineProps({
@@ -36,20 +37,27 @@ function setField(field, value) {
emitUpdate();
}
// 多语言键由模块编码自动生成(module.<编码>),无需手动填写。
// 目录和页面使用 menu.<编码>,数据模块使用 module.<编码>。
// 编码变化时始终以完整编码重新派生,避免只生成首字母后不再更新。
function onCodeInput(v) {
setField("b_code", v);
if (v) {
formData.value.b_i18n = `module.${v}`;
emitUpdate();
}
// 编码变化时始终以完整编码重新派生多语言键(清空编码时键也随之清空,保持跟随)。
formData.value.b_i18n = buildModuleI18nKey(formData.value.b_module_type, v);
emitUpdate();
}
function onModuleTypeChange(v) {
formData.value.b_module_type = v;
formData.value.b_i18n = buildModuleI18nKey(v, formData.value.b_code);
emitUpdate();
}
const i18nModalRef = ref(null);
function openI18nModal() {
const key = formData.value?.b_i18n || `module.${formData.value?.b_code || ""}`;
const key =
formData.value?.b_i18n ||
buildModuleI18nKey(formData.value?.b_module_type, formData.value?.b_code);
i18nModalRef.value?.open(key, formData.value?.b_name || "");
}
@@ -83,12 +91,7 @@ function onI18nSaved(key) {
{ label: '页面', value: 'page' },
{ label: '数据', value: 'data' },
]"
@update:value="
(v) => {
formData.b_module_type = v;
emitUpdate();
}
"
@update:value="onModuleTypeChange"
/>
</a-form-item>
</a-col>
@@ -14,7 +14,7 @@ import { createTempId } from "@/utils/tempId";
const props = defineProps({
moduleId: { type: String, required: true },
i18nKeys: { type: Array, default: () => [] },
i18nItems: { type: Array, default: () => [] },
loading: { type: Boolean, default: false },
});
@@ -31,6 +31,66 @@ const activeKey = ref(null);
const tableRef = ref(null);
let loadRequestId = 0;
const managedItems = computed(() => (props.i18nItems || []).filter((item) => item?.managed));
const requestedKeys = computed(() => [
...new Set(
(props.i18nItems || [])
.flatMap((item) => [item?.key, item?.sourceKey])
.filter(Boolean)
.map(String),
),
]);
function isManagedRow(row) {
return Boolean(row?._managedId);
}
// 保证模块、字段、权限的受管键始终存在。简体中文为空或仍为上一次默认名称时,
// 自动跟随当前名称;人工修改后的翻译不再被名称变化覆盖。
// itemsOverride 用于保存时按最终草稿态生成受管键(不传则使用 props.i18nItems)。
function reconcileManagedRows(itemsOverride) {
const items = itemsOverride || managedItems.value;
const rows = [...pivotRows.value];
const rowByManagedId = new Map(
rows.filter((row) => row._managedId).map((row) => [String(row._managedId), row]),
);
const unclaimedByKey = new Map(
rows.filter((row) => !row._managedId).map((row) => [String(row.b_key), row]),
);
const currentManagedIds = new Set();
for (const item of items) {
const managedId = String(item.id || item.key);
currentManagedIds.add(managedId);
let row =
rowByManagedId.get(managedId) ||
unclaimedByKey.get(String(item.key)) ||
unclaimedByKey.get(String(item.sourceKey));
if (!row) {
row = { _id: createTempId(), b_key: item.key, _localeIds: {} };
rows.push(row);
}
const previousDefault = row._defaultValue;
const nextDefault = item.defaultValue == null ? "" : String(item.defaultValue);
if (row["zh-CN"] == null || row["zh-CN"] === "" || row["zh-CN"] === previousDefault) {
row["zh-CN"] = nextDefault;
}
row.b_key = String(item.key);
row._managedId = managedId;
row._defaultValue = nextDefault;
row._sortType = String(item.key).split(".", 1)[0];
}
// 已从模块配置中移除的条目不再受保护,保留其翻译供管理员自行处理。
for (const row of rows) {
if (!row._managedId || currentManagedIds.has(String(row._managedId))) continue;
delete row._managedId;
delete row._defaultValue;
}
pivotRows.value = rows;
}
async function loadLangTypes() {
try {
const res = await loadDataApi("b_i18n_type", "b_canuse = 1", "b_xh ASC, b_id ASC");
@@ -66,7 +126,7 @@ async function loadI18n() {
const requestId = ++loadRequestId;
isLoading.value = true;
try {
const keys = [...new Set((props.i18nKeys || []).filter(Boolean).map(String))];
const keys = requestedKeys.value;
const quotedKeys = keys.map((key) => "'" + key.replace(/'/g, "''") + "'");
const where = keys.length ? "b_key IN (" + quotedKeys.join(", ") + ")" : null;
const [i18nRes] = await Promise.all([
@@ -84,6 +144,7 @@ async function loadI18n() {
if (requestId !== loadRequestId) return;
originalsFlat.value = cloneData(flat);
pivotRows.value = buildPivot(flat);
reconcileManagedRows();
} catch (e) {
if (requestId === loadRequestId) message.error("加载多语言失败:" + (e.message || e));
} finally {
@@ -107,12 +168,14 @@ async function refreshKey(key) {
...cloneData(rows),
];
pivotRows.value = [...pivotRows.value.filter((row) => row.b_key !== key), ...buildPivot(rows)];
reconcileManagedRows();
} catch (e) {
message.error("刷新多语言失败:" + (e.message || e));
}
}
watch(() => props.moduleId, loadI18n, { immediate: true });
watch(() => props.i18nItems, reconcileManagedRows, { deep: true });
async function addRow() {
if (props.loading || isLoading.value) return;
@@ -122,11 +185,11 @@ async function addRow() {
activeKey.value = String(newId);
await nextTick();
tableRef.value?.scrollToRow(String(newId));
await tableRef.value?.editRow(String(newId), "b_key");
}
function deleteRow(record) {
if (props.loading || isLoading.value) return;
if (props.loading || isLoading.value || isManagedRow(record)) return;
pivotRows.value = pivotRows.value.filter((r) => r._id !== record._id);
// 同步清理选择状态,避免选中项被删后 keys 残留
const k = String(record._id);
@@ -140,15 +203,22 @@ function deleteRow(record) {
const selectedRowKeys = ref([]);
const rowSelection = computed(() => ({
selectedRowKeys: selectedRowKeys.value,
onChange: (keys) => {
selectedRowKeys.value = keys;
getCheckboxProps: (row) => ({ disabled: isManagedRow(row) }),
onChange: (keys, rows) => {
const selectableKeys = new Set(
(rows || []).filter((row) => !isManagedRow(row)).map((row) => String(row._id)),
);
selectedRowKeys.value = keys.map(String).filter((key) => selectableKeys.has(key));
},
}));
// 批量删除选中行(整条 key,含其全部语言)
function batchDelete() {
if (props.loading || isLoading.value) return;
const keys = selectedRowKeys.value;
const managedRowKeys = new Set(
pivotRows.value.filter(isManagedRow).map((row) => String(row._id)),
);
const keys = selectedRowKeys.value.filter((key) => !managedRowKeys.has(String(key)));
if (!keys.length) return;
Modal.confirm({
title: `确定删除选中的 ${keys.length} 条记录?`,
@@ -216,16 +286,33 @@ function markSaved() {
originalsFlat.value = cloneData(flatten(pivotRows.value));
}
defineExpose({ buildSaveRequest, markSaved, mergeDefaults, refreshKey });
defineExpose({ buildSaveRequest, markSaved, mergeDefaults, refreshKey, reconcileManagedRows });
const displayedData = computed(() => {
if (!searchKey.value) return pivotRows.value;
const kw = searchKey.value.toLowerCase();
return pivotRows.value.filter((r) => (r.b_key || "").toLowerCase().includes(kw));
const rows = kw
? pivotRows.value.filter((row) => (row.b_key || "").toLowerCase().includes(kw))
: pivotRows.value;
const sortOrder = { menu: 0, module: 0, field: 1, power: 2 };
return [...rows].sort((left, right) => {
const leftType = left._sortType || String(left.b_key || "").split(".", 1)[0];
const rightType = right._sortType || String(right.b_key || "").split(".", 1)[0];
return (
(sortOrder[leftType] ?? 3) - (sortOrder[rightType] ?? 3) ||
String(left.b_key || "").localeCompare(String(right.b_key || ""))
);
});
});
const columns = computed(() => {
const cols = [{ dataIndex: "b_key", title: "键", width: 240, editor: { type: "input" } }];
const cols = [
{
dataIndex: "b_key",
title: "键",
width: 240,
editor: { type: "input", disabled: (row) => isManagedRow(row) },
},
];
for (const opt of langOptions.value) {
cols.push({
dataIndex: opt.value,
@@ -244,6 +331,7 @@ const columns = computed(() => {
type: "delete",
label: "删除",
confirm: "确定删除该记录(含其全部语言)?",
disabled: (row) => isManagedRow(row),
onClick: (row) => deleteRow(row),
},
],
@@ -53,7 +53,7 @@ async function addRow() {
activeKey.value = String(newId);
await nextTick();
tableRef.value?.scrollToRow(String(newId));
await tableRef.value?.editRow(String(newId), "b_code");
}
function deleteRow(record) {
@@ -13,7 +13,12 @@ import {
hasChanges,
} from "@/utils/dataChanges";
import { createTempId } from "@/utils/tempId";
import { buildModuleItemI18nKey, syncModuleItemI18nKeys } from "@/utils/i18n";
import {
buildModuleI18nKey,
buildModuleItemI18nKey,
syncModuleItemI18nKeys,
validateModuleCode,
} from "@/utils/i18n";
import { Plus } from "@lucide/vue";
import ModuleBasicPanel from "./components/ModuleBasicPanel.vue";
import ModuleFieldsPanel from "./components/ModuleFieldsPanel.vue";
@@ -95,17 +100,61 @@ const isCurrentModule = computed(
() =>
loadedModuleId.value != null && String(loadedModuleId.value) === String(selectedModuleId.value),
);
const i18nKeys = computed(() => {
const keys = new Set();
const add = (key) => {
if (key) keys.add(String(key));
// 构建受管多语言项。managedSource 提供模块/字段/权限的受管键来源,
// plainSource 提供分组/列表标题等非受管键来源(手动填写,需即时反馈)。
function buildI18nItems(managedSource, plainSource) {
const items = new Map();
const add = (item) => {
if (!item.key) return;
const key = String(item.key);
const existing = items.get(key);
items.set(key, existing?.managed ? existing : { ...item, key });
};
add(drafts.value.module?.b_i18n);
for (const section of ["fields", "groups", "listConfig", "powers"]) {
for (const row of drafts.value[section] || []) add(row.b_i18n);
const module = managedSource.module;
if (module?.b_code) {
const moduleKey = buildModuleI18nKey(module.b_module_type, module.b_code);
add({
id: `module:${module.b_id}`,
key: moduleKey,
sourceKey: module.b_i18n,
defaultValue: module.b_name || module.b_code,
managed: true,
});
}
return [...keys];
});
for (const field of managedSource.fields || []) {
add({
id: `field:${field.b_id}`,
key: buildModuleItemI18nKey("field", module?.b_code, field.b_field),
sourceKey: field.b_i18n,
defaultValue: field.b_name || field.b_field,
managed: true,
});
}
for (const power of managedSource.powers || []) {
add({
id: `power:${power.b_id}`,
key: buildModuleItemI18nKey("power", module?.b_code, power.b_code),
sourceKey: power.b_i18n,
defaultValue: power.b_name || power.b_code,
managed: true,
});
}
// 分组和列表标题仍显示在本模块的多语言面板中,但不是系统生成的受管键。
for (const section of ["groups", "listConfig"]) {
for (const row of plainSource[section] || []) {
add({ key: row.b_i18n, managed: false });
}
}
return [...items.values()];
}
// 实时展示:受管键基于已保存快照(originals),编辑过程中不随草稿实时生成;
// 非受管键(分组/列表标题)基于草稿,保留手动编辑的即时反馈。
const i18nItems = computed(() => buildI18nItems(originals.value, drafts.value));
// 保存时:受管键基于最终草稿态(drafts),此时才按模块编码生成并落库。
const finalI18nItems = computed(() => buildI18nItems(drafts.value, drafts.value));
const referencedGroupIds = computed(() => {
const ids = new Set();
for (const section of ["listConfig", "editConfig"]) {
@@ -435,7 +484,24 @@ async function onDeleteNodeById(moduleId) {
return;
}
try {
await saveObjectApi([{ table: "s_module", key_field: "b_id", deletes: [{ b_id: moduleId }] }]);
const id = String(moduleId);
const deleteByModuleId = (table) => ({
table,
key_field: "b_module_id",
deletes: [{ b_module_id: id }],
});
await saveObjectApi([
deleteByModuleId("b_user_module"),
deleteByModuleId("b_user_power"),
deleteByModuleId("s_module_field_list"),
deleteByModuleId("s_module_field_edit"),
deleteByModuleId("s_module_field_query"),
deleteByModuleId("s_module_power"),
deleteByModuleId("s_module_field"),
deleteByModuleId("s_module_field_group"),
{ table: "s_module", key_field: "b_id", deletes: [{ b_id: id }] },
]);
message.success("模块已删除");
if (selectedModuleId.value === moduleId) {
selectedModuleId.value = null;
@@ -460,10 +526,16 @@ async function saveAll() {
// s_module 主表
const moduleData = drafts.value.module;
const moduleOriginal = originals.value.module;
if (!String(moduleData?.b_code || "").trim()) {
const moduleCode = String(moduleData?.b_code || "").trim();
if (!moduleCode) {
message.warning(`请填写${moduleData?.b_module_type === "data" ? "模块编码" : "菜单编码"}`);
return;
}
const codeError = validateModuleCode(moduleCode);
if (codeError) {
message.error(`编码格式不正确:${codeError}`);
return;
}
if (!String(moduleData?.b_name || "").trim()) {
message.warning("请填写模块名称");
return;
@@ -478,6 +550,7 @@ async function saveAll() {
}
syncModuleItemI18nKeys(drafts.value.fields, "field", moduleData.b_code, "b_field");
syncModuleItemI18nKeys(drafts.value.powers, "power", moduleData.b_code, "b_code");
moduleData.b_i18n = buildModuleI18nKey(moduleData.b_module_type, moduleData.b_code);
const isNew = !moduleOriginal?.b_code;
if (moduleData && JSON.stringify(moduleData) !== JSON.stringify(moduleOriginal)) {
saveReqs.push({
@@ -502,6 +575,13 @@ async function saveAll() {
const req = tableChange(table, changes);
if (hasChanges(req)) {
await allocateTemporaryIds([req.inserts || [], req.updates || [], req.deletes || []]);
if (table === "s_module_power" && changes.deletes.length) {
saveReqs.push({
table: "b_user_power",
key_field: "b_power_id",
deletes: changes.deletes.map(({ b_id }) => ({ b_power_id: String(b_id) })),
});
}
saveReqs.push(req);
}
}
@@ -510,6 +590,10 @@ async function saveAll() {
if (currentOperationId !== operationId || String(selectedModule.value?.b_id) !== moduleId)
return;
const i18nPanel = i18nPanelRef.value;
if (i18nPanel && typeof i18nPanel.reconcileManagedRows === "function") {
// 保存时才按最终草稿态生成受管键(模块编码/字段/权限),避免编辑过程中实时生成。
i18nPanel.reconcileManagedRows(finalI18nItems.value);
}
if (typeof i18nPanel?.buildSaveRequest === "function") {
const i18nReq = await i18nPanel.buildSaveRequest();
if (i18nReq) saveReqs.push(i18nReq);
@@ -859,19 +943,6 @@ void loadTree().catch((e) => message.error("模块树加载失败:" + (e.messa
/>
</a-space>
</div>
<span class="mm-header-title">{{
selectedModule.b_name || selectedModule.b_code
}}</span>
<span class="mm-header-code">{{ selectedModule.b_code || "(未设置编码)" }}</span>
<span class="mm-header-type">
<a-tag>{{
selectedModule.b_module_type === "directory"
? "目录"
: selectedModule.b_module_type === "page"
? "页面"
: "数据"
}}</a-tag>
</span>
</div>
<!-- Tab 面板 -->
@@ -943,7 +1014,7 @@ void loadTree().catch((e) => message.error("模块树加载失败:" + (e.messa
v-else-if="tab.key === 'i18n'"
:ref="setI18nPanelRef"
:module-id="String(selectedModule.b_id)"
:i18n-keys="i18nKeys"
:i18n-items="i18nItems"
:loading="busy"
/>
</a-tab-pane>
@@ -1051,22 +1122,6 @@ void loadTree().catch((e) => message.error("模块树加载失败:" + (e.messa
justify-content: center;
}
.mm-header-title {
font-size: 16px;
font-weight: 600;
color: var(--fms-text);
}
.mm-header-code {
font-size: 13px;
color: var(--fms-text-secondary);
font-family: monospace;
}
.mm-header-type {
flex: none;
}
.mm-body {
flex: 1;
min-height: 0;