chore: initial commit

This commit is contained in:
2026-07-30 17:59:43 +08:00
commit dd0d365b03
73 changed files with 9898 additions and 0 deletions
+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
}
+50
View File
@@ -0,0 +1,50 @@
import { describe, expect, it } from 'vitest'
import router from './index'
describe('admin router access metadata', () => {
it('uses the root route only as a permission-aware entry point', () => {
const home = router.getRoutes().find((route) => route.name === 'home')
expect(home?.path).toBe('/')
expect(home?.meta.permission).toBeUndefined()
expect(router.getRoutes().some((route) => route.name === 'dashboard')).toBe(false)
})
it('protects warehouse management with its stable list permission', () => {
const warehouseRoute = router.getRoutes().find((route) => route.name === 'warehouses')
expect(warehouseRoute?.path).toBe('/warehouses')
expect(warehouseRoute?.meta.permission).toBe('platform:warehouse:list')
})
it('protects express company records with a stable master-data permission', () => {
const route = router.getRoutes().find((item) => item.name === 'express-companies')
expect(route?.path).toBe('/master-data/express-companies')
expect(route?.meta.permission).toBe('platform:express:list')
})
it('protects counterparty records with a stable master-data permission', () => {
const route = router.getRoutes().find((item) => item.name === 'counterparties')
expect(route?.path).toBe('/master-data/counterparties')
expect(route?.meta.permission).toBe('platform:counterparty:list')
})
it('protects enterprise reviews with the backend review menu permission', () => {
const route = router.getRoutes().find((item) => item.name === 'enterprise-reviews')
expect(route?.path).toBe('/customers/reviews')
expect(route?.meta.permission).toBe('platform:customer:review')
})
it('keeps forbidden inside the authenticated layout without a business permission', () => {
const authenticatedLayout = router.options.routes.find((route) => route.path === '/')
const forbidden = authenticatedLayout?.children?.find((route) => route.name === 'forbidden')
const resolved = router.resolve('/403')
expect(authenticatedLayout?.meta?.requiresAuth).toBe(true)
expect(forbidden?.path).toBe('403')
expect(forbidden?.meta?.permission).toBeUndefined()
expect(resolved.matched.some((record) => record.meta.requiresAuth)).toBe(true)
})
})
+125
View File
@@ -0,0 +1,125 @@
import { createRouter, createWebHistory } from 'vue-router'
import AppLayout from '@/layouts/AppLayout.vue'
import { decideRouteAccess } from '@/router/accessDecision'
import { useAccessPreviewStore } from '@/stores/accessPreview'
import { useAuthStore } from '@/stores/auth'
const router = createRouter({
history: createWebHistory(),
routes: [
{
path: '/login',
name: 'login',
component: () => import('@/views/LoginView.vue'),
meta: { title: '登录' },
},
{
path: '/',
component: AppLayout,
meta: { requiresAuth: true },
children: [
{
path: '',
name: 'home',
component: () => import('@/views/ModuleView.vue'),
meta: { title: '进入管理后台', description: '正在进入当前角色可访问的业务页面' },
},
{
path: 'customers',
name: 'customers',
component: () => import('@/views/CustomersView.vue'),
meta: { title: '客户管理', description: '维护客户企业资料、合作状态和注册审核', permission: 'platform:customer:list' },
},
{
path: 'customers/reviews',
name: 'enterprise-reviews',
component: () => import('@/views/EnterpriseReviewsView.vue'),
meta: { title: '企业审核', description: '核验企业资质并维护客户下单资格', permission: 'platform:customer:review' },
},
{
path: 'warehouses',
name: 'warehouses',
component: () => import('@/views/WarehousesView.vue'),
meta: { title: '仓库管理', description: '维护客户头程下单可选择的平台收货仓', permission: 'platform:warehouse:list' },
},
{
path: 'orders/first-leg',
name: 'first-leg-orders',
component: () => import('@/views/FirstLegOrdersView.vue'),
meta: { title: '头程订单', description: '创建、受理和跟进客户提交的头程运输订单', permission: 'platform:order:list' },
},
{
path: 'master-data/express-companies',
name: 'express-companies',
component: () => import('@/views/ExpressCompaniesView.vue'),
meta: { title: '快递公司', description: '查询平台可用的快递公司档案与公开联系信息', permission: 'platform:express:list' },
},
{
path: 'master-data/counterparties',
name: 'counterparties',
component: () => import('@/views/CounterpartiesView.vue'),
meta: { title: '往来单位', description: '维护供应商、客户、承运商、报关行及收发货单位档案', permission: 'platform:counterparty:list' },
},
{
path: 'system/users',
name: 'users',
component: () => import('@/views/InternalUsersView.vue'),
meta: { title: '内部用户', description: '管理平台内部员工账号、状态和角色', permission: 'platform:user:list' },
},
{
path: 'system/roles',
name: 'roles',
component: () => import('@/views/RolesView.vue'),
meta: { title: '角色管理', description: '维护平台角色并配置菜单及操作权限', permission: 'platform:role:list' },
},
{
path: 'system/permissions',
name: 'permissions',
component: () => import('@/views/PermissionsView.vue'),
meta: { title: '菜单权限', description: '维护菜单、页面、按钮和接口权限编码', permission: 'platform:permission:list' },
},
{
path: '403',
name: 'forbidden',
component: () => import('@/views/ForbiddenView.vue'),
meta: { title: '权限不足', description: '当前权限预览角色无法访问此页面' },
},
],
},
{ path: '/:pathMatch(.*)*', redirect: '/' },
],
})
router.beforeEach((to) => {
document.title = to.meta.title ? `${to.meta.title} - 虫虫出海管理后台` : '虫虫出海管理后台'
const auth = useAuthStore()
if (to.name === 'login' && auth.isAuthenticated) {
return { name: 'home' }
}
if (!auth.isAuthenticated && !to.meta.requiresAuth) return true
const access = useAccessPreviewStore()
if (auth.isAuthenticated) access.initializeAfterLogin()
const decision = decideRouteAccess({
requiresAuth: to.meta.requiresAuth,
isAuthenticated: auth.isAuthenticated,
fullPath: to.fullPath,
permission: to.meta.permission,
hasPermission: to.meta.permission ? access.can(to.meta.permission) : true,
})
if (decision.type === 'login') {
return { name: 'login', query: { redirect: decision.redirect } }
}
if (decision.type === 'forbidden') {
return { name: 'forbidden', query: { from: decision.from }, replace: true }
}
if (to.name === 'home') {
return access.firstAccessiblePath
? { path: access.firstAccessiblePath, replace: true }
: { name: 'forbidden', replace: true }
}
return true
})
export default router
+10
View File
@@ -0,0 +1,10 @@
import 'vue-router'
declare module 'vue-router' {
interface RouteMeta {
title?: string
description?: string
requiresAuth?: boolean
permission?: string
}
}