20260728225443

This commit is contained in:
oneao committed 2026-07-28 22:54:43 +08:00
1 parent 5ec7aadef9
commit f6b79ea09a
30 files changed
+1541 -1120

No files matched your search

@@ -44,9 +44,12 @@ const treeData = computed(() =>
}),
);
const selectedGroup = computed(() =>
localGroups.value.find((g) => String(g.b_id) === selectedGroupId.value),
);
const selectedGroup = computed(() => {
for (const group of localGroups.value) {
if (String(group.b_id) === selectedGroupId.value) return group;
}
return null;
});
function onTreeSelect(keys) {
if (keys.length > 0) {
@@ -72,14 +75,25 @@ function addGroup(parentId) {
function deleteGroup() {
if (!selectedGroupId.value) return;
const hasChildren = localGroups.value.some(
(group) => String(group.b_parent_id) === selectedGroupId.value,
);
let hasChildren = false;
for (const group of localGroups.value) {
if (String(group.b_parent_id) === selectedGroupId.value) {
hasChildren = true;
break;
}
}
if (hasChildren) {
message.warning("该分组下还有子分组,请先删除子分组");
return;
}
if (props.referencedGroupIds.map(String).includes(selectedGroupId.value)) {
let isReferenced = false;
for (const groupId of props.referencedGroupIds) {
if (String(groupId) === selectedGroupId.value) {
isReferenced = true;
break;
}
}
if (isReferenced) {
message.warning("该分组仍被列表或编辑配置引用,请先清空引用");
return;
}
@@ -88,7 +102,14 @@ function deleteGroup() {
}
async function saveAllGroups() {
if (localGroups.value.some((group) => !String(group.b_title || "").trim())) {
let hasEmptyTitle = false;
for (const group of localGroups.value) {
if (!String(group.b_title || "").trim()) {
hasEmptyTitle = true;
break;
}
}
if (hasEmptyTitle) {
message.warning("请填写分组标题");
return;
}
@@ -161,7 +182,7 @@ function onClose() {
<a-input-number v-model:value="selectedGroup.b_xh" :min="0" />
</a-form-item>
<a-form-item label="启用">
<a-switch
<a-checkbox
:checked="selectedGroup.b_canuse === 1"
@update:checked="(v) => (selectedGroup.b_canuse = v ? 1 : 0)"
/>
@@ -2,30 +2,18 @@
import { ref } from "vue";
import { message } from "antdv-next";
import { loadDataApi, saveObjectApi } from "@/services/api";
import { useAppStore } from "@/stores/app";
// 快速设置多语言弹窗:按 key 加载 b_i18n 各语言值,保存后 emit("saved", key)。
// 调用方通过 ref 调用 open(key, defaultValue),defaultValue 用于默认语言的初始值。
const emit = defineEmits(["saved"]);
const appStore = useAppStore();
const visible = ref(false);
const saving = ref(false);
const i18nKey = ref("");
const entries = ref([]);
const langTypes = ref([]);
async function loadLangTypes() {
try {
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));
}
}
async function open(key, defaultValue = "") {
if (!key) {
@@ -35,22 +23,38 @@ async function open(key, defaultValue = "") {
i18nKey.value = key;
const escaped = key.replace(/'/g, "''");
try {
const [i18nRes] = await Promise.all([
const responses = await Promise.all([
loadDataApi("b_i18n", `b_key = '${escaped}'`, "b_locale ASC"),
loadLangTypes(),
loadDataApi("b_i18n_type", "b_canuse = 1", "b_default DESC, b_xh ASC, b_id ASC"),
]);
const i18nRes = responses[0];
const langRes = responses[1];
const rows = i18nRes.data || [];
const byLocale = Object.fromEntries(rows.map((r) => [r.b_locale, r]));
entries.value = langTypes.value.map((t) => {
const ex = byLocale[t.b_code];
const isDefault = t.b_default === 1 || t.b_code === "zh-CN";
return {
b_id: ex ? ex.b_id : null,
b_locale: t.b_code,
b_name: t.b_name,
b_value: ex ? ex.b_value : isDefault ? defaultValue : "",
};
});
const languages = langRes.data || [];
const byLocale = {};
for (const row of rows) {
byLocale[row.b_locale] = row;
}
const defaultLanguage = languages.find((item) => Number(item.b_default) === 1);
const defaultLocale = String(defaultLanguage?.b_code || languages[0]?.b_code || "");
const values = [];
for (const language of languages) {
const existing = byLocale[language.b_code];
let value = "";
if (existing) {
value = existing.b_value;
} else if (language.b_code === defaultLocale) {
value = defaultValue;
}
values.push({
b_id: existing ? existing.b_id : null,
b_locale: language.b_code,
b_name: language.b_name,
b_value: value,
});
}
entries.value = values;
} catch {
entries.value = [];
}
@@ -66,7 +70,7 @@ async function save() {
try {
const req = { table: "b_i18n", key_field: "b_id", inserts: [], updates: [] };
for (const e of entries.value) {
const value = e.b_value ?? "";
const value = e.b_value == null ? "" : e.b_value;
if (!e.b_id && !value) continue;
const row = { b_key: i18nKey.value, b_locale: e.b_locale, b_value: value };
if (e.b_id) req.updates.push({ ...row, b_id: e.b_id });
@@ -74,6 +78,7 @@ async function save() {
}
if (req.inserts.length || req.updates.length) {
await saveObjectApi([req]);
if (appStore.locale) await appStore.loadTranslations(appStore.locale);
}
message.success("多语言已保存");
visible.value = false;
@@ -1,6 +1,7 @@
<script setup>
import { computed, ref, watch } from "vue";
import { Languages } from "@lucide/vue";
import FmsMemoInput from "@/components/FmsMemoInput.vue";
import { buildModuleI18nKey } from "@/utils/i18n";
import I18nQuickSetModal from "./I18nQuickSetModal.vue";
@@ -24,8 +25,12 @@ watch(
{ immediate: true },
);
const isPage = computed(() => formData.value?.b_module_type === "page");
const isData = computed(() => formData.value?.b_module_type === "data");
const isPage = computed(() => {
return formData.value && formData.value.b_module_type === "page";
});
const isData = computed(() => {
return formData.value && formData.value.b_module_type === "data";
});
function emitUpdate() {
if (formData.value) emit("update", formData.value);
@@ -55,10 +60,12 @@ function onModuleTypeChange(v) {
const i18nModalRef = ref(null);
function openI18nModal() {
const key =
formData.value?.b_i18n ||
buildModuleI18nKey(formData.value?.b_module_type, formData.value?.b_code);
i18nModalRef.value?.open(key, formData.value?.b_name || "");
if (!formData.value || !i18nModalRef.value) return;
let key = formData.value.b_i18n;
if (!key) {
key = buildModuleI18nKey(formData.value.b_module_type, formData.value.b_code);
}
i18nModalRef.value.open(key, formData.value.b_name || "");
}
function onI18nSaved(key) {
@@ -155,9 +162,10 @@ function onI18nSaved(key) {
</a-col>
<a-col v-if="isData" :span="8">
<a-form-item label="默认排序">
<a-input
:value="formData.b_orderfield"
@update:value="(v) => setField('b_orderfield', v)"
<FmsMemoInput
:model-value="formData.b_orderfield || ''"
:disabled="loading"
@update:model-value="(v) => setField('b_orderfield', v)"
/>
</a-form-item>
</a-col>
@@ -22,7 +22,11 @@ watch(
() => props.editConfig,
(val) => {
if (val === localEdit.value) return;
localEdit.value = val?.map((r) => ({ ...r })) || [];
const rows = [];
for (const row of val || []) {
rows.push({ ...row });
}
localEdit.value = rows;
},
{ immediate: true },
);
@@ -32,24 +36,31 @@ function emitUpdate() {
}
function onRowOrderChange(rows) {
localEdit.value = rows.map((row, index) => ({
...row,
b_xh: (index + 1) * 10,
}));
const sortedRows = [];
for (let index = 0; index < rows.length; index++) {
sortedRows.push({ ...rows[index], b_xh: (index + 1) * 10 });
}
localEdit.value = sortedRows;
emitUpdate();
}
const availableFields = computed(() =>
(props.fields || [])
.filter((f) => f.b_canuse === 1)
.map((f) => ({ label: f.b_name || f.b_field, value: String(f.b_id) })),
);
const availableFields = computed(() => {
const options = [];
for (const field of props.fields || []) {
if (field.b_canuse !== 1) continue;
options.push({ label: field.b_name || field.b_field, value: String(field.b_id) });
}
return options;
});
const groupOptions = computed(() =>
(props.groups || [])
.filter((g) => g.b_canuse === 1)
.map((g) => ({ label: g.b_title || `分组-${g.b_id}`, value: String(g.b_id) })),
);
const groupOptions = computed(() => {
const options = [];
for (const group of props.groups || []) {
if (group.b_canuse !== 1) continue;
options.push({ label: group.b_title || `分组-${group.b_id}`, value: String(group.b_id) });
}
return options;
});
function deleteRow(record) {
if (props.loading) return;
@@ -57,16 +68,6 @@ function deleteRow(record) {
emitUpdate();
}
function makeSwitchCol(field, title, width) {
return {
dataIndex: field,
title,
width: width || 60,
align: "center",
editor: { type: "checkbox" },
};
}
const columns = shallowRef([
{
dataIndex: "b_field_id",
@@ -77,9 +78,27 @@ const columns = shallowRef([
options: () => availableFields.value,
},
},
makeSwitchCol("b_required", "必填"),
makeSwitchCol("b_readonly", "只读"),
makeSwitchCol("b_disabled", "禁用"),
{
dataIndex: "b_required",
title: "必填",
width: 60,
align: "center",
editor: { type: "checkbox" },
},
{
dataIndex: "b_readonly",
title: "只读",
width: 60,
align: "center",
editor: { type: "checkbox" },
},
{
dataIndex: "b_disabled",
title: "禁用",
width: 60,
align: "center",
editor: { type: "checkbox" },
},
{
dataIndex: "b_group_id",
title: "编辑分组",
@@ -1,7 +1,10 @@
<script setup>
import { ref, watch } from "vue";
import { h, ref, watch } from "vue";
import { message } from "antdv-next";
import { Languages } from "@lucide/vue";
import FmsTable from "@/components/fms-table/FmsTable.vue";
import { syncModuleItemI18nKeys } from "@/utils/i18n";
import { buildModuleItemI18nKey, syncModuleItemI18nKeys } from "@/utils/i18n";
import I18nQuickSetModal from "./I18nQuickSetModal.vue";
const FIELD_TYPES = [
{ label: "输入框", value: "input" },
@@ -10,7 +13,7 @@ const FIELD_TYPES = [
{ label: "金额", value: "money" },
{ label: "日期", value: "date" },
{ label: "日期时间", value: "datetime" },
{ label: "开关", value: "switch" },
{ label: "复选框", value: "checkbox" },
{ label: "下拉选择", value: "select" },
];
@@ -21,16 +24,20 @@ const props = defineProps({
loading: { type: Boolean, default: false },
});
const emit = defineEmits(["update"]);
const emit = defineEmits(["update", "i18nSaved"]);
const localFields = ref([]);
const activeKey = ref(null);
const i18nModalRef = ref(null);
watch(
() => [props.fields, props.moduleCode],
([val, moduleCode], previous = []) => {
if (val === localFields.value && moduleCode === previous[1]) return;
const rows = val?.map((row) => ({ ...row })) || [];
const rows = [];
for (const row of val || []) {
rows.push({ ...row });
}
const changed = syncModuleItemI18nKeys(rows, "field", moduleCode, "b_field");
localFields.value = rows;
if (changed) emit("update", rows);
@@ -48,6 +55,59 @@ function onCellChange() {
emitUpdate();
}
function openI18n(row) {
const key = buildModuleItemI18nKey("field", props.moduleCode, row.b_field);
if (!key) {
message.warning("请先填写字段名和模块编码");
return;
}
if (i18nModalRef.value) {
i18nModalRef.value.open(key, row.b_name || row.b_field || "");
}
}
const I18nKeyCell = ({ row, cellValue }) =>
h(
"span",
{
style: {
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: "4px",
width: "100%",
},
},
[
h(
"span",
{ style: { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" } },
cellValue || "",
),
h(
"button",
{
type: "button",
title: "设置多语言",
style: {
flex: "none",
border: "none",
background: "transparent",
cursor: "pointer",
padding: "2px",
display: "inline-flex",
alignItems: "center",
},
onClick: (event) => {
event.stopPropagation();
openI18n(row);
},
},
[h(Languages, { size: 14 })],
),
],
);
const columns = ref([
{ dataIndex: "b_field", title: "字段名", width: 140 },
{
@@ -75,6 +135,7 @@ const columns = ref([
dataIndex: "b_i18n",
title: "多语言键",
width: 220,
customCell: I18nKeyCell,
},
{
dataIndex: "b_canuse",
@@ -99,6 +160,7 @@ const columns = ref([
@change="onCellChange"
/>
</div>
<I18nQuickSetModal ref="i18nModalRef" @saved="(key) => emit('i18nSaved', key)" />
</div>
</template>
@@ -24,49 +24,59 @@ const langOptions = ref([]);
// 扁平原始数据(b_id, b_key, b_locale, b_value),仅用于保存时差量对比。
const originalsFlat = ref([]);
// 透视后的行数据:一行一个 key,每种语言的值挂在对应 locale 字段上。
// 形如 { _id, b_key, "zh-CN": "…", "en-US": "…", _localeIds: { "zh-CN": b_id, … } }
const pivotRows = ref([]);
const isLoading = ref(false);
const searchKey = ref("");
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),
),
]);
const managedItems = computed(() => {
const items = [];
for (const item of props.i18nItems || []) {
if (item && item.managed) items.push(item);
}
return items;
});
const requestedKeys = computed(() => {
const keys = new Set();
for (const item of props.i18nItems || []) {
if (!item) continue;
if (item.key) keys.add(String(item.key));
if (item.sourceKey) keys.add(String(item.sourceKey));
}
return Array.from(keys);
});
function isManagedRow(row) {
return Boolean(row?._managedId);
return Boolean(row && 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 rowByManagedId = new Map();
const unclaimedByKey = new Map();
for (const row of rows) {
if (row._managedId) {
rowByManagedId.set(String(row._managedId), row);
} else {
unclaimedByKey.set(String(row.b_key), row);
}
}
const currentManagedIds = new Set();
const defaultLocale =
langOptions.value.find((item) => item.default)?.value || langOptions.value[0]?.value || "";
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));
let row = rowByManagedId.get(managedId);
if (!row) row = unclaimedByKey.get(String(item.key));
if (!row && item.sourceKey) row = unclaimedByKey.get(String(item.sourceKey));
if (!row) {
row = { _id: createTempId(), b_key: item.key, _localeIds: {} };
rows.push(row);
@@ -74,8 +84,13 @@ function reconcileManagedRows(itemsOverride) {
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;
if (
defaultLocale &&
(row[defaultLocale] == null ||
row[defaultLocale] === "" ||
row[defaultLocale] === previousDefault)
) {
row[defaultLocale] = nextDefault;
}
row.b_key = String(item.key);
row._managedId = managedId;
@@ -103,10 +118,15 @@ async function loadLangTypes() {
"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,
}));
const options = [];
for (const language of res.data || []) {
options.push({
label: `${language.b_name} (${language.b_code})`,
value: language.b_code,
default: Number(language.b_default) === 1,
});
}
langOptions.value = options;
} catch (e) {
message.error("加载语言类型失败:" + (e.message || e));
}
@@ -132,32 +152,40 @@ function buildPivot(flat) {
}
async function loadI18n() {
const requestId = ++loadRequestId;
const moduleId = String(props.moduleId);
isLoading.value = true;
try {
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([
const responses = await Promise.all([
where
? loadDataApi("b_i18n", where, "b_key ASC, b_locale ASC")
: Promise.resolve({ data: [] }),
loadLangTypes(),
]);
const flat = (i18nRes.data || []).map((r) => ({
b_id: r.b_id,
b_key: r.b_key,
b_locale: r.b_locale,
b_value: r.b_value,
}));
if (requestId !== loadRequestId) return;
const i18nRes = responses[0];
const flat = [];
for (const row of i18nRes.data || []) {
flat.push({
b_id: row.b_id,
b_key: row.b_key,
b_locale: row.b_locale,
b_value: row.b_value,
});
}
if (String(props.moduleId) !== moduleId) return;
originalsFlat.value = cloneData(flat);
pivotRows.value = buildPivot(flat);
reconcileManagedRows();
} catch (e) {
if (requestId === loadRequestId) message.error("加载多语言失败:" + (e.message || e));
if (String(props.moduleId) === moduleId) {
message.error("加载多语言失败:" + (e.message || e));
}
} finally {
if (requestId === loadRequestId) isLoading.value = false;
if (String(props.moduleId) === moduleId) {
isLoading.value = false;
}
}
}
@@ -166,12 +194,15 @@ async function refreshKey(key) {
const escaped = String(key).replace(/'/g, "''");
try {
const res = await loadDataApi("b_i18n", "b_key = '" + escaped + "'", "b_locale ASC");
const rows = (res.data || []).map((row) => ({
b_id: row.b_id,
b_key: row.b_key,
b_locale: row.b_locale,
b_value: row.b_value,
}));
const rows = [];
for (const row of res.data || []) {
rows.push({
b_id: row.b_id,
b_key: row.b_key,
b_locale: row.b_locale,
b_value: row.b_value,
});
}
originalsFlat.value = [
...originalsFlat.value.filter((row) => row.b_key !== key),
...cloneData(rows),
@@ -194,7 +225,9 @@ async function addRow() {
activeKey.value = String(newId);
await nextTick();
await tableRef.value?.editRow(String(newId), "b_key");
if (tableRef.value) {
await tableRef.value.editRow(String(newId), "b_key");
}
}
function deleteRow(record) {
@@ -272,7 +305,7 @@ function flatten(rows) {
// 由父级「同步字段」调用:把默认翻译条目({ key, locale, value, managedId })并入草稿行。
// 已存在同 b_key 的行只补全空值(不覆盖用户配置),避免新增重复键;逻辑见 utils/i18n.mergeI18nDefaults。
function mergeDefaults(entries) {
if (!entries?.length) return;
if (!entries || entries.length === 0) return;
pivotRows.value = mergeI18nDefaults(pivotRows.value, entries);
}
@@ -296,7 +329,11 @@ async function loadExistingByKeyLocale(keys) {
// 由父级统一保存调用:返回 b_i18n 的变更请求(无变更返回 null),保存成功后父级调用 markSaved 同步基线
async function buildSaveRequest() {
// 保存涉及的键(受管键可能在新建/改名模块时才生成,打开面板时未被加载)
const keys = [...new Set(pivotRows.value.map((r) => String(r.b_key)).filter(Boolean))];
const keySet = new Set();
for (const row of pivotRows.value) {
if (row.b_key) keySet.add(String(row.b_key));
}
const keys = Array.from(keySet);
if (keys.length) {
const existing = await loadExistingByKeyLocale(keys);
if (existing.size) {
@@ -362,7 +399,6 @@ const columns = computed(() => {
title: "键",
width: 240,
align: "left",
headerAlign: "left",
editor: { type: "input", disabled: (row) => isManagedRow(row) },
},
];
@@ -372,7 +408,6 @@ const columns = computed(() => {
title: opt.label,
width: 180,
align: "left",
headerAlign: "left",
editor: { type: "input" },
});
}
@@ -22,7 +22,11 @@ watch(
() => props.listConfig,
(val) => {
if (val === localList.value) return;
localList.value = val?.map((r) => ({ ...r })) || [];
const rows = [];
for (const row of val || []) {
rows.push({ ...row });
}
localList.value = rows;
},
{ immediate: true },
);
@@ -32,24 +36,31 @@ function emitUpdate() {
}
function onRowOrderChange(rows) {
localList.value = rows.map((row, index) => ({
...row,
b_xh: (index + 1) * 10,
}));
const sortedRows = [];
for (let index = 0; index < rows.length; index++) {
sortedRows.push({ ...rows[index], b_xh: (index + 1) * 10 });
}
localList.value = sortedRows;
emitUpdate();
}
const availableFields = computed(() =>
(props.fields || [])
.filter((f) => f.b_canuse === 1)
.map((f) => ({ label: f.b_name || f.b_field, value: String(f.b_id) })),
);
const availableFields = computed(() => {
const options = [];
for (const field of props.fields || []) {
if (field.b_canuse !== 1) continue;
options.push({ label: field.b_name || field.b_field, value: String(field.b_id) });
}
return options;
});
const groupOptions = computed(() =>
(props.groups || [])
.filter((g) => g.b_canuse === 1)
.map((g) => ({ label: g.b_title || `分组-${g.b_id}`, value: String(g.b_id) })),
);
const groupOptions = computed(() => {
const options = [];
for (const group of props.groups || []) {
if (group.b_canuse !== 1) continue;
options.push({ label: group.b_title || `分组-${group.b_id}`, value: String(group.b_id) });
}
return options;
});
function deleteRow(record) {
if (props.loading) return;
@@ -25,7 +25,10 @@ watch(
() => [props.powers, props.moduleCode],
([val, moduleCode], previous = []) => {
if (val === localPowers.value && moduleCode === previous[1]) return;
const rows = (val || []).map((row) => ({ ...row }));
const rows = [];
for (const row of val || []) {
rows.push({ ...row });
}
const changed = syncModuleItemI18nKeys(rows, "power", moduleCode, "b_code");
localPowers.value = rows;
if (changed) emit("update", rows);
@@ -40,7 +43,7 @@ function emitUpdate() {
async function addRow() {
if (props.loading) return;
const newId = createTempId();
localPowers.value.push({
const newRow = {
b_id: newId,
b_module_id: props.moduleId,
b_code: "",
@@ -48,12 +51,17 @@ async function addRow() {
b_i18n: null,
b_xh: (localPowers.value.length + 1) * 10,
b_canuse: 1,
});
};
const rows = localPowers.value.slice();
rows.push(newRow);
localPowers.value = rows;
emitUpdate();
activeKey.value = String(newId);
await nextTick();
await tableRef.value?.editRow(String(newId), "b_code");
if (tableRef.value) {
await tableRef.value.editRow(String(newId), "b_code");
}
}
function deleteRow(record) {
@@ -112,7 +120,9 @@ function openI18n(row) {
message.warning("请先填写操作编码");
return;
}
i18nModalRef.value?.open(key, row.b_name || "");
if (i18nModalRef.value) {
i18nModalRef.value.open(key, row.b_name || "");
}
}
// 多语言键单元格:值 + 右侧翻译 icon 按钮(原生 button,避免 antdv Button 组件开销)
@@ -159,10 +169,30 @@ const I18nKeyCell = ({ row, cellValue }) =>
);
const columns = ref([
{ dataIndex: "b_code", title: "操作编码", width: 160, editor: { type: "input" } },
{ dataIndex: "b_name", title: "操作名称", width: 160, editor: { type: "input" } },
{ dataIndex: "b_i18n", title: "多语言键", width: 160, customCell: I18nKeyCell },
{ dataIndex: "b_xh", title: "排序", width: 80, editor: { type: "input" } },
{
dataIndex: "b_code",
title: "操作编码",
width: 160,
editor: { type: "input" },
},
{
dataIndex: "b_name",
title: "操作名称",
width: 160,
editor: { type: "input" },
},
{
dataIndex: "b_i18n",
title: "多语言键",
width: 160,
customCell: I18nKeyCell,
},
{
dataIndex: "b_xh",
title: "排序",
width: 80,
editor: { type: "input" },
},
{
dataIndex: "b_canuse",
title: "启用",
@@ -34,7 +34,11 @@ watch(
() => props.queryConfig,
(val) => {
if (val === localQuery.value) return;
localQuery.value = val?.map((r) => ({ ...r })) || [];
const rows = [];
for (const row of val || []) {
rows.push({ ...row });
}
localQuery.value = rows;
},
{ immediate: true },
);
@@ -44,18 +48,22 @@ function emitUpdate() {
}
function onRowOrderChange(rows) {
localQuery.value = rows.map((row, index) => ({
...row,
b_xh: (index + 1) * 10,
}));
const sortedRows = [];
for (let index = 0; index < rows.length; index++) {
sortedRows.push({ ...rows[index], b_xh: (index + 1) * 10 });
}
localQuery.value = sortedRows;
emitUpdate();
}
const availableFields = computed(() =>
(props.fields || [])
.filter((f) => f.b_canuse === 1)
.map((f) => ({ label: f.b_name || f.b_field, value: String(f.b_id) })),
);
const availableFields = computed(() => {
const options = [];
for (const field of props.fields || []) {
if (field.b_canuse !== 1) continue;
options.push({ label: field.b_name || field.b_field, value: String(field.b_id) });
}
return options;
});
function addRow() {
if (props.loading) return;
@@ -4,6 +4,7 @@ import { message, Modal } from "antdv-next";
import FmsLoadingOverlay from "@/components/FmsLoadingOverlay.vue";
import FmsTree from "@/components/FmsTree.vue";
import { loadDataApi, saveObjectApi, nextIdApi, describeApi } from "@/services/api";
import { useAppStore } from "@/stores/app";
import {
cloneData,
diffRows,
@@ -30,6 +31,7 @@ import ModulePowersPanel from "./components/ModulePowersPanel.vue";
import ModuleI18nPanel from "./components/ModuleI18nPanel.vue";
// ---- 状态 ----
const appStore = useAppStore();
const treeData = ref([]);
const treeLoading = ref(false);
const expandedTreeKeys = ref([]);
@@ -80,8 +82,6 @@ const configSaving = ref(false);
const configSyncing = ref(false);
const i18nPanelRef = ref(null);
const loadedModuleId = ref(null);
let configRequestId = 0;
let operationId = 0;
// 使用函数 ref,避免组件位于 TabPane 的 v-for 内时被 Vue 收集成实例数组。
function setI18nPanelRef(instance) {
@@ -94,10 +94,6 @@ const busy = computed(() => configLoading.value || configSaving.value || configS
const syncDisabled = computed(
() => busy.value || !String(selectedModule.value?.b_viewtable || "").trim(),
);
const isCurrentModule = computed(
() =>
loadedModuleId.value != null && String(loadedModuleId.value) === String(selectedModuleId.value),
);
// 构建受管多语言项。managedSource 提供模块/字段/权限的受管键来源,
// plainSource 提供分组/列表标题等非受管键来源(手动填写,需即时反馈)。
function buildI18nItems(managedSource, plainSource) {
@@ -245,64 +241,70 @@ async function loadTree() {
}
// ---- 模块配置加载 ----
async function loadModuleConfig(moduleId, requestId) {
async function loadModuleConfig(moduleId) {
configLoading.value = true;
try {
const id = String(moduleId);
const moduleType = findNode(id)?.b_module_type;
const requests = [["module", loadDataApi("s_module", `b_id = ${id}`)]];
let moduleResponse;
let fieldsResponse;
let groupsResponse;
let listResponse;
let editResponse;
let queryResponse;
let powersResponse;
if (moduleType === "data") {
requests.push(
["fields", loadDataApi("s_module_field", `b_module_id = ${id}`, "b_id ASC")],
[
"groups",
loadDataApi("s_module_field_group", `b_module_id = ${id}`, "b_xh ASC, b_id ASC"),
],
[
"listConfig",
loadDataApi("s_module_field_list", `b_module_id = ${id}`, "b_xh ASC, b_id ASC"),
],
[
"editConfig",
loadDataApi("s_module_field_edit", `b_module_id = ${id}`, "b_xh ASC, b_id ASC"),
],
[
"queryConfig",
loadDataApi("s_module_field_query", `b_module_id = ${id}`, "b_xh ASC, b_id ASC"),
],
);
const responses = await Promise.all([
loadDataApi("s_module", `b_id = ${id}`),
loadDataApi("s_module_field", `b_module_id = ${id}`, "b_id ASC"),
loadDataApi("s_module_field_group", `b_module_id = ${id}`, "b_xh ASC, b_id ASC"),
loadDataApi("s_module_field_list", `b_module_id = ${id}`, "b_xh ASC, b_id ASC"),
loadDataApi("s_module_field_edit", `b_module_id = ${id}`, "b_xh ASC, b_id ASC"),
loadDataApi("s_module_field_query", `b_module_id = ${id}`, "b_xh ASC, b_id ASC"),
]);
moduleResponse = responses[0];
fieldsResponse = responses[1];
groupsResponse = responses[2];
listResponse = responses[3];
editResponse = responses[4];
queryResponse = responses[5];
} else if (moduleType === "page") {
requests.push([
"powers",
const responses = await Promise.all([
loadDataApi("s_module", `b_id = ${id}`),
loadDataApi("s_module_power", `b_module_id = ${id}`, "b_xh ASC, b_id ASC"),
]);
moduleResponse = responses[0];
powersResponse = responses[1];
} else {
moduleResponse = await loadDataApi("s_module", `b_id = ${id}`);
}
const responses = Object.fromEntries(
await Promise.all(requests.map(async ([key, request]) => [key, await request])),
);
if (String(selectedModuleId.value) !== id) return;
const original = {
module: responses.module.data?.[0] || null,
fields: responses.fields?.data || [],
groups: responses.groups?.data || [],
listConfig: responses.listConfig?.data || [],
editConfig: responses.editConfig?.data || [],
queryConfig: responses.queryConfig?.data || [],
powers: responses.powers?.data || [],
module: moduleResponse.data?.[0] || null,
fields: fieldsResponse ? fieldsResponse.data || [] : [],
groups: groupsResponse ? groupsResponse.data || [] : [],
listConfig: listResponse ? listResponse.data || [] : [],
editConfig: editResponse ? editResponse.data || [] : [],
queryConfig: queryResponse ? queryResponse.data || [] : [],
powers: powersResponse ? powersResponse.data || [] : [],
};
if (requestId !== configRequestId) return;
originals.value = original;
drafts.value = cloneData(original);
loadedModuleId.value = id;
activeTab.value = "basic";
} catch (e) {
if (requestId === configRequestId) {
if (String(selectedModuleId.value) === String(moduleId)) {
loadedModuleId.value = null;
drafts.value.module = null;
message.error("模块配置加载失败:" + (e.message || e));
}
} finally {
if (requestId === configRequestId) configLoading.value = false;
if (String(selectedModuleId.value) === String(moduleId)) {
configLoading.value = false;
}
}
}
@@ -315,8 +317,6 @@ function onTreeSelect(keys) {
}
watch(selectedModuleId, (id) => {
const requestId = ++configRequestId;
operationId++;
if (!id) {
configLoading.value = false;
loadedModuleId.value = null;
@@ -329,7 +329,7 @@ watch(selectedModuleId, (id) => {
return;
}
loadedModuleId.value = null;
void loadModuleConfig(id, requestId);
void loadModuleConfig(id);
});
// ---- 子面板数据变更 ----
@@ -345,7 +345,7 @@ function onSetField(field, value) {
// 分组管理已自行保存到后端,同步更新 originals 和 drafts
function onGroupsSaved(newGroups, idMapping) {
if (idMapping?.size) {
if (idMapping && idMapping.size) {
remapForeignKeys(drafts.value.listConfig || [], ["b_group_id"], idMapping);
remapForeignKeys(drafts.value.editConfig || [], ["b_group_id"], idMapping);
}
@@ -354,7 +354,9 @@ function onGroupsSaved(newGroups, idMapping) {
}
function onI18nSaved(key) {
void i18nPanelRef.value?.refreshKey?.(key);
if (i18nPanelRef.value) {
void i18nPanelRef.value.refreshKey(key);
}
}
// ---- 新增模块 ----
@@ -442,7 +444,7 @@ function findNode(key) {
function hasChildNode(moduleId) {
const node = findNode(moduleId);
return !!node?.children?.length;
return Boolean(node && node.children && node.children.length);
}
async function onDeleteNodeById(moduleId) {
@@ -486,9 +488,10 @@ async function onDeleteNodeById(moduleId) {
// ---- 统一保存 ----
async function saveAll() {
if (!selectedModule.value || !isCurrentModule.value || busy.value) return;
if (!selectedModule.value || busy.value) return;
if (loadedModuleId.value == null) return;
if (String(loadedModuleId.value) !== String(selectedModuleId.value)) return;
const moduleId = String(selectedModule.value.b_id);
const currentOperationId = ++operationId;
configSaving.value = true;
try {
@@ -497,9 +500,13 @@ async function saveAll() {
// s_module 主表
const moduleData = drafts.value.module;
const moduleOriginal = originals.value.module;
const moduleCode = String(moduleData?.b_code || "").trim();
const moduleCode = String(moduleData.b_code || "").trim();
if (!moduleCode) {
message.warning(`请填写${moduleData?.b_module_type === "data" ? "模块编码" : "菜单编码"}`);
if (moduleData.b_module_type === "data") {
message.warning("请填写模块编码");
} else {
message.warning("请填写菜单编码");
}
return;
}
const codeError = validateModuleCode(moduleCode);
@@ -507,7 +514,7 @@ async function saveAll() {
message.error(`编码格式不正确:${codeError}`);
return;
}
if (!String(moduleData?.b_name || "").trim()) {
if (!String(moduleData.b_name || "").trim()) {
message.warning("请填写模块名称");
return;
}
@@ -522,13 +529,18 @@ 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;
const isNew = !moduleOriginal || !moduleOriginal.b_code;
if (moduleData && JSON.stringify(moduleData) !== JSON.stringify(moduleOriginal)) {
saveReqs.push({
const moduleRequest = {
table: "s_module",
key_field: "b_id",
[isNew ? "inserts" : "updates"]: [moduleData],
});
};
if (isNew) {
moduleRequest.inserts = [moduleData];
} else {
moduleRequest.updates = [moduleData];
}
saveReqs.push(moduleRequest);
}
// 子表:fields, listConfig, editConfig, queryConfig, powers
@@ -558,14 +570,13 @@ async function saveAll() {
}
// 多语言:由各子面板收集变更,统一在此保存
if (currentOperationId !== operationId || String(selectedModule.value?.b_id) !== moduleId)
return;
if (!selectedModule.value || 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") {
if (i18nPanel && typeof i18nPanel.buildSaveRequest === "function") {
const i18nReq = await i18nPanel.buildSaveRequest();
if (i18nReq) saveReqs.push(i18nReq);
}
@@ -579,8 +590,11 @@ async function saveAll() {
// 更新 originals
originals.value = cloneData(drafts.value);
i18nPanelRef.value?.markSaved?.();
if (i18nPanelRef.value) {
i18nPanelRef.value.markSaved();
}
updateSavedTreeNode(moduleData);
if (appStore.locale) await appStore.loadTranslations(appStore.locale);
message.success("保存成功");
} catch (e) {
message.error("保存失败:" + (e.message || e));
@@ -591,12 +605,16 @@ async function saveAll() {
// ---- 字段同步与默认配置 ----
function configuredFieldIds(section) {
return new Set(
[...(originals.value[section] || []), ...(drafts.value[section] || [])]
.map((row) => row.b_field_id)
.filter((id) => id != null)
.map(String),
);
const ids = new Set();
const originalRows = originals.value[section] || [];
const draftRows = drafts.value[section] || [];
for (const row of originalRows) {
if (row.b_field_id != null) ids.add(String(row.b_field_id));
}
for (const row of draftRows) {
if (row.b_field_id != null) ids.add(String(row.b_field_id));
}
return ids;
}
function buildMissingDefaultConfigs(module, fields) {
@@ -606,77 +624,84 @@ function buildMissingDefaultConfigs(module, fields) {
const queryFieldIds = configuredFieldIds("queryConfig");
const generated = { listConfig: [], editConfig: [], queryConfig: [] };
fields
.filter((field) => field.b_canuse === 1)
.forEach((field, index) => {
const fieldId = String(field.b_id);
const order = (index + 1) * 10;
let orderIndex = 0;
for (const field of fields) {
if (field.b_canuse !== 1) continue;
orderIndex++;
const fieldId = String(field.b_id);
const order = orderIndex * 10;
if (!listFieldIds.has(fieldId)) {
generated.listConfig.push({
b_id: createTempId(),
b_module_id: moduleId,
b_field_id: fieldId,
b_group_id: null,
b_title: null,
b_i18n: field.b_i18n || null,
b_visible: 1,
b_xh: order,
b_width: 100,
b_sortable: 0,
});
}
if (!listFieldIds.has(fieldId)) {
generated.listConfig.push({
b_id: createTempId(),
b_module_id: moduleId,
b_field_id: fieldId,
b_group_id: null,
b_title: null,
b_i18n: field.b_i18n || null,
b_visible: 1,
b_xh: order,
b_width: 100,
b_sortable: 0,
});
}
if (!editFieldIds.has(fieldId)) {
generated.editConfig.push({
b_id: createTempId(),
b_module_id: moduleId,
b_field_id: fieldId,
b_required: 0,
b_readonly: 0,
b_disabled: 0,
b_group_id: null,
b_xh: order,
});
}
if (!editFieldIds.has(fieldId)) {
generated.editConfig.push({
b_id: createTempId(),
b_module_id: moduleId,
b_field_id: fieldId,
b_required: 0,
b_readonly: 0,
b_disabled: 0,
b_group_id: null,
b_xh: order,
});
}
if (!queryFieldIds.has(fieldId)) {
generated.queryConfig.push({
b_id: createTempId(),
b_module_id: moduleId,
b_field_id: fieldId,
b_component: "input",
b_operator: "=",
b_default_value: null,
b_xh: order,
b_canuse: 1,
});
}
});
if (!queryFieldIds.has(fieldId)) {
generated.queryConfig.push({
b_id: createTempId(),
b_module_id: moduleId,
b_field_id: fieldId,
b_component: "input",
b_operator: "=",
b_default_value: null,
b_xh: order,
b_canuse: 1,
});
}
}
return generated;
}
async function syncFields() {
const module = selectedModule.value;
const view = String(module?.b_viewtable || "").trim();
if (!module) return;
const view = String(module.b_viewtable || "").trim();
if (!view || busy.value) return;
const moduleId = String(module.b_id);
const currentOperationId = ++operationId;
try {
configSyncing.value = true;
const res = await describeApi(view);
if (currentOperationId !== operationId || String(selectedModule.value?.b_id) !== moduleId)
return;
if (!selectedModule.value || String(selectedModule.value.b_id) !== moduleId) return;
const columns = res.data || [];
const existingFields = drafts.value.fields || [];
// 构建视图列名集合
const viewColumnSet = new Set(columns.map((c) => c.name.toLowerCase()));
const viewColumnSet = new Set();
for (const column of columns) {
viewColumnSet.add(column.name.toLowerCase());
}
// 已有字段映射(按字段名)
const existingMap = new Map(existingFields.map((f) => [f.b_field?.toLowerCase(), f]));
const existingMap = new Map();
for (const field of existingFields) {
if (!field.b_field) continue;
existingMap.set(field.b_field.toLowerCase(), field);
}
const mergedFields = [];
@@ -704,15 +729,16 @@ async function syncFields() {
// 视图中不存在的已有字段标记为不可用
for (const existing of existingFields) {
if (!viewColumnSet.has(existing.b_field?.toLowerCase())) {
const fieldName = existing.b_field ? existing.b_field.toLowerCase() : "";
if (!viewColumnSet.has(fieldName)) {
mergedFields.push({ ...existing, b_canuse: 0 });
}
}
// 多语言键始终按 field.<模块代码>.<字段编码> 自动生成。
mergedFields.forEach((field) => {
for (const field of mergedFields) {
field.b_i18n = buildModuleItemI18nKey("field", module.b_code, field.b_field);
});
}
// 先为新字段分配真实 ID,后续配置通过外键引用这些 ID。
const changes = diffRows(mergedFields, existingFields);
@@ -725,38 +751,48 @@ async function syncFields() {
// 为"有 b_i18n 键"的字段生成多语言默认配置(b_i18n 草稿行),注入 i18n 面板,
// 由顶部"保存"统一落库;已存在的键由面板去重,不覆盖用户配置。
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,
managedId: `field:${f.b_id}`,
}));
let primaryLocale = "zh-CN";
const candidateI18n = [];
for (const field of mergedFields) {
if (field.b_canuse !== 1 || !field.b_i18n) continue;
candidateI18n.push({
key: field.b_i18n,
value: field.b_name || field.b_field,
managedId: `field:${field.b_id}`,
});
}
let primaryLocale = "";
try {
const langRes = await loadDataApi("b_i18n_type", "b_canuse = 1", "b_xh ASC, b_id ASC");
const langRes = await loadDataApi(
"b_i18n_type",
"b_canuse = 1",
"b_default DESC, b_xh ASC, b_id ASC",
);
const langs = langRes.data || [];
if (langs.length) {
// 默认字段名翻译写到"简体中文"(zh-CN),找不到再退回排序第一的语言
const zh = langs.find((l) => l.b_code === "zh-CN");
primaryLocale = (zh || langs[0]).b_code;
const defaultLanguage = langs.find((item) => Number(item.b_default) === 1);
primaryLocale = String(defaultLanguage?.b_code || langs[0].b_code || "");
}
} catch {
primaryLocale = "";
}
const i18nEntries = [];
if (primaryLocale) {
for (const item of candidateI18n) {
i18nEntries.push({
key: item.key,
locale: primaryLocale,
value: item.value,
managedId: item.managedId,
});
}
} catch (e) {
// 取不到语言类型时回退默认值
}
const i18nEntries = candidateI18n.map((e) => ({
key: e.key,
locale: primaryLocale,
value: e.value,
managedId: e.managedId,
}));
const generated = buildMissingDefaultConfigs(module, mergedFields);
const generatedSections = ["listConfig", "editConfig", "queryConfig"];
const generatedCount = generatedSections.reduce(
(total, section) => total + generated[section].length,
0,
);
let generatedCount = 0;
for (const section of generatedSections) {
generatedCount += generated[section].length;
}
if (!hasChanges(fieldReq) && !generatedCount && !i18nEntries.length) {
message.success("同步成功");
@@ -765,8 +801,8 @@ async function syncFields() {
// 多语言默认配置注入 i18n 面板草稿;字段已预分配真实 ID(见上方 allocateTemporaryIds),
// 列表/编辑/查询配置稍后追加到草稿。全部由顶部"保存"统一提交,期间不写库。
if (i18nEntries.length) {
i18nPanelRef.value?.mergeDefaults?.(i18nEntries);
if (i18nEntries.length && i18nPanelRef.value) {
i18nPanelRef.value.mergeDefaults(i18nEntries);
}
for (const section of generatedSections) {
@@ -850,14 +886,14 @@ void loadTree().catch((e) => message.error("模块树加载失败:" + (e.messa
<div class="mm-header-center">
<a-space size="middle">
<span>启用</span>
<a-switch
<a-checkbox
:checked="drafts.module?.b_canuse === 1"
:disabled="busy"
@update:checked="(v) => onSetField('b_canuse', v ? 1 : 0)"
/>
<a-divider type="vertical" />
<span>显示菜单</span>
<a-switch
<a-checkbox
:checked="drafts.module?.b_canmenu === 1"
:disabled="busy || drafts.module?.b_module_type === 'data'"
@update:checked="(v) => onSetField('b_canmenu', v ? 1 : 0)"
@@ -891,6 +927,7 @@ void loadTree().catch((e) => message.error("模块树加载失败:" + (e.messa
:fields="drafts.fields"
:loading="busy"
@update="(v) => onUpdate('fields', v)"
@i18n-saved="onI18nSaved"
/>
<ModuleListConfigPanel
v-else-if="tab.key === 'list'"
@@ -1,163 +1,143 @@
<script setup>
import { computed, h, onMounted, ref, shallowRef } from "vue";
import { message, Switch, Button, Popconfirm, Tag } from "antdv-next";
import { Plus } from "@lucide/vue";
import FmsTable from "@/components/fms-table/FmsTable.vue";
import { loadDataApi, saveObjectApi, nextIdApi } from "@/services/api";
import { usePermissionStore } from "@/stores/permissions";
import { computed, ref } from "vue";
import { message, Modal } from "antdv-next";
import { Plus, Trash2 } from "@lucide/vue";
import FmsModuleListPage from "@/components/fms-module-list/FmsModuleListPage.vue";
import { loadDataApi, nextIdApi, saveObjectApi } from "@/services/api";
import { useAppStore } from "@/stores/app";
const permissionStore = usePermissionStore();
const data = ref([]);
const loading = ref(false);
const appStore = useAppStore();
const listRef = ref(null);
const selectedRowKeys = ref([]);
const saving = ref(false);
const canCreate = computed(() => permissionStore.canPower("m_i18n_type", "create"));
const canUpdate = computed(() => permissionStore.canPower("m_i18n_type", "update"));
const canDelete = computed(() => permissionStore.canPower("m_i18n_type", "delete"));
const rowSelection = computed(() => {
return {
selectedRowKeys: selectedRowKeys.value,
getCheckboxProps: (row) => ({ disabled: Number(row.b_default) === 1 }),
onChange: (keys) => {
selectedRowKeys.value = keys;
},
};
});
async function loadData() {
loading.value = true;
const rowActions = computed(() => {
return [
{
key: "edit",
label: appStore.t("common.edit", "编辑"),
disabled: () => saving.value,
onClick: openEdit,
},
{
key: "delete",
type: "delete",
label: appStore.t("common.delete", "删除"),
confirm: appStore.t("common.confirmDeleteLanguage", "确定删除该语言?"),
disabled: ({ record }) => saving.value || Number(record.b_default) === 1,
onClick: removeRow,
},
];
});
async function reloadList() {
selectedRowKeys.value = [];
if (listRef.value) await listRef.value.reload();
try {
const res = await loadDataApi("b_i18n_type", "", "b_xh ASC, b_id ASC");
data.value = res.data || [];
} catch (e) {
message.error("加载多语言类型失败:" + (e?.message || e));
} finally {
loading.value = false;
await appStore.loadLanguages();
} catch (error) {
message.error("刷新语言配置失败:" + (error?.message || error));
}
}
onMounted(loadData);
/* ---------- 行内单元格渲染 ---------- */
function createDefaultCell() {
return (props) =>
props.row.b_default === 1
? h(Tag, { color: "blue" }, "默认")
: h("span", { class: "muted" }, "—");
}
function createCanuseCell() {
return (props) => {
const row = props.row;
return h(Switch, {
checked: row.b_canuse === 1,
size: "small",
disabled: !canUpdate.value,
"onUpdate:checked": (val) => {
const next = val ? 1 : 0;
if (row.b_canuse === next) return;
row.b_canuse = next;
saveRow(row, { b_canuse: next });
},
});
};
}
function createActionsCell() {
return (props) => {
const row = props.row;
const isDefault = row.b_default === 1;
const children = [];
if (canUpdate.value) {
children.push(
h(
Button,
{
type: "link",
size: "small",
onClick: (e) => {
e.stopPropagation();
openEdit(row);
},
},
"编辑",
),
);
}
if (canDelete.value && !isDefault) {
children.push(
h(
Popconfirm,
{
title: "确定删除该语言?",
okText: "删除",
okButtonProps: { danger: true },
onConfirm: () => removeRow(row),
},
{
default: () =>
h(
Button,
{ type: "link", size: "small", danger: true, onClick: (e) => e.stopPropagation() },
"删除",
),
},
),
);
}
return h("span", { class: "actions-cell" }, children);
};
}
const columns = shallowRef([
{ dataIndex: "b_code", title: "简称 / 编码", width: 150 },
{ dataIndex: "b_name", title: "名称", width: 160 },
{
dataIndex: "b_default",
title: "默认",
width: 80,
align: "center",
customCell: createDefaultCell(),
},
{
dataIndex: "b_canuse",
title: "是否启用",
width: 100,
align: "center",
customCell: createCanuseCell(),
},
{ dataIndex: "b_xh", title: "排序", width: 80, align: "center" },
{
dataIndex: "actions",
title: "操作",
width: 130,
align: "center",
customCell: createActionsCell(),
},
]);
/* ---------- 行内开关即时保存 ---------- */
async function saveRow(row, patch) {
try {
await saveObjectApi([
{ table: "b_i18n_type", key_field: "b_id", updates: [{ b_id: row.b_id, ...patch }] },
]);
message.success("已更新");
} catch (e) {
message.error("更新失败:" + (e?.message || e));
await loadData();
async function getOtherDefaultUpdates(currentId) {
const response = await loadDataApi("b_i18n_type", "b_default = 1", "b_id ASC");
const updates = [];
for (const row of response.data || []) {
if (String(row.b_id) === String(currentId)) continue;
updates.push({ b_id: row.b_id, b_default: 0 });
}
return updates;
}
async function removeRow(row) {
if (row.b_default === 1) return;
async function updateCheckbox(row, field, checked) {
if (saving.value) return;
saving.value = true;
try {
const value = checked ? 1 : 0;
const updates = [{ b_id: row.b_id, [field]: value }];
if (field === "b_default" && value === 1) {
updates.push(...(await getOtherDefaultUpdates(row.b_id)));
}
await saveObjectApi([
{ table: "b_i18n_type", key_field: "b_id", deletes: [{ b_id: row.b_id }] },
{ table: "b_i18n_type", key_field: "b_id", inserts: [], updates, deletes: [] },
]);
message.success("删除成功");
await loadData();
} catch (e) {
message.error("删除失败:" + (e?.message || e));
message.success(appStore.t("common.updateSuccess", "更新成功"));
await reloadList();
} catch (error) {
message.error(appStore.t("common.updateFailed", "更新失败") + ":" + (error?.message || error));
await reloadList();
} finally {
saving.value = false;
}
}
/* ---------- 新增 / 编辑弹窗 ---------- */
async function removeRow(row) {
if (saving.value || Number(row.b_default) === 1) return;
saving.value = true;
try {
await saveObjectApi([
{
table: "b_i18n_type",
key_field: "b_id",
inserts: [],
updates: [],
deletes: [{ b_id: row.b_id }],
},
]);
message.success(appStore.t("common.deleteSuccess", "删除成功"));
await reloadList();
} catch (error) {
message.error(appStore.t("common.deleteFailed", "删除失败") + ":" + (error?.message || error));
} finally {
saving.value = false;
}
}
function batchDelete() {
if (saving.value || !selectedRowKeys.value.length) return;
const keys = [...selectedRowKeys.value];
Modal.confirm({
title: `${appStore.t("common.confirmBatchDelete", "确定删除选中的记录?")} (${keys.length})`,
okText: appStore.t("common.delete", "删除"),
okType: "danger",
cancelText: appStore.t("common.cancel", "取消"),
onOk: async () => {
saving.value = true;
try {
const deletes = keys.map((b_id) => ({ b_id }));
await saveObjectApi([
{
table: "b_i18n_type",
key_field: "b_id",
inserts: [],
updates: [],
deletes,
},
]);
message.success(appStore.t("common.deleteSuccess", "删除成功"));
await reloadList();
} catch (error) {
message.error(
appStore.t("common.deleteFailed", "删除失败") + ":" + (error?.message || error),
);
} finally {
saving.value = false;
}
},
});
}
const modalVisible = ref(false);
const modalMode = ref("create");
const editingId = ref(null);
@@ -174,63 +154,69 @@ function openCreate() {
modalMode.value = "create";
editingId.value = null;
editingDefault.value = false;
form.value = { b_code: "", b_name: "", b_xh: 0, defaultOn: false, canuseOn: true };
form.value = {
b_code: "",
b_name: "",
b_xh: 0,
defaultOn: false,
canuseOn: true,
};
modalVisible.value = true;
}
function openEdit(row) {
modalMode.value = "edit";
editingId.value = row.b_id;
editingDefault.value = row.b_default === 1;
editingDefault.value = Number(row.b_default) === 1;
form.value = {
b_code: row.b_code,
b_name: row.b_name,
b_code: row.b_code || "",
b_name: row.b_name || "",
b_xh: row.b_xh ?? 0,
defaultOn: row.b_default === 1,
canuseOn: row.b_canuse === 1,
defaultOn: Number(row.b_default) === 1,
canuseOn: Number(row.b_canuse) === 1,
};
modalVisible.value = true;
}
async function saveForm() {
const f = form.value;
if (!f.b_code || !f.b_code.trim()) {
message.warning("请填写语言编码");
const current = form.value;
if (!String(current.b_code || "").trim()) {
message.warning(appStore.t("common.enterLanguageCode", "请填写语言编码"));
return;
}
if (!f.b_name || !f.b_name.trim()) {
message.warning("请填写语言名称");
if (!String(current.b_name || "").trim()) {
message.warning(appStore.t("common.enterLanguageName", "请填写语言名称"));
return;
}
saving.value = true;
try {
const isNew = modalMode.value === "create" || !editingId.value;
const b_id = isNew ? (await nextIdApi(1)).data : editingId.value;
const isNew = modalMode.value === "create";
const b_id = isNew ? (await nextIdApi()).data : editingId.value;
const row = {
b_id,
b_code: f.b_code.trim(),
b_name: f.b_name.trim(),
b_canuse: f.canuseOn ? 1 : 0,
b_default: f.defaultOn ? 1 : 0,
b_xh: f.b_xh ?? 0,
b_code: current.b_code.trim(),
b_name: current.b_name.trim(),
b_default: current.defaultOn ? 1 : 0,
b_canuse: current.canuseOn ? 1 : 0,
b_xh: current.b_xh ?? 0,
};
const request = {
table: "b_i18n_type",
key_field: "b_id",
inserts: isNew ? [row] : [],
updates: isNew ? [] : [row],
deletes: [],
};
const req = { table: "b_i18n_type", key_field: "b_id", inserts: [], updates: [], deletes: [] };
if (isNew) req.inserts.push(row);
else req.updates.push(row);
// 保证仅一个默认语言:若本次设为默认,则将其它语言取消默认
if (row.b_default === 1) {
for (const r of data.value) {
if (r.b_id !== b_id && r.b_default === 1) {
req.updates.push({ b_id: r.b_id, b_default: 0 });
}
}
request.updates.push(...(await getOtherDefaultUpdates(b_id)));
}
await saveObjectApi([req]);
message.success("保存成功");
await saveObjectApi([request]);
message.success(appStore.t("common.saveSuccess", "保存成功"));
modalVisible.value = false;
await loadData();
} catch (e) {
message.error("保存失败:" + (e?.message || e));
await reloadList();
} catch (error) {
message.error(appStore.t("common.saveFailed", "保存失败") + ":" + (error?.message || error));
} finally {
saving.value = false;
}
@@ -239,106 +225,90 @@ async function saveForm() {
<template>
<div class="i18n-type-page">
<div class="panel-card">
<div class="panel-toolbar">
<a-button v-if="canCreate" type="primary" @click="openCreate">
<Plus :size="16" /> 新增语言
</a-button>
</div>
<div class="panel-body">
<FmsTable
v-model:columns="columns"
:data-source="data"
row-key="b_id"
:loading="loading"
:pagination="false"
<FmsModuleListPage
ref="listRef"
:row-actions="rowActions"
:row-selection="rowSelection"
@loaded="selectedRowKeys = []"
>
<template #actions="{ loading }">
<a-space>
<a-button
type="primary"
:disabled="loading || saving"
@click="openCreate"
>
<Plus :size="16" />
{{ appStore.t("common.create", "新增") }}
</a-button>
<a-button
danger
:disabled="loading || saving || !selectedRowKeys.length"
@click="batchDelete"
>
<Trash2 :size="16" />
{{ appStore.t("common.batchDelete", "批量删除")
}}{{ selectedRowKeys.length ? `(${selectedRowKeys.length})` : "" }}
</a-button>
</a-space>
</template>
<template #cell-b_canuse="{ value, record }">
<a-checkbox
:checked="Number(value) === 1"
:disabled="saving"
@mousedown.stop
@click.stop
@update:checked="(checked) => updateCheckbox(record, 'b_canuse', checked)"
/>
</div>
</div>
</template>
<template #cell-b_default="{ value, record }">
<a-checkbox
:checked="Number(value) === 1"
:disabled="saving"
@mousedown.stop
@click.stop
@update:checked="(checked) => updateCheckbox(record, 'b_default', checked)"
/>
</template>
</FmsModuleListPage>
<a-modal
v-model:open="modalVisible"
:title="modalMode === 'create' ? '新增语言' : '编辑语言'"
:title="
modalMode === 'create'
? appStore.t('common.createLanguage', '新增语言')
: appStore.t('common.editLanguage', '编辑语言')
"
:confirm-loading="saving"
@ok="saveForm"
@cancel="modalVisible = false"
>
<a-form layout="vertical">
<a-form-item label="语言编码(简称)" required>
<a-form-item :label="appStore.t('common.languageCode', '语言编码')" required>
<a-input v-model:value="form.b_code" :disabled="editingDefault" />
</a-form-item>
<a-form-item label="语言名称" required>
<a-form-item :label="appStore.t('common.languageName', '语言名称')" required>
<a-input v-model:value="form.b_name" />
</a-form-item>
<a-form-item label="是否默认">
<a-switch v-model:checked="form.defaultOn" :disabled="editingDefault" />
<span class="form-tip">设为默认后,其它语言将自动取消默认</span>
<a-form-item :label="appStore.t('common.defaultLanguage', '默认语言')">
<a-checkbox v-model:checked="form.defaultOn" />
</a-form-item>
<a-form-item label="是否启用">
<a-switch v-model:checked="form.canuseOn" />
<a-form-item :label="appStore.t('common.enabled', '启用')">
<a-checkbox v-model:checked="form.canuseOn" />
</a-form-item>
<a-form-item label="排序">
<a-input-number v-model:value="form.b_xh" :min="0" style="width: 100%" />
<a-form-item :label="appStore.t('common.sort', '排序')">
<a-input-number v-model:value="form.b_xh" style="width: 100%" />
</a-form-item>
</a-form>
</a-modal>
</div>
</template>
<style scoped lang="scss">
<style scoped>
.i18n-type-page {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
gap: 12px;
}
.panel-card {
display: flex;
flex: 1;
min-height: 0;
flex-direction: column;
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 8px;
box-shadow: 0 1px 4px rgb(0 0 0 / 6%);
overflow: hidden;
}
.panel-toolbar {
display: flex;
flex: none;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border-bottom: 1px solid #f0f0f0;
&__title {
font-size: 14px;
font-weight: 500;
color: #1f2329;
}
}
.panel-body {
flex: 1;
min-height: 0;
padding: 12px 16px;
}
.actions-cell {
display: inline-flex;
gap: 4px;
align-items: center;
}
.muted {
color: #c0c4cc;
}
.form-tip {
margin-left: 8px;
font-size: 12px;
color: #646a73;
}
</style>