优化构架
This commit is contained in:
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
@@ -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 }
|
||||
}
|
||||
Reference in New Issue
Block a user