20260914223013

This commit is contained in:
oneao committed 2026-09-14 22:30:13 +08:00
1 parent 549c8bd2eb
commit 63514476d7
60 files changed
+6478 -2004

No files matched your search

@@ -34,6 +34,7 @@ import UnsavedChangesModal from '@/components/UnsavedChangesModal.vue'
import I18nNameModal from '@/components/I18nNameModal.vue'
import MenuTreePanel from './MenuTreePanel.vue'
import MenuBasicPanel from './MenuBasicPanel.vue'
import ModulePickerModal from './ModulePickerModal.vue'
import {
addRowUpdate,
applyMenuPosition,
@@ -51,6 +52,11 @@ function sqlText(value) {
return `N'${String(value ?? '').replace(/'/g, "''")}'`
}
/** 编码数组的顺序无关签名(比较模块绑定集合是否变化) */
function sortedKey(list) {
return (list || []).map(String).toSorted().join(',')
}
/* ================= 多语言配置(s_i18n_type 驱动) ================= */
// 菜单多语言:默认语言(b_default=1)名称直接存 b_name;其余语言存 s_i18n(b_key=menu.编码)。
// 语言清单来自数据库(stores/i18n),不再写死 zh-CN/en-US。
@@ -105,6 +111,13 @@ const i18n_org = ref([])
/** 多语言名称编辑弹窗 */
const i18nModalVisible = ref(false)
/** 绑定模块草稿(s_menu_module 的模块编码数组,随菜单保存一并提交) */
const menuModules = ref([])
/** 绑定模块加载基线(保存时行级 diffRows 的原始集合) */
const menuModules_org = ref([])
/** 模块绑定选择弹层 */
const modulePickerOpen = ref(false)
/** 菜单基础信息面板(保存前校验入口) */
const basicPanelRef = ref(null)
@@ -154,37 +167,6 @@ async function loadMenuTree() {
}
}
/**
* 工具栏「刷新」:loadMenuTree 会用库里数据整体重建树,
* 未保存的新建菜单会凭空消失、当前草稿也会与新树脱节,故有未保存内容时先确认。
*/
function requestReloadTree() {
const hasDraftNode = [...unsavedMenuIds].some((id) => findTreeNode(menuTreeData.value, id))
// 选中未保存节点时,maindata 与 maindata_org 一致(未编辑态),hasUnsavedChanges 判不出来,需单独看
const editingDraftNode =
!!menuSelectedId.value && unsavedMenuIds.has(String(menuSelectedId.value))
const dirty = loadedMenuId.value && hasUnsavedChanges()
if (!hasDraftNode && !dirty) {
void loadMenuTree()
return
}
showConfirm({
title: '刷新菜单树',
content:
'刷新会用数据库数据重建整棵树,当前未保存的修改与未保存的新建菜单都将丢失。确定继续吗?',
okText: '刷新',
onOk: async () => {
// 选中项若为未保存节点,刷新后它必然不在树上:先清空选中,避免右侧残留悬空草稿。
// 已保存节点则记录 id,树刷新后连同草稿一起重载,保持左右两侧一致。
const reloadId =
editingDraftNode || !menuSelectedId.value ? null : String(menuSelectedId.value)
if (editingDraftNode) menuSelectedId.value = null
await loadMenuTree()
if (reloadId) await loadMenuConfig(reloadId)
},
})
}
/** 工具栏「重载」:重载会放弃当前未保存的改动,先确认 */
function requestReloadConfig() {
const id = menuSelectedId.value
@@ -342,7 +324,6 @@ async function addMenu(parentId, menuTypeValue = 'page') {
b_i18n: null,
b_menu_type: menuTypeValue,
b_route: null,
b_module_id: null,
b_icon: null,
b_xh: 10,
b_canuse: 1,
@@ -397,6 +378,8 @@ async function deleteMenu(id) {
// s_i18n 无菜单归属列,按资源键删该菜单的全部语言行
const reqs = [
{ table: 's_i18n', key_field: 'b_key', deletes: [{ b_key: buildMenuI18nKey(menuId) }] },
// 模块绑定关系随菜单删除(b_menu_id 为复合主键首列,按它批量删)
{ table: 's_menu_module', key_field: 'b_menu_id', deletes: [{ b_menu_id: menuId }] },
]
reqs.push({ table: 's_menu', key_field: 'b_id', deletes: [{ b_id: menuId }] })
await saveObjectApi(reqs)
@@ -444,6 +427,15 @@ async function loadMenuConfig(menuId) {
i18n_values.value = i18nInitValues
i18n_values_org.value = { ...i18nInitValues }
i18n_org.value = i18n_org_rows
// 模块绑定(s_menu_module):行身份 (b_menu_id, b_module_id),只登记 data / virtual 模块
const moduleRes = await loadDataApi(
's_menu_module',
`b_menu_id = ${sqlText(id)}`,
'b_xh ASC, b_module_id ASC',
)
if (String(menuSelectedId.value) !== id) return
menuModules_org.value = (moduleRes.data || []).map((row) => String(row.b_module_id))
menuModules.value = [...menuModules_org.value]
loadedMenuId.value = id
} catch (e) {
if (String(menuSelectedId.value) === String(menuId)) {
@@ -466,6 +458,8 @@ watch(menuSelectedId, (id) => {
configLoading.value = false
loadedMenuId.value = null
maindata.value = null
menuModules.value = []
menuModules_org.value = []
return
}
// 未落库的新节点:临时 id 在库里查不到行,不能走 loadMenuConfig
@@ -484,6 +478,8 @@ watch(menuSelectedId, (id) => {
i18n_values.value = { [defaultLocale.value]: node.b_name || '' }
i18n_values_org.value = { ...i18n_values.value }
i18n_org.value = []
menuModules.value = []
menuModules_org.value = []
loadedMenuId.value = String(id)
return
}
@@ -507,12 +503,14 @@ function hasUnsavedChanges() {
)
)
return true
// 模块绑定变化视为未保存(顺序无关)
if (sortedKey(menuModules.value) !== sortedKey(menuModules_org.value)) return true
return false
}
/**
* 统一的「有未保存改动就先确认」闸门:无改动直接执行 action,有改动弹窗等待用户选择。
* 所有会覆盖当前草稿的操作(切换节点 / 新建菜单 / 刷新树)都必须经此,
* 所有会覆盖当前草稿的操作(切换节点 / 新建菜单)都必须经此,
* 否则会出现「某条路径静默丢弃编辑」的不一致。
*/
function confirmIfUnsaved(action) {
@@ -594,9 +592,9 @@ async function beforeSave() {
)
return false
}
// 绑定模块非必填:页面菜单可不绑定业务模块,留空即保存为空
// 路由 / 模块按菜单类型收敛:目录、外链不绑模块(清空草稿,保存时按 diff 删除关系行)
if (!typeRule.route && menuData.b_route) menuData.b_route = null
if (!typeRule.module && menuData.b_module_id) menuData.b_module_id = null
if (!typeRule.module && menuModules.value.length) menuModules.value = []
// 字符串业务键:菜单 b_id 即编码,创建后不可修改;保存时统一去除首尾空格
const menuCode = String(menuData.b_id || '').trim()
@@ -681,6 +679,32 @@ async function buildSaveData() {
reqs.push(i18nReq)
}
// 模块绑定(s_menu_module):行身份 (b_menu_id, b_module_id),只提交增删行;
// b_xh 记录页面内模块顺序(按当前勾选顺序 10 起步),顺序变化会产生 update,可接受。
const toModuleRows = (list) =>
list.map((moduleId, index) => ({
b_menu_id: menuId,
b_module_id: String(moduleId),
b_xh: (index + 1) * 10,
b_canuse: 1,
}))
const moduleChanges = diffRows(
toModuleRows(menuModules.value),
toModuleRows(menuModules_org.value),
{ keyFields: ['b_menu_id', 'b_module_id'] },
)
if (
moduleChanges.inserts.length ||
moduleChanges.updates.length ||
moduleChanges.deletes.length
) {
const moduleReq = { table: 's_menu_module', key_field: 'b_menu_id,b_module_id' }
if (moduleChanges.inserts.length) moduleReq.inserts = moduleChanges.inserts
if (moduleChanges.updates.length) moduleReq.updates = moduleChanges.updates
if (moduleChanges.deletes.length) moduleReq.deletes = moduleChanges.deletes
reqs.push(moduleReq)
}
return reqs.length ? reqs : null
}
@@ -787,22 +811,24 @@ async function onToggleEnable({ node, checked }) {
}
}
/* ================= 加载模块列表(供页面类型下拉选择) ================= */
const moduleOptions = ref([])
/* ================= 加载模块清单(绑定模块选择器) ================= */
/** 模块平铺行(s_module 启用行;已绑模块名称展示与类型过滤用) */
const moduleRows = ref([])
/** 模块树(module 分类 + data / virtual 数据模块;绑定弹层勾选用) */
const moduleTree = ref([])
/** 模块编码 → 模块类型(弹层确定时过滤掉 module 分类节点) */
const moduleTypes = computed(() =>
Object.fromEntries(moduleRows.value.map((m) => [String(m.b_id), String(m.b_module_type)])),
)
let moduleOptionsLoaded = false
async function loadModuleOptions() {
if (moduleOptionsLoaded) return
try {
const res = await loadDataApi(
's_module',
"b_canuse = 1 AND b_module_type IN ('data', 'virtual')",
'b_xh ASC, b_id ASC',
)
moduleOptions.value = (res.data || []).map((m) => ({
label: `[${m.b_id}] ${m.b_name}`,
value: String(m.b_id),
}))
const res = await loadDataApi('s_module', 'b_canuse = 1', 'b_path ASC, b_xh ASC, b_id ASC')
const rows = res.data || []
moduleRows.value = rows
moduleTree.value = sortTree(buildTree(rows.map((row) => ({ ...row }))))
moduleOptionsLoaded = true
} catch (e) {
// Silently fail; modules will be empty
@@ -860,7 +886,6 @@ loadModuleOptions()
@add="onMenuAdd"
@delete="(id) => void deleteMenu(id)"
@drop="(payload) => void onMenuDrop(payload)"
@refresh="requestReloadTree"
@toggle-enable="(payload) => void onToggleEnable(payload)"
/>
</SplitterPanel>
@@ -894,10 +919,12 @@ loadModuleOptions()
<MenuBasicPanel
ref="basicPanelRef"
v-model="maindata"
v-model:modules="menuModules"
:type-locked="menuTypeLocked"
:module-options="moduleOptions"
:module-rows="moduleRows"
:loading="busy"
@open-i18n="i18nModalVisible = true"
@open-module-picker="modulePickerOpen = true"
/>
</div>
</SplitterPanel>
@@ -941,6 +968,16 @@ loadModuleOptions()
</template>
</Modal>
<!-- 绑定模块选择(模块树多选:勾分类节点自动级联其下全部模块) -->
<ModulePickerModal
:open="modulePickerOpen"
:tree-data="moduleTree"
:checked-keys="menuModules"
:module-types="moduleTypes"
@update:open="(v) => (modulePickerOpen = v)"
@confirm="menuModules = $event"
/>
<!-- 多语言名称编辑(通用组件,与模块配置共用) -->
<I18nNameModal
:open="i18nModalVisible"