优化构架

This commit is contained in:
2026-08-03 18:49:18 +08:00
parent 1241d11c01
commit 44c33a6280
133 changed files with 651 additions and 603 deletions
+27
View File
@@ -0,0 +1,27 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { useAuthStore } from '@/core/auth/auth'
describe('server access store', () => {
beforeEach(() => setActivePinia(createPinia()))
it('uses only the authenticated permission snapshot', () => {
const auth = useAuthStore()
auth.token = 'token'
auth.permissions = ['platform:warehouse:list']
const access = useAccessPreviewStore()
access.initializeAfterLogin()
expect(access.can('platform:warehouse:list')).toBe(true)
expect(access.can('platform:user:list')).toBe(false)
expect(access.firstAccessiblePath).toBe('/warehouses')
})
it('exposes no permissions when the server snapshot is empty', () => {
const access = useAccessPreviewStore()
expect(access.permissionCodes.size).toBe(0)
expect(access.filteredNavigation).toHaveLength(0)
expect(access.firstAccessiblePath).toBeNull()
})
})
+40
View File
@@ -0,0 +1,40 @@
import { computed, ref } from 'vue'
import { defineStore } from 'pinia'
import {
adminNavigation,
filterAdminNavigation,
getFirstNavigationLeaf,
} from '@/core/navigation/adminNavigation'
import { useAuthStore } from '@/core/auth/auth'
export const useAccessPreviewStore = defineStore('access-preview', () => {
const auth = useAuthStore()
const initialized = ref(false)
const permissionCodes = computed<ReadonlySet<string>>(() => auth.permissionCodes)
const permissionSignature = computed(() => [...permissionCodes.value].sort().join('|'))
const filteredNavigation = computed(() => filterAdminNavigation(adminNavigation, permissionCodes.value))
const firstAccessiblePath = computed(() => getFirstNavigationLeaf(filteredNavigation.value)?.path ?? null)
function initializeAfterLogin() {
initialized.value = auth.isAuthenticated
}
function can(permissionCode: string): boolean {
return permissionCodes.value.has(permissionCode)
}
function reset() {
initialized.value = false
}
return {
initialized,
permissionCodes,
permissionSignature,
filteredNavigation,
firstAccessiblePath,
initializeAfterLogin,
can,
reset,
}
})
+154
View File
@@ -0,0 +1,154 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
post: vi.fn(),
listCurrentPlatformPermissionCodes: vi.fn(),
refreshHandler: null as null | (() => Promise<string>),
}))
vi.mock('@/shared/api/http', () => ({
http: { post: mocks.post },
registerAuthRefreshHandler: (handler: () => Promise<string>) => { mocks.refreshHandler = handler },
unwrapApiResponse: <T>(response: { code: string; message: string; data: T }) => {
if (response.code !== '200') throw new Error(response.message)
return response.data
},
getApiErrorDetails: (error: { code?: string; message?: string }) => ({
message: error.message || '接口请求失败',
code: error.code,
}),
}))
vi.mock('./platformAuthorization', () => ({
listCurrentPlatformPermissionCodes: mocks.listCurrentPlatformPermissionCodes,
}))
import { useAuthStore } from './auth'
function createMemoryStorage(): Storage {
const values = new Map<string, string>()
return {
get length() { return values.size },
clear: () => values.clear(),
getItem: (key) => values.get(key) ?? null,
key: (index) => [...values.keys()][index] ?? null,
removeItem: (key) => values.delete(key),
setItem: (key, value) => values.set(key, value),
}
}
function token(overrides: Record<string, unknown> = {}) {
const claims = {
user_id: '1000001',
username: 'platform_admin',
app_code: 'PLATFORM',
roles: ['PLATFORM_ADMIN'],
permissions: ['platform:warehouse:list', 'platform:order:list'],
exp: Math.floor(Date.now() / 1000) + 1800,
...overrides,
}
const payload = Buffer.from(JSON.stringify(claims)).toString('base64url')
return `header.${payload}.signature`
}
function loginResponse(accessToken = token(), refreshToken = 'refresh-next') {
return {
code: '200',
message: 'success',
data: {
tokenType: 'Bearer',
accessToken,
expiresIn: 1800,
refreshToken,
refreshExpiresIn: 604800,
},
}
}
describe('admin auth session', () => {
beforeEach(() => {
vi.stubGlobal('localStorage', createMemoryStorage())
setActivePinia(createPinia())
mocks.post.mockReset()
mocks.listCurrentPlatformPermissionCodes.mockReset()
mocks.refreshHandler = null
})
it('intersects JWT permissions with the current authenticated permission snapshot', async () => {
mocks.post.mockResolvedValue(loginResponse())
mocks.listCurrentPlatformPermissionCodes.mockResolvedValue(['platform:warehouse:list'])
const auth = useAuthStore()
await auth.login('warehouse_operator', 'password')
expect(auth.isAuthenticated).toBe(true)
expect([...auth.permissionCodes]).toEqual(['platform:warehouse:list'])
expect(auth.permissionResources).toEqual([])
expect(localStorage.getItem('cc-admin-permission-codes'))
.toBe('["platform:warehouse:list"]')
})
it('rejects login and clears the session when the current permission snapshot is unavailable', async () => {
mocks.post.mockResolvedValue(loginResponse())
mocks.listCurrentPlatformPermissionCodes.mockRejectedValue({ code: 'S0503', message: 'IAM 服务不可用' })
const auth = useAuthStore()
await expect(auth.login('platform_admin', 'password')).rejects.toMatchObject({ code: 'S0503' })
expect(auth.isAuthenticated).toBe(false)
expect(localStorage.getItem('cc-admin-token')).toBeNull()
expect(localStorage.getItem('cc-admin-refresh-token')).toBeNull()
})
it('revokes an issued token pair when the permission snapshot prevents login completion', async () => {
mocks.post
.mockResolvedValueOnce(loginResponse())
.mockResolvedValueOnce({ code: '200', message: 'success', data: null })
mocks.listCurrentPlatformPermissionCodes.mockRejectedValue({ code: 'S0503', message: 'IAM 服务不可用' })
const auth = useAuthStore()
await expect(auth.login('platform_admin', 'password')).rejects.toMatchObject({ code: 'S0503' })
await Promise.resolve()
expect(mocks.post).toHaveBeenNthCalledWith(2, '/auth/logout', { refreshToken: 'refresh-next' }, {
headers: { Authorization: expect.stringContaining('Bearer header.') },
})
})
it('restores an expired access session with the persisted refresh token', async () => {
localStorage.setItem('cc-admin-token', token({ exp: Math.floor(Date.now() / 1000) - 30 }))
localStorage.setItem('cc-admin-refresh-token', 'refresh-old')
mocks.post.mockResolvedValue(loginResponse(token(), 'refresh-rotated'))
mocks.listCurrentPlatformPermissionCodes.mockResolvedValue(['platform:warehouse:list'])
const auth = useAuthStore()
await expect(auth.restoreSession()).resolves.toBe(true)
expect(mocks.post).toHaveBeenCalledWith('/auth/refresh', { refreshToken: 'refresh-old' })
expect(auth.refreshToken).toBe('refresh-rotated')
expect(auth.isAuthenticated).toBe(true)
})
it('does not let an in-flight refresh restore a session after logout', async () => {
localStorage.setItem('cc-admin-token', token())
localStorage.setItem('cc-admin-refresh-token', 'refresh-old')
let resolveRefresh: ((value: ReturnType<typeof loginResponse>) => void) | undefined
mocks.post.mockImplementation((url: string) => {
if (url === '/auth/refresh') {
return new Promise((resolve) => { resolveRefresh = resolve })
}
return Promise.resolve({ code: '200', message: 'success', data: null })
})
const auth = useAuthStore()
const refreshing = auth.refreshSession()
await Promise.resolve()
await auth.logout()
resolveRefresh?.(loginResponse(token(), 'refresh-rotated'))
await expect(refreshing).rejects.toThrow('登录会话已被后续操作替代')
expect(auth.isAuthenticated).toBe(false)
expect(auth.refreshToken).toBe('')
expect(localStorage.getItem('cc-admin-token')).toBeNull()
})
})
+204
View File
@@ -0,0 +1,204 @@
import { computed, ref } from 'vue'
import { defineStore } from 'pinia'
import type { CurrentUser } from '@chongchong/shared'
import {
http,
registerAuthRefreshHandler,
unwrapApiResponse,
type ApiEnvelope,
} from '@/shared/api/http'
import type { PlatformPermissionResource } from '@/shared/types/platformPermission'
import { decodeJwtClaims } from './jwt'
import { listCurrentPlatformPermissionCodes } from './platformAuthorization'
const TOKEN_KEY = 'cc-admin-token'
const REFRESH_TOKEN_KEY = 'cc-admin-refresh-token'
const PERMISSION_CODES_KEY = 'cc-admin-permission-codes'
interface LoginResponse {
tokenType: string
accessToken: string
expiresIn: number
refreshToken: string
refreshExpiresIn: number
}
class ExpiredAccessTokenError extends Error {}
class SupersededSessionError extends Error {}
function sessionFromToken(token: string) {
const claims = decodeJwtClaims(token)
if (claims.app_code !== 'PLATFORM') throw new Error('账号不属于当前应用端')
if (claims.exp && claims.exp * 1000 <= Date.now()) throw new ExpiredAccessTokenError('登录状态已过期')
return {
user: {
id: String(claims.user_id ?? ''),
name: claims.username || '平台用户',
account: claims.username || '',
roles: claims.roles ?? [],
} satisfies CurrentUser,
permissions: claims.permissions ?? [],
}
}
export const useAuthStore = defineStore('admin-auth', () => {
const token = ref(localStorage.getItem(TOKEN_KEY) || '')
const refreshToken = ref(localStorage.getItem(REFRESH_TOKEN_KEY) || '')
const user = ref<CurrentUser | null>(null)
const permissions = ref<string[]>([])
const permissionResources = ref<PlatformPermissionResource[]>([])
const isServerSession = ref(false)
let sessionGeneration = 0
if (token.value) {
try {
const session = sessionFromToken(token.value)
const cachedPermissions = JSON.parse(localStorage.getItem(PERMISSION_CODES_KEY) || '[]') as unknown
if (!Array.isArray(cachedPermissions)) throw new Error('权限缓存格式无效')
user.value = session.user
permissions.value = session.permissions.filter((code) => cachedPermissions.includes(code))
isServerSession.value = true
} catch (error) {
token.value = ''
localStorage.removeItem(TOKEN_KEY)
localStorage.removeItem(PERMISSION_CODES_KEY)
if (!(error instanceof ExpiredAccessTokenError) || !refreshToken.value) {
refreshToken.value = ''
localStorage.removeItem(REFRESH_TOKEN_KEY)
}
}
}
const isAuthenticated = computed(() => Boolean(token.value))
const permissionCodes = computed<ReadonlySet<string>>(() => new Set(permissions.value))
function ensureCurrentGeneration(expectedGeneration: number) {
if (expectedGeneration !== sessionGeneration) {
throw new SupersededSessionError('登录会话已被后续操作替代')
}
}
function applyPermissionCodes(codes: string[]) {
permissions.value = codes
localStorage.setItem(PERMISSION_CODES_KEY, JSON.stringify(codes))
}
async function applySession(result: LoginResponse, expectedGeneration: number) {
const session = sessionFromToken(result.accessToken)
ensureCurrentGeneration(expectedGeneration)
token.value = result.accessToken
refreshToken.value = result.refreshToken
user.value = session.user
isServerSession.value = true
localStorage.setItem(TOKEN_KEY, token.value)
localStorage.setItem(REFRESH_TOKEN_KEY, refreshToken.value)
try {
const activePermissionCodes = await listCurrentPlatformPermissionCodes()
ensureCurrentGeneration(expectedGeneration)
permissionResources.value = []
const activePermissionCodeSet = new Set(activePermissionCodes)
applyPermissionCodes(session.permissions.filter((code) => activePermissionCodeSet.has(code)))
} catch (error) {
ensureCurrentGeneration(expectedGeneration)
throw error
}
}
function clearSession() {
sessionGeneration += 1
token.value = ''
refreshToken.value = ''
user.value = null
permissions.value = []
permissionResources.value = []
isServerSession.value = false
localStorage.removeItem(TOKEN_KEY)
localStorage.removeItem(REFRESH_TOKEN_KEY)
localStorage.removeItem(PERMISSION_CODES_KEY)
}
async function revokeTokenPair(accessToken: string, refreshTokenToRevoke: string) {
await http.post('/auth/logout', { refreshToken: refreshTokenToRevoke }, {
headers: { Authorization: `Bearer ${accessToken}` },
})
}
async function login(account: string, password: string) {
const expectedGeneration = ++sessionGeneration
try {
const response = await http.post<ApiEnvelope<LoginResponse>, ApiEnvelope<LoginResponse>>(
'/auth/login',
{ username: account, password },
)
const result = unwrapApiResponse<LoginResponse>(response)
await applySession(result, expectedGeneration)
} catch (error) {
if (expectedGeneration === sessionGeneration) {
const issuedAccessToken = token.value
const issuedRefreshToken = refreshToken.value
clearSession()
if (issuedAccessToken && issuedRefreshToken) {
void revokeTokenPair(issuedAccessToken, issuedRefreshToken).catch(() => undefined)
}
}
throw error
}
}
async function refreshSession() {
if (!refreshToken.value) throw new Error('刷新令牌不存在,请重新登录')
const expectedGeneration = sessionGeneration
try {
const response = await http.post<ApiEnvelope<LoginResponse>, ApiEnvelope<LoginResponse>>(
'/auth/refresh',
{ refreshToken: refreshToken.value },
)
const result = unwrapApiResponse<LoginResponse>(response)
await applySession(result, expectedGeneration)
} catch (error) {
if (expectedGeneration === sessionGeneration) clearSession()
throw error
}
}
async function restoreSession() {
if (isAuthenticated.value) return true
if (!refreshToken.value) return false
try {
await refreshSession()
return true
} catch {
return false
}
}
registerAuthRefreshHandler(async () => {
await refreshSession()
if (!token.value) throw new Error('登录状态刷新失败')
return token.value
})
async function logout() {
const accessToken = token.value
const tokenToRevoke = refreshToken.value
clearSession()
if (accessToken && tokenToRevoke) {
await revokeTokenPair(accessToken, tokenToRevoke)
}
}
return {
token,
refreshToken,
user,
permissions,
permissionResources,
isServerSession,
permissionCodes,
isAuthenticated,
login,
refreshSession,
restoreSession,
logout,
}
})
+26
View File
@@ -0,0 +1,26 @@
export interface PlatformJwtClaims {
user_id?: string | number
username?: string
app_code?: string
roles?: string[]
permissions?: string[]
tenant_id?: string | number
membership_id?: string | number
exp?: number
}
function decodeBase64Url(value: string): string {
const normalized = value.replace(/-/g, '+').replace(/_/g, '/')
const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, '=')
return atob(padded)
}
export function decodeJwtClaims(token: string): PlatformJwtClaims {
const payload = token.split('.')[1]
if (!payload) throw new Error('登录响应中的 accessToken 格式无效')
try {
return JSON.parse(decodeBase64Url(payload)) as PlatformJwtClaims
} catch {
throw new Error('登录响应中的 accessToken 无法解析')
}
}
@@ -0,0 +1,19 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { http } from '@/shared/api/http'
import { listCurrentPlatformPermissionCodes } from './platformAuthorization'
vi.mock('@/shared/api/http', () => ({
http: { get: vi.fn() },
unwrapApiResponse: <T>(response: { data: T }) => response.data,
}))
describe('platformAuthorization', () => {
beforeEach(() => vi.clearAllMocks())
it('loads the current user permission snapshot', async () => {
vi.mocked(http.get).mockResolvedValue({ code: '200', message: 'success', data: ['platform:warehouse:list'] })
await expect(listCurrentPlatformPermissionCodes()).resolves.toEqual(['platform:warehouse:list'])
expect(http.get).toHaveBeenCalledWith('/platform/v1/iam/authorization/permissions')
})
})
+8
View File
@@ -0,0 +1,8 @@
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
export async function listCurrentPlatformPermissionCodes() {
const response = await http.get<ApiEnvelope<string[]>, ApiEnvelope<string[]>>(
'/platform/v1/iam/authorization/permissions',
)
return unwrapApiResponse(response)
}
+48
View File
@@ -0,0 +1,48 @@
import { readdirSync, readFileSync } from 'node:fs'
import { relative, resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
const sourceRoot = resolve(process.cwd(), 'src')
function sourceFiles(directory: string): string[] {
return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
const path = resolve(directory, entry.name)
if (entry.isDirectory()) return sourceFiles(path)
return /\.(ts|vue)$/.test(entry.name) ? [path] : []
})
}
function aliasImports(path: string): string[] {
const source = readFileSync(path, 'utf8')
return [...source.matchAll(/['"](@\/[^'"]+)['"]/g)].map((match) => match[1])
}
describe('source dependency boundaries', () => {
it('keeps app, core, modules and shared dependencies pointing inward', () => {
const violations: string[] = []
for (const path of sourceFiles(sourceRoot)) {
const sourcePath = relative(sourceRoot, path).replaceAll('\\', '/')
const sourceModule = sourcePath.match(/^modules\/([^/]+)\//)?.[1]
for (const dependency of aliasImports(path)) {
if (sourcePath.startsWith('shared/') && /^@\/(app|core|modules|prototype)\//.test(dependency)) {
violations.push(`${sourcePath} -> ${dependency}`)
}
if (sourcePath.startsWith('core/') && /^@\/(app|modules|prototype)\//.test(dependency)) {
violations.push(`${sourcePath} -> ${dependency}`)
}
if (sourceModule && /^@\/(app|prototype)\//.test(dependency)) {
violations.push(`${sourcePath} -> ${dependency}`)
}
const targetModule = dependency.match(/^@\/modules\/([^/]+)(\/.*)?$/)
if (sourceModule && targetModule && targetModule[1] !== sourceModule && targetModule[2]) {
violations.push(`${sourcePath} -> ${dependency}`)
}
}
}
expect(violations).toEqual([])
})
})
+114
View File
@@ -0,0 +1,114 @@
import { describe, expect, it } from 'vitest'
import {
adminNavigation,
filterAdminNavigation,
getFirstNavigationLeaf,
getNavigationLeaves,
type AdminNavigationNode,
} from './adminNavigation'
describe('adminNavigation', () => {
it('keeps only implemented phase-one menus visible while retaining prototype nodes as comments', () => {
expect(adminNavigation.map((item) => item.title)).toEqual([
'客户管理',
'国内仓',
'头程订单',
'通用',
'系统管理',
])
expect(adminNavigation[0].children?.map((item) => item.title)).toEqual([
'客户列表',
'企业审核',
])
expect(adminNavigation.find((item) => item.key === 'general')?.children?.map((item) => item.title)).toContain('快递公司')
expect(adminNavigation.find((item) => item.key === 'general')?.children?.map((item) => item.title)).toContain('下载中心')
expect(adminNavigation.find((item) => item.key === 'system')?.children?.map((item) => item.title)).toEqual([
'用户管理',
'角色权限',
'菜单权限',
])
})
it('assigns every page a unique path and keeps real permissions unique', () => {
const leaves = getNavigationLeaves(adminNavigation)
const paths = leaves.map((item) => item.path)
const permissions = leaves.flatMap((item) => item.permission ? [item.permission] : [])
expect(paths).not.toContain('/dashboard')
expect(paths).not.toContain('/prototype/finance/profit-report')
expect(paths).not.toContain('/prototype/system-management/brand-settings')
expect(paths).toContain('/system/permissions')
expect(new Set(paths).size).toBe(paths.length)
expect(new Set(permissions).size).toBe(permissions.length)
expect(leaves.every((item) => Boolean(item.icon))).toBe(true)
})
it('filters protected pages without mutating the source tree', () => {
const sourceSnapshot = JSON.stringify(adminNavigation, (key, value) => key === 'icon' ? '[icon]' : value)
const filtered = filterAdminNavigation(adminNavigation, new Set(['platform:permission:list']))
const paths = getNavigationLeaves(filtered).map((item) => item.path)
expect(paths).toContain('/system/permissions')
expect(paths).not.toContain('/prototype/finance/profit-report')
expect(paths).not.toContain('/customers')
expect(filtered).not.toBe(adminNavigation)
expect(filtered.find((item) => item.key === 'system')).not.toBe(adminNavigation.find((item) => item.key === 'system'))
expect(JSON.stringify(adminNavigation, (key, value) => key === 'icon' ? '[icon]' : value)).toBe(sourceSnapshot)
})
it('supports manually hiding a module without removing its route definition', () => {
const filtered = filterAdminNavigation([{ key: 'hidden', title: '隐藏模块', icon: adminNavigation[0].icon, path: '/hidden', hidden: true }], new Set())
expect(filtered).toEqual([])
})
it('keeps directories based on accessible descendants instead of a directory permission', () => {
const nodes: AdminNavigationNode[] = [{
key: 'operations',
title: '运营管理',
icon: adminNavigation[0].icon,
permission: 'directory:denied',
children: [{
key: 'tracking',
title: '轨迹管理',
icon: adminNavigation[0].icon,
path: '/tracking',
permission: 'tracking:list',
}],
}]
const filtered = filterAdminNavigation(nodes, new Set(['tracking:list']))
expect(filtered).toHaveLength(1)
expect(filtered[0].children?.[0].path).toBe('/tracking')
})
it('selects the first fallback leaf in strict depth-first configuration order', () => {
const nodes: AdminNavigationNode[] = [{
key: 'level-one',
title: '一级目录',
icon: adminNavigation[0].icon,
children: [{
key: 'level-two',
title: '二级目录',
icon: adminNavigation[0].icon,
children: [{
key: 'tracking',
title: '轨迹管理',
icon: adminNavigation[0].icon,
path: '/tracking',
permission: 'tracking:list',
}],
}, {
key: 'reports',
title: '报表管理',
icon: adminNavigation[0].icon,
path: '/reports',
permission: 'reports:list',
}],
}]
expect(getNavigationLeaves(nodes).map((item) => item.path)).toEqual(['/tracking', '/reports'])
expect(getFirstNavigationLeaf(nodes)?.path).toBe('/tracking')
})
})
+268
View File
@@ -0,0 +1,268 @@
import type { Component } from 'vue'
import {
Building2,
ContactRound,
ClipboardList,
Database,
Download,
FileCheck2,
KeyRound,
Settings,
ShieldCheck,
Truck,
Users,
Warehouse as WarehouseIcon,
} from '@lucide/vue'
export interface AdminNavigationNode {
key: string
title: string
icon: Component
iconColor?: string
path?: string
permission?: string
/** Set to true to remove a prototype module from the sidebar without deleting its route. */
hidden?: boolean
children?: AdminNavigationNode[]
}
export interface AdminNavigationLeaf extends AdminNavigationNode {
path: string
permission?: string
children?: never
}
export const adminNavigation: AdminNavigationNode[] = [
// 原型菜单暂未接入一期范围;恢复展示时移除对应注释即可。
// { key: 'dashboard', title: '首页数据看板', icon: ClipboardList, path: '/dashboard' },
{
key: 'customers',
title: '客户管理',
icon: Building2,
iconColor: '#52c41a',
children: [
{ key: 'customers-list', title: '客户列表', icon: Building2, path: '/customers', permission: 'platform:customer:list' },
{ key: 'enterprise-reviews', title: '企业审核', icon: FileCheck2, path: '/customers/reviews', permission: 'platform:customer:review' },
// { key: 'warehouse-tier-fee', title: '海外仓阶梯费用', icon: Building2, path: '/prototype/customer-management/warehouse-tier-fee' },
],
},
{
key: 'domestic-warehouse',
title: '国内仓',
icon: WarehouseIcon,
iconColor: '#52c41a',
children: [
{ key: 'warehouses', title: '仓库管理', icon: WarehouseIcon, path: '/warehouses', permission: 'platform:warehouse:list' },
// { key: 'inbound-list', title: '入库单列表', icon: ClipboardList, path: '/prototype/domestic-warehouse/inbound-list' },
// { key: 'inbound-dedup', title: '入库单测量', icon: ClipboardList, path: '/prototype/domestic-warehouse/inbound-dedup' },
// { key: 'inbound-measure', title: '测量数据审核', icon: FileCheck2, path: '/prototype/domestic-warehouse/inbound-measure' },
// { key: 'inbound-receive', title: '接收审核', icon: FileCheck2, path: '/prototype/domestic-warehouse/inbound-receive' },
// { key: 'overseas-location', title: '库位管理', icon: WarehouseIcon, path: '/prototype/domestic-warehouse/location-management' },
// { key: 'pallet-management', title: '托盘管理', icon: WarehouseIcon, path: '/prototype/domestic-warehouse/pallet-management' },
],
},
{
key: 'first-leg-orders',
title: '头程订单',
icon: ClipboardList,
iconColor: '#1890ff',
path: '/orders/first-leg',
permission: 'platform:order:list',
},
/* {
key: 'overseas-delivery',
title: '海外派送',
icon: Truck,
children: [
{ key: 'outbound-dispatch', title: '派送单列表', icon: Truck, path: '/prototype/overseas-delivery/dispatch-list' },
{ key: 'outbound-vehicle', title: '排车列表', icon: Truck, path: '/prototype/overseas-delivery/vehicle-scheduling' },
{ key: 'outbound-bol', title: 'BOL', icon: FileCheck2, path: '/prototype/overseas-delivery/bol' },
{ key: 'outbound-selfpick', title: '自提单', icon: ClipboardList, path: '/prototype/overseas-delivery/self-pickup' },
],
},
{
key: 'overseas-inventory',
title: '海外仓库存',
icon: WarehouseIcon,
children: [
{ key: 'overseas-stock', title: '库存列表', icon: WarehouseIcon, path: '/prototype/overseas-inventory/stock-list' },
{ key: 'overseas-inbound', title: '海外入库单', icon: ClipboardList, path: '/prototype/overseas-inventory/inbound-orders' },
{ key: 'overseas-outbound', title: '出库单', icon: ClipboardList, path: '/prototype/overseas-inventory/outbound-orders' },
{ key: 'overseas-relabel', title: '换标订单', icon: ClipboardList, path: '/prototype/overseas-inventory/relabel-orders' },
],
},
{
key: 'service-center',
title: '服务中心',
icon: ClipboardList,
children: [
{ key: 'booking-list', title: '订舱列表', icon: ClipboardList, path: '/prototype/service-center/booking-list' },
{ key: 'truck-list', title: '拖车列表', icon: Truck, path: '/prototype/service-center/truck-list' },
],
},
{
key: 'supplier-center',
title: '供应商中心',
icon: ContactRound,
children: [
{ key: 'supplier-list', title: '供应商列表', icon: ContactRound, path: '/prototype/supplier-center/supplier-list' },
],
},
{
key: 'container-management',
title: '货柜管理',
icon: WarehouseIcon,
children: [
{ key: 'container-list', title: '货柜列表', icon: WarehouseIcon, path: '/prototype/container-management/container-list' },
{ key: 'container-manifest', title: '装柜清单', icon: ClipboardList, path: '/prototype/container-management/manifest' },
{ key: 'container-preload', title: '预装清单', icon: ClipboardList, path: '/prototype/container-management/preload-list' },
],
},
{
key: 'overseas-business',
title: '海外业务',
icon: Building2,
children: [
{ key: 'overseas-users', title: '用户管理', icon: Users, path: '/prototype/overseas-business/users' },
{ key: 'overseas-purchase', title: '采购订单', icon: ClipboardList, path: '/prototype/overseas-business/purchase-orders' },
{ key: 'overseas-truck', title: '本土卡车', icon: Truck, path: '/prototype/overseas-business/local-truck' },
{ key: 'overseas-looseboard', title: '本土散板', icon: ClipboardList, path: '/prototype/overseas-business/local-looseboard' },
],
},
{
key: 'overseas-warehouse-orders',
title: '海外仓下单',
icon: ClipboardList,
children: [
{ key: 'overseas-order-inbound', title: '退货入库', icon: ClipboardList, path: '/prototype/overseas-warehouse-orders/return-inbound' },
{ key: 'overseas-order-relabel', title: '退货换标', icon: ClipboardList, path: '/prototype/overseas-warehouse-orders/return-relabel' },
{ key: 'overseas-order-outbound', title: '退货出库', icon: ClipboardList, path: '/prototype/overseas-warehouse-orders/return-outbound' },
],
},
{
key: 'fba-returns',
title: 'FBA退货',
icon: FileCheck2,
children: [
{ key: 'fba-inbound-order', title: '入库单', icon: ClipboardList, path: '/prototype/fba-returns/inbound-orders' },
{ key: 'fba-relabel-order', title: '换标单', icon: ClipboardList, path: '/prototype/fba-returns/relabel-orders' },
{ key: 'fba-outbound-order', title: '出库单', icon: ClipboardList, path: '/prototype/fba-returns/outbound-orders' },
{ key: 'fba-pallet', title: '托盘管理', icon: WarehouseIcon, path: '/prototype/fba-returns/pallet-management' },
{ key: 'fba-carton', title: '箱号管理', icon: ClipboardList, path: '/prototype/fba-returns/carton-management' },
{ key: 'fba-product', title: '产品库', icon: Database, path: '/prototype/fba-returns/product-catalog' },
{ key: 'fba-return-stock', title: '退货库存', icon: WarehouseIcon, path: '/prototype/fba-returns/return-stock' },
],
},
{
key: 'finance',
title: '财务管理',
icon: Database,
children: [
{ key: 'customer-arap', title: '客户应收应付', icon: Database, path: '/prototype/finance/customer-arap' },
{ key: 'customer-recon-stmt', title: '客户对账单', icon: FileCheck2, path: '/prototype/finance/customer-reconciliation' },
{ key: 'settlement-ar', title: '结算单应收', icon: FileCheck2, path: '/prototype/finance/settlement-ar' },
{ key: 'settlement-ap', title: '结算单应付', icon: FileCheck2, path: '/prototype/finance/settlement-ap' },
{ key: 'finance-cost', title: '费用分析', icon: Database, path: '/prototype/finance/cost-analysis' },
{ key: 'profit', title: '利润表', icon: Database, path: '/prototype/finance/profit-report' },
],
},
{
key: 'vehicle-management',
title: '车辆管理',
icon: Truck,
children: [
{ key: 'vehicle-list', title: '车辆列表', icon: Truck, path: '/prototype/vehicle-management/vehicle-list' },
],
},
{
key: 'reports',
title: '报表中心',
icon: Database,
children: [
{ key: 'report-overview', title: '报表总览', icon: Database, path: '/prototype/reports/overview' },
{ key: 'report-income', title: '收支报表', icon: Database, path: '/prototype/reports/income-expense' },
{ key: 'report-ar-aging', title: '应收账龄分析', icon: Database, path: '/prototype/reports/ar-aging' },
{ key: 'report-cost', title: '费用构成分析', icon: Database, path: '/prototype/reports/cost-composition' },
{ key: 'report-freight', title: '运费报表', icon: Database, path: '/prototype/reports/freight' },
{ key: 'report-business', title: '业务统计报表', icon: Database, path: '/prototype/reports/business-statistics' },
],
},
*/
{
key: 'general',
title: '通用',
icon: Database,
iconColor: '#722ed1',
children: [
// { key: 'general-product', title: '产品信息', icon: Database, path: '/prototype/general/product-information' },
// { key: 'general-fee', title: '费用项目', icon: Database, path: '/prototype/general/fee-items' },
// { key: 'general-fba', title: 'FBA编码地址', icon: Database, path: '/prototype/general/fba-address' },
// { key: 'general-pallet', title: '托盘管理', icon: WarehouseIcon, path: '/prototype/general/pallet-management' },
// { key: 'general-location', title: '库位/货架管理', icon: WarehouseIcon, path: '/prototype/general/location-management' },
{ key: 'express-companies', title: '快递公司', icon: Truck, path: '/master-data/express-companies', permission: 'platform:express:list' },
{ key: 'counterparties', title: '往来单位', icon: ContactRound, path: '/master-data/counterparties', permission: 'platform:counterparty:list' },
{ key: 'download-center', title: '下载中心', icon: Download, path: '/downloads', permission: 'platform:download:list' },
// { key: 'general-voyage', title: '船期价格表', icon: Database, path: '/prototype/general/voyage-price-list' },
],
},
/* {
key: 'sales',
title: '销售管理',
icon: Users,
children: [
{ key: 'sales-visits', title: '拜访记录', icon: Users, path: '/prototype/sales/visit-records' },
{ key: 'sales-commission', title: '销售提成', icon: Database, path: '/prototype/sales/commission' },
],
},
{
key: 'messages',
title: '消息中心',
icon: ClipboardList,
children: [
{ key: 'message-management', title: '消息管理', icon: ClipboardList, path: '/prototype/messages/management' },
{ key: 'message-template', title: '模板管理', icon: ClipboardList, path: '/prototype/messages/templates' },
{ key: 'my-messages', title: '我的消息', icon: ClipboardList, path: '/prototype/messages/my-messages' },
],
},
*/
{
key: 'system',
title: '系统管理',
icon: Settings,
iconColor: '#8c8c8c',
children: [
{ key: 'internal-users', title: '用户管理', icon: Users, path: '/system/users', permission: 'platform:user:list' },
{ key: 'roles', title: '角色权限', icon: ShieldCheck, path: '/system/roles', permission: 'platform:role:list' },
{ key: 'permissions', title: '菜单权限', icon: KeyRound, path: '/system/permissions', permission: 'platform:permission:list' },
// { key: 'tenant-management', title: '租户管理', icon: Building2, path: '/prototype/system-management/tenant-management' },
// { key: 'brand-settings', title: '品牌设置', icon: Settings, path: '/prototype/system-management/brand-settings' },
],
},
]
export function getNavigationLeaves(nodes: AdminNavigationNode[]): AdminNavigationLeaf[] {
return nodes.flatMap((node) => {
if (node.children?.length) return getNavigationLeaves(node.children)
if (!node.path) return []
return [node as AdminNavigationLeaf]
})
}
export function filterAdminNavigation(
nodes: AdminNavigationNode[],
permissionCodes: ReadonlySet<string>,
): AdminNavigationNode[] {
return nodes.flatMap((node) => {
if (node.hidden) return []
if (node.children?.length) {
const children = filterAdminNavigation(node.children, permissionCodes)
return children.length ? [{ ...node, children }] : []
}
if (!node.path || (node.permission && !permissionCodes.has(node.permission))) return []
return [{ ...node }]
})
}
export function getFirstNavigationLeaf(nodes: AdminNavigationNode[]): AdminNavigationLeaf | null {
return getNavigationLeaves(nodes)[0] ?? null
}
+109
View File
@@ -0,0 +1,109 @@
import { describe, expect, it } from 'vitest'
import {
decideRouteAccess,
resolvePreviewTarget,
sanitizeInternalPath,
} from './accessDecision'
describe('sanitizeInternalPath', () => {
it.each([
[undefined, '/'],
[null, '/'],
[42, '/'],
['', '/'],
['customers', '/'],
['//example.com', '/'],
['/\\example.com', '/'],
['/\n/example.com', '/'],
['/\r/example.com', '/'],
['/\t/example.com', '/'],
['/\u0000/example.com', '/'],
['/\u007f/example.com', '/'],
['https://example.com', '/'],
])('sanitizes %s to %s', (value, expected) => {
expect(sanitizeInternalPath(value)).toBe(expected)
})
it('accepts an internal path with its query string', () => {
expect(sanitizeInternalPath('/customers?status=pending')).toBe('/customers?status=pending')
})
})
describe('decideRouteAccess', () => {
it('checks authentication before route permission', () => {
expect(decideRouteAccess({
requiresAuth: true,
isAuthenticated: false,
fullPath: '/system/roles?tab=enabled',
permission: 'system:role:list',
hasPermission: false,
})).toEqual({ type: 'login', redirect: '/system/roles?tab=enabled' })
})
it('sanitizes an unsafe login redirect', () => {
expect(decideRouteAccess({
requiresAuth: true,
isAuthenticated: false,
fullPath: '//example.com',
permission: 'system:role:list',
hasPermission: false,
})).toEqual({ type: 'login', redirect: '/' })
})
it('sends an authenticated user without permission to forbidden and preserves the internal source', () => {
expect(decideRouteAccess({
requiresAuth: true,
isAuthenticated: true,
fullPath: '/customers?status=pending',
permission: 'customer:list',
hasPermission: false,
})).toEqual({ type: 'forbidden', from: '/customers?status=pending' })
})
it('allows the authenticated forbidden route because it has no permission metadata', () => {
expect(decideRouteAccess({
requiresAuth: true,
isAuthenticated: true,
fullPath: '/403?from=%2Fcustomers',
hasPermission: false,
})).toEqual({ type: 'allow' })
})
})
describe('resolvePreviewTarget', () => {
it('recovers from forbidden to the first accessible route', () => {
expect(resolvePreviewTarget({
currentPath: '/403',
currentPermission: undefined,
candidateCodes: new Set(['customer:list']),
firstPath: '/customers',
})).toBe('/customers')
})
it('stays on forbidden when the candidate has no accessible route', () => {
expect(resolvePreviewTarget({
currentPath: '/403',
currentPermission: undefined,
candidateCodes: new Set<string>(),
firstPath: null,
})).toBe('/403')
})
it('falls back when the current route permission is lost', () => {
expect(resolvePreviewTarget({
currentPath: '/system/roles',
currentPermission: 'system:role:list',
candidateCodes: new Set(['customer:list']),
firstPath: '/customers',
})).toBe('/customers')
})
it('keeps the current route when its permission remains available', () => {
expect(resolvePreviewTarget({
currentPath: '/customers',
currentPermission: 'customer:list',
candidateCodes: new Set(['customer:list']),
firstPath: '/customers',
})).toBeNull()
})
})
+48
View File
@@ -0,0 +1,48 @@
export type RouteAccessDecision =
| { type: 'allow' }
| { type: 'login'; redirect: string }
| { type: 'forbidden'; from: string }
export interface RouteAccessInput {
requiresAuth?: boolean
isAuthenticated: boolean
fullPath: string
permission?: string
hasPermission: boolean
}
export interface PreviewTargetInput {
currentPath: string
currentPermission?: string
candidateCodes: ReadonlySet<string>
firstPath: string | null
}
export function sanitizeInternalPath(value: unknown): string {
if (
typeof value !== 'string'
|| !value.startsWith('/')
|| value[1] === '/'
|| value[1] === '\\'
|| /[\u0000-\u001F\u007F]/.test(value)
) return '/'
return value
}
export function decideRouteAccess(input: RouteAccessInput): RouteAccessDecision {
if (input.requiresAuth && !input.isAuthenticated) {
return { type: 'login', redirect: sanitizeInternalPath(input.fullPath) }
}
if (input.permission && !input.hasPermission) {
return { type: 'forbidden', from: sanitizeInternalPath(input.fullPath) }
}
return { type: 'allow' }
}
export function resolvePreviewTarget(input: PreviewTargetInput): string | null {
if (input.currentPath === '/403') return input.firstPath ?? '/403'
if (input.currentPermission && !input.candidateCodes.has(input.currentPermission)) {
return input.firstPath ?? '/403'
}
return null
}
+83
View File
@@ -0,0 +1,83 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
import { useUnsavedWorkStore } from '@/core/unsaved-work/unsavedWork'
describe('unsaved work store', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
it('blocks dirty work only when a role switch removes permission or leaves the page', () => {
const store = useUnsavedWorkStore()
store.upsert({
key: 'customer-form',
label: '客户资料',
dirty: true,
requiredPermission: 'customer:create',
})
store.upsert({
key: 'order-form',
label: '头程订单',
dirty: true,
requiredPermission: 'first-leg:create',
})
expect(store.findBlocking(new Set(['customer:create', 'first-leg:create']), false)).toEqual([])
expect(store.findBlocking(new Set(), false).map((entry) => entry.key)).toEqual([
'customer-form',
'order-form',
])
expect(store.findBlocking(new Set(['customer:create', 'first-leg:create']), true)
.map((entry) => entry.key)).toEqual(['customer-form', 'order-form'])
})
it('does not block clean work', () => {
const store = useUnsavedWorkStore()
store.upsert({
key: 'customer-form',
label: '客户资料',
dirty: false,
requiredPermission: 'customer:create',
})
expect(store.findBlocking(new Set(), true)).toEqual([])
})
it('removes an entry by key', () => {
const store = useUnsavedWorkStore()
store.upsert({ key: 'customer-form', label: '客户资料', dirty: true, requiredPermission: null })
store.remove('customer-form')
expect(store.entries).toEqual([])
})
it('replaces an existing entry with the same key', () => {
const store = useUnsavedWorkStore()
store.upsert({ key: 'customer-form', label: '旧名称', dirty: false, requiredPermission: null })
store.upsert({
key: 'customer-form',
label: '客户资料',
dirty: true,
requiredPermission: 'customer:create',
})
expect(store.entries).toEqual([{
key: 'customer-form',
label: '客户资料',
dirty: true,
requiredPermission: 'customer:create',
}])
})
it('clears every registered entry', () => {
const store = useUnsavedWorkStore()
store.upsert({ key: 'customer-form', label: '客户资料', dirty: true, requiredPermission: null })
store.upsert({ key: 'order-form', label: '头程订单', dirty: true, requiredPermission: null })
store.clear()
expect(store.entries).toEqual([])
})
})
+40
View File
@@ -0,0 +1,40 @@
import { ref } from 'vue'
import { defineStore } from 'pinia'
export interface UnsavedWorkEntry {
key: string
label: string
dirty: boolean
requiredPermission: string | null
}
export const useUnsavedWorkStore = defineStore('unsaved-work', () => {
const entries = ref<UnsavedWorkEntry[]>([])
function upsert(entry: UnsavedWorkEntry) {
const index = entries.value.findIndex((item) => item.key === entry.key)
if (index === -1) {
entries.value.push(entry)
return
}
entries.value[index] = entry
}
function remove(key: string) {
const index = entries.value.findIndex((item) => item.key === key)
if (index !== -1) entries.value.splice(index, 1)
}
function findBlocking(candidatePermissions: ReadonlySet<string>, willLeave: boolean) {
return entries.value.filter((entry) => entry.dirty && (
willLeave
|| (entry.requiredPermission !== null && !candidatePermissions.has(entry.requiredPermission))
))
}
function clear() {
entries.value = []
}
return { entries, upsert, remove, findBlocking, clear }
})
@@ -0,0 +1,101 @@
import { computed, defineComponent, nextTick, reactive, ref } from 'vue'
import { mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRegistration'
import { useUnsavedWorkStore } from '@/core/unsaved-work/unsavedWork'
interface HarnessModel {
name: string
}
function mountHarness() {
const isOpen = ref(false)
const label = ref('客户资料')
const requiredPermission = ref<string | null>('customer:create')
const model = reactive<HarnessModel>({ name: '' })
let captureBaseline: () => void = () => undefined
const wrapper = mount(defineComponent({
setup() {
const registration = useUnsavedWorkRegistration({
key: 'customer-form',
label: computed(() => label.value),
isOpen,
model,
requiredPermission: computed(() => requiredPermission.value),
})
captureBaseline = registration.captureBaseline
return () => null
},
}))
return { wrapper, isOpen, label, requiredPermission, model, captureBaseline }
}
describe('useUnsavedWorkRegistration', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
it('tracks open model changes against a captured baseline and removes closed work', async () => {
const store = useUnsavedWorkStore()
const harness = mountHarness()
harness.captureBaseline()
expect(store.entries).toEqual([])
harness.isOpen.value = true
await nextTick()
expect(store.entries[0]?.dirty).toBe(false)
harness.model.name = '柏悦物流'
await nextTick()
expect(store.entries[0]?.dirty).toBe(true)
harness.isOpen.value = false
await nextTick()
expect(store.entries).toEqual([])
harness.wrapper.unmount()
})
it('updates label and permission reactively and becomes clean after restoring the baseline', async () => {
const store = useUnsavedWorkStore()
const harness = mountHarness()
harness.captureBaseline()
harness.isOpen.value = true
await nextTick()
harness.label.value = '新增客户'
harness.requiredPermission.value = 'customer:update'
harness.model.name = '柏悦物流'
await nextTick()
expect(store.entries[0]).toEqual({
key: 'customer-form',
label: '新增客户',
dirty: true,
requiredPermission: 'customer:update',
})
harness.model.name = ''
await nextTick()
expect(store.entries[0]?.dirty).toBe(false)
harness.wrapper.unmount()
})
it('removes registered work when its component scope is disposed', async () => {
const store = useUnsavedWorkStore()
const harness = mountHarness()
harness.captureBaseline()
harness.isOpen.value = true
harness.model.name = '柏悦物流'
await nextTick()
expect(store.entries).toHaveLength(1)
harness.wrapper.unmount()
expect(store.entries).toEqual([])
})
})
@@ -0,0 +1,47 @@
import { computed, onScopeDispose, ref, watch, type ComputedRef, type Ref } from 'vue'
import { useUnsavedWorkStore } from '@/core/unsaved-work/unsavedWork'
interface UnsavedWorkRegistrationOptions<T extends object> {
key: string
label: ComputedRef<string>
isOpen: Ref<boolean>
model: T
requiredPermission: ComputedRef<string | null>
}
export function useUnsavedWorkRegistration<T extends object>(
options: UnsavedWorkRegistrationOptions<T>,
) {
const store = useUnsavedWorkStore()
const snapshot = computed(() => JSON.stringify(options.model))
const baseline = ref(snapshot.value)
function sync() {
if (!options.isOpen.value) {
store.remove(options.key)
return
}
store.upsert({
key: options.key,
label: options.label.value,
dirty: snapshot.value !== baseline.value,
requiredPermission: options.requiredPermission.value,
})
}
function captureBaseline() {
baseline.value = snapshot.value
sync()
}
watch(
[options.isOpen, snapshot, options.label, options.requiredPermission],
sync,
{ flush: 'sync' },
)
sync()
onScopeDispose(() => store.remove(options.key))
return { captureBaseline }
}