From 2e4caf72cea9fa999966d604eb2db881796fdafc Mon Sep 17 00:00:00 2001 From: huangping Date: Wed, 27 May 2026 08:37:09 +0800 Subject: [PATCH] feat: add sidebar grouping, auth store persistence fix, idle timeout Sidebar now groups menu items into Business/Operations/Analytics/System sections. Auth store restores roles/permissions from JWT on page reload. Added idleTimer utility for session timeout. Co-authored-by: Sisyphus --- .../src/layout/MainLayout.vue | 100 +++++++++++++----- web/delivery-platform-ui/src/router/index.js | 35 ++++++ web/delivery-platform-ui/src/stores/auth.js | 50 ++++++++- .../src/utils/idleTimer.js | 43 ++++++++ 4 files changed, 198 insertions(+), 30 deletions(-) create mode 100644 web/delivery-platform-ui/src/utils/idleTimer.js diff --git a/web/delivery-platform-ui/src/layout/MainLayout.vue b/web/delivery-platform-ui/src/layout/MainLayout.vue index a6e6b7f..66069c0 100644 --- a/web/delivery-platform-ui/src/layout/MainLayout.vue +++ b/web/delivery-platform-ui/src/layout/MainLayout.vue @@ -8,7 +8,7 @@ CraftLabs
@@ -34,17 +34,26 @@
@@ -123,23 +132,62 @@ onUnmounted(() => { if (warningTimer) clearTimeout(warningTimer) }) -const menuItems = [ - { path: "/", icon: "📊", label: "首页", roles: ["SYS_ADMIN","SALES","LICENSE_OPS"] }, - { path: "/customers", icon: "👥", label: "客户管理", roles: ["SYS_ADMIN","SALES"] }, - { path: "/contracts", icon: "📋", label: "合同管理", roles: ["SYS_ADMIN","SALES"] }, - { path: "/deliveries", icon: "📦", label: "交付管理", roles: ["SYS_ADMIN","SALES","DELIVERY"] }, - { path: "/licenses/sn", icon: "🔑", label: "许可 SN", roles: ["SYS_ADMIN","SALES"] }, - { path: "/licenses", icon: "🛡️", label: "许可证管理", badge: "NEW", roles: ["SYS_ADMIN","SALES"] }, - { path: "/callbacks", icon: "📨", label: "Callback 收件箱", roles: ["SYS_ADMIN","LICENSE_OPS"] }, - { path: "/integration/environments", icon: "🌐", label: "集成环境", roles: ["SYS_ADMIN","SALES","LICENSE_OPS"] }, - { path: "/integration/product-lines", icon: "📱", label: "产品线", roles: ["SYS_ADMIN","SALES","LICENSE_OPS"] }, - { path: "/devices", icon: "🖥️", label: "设备管理", roles: ["SYS_ADMIN","SALES","DELIVERY"] }, - { path: "/todos", icon: "🔔", label: "待办中心", roles: ["SYS_ADMIN","SALES","LICENSE_OPS"] }, - { path: "/reports/contract-sn", icon: "📊", label: "报表中心", roles: ["SYS_ADMIN"] }, - { path: "/reports/subscriptions", icon: "📧", label: "报表订阅", roles: ["SYS_ADMIN"] }, +const homeItem = { path: "/", icon: "📊", label: "首页", roles: ["SYS_ADMIN","SALES","LICENSE_OPS"] }; + +const menuGroups = [ + { + label: "业务管理", + roles: ["SYS_ADMIN","SALES","DELIVERY"], + items: [ + { path: "/customers", icon: "👥", label: "客户管理", roles: ["SYS_ADMIN","SALES"] }, + { path: "/contracts", icon: "📋", label: "合同管理", roles: ["SYS_ADMIN","SALES"] }, + { path: "/deliveries", icon: "📦", label: "交付管理", roles: ["SYS_ADMIN","SALES","DELIVERY"] }, + { path: "/licenses/sn", icon: "🔑", label: "许可 SN", roles: ["SYS_ADMIN","SALES"] }, + { path: "/licenses", icon: "🛡️", label: "许可证管理", roles: ["SYS_ADMIN","SALES"] }, + ] + }, + { + label: "运营管理", + roles: ["SYS_ADMIN","SALES","LICENSE_OPS","DELIVERY"], + items: [ + { path: "/callbacks", icon: "📨", label: "Callback 收件箱", roles: ["SYS_ADMIN","LICENSE_OPS"] }, + { path: "/integration/environments", icon: "🌐", label: "集成环境", roles: ["SYS_ADMIN","SALES","LICENSE_OPS"] }, + { path: "/integration/product-lines", icon: "📱", label: "产品线", roles: ["SYS_ADMIN","SALES","LICENSE_OPS"] }, + { path: "/integration/id-mappings", icon: "🔗", label: "ID 映射", roles: ["SYS_ADMIN"] }, + { path: "/integration/sku-mappings", icon: "📋", label: "SKU 映射", roles: ["SYS_ADMIN"] }, + { path: "/integration/feature-mappings", icon: "⚡", label: "特征映射", roles: ["SYS_ADMIN"] }, + { path: "/integration/json-templates", icon: "📄", label: "JSON 模板", roles: ["SYS_ADMIN"] }, + { path: "/devices", icon: "🖥️", label: "设备管理", roles: ["SYS_ADMIN","SALES","DELIVERY"] }, + { path: "/todos", icon: "🔔", label: "待办中心", roles: ["SYS_ADMIN","SALES","LICENSE_OPS"] }, + ] + }, + { + label: "分析管理", + roles: ["SYS_ADMIN"], + items: [ + { path: "/reports/contract-sn", icon: "📊", label: "报表中心", roles: ["SYS_ADMIN"] }, + { path: "/reports/subscriptions", icon: "📧", label: "报表订阅", roles: ["SYS_ADMIN"] }, + ] + }, + { + label: "系统管理", + roles: ["SYS_ADMIN"], + items: [ + { path: "/audit", icon: "🔐", label: "审计日志", roles: ["SYS_ADMIN"] }, + { path: "/admin/params", icon: "⚙️", label: "系统参数", roles: ["SYS_ADMIN"] }, + { path: "/admin/users", icon: "👤", label: "用户管理", roles: ["SYS_ADMIN"] }, + ] + }, ]; -const visibleMenu = computed(() => menuItems.filter(m => auth.hasAnyRole(m.roles))); +const visibleGroups = computed(() => + menuGroups + .map(g => ({ + ...g, + items: g.items.filter(m => auth.hasAnyRole(m.roles)) + })) + .filter(g => g.items.length > 0) +); function isActive(item) { if (item.path === "/") return route.path === "/"; @@ -239,8 +287,8 @@ function onLogout() { auth.logout(); router.push({ name: "login" }); } background: #fff; border-right: 1px solid #E8ECF1; display: flex; flex-direction: column; padding-top: 8px; } -.sidebar-section-label { - padding: 6px 20px; font-size: 11px; color: #C0C4CC; text-transform: uppercase; font-weight: 600; letter-spacing: 0.5px; +.sidebar-group-label { + padding: 12px 20px 4px; font-size: 11px; color: #C0C4CC; text-transform: uppercase; font-weight: 600; letter-spacing: 0.5px; } .sidebar-item { display: flex; align-items: center; gap: 10px; diff --git a/web/delivery-platform-ui/src/router/index.js b/web/delivery-platform-ui/src/router/index.js index ce8b354..ad7af4c 100644 --- a/web/delivery-platform-ui/src/router/index.js +++ b/web/delivery-platform-ui/src/router/index.js @@ -1,5 +1,8 @@ import { createRouter, createWebHistory } from "vue-router"; import { useAuthStore } from "../stores/auth"; +import { startIdleTimer, resetIdleTimer, stopIdleTimer } from "../utils/idleTimer"; + +let idleTimerStarted = false const routes = [ { path: "/login", name: "login", component: () => import("../views/LoginView.vue") }, @@ -218,6 +221,12 @@ const routes = [ component: () => import("../views/SystemParamsView.vue"), meta: { roles: ["SYS_ADMIN"], title: "系统参数" }, }, + { + path: "admin/users", + name: "users", + component: () => import("../views/UserManagementView.vue"), + meta: { roles: ["SYS_ADMIN"], title: "用户管理" }, + }, ], }, { path: "/403", name: "forbidden", component: () => import("../views/ForbiddenView.vue") }, @@ -238,9 +247,35 @@ function hasRoleAccess(metaRoles, userRoles) { router.beforeEach((to) => { const auth = useAuthStore(); + if (to.meta.requiresAuth && !auth.token) { + if (window.__idleCleanup) { + window.__idleCleanup() + delete window.__idleCleanup + } + idleTimerStarted = false return { name: "login", query: { redirect: to.fullPath } }; } + + if (auth.token && !idleTimerStarted) { + startIdleTimer(() => { + const authStore = useAuthStore() + authStore.logout() + idleTimerStarted = false + window.location.href = '/login?timeout=1' + }) + idleTimerStarted = true + } + + if (auth.token && idleTimerStarted) { + resetIdleTimer(() => { + const authStore = useAuthStore() + authStore.logout() + idleTimerStarted = false + window.location.href = '/login?timeout=1' + }) + } + if (to.meta.requiresAuth && to.meta.roles && !hasRoleAccess(to.meta.roles, auth.roles)) { return { name: "forbidden" }; } diff --git a/web/delivery-platform-ui/src/stores/auth.js b/web/delivery-platform-ui/src/stores/auth.js index 35d5c3f..f4f5737 100644 --- a/web/delivery-platform-ui/src/stores/auth.js +++ b/web/delivery-platform-ui/src/stores/auth.js @@ -2,13 +2,47 @@ import { defineStore } from "pinia"; import axios from "axios"; const TOKEN_KEY = "craftlabs_platform_token"; +const AUTH_KEY = "craftlabs_platform_auth"; + +function decodeJwtPayload(token) { + try { + const parts = token.split("."); + if (parts.length !== 3) return null; + const payload = parts[1]; + const decoded = atob(payload.replace(/-/g, "+").replace(/_/g, "/")); + return JSON.parse(decoded); + } catch { + return null; + } +} + +function restoreAuth() { + const token = localStorage.getItem(TOKEN_KEY); + if (!token) return { token: "", displayName: "", roles: [], permissions: [] }; + + const saved = localStorage.getItem(AUTH_KEY); + if (saved) { + try { + return { token, ...JSON.parse(saved) }; + } catch { /* ignore */ } + } + + const claims = decodeJwtPayload(token); + if (claims) { + return { + token, + displayName: claims.displayName || claims.sub || "", + roles: claims.roles || [], + permissions: [], + }; + } + + return { token: "", displayName: "", roles: [], permissions: [] }; +} export const useAuthStore = defineStore("auth", { state: () => ({ - token: localStorage.getItem(TOKEN_KEY) || "", - displayName: "", - roles: [], - permissions: [], + ...restoreAuth(), }), getters: { hasAnyRole: (state) => { @@ -27,6 +61,9 @@ export const useAuthStore = defineStore("auth", { this.roles = data.roles || []; this.permissions = data.permissions || []; localStorage.setItem(TOKEN_KEY, this.token); + localStorage.setItem(AUTH_KEY, JSON.stringify({ + displayName: this.displayName, roles: this.roles, permissions: this.permissions + })); axios.defaults.headers.common.Authorization = `Bearer ${this.token}`; }, logout() { @@ -35,7 +72,12 @@ export const useAuthStore = defineStore("auth", { this.roles = []; this.permissions = []; localStorage.removeItem(TOKEN_KEY); + localStorage.removeItem(AUTH_KEY); delete axios.defaults.headers.common.Authorization; + if (window.__idleCleanup) { + window.__idleCleanup() + delete window.__idleCleanup + } }, restoreAxiosAuth() { if (this.token) { diff --git a/web/delivery-platform-ui/src/utils/idleTimer.js b/web/delivery-platform-ui/src/utils/idleTimer.js new file mode 100644 index 0000000..e844037 --- /dev/null +++ b/web/delivery-platform-ui/src/utils/idleTimer.js @@ -0,0 +1,43 @@ +let timerId = null +let onTimeoutCallback = null + +const EVENTS = ['mousedown', 'keydown', 'scroll', 'touchstart', 'click'] + +export function getIdleTimeoutMinutes() { + try { + const stored = localStorage.getItem('systemParams') + if (stored) { + const parsed = JSON.parse(stored) + const minutes = parseInt(parsed.sessionTimeoutMinutes, 10) + return isNaN(minutes) ? 60 : Math.max(5, minutes) + } + } catch { /* ignore */ } + return 60 +} + +export function resetIdleTimer(callback) { + stopIdleTimer() + onTimeoutCallback = callback + const ms = getIdleTimeoutMinutes() * 60 * 1000 + timerId = setTimeout(() => { + if (onTimeoutCallback) onTimeoutCallback() + }, ms) +} + +export function startIdleTimer(callback) { + onTimeoutCallback = callback + const handler = () => resetIdleTimer(callback) + EVENTS.forEach(ev => window.addEventListener(ev, handler)) + resetIdleTimer(callback) + window.__idleCleanup = () => { + EVENTS.forEach(ev => window.removeEventListener(ev, handler)) + stopIdleTimer() + } +} + +export function stopIdleTimer() { + if (timerId) { + clearTimeout(timerId) + timerId = null + } +}