20260806173205

This commit is contained in:
oneao committed 2026-08-06 17:32:07 +08:00
1 parent 74cdb7f306
commit 6baa339efa
14 files changed
+804 -324

No files matched your search

+27 -23
View File
@@ -2,34 +2,38 @@
import zhCN from "antdv-next/locale/zh_CN";
const themeConfig = {
// token: {
// controlHeight: 26,
// },
// components: {
// Splitter: {
// splitBarSize: 1,
// },
// Button: {
// contentFontSize: 12,
// paddingInline: 10,
// },
// Form: {
// labelFontSize: 12,
// verticalLabelMargin: "0 0 2px",
// itemMarginBottom: 8,
// },
// Collapse: {
// headerBg: "#ffffff",
// contentBg: "#ffffff",
// borderlessContentBg: "#ffffff",
// },
// },
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',
},
},
};
const timePickerLocale = {
...zhCN.TimePicker,
placeholder: "",
rangePlaceholder: ["", ""],
};
const datePickerLocale = {
...zhCN.DatePicker,
lang: {
@@ -70,7 +74,7 @@ const locale = {
</template>
<style>
/* 全局:按钮内图标跟随 contentFontSize(13px),覆盖 Lucide 的固定 size */
/* 全局:按钮内图标跟随 contentFontSize,覆盖 Lucide 的固定 size */
.ant-btn svg {
width: 1em !important;
height: 1em !important;
@@ -63,17 +63,21 @@ const routeInstanceKey = computed(
min-width: 0;
min-height: 0;
overflow: hidden;
background: var(--fms-bg);
background: #ffffff;
}
.app-main {
display: flex;
height: 100%;
height: calc(100% - 16px);
min-width: 0;
min-height: 0;
margin: 8px;
flex-direction: column;
overflow: hidden;
background: var(--fms-bg);
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;
}
.app-content {
@@ -83,10 +87,12 @@ const routeInstanceKey = computed(
overflow-x: hidden;
overflow-y: auto;
padding: 6px;
background: #ffffff;
}
.route-content-wrap {
height: 100%;
overflow: hidden;
}
.slide-enter-active,
@@ -1,33 +1,37 @@
<script setup>
import { computed, h, onMounted, ref, watch } from "vue";
import { computed, onMounted, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { Database, House, PanelLeftClose, PanelLeftOpen, Settings } from "@lucide/vue";
import { ChevronDown, Database, House, PanelLeftClose, PanelLeftOpen, Settings } from "@lucide/vue";
import { useAppStore } from "@/stores/app";
import { usePermissionStore } from "@/stores/permissions";
import SidebarMenuItem from "./SidebarMenuItem.vue";
const route = useRoute();
const router = useRouter();
const appStore = useAppStore();
const permissionStore = usePermissionStore();
const menuIcon = (icon) => () => h(icon, { size: 16, strokeWidth: 1.8 });
const openKeys = ref(["base", "system"]);
const openKeys = ref(["base", "system", "settings-dept"]);
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 canAccessMenu(item) {
if (item.moduleId) return permissionStore.canAccess(item.moduleId);
return true;
}
const menuItems = computed(() => {
const basePages = [
{
@@ -71,40 +75,118 @@ const menuItems = computed(() => {
const items = [
{
key: "/dashboard",
icon: menuIcon(House),
icon: House,
label: appStore.t("common.dashboard", "工作台"),
},
];
if (basePages.length)
items.push({
key: "base",
icon: menuIcon(Database),
icon: Database,
label: directoryLabel("基础数据"),
children: basePages,
});
if (systemPages.length)
items.push({
key: "system",
icon: menuIcon(Settings),
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: "操作日志" },
],
});
return items;
});
const selectedKeys = computed(() => [route.meta.menuKey || route.path]);
// 递归收集所有后代 key(用于路由变化时自动展开父级链)
function collectKeys(item) {
const keys = [item.key];
if (item.children) {
for (const child of item.children) {
keys.push(...collectKeys(child));
}
}
return keys;
}
const isActive = (key) => {
const current = route.meta.menuKey || route.path;
return current === key;
};
const isGroupOpen = (key) => openKeys.value.includes(key);
function toggleGroup(key) {
if (openKeys.value.includes(key)) {
openKeys.value = openKeys.value.filter((k) => k !== key);
} else {
openKeys.value = [...openKeys.value, key];
}
}
function navigate(key) {
if (typeof key === "string" && key.startsWith("/")) router.push(key);
}
function handleItemClick(item) {
if (item.children) {
if (!effectiveCollapsed.value) {
toggleGroup(item.key);
}
} else {
navigate(item.key);
}
}
// 展开某节点及其所有祖先
function expandAncestors(items, targetKey) {
for (const item of items) {
if (!item.children) continue;
const keys = collectKeys(item);
if (keys.includes(targetKey)) {
if (!openKeys.value.includes(item.key)) {
openKeys.value = [...openKeys.value, item.key];
}
expandAncestors(item.children, targetKey);
}
}
}
// 路由变化时自动展开父级链
watch(
() => route.path,
(path) => {
if (path.startsWith("/base") && !openKeys.value.includes("base"))
openKeys.value = [...openKeys.value, "base"];
if (path.startsWith("/system") && !openKeys.value.includes("system"))
openKeys.value = [...openKeys.value, "system"];
const current = route.meta.menuKey || path;
expandAncestors(menuItems.value, current);
},
{ immediate: true },
);
function navigate({ key }) {
if (typeof key === "string" && key.startsWith("/")) router.push(key);
}
onMounted(() => permissionStore.load().catch(() => {}));
</script>
@@ -115,24 +197,93 @@ onMounted(() => permissionStore.load().catch(() => {}));
:trigger="null"
:collapsed-width="56"
collapsible
resizable
theme="light"
width="208"
width="220"
>
<div class="brand" :class="{ 'brand--collapsed': effectiveCollapsed }">
<div class="brand__mark">F</div>
<strong>{{ appStore.t("common.appName", "FMS 管理系统") }}</strong>
<!-- 品牌区 -->
<div class="sidebar-header" :class="{ collapsed: effectiveCollapsed }">
<div class="sidebar-brand">
<div class="sidebar-brand__mark">F</div>
<span class="sidebar-brand__name">{{ appStore.t("common.appName", "FMS 管理系统") }}</span>
</div>
</div>
<div class="sider-menu">
<a-menu
mode="inline"
:items="menuItems"
:selected-keys="selectedKeys"
:open-keys="openKeys"
@click="navigate"
@open-change="openKeys = $event"
/>
</div>
<div class="sider-footer">
<!-- 菜单区 -->
<nav class="sidebar-nav">
<template v-for="group in menuItems" :key="group.key">
<!-- ===== 有子菜单:可展开 ===== -->
<template v-if="group.children">
<!-- 展开态 -->
<template v-if="!effectiveCollapsed">
<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
class="sidebar-menu-chevron"
:class="{ rotated: isGroupOpen(group.key) }"
/>
</button>
<div v-if="isGroupOpen(group.key)" class="sidebar-submenu">
<SidebarMenuItem
v-for="child in group.children"
:key="child.key"
:item="child"
:open-keys="openKeys"
:is-active="isActive"
:is-group-open="isGroupOpen"
@toggle="toggleGroup"
@navigate="navigate"
/>
</div>
</template>
<!-- 折叠态 -->
<template v-else>
<a-tooltip :title="group.label" placement="right">
<button
type="button"
class="sidebar-menu-button collapsed"
@click="handleItemClick(group)"
>
<component :is="group.icon" v-if="group.icon" class="sidebar-menu-icon" />
</button>
</a-tooltip>
</template>
</template>
<!-- ===== 无子菜单:普通按钮 ===== -->
<template v-else>
<a-tooltip v-if="effectiveCollapsed" :title="group.label" placement="right">
<button
type="button"
class="sidebar-menu-button collapsed"
:class="{ active: isActive(group.key) }"
@click="navigate(group.key)"
>
<component :is="group.icon" v-if="group.icon" class="sidebar-menu-icon" />
</button>
</a-tooltip>
<button
v-else
type="button"
class="sidebar-menu-button"
:class="{ active: isActive(group.key) }"
@click="navigate(group.key)"
>
<component :is="group.icon" v-if="group.icon" class="sidebar-menu-icon" />
<span class="sidebar-menu-label">{{ group.label }}</span>
</button>
</template>
</template>
</nav>
<!-- 底部折叠按钮 -->
<div class="sidebar-footer">
<a-tooltip
:title="
effectiveCollapsed
@@ -140,84 +291,223 @@ onMounted(() => permissionStore.load().catch(() => {}));
: appStore.t('common.collapseSidebar', '收起侧栏')
"
placement="right"
><a-button type="text" class="sider-collapse" @click="appStore.toggleSidebar"
><PanelLeftOpen v-if="effectiveCollapsed" /><PanelLeftClose v-else /></a-button
></a-tooltip>
>
<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>
<style scoped lang="scss">
/* ============================================================
* Sidebar 容器
* ============================================================ */
.app-sider {
height: 100%;
overflow: hidden;
border-right: 1px solid var(--fms-border);
border-right: 1px solid transparent;
background: #ffffff;
transition: border-color 0.2s ease;
&:hover {
border-right-color: var(--border, #e4e4e7);
}
:deep(.ant-layout-sider-children) {
display: flex;
height: 100%;
flex-direction: column;
}
:deep(.ant-menu) {
border-inline-end: 0;
}
:deep(.ant-menu-item),
:deep(.ant-menu-submenu-title) {
width: 100%;
height: 36px;
margin: 0;
border-radius: 0;
line-height: 36px;
transition: padding-inline-start 0.2s ease;
}
:deep(.ant-menu-inline-collapsed) {
.ant-menu-title-content {
display: none !important;
}
.ant-menu-item,
.ant-menu-submenu-title {
padding-inline-start: 20px !important;
}
.ant-menu-item-icon {
vertical-align: middle;
}
}
/* ============================================================
* Header / 品牌区
* ============================================================ */
.sidebar-header {
display: flex;
flex: none;
height: 48px;
align-items: center;
justify-content: center;
padding: 8px 12px 0;
&.collapsed {
padding: 8px 0 0;
}
}
.brand {
.sidebar-brand {
display: flex;
min-height: 44px;
align-items: center;
gap: 8px;
padding: 6px 12px 6px 15px;
gap: 10px;
overflow: hidden;
white-space: nowrap;
.collapsed & {
gap: 0;
}
&__mark {
display: grid;
flex: none;
width: 28px;
height: 28px;
place-items: center;
border-radius: 6px;
background: var(--primary, #0a0a0a);
color: var(--primary-foreground, #fafafa);
font-size: 14px;
font-weight: 700;
}
&__name {
font-size: 14px;
font-weight: 600;
color: var(--foreground, #0a0a0a);
.collapsed & {
display: none;
}
}
}
.brand__mark {
display: grid;
flex: none;
width: 26px;
height: 26px;
place-items: center;
border-radius: 6px;
background: #1677ff;
color: #fff;
font-weight: 700;
}
.brand strong {
font-size: 14px;
}
.brand--collapsed strong {
display: none;
}
.sider-menu {
/* ============================================================
* 导航区域
* ============================================================ */
.sidebar-nav {
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
overflow: auto;
gap: 2px;
overflow-y: auto;
padding: 8px;
}
.sider-footer {
padding: 4px;
border-top: 1px solid var(--fms-border);
}
.sider-collapse {
/* ---- 菜单按钮(一级 & 分组标题)---- */
.sidebar-menu-button {
display: flex;
width: 100%;
height: 32px;
align-items: center;
gap: 10px;
padding: 0 10px;
border: 0;
border-radius: 6px;
background: transparent;
color: #3f3f46;
cursor: pointer;
font-size: 14px;
text-align: left;
transition:
background-color 0.15s ease,
color 0.15s ease;
user-select: none;
&:hover {
background: var(--accent, #f4f4f5);
color: #3f3f46;
}
&.active,
&: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%);
}
&.collapsed {
justify-content: center;
padding: 0;
}
}
.sidebar-menu-icon {
width: 16px;
height: 16px;
flex: none;
stroke-width: 1.8;
}
.sidebar-menu-label {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sidebar-menu-chevron {
width: 14px;
height: 14px;
flex: none;
stroke-width: 2;
color: var(--muted-foreground, #71717a);
transition: transform 0.2s ease;
&.rotated {
transform: rotate(180deg);
}
}
/* ---- 子菜单容器 ---- */
.sidebar-submenu {
display: flex;
position: relative;
flex-direction: column;
gap: 2px;
overflow: hidden;
padding: 2px 0 2px 24px;
&::before {
content: "";
position: absolute;
top: 4px;
bottom: 4px;
left: 16px;
width: 1px;
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>
@@ -1,17 +1,7 @@
<script setup>
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import {
ArrowLeft,
ChevronLeft,
ChevronRight,
House,
Maximize2,
Minimize2,
RotateCw,
X,
} from "@lucide/vue";
import { theme } from "antdv-next";
import { ChevronLeft, ChevronRight, X } from "@lucide/vue";
import { useAppStore } from "@/stores/app";
import { usePermissionStore } from "@/stores/permissions";
@@ -19,7 +9,6 @@ const route = useRoute();
const router = useRouter();
const appStore = useAppStore();
const permissionStore = usePermissionStore();
const { token } = theme.useToken();
const headerRef = ref(null);
const trackRef = ref(null);
const overflowed = ref(false);
@@ -161,62 +150,15 @@ function handleTabKeydown(event, index) {
focusAdjacentTab(index, event.key === "ArrowLeft" ? -1 : 1);
}
function goConsole() {
router.push("/dashboard");
}
function goBack() {
router.back();
}
function refreshCurrentRoute() {
appStore.refreshRoute(route.fullPath);
}
function toggleMaximize() {
appStore.toggleContentMaximized();
}
</script>
<template>
<header
ref="headerRef"
class="tab-header"
:style="{
'--tabs-active-bg': token.colorPrimaryBg,
'--tabs-active-color': token.colorPrimary,
'--tabs-idle-bg': token.colorFillQuaternary,
}"
>
<div class="tab-actions">
<a-tooltip title="返回上一页" placement="bottom">
<button type="button" class="tab-action" aria-label="返回上一页" @click="goBack">
<ArrowLeft />
</button>
</a-tooltip>
<a-tooltip title="刷新当前页" placement="bottom">
<button
type="button"
class="tab-action"
aria-label="刷新当前页"
@click="refreshCurrentRoute"
>
<RotateCw />
</button>
</a-tooltip>
<a-tooltip title="返回控制台" placement="bottom">
<button type="button" class="tab-action" aria-label="返回控制台" @click="goConsole">
<House />
</button>
</a-tooltip>
</div>
<header ref="headerRef" class="tab-header">
<!-- 滚动按钮 -->
<button
v-if="overflowed"
type="button"
class="tab-nav-button"
class="tab-btn"
aria-label="向左滚动页签"
:disabled="!canScrollLeft"
@click="scrollTabs(-1)"
@@ -224,6 +166,7 @@ function toggleMaximize() {
<ChevronLeft />
</button>
<!-- 标签轨道 -->
<div ref="trackRef" class="tab-track" role="tablist" @scroll.passive="updateScrollState">
<div
v-for="(item, index) in tabItems"
@@ -258,28 +201,13 @@ function toggleMaximize() {
<button
v-if="overflowed"
type="button"
class="tab-nav-button"
class="tab-btn"
aria-label="向右滚动页签"
:disabled="!canScrollRight"
@click="scrollTabs(1)"
>
<ChevronRight />
</button>
<a-tooltip
:title="appStore.contentMaximized ? '退出最大化' : '应用内最大化'"
placement="bottom"
>
<button
type="button"
class="tab-nav-button tab-maximize"
aria-label="应用内最大化"
@click="toggleMaximize"
>
<Minimize2 v-if="appStore.contentMaximized" />
<Maximize2 v-else />
</button>
</a-tooltip>
</header>
</template>
@@ -292,16 +220,53 @@ function toggleMaximize() {
align-items: center;
gap: 4px;
padding: 3px 8px;
background: var(--fms-surface);
background: #ffffff;
}
/* ---- 通用小按钮 ---- */
.tab-btn {
display: grid;
flex: none;
width: 28px;
height: 28px;
padding: 0;
place-items: center;
border: 0;
border-radius: 6px;
color: #71717a;
background: transparent;
cursor: pointer;
transition:
color 0.15s ease,
background-color 0.15s ease;
&:hover:not(:disabled) {
color: #3f3f46;
background: #f4f4f5;
}
&:disabled {
cursor: default;
opacity: 0.35;
}
.lucide {
width: 14px;
height: 14px;
stroke-width: 1.8;
}
}
/* ---- 标签轨道 ---- */
.tab-track {
display: flex;
flex: 1;
min-width: 0;
gap: 6px;
padding: 2px 0;
margin: -2px 0;
overflow-x: auto;
overflow-y: hidden;
overflow-y: visible;
overscroll-behavior-inline: contain;
scrollbar-width: none;
@@ -310,6 +275,7 @@ function toggleMaximize() {
}
}
/* ---- 标签项 ---- */
.tab-item {
display: flex;
flex: none;
@@ -317,17 +283,28 @@ function toggleMaximize() {
height: 28px;
align-items: center;
overflow: hidden;
border-radius: 4px;
background: var(--tabs-idle-bg);
color: var(--fms-text-secondary);
border-radius: 6px;
background: #f4f4f5;
color: #71717a;
transition:
background-color 0.15s ease,
color 0.15s ease;
&:hover {
color: var(--tabs-active-color);
background: #f4f4f5;
color: #3f3f46;
}
&--active {
background: var(--tabs-active-bg);
color: var(--tabs-active-color);
background: #f4f4f5;
color: #3f3f46;
font-weight: 500;
box-shadow: 0 1px 3px rgb(0 0 0 / 6%), 0 1px 2px rgb(0 0 0 / 4%);
&:hover {
background: #f4f4f5;
color: #3f3f46;
}
}
&__select,
@@ -345,8 +322,8 @@ function toggleMaximize() {
flex: 1;
align-items: center;
justify-content: center;
padding: 0 7px;
font-size: 12px;
padding: 0 8px;
font-size: 13px;
span {
white-space: nowrap;
@@ -359,6 +336,7 @@ function toggleMaximize() {
flex: none;
padding: 0;
place-items: center;
border-radius: 4px;
&:hover {
background: rgb(0 0 0 / 6%);
@@ -367,70 +345,8 @@ function toggleMaximize() {
.lucide {
width: 10px;
height: 10px;
stroke-width: 2;
}
}
}
.tab-nav-button {
display: grid;
flex: none;
width: 28px;
height: 28px;
padding: 0;
place-items: center;
border: 0;
border-radius: 4px;
color: var(--fms-text-secondary);
background: var(--tabs-idle-bg);
cursor: pointer;
&:hover:not(:disabled) {
color: var(--tabs-active-color);
background: var(--tabs-active-bg);
}
&:disabled {
cursor: default;
opacity: 0.35;
}
.lucide {
width: 14px;
height: 14px;
}
}
.tab-actions {
display: flex;
flex: none;
align-items: center;
gap: 4px;
}
.tab-action {
display: grid;
flex: none;
width: 28px;
height: 28px;
padding: 0;
place-items: center;
border: 0;
border-radius: 4px;
color: var(--fms-text-secondary);
background: var(--tabs-idle-bg);
cursor: pointer;
transition:
color 0.15s ease,
background-color 0.15s ease;
&:hover:not(:disabled) {
color: var(--tabs-active-color);
background: var(--tabs-active-bg);
}
.lucide {
width: 14px;
height: 14px;
}
}
</style>
@@ -1,8 +1,8 @@
<script setup>
import { computed, h, onMounted, ref, watch } from "vue";
import { computed, onMounted, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { message } from "antdv-next";
import { Bell, ChevronDown, Languages, LogOut, Settings, UserRound } from "@lucide/vue";
import { Bell, ChevronDown, Languages, LogOut, UserRound } from "@lucide/vue";
import { useAppStore } from "@/stores/app";
import { useAuthStore } from "@/stores/auth";
import { usePermissionStore } from "@/stores/permissions";
@@ -14,7 +14,6 @@ const authStore = useAuthStore();
const permissionStore = usePermissionStore();
const profileOpen = ref(false);
const logoutOpen = ref(false);
const menuIcon = (icon) => () => h(icon, { size: 16, strokeWidth: 1.8 });
const breadcrumbItems = computed(() => {
const items = [];
@@ -58,18 +57,18 @@ const userMenu = computed(() => ({
items: [
{
key: "profile",
icon: menuIcon(UserRound),
icon: () => h(UserRound, { size: 16, strokeWidth: 1.8 }),
label: appStore.t("common.profile", "个人资料"),
},
{
key: "settings",
icon: menuIcon(Settings),
icon: () => h(Settings, { size: 16, strokeWidth: 1.8 }),
label: appStore.t("common.accountSettings", "账户设置"),
},
{ type: "divider" },
{
key: "logout",
icon: menuIcon(LogOut),
icon: () => h(LogOut, { size: 16, strokeWidth: 1.8 }),
label: appStore.t("common.logout", "退出登录"),
danger: true,
},
@@ -107,12 +106,17 @@ function confirmLogout() {
<template>
<header class="top-toolbar" :class="{ 'top-toolbar--hidden': appStore.contentMaximized }">
<a-breadcrumb class="toolbar-breadcrumb" separator=">">
<a-breadcrumb-item v-for="item in breadcrumbItems" :key="item.label">
{{ item.label }}
</a-breadcrumb-item>
</a-breadcrumb>
<!-- 面包屑 -->
<nav class="toolbar-breadcrumb" aria-label="面包屑导航">
<template v-for="(item, index) in breadcrumbItems" :key="item.label">
<span v-if="index > 0" class="toolbar-breadcrumb__sep">/</span>
<span class="toolbar-breadcrumb__item" :class="{ current: index === breadcrumbItems.length - 1 }">
{{ item.label }}
</span>
</template>
</nav>
<!-- 右侧操作 -->
<div class="toolbar-actions">
<a-tooltip :title="appStore.t('common.switchLanguage', '切换语言')" placement="bottom">
<a-dropdown
@@ -123,27 +127,29 @@ function confirmLogout() {
:trigger="['click']"
placement="bottomRight"
>
<a-button
type="text"
class="toolbar-action"
<button
type="button"
class="toolbar-btn"
:aria-label="appStore.t('common.switchLanguage', '切换语言')"
>
<template #icon><Languages /></template>
</a-button>
<Languages />
</button>
</a-dropdown>
</a-tooltip>
<a-tooltip :title="appStore.t('common.notifications', '通知提醒')" placement="bottom">
<a-button
type="text"
class="toolbar-action"
<button
type="button"
class="toolbar-btn"
:aria-label="appStore.t('common.notifications', '通知提醒')"
>
<a-badge dot><Bell /></a-badge>
</a-button>
</button>
</a-tooltip>
<a-dropdown :menu="userMenu" :trigger="['click']" placement="bottomRight">
<button type="button" class="toolbar-user" aria-label="用户菜单">
<a-avatar :size="24"><UserRound /></a-avatar>
<a-avatar :size="26"><UserRound /></a-avatar>
<span>{{ displayName }}</span>
<ChevronDown class="toolbar-user__arrow" />
</button>
@@ -179,23 +185,40 @@ function confirmLogout() {
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 0 8px 0 12px;
border-bottom: 1px solid var(--fms-border);
background: var(--fms-surface);
padding: 0 8px 0 14px;
background: #ffffff;
&--hidden {
display: none;
}
}
/* ---- 面包屑 ---- */
.toolbar-breadcrumb {
display: flex;
min-width: 0;
align-items: center;
gap: 6px;
overflow: hidden;
font-size: 13px;
text-overflow: ellipsis;
white-space: nowrap;
&__sep {
color: #d4d4d8;
font-size: 12px;
}
&__item {
color: #71717a;
&.current {
color: #3f3f46;
}
}
}
/* ---- 右侧操作区 ---- */
.toolbar-actions {
display: flex;
flex: none;
@@ -204,38 +227,63 @@ function confirmLogout() {
gap: 2px;
}
.toolbar-action {
/* ---- 通用操作按钮 ---- */
.toolbar-btn {
display: grid;
width: 32px;
height: 32px;
padding-inline: 0;
border-radius: 4px;
color: var(--fms-text-secondary);
padding: 0;
place-items: center;
border: 0;
border-radius: 6px;
color: #71717a;
background: transparent;
cursor: pointer;
font-size: 17px;
transition:
color 0.15s ease,
background-color 0.15s ease;
&:hover {
color: #3f3f46;
background: #f4f4f5;
}
.lucide {
width: 16px;
height: 16px;
stroke-width: 1.8;
}
}
/* ---- 用户按钮 ---- */
.toolbar-user {
display: flex;
height: 32px;
align-items: center;
gap: 6px;
margin-left: 4px;
padding: 0 6px 0 4px;
padding: 0 8px 0 4px;
border: 0;
border-radius: 4px;
color: var(--fms-text);
border-radius: 6px;
color: #3f3f46;
background: transparent;
cursor: pointer;
font-size: 13px;
white-space: nowrap;
transition:
color 0.15s ease,
background-color 0.15s ease;
&:hover {
background: var(--fms-hover);
background: #f4f4f5;
}
&__arrow {
width: 12px;
height: 12px;
color: var(--fms-text-secondary);
color: #71717a;
stroke-width: 2;
}
}
</style>
@@ -0,0 +1,162 @@
<script setup>
import { ChevronDown } from "@lucide/vue";
const props = defineProps({
item: { type: Object, required: true },
openKeys: { type: Array, required: true },
isActive: { type: Function, required: true },
isGroupOpen: { type: Function, required: true },
});
const emit = defineEmits(["toggle", "navigate"]);
function handleClick() {
if (props.item.children) {
emit("toggle", props.item.key);
} else {
emit("navigate", props.item.key);
}
}
</script>
<template>
<!-- 有子节点:可展开 -->
<template v-if="item.children">
<button
type="button"
class="sidebar-submenu-button"
@click="handleClick"
>
<span class="sidebar-submenu-label">{{ item.label }}</span>
<ChevronDown
class="sidebar-menu-chevron"
:class="{ rotated: isGroupOpen(item.key) }"
/>
</button>
<div v-if="isGroupOpen(item.key)" class="sidebar-submenu-nested">
<SidebarMenuItem
v-for="child in item.children"
:key="child.key"
:item="child"
:open-keys="openKeys"
:is-active="isActive"
:is-group-open="isGroupOpen"
@toggle="(key) => emit('toggle', key)"
@navigate="(key) => emit('navigate', key)"
/>
</div>
</template>
<!-- 叶子节点 -->
<button
v-else
type="button"
class="sidebar-submenu-item"
:class="{ active: isActive(item.key) }"
@click="handleClick"
>
<span class="sidebar-submenu-label">{{ item.label }}</span>
</button>
</template>
<style scoped>
/* ---- 叶子节点 ---- */
.sidebar-submenu-item {
display: flex;
width: 100%;
height: 30px;
align-items: center;
padding: 0 10px;
border: 0;
border-radius: 6px;
background: transparent;
color: #3f3f46;
cursor: pointer;
font-size: 14px;
text-align: left;
transition:
background-color 0.15s ease,
color 0.15s ease;
user-select: none;
}
.sidebar-submenu-item:hover {
background: var(--accent, #f4f4f5);
color: #3f3f46;
}
.sidebar-submenu-item.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%);
}
/* ---- 可展开的父级按钮 ---- */
.sidebar-submenu-button {
display: flex;
width: 100%;
height: 30px;
align-items: center;
gap: 10px;
padding: 0 10px;
border: 0;
border-radius: 6px;
background: transparent;
color: #3f3f46;
cursor: pointer;
font-size: 14px;
text-align: left;
transition:
background-color 0.15s ease,
color 0.15s ease;
user-select: none;
}
.sidebar-submenu-button:hover {
background: var(--accent, #f4f4f5);
color: #3f3f46;
}
/* ---- 嵌套容器 ---- */
.sidebar-submenu-nested {
display: flex;
position: relative;
flex-direction: column;
gap: 2px;
overflow: hidden;
padding: 2px 0 2px 16px;
}
.sidebar-submenu-nested::before {
content: "";
position: absolute;
top: 4px;
bottom: 4px;
left: 8px;
width: 1px;
background: var(--border, #e4e4e7);
}
/* ---- 文字 ---- */
.sidebar-submenu-label {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sidebar-menu-chevron {
width: 12px;
height: 12px;
flex: none;
stroke-width: 2;
color: var(--muted-foreground, #71717a);
transition: transform 0.2s ease;
}
.sidebar-menu-chevron.rotated {
transform: rotate(180deg);
}
</style>
+14 -13
View File
@@ -2,26 +2,27 @@
* 基础重置 / Base Reset
* ============================================================ */
:root {
color: #1f2329;
background: #f5f7fa;
color: #0a0a0a;
background: #fafafa;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
/* 设计令牌:提升到 :root,确保 teleport 到 <body> 的模态框(如附件管理)也能继承,
否则模态框内 var(--fms-border) 等全部未定义,导致报错且边框/颜色失效。 */
--fms-bg: #f5f7fa;
否则模态框内 var(--fms-border) 等全部未定义,导致报错且边框/颜色失效。
shadcn 黑白极简风,对齐 antdv themeConfig */
--fms-bg: #fafafa;
--fms-surface: #ffffff;
--fms-border: #e5e8eb;
--fms-hover: #f2f3f5;
--fms-text: #1f2329;
--fms-text-secondary: #646a73;
--fms-disabled: #c9cdd4;
--fms-shadow: 0 4px 12px rgb(31 35 41 / 6%);
--fms-primary: #1677ff;
--fms-danger: #ff4d4f;
--fms-warning: #fa8c16;
--fms-border: #e4e4e7;
--fms-hover: #f4f4f5;
--fms-text: #0a0a0a;
--fms-text-secondary: #71717a;
--fms-disabled: #d4d4d8;
--fms-shadow: 0 4px 12px rgb(10 10 10 / 6%);
--fms-primary: #0a0a0a;
--fms-danger: #ef4444;
--fms-warning: #f59e0b;
}
* {
+69
View File
@@ -1,2 +1,71 @@
@import "tailwindcss";
@import "@antdv-next/tailwind/compat.css";
/* ============================================================
* shadcn 风格主题令牌(仅亮色模式)
* 对齐 antdv themeConfig:colorPrimary=#0a0a0a, borderRadius=6
* ============================================================ */
/* 圆角比例体系:--radius 为基准 (≈6px) */
:root {
--radius: 0.5rem;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--radius-sm: calc(var(--radius) - 2px);
--radius-md: calc(var(--radius) - 1px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 2px);
}
:root {
--background: #fafafa;
--foreground: #0a0a0a;
--card: #ffffff;
--card-foreground: #0a0a0a;
--popover: #ffffff;
--popover-foreground: #0a0a0a;
--primary: #0a0a0a;
--primary-foreground: #fafafa;
--secondary: #f4f4f5;
--secondary-foreground: #0a0a0a;
--muted: #f4f4f5;
--muted-foreground: #71717a;
--accent: #f4f4f5;
--accent-foreground: #0a0a0a;
--destructive: #ef4444;
--destructive-foreground: #fafafa;
--border: #e4e4e7;
--input: #e4e4e7;
--ring: #0a0a0a;
}
@layer base {
* {
border-color: var(--border);
}
body {
background-color: var(--background);
color: var(--foreground);
}
}
@@ -35,9 +35,6 @@ function goBack() {
display: flex;
flex-direction: column;
overflow: hidden;
background: var(--fms-surface);
border: 1px solid var(--fms-border);
border-radius: 6px;
}
.detail-header {
@@ -85,9 +85,6 @@ function confirmBatchDelete() {
width: 100%;
height: 100%;
overflow: hidden;
border: 1px solid var(--fms-border);
border-radius: 6px;
background: var(--fms-surface);
}
.contact-page :deep(.fms-module-list-page) {
@@ -503,9 +503,6 @@ onMounted(() => {
.othercompany-page {
overflow: hidden;
border: 1px solid var(--fms-border);
border-radius: 6px;
background: var(--fms-surface);
}
.category-panel,
@@ -520,9 +520,6 @@ onMounted(loadCategories);
}
.otherdata-page {
overflow: hidden;
border: 1px solid var(--fms-border);
border-radius: 6px;
background: var(--fms-surface);
}
.category-panel,
.data-panel {
@@ -240,7 +240,6 @@ function onLocaleChange(record) {
height: 100%;
min-height: 0;
color: var(--fms-text);
background: var(--fms-surface);
}
.table-demo__toolbar {
@@ -432,9 +432,6 @@ async function handleSave() {
width: 100%;
height: 100%;
overflow: hidden;
border: 1px solid var(--fms-border);
border-radius: 6px;
background: var(--fms-surface);
}
.user-page :deep(.fms-module-list-page) {