20260806224941
This commit is contained in:
1 parent
6baa339efa
commit
6938deef39
12 files changed
+367
-300
No files matched your search
@@ -2,29 +2,6 @@
|
||||
import zhCN from "antdv-next/locale/zh_CN";
|
||||
|
||||
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",
|
||||
"onRightClick",
|
||||
"onSelect",
|
||||
"onUpdate:expandedKeys",
|
||||
]);
|
||||
|
||||
@@ -35,6 +36,7 @@ const props = defineProps({
|
||||
expandedKeys: { type: Array, default: undefined },
|
||||
blockNode: { type: Boolean, default: true },
|
||||
virtual: { type: Boolean, default: true },
|
||||
showLine: { type: Boolean, default: false },
|
||||
draggable: { type: [Boolean, Function, Object], default: false },
|
||||
allowDrop: { type: Function, default: null },
|
||||
rootKey: { type: [String, Number], default: "__fms_tree_root__" },
|
||||
@@ -86,6 +88,7 @@ const emit = defineEmits([
|
||||
"dragOver",
|
||||
"dragEnd",
|
||||
"rightClick",
|
||||
"select",
|
||||
"update:expandedKeys",
|
||||
]);
|
||||
|
||||
@@ -121,7 +124,21 @@ function onMenuClick({ key }) {
|
||||
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) {
|
||||
if (selectSuppress.value) {
|
||||
selectSuppress.value = false;
|
||||
return;
|
||||
}
|
||||
emit("update:expandedKeys", keys);
|
||||
}
|
||||
</script>
|
||||
@@ -141,10 +158,11 @@ function onExpandedKeysChange(keys) {
|
||||
:default-expanded-keys="[rootKey]"
|
||||
:block-node="blockNode"
|
||||
:virtual="effectiveVirtual"
|
||||
show-line
|
||||
:show-line="showLine"
|
||||
:draggable="draggable"
|
||||
:allow-drop="allowDrop"
|
||||
@right-click="onTreeRightClick"
|
||||
@select="onSelect"
|
||||
@drop="$emit('drop', $event)"
|
||||
@dragstart="$emit('dragStart', $event)"
|
||||
@dragenter="$emit('dragEnter', $event)"
|
||||
@@ -167,9 +185,10 @@ function onExpandedKeysChange(keys) {
|
||||
:default-expanded-keys="[rootKey]"
|
||||
:block-node="blockNode"
|
||||
:virtual="effectiveVirtual"
|
||||
show-line
|
||||
:show-line="showLine"
|
||||
:draggable="draggable"
|
||||
:allow-drop="allowDrop"
|
||||
@select="onSelect"
|
||||
@drop="$emit('drop', $event)"
|
||||
@dragstart="$emit('dragStart', $event)"
|
||||
@dragenter="$emit('dragEnter', $event)"
|
||||
|
||||
@@ -77,7 +77,7 @@ const routeInstanceKey = computed(
|
||||
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%);
|
||||
background: #ffffff;
|
||||
padding: 8px;
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.app-content {
|
||||
@@ -86,7 +86,7 @@ const routeInstanceKey = computed(
|
||||
min-height: 0;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
padding: 6px;
|
||||
padding: 6px 0;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,30 +1,23 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref, watch } from "vue";
|
||||
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 { 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";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const appStore = useAppStore();
|
||||
const permissionStore = usePermissionStore();
|
||||
const openKeys = ref(["base", "system", "settings-dept"]);
|
||||
const openKeys = ref([]);
|
||||
const effectiveCollapsed = computed(() => appStore.sidebarCollapsed);
|
||||
|
||||
function moduleLabel(moduleId, path, fallback) {
|
||||
const module = permissionStore.modules.find(
|
||||
(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 resolveIcon(iconName, fallback) {
|
||||
return getIcon(iconName) || fallback;
|
||||
}
|
||||
|
||||
function canAccessMenu(item) {
|
||||
@@ -33,45 +26,6 @@ function canAccessMenu(item) {
|
||||
}
|
||||
|
||||
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 = [
|
||||
{
|
||||
key: "/dashboard",
|
||||
@@ -79,46 +33,63 @@ const menuItems = computed(() => {
|
||||
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,
|
||||
});
|
||||
|
||||
// --- 模拟三级菜单(爷爷 → 爸爸 → 儿子)---
|
||||
items.push({
|
||||
key: "settings",
|
||||
icon: Settings,
|
||||
label: "系统设置",
|
||||
children: [
|
||||
{
|
||||
key: "settings-dept",
|
||||
label: "部门管理",
|
||||
children: [
|
||||
{ key: "/settings/dept/list", label: "部门列表" },
|
||||
{ key: "/settings/dept/position", label: "职位管理" },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "settings-role",
|
||||
label: "角色权限",
|
||||
children: [
|
||||
{ key: "/settings/role/list", label: "角色列表" },
|
||||
{ key: "/settings/role/assign", label: "权限分配" },
|
||||
],
|
||||
},
|
||||
{ key: "/settings/audit", label: "操作日志" },
|
||||
],
|
||||
});
|
||||
// 筛选可显示在菜单中的模块(仅 directory 和 page,且已启用、可菜单)
|
||||
const menuModules = permissionStore.modules.filter(
|
||||
(m) =>
|
||||
m.b_canmenu === 1 &&
|
||||
m.b_canuse === 1 &&
|
||||
["directory", "page"].includes(m.b_module_type),
|
||||
);
|
||||
|
||||
if (!menuModules.length) return items;
|
||||
|
||||
// 父节点未启用菜单 → 子节点也不显示
|
||||
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)),
|
||||
);
|
||||
|
||||
if (!validModules.length) return items;
|
||||
|
||||
// 深克隆避免 buildTree/sortTree 修改响应式对象触发死循环
|
||||
const cloned = cloneData(validModules);
|
||||
const tree = buildTree(cloned);
|
||||
sortTree(tree);
|
||||
|
||||
// 树节点 → 菜单项
|
||||
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;
|
||||
});
|
||||
@@ -153,14 +124,22 @@ function navigate(key) {
|
||||
if (typeof key === "string" && key.startsWith("/")) router.push(key);
|
||||
}
|
||||
|
||||
function handleItemClick(item) {
|
||||
if (item.children) {
|
||||
if (!effectiveCollapsed.value) {
|
||||
toggleGroup(item.key);
|
||||
function getDropdownItems(children) {
|
||||
return children.map((item) => {
|
||||
if (item.children) {
|
||||
return {
|
||||
key: item.key,
|
||||
label: item.label,
|
||||
popupClassName: "sidebar-submenu-popup",
|
||||
children: getDropdownItems(item.children),
|
||||
};
|
||||
}
|
||||
} else {
|
||||
navigate(item.key);
|
||||
}
|
||||
return { key: item.key, label: item.label };
|
||||
});
|
||||
}
|
||||
|
||||
function dropdownGetContainer() {
|
||||
return document.body;
|
||||
}
|
||||
|
||||
// 展开某节点及其所有祖先
|
||||
@@ -177,17 +156,21 @@ function expandAncestors(items, targetKey) {
|
||||
}
|
||||
}
|
||||
|
||||
// 路由变化时自动展开父级链
|
||||
// 路由变化或菜单数据加载后,自动展开当前路由的父级链
|
||||
watch(
|
||||
() => route.path,
|
||||
(path) => {
|
||||
const current = route.meta.menuKey || path;
|
||||
[() => route.path, menuItems],
|
||||
() => {
|
||||
const current = route.meta.menuKey || route.path;
|
||||
expandAncestors(menuItems.value, current);
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
onMounted(() => permissionStore.load().catch(() => {}));
|
||||
onMounted(() => {
|
||||
permissionStore.load().catch(() => {});
|
||||
// 预加载图标,确保菜单目录图标的 computed 能正确解析
|
||||
getIconNames();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -216,11 +199,7 @@ onMounted(() => permissionStore.load().catch(() => {}));
|
||||
<template v-if="group.children">
|
||||
<!-- 展开态 -->
|
||||
<template v-if="!effectiveCollapsed">
|
||||
<button
|
||||
type="button"
|
||||
class="sidebar-menu-button"
|
||||
@click="toggleGroup(group.key)"
|
||||
>
|
||||
<button type="button" class="sidebar-menu-button" @click="toggleGroup(group.key)">
|
||||
<component :is="group.icon" v-if="group.icon" class="sidebar-menu-icon" />
|
||||
<span class="sidebar-menu-label">{{ group.label }}</span>
|
||||
<ChevronDown
|
||||
@@ -242,17 +221,19 @@ onMounted(() => permissionStore.load().catch(() => {}));
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 折叠态 -->
|
||||
<!-- 折叠态:弹出浮动菜单 -->
|
||||
<template v-else>
|
||||
<a-tooltip :title="group.label" placement="right">
|
||||
<button
|
||||
type="button"
|
||||
class="sidebar-menu-button collapsed"
|
||||
@click="handleItemClick(group)"
|
||||
>
|
||||
<a-dropdown
|
||||
:trigger="['click']"
|
||||
placement="rightTop"
|
||||
:getPopupContainer="dropdownGetContainer"
|
||||
: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" />
|
||||
</button>
|
||||
</a-tooltip>
|
||||
</a-dropdown>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
@@ -281,23 +262,6 @@ onMounted(() => permissionStore.load().catch(() => {}));
|
||||
</template>
|
||||
</template>
|
||||
</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>
|
||||
</template>
|
||||
|
||||
@@ -329,13 +293,14 @@ onMounted(() => permissionStore.load().catch(() => {}));
|
||||
.sidebar-header {
|
||||
display: flex;
|
||||
flex: none;
|
||||
height: 48px;
|
||||
height: 44px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 8px 12px 0;
|
||||
margin-top: 8px;
|
||||
padding: 0 12px;
|
||||
|
||||
&.collapsed {
|
||||
padding: 8px 0 0;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -412,12 +377,18 @@ onMounted(() => permissionStore.load().catch(() => {}));
|
||||
color: #3f3f46;
|
||||
}
|
||||
|
||||
&.active,
|
||||
&:active {
|
||||
background: var(--accent, #f4f4f5);
|
||||
color: #3f3f46;
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: #f4f4f5;
|
||||
color: #3f3f46;
|
||||
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 {
|
||||
@@ -473,41 +444,4 @@ onMounted(() => permissionStore.load().catch(() => {}));
|
||||
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>
|
||||
@@ -96,11 +96,10 @@ function updateScrollState() {
|
||||
const track = trackRef.value;
|
||||
if (!header || !track) return;
|
||||
|
||||
const shouldOverflow = track.scrollWidth > header.clientWidth - 16 + 1;
|
||||
const shouldOverflow = track.scrollWidth > header.clientWidth + 1;
|
||||
if (overflowed.value !== shouldOverflow) {
|
||||
overflowed.value = shouldOverflow;
|
||||
if (!shouldOverflow) track.scrollLeft = 0;
|
||||
nextTick(updateScrollState);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -219,7 +218,7 @@ function handleTabKeydown(event, index) {
|
||||
height: 34px;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 3px 8px;
|
||||
padding: 3px 0;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
@@ -231,7 +230,7 @@ function handleTabKeydown(event, index) {
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
place-items: center;
|
||||
border: 0;
|
||||
border: 1px solid #e4e4e7;
|
||||
border-radius: 6px;
|
||||
color: #71717a;
|
||||
background: transparent;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { computed, onMounted, ref, watch } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
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 { useAuthStore } from "@/stores/auth";
|
||||
import { usePermissionStore } from "@/stores/permissions";
|
||||
@@ -18,9 +18,6 @@ const logoutOpen = ref(false);
|
||||
const breadcrumbItems = computed(() => {
|
||||
const items = [];
|
||||
|
||||
if (route.path === "/dashboard") {
|
||||
return [{ label: appStore.t("common.dashboard", "工作台") }];
|
||||
}
|
||||
if (route.meta.section) {
|
||||
const directory = permissionStore.modules.find(
|
||||
(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) });
|
||||
}
|
||||
|
||||
// 兜底确保始终有内容
|
||||
if (items.length === 0) {
|
||||
items.push({ label: appStore.t("common.dashboard", route.meta.title || "工作台") });
|
||||
}
|
||||
|
||||
return items;
|
||||
});
|
||||
|
||||
@@ -106,6 +108,23 @@ function confirmLogout() {
|
||||
|
||||
<template>
|
||||
<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="面包屑导航">
|
||||
<template v-for="(item, index) in breadcrumbItems" :key="item.label">
|
||||
@@ -118,6 +137,10 @@ function confirmLogout() {
|
||||
|
||||
<!-- 右侧操作 -->
|
||||
<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-dropdown
|
||||
:menu="languageMenu"
|
||||
@@ -151,7 +174,6 @@ function confirmLogout() {
|
||||
<button type="button" class="toolbar-user" aria-label="用户菜单">
|
||||
<a-avatar :size="26"><UserRound /></a-avatar>
|
||||
<span>{{ displayName }}</span>
|
||||
<ChevronDown class="toolbar-user__arrow" />
|
||||
</button>
|
||||
</a-dropdown>
|
||||
</div>
|
||||
@@ -183,9 +205,9 @@ function confirmLogout() {
|
||||
flex: none;
|
||||
height: 44px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
gap: 12px;
|
||||
padding: 0 8px 0 14px;
|
||||
padding: 0;
|
||||
background: #ffffff;
|
||||
|
||||
&--hidden {
|
||||
@@ -225,6 +247,22 @@ function confirmLogout() {
|
||||
height: 100%;
|
||||
align-items: center;
|
||||
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;
|
||||
}
|
||||
|
||||
&--bordered {
|
||||
border: 1px solid #e4e4e7;
|
||||
}
|
||||
|
||||
.lucide {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
@@ -278,12 +320,5 @@ function confirmLogout() {
|
||||
&:hover {
|
||||
background: #f4f4f5;
|
||||
}
|
||||
|
||||
&__arrow {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
color: #71717a;
|
||||
stroke-width: 2;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -36,15 +36,27 @@ export const usePermissionStore = defineStore("permissions", () => {
|
||||
if (isG3soft.value) {
|
||||
const elevatedKey = `g3soft:${principalKey}`;
|
||||
if (loadedPrincipalKey.value === elevatedKey) return;
|
||||
loadVersion++;
|
||||
loadingPromise = null;
|
||||
loadingPrincipalKey = "";
|
||||
loadedPrincipalKey.value = elevatedKey;
|
||||
modules.value = [];
|
||||
grantedPageIds.value = [];
|
||||
moduleCodes.value = [];
|
||||
powerCodes.value = [];
|
||||
return;
|
||||
|
||||
// g3soft 也需要加载模块列表用于菜单渲染,权限检查直接返回 true
|
||||
const requestVersion = ++loadVersion;
|
||||
const request = loadDataApi("s_module", "b_canuse = 1", "b_id ASC")
|
||||
.then((res) => {
|
||||
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 (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>
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { computed, onMounted, ref, watch } from "vue";
|
||||
import { Languages } from "@lucide/vue";
|
||||
import FmsMemoInput from "@/components/FmsMemoInput.vue";
|
||||
import { buildModuleI18nKey } from "@/utils/i18n";
|
||||
import { getIcon, getIconNames } from "@/utils/lucideIcons";
|
||||
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({
|
||||
moduleId: { type: String, required: true },
|
||||
data: { type: Object, default: null },
|
||||
@@ -28,12 +51,16 @@ watch(
|
||||
const isPage = computed(() => {
|
||||
return formData.value && formData.value.b_module_type === "page";
|
||||
});
|
||||
const isDirectory = computed(() => {
|
||||
return formData.value && formData.value.b_module_type === "directory";
|
||||
});
|
||||
const isData = computed(() => {
|
||||
return formData.value && formData.value.b_module_type === "data";
|
||||
});
|
||||
const isDataCategory = computed(() => {
|
||||
return formData.value && formData.value.b_module_type === "data_category";
|
||||
});
|
||||
const showIconField = computed(() => isDirectory.value || isPage.value);
|
||||
|
||||
function emitUpdate() {
|
||||
if (formData.value) emit("update", formData.value);
|
||||
@@ -138,6 +165,47 @@ function onI18nSaved(key) {
|
||||
/>
|
||||
</a-form-item>
|
||||
</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-form-item label="页面路由" required>
|
||||
<a-input :value="formData.b_route" @update:value="(v) => setField('b_route', v)" />
|
||||
@@ -215,4 +283,15 @@ function onI18nSaved(key) {
|
||||
.i18n-key-btn {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.icon-option {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
|
||||
&__icon {
|
||||
flex: none;
|
||||
stroke-width: 1.8;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { computed, h, onBeforeUnmount, ref, watch } from "vue";
|
||||
import { computed, h, ref, watch } from "vue";
|
||||
import { message, Modal } from "antdv-next";
|
||||
import { Plus, Trash2 } from "@lucide/vue";
|
||||
import FmsLoadingOverlay from "@/components/FmsLoadingOverlay.vue";
|
||||
@@ -41,9 +41,7 @@ const treeLoading = ref(false);
|
||||
const expandedTreeKeys = ref([]);
|
||||
const selectedModuleId = ref(null);
|
||||
const skipLoad = ref(false);
|
||||
const expandTimer = ref(null);
|
||||
const activeTab = ref("basic");
|
||||
let expandDelayResolve = null;
|
||||
function buildTreeContextMenu(node) {
|
||||
const items = [];
|
||||
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) {
|
||||
if (busy.value) return;
|
||||
if (info?.node?.isFmsRoot) {
|
||||
selectedModuleId.value = null;
|
||||
clearExpandTimer();
|
||||
return;
|
||||
}
|
||||
const nodeKey = info?.node?.key ?? keys[0];
|
||||
if (nodeKey == null) return;
|
||||
|
||||
const key = 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(),
|
||||
});
|
||||
}
|
||||
selectedModuleId.value = String(nodeKey);
|
||||
}
|
||||
|
||||
watch(selectedModuleId, (id) => {
|
||||
@@ -846,6 +789,8 @@ async function saveAll() {
|
||||
unsavedModuleIds.delete(moduleId);
|
||||
updateSavedTreeNode(moduleData);
|
||||
if (appStore.locale) await appStore.loadTranslations(appStore.locale);
|
||||
// 刷新权限数据,让菜单立即反映模块结构变更
|
||||
permissionStore.load().catch(() => {});
|
||||
message.success("保存成功");
|
||||
} catch (e) {
|
||||
message.error("保存失败:" + (e.message || e));
|
||||
@@ -1091,8 +1036,6 @@ void permissionStore
|
||||
.load()
|
||||
.then(loadTree)
|
||||
.catch((e) => message.error("模块树加载失败:" + (e.message || e)));
|
||||
|
||||
onBeforeUnmount(clearExpandTimer);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -1161,15 +1104,17 @@ onBeforeUnmount(clearExpandTimer);
|
||||
:disabled="busy"
|
||||
@update:checked="(v) => onSetField('b_canuse', v ? 1 : 0)"
|
||||
/>
|
||||
<a-divider type="vertical" />
|
||||
<span>显示菜单</span>
|
||||
<a-checkbox
|
||||
:checked="drafts.module?.b_canmenu === 1"
|
||||
:disabled="
|
||||
busy || ['data', 'data_category'].includes(drafts.module?.b_module_type)
|
||||
"
|
||||
@update:checked="(v) => onSetField('b_canmenu', v ? 1 : 0)"
|
||||
/>
|
||||
<template v-if="drafts.module?.b_module_type !== 'data'">
|
||||
<a-divider type="vertical" />
|
||||
<span>显示菜单</span>
|
||||
<a-checkbox
|
||||
:checked="drafts.module?.b_canmenu === 1"
|
||||
:disabled="
|
||||
busy || ['data', 'data_category'].includes(drafts.module?.b_module_type)
|
||||
"
|
||||
@update:checked="(v) => onSetField('b_canmenu', v ? 1 : 0)"
|
||||
/>
|
||||
</template>
|
||||
<template v-if="drafts.module?.b_module_type === 'data'">
|
||||
<a-divider type="vertical" />
|
||||
<span>自动编码</span>
|
||||
|
||||
Reference in new issue
Block a user