mirror of
https://github.com/hpd840321/craftlabs-authorization-sdk.git
synced 2026-06-09 10:00:30 +08:00
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 <clio-agent@sisyphuslabs.ai>
This commit is contained in:
@@ -8,7 +8,7 @@
|
|||||||
<span class="logo-text">CraftLabs</span>
|
<span class="logo-text">CraftLabs</span>
|
||||||
</div>
|
</div>
|
||||||
<nav class="header-nav">
|
<nav class="header-nav">
|
||||||
<span class="nav-item active">授权平台</span>
|
<span class="nav-item active">交付管理平台</span>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-right">
|
<div class="header-right">
|
||||||
@@ -34,17 +34,26 @@
|
|||||||
<div class="app-body">
|
<div class="app-body">
|
||||||
<!-- SIDEBAR 232px WHITE -->
|
<!-- SIDEBAR 232px WHITE -->
|
||||||
<aside class="app-sidebar">
|
<aside class="app-sidebar">
|
||||||
<div class="sidebar-section-label">业务管理</div>
|
|
||||||
<div
|
<div
|
||||||
v-for="item in menuItems"
|
class="sidebar-item"
|
||||||
:key="item.path"
|
:class="{ active: isActive(homeItem) }"
|
||||||
:class="['sidebar-item', { active: isActive(item) }]"
|
@click="$router.push(homeItem.path)"
|
||||||
@click="$router.push(item.path)"
|
|
||||||
>
|
>
|
||||||
<span class="sidebar-item-icon">{{ item.icon }}</span>
|
<span class="sidebar-item-icon">{{ homeItem.icon }}</span>
|
||||||
<span class="sidebar-item-text">{{ item.label }}</span>
|
<span class="sidebar-item-text">{{ homeItem.label }}</span>
|
||||||
<span v-if="item.badge" class="sidebar-item-badge">{{ item.badge }}</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
<template v-for="group in visibleGroups" :key="group.label">
|
||||||
|
<div class="sidebar-group-label">{{ group.label }}</div>
|
||||||
|
<div
|
||||||
|
v-for="item in group.items"
|
||||||
|
:key="item.path"
|
||||||
|
:class="['sidebar-item', { active: isActive(item) }]"
|
||||||
|
@click="$router.push(item.path)"
|
||||||
|
>
|
||||||
|
<span class="sidebar-item-icon">{{ item.icon }}</span>
|
||||||
|
<span class="sidebar-item-text">{{ item.label }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
<div class="sidebar-footer">CraftLabs Platform v0.1.0</div>
|
<div class="sidebar-footer">CraftLabs Platform v0.1.0</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
@@ -123,23 +132,62 @@ onUnmounted(() => {
|
|||||||
if (warningTimer) clearTimeout(warningTimer)
|
if (warningTimer) clearTimeout(warningTimer)
|
||||||
})
|
})
|
||||||
|
|
||||||
const menuItems = [
|
const homeItem = { path: "/", icon: "📊", label: "首页", roles: ["SYS_ADMIN","SALES","LICENSE_OPS"] };
|
||||||
{ path: "/", icon: "📊", label: "首页", roles: ["SYS_ADMIN","SALES","LICENSE_OPS"] },
|
|
||||||
{ path: "/customers", icon: "👥", label: "客户管理", roles: ["SYS_ADMIN","SALES"] },
|
const menuGroups = [
|
||||||
{ path: "/contracts", icon: "📋", label: "合同管理", roles: ["SYS_ADMIN","SALES"] },
|
{
|
||||||
{ path: "/deliveries", icon: "📦", label: "交付管理", roles: ["SYS_ADMIN","SALES","DELIVERY"] },
|
label: "业务管理",
|
||||||
{ path: "/licenses/sn", icon: "🔑", label: "许可 SN", roles: ["SYS_ADMIN","SALES"] },
|
roles: ["SYS_ADMIN","SALES","DELIVERY"],
|
||||||
{ path: "/licenses", icon: "🛡️", label: "许可证管理", badge: "NEW", roles: ["SYS_ADMIN","SALES"] },
|
items: [
|
||||||
{ path: "/callbacks", icon: "📨", label: "Callback 收件箱", roles: ["SYS_ADMIN","LICENSE_OPS"] },
|
{ path: "/customers", icon: "👥", label: "客户管理", roles: ["SYS_ADMIN","SALES"] },
|
||||||
{ path: "/integration/environments", icon: "🌐", label: "集成环境", roles: ["SYS_ADMIN","SALES","LICENSE_OPS"] },
|
{ path: "/contracts", icon: "📋", label: "合同管理", roles: ["SYS_ADMIN","SALES"] },
|
||||||
{ path: "/integration/product-lines", icon: "📱", label: "产品线", roles: ["SYS_ADMIN","SALES","LICENSE_OPS"] },
|
{ path: "/deliveries", icon: "📦", label: "交付管理", roles: ["SYS_ADMIN","SALES","DELIVERY"] },
|
||||||
{ path: "/devices", icon: "🖥️", label: "设备管理", roles: ["SYS_ADMIN","SALES","DELIVERY"] },
|
{ path: "/licenses/sn", icon: "🔑", label: "许可 SN", roles: ["SYS_ADMIN","SALES"] },
|
||||||
{ path: "/todos", icon: "🔔", label: "待办中心", roles: ["SYS_ADMIN","SALES","LICENSE_OPS"] },
|
{ path: "/licenses", icon: "🛡️", label: "许可证管理", roles: ["SYS_ADMIN","SALES"] },
|
||||||
{ path: "/reports/contract-sn", icon: "📊", label: "报表中心", roles: ["SYS_ADMIN"] },
|
]
|
||||||
{ path: "/reports/subscriptions", icon: "📧", label: "报表订阅", roles: ["SYS_ADMIN"] },
|
},
|
||||||
|
{
|
||||||
|
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) {
|
function isActive(item) {
|
||||||
if (item.path === "/") return route.path === "/";
|
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;
|
background: #fff; border-right: 1px solid #E8ECF1;
|
||||||
display: flex; flex-direction: column; padding-top: 8px;
|
display: flex; flex-direction: column; padding-top: 8px;
|
||||||
}
|
}
|
||||||
.sidebar-section-label {
|
.sidebar-group-label {
|
||||||
padding: 6px 20px; font-size: 11px; color: #C0C4CC; text-transform: uppercase; font-weight: 600; letter-spacing: 0.5px;
|
padding: 12px 20px 4px; font-size: 11px; color: #C0C4CC; text-transform: uppercase; font-weight: 600; letter-spacing: 0.5px;
|
||||||
}
|
}
|
||||||
.sidebar-item {
|
.sidebar-item {
|
||||||
display: flex; align-items: center; gap: 10px;
|
display: flex; align-items: center; gap: 10px;
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import { createRouter, createWebHistory } from "vue-router";
|
import { createRouter, createWebHistory } from "vue-router";
|
||||||
import { useAuthStore } from "../stores/auth";
|
import { useAuthStore } from "../stores/auth";
|
||||||
|
import { startIdleTimer, resetIdleTimer, stopIdleTimer } from "../utils/idleTimer";
|
||||||
|
|
||||||
|
let idleTimerStarted = false
|
||||||
|
|
||||||
const routes = [
|
const routes = [
|
||||||
{ path: "/login", name: "login", component: () => import("../views/LoginView.vue") },
|
{ path: "/login", name: "login", component: () => import("../views/LoginView.vue") },
|
||||||
@@ -218,6 +221,12 @@ const routes = [
|
|||||||
component: () => import("../views/SystemParamsView.vue"),
|
component: () => import("../views/SystemParamsView.vue"),
|
||||||
meta: { roles: ["SYS_ADMIN"], title: "系统参数" },
|
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") },
|
{ path: "/403", name: "forbidden", component: () => import("../views/ForbiddenView.vue") },
|
||||||
@@ -238,9 +247,35 @@ function hasRoleAccess(metaRoles, userRoles) {
|
|||||||
|
|
||||||
router.beforeEach((to) => {
|
router.beforeEach((to) => {
|
||||||
const auth = useAuthStore();
|
const auth = useAuthStore();
|
||||||
|
|
||||||
if (to.meta.requiresAuth && !auth.token) {
|
if (to.meta.requiresAuth && !auth.token) {
|
||||||
|
if (window.__idleCleanup) {
|
||||||
|
window.__idleCleanup()
|
||||||
|
delete window.__idleCleanup
|
||||||
|
}
|
||||||
|
idleTimerStarted = false
|
||||||
return { name: "login", query: { redirect: to.fullPath } };
|
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)) {
|
if (to.meta.requiresAuth && to.meta.roles && !hasRoleAccess(to.meta.roles, auth.roles)) {
|
||||||
return { name: "forbidden" };
|
return { name: "forbidden" };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,13 +2,47 @@ import { defineStore } from "pinia";
|
|||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
|
|
||||||
const TOKEN_KEY = "craftlabs_platform_token";
|
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", {
|
export const useAuthStore = defineStore("auth", {
|
||||||
state: () => ({
|
state: () => ({
|
||||||
token: localStorage.getItem(TOKEN_KEY) || "",
|
...restoreAuth(),
|
||||||
displayName: "",
|
|
||||||
roles: [],
|
|
||||||
permissions: [],
|
|
||||||
}),
|
}),
|
||||||
getters: {
|
getters: {
|
||||||
hasAnyRole: (state) => {
|
hasAnyRole: (state) => {
|
||||||
@@ -27,6 +61,9 @@ export const useAuthStore = defineStore("auth", {
|
|||||||
this.roles = data.roles || [];
|
this.roles = data.roles || [];
|
||||||
this.permissions = data.permissions || [];
|
this.permissions = data.permissions || [];
|
||||||
localStorage.setItem(TOKEN_KEY, this.token);
|
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}`;
|
axios.defaults.headers.common.Authorization = `Bearer ${this.token}`;
|
||||||
},
|
},
|
||||||
logout() {
|
logout() {
|
||||||
@@ -35,7 +72,12 @@ export const useAuthStore = defineStore("auth", {
|
|||||||
this.roles = [];
|
this.roles = [];
|
||||||
this.permissions = [];
|
this.permissions = [];
|
||||||
localStorage.removeItem(TOKEN_KEY);
|
localStorage.removeItem(TOKEN_KEY);
|
||||||
|
localStorage.removeItem(AUTH_KEY);
|
||||||
delete axios.defaults.headers.common.Authorization;
|
delete axios.defaults.headers.common.Authorization;
|
||||||
|
if (window.__idleCleanup) {
|
||||||
|
window.__idleCleanup()
|
||||||
|
delete window.__idleCleanup
|
||||||
|
}
|
||||||
},
|
},
|
||||||
restoreAxiosAuth() {
|
restoreAxiosAuth() {
|
||||||
if (this.token) {
|
if (this.token) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user