20260806224941
This commit is contained in:
1 parent
6baa339efa
commit
6938deef39
12 files changed
+367
-300
No files matched your search
@@ -0,0 +1,16 @@
|
|||||||
|
/*
|
||||||
|
Add b_icon column to s_module so directory/page modules can assign a
|
||||||
|
Lucide icon for sidebar menu display.
|
||||||
|
*/
|
||||||
|
SET NOCOUNT ON;
|
||||||
|
SET XACT_ABORT ON;
|
||||||
|
|
||||||
|
IF DB_NAME() <> N'FMS'
|
||||||
|
THROW 53600, N'This migration can only run against the FMS database.', 1;
|
||||||
|
|
||||||
|
IF COL_LENGTH(N'dbo.s_module', N'b_icon') IS NOT NULL
|
||||||
|
THROW 53601, N'Column b_icon already exists on s_module.', 1;
|
||||||
|
|
||||||
|
ALTER TABLE dbo.s_module ADD b_icon VARCHAR(50) NULL;
|
||||||
|
|
||||||
|
PRINT N'Migration 036 applied: added s_module.b_icon.';
|
||||||
@@ -2,29 +2,6 @@
|
|||||||
import zhCN from "antdv-next/locale/zh_CN";
|
import zhCN from "antdv-next/locale/zh_CN";
|
||||||
|
|
||||||
const themeConfig = {
|
const themeConfig = {
|
||||||
token: {
|
|
||||||
controlHeight: 32,
|
|
||||||
borderRadius: 6,
|
|
||||||
colorPrimary: '#0a0a0a',
|
|
||||||
colorBgContainer: '#ffffff',
|
|
||||||
colorBgLayout: '#fafafa',
|
|
||||||
colorBorder: '#e4e4e7',
|
|
||||||
colorText: '#0a0a0a',
|
|
||||||
colorTextSecondary: '#71717a',
|
|
||||||
fontSize: 14,
|
|
||||||
},
|
|
||||||
components: {
|
|
||||||
Splitter: {
|
|
||||||
splitBarSize: 1,
|
|
||||||
},
|
|
||||||
Button: {
|
|
||||||
contentFontSize: 14,
|
|
||||||
},
|
|
||||||
Tree: {
|
|
||||||
nodeSelectedBg: '#0a0a0a',
|
|
||||||
nodeSelectedColor: '#fafafa',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ const handledEventAttrs = new Set([
|
|||||||
"onDragEnd",
|
"onDragEnd",
|
||||||
"onDragend",
|
"onDragend",
|
||||||
"onRightClick",
|
"onRightClick",
|
||||||
|
"onSelect",
|
||||||
"onUpdate:expandedKeys",
|
"onUpdate:expandedKeys",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -35,6 +36,7 @@ const props = defineProps({
|
|||||||
expandedKeys: { type: Array, default: undefined },
|
expandedKeys: { type: Array, default: undefined },
|
||||||
blockNode: { type: Boolean, default: true },
|
blockNode: { type: Boolean, default: true },
|
||||||
virtual: { type: Boolean, default: true },
|
virtual: { type: Boolean, default: true },
|
||||||
|
showLine: { type: Boolean, default: false },
|
||||||
draggable: { type: [Boolean, Function, Object], default: false },
|
draggable: { type: [Boolean, Function, Object], default: false },
|
||||||
allowDrop: { type: Function, default: null },
|
allowDrop: { type: Function, default: null },
|
||||||
rootKey: { type: [String, Number], default: "__fms_tree_root__" },
|
rootKey: { type: [String, Number], default: "__fms_tree_root__" },
|
||||||
@@ -86,6 +88,7 @@ const emit = defineEmits([
|
|||||||
"dragOver",
|
"dragOver",
|
||||||
"dragEnd",
|
"dragEnd",
|
||||||
"rightClick",
|
"rightClick",
|
||||||
|
"select",
|
||||||
"update:expandedKeys",
|
"update:expandedKeys",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -121,7 +124,21 @@ function onMenuClick({ key }) {
|
|||||||
if (node) emit("menuClick", { key, node });
|
if (node) emit("menuClick", { key, node });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const selectSuppress = ref(false);
|
||||||
|
|
||||||
|
function onSelect(keys, info) {
|
||||||
|
// 点击有子节点的父节点时,阻止 a-tree 内部自动触发展开
|
||||||
|
if (info?.node?.children?.length) {
|
||||||
|
selectSuppress.value = true;
|
||||||
|
}
|
||||||
|
emit("select", keys, info);
|
||||||
|
}
|
||||||
|
|
||||||
function onExpandedKeysChange(keys) {
|
function onExpandedKeysChange(keys) {
|
||||||
|
if (selectSuppress.value) {
|
||||||
|
selectSuppress.value = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
emit("update:expandedKeys", keys);
|
emit("update:expandedKeys", keys);
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -141,10 +158,11 @@ function onExpandedKeysChange(keys) {
|
|||||||
:default-expanded-keys="[rootKey]"
|
:default-expanded-keys="[rootKey]"
|
||||||
:block-node="blockNode"
|
:block-node="blockNode"
|
||||||
:virtual="effectiveVirtual"
|
:virtual="effectiveVirtual"
|
||||||
show-line
|
:show-line="showLine"
|
||||||
:draggable="draggable"
|
:draggable="draggable"
|
||||||
:allow-drop="allowDrop"
|
:allow-drop="allowDrop"
|
||||||
@right-click="onTreeRightClick"
|
@right-click="onTreeRightClick"
|
||||||
|
@select="onSelect"
|
||||||
@drop="$emit('drop', $event)"
|
@drop="$emit('drop', $event)"
|
||||||
@dragstart="$emit('dragStart', $event)"
|
@dragstart="$emit('dragStart', $event)"
|
||||||
@dragenter="$emit('dragEnter', $event)"
|
@dragenter="$emit('dragEnter', $event)"
|
||||||
@@ -167,9 +185,10 @@ function onExpandedKeysChange(keys) {
|
|||||||
:default-expanded-keys="[rootKey]"
|
:default-expanded-keys="[rootKey]"
|
||||||
:block-node="blockNode"
|
:block-node="blockNode"
|
||||||
:virtual="effectiveVirtual"
|
:virtual="effectiveVirtual"
|
||||||
show-line
|
:show-line="showLine"
|
||||||
:draggable="draggable"
|
:draggable="draggable"
|
||||||
:allow-drop="allowDrop"
|
:allow-drop="allowDrop"
|
||||||
|
@select="onSelect"
|
||||||
@drop="$emit('drop', $event)"
|
@drop="$emit('drop', $event)"
|
||||||
@dragstart="$emit('dragStart', $event)"
|
@dragstart="$emit('dragStart', $event)"
|
||||||
@dragenter="$emit('dragEnter', $event)"
|
@dragenter="$emit('dragEnter', $event)"
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ const routeInstanceKey = computed(
|
|||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
box-shadow: 0 0 0 1px rgb(0 0 0 / 4%), 0 1px 3px rgb(0 0 0 / 4%), 0 4px 12px rgb(0 0 0 / 3%);
|
box-shadow: 0 0 0 1px rgb(0 0 0 / 4%), 0 1px 3px rgb(0 0 0 / 4%), 0 4px 12px rgb(0 0 0 / 3%);
|
||||||
background: #ffffff;
|
background: #ffffff;
|
||||||
padding: 8px;
|
padding: 0 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-content {
|
.app-content {
|
||||||
@@ -86,7 +86,7 @@ const routeInstanceKey = computed(
|
|||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 6px;
|
padding: 6px 0;
|
||||||
background: #ffffff;
|
background: #ffffff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,30 +1,23 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed, onMounted, ref, watch } from "vue";
|
import { computed, onMounted, ref, watch } from "vue";
|
||||||
import { useRoute, useRouter } from "vue-router";
|
import { useRoute, useRouter } from "vue-router";
|
||||||
import { ChevronDown, Database, House, PanelLeftClose, PanelLeftOpen, Settings } from "@lucide/vue";
|
import { ChevronDown, House } from "@lucide/vue";
|
||||||
import { useAppStore } from "@/stores/app";
|
import { useAppStore } from "@/stores/app";
|
||||||
import { usePermissionStore } from "@/stores/permissions";
|
import { usePermissionStore } from "@/stores/permissions";
|
||||||
|
import { getIcon, getIconNames } from "@/utils/lucideIcons";
|
||||||
|
import { buildTree, sortTree } from "@/utils/tree";
|
||||||
|
import { cloneData } from "@/utils/dataChanges";
|
||||||
import SidebarMenuItem from "./SidebarMenuItem.vue";
|
import SidebarMenuItem from "./SidebarMenuItem.vue";
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const appStore = useAppStore();
|
const appStore = useAppStore();
|
||||||
const permissionStore = usePermissionStore();
|
const permissionStore = usePermissionStore();
|
||||||
const openKeys = ref(["base", "system", "settings-dept"]);
|
const openKeys = ref([]);
|
||||||
const effectiveCollapsed = computed(() => appStore.sidebarCollapsed);
|
const effectiveCollapsed = computed(() => appStore.sidebarCollapsed);
|
||||||
|
|
||||||
function moduleLabel(moduleId, path, fallback) {
|
function resolveIcon(iconName, fallback) {
|
||||||
const module = permissionStore.modules.find(
|
return getIcon(iconName) || fallback;
|
||||||
(item) => item.b_code === moduleId || item.b_route === path,
|
|
||||||
);
|
|
||||||
return appStore.t(module?.b_i18n, fallback);
|
|
||||||
}
|
|
||||||
|
|
||||||
function directoryLabel(fallback) {
|
|
||||||
const module = permissionStore.modules.find(
|
|
||||||
(item) => item.b_module_type === "directory" && item.b_name === fallback,
|
|
||||||
);
|
|
||||||
return appStore.t(module?.b_i18n, fallback);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function canAccessMenu(item) {
|
function canAccessMenu(item) {
|
||||||
@@ -33,45 +26,6 @@ function canAccessMenu(item) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const menuItems = computed(() => {
|
const menuItems = computed(() => {
|
||||||
const basePages = [
|
|
||||||
{
|
|
||||||
key: "/base/othercompany",
|
|
||||||
label: moduleLabel("base_othercompany", "/base/othercompany", "合作伙伴"),
|
|
||||||
moduleId: "base_othercompany",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "/base/contact",
|
|
||||||
label: moduleLabel("v_b_contact", "/base/contact", "联系人"),
|
|
||||||
moduleId: "v_b_contact",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "/base/otherdata",
|
|
||||||
label: moduleLabel("base_otherdata", "/base/otherdata", "其他数据"),
|
|
||||||
moduleId: "base_otherdata",
|
|
||||||
},
|
|
||||||
].filter(canAccessMenu);
|
|
||||||
const systemPages = [
|
|
||||||
{
|
|
||||||
key: "/system/module-management",
|
|
||||||
label: moduleLabel("m_metadata", "/system/module-management", "模块管理"),
|
|
||||||
moduleId: "m_metadata",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "/system/i18n-type",
|
|
||||||
label: moduleLabel("b_i18n_type", "/system/i18n-type", "多语言类型"),
|
|
||||||
moduleId: "b_i18n_type",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "/system/login-log",
|
|
||||||
label: moduleLabel("d_login_log", "/system/login-log", "登录日志"),
|
|
||||||
moduleId: "d_login_log",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "/system/user",
|
|
||||||
label: moduleLabel("b_user", "/system/user", "用户管理"),
|
|
||||||
moduleId: "b_user",
|
|
||||||
},
|
|
||||||
].filter(canAccessMenu);
|
|
||||||
const items = [
|
const items = [
|
||||||
{
|
{
|
||||||
key: "/dashboard",
|
key: "/dashboard",
|
||||||
@@ -79,46 +33,63 @@ const menuItems = computed(() => {
|
|||||||
label: appStore.t("common.dashboard", "工作台"),
|
label: appStore.t("common.dashboard", "工作台"),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
if (basePages.length)
|
|
||||||
items.push({
|
|
||||||
key: "base",
|
|
||||||
icon: Database,
|
|
||||||
label: directoryLabel("基础数据"),
|
|
||||||
children: basePages,
|
|
||||||
});
|
|
||||||
if (systemPages.length)
|
|
||||||
items.push({
|
|
||||||
key: "system",
|
|
||||||
icon: Settings,
|
|
||||||
label: directoryLabel("系统管理"),
|
|
||||||
children: systemPages,
|
|
||||||
});
|
|
||||||
|
|
||||||
// --- 模拟三级菜单(爷爷 → 爸爸 → 儿子)---
|
// 筛选可显示在菜单中的模块(仅 directory 和 page,且已启用、可菜单)
|
||||||
items.push({
|
const menuModules = permissionStore.modules.filter(
|
||||||
key: "settings",
|
(m) =>
|
||||||
icon: Settings,
|
m.b_canmenu === 1 &&
|
||||||
label: "系统设置",
|
m.b_canuse === 1 &&
|
||||||
children: [
|
["directory", "page"].includes(m.b_module_type),
|
||||||
{
|
);
|
||||||
key: "settings-dept",
|
|
||||||
label: "部门管理",
|
if (!menuModules.length) return items;
|
||||||
children: [
|
|
||||||
{ key: "/settings/dept/list", label: "部门列表" },
|
// 父节点未启用菜单 → 子节点也不显示
|
||||||
{ key: "/settings/dept/position", label: "职位管理" },
|
const allowedIds = new Set(menuModules.map((m) => String(m.b_id)));
|
||||||
],
|
const validModules = menuModules.filter(
|
||||||
},
|
(m) => m.b_parent_id == null || allowedIds.has(String(m.b_parent_id)),
|
||||||
{
|
);
|
||||||
key: "settings-role",
|
|
||||||
label: "角色权限",
|
if (!validModules.length) return items;
|
||||||
children: [
|
|
||||||
{ key: "/settings/role/list", label: "角色列表" },
|
// 深克隆避免 buildTree/sortTree 修改响应式对象触发死循环
|
||||||
{ key: "/settings/role/assign", label: "权限分配" },
|
const cloned = cloneData(validModules);
|
||||||
],
|
const tree = buildTree(cloned);
|
||||||
},
|
sortTree(tree);
|
||||||
{ key: "/settings/audit", label: "操作日志" },
|
|
||||||
],
|
// 树节点 → 菜单项
|
||||||
});
|
function toMenuItem(node) {
|
||||||
|
const isDir = node.b_module_type === "directory";
|
||||||
|
const item = {
|
||||||
|
key: node.b_route || node.b_code,
|
||||||
|
label: appStore.t(node.b_i18n, node.b_name || node.b_code),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isDir) {
|
||||||
|
item.icon = resolveIcon(node.b_icon, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.children?.length) {
|
||||||
|
const children = node.children
|
||||||
|
.map(toMenuItem)
|
||||||
|
.filter(Boolean)
|
||||||
|
.filter(canAccessMenu);
|
||||||
|
if (children.length) {
|
||||||
|
item.children = children;
|
||||||
|
} else {
|
||||||
|
return null; // 无可见子节点的目录,隐藏
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isDir) {
|
||||||
|
item.moduleId = node.b_code;
|
||||||
|
}
|
||||||
|
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
const roots = tree.map(toMenuItem).filter(Boolean).filter(canAccessMenu);
|
||||||
|
items.push(...roots);
|
||||||
|
|
||||||
return items;
|
return items;
|
||||||
});
|
});
|
||||||
@@ -153,14 +124,22 @@ function navigate(key) {
|
|||||||
if (typeof key === "string" && key.startsWith("/")) router.push(key);
|
if (typeof key === "string" && key.startsWith("/")) router.push(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleItemClick(item) {
|
function getDropdownItems(children) {
|
||||||
if (item.children) {
|
return children.map((item) => {
|
||||||
if (!effectiveCollapsed.value) {
|
if (item.children) {
|
||||||
toggleGroup(item.key);
|
return {
|
||||||
|
key: item.key,
|
||||||
|
label: item.label,
|
||||||
|
popupClassName: "sidebar-submenu-popup",
|
||||||
|
children: getDropdownItems(item.children),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
} else {
|
return { key: item.key, label: item.label };
|
||||||
navigate(item.key);
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function dropdownGetContainer() {
|
||||||
|
return document.body;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 展开某节点及其所有祖先
|
// 展开某节点及其所有祖先
|
||||||
@@ -177,17 +156,21 @@ function expandAncestors(items, targetKey) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 路由变化时自动展开父级链
|
// 路由变化或菜单数据加载后,自动展开当前路由的父级链
|
||||||
watch(
|
watch(
|
||||||
() => route.path,
|
[() => route.path, menuItems],
|
||||||
(path) => {
|
() => {
|
||||||
const current = route.meta.menuKey || path;
|
const current = route.meta.menuKey || route.path;
|
||||||
expandAncestors(menuItems.value, current);
|
expandAncestors(menuItems.value, current);
|
||||||
},
|
},
|
||||||
{ immediate: true },
|
{ immediate: true },
|
||||||
);
|
);
|
||||||
|
|
||||||
onMounted(() => permissionStore.load().catch(() => {}));
|
onMounted(() => {
|
||||||
|
permissionStore.load().catch(() => {});
|
||||||
|
// 预加载图标,确保菜单目录图标的 computed 能正确解析
|
||||||
|
getIconNames();
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -216,11 +199,7 @@ onMounted(() => permissionStore.load().catch(() => {}));
|
|||||||
<template v-if="group.children">
|
<template v-if="group.children">
|
||||||
<!-- 展开态 -->
|
<!-- 展开态 -->
|
||||||
<template v-if="!effectiveCollapsed">
|
<template v-if="!effectiveCollapsed">
|
||||||
<button
|
<button type="button" class="sidebar-menu-button" @click="toggleGroup(group.key)">
|
||||||
type="button"
|
|
||||||
class="sidebar-menu-button"
|
|
||||||
@click="toggleGroup(group.key)"
|
|
||||||
>
|
|
||||||
<component :is="group.icon" v-if="group.icon" class="sidebar-menu-icon" />
|
<component :is="group.icon" v-if="group.icon" class="sidebar-menu-icon" />
|
||||||
<span class="sidebar-menu-label">{{ group.label }}</span>
|
<span class="sidebar-menu-label">{{ group.label }}</span>
|
||||||
<ChevronDown
|
<ChevronDown
|
||||||
@@ -242,17 +221,19 @@ onMounted(() => permissionStore.load().catch(() => {}));
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- 折叠态 -->
|
<!-- 折叠态:弹出浮动菜单 -->
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<a-tooltip :title="group.label" placement="right">
|
<a-dropdown
|
||||||
<button
|
:trigger="['click']"
|
||||||
type="button"
|
placement="rightTop"
|
||||||
class="sidebar-menu-button collapsed"
|
:getPopupContainer="dropdownGetContainer"
|
||||||
@click="handleItemClick(group)"
|
:menu="{ items: getDropdownItems(group.children), inlineCollapsed: false }"
|
||||||
>
|
@menuClick="({ key }) => navigate(key)"
|
||||||
|
>
|
||||||
|
<button type="button" class="sidebar-menu-button collapsed">
|
||||||
<component :is="group.icon" v-if="group.icon" class="sidebar-menu-icon" />
|
<component :is="group.icon" v-if="group.icon" class="sidebar-menu-icon" />
|
||||||
</button>
|
</button>
|
||||||
</a-tooltip>
|
</a-dropdown>
|
||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -281,23 +262,6 @@ onMounted(() => permissionStore.load().catch(() => {}));
|
|||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<!-- 底部折叠按钮 -->
|
|
||||||
<div class="sidebar-footer">
|
|
||||||
<a-tooltip
|
|
||||||
:title="
|
|
||||||
effectiveCollapsed
|
|
||||||
? appStore.t('common.expandSidebar', '展开侧栏')
|
|
||||||
: appStore.t('common.collapseSidebar', '收起侧栏')
|
|
||||||
"
|
|
||||||
placement="right"
|
|
||||||
>
|
|
||||||
<button type="button" class="sidebar-collapse-btn" @click="appStore.toggleSidebar">
|
|
||||||
<PanelLeftOpen v-if="effectiveCollapsed" class="lucide" />
|
|
||||||
<PanelLeftClose v-else class="lucide" />
|
|
||||||
</button>
|
|
||||||
</a-tooltip>
|
|
||||||
</div>
|
|
||||||
</a-layout-sider>
|
</a-layout-sider>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -329,13 +293,14 @@ onMounted(() => permissionStore.load().catch(() => {}));
|
|||||||
.sidebar-header {
|
.sidebar-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex: none;
|
flex: none;
|
||||||
height: 48px;
|
height: 44px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 8px 12px 0;
|
margin-top: 8px;
|
||||||
|
padding: 0 12px;
|
||||||
|
|
||||||
&.collapsed {
|
&.collapsed {
|
||||||
padding: 8px 0 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -412,12 +377,18 @@ onMounted(() => permissionStore.load().catch(() => {}));
|
|||||||
color: #3f3f46;
|
color: #3f3f46;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.active,
|
|
||||||
&:active {
|
&:active {
|
||||||
|
background: var(--accent, #f4f4f5);
|
||||||
|
color: #3f3f46;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
background: #f4f4f5;
|
background: #f4f4f5;
|
||||||
color: #3f3f46;
|
color: #3f3f46;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
box-shadow: 0 1px 3px rgb(0 0 0 / 8%), 0 2px 4px rgb(0 0 0 / 6%);
|
box-shadow:
|
||||||
|
0 1px 3px rgb(0 0 0 / 8%),
|
||||||
|
0 2px 4px rgb(0 0 0 / 6%);
|
||||||
}
|
}
|
||||||
|
|
||||||
&.collapsed {
|
&.collapsed {
|
||||||
@@ -473,41 +444,4 @@ onMounted(() => permissionStore.load().catch(() => {}));
|
|||||||
background: var(--border, #e4e4e7);
|
background: var(--border, #e4e4e7);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ============================================================
|
|
||||||
* Footer / 底部操作区
|
|
||||||
* ============================================================ */
|
|
||||||
.sidebar-footer {
|
|
||||||
display: flex;
|
|
||||||
flex: none;
|
|
||||||
padding: 0 6px 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-collapse-btn {
|
|
||||||
display: flex;
|
|
||||||
width: 100%;
|
|
||||||
height: 32px;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 0;
|
|
||||||
border: 0;
|
|
||||||
border-radius: 6px;
|
|
||||||
background: transparent;
|
|
||||||
color: var(--muted-foreground, #71717a);
|
|
||||||
cursor: pointer;
|
|
||||||
transition:
|
|
||||||
background-color 0.15s ease,
|
|
||||||
color 0.15s ease;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: var(--accent, #f4f4f5);
|
|
||||||
color: var(--foreground, #0a0a0a);
|
|
||||||
}
|
|
||||||
|
|
||||||
.lucide {
|
|
||||||
width: 16px;
|
|
||||||
height: 16px;
|
|
||||||
stroke-width: 1.8;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
@@ -96,11 +96,10 @@ function updateScrollState() {
|
|||||||
const track = trackRef.value;
|
const track = trackRef.value;
|
||||||
if (!header || !track) return;
|
if (!header || !track) return;
|
||||||
|
|
||||||
const shouldOverflow = track.scrollWidth > header.clientWidth - 16 + 1;
|
const shouldOverflow = track.scrollWidth > header.clientWidth + 1;
|
||||||
if (overflowed.value !== shouldOverflow) {
|
if (overflowed.value !== shouldOverflow) {
|
||||||
overflowed.value = shouldOverflow;
|
overflowed.value = shouldOverflow;
|
||||||
if (!shouldOverflow) track.scrollLeft = 0;
|
if (!shouldOverflow) track.scrollLeft = 0;
|
||||||
nextTick(updateScrollState);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -219,7 +218,7 @@ function handleTabKeydown(event, index) {
|
|||||||
height: 34px;
|
height: 34px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
padding: 3px 8px;
|
padding: 3px 0;
|
||||||
background: #ffffff;
|
background: #ffffff;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -231,7 +230,7 @@ function handleTabKeydown(event, index) {
|
|||||||
height: 28px;
|
height: 28px;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
border: 0;
|
border: 1px solid #e4e4e7;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
color: #71717a;
|
color: #71717a;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import { computed, onMounted, ref, watch } from "vue";
|
import { computed, onMounted, ref, watch } from "vue";
|
||||||
import { useRoute, useRouter } from "vue-router";
|
import { useRoute, useRouter } from "vue-router";
|
||||||
import { message } from "antdv-next";
|
import { message } from "antdv-next";
|
||||||
import { Bell, ChevronDown, Languages, LogOut, UserRound } from "@lucide/vue";
|
import { Bell, Languages, LogOut, PanelLeftClose, PanelLeftOpen, UserRound } from "@lucide/vue";
|
||||||
import { useAppStore } from "@/stores/app";
|
import { useAppStore } from "@/stores/app";
|
||||||
import { useAuthStore } from "@/stores/auth";
|
import { useAuthStore } from "@/stores/auth";
|
||||||
import { usePermissionStore } from "@/stores/permissions";
|
import { usePermissionStore } from "@/stores/permissions";
|
||||||
@@ -18,9 +18,6 @@ const logoutOpen = ref(false);
|
|||||||
const breadcrumbItems = computed(() => {
|
const breadcrumbItems = computed(() => {
|
||||||
const items = [];
|
const items = [];
|
||||||
|
|
||||||
if (route.path === "/dashboard") {
|
|
||||||
return [{ label: appStore.t("common.dashboard", "工作台") }];
|
|
||||||
}
|
|
||||||
if (route.meta.section) {
|
if (route.meta.section) {
|
||||||
const directory = permissionStore.modules.find(
|
const directory = permissionStore.modules.find(
|
||||||
(item) => item.b_module_type === "directory" && item.b_name === route.meta.section,
|
(item) => item.b_module_type === "directory" && item.b_name === route.meta.section,
|
||||||
@@ -34,6 +31,11 @@ const breadcrumbItems = computed(() => {
|
|||||||
items.push({ label: appStore.t(module?.b_i18n, route.meta.title) });
|
items.push({ label: appStore.t(module?.b_i18n, route.meta.title) });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 兜底确保始终有内容
|
||||||
|
if (items.length === 0) {
|
||||||
|
items.push({ label: appStore.t("common.dashboard", route.meta.title || "工作台") });
|
||||||
|
}
|
||||||
|
|
||||||
return items;
|
return items;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -106,6 +108,23 @@ function confirmLogout() {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<header class="top-toolbar" :class="{ 'top-toolbar--hidden': appStore.contentMaximized }">
|
<header class="top-toolbar" :class="{ 'top-toolbar--hidden': appStore.contentMaximized }">
|
||||||
|
<!-- 折叠按钮 -->
|
||||||
|
<a-tooltip
|
||||||
|
:title="
|
||||||
|
appStore.sidebarCollapsed
|
||||||
|
? appStore.t('common.expandSidebar', '展开侧栏')
|
||||||
|
: appStore.t('common.collapseSidebar', '收起侧栏')
|
||||||
|
"
|
||||||
|
placement="bottom"
|
||||||
|
>
|
||||||
|
<button type="button" class="toolbar-btn toolbar-btn--bordered" @click="appStore.toggleSidebar">
|
||||||
|
<PanelLeftOpen v-if="appStore.sidebarCollapsed" />
|
||||||
|
<PanelLeftClose v-else />
|
||||||
|
</button>
|
||||||
|
</a-tooltip>
|
||||||
|
|
||||||
|
<div class="toolbar-divider" />
|
||||||
|
|
||||||
<!-- 面包屑 -->
|
<!-- 面包屑 -->
|
||||||
<nav class="toolbar-breadcrumb" aria-label="面包屑导航">
|
<nav class="toolbar-breadcrumb" aria-label="面包屑导航">
|
||||||
<template v-for="(item, index) in breadcrumbItems" :key="item.label">
|
<template v-for="(item, index) in breadcrumbItems" :key="item.label">
|
||||||
@@ -118,6 +137,10 @@ function confirmLogout() {
|
|||||||
|
|
||||||
<!-- 右侧操作 -->
|
<!-- 右侧操作 -->
|
||||||
<div class="toolbar-actions">
|
<div class="toolbar-actions">
|
||||||
|
<span class="toolbar-org">{{ appStore.t("common.company", "全球货运代理有限公司") }}</span>
|
||||||
|
|
||||||
|
<div class="toolbar-divider" />
|
||||||
|
|
||||||
<a-tooltip :title="appStore.t('common.switchLanguage', '切换语言')" placement="bottom">
|
<a-tooltip :title="appStore.t('common.switchLanguage', '切换语言')" placement="bottom">
|
||||||
<a-dropdown
|
<a-dropdown
|
||||||
:menu="languageMenu"
|
:menu="languageMenu"
|
||||||
@@ -151,7 +174,6 @@ function confirmLogout() {
|
|||||||
<button type="button" class="toolbar-user" aria-label="用户菜单">
|
<button type="button" class="toolbar-user" aria-label="用户菜单">
|
||||||
<a-avatar :size="26"><UserRound /></a-avatar>
|
<a-avatar :size="26"><UserRound /></a-avatar>
|
||||||
<span>{{ displayName }}</span>
|
<span>{{ displayName }}</span>
|
||||||
<ChevronDown class="toolbar-user__arrow" />
|
|
||||||
</button>
|
</button>
|
||||||
</a-dropdown>
|
</a-dropdown>
|
||||||
</div>
|
</div>
|
||||||
@@ -183,9 +205,9 @@ function confirmLogout() {
|
|||||||
flex: none;
|
flex: none;
|
||||||
height: 44px;
|
height: 44px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
padding: 0 8px 0 14px;
|
padding: 0;
|
||||||
background: #ffffff;
|
background: #ffffff;
|
||||||
|
|
||||||
&--hidden {
|
&--hidden {
|
||||||
@@ -225,6 +247,22 @@ function confirmLogout() {
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-org {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #71717a;
|
||||||
|
padding: 0 8px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-divider {
|
||||||
|
width: 1px;
|
||||||
|
height: 20px;
|
||||||
|
flex: none;
|
||||||
|
margin: 0 6px;
|
||||||
|
background: #e4e4e7;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- 通用操作按钮 ---- */
|
/* ---- 通用操作按钮 ---- */
|
||||||
@@ -249,6 +287,10 @@ function confirmLogout() {
|
|||||||
background: #f4f4f5;
|
background: #f4f4f5;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&--bordered {
|
||||||
|
border: 1px solid #e4e4e7;
|
||||||
|
}
|
||||||
|
|
||||||
.lucide {
|
.lucide {
|
||||||
width: 16px;
|
width: 16px;
|
||||||
height: 16px;
|
height: 16px;
|
||||||
@@ -278,12 +320,5 @@ function confirmLogout() {
|
|||||||
&:hover {
|
&:hover {
|
||||||
background: #f4f4f5;
|
background: #f4f4f5;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__arrow {
|
|
||||||
width: 12px;
|
|
||||||
height: 12px;
|
|
||||||
color: #71717a;
|
|
||||||
stroke-width: 2;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -36,15 +36,27 @@ export const usePermissionStore = defineStore("permissions", () => {
|
|||||||
if (isG3soft.value) {
|
if (isG3soft.value) {
|
||||||
const elevatedKey = `g3soft:${principalKey}`;
|
const elevatedKey = `g3soft:${principalKey}`;
|
||||||
if (loadedPrincipalKey.value === elevatedKey) return;
|
if (loadedPrincipalKey.value === elevatedKey) return;
|
||||||
loadVersion++;
|
|
||||||
loadingPromise = null;
|
loadingPromise = null;
|
||||||
loadingPrincipalKey = "";
|
loadingPrincipalKey = "";
|
||||||
loadedPrincipalKey.value = elevatedKey;
|
|
||||||
modules.value = [];
|
// g3soft 也需要加载模块列表用于菜单渲染,权限检查直接返回 true
|
||||||
grantedPageIds.value = [];
|
const requestVersion = ++loadVersion;
|
||||||
moduleCodes.value = [];
|
const request = loadDataApi("s_module", "b_canuse = 1", "b_id ASC")
|
||||||
powerCodes.value = [];
|
.then((res) => {
|
||||||
return;
|
if (requestVersion !== loadVersion) return;
|
||||||
|
modules.value = res.data || [];
|
||||||
|
grantedPageIds.value = [];
|
||||||
|
moduleCodes.value = [];
|
||||||
|
powerCodes.value = [];
|
||||||
|
loadedPrincipalKey.value = elevatedKey;
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (requestVersion !== loadVersion) return;
|
||||||
|
loadingPromise = null;
|
||||||
|
loadingPrincipalKey = "";
|
||||||
|
});
|
||||||
|
loadingPromise = request;
|
||||||
|
return request;
|
||||||
}
|
}
|
||||||
if (!/^\d+$/.test(userId)) throw new Error("登录用户 ID 无效,请重新登录");
|
if (!/^\d+$/.test(userId)) throw new Error("登录用户 ID 无效,请重新登录");
|
||||||
if (loadedPrincipalKey.value === principalKey) return;
|
if (loadedPrincipalKey.value === principalKey) return;
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
/**
|
||||||
|
* Lucide 图标注册表 — 懒加载所有图标并按名称查找。
|
||||||
|
* 首次使用时动态 import('@lucide/vue'),之后缓存复用。
|
||||||
|
* iconMap 为响应式 ref,加载完成后自动触发依赖它的 computed 重算。
|
||||||
|
*/
|
||||||
|
import { ref } from "vue";
|
||||||
|
|
||||||
|
const iconMap = ref(null);
|
||||||
|
let loadingPromise = null;
|
||||||
|
const KNOWN_NON_ICONS = new Set(["createLucideIcon"]);
|
||||||
|
|
||||||
|
async function ensureLoaded() {
|
||||||
|
if (iconMap.value) return iconMap.value;
|
||||||
|
if (!loadingPromise) {
|
||||||
|
loadingPromise = import("@lucide/vue").then((mod) => {
|
||||||
|
const map = {};
|
||||||
|
for (const [key, component] of Object.entries(mod)) {
|
||||||
|
if (!KNOWN_NON_ICONS.has(key) && key[0] === key[0].toUpperCase()) {
|
||||||
|
map[key] = component;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
iconMap.value = map;
|
||||||
|
return map;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return loadingPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取所有图标名称(按字母排序),需要 await */
|
||||||
|
export async function getIconNames() {
|
||||||
|
const map = await ensureLoaded();
|
||||||
|
return Object.keys(map).sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 同步获取图标组件;响应式,加载完成后自动重算依赖的 computed */
|
||||||
|
export function getIcon(name) {
|
||||||
|
if (!iconMap.value || !name) return null;
|
||||||
|
return iconMap.value[name] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 异步获取图标组件 */
|
||||||
|
export async function getIconAsync(name) {
|
||||||
|
const map = await ensureLoaded();
|
||||||
|
return map[name] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 图标是否已加载完成 */
|
||||||
|
export function isLoaded() {
|
||||||
|
return iconMap.value !== null;
|
||||||
|
}
|
||||||
@@ -1,10 +1,33 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed, ref, watch } from "vue";
|
import { computed, onMounted, ref, watch } from "vue";
|
||||||
import { Languages } from "@lucide/vue";
|
import { Languages } from "@lucide/vue";
|
||||||
import FmsMemoInput from "@/components/FmsMemoInput.vue";
|
import FmsMemoInput from "@/components/FmsMemoInput.vue";
|
||||||
import { buildModuleI18nKey } from "@/utils/i18n";
|
import { buildModuleI18nKey } from "@/utils/i18n";
|
||||||
|
import { getIcon, getIconNames } from "@/utils/lucideIcons";
|
||||||
import I18nQuickSetModal from "./I18nQuickSetModal.vue";
|
import I18nQuickSetModal from "./I18nQuickSetModal.vue";
|
||||||
|
|
||||||
|
const iconOptions = ref([]);
|
||||||
|
const iconsReady = ref(false);
|
||||||
|
|
||||||
|
async function loadIconOptions() {
|
||||||
|
if (iconsReady.value) return;
|
||||||
|
const names = await getIconNames();
|
||||||
|
iconOptions.value = names.map((name) => ({
|
||||||
|
value: name,
|
||||||
|
label: name,
|
||||||
|
icon: getIcon(name),
|
||||||
|
}));
|
||||||
|
iconsReady.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadIconOptions();
|
||||||
|
});
|
||||||
|
|
||||||
|
function getIconByName(name) {
|
||||||
|
return getIcon(name);
|
||||||
|
}
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
moduleId: { type: String, required: true },
|
moduleId: { type: String, required: true },
|
||||||
data: { type: Object, default: null },
|
data: { type: Object, default: null },
|
||||||
@@ -28,12 +51,16 @@ watch(
|
|||||||
const isPage = computed(() => {
|
const isPage = computed(() => {
|
||||||
return formData.value && formData.value.b_module_type === "page";
|
return formData.value && formData.value.b_module_type === "page";
|
||||||
});
|
});
|
||||||
|
const isDirectory = computed(() => {
|
||||||
|
return formData.value && formData.value.b_module_type === "directory";
|
||||||
|
});
|
||||||
const isData = computed(() => {
|
const isData = computed(() => {
|
||||||
return formData.value && formData.value.b_module_type === "data";
|
return formData.value && formData.value.b_module_type === "data";
|
||||||
});
|
});
|
||||||
const isDataCategory = computed(() => {
|
const isDataCategory = computed(() => {
|
||||||
return formData.value && formData.value.b_module_type === "data_category";
|
return formData.value && formData.value.b_module_type === "data_category";
|
||||||
});
|
});
|
||||||
|
const showIconField = computed(() => isDirectory.value || isPage.value);
|
||||||
|
|
||||||
function emitUpdate() {
|
function emitUpdate() {
|
||||||
if (formData.value) emit("update", formData.value);
|
if (formData.value) emit("update", formData.value);
|
||||||
@@ -138,6 +165,47 @@ function onI18nSaved(key) {
|
|||||||
/>
|
/>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
</a-col>
|
</a-col>
|
||||||
|
<a-col v-if="showIconField" :span="8">
|
||||||
|
<a-form-item label="菜单图标">
|
||||||
|
<a-select
|
||||||
|
v-if="iconsReady"
|
||||||
|
:value="formData.b_icon || ''"
|
||||||
|
:options="iconOptions"
|
||||||
|
:virtual="true"
|
||||||
|
:list-item-height="32"
|
||||||
|
:list-height="280"
|
||||||
|
show-search
|
||||||
|
:filter-option="(input, option) => option.value.toLowerCase().includes(input.toLowerCase())"
|
||||||
|
allow-clear
|
||||||
|
placeholder="搜索图标名称"
|
||||||
|
@update:value="(v) => setField('b_icon', v)"
|
||||||
|
>
|
||||||
|
<template #optionRender="{ option }">
|
||||||
|
<span class="icon-option">
|
||||||
|
<component
|
||||||
|
:is="option.data.icon"
|
||||||
|
v-if="option.data.icon"
|
||||||
|
class="icon-option__icon"
|
||||||
|
:size="16"
|
||||||
|
/>
|
||||||
|
<span>{{ option.data.label }}</span>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
<template #labelRender="{ label, value }">
|
||||||
|
<span class="icon-option">
|
||||||
|
<component
|
||||||
|
:is="getIconByName(value)"
|
||||||
|
v-if="value"
|
||||||
|
class="icon-option__icon"
|
||||||
|
:size="16"
|
||||||
|
/>
|
||||||
|
<span>{{ label }}</span>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</a-select>
|
||||||
|
<a-input v-else :value="formData.b_icon || ''" readonly placeholder="图标加载中..." />
|
||||||
|
</a-form-item>
|
||||||
|
</a-col>
|
||||||
<a-col v-if="isPage" :span="8">
|
<a-col v-if="isPage" :span="8">
|
||||||
<a-form-item label="页面路由" required>
|
<a-form-item label="页面路由" required>
|
||||||
<a-input :value="formData.b_route" @update:value="(v) => setField('b_route', v)" />
|
<a-input :value="formData.b_route" @update:value="(v) => setField('b_route', v)" />
|
||||||
@@ -215,4 +283,15 @@ function onI18nSaved(key) {
|
|||||||
.i18n-key-btn {
|
.i18n-key-btn {
|
||||||
flex: none;
|
flex: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.icon-option {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
|
||||||
|
&__icon {
|
||||||
|
flex: none;
|
||||||
|
stroke-width: 1.8;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed, h, onBeforeUnmount, ref, watch } from "vue";
|
import { computed, h, ref, watch } from "vue";
|
||||||
import { message, Modal } from "antdv-next";
|
import { message, Modal } from "antdv-next";
|
||||||
import { Plus, Trash2 } from "@lucide/vue";
|
import { Plus, Trash2 } from "@lucide/vue";
|
||||||
import FmsLoadingOverlay from "@/components/FmsLoadingOverlay.vue";
|
import FmsLoadingOverlay from "@/components/FmsLoadingOverlay.vue";
|
||||||
@@ -41,9 +41,7 @@ const treeLoading = ref(false);
|
|||||||
const expandedTreeKeys = ref([]);
|
const expandedTreeKeys = ref([]);
|
||||||
const selectedModuleId = ref(null);
|
const selectedModuleId = ref(null);
|
||||||
const skipLoad = ref(false);
|
const skipLoad = ref(false);
|
||||||
const expandTimer = ref(null);
|
|
||||||
const activeTab = ref("basic");
|
const activeTab = ref("basic");
|
||||||
let expandDelayResolve = null;
|
|
||||||
function buildTreeContextMenu(node) {
|
function buildTreeContextMenu(node) {
|
||||||
const items = [];
|
const items = [];
|
||||||
const iconSize = 14;
|
const iconSize = 14;
|
||||||
@@ -361,71 +359,16 @@ async function loadModuleConfig(moduleId, options = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---- 树节点选择 ----
|
// ---- 树节点选择 ----
|
||||||
function clearExpandTimer() {
|
|
||||||
if (expandTimer.value != null) {
|
|
||||||
clearTimeout(expandTimer.value);
|
|
||||||
expandTimer.value = null;
|
|
||||||
}
|
|
||||||
if (expandDelayResolve) {
|
|
||||||
expandDelayResolve(false);
|
|
||||||
expandDelayResolve = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function waitForExpandAnimation() {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
expandDelayResolve = (ready) => {
|
|
||||||
resolve(ready);
|
|
||||||
};
|
|
||||||
expandTimer.value = setTimeout(() => {
|
|
||||||
expandTimer.value = null;
|
|
||||||
expandDelayResolve = null;
|
|
||||||
resolve(true);
|
|
||||||
}, 300);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function onTreeSelect(keys, info) {
|
function onTreeSelect(keys, info) {
|
||||||
if (busy.value) return;
|
if (busy.value) return;
|
||||||
if (info?.node?.isFmsRoot) {
|
if (info?.node?.isFmsRoot) {
|
||||||
selectedModuleId.value = null;
|
selectedModuleId.value = null;
|
||||||
clearExpandTimer();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const nodeKey = info?.node?.key ?? keys[0];
|
const nodeKey = info?.node?.key ?? keys[0];
|
||||||
if (nodeKey == null) return;
|
if (nodeKey == null) return;
|
||||||
|
|
||||||
const key = String(nodeKey);
|
selectedModuleId.value = String(nodeKey);
|
||||||
const node = findNode(key);
|
|
||||||
const hasChildren = node?.children?.length;
|
|
||||||
|
|
||||||
clearExpandTimer();
|
|
||||||
|
|
||||||
if (!hasChildren) {
|
|
||||||
// 叶子节点:立即加载数据
|
|
||||||
selectedModuleId.value = key;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 父节点:处理展开/收起
|
|
||||||
const expandedKeys = expandedTreeKeys.value.map(String);
|
|
||||||
const isCurrentlyExpanded = expandedKeys.includes(key);
|
|
||||||
|
|
||||||
if (isCurrentlyExpanded) {
|
|
||||||
// 收起:立即生效,同时加载数据
|
|
||||||
expandedTreeKeys.value = expandedKeys.filter((item) => item !== key);
|
|
||||||
selectedModuleId.value = key;
|
|
||||||
} else {
|
|
||||||
// 展开:立即高亮,下一帧发请求;结果回写最多等到展开动画结束。
|
|
||||||
expandedTreeKeys.value = [...expandedKeys, key];
|
|
||||||
skipLoad.value = true;
|
|
||||||
selectedModuleId.value = key;
|
|
||||||
loadedModuleId.value = null;
|
|
||||||
void loadModuleConfig(key, {
|
|
||||||
waitForPaint: true,
|
|
||||||
applyReady: waitForExpandAnimation(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(selectedModuleId, (id) => {
|
watch(selectedModuleId, (id) => {
|
||||||
@@ -846,6 +789,8 @@ async function saveAll() {
|
|||||||
unsavedModuleIds.delete(moduleId);
|
unsavedModuleIds.delete(moduleId);
|
||||||
updateSavedTreeNode(moduleData);
|
updateSavedTreeNode(moduleData);
|
||||||
if (appStore.locale) await appStore.loadTranslations(appStore.locale);
|
if (appStore.locale) await appStore.loadTranslations(appStore.locale);
|
||||||
|
// 刷新权限数据,让菜单立即反映模块结构变更
|
||||||
|
permissionStore.load().catch(() => {});
|
||||||
message.success("保存成功");
|
message.success("保存成功");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
message.error("保存失败:" + (e.message || e));
|
message.error("保存失败:" + (e.message || e));
|
||||||
@@ -1091,8 +1036,6 @@ void permissionStore
|
|||||||
.load()
|
.load()
|
||||||
.then(loadTree)
|
.then(loadTree)
|
||||||
.catch((e) => message.error("模块树加载失败:" + (e.message || e)));
|
.catch((e) => message.error("模块树加载失败:" + (e.message || e)));
|
||||||
|
|
||||||
onBeforeUnmount(clearExpandTimer);
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -1161,15 +1104,17 @@ onBeforeUnmount(clearExpandTimer);
|
|||||||
:disabled="busy"
|
:disabled="busy"
|
||||||
@update:checked="(v) => onSetField('b_canuse', v ? 1 : 0)"
|
@update:checked="(v) => onSetField('b_canuse', v ? 1 : 0)"
|
||||||
/>
|
/>
|
||||||
<a-divider type="vertical" />
|
<template v-if="drafts.module?.b_module_type !== 'data'">
|
||||||
<span>显示菜单</span>
|
<a-divider type="vertical" />
|
||||||
<a-checkbox
|
<span>显示菜单</span>
|
||||||
:checked="drafts.module?.b_canmenu === 1"
|
<a-checkbox
|
||||||
:disabled="
|
:checked="drafts.module?.b_canmenu === 1"
|
||||||
busy || ['data', 'data_category'].includes(drafts.module?.b_module_type)
|
:disabled="
|
||||||
"
|
busy || ['data', 'data_category'].includes(drafts.module?.b_module_type)
|
||||||
@update:checked="(v) => onSetField('b_canmenu', v ? 1 : 0)"
|
"
|
||||||
/>
|
@update:checked="(v) => onSetField('b_canmenu', v ? 1 : 0)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
<template v-if="drafts.module?.b_module_type === 'data'">
|
<template v-if="drafts.module?.b_module_type === 'data'">
|
||||||
<a-divider type="vertical" />
|
<a-divider type="vertical" />
|
||||||
<span>自动编码</span>
|
<span>自动编码</span>
|
||||||
|
|||||||
+2
-1
@@ -45,13 +45,14 @@ s_module / s_module_field / s_module_field_list / s_module_field_group / s_modul
|
|||||||
| `b_parent_id` | `bigint` | 上级模块 ID;根节点为空 |
|
| `b_parent_id` | `bigint` | 上级模块 ID;根节点为空 |
|
||||||
| `b_name` | `nvarchar(100)` | 默认中文名称 |
|
| `b_name` | `nvarchar(100)` | 默认中文名称 |
|
||||||
| `b_i18n` | `varchar(150)` | 多语言键 |
|
| `b_i18n` | `varchar(150)` | 多语言键 |
|
||||||
| `b_module_type` | `varchar(20)` | `directory`、`page` 或 `data` |
|
| `b_module_type` | `varchar(20)` | `directory`、`page`、`data_category` 或 `data` |
|
||||||
| `b_route` | `varchar(200)` | 页面路由,例如 `/sea/list` |
|
| `b_route` | `varchar(200)` | 页面路由,例如 `/sea/list` |
|
||||||
| `b_viewtable` | `varchar(128)` | 查询来源表或视图;数据模块字段从此处同步 |
|
| `b_viewtable` | `varchar(128)` | 查询来源表或视图;数据模块字段从此处同步 |
|
||||||
| `b_savetable` | `varchar(128)` | 保存目标表 |
|
| `b_savetable` | `varchar(128)` | 保存目标表 |
|
||||||
| `b_keyfield` | `varchar(50)` | 主键字段,例如 `b_id` |
|
| `b_keyfield` | `varchar(50)` | 主键字段,例如 `b_id` |
|
||||||
| `b_orderfield` | `varchar(500)` | 默认排序,例如 `b_xh ASC, b_id DESC` |
|
| `b_orderfield` | `varchar(500)` | 默认排序,例如 `b_xh ASC, b_id DESC` |
|
||||||
| `b_canmenu` | `int` | 是否显示在菜单,默认 `0` |
|
| `b_canmenu` | `int` | 是否显示在菜单,默认 `0` |
|
||||||
|
| `b_icon` | `varchar(50)` | Lucide 图标名称,仅 `directory` 和 `page` 类型使用,用于菜单显示 |
|
||||||
| `b_canuse` | `int` | 是否启用,默认 `1` |
|
| `b_canuse` | `int` | 是否启用,默认 `1` |
|
||||||
| `b_xh` | `int` | 同级显示顺序,默认 `0` |
|
| `b_xh` | `int` | 同级显示顺序,默认 `0` |
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user