20260806224941

This commit is contained in:
oneao committed 2026-08-06 22:49:41 +08:00
1 parent 6baa339efa
commit 6938deef39
12 files changed
+353 -286

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.';
-23
View File
@@ -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',
},
},
};
+21 -2
View File
@@ -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) {
function getDropdownItems(children) {
return children.map((item) => {
if (item.children) {
if (!effectiveCollapsed.value) {
toggleGroup(item.key);
}
} else {
navigate(item.key);
return {
key: item.key,
label: item.label,
popupClassName: "sidebar-submenu-popup",
children: getDropdownItems(item.children),
};
}
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>
+16 -4
View File
@@ -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 = [];
// 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 = [];
return;
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;
+50
View File
@@ -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,6 +1104,7 @@ onBeforeUnmount(clearExpandTimer);
:disabled="busy"
@update:checked="(v) => onSetField('b_canuse', v ? 1 : 0)"
/>
<template v-if="drafts.module?.b_module_type !== 'data'">
<a-divider type="vertical" />
<span>显示菜单</span>
<a-checkbox
@@ -1170,6 +1114,7 @@ onBeforeUnmount(clearExpandTimer);
"
@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>
+2 -1
View File
@@ -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_name` | `nvarchar(100)` | 默认中文名称 |
| `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_viewtable` | `varchar(128)` | 查询来源表或视图;数据模块字段从此处同步 |
| `b_savetable` | `varchar(128)` | 保存目标表 |
| `b_keyfield` | `varchar(50)` | 主键字段,例如 `b_id` |
| `b_orderfield` | `varchar(500)` | 默认排序,例如 `b_xh ASC, b_id DESC` |
| `b_canmenu` | `int` | 是否显示在菜单,默认 `0` |
| `b_icon` | `varchar(50)` | Lucide 图标名称,仅 `directory` 和 `page` 类型使用,用于菜单显示 |
| `b_canuse` | `int` | 是否启用,默认 `1` |
| `b_xh` | `int` | 同级显示顺序,默认 `0` |