chore: initial commit
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
VITE_API_BASE_URL=/api
|
||||
API_PROXY_TARGET=http://localhost:9000
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
node_modules/
|
||||
node_modules.broken-by-path/
|
||||
dist/
|
||||
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
.playwright-cli/
|
||||
.impeccable/
|
||||
.codex-temp/
|
||||
.codex-tmp/
|
||||
.logs/
|
||||
outputs/
|
||||
|
||||
*.log
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
# Admin Web Instructions
|
||||
|
||||
## 项目定位
|
||||
|
||||
- 本项目是虫虫出海内部运营与管理后台,仅服务平台员工。
|
||||
- 当前范围包括工作台、客户管理、头程订单、内部用户、角色管理和菜单权限。
|
||||
- 不在本项目承载企业客户自助流程、品牌官网、微信小程序、独立 APP 或仓库 PDA 作业。
|
||||
|
||||
## 技术基线
|
||||
|
||||
- 使用 Vue 3、TypeScript、Vite、Vue Router、Pinia、Element Plus、Axios 和 Lucide Vue。
|
||||
- TypeScript 保持严格模式;不要使用 `any` 绕过业务模型,优先从 `@chongchong/shared` 复用跨项目类型。
|
||||
- 使用现有 `@` 别名引用 `src/`。保持 ES Module 和现有无分号代码风格。
|
||||
- Element Plus 组件使用现有自动导入配置;`ref`、`computed` 等 Vue API 必须显式导入。`src/auto-imports.d.ts` 与 `src/components.d.ts` 属于生成声明,不手工维护业务内容。
|
||||
- 不更换框架、路由、状态管理、组件库或构建工具,除非任务明确要求并完成影响评估。
|
||||
|
||||
## 目录与职责
|
||||
|
||||
- `src/views/`:路由级页面和业务编排;复杂、可复用的展示或交互下沉到组件。
|
||||
- `src/components/`:无路由依赖的复用组件,输入输出应有明确类型。
|
||||
- `src/layouts/`:应用壳层、导航和全局页面结构。
|
||||
- `src/router/`:路由、标题、登录保护和权限元数据;新增模块时同步维护导航与路由配置。
|
||||
- `src/stores/`:跨页面状态和业务动作;仅页面局部使用的状态留在组件内。
|
||||
- `src/api/`:HTTP 客户端和接口适配;页面与组件不得散落创建 Axios 实例。
|
||||
- `VITE_API_BASE_URL` 控制浏览器请求前缀,`API_PROXY_TARGET` 控制 Vite 开发代理上游;后者只由开发服务器读取,不进入客户端构建。新增或变更环境变量时同步更新 `.env.example`,不得提交真实密钥或环境专属凭据。
|
||||
- `src/types/`:后台专属类型;跨应用共享的稳定契约放到 `packages/shared`。
|
||||
- `src/styles/`:设计令牌和全局样式;优先复用现有 `--cc-*` 变量和组件样式。
|
||||
|
||||
## 业务与安全约束
|
||||
|
||||
- 菜单隐藏和前端路由守卫只改善体验,不能替代服务端鉴权。新增操作必须使用稳定的权限编码,并与 API 权限保持一致。
|
||||
- 认证统一经过 `src/stores/auth.ts` 和 `src/api/http.ts`;不要在页面中复制令牌读写或手工拼装 `Authorization`。
|
||||
- 当前登录和部分数据仍可能是模拟实现。接入 `services/api` 时删除对应模拟路径,不允许让生产逻辑静默回退到假数据。
|
||||
- 删除、停用、改权限等高风险操作必须有明确确认、处理中、成功和失败反馈;失败时保留用户上下文,避免误以为已生效。
|
||||
- 日志、提示和示例数据不得包含真实账号、令牌、客户隐私或生产数据。
|
||||
|
||||
## UI 与交互
|
||||
|
||||
- 延续现有后台视觉体系:克制的企业级界面、清晰的信息层级和高密度但可扫描的数据展示。
|
||||
- 优先使用 Element Plus 与现有复用组件;图标使用 Lucide,不用表情符号充当功能图标。
|
||||
- 表格、筛选、抽屉和表单应按实际语义覆盖适用的加载、空数据、错误、禁用和无权限状态,不为不适用的组件机械增加状态。
|
||||
- 保持键盘可操作、可见焦点、表单标签和足够的颜色对比度;不要只用颜色表达状态。
|
||||
- 新页面至少适配现有 320px 最小宽度策略,并检查侧栏、表格、抽屉和表单在窄屏下的退化方式。
|
||||
|
||||
## 本地命令与质量门禁
|
||||
|
||||
在 `apps/admin-web` 目录执行:
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm dev
|
||||
pnpm test
|
||||
pnpm typecheck
|
||||
pnpm build
|
||||
```
|
||||
|
||||
- 日常变更至少运行 `pnpm typecheck` 和受影响测试;交付前运行 `pnpm test` 与 `pnpm build`,后者也会执行类型检查。
|
||||
- 当前项目未声明 lint 脚本,不要声称 lint 已通过。若任务新增或变更质量脚本,应同时更新 `package.json`、`README.md` 和本文件。
|
||||
- 不手工修改 `dist/` 或 `node_modules/`;依赖变更必须同步当前项目的 `package.json` 与锁文件。
|
||||
@@ -0,0 +1,60 @@
|
||||
# 虫虫出海管理后台
|
||||
|
||||
平台内部运营与管理应用,使用 Vue 3、TypeScript、Vite、Vue Router、Pinia 和 Element Plus。
|
||||
|
||||
## 本地运行
|
||||
|
||||
```bash
|
||||
cd apps/admin-web
|
||||
pnpm install
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
生产构建:
|
||||
|
||||
```bash
|
||||
pnpm typecheck
|
||||
pnpm build
|
||||
```
|
||||
|
||||
自动化测试:
|
||||
|
||||
```bash
|
||||
pnpm test
|
||||
```
|
||||
|
||||
依赖和 `pnpm-lock.yaml` 均保存在当前前端项目中,不需要在仓库根目录安装 Node 依赖。
|
||||
|
||||
工作台已从一期路由与菜单移除;登录后按当前角色进入第一个有权限的业务页面。一期 Admin 路由聚焦登录、客户及注册审核、平台收货仓、快递公司、往来单位、头程订单、内部用户、角色管理和菜单权限。
|
||||
|
||||
后台菜单按“业务管理”和“系统管理”分组。企业审核、平台角色、菜单及按钮权限资源已接入真实 IAM 接口;客户档案、基础资料和部分系统管理页面仍使用 Pinia 模拟数据,不请求尚未确定的 Java 业务接口。
|
||||
|
||||
## 本地 Gateway 联调
|
||||
|
||||
开发环境统一通过 Vite 的 `/api` 代理访问 Gateway,不在浏览器代码中拼接 Nacos 服务名或服务直连端口。复制 `.env.example` 为 `.env.local`,并按联调机器修改:
|
||||
|
||||
```dotenv
|
||||
VITE_API_BASE_URL=/api
|
||||
API_PROXY_TARGET=http://192.168.110.149:9000
|
||||
```
|
||||
|
||||
认证请求使用 `/api/auth/**`,平台 IAM 请求使用 `/api/platform/v1/iam/**`。登录后从 JWT 读取平台身份和授权快照,再与 IAM 返回的启用权限资源取交集。企业审核调用 `/reviews`,打开审核详情后按租户调用 `/tenants/{tenantId}/qualifications`;营业执照通过 `/api/platform/v1/files/qualifications/{tenantId}/{fileName}` 携带平台 Token 临时读取,关闭预览后释放本地对象地址。平台角色调用 `/roles`,权限资源调用 `/permissions`;接口未提供的编辑、删除、启停和已有授权回显不会模拟成功。一期真实权限资源只创建 `MENU` 与 `BUTTON`,不创建 `PAGE` 或 `API` 类型。其余业务页面需在对应服务契约确认后逐项替换,生产逻辑不得静默回退到模拟数据。
|
||||
|
||||
仓库管理当前维护义乌、深圳、青岛、上海、宁波、厦门等平台国内收货仓,支持筛选、新增、编辑和启停。停用仓库保留历史数据,但不再提供给客户新建头程订单选择;订单详情使用下单时保存的仓库编码、名称和地址快照,仓库主数据后续修改不改变历史展示。本阶段不包含 FBA/海外目的仓、库存、库位或出入库作业,快照仍为 Pinia 模拟数据,服务端持久化待接入。
|
||||
|
||||
快递公司和往来单位一期静态维护已完成:支持筛选、详情、新增、编辑、启停、唯一校验、标准模板下载和导入文件基础检查;往来单位支持六类多选角色、联系人和完整地址。两类主数据只导入不导出,导入只新增,任一行错误整批失败。工作表内容、模板版本、重复数据和整批事务必须由后端解析校验;接口提供前,前端不模拟校验或导入成功。头程订单可维护一组快递公司和快递单号;订单不设“已驳回”,后台修改待审核订单后仍保持待审核并写入审计。上述业务数据仍使用 Pinia,等待后端新架构明确契约后接入。
|
||||
|
||||
真实接口错误会保留后端返回的业务错误码、`requestId` 和 `traceId`。登录、企业审核、平台角色及权限资源的主要失败态支持直接复制追踪标识,便于与后端日志关联。
|
||||
|
||||
平台联调账号和密码只在 Vite 开发模式下预填,联调提示也只在开发模式显示。生产登录表单默认留空;生产构建产物不得包含本地联调账号、密码或开发提示文案。
|
||||
|
||||
## 静态权限预览
|
||||
|
||||
管理后台支持在顶栏切换本地角色,预览该角色可见的菜单、可访问页面和可用操作。预览角色保存在浏览器本地,刷新后继续保留;退出登录时清除,下次登录重新使用默认角色。
|
||||
|
||||
该功能仅用于静态页面和交互验收:
|
||||
|
||||
- 预览角色和演示业务数据来自本地 Pinia;真实登录会切换到服务端权限,并让已接入页面调用 Java 业务 API。
|
||||
- 当前不模拟角色的 `dataScope` 数据范围。
|
||||
- 菜单隐藏、页面拦截和操作禁用不能替代服务端鉴权。
|
||||
- 后续接入 Java 服务时,接口仍须独立校验登录身份、角色权限和数据范围。
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="description" content="虫虫出海管理后台" />
|
||||
<title>虫虫出海管理后台</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "@chongchong/admin-web",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 0.0.0.0 --port 5173",
|
||||
"build": "vue-tsc --noEmit && vite build",
|
||||
"typecheck": "vue-tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"preview": "vite preview --port 4173"
|
||||
},
|
||||
"dependencies": {
|
||||
"@chongchong/shared": "link:../../packages/shared",
|
||||
"@lucide/vue": "^1.27.0",
|
||||
"axios": "^1.18.1",
|
||||
"element-plus": "^2.14.3",
|
||||
"pinia": "^4.0.2",
|
||||
"vue": "^3.5.40",
|
||||
"vue-router": "^5.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.10.0",
|
||||
"@vitejs/plugin-vue": "^6.0.8",
|
||||
"@vue/test-utils": "^2.4.11",
|
||||
"jsdom": "^29.1.1",
|
||||
"typescript": "^5.9.3",
|
||||
"unplugin-auto-import": "^21.0.0",
|
||||
"unplugin-vue-components": "^32.1.0",
|
||||
"vite": "^8.1.5",
|
||||
"vitest": "^4.1.10",
|
||||
"vue-tsc": "^3.3.8"
|
||||
}
|
||||
}
|
||||
Generated
+2641
File diff suppressed because it is too large
Load Diff
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,4 @@
|
||||
<template>
|
||||
<RouterView />
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import axios, { AxiosHeaders } from 'axios'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { ApiResponseError, getApiErrorDetails, unwrapApiResponse } from './http'
|
||||
|
||||
describe('API error diagnostics', () => {
|
||||
it('keeps business request and trace identifiers when unwrapping fails', () => {
|
||||
expect(() => unwrapApiResponse({
|
||||
code: 'A0403',
|
||||
message: '无权限',
|
||||
data: null,
|
||||
requestId: 'request-1',
|
||||
traceId: 'trace-1',
|
||||
})).toThrow(ApiResponseError)
|
||||
|
||||
try {
|
||||
unwrapApiResponse({ code: 'A0403', message: '无权限', data: null, requestId: 'request-1', traceId: 'trace-1' })
|
||||
} catch (error) {
|
||||
expect(getApiErrorDetails(error)).toEqual({
|
||||
message: '无权限',
|
||||
code: 'A0403',
|
||||
requestId: 'request-1',
|
||||
traceId: 'trace-1',
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it('extracts identifiers from an HTTP error envelope', () => {
|
||||
const error = new axios.AxiosError('Request failed', 'ERR_BAD_RESPONSE', undefined, undefined, {
|
||||
status: 503,
|
||||
statusText: 'Service Unavailable',
|
||||
headers: {},
|
||||
config: { headers: new AxiosHeaders() },
|
||||
data: { code: 'S0503', message: '服务不可用', requestId: 'request-2', traceId: 'trace-2' },
|
||||
})
|
||||
|
||||
expect(getApiErrorDetails(error)).toEqual({
|
||||
message: '服务不可用',
|
||||
code: 'S0503',
|
||||
requestId: 'request-2',
|
||||
traceId: 'trace-2',
|
||||
})
|
||||
})
|
||||
})
|
||||
+121
@@ -0,0 +1,121 @@
|
||||
import axios, { type InternalAxiosRequestConfig } from 'axios'
|
||||
|
||||
export interface ApiEnvelope<T> {
|
||||
code: string
|
||||
message: string
|
||||
data: T
|
||||
requestId?: string | null
|
||||
traceId?: string | null
|
||||
timestamp?: string
|
||||
}
|
||||
|
||||
export class ApiResponseError extends Error {
|
||||
readonly code: string
|
||||
readonly status?: number
|
||||
readonly details?: unknown
|
||||
readonly requestId?: string
|
||||
readonly traceId?: string
|
||||
|
||||
constructor(message: string, code: string, status?: number, details?: unknown, requestId?: string | null, traceId?: string | null) {
|
||||
super(message)
|
||||
this.name = 'ApiResponseError'
|
||||
this.code = code
|
||||
this.status = status
|
||||
this.details = details
|
||||
this.requestId = requestId || undefined
|
||||
this.traceId = traceId || undefined
|
||||
}
|
||||
}
|
||||
|
||||
export interface ApiErrorDetails {
|
||||
message: string
|
||||
code?: string
|
||||
requestId?: string
|
||||
traceId?: string
|
||||
}
|
||||
|
||||
export const http = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_BASE_URL || '/api',
|
||||
timeout: 15_000,
|
||||
})
|
||||
|
||||
type RetryableRequestConfig = InternalAxiosRequestConfig & { _authRetry?: boolean }
|
||||
type AuthRefreshHandler = () => Promise<string>
|
||||
|
||||
let authRefreshHandler: AuthRefreshHandler | null = null
|
||||
let activeRefresh: Promise<string> | null = null
|
||||
|
||||
export function registerAuthRefreshHandler(handler: AuthRefreshHandler) {
|
||||
authRefreshHandler = handler
|
||||
}
|
||||
|
||||
http.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem('cc-admin-token')
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
return config
|
||||
})
|
||||
|
||||
http.interceptors.response.use(
|
||||
(response) => response.data,
|
||||
async (error) => {
|
||||
const config = error.config as RetryableRequestConfig | undefined
|
||||
const isAuthEndpoint = typeof config?.url === 'string' && config.url.startsWith('/auth/')
|
||||
if (error.response?.status !== 401 || !config || config._authRetry || isAuthEndpoint || !authRefreshHandler) {
|
||||
return Promise.reject(error)
|
||||
}
|
||||
|
||||
config._authRetry = true
|
||||
try {
|
||||
if (!activeRefresh) {
|
||||
activeRefresh = authRefreshHandler().finally(() => { activeRefresh = null })
|
||||
}
|
||||
const nextToken = await activeRefresh
|
||||
config.headers.Authorization = `Bearer ${nextToken}`
|
||||
return http.request(config)
|
||||
} catch {
|
||||
return Promise.reject(error)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
export function unwrapApiResponse<T>(response: ApiEnvelope<T>): T {
|
||||
if (response?.code !== '0') {
|
||||
throw new ApiResponseError(
|
||||
response?.message || '接口请求失败',
|
||||
response?.code || 'UNKNOWN',
|
||||
undefined,
|
||||
response?.data,
|
||||
response?.requestId,
|
||||
response?.traceId,
|
||||
)
|
||||
}
|
||||
return response.data
|
||||
}
|
||||
|
||||
export function getApiErrorDetails(error: unknown, fallback = '接口请求失败'): ApiErrorDetails {
|
||||
if (error instanceof ApiResponseError) {
|
||||
return { message: error.message, code: error.code, requestId: error.requestId, traceId: error.traceId }
|
||||
}
|
||||
if (axios.isAxiosError(error)) {
|
||||
const response = error.response?.data as {
|
||||
code?: string
|
||||
message?: string
|
||||
error?: string
|
||||
requestId?: string | null
|
||||
traceId?: string | null
|
||||
} | undefined
|
||||
return {
|
||||
message: response?.message || response?.error || fallback,
|
||||
code: response?.code || (error.response?.status ? `HTTP_${error.response.status}` : undefined),
|
||||
requestId: response?.requestId || undefined,
|
||||
traceId: response?.traceId || undefined,
|
||||
}
|
||||
}
|
||||
return { message: error instanceof Error ? error.message : fallback }
|
||||
}
|
||||
|
||||
export function getApiErrorMessage(error: unknown, fallback = '接口请求失败'): string {
|
||||
return getApiErrorDetails(error, fallback).message
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { http } from '@/api/http'
|
||||
import {
|
||||
createPlatformPermission,
|
||||
createPlatformRole,
|
||||
downloadTenantQualification,
|
||||
listTenantQualifications,
|
||||
listPlatformPermissions,
|
||||
listPlatformRoles,
|
||||
qualificationFileName,
|
||||
type PlatformPermissionResource,
|
||||
type PlatformRoleResource,
|
||||
} from './platformIam'
|
||||
|
||||
vi.mock('@/api/http', () => ({
|
||||
http: { get: vi.fn(), post: vi.fn() },
|
||||
unwrapApiResponse: <T>(response: { data: T }) => response.data,
|
||||
}))
|
||||
|
||||
const role: PlatformRoleResource = {
|
||||
id: 4000002,
|
||||
tenantId: 0,
|
||||
appCode: 'PLATFORM',
|
||||
roleCode: 'CUSTOMER_REVIEWER',
|
||||
roleName: '企业审核员',
|
||||
roleType: 'SYSTEM',
|
||||
status: 'ACTIVE',
|
||||
description: '企业资料与入驻审核',
|
||||
}
|
||||
|
||||
const permission: PlatformPermissionResource = {
|
||||
id: 5100010,
|
||||
tenantId: 0,
|
||||
appCode: 'PLATFORM',
|
||||
permissionType: 'MENU',
|
||||
parentId: 5100000,
|
||||
permissionCode: 'platform:customer:list',
|
||||
permissionName: '企业客户',
|
||||
routePath: '/customers',
|
||||
httpMethod: null,
|
||||
sortNo: 10,
|
||||
status: 'ACTIVE',
|
||||
}
|
||||
|
||||
describe('platform IAM roles', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(http.get).mockReset()
|
||||
vi.mocked(http.post).mockReset()
|
||||
})
|
||||
|
||||
it('loads roles from the platform IAM prefix', async () => {
|
||||
vi.mocked(http.get).mockResolvedValue({ code: '0', message: 'success', data: [role] })
|
||||
|
||||
await expect(listPlatformRoles()).resolves.toEqual([role])
|
||||
expect(http.get).toHaveBeenCalledWith('/platform/v1/iam/roles')
|
||||
})
|
||||
|
||||
it('creates a platform role with the documented payload', async () => {
|
||||
const payload = {
|
||||
roleCode: 'CUSTOMER_REVIEWER',
|
||||
roleName: '企业审核员',
|
||||
description: '企业资料与入驻审核',
|
||||
}
|
||||
vi.mocked(http.post).mockResolvedValue({ code: '0', message: 'success', data: role })
|
||||
|
||||
await expect(createPlatformRole(payload)).resolves.toEqual(role)
|
||||
expect(http.post).toHaveBeenCalledWith('/platform/v1/iam/roles', payload)
|
||||
})
|
||||
|
||||
it('loads and creates permission resources under the platform IAM prefix', async () => {
|
||||
vi.mocked(http.get).mockResolvedValue({ code: '0', message: 'success', data: [permission] })
|
||||
vi.mocked(http.post).mockResolvedValue({ code: '0', message: 'success', data: permission })
|
||||
const payload = {
|
||||
type: 'MENU' as const,
|
||||
code: permission.permissionCode,
|
||||
name: permission.permissionName,
|
||||
parentId: permission.parentId,
|
||||
routePath: permission.routePath,
|
||||
httpMethod: null,
|
||||
sortNo: permission.sortNo,
|
||||
}
|
||||
|
||||
await expect(listPlatformPermissions()).resolves.toEqual([permission])
|
||||
await expect(createPlatformPermission(payload)).resolves.toEqual(permission)
|
||||
expect(http.get).toHaveBeenCalledWith('/platform/v1/iam/permissions')
|
||||
expect(http.post).toHaveBeenCalledWith('/platform/v1/iam/permissions', payload)
|
||||
})
|
||||
|
||||
it('loads qualifications for the selected tenant', async () => {
|
||||
const qualification = {
|
||||
id: 12,
|
||||
tenantId: 8,
|
||||
qualificationType: 'BUSINESS_LICENSE',
|
||||
certificateNo: '91440300XXXXXXXXXX',
|
||||
documentUrl: '/api/merchant/v1/files/qualifications/license.png',
|
||||
status: 'PENDING' as const,
|
||||
reviewComment: null,
|
||||
}
|
||||
vi.mocked(http.get).mockResolvedValue({ code: '0', message: 'success', data: [qualification] })
|
||||
|
||||
await expect(listTenantQualifications(8)).resolves.toEqual([qualification])
|
||||
expect(http.get).toHaveBeenCalledWith('/platform/v1/iam/tenants/8/qualifications')
|
||||
})
|
||||
|
||||
it('downloads a qualification with the platform file endpoint and a safe filename', async () => {
|
||||
const blob = new Blob(['image'], { type: 'image/png' })
|
||||
vi.mocked(http.get).mockResolvedValue(blob)
|
||||
|
||||
expect(qualificationFileName('/api/merchant/v1/files/qualifications/%E8%90%A5%E4%B8%9A%E6%89%A7%E7%85%A7.png?token=ignored')).toBe('营业执照.png')
|
||||
await expect(downloadTenantQualification(8, '/api/merchant/v1/files/qualifications/%E8%90%A5%E4%B8%9A%E6%89%A7%E7%85%A7.png')).resolves.toBe(blob)
|
||||
expect(http.get).toHaveBeenCalledWith(
|
||||
'/platform/v1/files/qualifications/8/%E8%90%A5%E4%B8%9A%E6%89%A7%E7%85%A7.png',
|
||||
{ responseType: 'blob' },
|
||||
)
|
||||
expect(() => qualificationFileName('/api/merchant/v1/files/qualifications/%2Fetc%2Fpasswd')).toThrow('资质文件地址无效')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,136 @@
|
||||
import { http, unwrapApiResponse, type ApiEnvelope } from '@/api/http'
|
||||
|
||||
export interface PlatformPermissionResource {
|
||||
id: number
|
||||
tenantId: number
|
||||
appCode: 'PLATFORM'
|
||||
permissionType: 'MENU' | 'BUTTON'
|
||||
parentId: number
|
||||
permissionCode: string
|
||||
permissionName: string
|
||||
routePath: string | null
|
||||
httpMethod: string | null
|
||||
sortNo: number
|
||||
status: 'ACTIVE' | 'INACTIVE'
|
||||
}
|
||||
|
||||
export interface EnterpriseReview {
|
||||
id: number
|
||||
tenantId: number
|
||||
status: 'PENDING' | 'APPROVED' | 'REJECTED'
|
||||
tenantName?: string | null
|
||||
tenantCode?: string | null
|
||||
qualificationId?: number | null
|
||||
qualificationType?: string | null
|
||||
certificateNo?: string | null
|
||||
documentUrl?: string | null
|
||||
contactName?: string | null
|
||||
contactPhone?: string | null
|
||||
contactEmail?: string | null
|
||||
submittedAt?: string | null
|
||||
createdAt?: string | null
|
||||
reviewComment?: string | null
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export interface PlatformTenantQualification {
|
||||
id: number | string
|
||||
tenantId: number | string
|
||||
qualificationType: 'BUSINESS_LICENSE' | string
|
||||
certificateNo: string | null
|
||||
documentUrl: string
|
||||
status: 'PENDING' | 'APPROVED' | 'REJECTED'
|
||||
reviewComment: string | null
|
||||
}
|
||||
|
||||
export interface PlatformRoleResource {
|
||||
id: number
|
||||
tenantId: number
|
||||
appCode: 'PLATFORM'
|
||||
roleCode: string
|
||||
roleName: string
|
||||
roleType: 'SYSTEM' | 'CUSTOM' | string
|
||||
status: 'ACTIVE' | 'INACTIVE' | string
|
||||
description: string | null
|
||||
}
|
||||
|
||||
export interface CreatePlatformRoleRequest {
|
||||
roleCode: string
|
||||
roleName: string
|
||||
description: string
|
||||
}
|
||||
|
||||
export interface CreatePlatformPermissionRequest {
|
||||
type: 'MENU' | 'BUTTON'
|
||||
code: string
|
||||
name: string
|
||||
parentId: number
|
||||
routePath: string | null
|
||||
httpMethod: null
|
||||
sortNo: number
|
||||
}
|
||||
|
||||
async function getData<T>(path: string): Promise<T> {
|
||||
const response = await http.get<ApiEnvelope<T>, ApiEnvelope<T>>(path)
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
async function postData<T>(path: string, body: unknown): Promise<T> {
|
||||
const response = await http.post<ApiEnvelope<T>, ApiEnvelope<T>>(path, body)
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export function listPlatformPermissions() {
|
||||
return getData<PlatformPermissionResource[]>('/platform/v1/iam/permissions')
|
||||
}
|
||||
|
||||
export function listEnterpriseReviews() {
|
||||
return getData<EnterpriseReview[]>('/platform/v1/iam/reviews')
|
||||
}
|
||||
|
||||
export function listTenantQualifications(tenantId: number | string) {
|
||||
return getData<PlatformTenantQualification[]>(`/platform/v1/iam/tenants/${tenantId}/qualifications`)
|
||||
}
|
||||
|
||||
export function qualificationFileName(documentUrl: string) {
|
||||
const path = documentUrl.split(/[?#]/, 1)[0]
|
||||
const encodedName = path.split('/').filter(Boolean).at(-1)
|
||||
if (!encodedName) throw new Error('资质文件地址无效')
|
||||
let fileName: string
|
||||
try {
|
||||
fileName = decodeURIComponent(encodedName)
|
||||
} catch {
|
||||
throw new Error('资质文件地址无效')
|
||||
}
|
||||
if (!fileName || fileName === '.' || fileName === '..' || /[\\/]/.test(fileName)) {
|
||||
throw new Error('资质文件地址无效')
|
||||
}
|
||||
return fileName
|
||||
}
|
||||
|
||||
export function downloadTenantQualification(tenantId: number | string, documentUrl: string) {
|
||||
const fileName = qualificationFileName(documentUrl)
|
||||
return http.get<Blob, Blob>(`/platform/v1/files/qualifications/${tenantId}/${encodeURIComponent(fileName)}`, {
|
||||
responseType: 'blob',
|
||||
})
|
||||
}
|
||||
|
||||
export function listPlatformRoles() {
|
||||
return getData<PlatformRoleResource[]>('/platform/v1/iam/roles')
|
||||
}
|
||||
|
||||
export function createPlatformRole(payload: CreatePlatformRoleRequest) {
|
||||
return postData<PlatformRoleResource>('/platform/v1/iam/roles', payload)
|
||||
}
|
||||
|
||||
export function createPlatformPermission(payload: CreatePlatformPermissionRequest) {
|
||||
return postData<PlatformPermissionResource>('/platform/v1/iam/permissions', payload)
|
||||
}
|
||||
|
||||
export function approveEnterpriseReview(reviewId: number, comment: string) {
|
||||
return postData<unknown>(`/platform/v1/iam/reviews/${reviewId}/approve`, { comment })
|
||||
}
|
||||
|
||||
export function rejectEnterpriseReview(reviewId: number, comment: string) {
|
||||
return postData<unknown>(`/platform/v1/iam/reviews/${reviewId}/reject`, { comment })
|
||||
}
|
||||
Vendored
+10
@@ -0,0 +1,10 @@
|
||||
/* eslint-disable */
|
||||
/* prettier-ignore */
|
||||
// @ts-nocheck
|
||||
// noinspection JSUnusedGlobalSymbols
|
||||
// Generated by unplugin-auto-import
|
||||
// biome-ignore lint: disable
|
||||
export {}
|
||||
declare global {
|
||||
|
||||
}
|
||||
Vendored
+58
@@ -0,0 +1,58 @@
|
||||
/* eslint-disable */
|
||||
// @ts-nocheck
|
||||
// biome-ignore lint: disable
|
||||
// oxlint-disable
|
||||
// ------
|
||||
// Generated by unplugin-vue-components
|
||||
// Read more: https://github.com/vuejs/core/pull/3399
|
||||
|
||||
export {}
|
||||
|
||||
/* prettier-ignore */
|
||||
declare module 'vue' {
|
||||
export interface GlobalComponents {
|
||||
AdminSidebarMenu: typeof import('./components/AdminSidebarMenu.vue')['default']
|
||||
AdminSidebarMenuNode: typeof import('./components/AdminSidebarMenuNode.vue')['default']
|
||||
ApiErrorMeta: typeof import('./components/ApiErrorMeta.vue')['default']
|
||||
ElAlert: typeof import('element-plus/es')['ElAlert']
|
||||
ElButton: typeof import('element-plus/es')['ElButton']
|
||||
ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
|
||||
ElDescriptions: typeof import('element-plus/es')['ElDescriptions']
|
||||
ElDescriptionsItem: typeof import('element-plus/es')['ElDescriptionsItem']
|
||||
ElDialog: typeof import('element-plus/es')['ElDialog']
|
||||
ElDrawer: typeof import('element-plus/es')['ElDrawer']
|
||||
ElDropdown: typeof import('element-plus/es')['ElDropdown']
|
||||
ElDropdownItem: typeof import('element-plus/es')['ElDropdownItem']
|
||||
ElDropdownMenu: typeof import('element-plus/es')['ElDropdownMenu']
|
||||
ElEmpty: typeof import('element-plus/es')['ElEmpty']
|
||||
ElForm: typeof import('element-plus/es')['ElForm']
|
||||
ElFormItem: typeof import('element-plus/es')['ElFormItem']
|
||||
ElInput: typeof import('element-plus/es')['ElInput']
|
||||
ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
|
||||
ElMenu: typeof import('element-plus/es')['ElMenu']
|
||||
ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
|
||||
ElOption: typeof import('element-plus/es')['ElOption']
|
||||
ElPagination: typeof import('element-plus/es')['ElPagination']
|
||||
ElRadioButton: typeof import('element-plus/es')['ElRadioButton']
|
||||
ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
|
||||
ElSelect: typeof import('element-plus/es')['ElSelect']
|
||||
ElStep: typeof import('element-plus/es')['ElStep']
|
||||
ElSteps: typeof import('element-plus/es')['ElSteps']
|
||||
ElSubMenu: typeof import('element-plus/es')['ElSubMenu']
|
||||
ElTable: typeof import('element-plus/es')['ElTable']
|
||||
ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
|
||||
ElTag: typeof import('element-plus/es')['ElTag']
|
||||
ElTimeline: typeof import('element-plus/es')['ElTimeline']
|
||||
ElTimelineItem: typeof import('element-plus/es')['ElTimelineItem']
|
||||
ElTree: typeof import('element-plus/es')['ElTree']
|
||||
MetricStrip: typeof import('./components/MetricStrip.vue')['default']
|
||||
RolePreviewSwitcher: typeof import('./components/RolePreviewSwitcher.vue')['default']
|
||||
RouterLink: typeof import('vue-router')['RouterLink']
|
||||
RouterView: typeof import('vue-router')['RouterView']
|
||||
StaticBatchImportDialog: typeof import('./components/StaticBatchImportDialog.vue')['default']
|
||||
StatusBadge: typeof import('./components/StatusBadge.vue')['default']
|
||||
}
|
||||
export interface GlobalDirectives {
|
||||
vLoading: typeof import('element-plus/es')['ElLoadingDirective']
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { adminNavigation, filterAdminNavigation, type AdminNavigationNode } from '@/navigation/adminNavigation'
|
||||
import AdminSidebarMenu from './AdminSidebarMenu.vue'
|
||||
|
||||
const stubs = {
|
||||
'el-menu': {
|
||||
props: ['defaultActive', 'collapse'],
|
||||
template: '<nav :data-active="defaultActive" :data-collapsed="String(collapse)"><slot /></nav>',
|
||||
},
|
||||
'el-sub-menu': {
|
||||
props: ['index'],
|
||||
template: '<section data-kind="group" :data-index="index"><header><slot name="title" /></header><slot /></section>',
|
||||
},
|
||||
'el-menu-item': {
|
||||
props: ['index'],
|
||||
template: '<a data-kind="item" :data-index="index"><slot name="title" /><slot /></a>',
|
||||
},
|
||||
}
|
||||
|
||||
describe('AdminSidebarMenu', () => {
|
||||
it('renders grouped page entries and forwards the active route', () => {
|
||||
const wrapper = mount(AdminSidebarMenu, {
|
||||
props: { items: adminNavigation, activePath: '/system/users', collapsed: false },
|
||||
global: { stubs },
|
||||
})
|
||||
|
||||
expect(wrapper.get('nav').attributes('data-active')).toBe('/system/users')
|
||||
expect(wrapper.findAll('[data-kind="group"] header').map((node) => node.text())).toEqual([
|
||||
'业务管理',
|
||||
'基础资料',
|
||||
'系统管理',
|
||||
])
|
||||
expect(wrapper.findAll('[data-kind="item"]').map((node) => node.attributes('data-index'))).toEqual([
|
||||
'/customers',
|
||||
'/customers/reviews',
|
||||
'/warehouses',
|
||||
'/orders/first-leg',
|
||||
'/master-data/express-companies',
|
||||
'/master-data/counterparties',
|
||||
'/system/users',
|
||||
'/system/roles',
|
||||
'/system/permissions',
|
||||
])
|
||||
})
|
||||
|
||||
it('forwards desktop collapse state to Element Plus', () => {
|
||||
const wrapper = mount(AdminSidebarMenu, {
|
||||
props: { items: adminNavigation, activePath: '/', collapsed: true },
|
||||
global: { stubs },
|
||||
})
|
||||
|
||||
expect(wrapper.get('nav').attributes('data-collapsed')).toBe('true')
|
||||
})
|
||||
|
||||
it('renders only entries included in the filtered navigation input', () => {
|
||||
const items = filterAdminNavigation(adminNavigation, new Set(['platform:permission:list']))
|
||||
const wrapper = mount(AdminSidebarMenu, {
|
||||
props: { items, activePath: '/', collapsed: false },
|
||||
global: { stubs },
|
||||
})
|
||||
|
||||
expect(wrapper.text()).toContain('菜单权限')
|
||||
expect(wrapper.text()).not.toContain('客户管理')
|
||||
})
|
||||
|
||||
it('recursively renders three navigation levels', () => {
|
||||
const items: AdminNavigationNode[] = [{
|
||||
key: 'operations',
|
||||
title: '运营管理',
|
||||
icon: adminNavigation[0].icon,
|
||||
children: [{
|
||||
key: 'shipment',
|
||||
title: '运输管理',
|
||||
icon: adminNavigation[0].icon,
|
||||
children: [{
|
||||
key: 'tracking',
|
||||
title: '轨迹管理',
|
||||
icon: adminNavigation[0].icon,
|
||||
path: '/tracking',
|
||||
permission: 'tracking:list',
|
||||
}],
|
||||
}],
|
||||
}]
|
||||
const wrapper = mount(AdminSidebarMenu, {
|
||||
props: { items, activePath: '/tracking', collapsed: false },
|
||||
global: { stubs },
|
||||
})
|
||||
|
||||
expect(wrapper.findAll('[data-kind="group"]')).toHaveLength(2)
|
||||
expect(wrapper.get('[data-kind="item"]').attributes('data-index')).toBe('/tracking')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,31 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { AdminNavigationNode } from '@/navigation/adminNavigation'
|
||||
import AdminSidebarMenuNode from './AdminSidebarMenuNode.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
items: AdminNavigationNode[]
|
||||
activePath: string
|
||||
collapsed: boolean
|
||||
}>()
|
||||
|
||||
function getGroupKeys(nodes: AdminNavigationNode[]): string[] {
|
||||
return nodes.flatMap((node) => node.children?.length
|
||||
? [node.key, ...getGroupKeys(node.children)]
|
||||
: [])
|
||||
}
|
||||
|
||||
const defaultOpeneds = computed(() => getGroupKeys(props.items))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-menu
|
||||
class="side-nav-menu"
|
||||
:default-active="activePath"
|
||||
:collapse="collapsed"
|
||||
:default-openeds="defaultOpeneds"
|
||||
router
|
||||
>
|
||||
<AdminSidebarMenuNode v-for="item in items" :key="item.key" :node="item" />
|
||||
</el-menu>
|
||||
</template>
|
||||
@@ -0,0 +1,25 @@
|
||||
<script setup lang="ts">
|
||||
import type { AdminNavigationNode } from '@/navigation/adminNavigation'
|
||||
|
||||
defineOptions({ name: 'AdminSidebarMenuNode' })
|
||||
|
||||
defineProps<{
|
||||
node: AdminNavigationNode
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-sub-menu v-if="node.children?.length" :index="node.key">
|
||||
<template #title>
|
||||
<component :is="node.icon" :size="19" :stroke-width="1.8" />
|
||||
<span>{{ node.title }}</span>
|
||||
</template>
|
||||
|
||||
<AdminSidebarMenuNode v-for="child in node.children" :key="child.key" :node="child" />
|
||||
</el-sub-menu>
|
||||
|
||||
<el-menu-item v-else :index="node.path || node.key">
|
||||
<component :is="node.icon" :size="19" :stroke-width="1.8" />
|
||||
<template #title>{{ node.title }}</template>
|
||||
</el-menu-item>
|
||||
</template>
|
||||
@@ -0,0 +1,38 @@
|
||||
<script setup lang="ts">
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Copy } from '@lucide/vue'
|
||||
import type { ApiErrorDetails } from '@/api/http'
|
||||
|
||||
const props = defineProps<{ details: ApiErrorDetails }>()
|
||||
|
||||
async function copyIdentifier(value: string, label: string) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(value)
|
||||
ElMessage.success(`${label} 已复制`)
|
||||
} catch {
|
||||
ElMessage.error(`${label} 复制失败,请手动选择复制`)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="details.code || details.requestId || details.traceId" class="api-error-meta" aria-label="接口错误追踪信息">
|
||||
<span v-if="details.code" class="api-error-code">{{ details.code }}</span>
|
||||
<button v-if="details.requestId" type="button" :title="`复制请求 ID:${props.details.requestId}`" @click="copyIdentifier(props.details.requestId!, '请求 ID')">
|
||||
<span>请求 ID</span><code>{{ props.details.requestId }}</code><Copy :size="13" />
|
||||
</button>
|
||||
<button v-if="details.traceId" type="button" :title="`复制 Trace ID:${props.details.traceId}`" @click="copyIdentifier(props.details.traceId!, 'Trace ID')">
|
||||
<span>Trace ID</span><code>{{ props.details.traceId }}</code><Copy :size="13" />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.api-error-meta { display: flex; min-width: 0; flex-wrap: wrap; align-items: center; gap: 6px; margin-top: 8px; }
|
||||
.api-error-code { padding: 3px 7px; border-radius: 4px; background: #fee4e2; color: #912018; font-size: 10px; font-weight: 750; }
|
||||
.api-error-meta button { display: inline-flex; min-width: 0; max-width: 100%; min-height: 30px; align-items: center; gap: 6px; padding: 4px 7px; border: 1px solid #fecdca; border-radius: 4px; background: #fff; color: #7a271a; cursor: pointer; }
|
||||
.api-error-meta button:hover { background: #fff5f4; }
|
||||
.api-error-meta button span { flex: 0 0 auto; font-size: 10px; font-weight: 700; }
|
||||
.api-error-meta code { min-width: 0; overflow: hidden; color: #b42318; font-family: "Cascadia Code", Consolas, monospace; font-size: 10px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.api-error-meta svg { flex: 0 0 auto; }
|
||||
</style>
|
||||
@@ -0,0 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
items: Array<{ label: string; value: string | number; note?: string }>
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="metric-strip" aria-label="数据摘要">
|
||||
<div v-for="item in items" :key="item.label" class="metric-item">
|
||||
<span>{{ item.label }}</span>
|
||||
<strong>{{ item.value }}</strong>
|
||||
<small v-if="item.note">{{ item.note }}</small>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { Role } from '@/types/admin'
|
||||
import RolePreviewSwitcher from './RolePreviewSwitcher.vue'
|
||||
|
||||
const roles: Role[] = [
|
||||
{ id: 'R001', name: '超级管理员', code: 'SUPER_ADMIN', description: '', userCount: 1, dataScope: '全部数据', status: '启用', permissionIds: [] },
|
||||
{ id: 'R003', name: '操作员', code: 'OPERATOR', description: '', userCount: 2, dataScope: '本部门数据', status: '启用', permissionIds: [] },
|
||||
]
|
||||
|
||||
const stubs = {
|
||||
'el-dropdown': {
|
||||
name: 'ElDropdownStub',
|
||||
emits: ['command'],
|
||||
template: '<section data-dropdown><slot /><slot name="dropdown" /></section>',
|
||||
},
|
||||
'el-dropdown-menu': { template: '<div data-menu><slot /></div>' },
|
||||
'el-dropdown-item': {
|
||||
props: ['command', 'disabled'],
|
||||
template: '<button data-role :data-command="command" :disabled="disabled"><slot /></button>',
|
||||
},
|
||||
}
|
||||
|
||||
describe('RolePreviewSwitcher', () => {
|
||||
it('announces the active role and explains the static preview boundary', () => {
|
||||
const wrapper = mount(RolePreviewSwitcher, {
|
||||
props: { roles, currentRole: roles[0] },
|
||||
global: { stubs },
|
||||
})
|
||||
|
||||
const trigger = wrapper.get('button[aria-label]')
|
||||
expect(trigger.attributes('aria-label')).toBe('权限预览:超级管理员,数据范围未模拟')
|
||||
expect(trigger.attributes('title')).toBe('权限预览:超级管理员,数据范围未模拟')
|
||||
expect(wrapper.text()).toContain('权限预览:超级管理员')
|
||||
expect(wrapper.text()).toContain('仅预览菜单、页面和操作;数据范围未模拟')
|
||||
expect(wrapper.find('svg.lucide-user-round-cog').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('disables the current role and emits a string role id for another selection', async () => {
|
||||
const wrapper = mount(RolePreviewSwitcher, {
|
||||
props: { roles, currentRole: roles[0] },
|
||||
global: { stubs },
|
||||
})
|
||||
|
||||
const items = wrapper.findAll('[data-role]')
|
||||
expect(items[0].attributes('disabled')).toBeDefined()
|
||||
expect(items[1].attributes('disabled')).toBeUndefined()
|
||||
wrapper.getComponent({ name: 'ElDropdownStub' }).vm.$emit('command', 3)
|
||||
|
||||
expect(wrapper.emitted('select')).toEqual([['3']])
|
||||
})
|
||||
|
||||
it('labels the trigger when no enabled role is available', () => {
|
||||
const wrapper = mount(RolePreviewSwitcher, {
|
||||
props: { roles: [], currentRole: null },
|
||||
global: { stubs },
|
||||
})
|
||||
|
||||
expect(wrapper.get('button[aria-label]').attributes('aria-label'))
|
||||
.toBe('权限预览:无可用角色,数据范围未模拟')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { ChevronDown, UserRoundCog } from '@lucide/vue'
|
||||
import type { Role } from '@/types/admin'
|
||||
|
||||
const props = defineProps<{
|
||||
roles: Role[]
|
||||
currentRole: Role | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [roleId: string]
|
||||
}>()
|
||||
|
||||
const roleName = computed(() => props.currentRole?.name ?? '无可用角色')
|
||||
const accessibleLabel = computed(() => `权限预览:${roleName.value},数据范围未模拟`)
|
||||
|
||||
function handleCommand(command: string | number) {
|
||||
emit('select', String(command))
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-dropdown :disabled="roles.length === 0" trigger="click" @command="handleCommand">
|
||||
<button
|
||||
class="role-preview-trigger"
|
||||
type="button"
|
||||
:aria-label="accessibleLabel"
|
||||
:title="accessibleLabel"
|
||||
>
|
||||
<UserRoundCog :size="18" aria-hidden="true" />
|
||||
<span class="role-preview-copy">
|
||||
<strong>权限预览:{{ roleName }}</strong>
|
||||
<small>仅预览菜单、页面和操作;数据范围未模拟</small>
|
||||
</span>
|
||||
<ChevronDown :size="15" aria-hidden="true" />
|
||||
</button>
|
||||
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item
|
||||
v-for="role in roles"
|
||||
:key="role.id"
|
||||
:command="role.id"
|
||||
:disabled="role.id === currentRole?.id"
|
||||
>
|
||||
{{ role.name }}
|
||||
</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</template>
|
||||
@@ -0,0 +1,19 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const source = readFileSync('src/components/StaticBatchImportDialog.vue', 'utf8')
|
||||
|
||||
describe('StaticBatchImportDialog source contract', () => {
|
||||
it('only performs safe client-side file checks before the backend endpoint exists', () => {
|
||||
expect(source).toContain("fileName.endsWith('.xlsx')")
|
||||
expect(source).toContain('10 * 1024 * 1024')
|
||||
expect(source).toContain('前端不会解析工作表内容')
|
||||
expect(source).toContain('等待后端导入接口')
|
||||
})
|
||||
|
||||
it('does not simulate server validation or a successful import', () => {
|
||||
expect(source).not.toContain('demonstratesFailure')
|
||||
expect(source).not.toContain('静态演示')
|
||||
expect(source).not.toContain("emit('imported'")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,118 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { Download, FileCheck2, FileSpreadsheet, Upload, XCircle } from '@lucide/vue'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
entityLabel: string
|
||||
templateUrl: string
|
||||
templateFileName: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
}>()
|
||||
|
||||
type ValidationState = 'idle' | 'pending' | 'ready' | 'invalid'
|
||||
|
||||
const inputRef = ref<HTMLInputElement | null>(null)
|
||||
const selectedFile = ref<File | null>(null)
|
||||
const validationState = ref<ValidationState>('idle')
|
||||
const validationMessage = ref('')
|
||||
const dialogOpen = computed({ get: () => props.modelValue, set: (value) => emit('update:modelValue', value) })
|
||||
|
||||
watch(() => props.modelValue, (open) => {
|
||||
if (open) return
|
||||
selectedFile.value = null
|
||||
validationState.value = 'idle'
|
||||
validationMessage.value = ''
|
||||
})
|
||||
|
||||
function chooseFile() {
|
||||
inputRef.value?.click()
|
||||
}
|
||||
|
||||
function handleFile(event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
selectedFile.value = input.files?.[0] ?? null
|
||||
validationState.value = selectedFile.value ? 'pending' : 'idle'
|
||||
validationMessage.value = ''
|
||||
input.value = ''
|
||||
}
|
||||
|
||||
function validateFile() {
|
||||
const file = selectedFile.value
|
||||
if (!file) return
|
||||
const fileName = file.name.toLowerCase()
|
||||
if (!fileName.endsWith('.xlsx')) {
|
||||
validationMessage.value = '仅支持标准模板 .xlsx 文件'
|
||||
validationState.value = 'invalid'
|
||||
return
|
||||
}
|
||||
if (file.size > 10 * 1024 * 1024) {
|
||||
validationMessage.value = '文件超过上传上限 10MB'
|
||||
validationState.value = 'invalid'
|
||||
return
|
||||
}
|
||||
validationMessage.value = '扩展名和文件大小符合上传要求'
|
||||
validationState.value = 'ready'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-dialog v-model="dialogOpen" :title="`${entityLabel}批量导入`" width="min(680px, 94vw)" class="batch-import-dialog" :close-on-click-modal="false">
|
||||
<div class="import-guidance">
|
||||
<FileSpreadsheet :size="22" />
|
||||
<div><strong>只新增,不更新或覆盖已有档案</strong><span>前端只检查文件类型和大小;模板内容、重复数据及整批事务由后端校验。</span></div>
|
||||
<a :href="templateUrl" :download="templateFileName"><Download :size="15" />下载标准模板</a>
|
||||
</div>
|
||||
|
||||
<input ref="inputRef" class="visually-hidden" type="file" accept=".xlsx" @change="handleFile" />
|
||||
<button type="button" class="import-dropzone" @click="chooseFile">
|
||||
<Upload :size="24" />
|
||||
<strong>{{ selectedFile ? selectedFile.name : '选择 .xlsx 导入文件' }}</strong>
|
||||
<span>{{ selectedFile ? `${(selectedFile.size / 1024).toFixed(1)} KB · 点击重新选择` : '仅支持 .xlsx,最大 10MB' }}</span>
|
||||
</button>
|
||||
|
||||
<div v-if="validationState === 'pending'" class="import-result is-pending">
|
||||
<FileSpreadsheet :size="20" /><div><strong>文件已选择,等待基础检查</strong><span>点击“检查文件”确认扩展名和文件大小;前端不会解析工作表内容。</span></div>
|
||||
</div>
|
||||
<div v-else-if="validationState === 'ready'" class="import-result is-passed" role="status">
|
||||
<FileCheck2 :size="20" /><div><strong>文件基础检查通过</strong><span>{{ validationMessage }}。后端导入接口尚未提供,文件没有上传,也没有写入任何业务数据。</span></div>
|
||||
</div>
|
||||
<div v-else-if="validationState === 'invalid'" class="import-result is-failed" role="alert">
|
||||
<XCircle :size="20" /><div><strong>文件基础检查未通过</strong><span>{{ validationMessage }};当前文件没有上传。</span></div>
|
||||
</div>
|
||||
|
||||
<p class="server-boundary">后端接口接入后,此处将展示上传进度、整批校验结果、逐行错误和错误报告下载;任一行错误时后端必须保证 0 行写入。</p>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="dialogOpen = false">取消</el-button>
|
||||
<el-button :disabled="!selectedFile" @click="validateFile">检查文件</el-button>
|
||||
<el-button type="primary" disabled>等待后端导入接口</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.import-guidance { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 12px; padding: 14px 16px; background: #f6f8fb; color: var(--cc-primary); }
|
||||
.import-guidance > div { display: flex; min-width: 0; flex-direction: column; gap: 3px; }
|
||||
.import-guidance strong { color: #172033; font-size: 13px; }
|
||||
.import-guidance span { color: var(--cc-muted); font-size: 11px; line-height: 1.55; }
|
||||
.import-guidance a { display: inline-flex; align-items: center; gap: 5px; color: var(--cc-primary); font-size: 12px; font-weight: 700; text-decoration: none; }
|
||||
.import-dropzone { display: flex; width: 100%; min-height: 132px; margin-top: 16px; align-items: center; justify-content: center; border: 1px dashed #aab6c8; background: #fff; color: var(--cc-primary); cursor: pointer; flex-direction: column; gap: 8px; }
|
||||
.import-dropzone:hover { border-color: var(--cc-primary); background: #f8faff; }
|
||||
.import-dropzone:focus-visible { outline: 3px solid rgba(21, 94, 239, .2); outline-offset: 2px; }
|
||||
.import-dropzone strong { color: #172033; font-size: 13px; }
|
||||
.import-dropzone span { color: var(--cc-muted); font-size: 11px; }
|
||||
.import-result { display: flex; align-items: center; gap: 10px; margin-top: 14px; padding: 13px 14px; }
|
||||
.import-result > div { display: flex; min-width: 0; flex: 1; flex-direction: column; gap: 3px; }
|
||||
.import-result strong { font-size: 13px; }
|
||||
.import-result span { font-size: 11px; line-height: 1.5; }
|
||||
.import-result.is-pending { background: #f5f7fa; color: #475467; }
|
||||
.import-result.is-passed { background: #ecf8f2; color: #137a4b; }
|
||||
.import-result.is-failed { background: #fff1f1; color: #b42318; }
|
||||
.server-boundary { margin: 14px 0 0; color: var(--cc-muted); font-size: 11px; line-height: 1.65; }
|
||||
.visually-hidden { position: fixed; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); clip-path: inset(50%); white-space: nowrap; }
|
||||
@media (max-width: 600px) { .import-guidance { grid-template-columns: auto 1fr; } .import-guidance a { grid-column: 2; justify-self: start; } }
|
||||
</style>
|
||||
@@ -0,0 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps<{ status: string }>()
|
||||
|
||||
const tone = computed(() => {
|
||||
if (['正常', '启用', '已完成'].includes(props.status)) return 'success'
|
||||
if (['待审核', '草稿', '待处理'].includes(props.status)) return 'warning'
|
||||
if (['已受理', '已订舱', '运输中', '处理中'].includes(props.status)) return 'info'
|
||||
if (['停用', '已取消', '已驳回', '失败'].includes(props.status)) return 'danger'
|
||||
return 'neutral'
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="status-badge" :class="`is-${tone}`"><i aria-hidden="true" />{{ status }}</span>
|
||||
</template>
|
||||
|
||||
@@ -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 '@/composables/useUnsavedWorkRegistration'
|
||||
import { useUnsavedWorkStore } from '@/stores/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 '@/stores/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 }
|
||||
}
|
||||
Vendored
+15
@@ -0,0 +1,15 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
import 'vue-router'
|
||||
|
||||
export {}
|
||||
|
||||
declare module 'vue-router' {
|
||||
interface RouteMeta {
|
||||
title?: string
|
||||
description?: string
|
||||
requiresAuth?: boolean
|
||||
permission?: string
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,323 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { createMemoryHistory, createRouter } from 'vue-router'
|
||||
import { nextTick } from 'vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { useAccessPreviewStore } from '@/stores/accessPreview'
|
||||
import { useAdminDataStore } from '@/stores/adminData'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useUnsavedWorkStore } from '@/stores/unsavedWork'
|
||||
import AppLayout from './AppLayout.vue'
|
||||
|
||||
vi.mock('element-plus', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('element-plus')>()
|
||||
return {
|
||||
...actual,
|
||||
ElMessage: { success: vi.fn(), error: vi.fn() },
|
||||
ElMessageBox: { confirm: vi.fn() },
|
||||
}
|
||||
})
|
||||
|
||||
const page = { template: '<p>page</p>' }
|
||||
|
||||
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),
|
||||
}
|
||||
}
|
||||
|
||||
async function mountLayout(path: string) {
|
||||
const pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
const auth = useAuthStore()
|
||||
auth.token = 'test-access-token'
|
||||
auth.user = { id: '1', name: '平台管理员', account: 'platform_admin', roles: ['PLATFORM_ADMIN'] }
|
||||
localStorage.setItem('cc-admin-token', auth.token)
|
||||
const router = createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [
|
||||
{ path: '/', component: page, meta: { title: '管理后台' } },
|
||||
{ path: '/customers', component: page, meta: { title: '客户管理', permission: 'platform:customer:list' } },
|
||||
{ path: '/warehouses', component: page, meta: { title: '仓库管理', permission: 'platform:warehouse:list' } },
|
||||
{ path: '/orders/first-leg', component: page, meta: { title: '头程订单', permission: 'platform:order:list' } },
|
||||
{ path: '/system/users', component: page, meta: { title: '内部用户', permission: 'platform:user:list' } },
|
||||
{ path: '/403', component: page, meta: { title: '权限不足' } },
|
||||
{ path: '/login', component: page, meta: { title: '登录' } },
|
||||
],
|
||||
})
|
||||
await router.push(path)
|
||||
await router.isReady()
|
||||
|
||||
const wrapper = mount(AppLayout, {
|
||||
attachTo: document.body,
|
||||
global: {
|
||||
plugins: [pinia, router],
|
||||
stubs: {
|
||||
AdminSidebarMenu: { props: ['items'], template: '<nav data-sidebar :data-count="items.length" />' },
|
||||
RolePreviewSwitcher: {
|
||||
props: ['roles', 'currentRole'],
|
||||
emits: ['select'],
|
||||
template: '<div><span data-preview>{{ currentRole?.name }}</span><button data-switch-r003 @click="$emit(\'select\', \'R003\')">switch R003</button><button data-switch-r005 @click="$emit(\'select\', \'R005\')">switch R005</button></div>',
|
||||
},
|
||||
'el-button': { emits: ['click'], template: '<button type="button" @click="$emit(\'click\')"><slot /></button>' },
|
||||
},
|
||||
},
|
||||
})
|
||||
await nextTick()
|
||||
return {
|
||||
wrapper,
|
||||
router,
|
||||
auth,
|
||||
access: useAccessPreviewStore(),
|
||||
data: useAdminDataStore(),
|
||||
unsaved: useUnsavedWorkStore(),
|
||||
}
|
||||
}
|
||||
|
||||
describe('AppLayout role preview coordination', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('localStorage', createMemoryStorage())
|
||||
vi.mocked(ElMessageBox.confirm).mockReset()
|
||||
vi.mocked(ElMessage.success).mockReset()
|
||||
vi.mocked(ElMessage.error).mockReset()
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
it('keeps the login identity visibly separate from the preview role', async () => {
|
||||
const { wrapper } = await mountLayout('/')
|
||||
|
||||
expect(wrapper.get('[aria-label="登录身份:平台管理员(platform_admin)"]').text())
|
||||
.toBe('登录身份:平台管理员(platform_admin)')
|
||||
expect(wrapper.get('[data-preview]').text()).toBe('超级管理员')
|
||||
})
|
||||
|
||||
it('commits a role and replaces an inaccessible system route with its first leaf', async () => {
|
||||
const { wrapper, router, access } = await mountLayout('/system/users')
|
||||
const replace = vi.spyOn(router, 'replace')
|
||||
|
||||
await wrapper.get('[data-switch-r003]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(access.currentRole?.id).toBe('R003')
|
||||
expect(replace).toHaveBeenCalledWith('/warehouses')
|
||||
expect(router.currentRoute.value.path).toBe('/warehouses')
|
||||
expect(wrapper.get('[aria-live="polite"]').text()).toContain('操作员')
|
||||
expect(wrapper.get('[aria-live="polite"]').text()).toContain('仓库管理')
|
||||
expect(ElMessage.success).toHaveBeenCalledWith('已切换至“操作员”,当前仅为静态权限预览')
|
||||
expect(document.activeElement).toBe(wrapper.get('h1').element)
|
||||
expect(wrapper.get('[data-sidebar]').attributes('data-count'))
|
||||
.toBe(String(access.filteredNavigation.length))
|
||||
expect(access.filteredNavigation).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('recovers from 403 to the first accessible leaf after a role change', async () => {
|
||||
const { wrapper, router, access } = await mountLayout('/403')
|
||||
const replace = vi.spyOn(router, 'replace')
|
||||
|
||||
await wrapper.get('[data-switch-r003]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(access.currentRole?.id).toBe('R003')
|
||||
expect(replace).toHaveBeenCalledWith('/warehouses')
|
||||
})
|
||||
|
||||
it('keeps a queried 403 route when the candidate role has no accessible page', async () => {
|
||||
const { wrapper, router, access, data } = await mountLayout('/403?from=/system/users')
|
||||
data.roles.find((role) => role.id === 'R003')!.permissionIds = []
|
||||
const replace = vi.spyOn(router, 'replace')
|
||||
|
||||
await wrapper.get('[data-switch-r003]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(access.currentRole?.id).toBe('R003')
|
||||
expect(router.currentRoute.value.fullPath).toBe('/403?from=/system/users')
|
||||
expect(replace).not.toHaveBeenCalled()
|
||||
expect(wrapper.get('[aria-live="polite"]').text()).toBe('权限预览已切换为操作员')
|
||||
})
|
||||
|
||||
it('keeps role, route and dirty entry unchanged when role switching is cancelled', async () => {
|
||||
vi.mocked(ElMessageBox.confirm).mockRejectedValueOnce('cancel')
|
||||
const { wrapper, router, access, unsaved } = await mountLayout('/system/users')
|
||||
unsaved.upsert({ key: 'user-form', label: '内部用户编辑表单', dirty: true, requiredPermission: 'system:user:write' })
|
||||
|
||||
await wrapper.get('[data-switch-r003]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(ElMessageBox.confirm).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/切换到操作员.*将离开内部用户.*内部用户编辑表单/),
|
||||
expect.any(String),
|
||||
expect.any(Object),
|
||||
)
|
||||
expect(access.currentRole?.id).toBe('R001')
|
||||
expect(router.currentRoute.value.path).toBe('/system/users')
|
||||
expect(unsaved.entries).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('warns that same-page forms will lose save permission without claiming navigation', async () => {
|
||||
vi.mocked(ElMessageBox.confirm).mockRejectedValueOnce('cancel')
|
||||
const { wrapper, router, access, unsaved } = await mountLayout('/orders/first-leg')
|
||||
unsaved.upsert({ key: 'restricted-form', label: '受限订单表单', dirty: true, requiredPermission: 'system:user:write' })
|
||||
|
||||
await wrapper.get('[data-switch-r003]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
const message = String(vi.mocked(ElMessageBox.confirm).mock.calls[0]?.[0])
|
||||
expect(message).toContain('切换到操作员')
|
||||
expect(message).toContain('头程订单')
|
||||
expect(message).toContain('将失去保存权限')
|
||||
expect(message).toContain('受限订单表单')
|
||||
expect(message).not.toContain('将离开头程订单')
|
||||
expect(access.currentRole?.id).toBe('R001')
|
||||
expect(router.currentRoute.value.path).toBe('/orders/first-leg')
|
||||
})
|
||||
|
||||
it('commits only after dirty-work confirmation succeeds', async () => {
|
||||
let resolveConfirmation: (() => void) | undefined
|
||||
vi.mocked(ElMessageBox.confirm).mockReturnValueOnce(new Promise((resolve) => {
|
||||
resolveConfirmation = () => resolve({} as never)
|
||||
}))
|
||||
const { wrapper, access, unsaved } = await mountLayout('/system/users')
|
||||
unsaved.upsert({ key: 'user-form', label: '内部用户编辑表单', dirty: true, requiredPermission: 'system:user:write' })
|
||||
|
||||
await wrapper.get('[data-switch-r003]').trigger('click')
|
||||
await nextTick()
|
||||
expect(access.currentRole?.id).toBe('R001')
|
||||
|
||||
resolveConfirmation?.()
|
||||
await flushPromises()
|
||||
expect(access.currentRole?.id).toBe('R003')
|
||||
})
|
||||
|
||||
it('ignores another role selection while the first confirmation is pending', async () => {
|
||||
let resolveConfirmation: (() => void) | undefined
|
||||
vi.mocked(ElMessageBox.confirm).mockReturnValueOnce(new Promise((resolve) => {
|
||||
resolveConfirmation = () => resolve({} as never)
|
||||
}))
|
||||
const { wrapper, access, unsaved } = await mountLayout('/system/users')
|
||||
unsaved.upsert({ key: 'user-form', label: '内部用户编辑表单', dirty: true, requiredPermission: 'system:user:write' })
|
||||
|
||||
await wrapper.get('[data-switch-r003]').trigger('click')
|
||||
await wrapper.get('[data-switch-r005]').trigger('click')
|
||||
await nextTick()
|
||||
|
||||
expect(ElMessageBox.confirm).toHaveBeenCalledTimes(1)
|
||||
expect(access.currentRole?.id).toBe('R001')
|
||||
resolveConfirmation?.()
|
||||
await flushPromises()
|
||||
expect(access.currentRole?.id).toBe('R003')
|
||||
})
|
||||
|
||||
it('reconfirms when a new blocker appears while the first confirmation is pending', async () => {
|
||||
let resolveFirst: (() => void) | undefined
|
||||
let resolveSecond: (() => void) | undefined
|
||||
vi.mocked(ElMessageBox.confirm)
|
||||
.mockReturnValueOnce(new Promise((resolve) => { resolveFirst = () => resolve({} as never) }))
|
||||
.mockReturnValueOnce(new Promise((resolve) => { resolveSecond = () => resolve({} as never) }))
|
||||
const { wrapper, access, unsaved } = await mountLayout('/orders/first-leg')
|
||||
unsaved.upsert({ key: 'first-form', label: '第一张表单', dirty: true, requiredPermission: 'system:user:write' })
|
||||
|
||||
await wrapper.get('[data-switch-r003]').trigger('click')
|
||||
await nextTick()
|
||||
unsaved.upsert({ key: 'second-form', label: '新增表单', dirty: true, requiredPermission: 'customer:create' })
|
||||
resolveFirst?.()
|
||||
await flushPromises()
|
||||
|
||||
expect(ElMessageBox.confirm).toHaveBeenCalledTimes(2)
|
||||
expect(String(vi.mocked(ElMessageBox.confirm).mock.calls[1]?.[0])).toContain('新增表单')
|
||||
expect(access.currentRole?.id).toBe('R001')
|
||||
resolveSecond?.()
|
||||
await flushPromises()
|
||||
expect(access.currentRole?.id).toBe('R003')
|
||||
})
|
||||
|
||||
it('replaces the route when live permission changes make the current page inaccessible', async () => {
|
||||
const { router, access, data } = await mountLayout('/system/users')
|
||||
const replace = vi.spyOn(router, 'replace')
|
||||
const permission = data.permissions.find((item) => item.code === 'platform:user:list')
|
||||
if (!permission) throw new Error('fixture permission missing')
|
||||
|
||||
permission.status = '停用'
|
||||
await nextTick()
|
||||
await flushPromises()
|
||||
|
||||
expect(access.currentRole?.id).toBe('R001')
|
||||
expect(replace).toHaveBeenCalledWith('/customers')
|
||||
expect(document.activeElement).toBe(document.querySelector('h1'))
|
||||
expect(document.querySelector('[aria-live="polite"]')?.textContent)
|
||||
.toBe('当前页面权限已变化,已前往客户管理')
|
||||
})
|
||||
|
||||
it('does not replace or announce navigation when live access remains on queried 403', async () => {
|
||||
const { router, data } = await mountLayout('/403?from=/system/users')
|
||||
const replace = vi.spyOn(router, 'replace')
|
||||
|
||||
data.permissions.forEach((permission) => { permission.status = '停用' })
|
||||
await nextTick()
|
||||
await flushPromises()
|
||||
|
||||
expect(router.currentRoute.value.fullPath).toBe('/403?from=/system/users')
|
||||
expect(replace).not.toHaveBeenCalled()
|
||||
expect(document.querySelector('[aria-live="polite"]')?.textContent).toBe('')
|
||||
})
|
||||
|
||||
it('rolls back the role and reports an error when correction navigation fails', async () => {
|
||||
const { wrapper, router, access } = await mountLayout('/system/users')
|
||||
vi.spyOn(router, 'replace').mockRejectedValueOnce(new Error('navigation failed'))
|
||||
|
||||
await wrapper.get('[data-switch-r003]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(access.currentRole?.id).toBe('R001')
|
||||
expect(router.currentRoute.value.path).toBe('/system/users')
|
||||
expect(ElMessage.error).toHaveBeenCalledWith('权限预览切换失败,已恢复原角色')
|
||||
expect(ElMessage.success).not.toHaveBeenCalled()
|
||||
|
||||
await wrapper.get('[data-switch-r003]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(access.currentRole?.id).toBe('R003')
|
||||
})
|
||||
|
||||
it('clears preview and unsaved state before logging out and replacing with login', async () => {
|
||||
vi.mocked(ElMessageBox.confirm).mockResolvedValueOnce({} as never)
|
||||
const { wrapper, router, auth, access, unsaved } = await mountLayout('/')
|
||||
unsaved.upsert({ key: 'draft', label: '草稿', dirty: true, requiredPermission: null })
|
||||
const calls: string[] = []
|
||||
const resetAccess = access.reset
|
||||
const clearUnsaved = unsaved.clear
|
||||
const logout = auth.logout
|
||||
vi.spyOn(access, 'reset').mockImplementation(() => { calls.push('access'); resetAccess() })
|
||||
vi.spyOn(unsaved, 'clear').mockImplementation(() => { calls.push('unsaved'); clearUnsaved() })
|
||||
vi.spyOn(auth, 'logout').mockImplementation(() => { calls.push('auth'); logout() })
|
||||
const replace = vi.spyOn(router, 'replace')
|
||||
|
||||
await wrapper.get('[aria-label="退出登录"]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(calls).toEqual(['access', 'unsaved', 'auth'])
|
||||
expect(replace).toHaveBeenCalledWith('/login')
|
||||
})
|
||||
|
||||
it('keeps cleared logout state and reports a login navigation failure', async () => {
|
||||
vi.mocked(ElMessageBox.confirm).mockResolvedValueOnce({} as never)
|
||||
const { wrapper, router, auth, access, unsaved } = await mountLayout('/')
|
||||
unsaved.upsert({ key: 'draft', label: '草稿', dirty: true, requiredPermission: null })
|
||||
vi.spyOn(router, 'replace').mockRejectedValueOnce(new Error('navigation failed'))
|
||||
|
||||
await wrapper.get('[aria-label="退出登录"]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(auth.isAuthenticated).toBe(false)
|
||||
expect(access.currentRole).toBeNull()
|
||||
expect(unsaved.entries).toHaveLength(0)
|
||||
expect(ElMessage.error).toHaveBeenCalledWith('已退出登录,但登录页跳转失败,请刷新页面')
|
||||
expect(router.currentRoute.value.path).toBe('/')
|
||||
expect(router.replace).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,265 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
LogOut,
|
||||
Menu,
|
||||
} from '@lucide/vue'
|
||||
import { PRODUCT_NAME } from '@chongchong/shared'
|
||||
import AdminSidebarMenu from '@/components/AdminSidebarMenu.vue'
|
||||
import RolePreviewSwitcher from '@/components/RolePreviewSwitcher.vue'
|
||||
import { resolvePreviewTarget } from '@/router/accessDecision'
|
||||
import { useAccessPreviewStore } from '@/stores/accessPreview'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useUnsavedWorkStore } from '@/stores/unsavedWork'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
const access = useAccessPreviewStore()
|
||||
const unsaved = useUnsavedWorkStore()
|
||||
const collapsed = ref(false)
|
||||
const mobileMenuOpen = ref(false)
|
||||
const correctingRoute = ref(false)
|
||||
const roleSwitchPending = ref(false)
|
||||
const loggingOut = ref(false)
|
||||
const pageHeading = ref<HTMLHeadingElement | null>(null)
|
||||
const announcement = ref('')
|
||||
|
||||
access.initializeAfterLogin()
|
||||
|
||||
const menuCollapsed = computed(() => collapsed.value && !mobileMenuOpen.value)
|
||||
const pageTitle = computed(() => route.meta.title || '管理后台')
|
||||
const pageDescription = computed(() => route.meta.description || '')
|
||||
|
||||
function currentPermission(): string | undefined {
|
||||
return typeof route.meta.permission === 'string' ? route.meta.permission : undefined
|
||||
}
|
||||
|
||||
async function replaceAndFocus(path: string) {
|
||||
await router.replace(path)
|
||||
await nextTick()
|
||||
pageHeading.value?.focus()
|
||||
}
|
||||
|
||||
function getRoleSwitchDecision(roleId: string) {
|
||||
const candidateRole = access.enabledRoles.find((role) => role.id === roleId)
|
||||
if (!candidateRole) return null
|
||||
const candidate = access.previewRole(roleId)
|
||||
const target = resolvePreviewTarget({
|
||||
currentPath: route.path,
|
||||
currentPermission: currentPermission(),
|
||||
candidateCodes: candidate.codes,
|
||||
firstPath: candidate.firstPath,
|
||||
})
|
||||
const willLeave = target !== null && target !== route.path
|
||||
const blockers = unsaved.findBlocking(candidate.codes, willLeave)
|
||||
const blockerSignature = blockers
|
||||
.map((entry) => `${entry.key}:${entry.label}:${entry.requiredPermission ?? ''}`)
|
||||
.sort()
|
||||
.join('|')
|
||||
|
||||
return {
|
||||
candidateRole,
|
||||
target,
|
||||
willLeave,
|
||||
blockers,
|
||||
signature: `${target ?? ''}:${willLeave}:${blockerSignature}`,
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmRoleSwitch(decision: NonNullable<ReturnType<typeof getRoleSwitchDecision>>) {
|
||||
const labels = decision.blockers.map((entry) => entry.label).join('、')
|
||||
const currentPage = typeof route.meta.title === 'string' ? route.meta.title : '当前页面'
|
||||
const message = decision.willLeave
|
||||
? `切换到${decision.candidateRole.name}后,将离开${currentPage},并放弃以下未保存内容:${labels}。是否继续?`
|
||||
: `切换到${decision.candidateRole.name}后,${currentPage}中的以下表单将失去保存权限:${labels}。是否继续?`
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
message,
|
||||
'未保存内容',
|
||||
{ confirmButtonText: '放弃并切换', cancelButtonText: '取消', type: 'warning' },
|
||||
)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function requestRoleSwitch(roleId: string) {
|
||||
if (access.currentRole?.id === roleId || roleSwitchPending.value) return
|
||||
roleSwitchPending.value = true
|
||||
|
||||
try {
|
||||
let decision: NonNullable<ReturnType<typeof getRoleSwitchDecision>>
|
||||
let confirmedSignature: string | null = null
|
||||
|
||||
while (true) {
|
||||
const refreshedDecision = getRoleSwitchDecision(roleId)
|
||||
if (!refreshedDecision) return
|
||||
decision = refreshedDecision
|
||||
|
||||
if (decision.blockers.length && decision.signature !== confirmedSignature) {
|
||||
if (!await confirmRoleSwitch(decision)) return
|
||||
confirmedSignature = decision.signature
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
correctingRoute.value = true
|
||||
const currentPage = typeof route.meta.title === 'string' ? route.meta.title : '当前页面'
|
||||
const previousRoleId = access.currentRole?.id ?? null
|
||||
try {
|
||||
if (!access.commitRole(roleId)) return
|
||||
const navigated = Boolean(decision.target && decision.target !== route.path)
|
||||
if (navigated && decision.target) {
|
||||
try {
|
||||
await replaceAndFocus(decision.target)
|
||||
} catch {
|
||||
const rolledBack = previousRoleId ? access.commitRole(previousRoleId) : false
|
||||
if (rolledBack) {
|
||||
ElMessage.error('权限预览切换失败,已恢复原角色')
|
||||
return
|
||||
}
|
||||
|
||||
const reconciliationTarget = resolvePreviewTarget({
|
||||
currentPath: route.path,
|
||||
currentPermission: currentPermission(),
|
||||
candidateCodes: access.permissionCodes,
|
||||
firstPath: access.firstAccessiblePath,
|
||||
})
|
||||
if (reconciliationTarget && reconciliationTarget !== route.path) {
|
||||
try {
|
||||
await replaceAndFocus(reconciliationTarget)
|
||||
} catch {
|
||||
// 保留当前路由,错误提示要求用户刷新以重新进入一致状态。
|
||||
}
|
||||
}
|
||||
ElMessage.error('权限预览切换失败,当前权限状态可能已变化,请刷新页面')
|
||||
return
|
||||
}
|
||||
}
|
||||
const roleName = access.currentRole?.name ?? decision.candidateRole.name
|
||||
announcement.value = navigated
|
||||
? `权限预览已切换为${roleName},当前页面:${String(route.meta.title ?? currentPage)}`
|
||||
: `权限预览已切换为${roleName}`
|
||||
ElMessage.success(`已切换至“${roleName}”,当前仅为静态权限预览`)
|
||||
} finally {
|
||||
correctingRoute.value = false
|
||||
}
|
||||
} finally {
|
||||
roleSwitchPending.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLogout() {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定退出管理后台吗?', '退出登录', {
|
||||
confirmButtonText: '退出',
|
||||
cancelButtonText: '取消',
|
||||
})
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
loggingOut.value = true
|
||||
access.reset()
|
||||
unsaved.clear()
|
||||
auth.logout()
|
||||
try {
|
||||
await router.replace('/login')
|
||||
} catch {
|
||||
ElMessage.error('已退出登录,但登录页跳转失败,请刷新页面')
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => route.fullPath, () => { mobileMenuOpen.value = false })
|
||||
watch(
|
||||
() => `${access.currentRole?.id ?? ''}:${access.permissionSignature}`,
|
||||
async () => {
|
||||
if (correctingRoute.value || loggingOut.value) return
|
||||
const target = resolvePreviewTarget({
|
||||
currentPath: route.path,
|
||||
currentPermission: currentPermission(),
|
||||
candidateCodes: access.permissionCodes,
|
||||
firstPath: access.firstAccessiblePath,
|
||||
})
|
||||
if (!target || target === route.path) return
|
||||
|
||||
correctingRoute.value = true
|
||||
try {
|
||||
await replaceAndFocus(target)
|
||||
const targetTitle = String(route.meta.title ?? '可访问页面')
|
||||
announcement.value = target === '/403'
|
||||
? `当前权限预览角色没有可访问页面,已前往${targetTitle}`
|
||||
: `当前页面权限已变化,已前往${targetTitle}`
|
||||
} finally {
|
||||
correctingRoute.value = false
|
||||
}
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="app-shell" :class="{ 'is-collapsed': collapsed, 'is-mobile-open': mobileMenuOpen }">
|
||||
<button v-if="mobileMenuOpen" class="sidebar-scrim" type="button" aria-label="关闭菜单" @click="mobileMenuOpen = false" />
|
||||
<aside class="app-sidebar">
|
||||
<div class="brand-block">
|
||||
<div class="brand-mark">CC</div>
|
||||
<div v-if="!collapsed" class="brand-copy">
|
||||
<strong>{{ PRODUCT_NAME }}</strong>
|
||||
<span>管理后台</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav class="side-nav" aria-label="主导航">
|
||||
<AdminSidebarMenu :items="access.filteredNavigation" :active-path="route.path" :collapsed="menuCollapsed" />
|
||||
</nav>
|
||||
|
||||
<button class="sidebar-toggle" type="button" @click="collapsed = !collapsed">
|
||||
<ChevronRight v-if="collapsed" :size="18" />
|
||||
<ChevronLeft v-else :size="18" />
|
||||
<span v-if="!collapsed">收起导航</span>
|
||||
</button>
|
||||
</aside>
|
||||
|
||||
<div class="app-main">
|
||||
<header class="app-header">
|
||||
<button class="mobile-menu-button" type="button" aria-label="打开菜单" :aria-expanded="mobileMenuOpen" @click="mobileMenuOpen = true">
|
||||
<Menu :size="20" />
|
||||
</button>
|
||||
<div class="page-heading">
|
||||
<h1 ref="pageHeading" tabindex="-1">{{ pageTitle }}</h1>
|
||||
<p v-if="pageDescription">{{ pageDescription }}</p>
|
||||
</div>
|
||||
<RolePreviewSwitcher
|
||||
v-if="!access.usesServerPermissions"
|
||||
:roles="access.enabledRoles"
|
||||
:current-role="access.currentRole"
|
||||
@select="requestRoleSwitch"
|
||||
/>
|
||||
<div class="header-user">
|
||||
<div class="user-avatar">{{ auth.user?.name.slice(0, 1) }}</div>
|
||||
<div
|
||||
class="user-copy"
|
||||
:aria-label="`登录身份:${auth.user?.name ?? '-'}(${auth.user?.account ?? '-'})`"
|
||||
>
|
||||
<strong>登录身份:{{ auth.user?.name }}({{ auth.user?.account }})</strong>
|
||||
</div>
|
||||
<el-button text aria-label="退出登录" @click="handleLogout">
|
||||
<LogOut :size="18" />
|
||||
</el-button>
|
||||
</div>
|
||||
<p class="sr-only" aria-live="polite" aria-atomic="true">{{ announcement }}</p>
|
||||
</header>
|
||||
|
||||
<main class="page-container">
|
||||
<RouterView />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,9 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import 'element-plus/es/components/message/style/css'
|
||||
import 'element-plus/es/components/message-box/style/css'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import './styles/index.css'
|
||||
|
||||
createApp(App).use(createPinia()).use(router).mount('#app')
|
||||
@@ -0,0 +1,111 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
adminNavigation,
|
||||
filterAdminNavigation,
|
||||
getFirstNavigationLeaf,
|
||||
getNavigationLeaves,
|
||||
type AdminNavigationNode,
|
||||
} from './adminNavigation'
|
||||
|
||||
describe('adminNavigation', () => {
|
||||
it('keeps phase-one pages grouped without exposing the deferred dashboard', () => {
|
||||
expect(adminNavigation.map((item) => item.title)).toEqual([
|
||||
'业务管理',
|
||||
'基础资料',
|
||||
'系统管理',
|
||||
])
|
||||
expect(adminNavigation[0].children?.map((item) => item.title)).toEqual([
|
||||
'客户管理',
|
||||
'企业审核',
|
||||
'仓库管理',
|
||||
'头程订单',
|
||||
])
|
||||
expect(adminNavigation[1].children?.map((item) => item.title)).toEqual(['快递公司', '往来单位'])
|
||||
expect(adminNavigation[2].children?.map((item) => item.title)).toEqual([
|
||||
'内部用户',
|
||||
'角色管理',
|
||||
'菜单权限',
|
||||
])
|
||||
})
|
||||
|
||||
it('assigns every page a unique path and permission code', () => {
|
||||
const leaves = getNavigationLeaves(adminNavigation)
|
||||
const paths = leaves.map((item) => item.path)
|
||||
const permissions = leaves.map((item) => item.permission)
|
||||
|
||||
expect(paths).toEqual([
|
||||
'/customers',
|
||||
'/customers/reviews',
|
||||
'/warehouses',
|
||||
'/orders/first-leg',
|
||||
'/master-data/express-companies',
|
||||
'/master-data/counterparties',
|
||||
'/system/users',
|
||||
'/system/roles',
|
||||
'/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('recursively filters leaves 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']))
|
||||
|
||||
expect(getNavigationLeaves(filtered).map((item) => item.path)).toEqual(['/system/permissions'])
|
||||
expect(filtered).not.toBe(adminNavigation)
|
||||
expect(filtered[0]).not.toBe(adminNavigation[2])
|
||||
expect(JSON.stringify(adminNavigation, (key, value) => key === 'icon' ? '[icon]' : value)).toBe(sourceSnapshot)
|
||||
})
|
||||
|
||||
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,89 @@
|
||||
import type { Component } from 'vue'
|
||||
import {
|
||||
BriefcaseBusiness,
|
||||
Building2,
|
||||
ContactRound,
|
||||
ClipboardList,
|
||||
Database,
|
||||
FileCheck2,
|
||||
KeyRound,
|
||||
Settings,
|
||||
ShieldCheck,
|
||||
Truck,
|
||||
Users,
|
||||
Warehouse as WarehouseIcon,
|
||||
} from '@lucide/vue'
|
||||
|
||||
export interface AdminNavigationNode {
|
||||
key: string
|
||||
title: string
|
||||
icon: Component
|
||||
path?: string
|
||||
permission?: string
|
||||
children?: AdminNavigationNode[]
|
||||
}
|
||||
|
||||
export interface AdminNavigationLeaf extends AdminNavigationNode {
|
||||
path: string
|
||||
permission: string
|
||||
children?: never
|
||||
}
|
||||
|
||||
export const adminNavigation: AdminNavigationNode[] = [
|
||||
{
|
||||
key: 'business',
|
||||
title: '业务管理',
|
||||
icon: BriefcaseBusiness,
|
||||
children: [
|
||||
{ key: 'customers', title: '客户管理', icon: Building2, path: '/customers', permission: 'platform:customer:list' },
|
||||
{ key: 'enterprise-reviews', title: '企业审核', icon: FileCheck2, path: '/customers/reviews', permission: 'platform:customer:review' },
|
||||
{ key: 'warehouses', title: '仓库管理', icon: WarehouseIcon, path: '/warehouses', permission: 'platform:warehouse:list' },
|
||||
{ key: 'first-leg-orders', title: '头程订单', icon: ClipboardList, path: '/orders/first-leg', permission: 'platform:order:list' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'master-data',
|
||||
title: '基础资料',
|
||||
icon: Database,
|
||||
children: [
|
||||
{ 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: 'system',
|
||||
title: '系统管理',
|
||||
icon: Settings,
|
||||
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' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
export function getNavigationLeaves(nodes: AdminNavigationNode[]): AdminNavigationLeaf[] {
|
||||
return nodes.flatMap((node) => {
|
||||
if (node.children?.length) return getNavigationLeaves(node.children)
|
||||
if (!node.path || !node.permission) return []
|
||||
return [node as AdminNavigationLeaf]
|
||||
})
|
||||
}
|
||||
|
||||
export function filterAdminNavigation(
|
||||
nodes: AdminNavigationNode[],
|
||||
permissionCodes: ReadonlySet<string>,
|
||||
): AdminNavigationNode[] {
|
||||
return nodes.flatMap((node) => {
|
||||
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,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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
Vendored
+10
@@ -0,0 +1,10 @@
|
||||
import 'vue-router'
|
||||
|
||||
declare module 'vue-router' {
|
||||
interface RouteMeta {
|
||||
title?: string
|
||||
description?: string
|
||||
requiresAuth?: boolean
|
||||
permission?: string
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
import { beforeEach, describe, expect, expectTypeOf, it, vi } from 'vitest'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { nextTick } from 'vue'
|
||||
import { getNavigationLeaves } from '@/navigation/adminNavigation'
|
||||
import { useAdminDataStore } from '@/stores/adminData'
|
||||
import { PREVIEW_ROLE_KEY, useAccessPreviewStore } from '@/stores/accessPreview'
|
||||
|
||||
function createMemoryStorage(): Storage {
|
||||
const values = new Map<string, string>()
|
||||
|
||||
return {
|
||||
get length() {
|
||||
return values.size
|
||||
},
|
||||
clear() {
|
||||
values.clear()
|
||||
},
|
||||
getItem(key) {
|
||||
return values.get(key) ?? null
|
||||
},
|
||||
key(index) {
|
||||
return [...values.keys()][index] ?? null
|
||||
},
|
||||
removeItem(key) {
|
||||
values.delete(key)
|
||||
},
|
||||
setItem(key, value) {
|
||||
values.set(key, value)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe('access preview store', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.stubGlobal('localStorage', createMemoryStorage())
|
||||
})
|
||||
|
||||
it('defaults to the enabled super admin with all enabled permissions', () => {
|
||||
const preview = useAccessPreviewStore()
|
||||
|
||||
preview.initializeAfterLogin()
|
||||
|
||||
expect(preview.enabledRoles).toHaveLength(5)
|
||||
expect(preview.currentRole?.code).toBe('SUPER_ADMIN')
|
||||
expect(preview.can('system:permission:write')).toBe(true)
|
||||
expect(preview.firstAccessiblePath).toBe('/customers')
|
||||
expect(localStorage.getItem(PREVIEW_ROLE_KEY)).toBe('R001')
|
||||
})
|
||||
|
||||
it('ignores disabled permission resources even when the selected role still holds their ids', () => {
|
||||
const data = useAdminDataStore()
|
||||
const preview = useAccessPreviewStore()
|
||||
preview.initializeAfterLogin()
|
||||
const customerList = data.permissions.find((item) => item.code === 'platform:customer:list')
|
||||
expect(customerList).toBeDefined()
|
||||
|
||||
if (customerList) customerList.status = '停用'
|
||||
|
||||
expect(preview.can('platform:customer:list')).toBe(false)
|
||||
expect(getNavigationLeaves(preview.filteredNavigation).map((item) => item.path)).not.toContain('/customers')
|
||||
expect(preview.firstAccessiblePath).toBe('/customers/reviews')
|
||||
})
|
||||
|
||||
it('does not cascade a disabled directory to enabled descendant leaves', () => {
|
||||
const data = useAdminDataStore()
|
||||
const preview = useAccessPreviewStore()
|
||||
preview.initializeAfterLogin()
|
||||
const businessDirectory = data.permissions.find((item) => item.code === 'business')
|
||||
expect(businessDirectory).toBeDefined()
|
||||
|
||||
if (businessDirectory) businessDirectory.status = '停用'
|
||||
|
||||
expect(preview.can('business')).toBe(false)
|
||||
expect(preview.can('platform:customer:list')).toBe(true)
|
||||
expect(getNavigationLeaves(preview.filteredNavigation).map((item) => item.path)).toContain('/customers')
|
||||
})
|
||||
|
||||
it('reacts immediately to permission id changes and falls back when the current role is disabled', async () => {
|
||||
const data = useAdminDataStore()
|
||||
const preview = useAccessPreviewStore()
|
||||
preview.initializeAfterLogin()
|
||||
expect(preview.commitRole('R003')).toBe(true)
|
||||
const operator = data.roles.find((item) => item.id === 'R003')
|
||||
expect(operator).toBeDefined()
|
||||
|
||||
if (operator) operator.permissionIds = ['P100']
|
||||
expect(preview.can('first-leg:list')).toBe(false)
|
||||
expect(preview.can('platform:customer:list')).toBe(true)
|
||||
expect(preview.permissionSignature).toBe('platform:customer:list')
|
||||
|
||||
if (operator) operator.status = '停用'
|
||||
await nextTick()
|
||||
|
||||
expect(preview.currentRole?.code).toBe('SUPER_ADMIN')
|
||||
expect(localStorage.getItem(PREVIEW_ROLE_KEY)).toBe('R001')
|
||||
})
|
||||
|
||||
it('falls back from an invalid persisted role and reset clears selection and persistence', () => {
|
||||
localStorage.setItem(PREVIEW_ROLE_KEY, 'missing-role')
|
||||
const preview = useAccessPreviewStore()
|
||||
|
||||
preview.initializeAfterLogin()
|
||||
expect(preview.currentRole?.code).toBe('SUPER_ADMIN')
|
||||
|
||||
preview.reset()
|
||||
expect(preview.initialized).toBe(false)
|
||||
expect(preview.currentRole).toBeNull()
|
||||
expect(localStorage.getItem(PREVIEW_ROLE_KEY)).toBeNull()
|
||||
})
|
||||
|
||||
it('restores an enabled persisted role', () => {
|
||||
localStorage.setItem(PREVIEW_ROLE_KEY, 'R003')
|
||||
const preview = useAccessPreviewStore()
|
||||
|
||||
preview.initializeAfterLogin()
|
||||
|
||||
expect(preview.currentRole?.id).toBe('R003')
|
||||
expect(localStorage.getItem(PREVIEW_ROLE_KEY)).toBe('R003')
|
||||
})
|
||||
|
||||
it('does not reload an initialized role during guarded navigation', () => {
|
||||
const preview = useAccessPreviewStore()
|
||||
preview.initializeAfterLogin()
|
||||
expect(preview.commitRole('R003')).toBe(true)
|
||||
|
||||
localStorage.setItem(PREVIEW_ROLE_KEY, 'R001')
|
||||
preview.initializeAfterLogin()
|
||||
|
||||
expect(preview.currentRole?.id).toBe('R003')
|
||||
})
|
||||
|
||||
it('has no role, permissions, or route when every role is disabled', () => {
|
||||
const data = useAdminDataStore()
|
||||
data.roles.forEach((role) => {
|
||||
role.status = '停用'
|
||||
})
|
||||
const preview = useAccessPreviewStore()
|
||||
|
||||
preview.initializeAfterLogin()
|
||||
|
||||
expect(preview.currentRole).toBeNull()
|
||||
expect([...preview.permissionCodes]).toEqual([])
|
||||
expect(preview.filteredNavigation).toEqual([])
|
||||
expect(preview.firstAccessiblePath).toBeNull()
|
||||
expect(localStorage.getItem(PREVIEW_ROLE_KEY)).toBeNull()
|
||||
})
|
||||
|
||||
it('does not recreate a default preview after reset when role data changes', async () => {
|
||||
const data = useAdminDataStore()
|
||||
const preview = useAccessPreviewStore()
|
||||
preview.initializeAfterLogin()
|
||||
preview.reset()
|
||||
|
||||
data.roles[0].status = '停用'
|
||||
data.roles[1].status = '停用'
|
||||
await nextTick()
|
||||
|
||||
expect(preview.initialized).toBe(false)
|
||||
expect(preview.selectedRoleId).toBeNull()
|
||||
expect(preview.currentRole).toBeNull()
|
||||
expect(localStorage.getItem(PREVIEW_ROLE_KEY)).toBeNull()
|
||||
})
|
||||
|
||||
it('previews candidates without committing and rejects disabled roles', () => {
|
||||
const data = useAdminDataStore()
|
||||
const preview = useAccessPreviewStore()
|
||||
preview.initializeAfterLogin()
|
||||
const candidate = preview.previewRole('R003')
|
||||
|
||||
expect(candidate.codes.has('first-leg:create')).toBe(true)
|
||||
expect(candidate.firstPath).toBe('/warehouses')
|
||||
expect(getNavigationLeaves(candidate.navigation).map((item) => item.path)).toContain('/orders/first-leg')
|
||||
expectTypeOf(candidate.codes).toEqualTypeOf<ReadonlySet<string>>()
|
||||
expectTypeOf(preview.permissionCodes).toEqualTypeOf<ReadonlySet<string>>()
|
||||
expect(preview.currentRole?.id).toBe('R001')
|
||||
|
||||
const finance = data.roles.find((item) => item.id === 'R004')
|
||||
if (finance) finance.status = '停用'
|
||||
expect(preview.commitRole('R004')).toBe(false)
|
||||
expect(preview.previewRole('R004').firstPath).toBeNull()
|
||||
expect(preview.currentRole?.id).toBe('R001')
|
||||
})
|
||||
|
||||
it('does not filter or mutate mock business data when switching data scopes', () => {
|
||||
const data = useAdminDataStore()
|
||||
const preview = useAccessPreviewStore()
|
||||
preview.initializeAfterLogin()
|
||||
const customerSnapshot = JSON.parse(JSON.stringify(data.customers)) as typeof data.customers
|
||||
const orderSnapshot = JSON.parse(JSON.stringify(data.orders)) as typeof data.orders
|
||||
|
||||
expect(preview.commitRole('R003')).toBe(true)
|
||||
expect(preview.currentRole?.dataScope).toBe('本部门数据')
|
||||
expect(preview.commitRole('R005')).toBe(true)
|
||||
expect(preview.currentRole?.dataScope).toBe('本人数据')
|
||||
|
||||
expect(data.customers).toEqual(customerSnapshot)
|
||||
expect(data.orders).toEqual(orderSnapshot)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,155 @@
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
import {
|
||||
adminNavigation,
|
||||
filterAdminNavigation,
|
||||
getFirstNavigationLeaf,
|
||||
type AdminNavigationNode,
|
||||
} from '@/navigation/adminNavigation'
|
||||
import { useAdminDataStore } from '@/stores/adminData'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
export const PREVIEW_ROLE_KEY = 'cc-admin-preview-role'
|
||||
|
||||
export interface RoleAccessPreview {
|
||||
codes: ReadonlySet<string>
|
||||
navigation: AdminNavigationNode[]
|
||||
firstPath: string | null
|
||||
}
|
||||
|
||||
const serverPermissionAliases: Record<string, string[]> = {
|
||||
'customer:list': ['platform:customer:list'],
|
||||
'warehouse:list': ['platform:warehouse:list'],
|
||||
'first-leg:list': ['platform:order:list'],
|
||||
'express-company:list': ['platform:express:list'],
|
||||
'counterparty:list': ['platform:counterparty:list'],
|
||||
'system:user:list': ['platform:user:list'],
|
||||
'system:role:list': ['platform:role:list'],
|
||||
'system:role:grant': ['platform:role:create', 'platform:role:grant'],
|
||||
'system:permission:list': ['platform:permission:list'],
|
||||
'system:permission:write': ['platform:permission:create'],
|
||||
}
|
||||
|
||||
function withServerAliases(codes: ReadonlySet<string>): ReadonlySet<string> {
|
||||
const result = new Set(codes)
|
||||
codes.forEach((code) => serverPermissionAliases[code]?.forEach((alias) => result.add(alias)))
|
||||
return result
|
||||
}
|
||||
|
||||
export const useAccessPreviewStore = defineStore('access-preview', () => {
|
||||
const data = useAdminDataStore()
|
||||
const auth = useAuthStore()
|
||||
const selectedRoleId = ref<string | null>(null)
|
||||
const initialized = ref(false)
|
||||
|
||||
const enabledRoles = computed(() => data.roles.filter((role) => role.status === '启用'))
|
||||
const enabledRoleIdsSignature = computed(() => enabledRoles.value
|
||||
.map((role) => role.id)
|
||||
.sort()
|
||||
.join('|'))
|
||||
const currentRole = computed(() => enabledRoles.value
|
||||
.find((role) => role.id === selectedRoleId.value) ?? null)
|
||||
|
||||
function permissionCodesFor(roleId: string | null): ReadonlySet<string> {
|
||||
const role = enabledRoles.value.find((item) => item.id === roleId)
|
||||
if (!role) return new Set<string>()
|
||||
|
||||
const permissionIds = new Set(role.permissionIds)
|
||||
return new Set(data.permissions
|
||||
.filter((permission) => permission.status === '启用' && permissionIds.has(permission.id))
|
||||
.map((permission) => permission.code))
|
||||
}
|
||||
|
||||
const usesServerPermissions = computed(() => auth.isServerSession)
|
||||
const permissionCodes = computed<ReadonlySet<string>>(() => usesServerPermissions.value
|
||||
? auth.permissionCodes
|
||||
: withServerAliases(permissionCodesFor(selectedRoleId.value)))
|
||||
const permissionSignature = computed(() => [...permissionCodes.value].sort().join('|'))
|
||||
const filteredNavigation = computed(() => filterAdminNavigation(adminNavigation, permissionCodes.value))
|
||||
const firstAccessiblePath = computed(() => getFirstNavigationLeaf(filteredNavigation.value)?.path ?? null)
|
||||
|
||||
function getDefaultRoleId(): string | null {
|
||||
return enabledRoles.value.find((role) => role.code === 'SUPER_ADMIN')?.id
|
||||
?? enabledRoles.value[0]?.id
|
||||
?? null
|
||||
}
|
||||
|
||||
function persistRole(roleId: string | null) {
|
||||
if (roleId) {
|
||||
localStorage.setItem(PREVIEW_ROLE_KEY, roleId)
|
||||
return
|
||||
}
|
||||
localStorage.removeItem(PREVIEW_ROLE_KEY)
|
||||
}
|
||||
|
||||
function initializeAfterLogin() {
|
||||
if (usesServerPermissions.value) {
|
||||
initialized.value = true
|
||||
selectedRoleId.value = null
|
||||
localStorage.removeItem(PREVIEW_ROLE_KEY)
|
||||
return
|
||||
}
|
||||
if (initialized.value && currentRole.value) return
|
||||
initialized.value = true
|
||||
const persistedRoleId = localStorage.getItem(PREVIEW_ROLE_KEY)
|
||||
const roleId = enabledRoles.value.some((role) => role.id === persistedRoleId)
|
||||
? persistedRoleId
|
||||
: getDefaultRoleId()
|
||||
selectedRoleId.value = roleId
|
||||
persistRole(roleId)
|
||||
}
|
||||
|
||||
function commitRole(roleId: string): boolean {
|
||||
if (usesServerPermissions.value) return false
|
||||
if (!enabledRoles.value.some((role) => role.id === roleId)) return false
|
||||
selectedRoleId.value = roleId
|
||||
persistRole(roleId)
|
||||
return true
|
||||
}
|
||||
|
||||
function previewRole(roleId: string): RoleAccessPreview {
|
||||
const candidateCodes = withServerAliases(permissionCodesFor(roleId))
|
||||
const candidateNavigation = filterAdminNavigation(adminNavigation, candidateCodes)
|
||||
|
||||
return {
|
||||
codes: candidateCodes,
|
||||
navigation: candidateNavigation,
|
||||
firstPath: getFirstNavigationLeaf(candidateNavigation)?.path ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
function can(permissionCode: string): boolean {
|
||||
return permissionCodes.value.has(permissionCode)
|
||||
}
|
||||
|
||||
function reset() {
|
||||
initialized.value = false
|
||||
selectedRoleId.value = null
|
||||
localStorage.removeItem(PREVIEW_ROLE_KEY)
|
||||
}
|
||||
|
||||
watch(enabledRoleIdsSignature, () => {
|
||||
if (!initialized.value || currentRole.value) return
|
||||
const fallbackRoleId = getDefaultRoleId()
|
||||
selectedRoleId.value = fallbackRoleId
|
||||
persistRole(fallbackRoleId)
|
||||
})
|
||||
|
||||
return {
|
||||
selectedRoleId,
|
||||
initialized,
|
||||
usesServerPermissions,
|
||||
enabledRoles,
|
||||
currentRole,
|
||||
permissionCodes,
|
||||
permissionSignature,
|
||||
filteredNavigation,
|
||||
firstAccessiblePath,
|
||||
permissionCodesFor,
|
||||
initializeAfterLogin,
|
||||
commitRole,
|
||||
previewRole,
|
||||
can,
|
||||
reset,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,126 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { adminNavigation, getNavigationLeaves } from '@/navigation/adminNavigation'
|
||||
import { useAdminDataStore } from '@/stores/adminData'
|
||||
import { buildPermissionTree } from '@/utils/permissionTree'
|
||||
|
||||
describe('admin permission fixtures', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
})
|
||||
|
||||
it('groups page permissions under business and system directories', () => {
|
||||
const tree = buildPermissionTree(useAdminDataStore().permissions)
|
||||
|
||||
expect(tree.map((node) => node.name)).toEqual(['业务管理', '基础资料', '系统管理'])
|
||||
expect(tree[0]?.children?.map((node) => node.name)).toEqual(['客户管理', '企业审核', '仓库管理', '头程订单'])
|
||||
expect(tree[1]?.children?.map((node) => node.name)).toEqual(['快递公司', '往来单位'])
|
||||
expect(tree[2]?.children?.map((node) => node.name)).toEqual(['内部用户', '角色管理', '菜单权限'])
|
||||
})
|
||||
|
||||
it('keeps menu permission codes aligned with navigation leaves', () => {
|
||||
const store = useAdminDataStore()
|
||||
const menuCodes = store.permissions
|
||||
.filter((item) => item.type === '菜单')
|
||||
.map((item) => item.code)
|
||||
.sort()
|
||||
const navigationCodes = getNavigationLeaves(adminNavigation)
|
||||
.map((item) => item.permission)
|
||||
.sort()
|
||||
|
||||
expect(menuCodes).toEqual(navigationCodes)
|
||||
})
|
||||
|
||||
it('does not guess API paths for static button permissions', () => {
|
||||
const buttonPermissions = useAdminDataStore().permissions.filter((item) => item.type === '按钮')
|
||||
|
||||
expect(buttonPermissions.every((item) => item.path === '' && item.method === '-')).toBe(true)
|
||||
expect(useAdminDataStore().permissions.some((item) => item.type === '接口')).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps every role permission id valid after regrouping', () => {
|
||||
const store = useAdminDataStore()
|
||||
const permissionIds = new Set(store.permissions.map((item) => item.id))
|
||||
|
||||
store.roles.forEach((role) => {
|
||||
expect(role.permissionIds.every((id) => permissionIds.has(id))).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
it('maintains platform receiving warehouses with unique normalized codes', () => {
|
||||
const store = useAdminDataStore()
|
||||
expect(store.warehouses).toHaveLength(6)
|
||||
expect(store.activeWarehouses).toBe(5)
|
||||
|
||||
const id = store.saveWarehouse({
|
||||
code: 'cn-hz-01', name: '杭州仓', province: '浙江省', city: '杭州市', address: '演示物流园 7 号库',
|
||||
contactName: '仓库客服', contactPhone: '0571-****007', businessHours: '周一至周五 09:00-18:00',
|
||||
status: 'ACTIVE', remark: '',
|
||||
})
|
||||
expect(store.warehouses.find((item) => item.id === id)?.code).toBe('CN-HZ-01')
|
||||
expect(store.activeWarehouses).toBe(6)
|
||||
expect(() => store.saveWarehouse({
|
||||
code: 'CN-HZ-01', name: '重复仓', province: '', city: '杭州市', address: '重复地址',
|
||||
contactName: '', contactPhone: '', businessHours: '', status: 'ACTIVE', remark: '',
|
||||
})).toThrow('仓库编码已存在')
|
||||
|
||||
store.setWarehouseStatus(id, 'INACTIVE')
|
||||
expect(store.activeWarehouses).toBe(5)
|
||||
})
|
||||
|
||||
it('provides the express company records defined by the phase-one prototype', () => {
|
||||
const store = useAdminDataStore()
|
||||
|
||||
expect(store.expressCompanies).toHaveLength(8)
|
||||
expect(store.activeExpressCompanies).toBe(7)
|
||||
expect(store.expressCompanies.find((item) => item.code === 'YTO')?.status).toBe('已终止')
|
||||
expect(new Set(store.expressCompanies.map((item) => item.code)).size).toBe(store.expressCompanies.length)
|
||||
})
|
||||
|
||||
it('validates express company uniqueness and counterparty multi-types', () => {
|
||||
const store = useAdminDataStore()
|
||||
expect(() => store.saveExpressCompany({ name: '顺丰速运', code: 'NEW-SF', website: '', phone: '', status: '合作中', remark: '' })).toThrow('快递公司名称已存在')
|
||||
expect(() => store.saveExpressCompany({ name: '新快递', code: 'sf', website: '', phone: '', status: '合作中', remark: '' })).toThrow('快递公司编码已存在')
|
||||
const id = store.saveCounterparty({ code: 'cp-test', name: '测试多角色单位', types: ['供应商', '发货单位'], contactName: '联系人', contactPhone: '13800000000', email: '', country: '中国', province: '浙江省', city: '义乌市', postalCode: '', address: '测试地址', status: '启用', remark: '' })
|
||||
expect(store.counterparties.find((item) => item.id === id)?.types).toEqual(['供应商', '发货单位'])
|
||||
expect(() => store.saveCounterparty({ code: 'CP-TEST', name: '重复单位', types: ['客户'], contactName: '联系人', contactPhone: '13800000000', email: '', country: '中国', province: '', city: '深圳市', postalCode: '', address: '测试地址', status: '启用', remark: '' })).toThrow('往来单位编码已存在')
|
||||
})
|
||||
|
||||
it('keeps pending orders pending after admin edits and audits delivery maintenance', () => {
|
||||
const store = useAdminDataStore()
|
||||
const order = store.orders.find((item) => item.status === '待审核')!
|
||||
const before = order.timeline.length
|
||||
store.saveOrder({ ...order, id: order.id, remark: '后台补充资料' })
|
||||
const updated = store.orders.find((item) => item.id === order.id)!
|
||||
expect(updated.status).toBe('待审核')
|
||||
expect(updated.timeline).toHaveLength(before + 1)
|
||||
expect(updated.timeline.at(-1)?.title).toBe('后台修改待审核订单')
|
||||
store.saveOrderDeliveryInfo(order.id, 'E001', 'DHL-20260730-001', '张伟')
|
||||
expect(updated.deliveryInfo?.trackingNo).toBe('DHL-20260730-001')
|
||||
expect(updated.timeline.at(-1)?.title).toBe('更新履约快递信息')
|
||||
})
|
||||
|
||||
it('keeps the order warehouse snapshot stable when the warehouse master changes', () => {
|
||||
const store = useAdminDataStore()
|
||||
const order = store.orders[0]
|
||||
const warehouse = store.warehouses.find((item) => item.id === order.receivingWarehouseId)
|
||||
expect(warehouse).toBeDefined()
|
||||
const originalSnapshot = order.receivingWarehouseSnapshot ? { ...order.receivingWarehouseSnapshot } : null
|
||||
|
||||
store.saveWarehouse({ ...warehouse!, name: '宁波仓(新名称)' })
|
||||
|
||||
expect(store.warehouses.find((item) => item.id === warehouse!.id)?.name).toBe('宁波仓(新名称)')
|
||||
expect(order.receivingWarehouseSnapshot).toEqual(originalSnapshot)
|
||||
})
|
||||
|
||||
it('rejects new orders that select an inactive receiving warehouse', () => {
|
||||
const store = useAdminDataStore()
|
||||
const template = store.orders[0]
|
||||
|
||||
expect(() => store.saveOrder({
|
||||
...template,
|
||||
id: undefined,
|
||||
receivingWarehouseId: 'WH006',
|
||||
})).toThrow('所选收货仓当前不可用于新订单')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,389 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
import {
|
||||
createPlatformReceivingWarehouseSnapshot,
|
||||
DEMO_PLATFORM_RECEIVING_WAREHOUSES,
|
||||
} from '@chongchong/shared'
|
||||
import type {
|
||||
Customer,
|
||||
Counterparty,
|
||||
ExpressCompany,
|
||||
FirstLegOrder,
|
||||
InternalUser,
|
||||
OrderStatus,
|
||||
Permission,
|
||||
Role,
|
||||
Warehouse,
|
||||
} from '@/types/admin'
|
||||
|
||||
const initialExpressCompanies: ExpressCompany[] = [
|
||||
{ id: 'E001', name: 'DHL', code: 'DHL', website: 'www.dhl.com', phone: '95380', status: '合作中', remark: '国际快递渠道。', updatedAt: '2026-07-28 09:10' },
|
||||
{ id: 'E002', name: 'FedEx', code: 'FDX', website: 'www.fedex.com', phone: '400-886-1888', status: '合作中', remark: '国际快递渠道。', updatedAt: '2026-07-28 09:12' },
|
||||
{ id: 'E003', name: 'UPS', code: 'UPS', website: 'www.ups.com', phone: '400-820-8388', status: '合作中', remark: '', updatedAt: '2026-07-28 09:15' },
|
||||
{ id: 'E004', name: '顺丰速运', code: 'SF', website: 'www.sf-express.com', phone: '95338', status: '合作中', remark: '国内收货常用渠道。', updatedAt: '2026-07-28 09:20' },
|
||||
{ id: 'E005', name: '中通快递', code: 'ZTO', website: 'www.zto.com', phone: '95311', status: '合作中', remark: '', updatedAt: '2026-07-28 09:22' },
|
||||
{ id: 'E006', name: '圆通速递', code: 'YTO', website: 'www.yto.net.cn', phone: '95554', status: '已终止', remark: '暂停新增引用,历史订单保留。', updatedAt: '2026-07-28 09:25' },
|
||||
{ id: 'E007', name: 'EMS', code: 'EMS', website: 'www.ems.com.cn', phone: '11183', status: '合作中', remark: '', updatedAt: '2026-07-28 09:28' },
|
||||
{ id: 'E008', name: '德邦快递', code: 'DPK', website: 'www.deppon.com', phone: '95353', status: '合作中', remark: '大件货物备选渠道。', updatedAt: '2026-07-28 09:30' },
|
||||
]
|
||||
|
||||
const initialCounterparties: Counterparty[] = [
|
||||
{ id: 'CP001', code: 'CP-HY-001', name: '华远贸易有限公司', types: ['客户', '发货单位'], contactName: '张伟', contactPhone: '138****6789', email: 'zhangwei@huayuan.cn', country: '中国', province: '浙江省', city: '杭州市', postalCode: '310000', address: '滨江区江南大道 588 号', status: '启用', remark: '美线核心客户。', updatedAt: '2026-07-28 10:10' },
|
||||
{ id: 'CP002', code: 'CP-SD-002', name: '深圳速达供应链有限公司', types: ['供应商', '承运商', '发货单位'], contactName: '李明', contactPhone: '139****8901', email: 'liming@suda.cn', country: '中国', province: '广东省', city: '深圳市', postalCode: '518000', address: '盐田区深盐路 2028 号', status: '启用', remark: '欧洲线拼箱合作方。', updatedAt: '2026-07-28 10:15' },
|
||||
{ id: 'CP003', code: 'CP-CUS-003', name: 'HY GLOBAL LLC', types: ['客户', '收货单位'], contactName: 'Alice Chen', contactPhone: '+1 213 *** 8899', email: 'ops@hyglobal.example', country: '美国', province: 'California', city: 'Los Angeles', postalCode: '90001', address: '1200 S Alameda St', status: '启用', remark: '华远贸易美国收货方。', updatedAt: '2026-07-28 10:20' },
|
||||
{ id: 'CP004', code: 'CP-CB-004', name: '宁波海诚报关有限公司', types: ['供应商', '报关行'], contactName: '周海', contactPhone: '0574-****2211', email: 'service@haicheng.example', country: '中国', province: '浙江省', city: '宁波市', postalCode: '315800', address: '北仑区港兴路 36 号', status: '启用', remark: '宁波口岸报关服务。', updatedAt: '2026-07-28 10:25' },
|
||||
{ id: 'CP005', code: 'CP-OLD-005', name: '厦门远港运输有限公司', types: ['承运商'], contactName: '刘洋', contactPhone: '0592-****9812', email: 'dispatch@xm-old.example', country: '中国', province: '福建省', city: '厦门市', postalCode: '361000', address: '湖里区东港北路 31 号', status: '停用', remark: '合同到期,停止新增引用。', updatedAt: '2026-07-28 10:30' },
|
||||
]
|
||||
|
||||
const initialCustomers: Customer[] = [
|
||||
{ id: 'T001', name: '华远贸易有限公司', creditCode: '91330100MA15XXXX91', contact: '张伟', phone: '138****6789', email: 'zhangwei@huayuan.cn', sales: '王建国', packageName: '企业版', period: 30, status: '正常', orders: 286, joinedAt: '2025-03-15', address: '浙江省杭州市滨江区江南大道 588 号', remark: '美线核心客户,优先处理订舱需求。' },
|
||||
{ id: 'T002', name: '深圳速达供应链有限公司', creditCode: '91440300MA95XXXX13', contact: '李明', phone: '139****8901', email: 'liming@suda.cn', sales: '李秀英', packageName: '专业版', period: 45, status: '正常', orders: 152, joinedAt: '2025-06-20', address: '广东省深圳市盐田区深盐路 2028 号', remark: '欧洲线拼箱客户。' },
|
||||
{ id: 'T003', name: '广州恒通物流有限公司', creditCode: '91440100MA32XXXX45', contact: '王芳', phone: '137****2345', email: 'wangfang@hengtong.cn', sales: '张志强', packageName: '专业版', period: 60, status: '正常', orders: 98, joinedAt: '2025-09-01', address: '广东省广州市白云区机场路 1688 号', remark: '空运及敏感货渠道客户。' },
|
||||
{ id: 'T004', name: '宁波远洋货运代理有限公司', creditCode: '91330200MA18XXXX38', contact: '陈强', phone: '136****4567', email: 'chenqiang@ocean-nb.cn', sales: '刘德海', packageName: '基础版', period: 90, status: '正常', orders: 45, joinedAt: '2026-01-10', address: '浙江省宁波市北仑区港兴路 88 号', remark: '' },
|
||||
{ id: 'T005', name: '北京国贸跨境供应链有限公司', creditCode: '91110100MA87XXXX23', contact: '赵雪', phone: '135****7890', email: 'zhaoxue@bjgmt.cn', sales: '陈美玲', packageName: '基础版', period: 30, status: '待审核', orders: 0, joinedAt: '2026-07-26', address: '北京市朝阳区建国路 93 号', remark: '营业执照已上传,等待审核。' },
|
||||
{ id: 'T006', name: '厦门港务货运有限公司', creditCode: '91350200MA12XXXX79', contact: '刘洋', phone: '133****0123', email: 'liuyang@xmgw.cn', sales: '赵晓东', packageName: '企业版', period: 45, status: '停用', orders: 73, joinedAt: '2024-11-01', address: '福建省厦门市湖里区东港北路 31 号', remark: '合同到期,暂时停用。' },
|
||||
]
|
||||
|
||||
const initialUsers: InternalUser[] = [
|
||||
{ id: 'U001', account: 'admin', name: '系统管理员', department: '平台管理', phone: '13800000001', email: 'admin@ccglobal.cn', roleIds: ['R001'], status: '正常', lastLogin: '2026-07-27 17:56', createdAt: '2026-01-01' },
|
||||
{ id: 'U002', account: 'zhangwei', name: '张伟', department: '运营部', phone: '13800000002', email: 'zhangwei@ccglobal.cn', roleIds: ['R002'], status: '正常', lastLogin: '2026-07-27 16:30', createdAt: '2026-02-08' },
|
||||
{ id: 'U003', account: 'liming', name: '李明', department: '操作部', phone: '13800000003', email: 'liming@ccglobal.cn', roleIds: ['R003'], status: '正常', lastLogin: '2026-07-27 15:42', createdAt: '2026-02-18' },
|
||||
{ id: 'U004', account: 'wangfang', name: '王芳', department: '财务部', phone: '13800000004', email: 'wangfang@ccglobal.cn', roleIds: ['R004'], status: '正常', lastLogin: '2026-07-27 14:18', createdAt: '2026-03-02' },
|
||||
{ id: 'U005', account: 'chenqiang', name: '陈强', department: '销售部', phone: '13800000005', email: 'chenqiang@ccglobal.cn', roleIds: ['R005'], status: '正常', lastLogin: '2026-07-26 18:02', createdAt: '2026-04-15' },
|
||||
{ id: 'U006', account: 'zhaoting', name: '赵婷', department: '操作部', phone: '13800000006', email: 'zhaoting@ccglobal.cn', roleIds: ['R003'], status: '停用', lastLogin: '2026-07-18 10:10', createdAt: '2026-05-10' },
|
||||
]
|
||||
|
||||
const initialPermissions: Permission[] = [
|
||||
{ id: 'P010', parentId: null, name: '业务管理', code: 'business', type: '目录', path: '', method: '-', status: '启用', sort: 20 },
|
||||
{ id: 'P100', parentId: 'P010', name: '客户管理', code: 'platform:customer:list', type: '菜单', path: '/customers', method: '-', status: '启用', sort: 10 },
|
||||
{ id: 'P101', parentId: 'P010', name: '企业审核', code: 'platform:customer:review', type: '菜单', path: '/customers/reviews', method: '-', status: '启用', sort: 15 },
|
||||
{ id: 'P102', parentId: 'P100', name: '新增客户', code: 'customer:create', type: '按钮', path: '', method: '-', status: '启用', sort: 10 },
|
||||
{ id: 'P103', parentId: 'P101', name: '审核通过', code: 'platform:customer:approve', type: '按钮', path: '', method: '-', status: '启用', sort: 10 },
|
||||
{ id: 'P104', parentId: 'P101', name: '审核驳回', code: 'platform:customer:reject', type: '按钮', path: '', method: '-', status: '启用', sort: 20 },
|
||||
{ id: 'P150', parentId: 'P010', name: '仓库管理', code: 'platform:warehouse:list', type: '菜单', path: '/warehouses', method: '-', status: '启用', sort: 20 },
|
||||
{ id: 'P152', parentId: 'P150', name: '维护仓库', code: 'warehouse:write', type: '按钮', path: '', method: '-', status: '启用', sort: 10 },
|
||||
{ id: 'P200', parentId: 'P010', name: '头程订单', code: 'platform:order:list', type: '菜单', path: '/orders/first-leg', method: '-', status: '启用', sort: 30 },
|
||||
{ id: 'P202', parentId: 'P200', name: '创建订单', code: 'first-leg:create', type: '按钮', path: '', method: '-', status: '启用', sort: 10 },
|
||||
{ id: 'P203', parentId: 'P200', name: '更新状态', code: 'first-leg:status', type: '按钮', path: '', method: '-', status: '启用', sort: 20 },
|
||||
{ id: 'P204', parentId: 'P200', name: '维护履约快递', code: 'first-leg:delivery', type: '按钮', path: '', method: '-', status: '启用', sort: 30 },
|
||||
{ id: 'P015', parentId: null, name: '基础资料', code: 'master-data', type: '目录', path: '', method: '-', status: '启用', sort: 25 },
|
||||
{ id: 'P160', parentId: 'P015', name: '快递公司', code: 'platform:express:list', type: '菜单', path: '/master-data/express-companies', method: '-', status: '启用', sort: 10 },
|
||||
{ id: 'P161', parentId: 'P160', name: '维护快递公司', code: 'express-company:write', type: '按钮', path: '', method: '-', status: '启用', sort: 10 },
|
||||
{ id: 'P162', parentId: 'P160', name: '导入快递公司', code: 'express-company:import', type: '按钮', path: '', method: '-', status: '启用', sort: 20 },
|
||||
{ id: 'P165', parentId: 'P015', name: '往来单位', code: 'platform:counterparty:list', type: '菜单', path: '/master-data/counterparties', method: '-', status: '启用', sort: 20 },
|
||||
{ id: 'P166', parentId: 'P165', name: '维护往来单位', code: 'counterparty:write', type: '按钮', path: '', method: '-', status: '启用', sort: 10 },
|
||||
{ id: 'P167', parentId: 'P165', name: '导入往来单位', code: 'counterparty:import', type: '按钮', path: '', method: '-', status: '启用', sort: 20 },
|
||||
{ id: 'P020', parentId: null, name: '系统管理', code: 'system', type: '目录', path: '', method: '-', status: '启用', sort: 30 },
|
||||
{ id: 'P300', parentId: 'P020', name: '内部用户', code: 'platform:user:list', type: '菜单', path: '/system/users', method: '-', status: '启用', sort: 10 },
|
||||
{ id: 'P302', parentId: 'P300', name: '维护用户', code: 'system:user:write', type: '按钮', path: '', method: '-', status: '启用', sort: 10 },
|
||||
{ id: 'P400', parentId: 'P020', name: '角色管理', code: 'platform:role:list', type: '菜单', path: '/system/roles', method: '-', status: '启用', sort: 20 },
|
||||
{ id: 'P402', parentId: 'P400', name: '角色授权', code: 'system:role:grant', type: '按钮', path: '', method: '-', status: '启用', sort: 10 },
|
||||
{ id: 'P500', parentId: 'P020', name: '菜单权限', code: 'platform:permission:list', type: '菜单', path: '/system/permissions', method: '-', status: '启用', sort: 30 },
|
||||
{ id: 'P502', parentId: 'P500', name: '维护权限', code: 'system:permission:write', type: '按钮', path: '', method: '-', status: '启用', sort: 10 },
|
||||
]
|
||||
|
||||
const allPermissionIds = initialPermissions.map((item) => item.id)
|
||||
const initialRoles: Role[] = [
|
||||
{ id: 'R001', name: '超级管理员', code: 'SUPER_ADMIN', description: '平台最高权限,拥有全部菜单和数据权限。', userCount: 1, dataScope: '全部数据', status: '启用', permissionIds: allPermissionIds, builtIn: true },
|
||||
{ id: 'R002', name: '运营主管', code: 'OPS_MANAGER', description: '负责客户审核、仓库维护、基础资料、订单分派和运营监控。', userCount: 1, dataScope: '全部数据', status: '启用', permissionIds: ['P010', 'P100', 'P103', 'P150', 'P152', 'P200', 'P203', 'P204', 'P015', 'P160', 'P161', 'P162', 'P165', 'P166', 'P167'] },
|
||||
{ id: 'R003', name: '操作员', code: 'OPERATOR', description: '查看收货仓并处理头程订单和物流节点更新。', userCount: 2, dataScope: '本部门数据', status: '启用', permissionIds: ['P010', 'P150', 'P200', 'P202', 'P203', 'P204'] },
|
||||
{ id: 'R004', name: '财务', code: 'FINANCE', description: '查看订单应收并处理对账结算。', userCount: 1, dataScope: '本部门数据', status: '启用', permissionIds: ['P010', 'P200'] },
|
||||
{ id: 'R005', name: '销售', code: 'SALES', description: '维护客户、查看收货仓并跟进本人负责的订单。', userCount: 1, dataScope: '本人数据', status: '启用', permissionIds: ['P010', 'P100', 'P102', 'P150', 'P200'] },
|
||||
]
|
||||
|
||||
function demoWarehouseSnapshot(id: string) {
|
||||
const warehouse = DEMO_PLATFORM_RECEIVING_WAREHOUSES.find((item) => item.id === id)
|
||||
if (!warehouse) throw new Error(`演示仓库不存在:${id}`)
|
||||
return createPlatformReceivingWarehouseSnapshot(warehouse)
|
||||
}
|
||||
|
||||
const initialOrders: FirstLegOrder[] = [
|
||||
{ id: 'O001', no: 'BY20260727001', customerId: 'T001', customerName: '华远贸易有限公司', referenceNo: 'HY-0727-01', transportMode: '海运整箱 FCL', businessType: '出口', origin: '宁波', destination: '洛杉矶', receivingWarehouseId: 'WH005', receivingWarehouseSnapshot: demoWarehouseSnapshot('WH005'), container: '40HQ x 2', etd: '2026-08-02', eta: '2026-08-18', amount: 48500, currency: 'CNY', status: '已受理', priority: '紧急', operator: '张伟', shipper: '华远贸易有限公司', consignee: 'HY GLOBAL LLC', cargoName: '家居用品', pieces: 1260, weight: 27800, volume: 62.5, createdAt: '2026-07-27 09:18', remark: '需要在截关日前完成报关资料确认。', timeline: [{ time: '2026-07-27 09:18', title: '客户提交订单', description: '客户完成货物与平台收货仓信息提交。', operator: '客户管理员' }, { time: '2026-07-27 10:02', title: '订单已受理', description: '运营审核资料完整,进入订舱准备。', operator: '张伟' }] },
|
||||
{ id: 'O002', no: 'BY20260726008', customerId: 'T002', customerName: '深圳速达供应链有限公司', referenceNo: 'SD-EU-558', transportMode: '海运拼箱 LCL', businessType: '出口', origin: '深圳', destination: '汉堡', receivingWarehouseId: 'WH002', receivingWarehouseSnapshot: demoWarehouseSnapshot('WH002'), container: 'LCL 8.6 CBM', etd: '2026-08-05', eta: '2026-09-06', amount: 12800, currency: 'CNY', status: '已订舱', priority: '普通', operator: '李明', shipper: '深圳速达供应链有限公司', consignee: 'SUDA EUROPE GMBH', cargoName: '电子配件', pieces: 286, weight: 5200, volume: 8.6, createdAt: '2026-07-26 15:42', remark: '', timeline: [{ time: '2026-07-26 15:42', title: '客户提交订单', description: '订单进入平台审核。', operator: '客户管理员' }, { time: '2026-07-27 08:55', title: '订舱完成', description: '已获得船公司舱位确认。', operator: '李明' }] },
|
||||
{ id: 'O003', no: 'BY20260726005', customerId: 'T003', customerName: '广州恒通物流有限公司', referenceNo: 'HT-AIR-0719', transportMode: '空运', businessType: '出口', origin: '广州', destination: '纽约', receivingWarehouseId: 'WH002', receivingWarehouseSnapshot: demoWarehouseSnapshot('WH002'), container: '500 KG', etd: '2026-07-30', eta: '2026-08-01', amount: 35600, currency: 'CNY', status: '运输中', priority: '加急', operator: '赵婷', shipper: '广州恒通物流有限公司', consignee: 'HENGTONG US INC.', cargoName: '服装样品', pieces: 36, weight: 500, volume: 2.8, createdAt: '2026-07-26 10:20', remark: '航班起飞后第一时间同步轨迹。', timeline: [{ time: '2026-07-26 10:20', title: '客户提交订单', description: '加急空运订单。', operator: '客户管理员' }, { time: '2026-07-27 16:30', title: '货物已起飞', description: '航班 CZ699 离港。', operator: '赵婷' }] },
|
||||
{ id: 'O004', no: 'BY20260725012', customerId: 'T004', customerName: '宁波远洋货运代理有限公司', referenceNo: 'NB-ROAD-201', transportMode: '陆运', businessType: '出口', origin: '宁波', destination: '霍尔果斯', receivingWarehouseId: 'WH005', receivingWarehouseSnapshot: demoWarehouseSnapshot('WH005'), container: '整车 x 3', etd: '2026-07-28', eta: '2026-08-08', amount: 18200, currency: 'CNY', status: '待审核', priority: '普通', operator: '未分配', shipper: '宁波远洋货运代理有限公司', consignee: 'ALATAU TRADING LLP', cargoName: '机械配件', pieces: 480, weight: 18500, volume: 46, createdAt: '2026-07-25 14:08', remark: '', timeline: [{ time: '2026-07-25 14:08', title: '客户提交订单', description: '等待平台审核与人员分派。', operator: '客户管理员' }] },
|
||||
{ id: 'O005', no: 'BY20260724009', customerId: 'T001', customerName: '华远贸易有限公司', referenceNo: 'HY-0724-06', transportMode: '海运整箱 FCL', businessType: '出口', origin: '上海', destination: '长滩', receivingWarehouseId: 'WH004', receivingWarehouseSnapshot: demoWarehouseSnapshot('WH004'), container: '20GP x 5', etd: '2026-07-28', eta: '2026-08-12', amount: 56000, currency: 'CNY', status: '已完成', priority: '普通', operator: '张伟', shipper: '华远贸易有限公司', consignee: 'HY GLOBAL LLC', cargoName: '户外用品', pieces: 3200, weight: 55000, volume: 135, createdAt: '2026-07-24 08:45', remark: '', timeline: [{ time: '2026-07-24 08:45', title: '客户提交订单', description: '订单资料提交。', operator: '客户管理员' }, { time: '2026-07-27 12:20', title: '订单已完成', description: '全部业务节点已确认。', operator: '张伟' }] },
|
||||
{ id: 'O006', no: 'BY20260723007', customerId: 'T002', customerName: '深圳速达供应链有限公司', referenceNo: 'SD-EU-552', transportMode: '海运拼箱 LCL', businessType: '出口', origin: '青岛', destination: '鹿特丹', receivingWarehouseId: 'WH003', receivingWarehouseSnapshot: demoWarehouseSnapshot('WH003'), container: 'LCL 12 CBM', etd: '2026-08-10', eta: '2026-09-15', amount: 15300, currency: 'CNY', status: '草稿', priority: '普通', operator: '未分配', shipper: '深圳速达供应链有限公司', consignee: 'SUDA EUROPE GMBH', cargoName: '厨房用品', pieces: 520, weight: 8500, volume: 12, createdAt: '2026-07-23 17:10', remark: '等待客户补充收货人税号。', timeline: [{ time: '2026-07-23 17:10', title: '保存草稿', description: '订单尚未提交审核。', operator: '客户管理员' }] },
|
||||
]
|
||||
|
||||
export const useAdminDataStore = defineStore('admin-data', () => {
|
||||
const customers = ref<Customer[]>(structuredClone(initialCustomers))
|
||||
const users = ref<InternalUser[]>(structuredClone(initialUsers))
|
||||
const roles = ref<Role[]>(structuredClone(initialRoles))
|
||||
const permissions = ref<Permission[]>(structuredClone(initialPermissions))
|
||||
const warehouses = ref<Warehouse[]>(DEMO_PLATFORM_RECEIVING_WAREHOUSES.map((item) => ({ ...item })))
|
||||
const expressCompanies = ref<ExpressCompany[]>(structuredClone(initialExpressCompanies))
|
||||
const counterparties = ref<Counterparty[]>(structuredClone(initialCounterparties))
|
||||
const orders = ref<FirstLegOrder[]>(structuredClone(initialOrders))
|
||||
|
||||
const activeUsers = computed(() => users.value.filter((item) => item.status === '正常').length)
|
||||
const pendingCustomers = computed(() => customers.value.filter((item) => item.status === '待审核').length)
|
||||
const activeWarehouses = computed(() => warehouses.value.filter((item) => item.status === 'ACTIVE').length)
|
||||
const activeExpressCompanies = computed(() => expressCompanies.value.filter((item) => item.status === '合作中').length)
|
||||
const activeCounterparties = computed(() => counterparties.value.filter((item) => item.status === '启用').length)
|
||||
const pendingOrders = computed(() => orders.value.filter((item) => ['待审核', '已受理'].includes(item.status)).length)
|
||||
|
||||
function nextId(prefix: string, rows: Array<{ id: string }>) {
|
||||
const max = rows.reduce((value, item) => Math.max(value, Number(item.id.replace(/\D/g, '')) || 0), 0)
|
||||
return `${prefix}${String(max + 1).padStart(3, '0')}`
|
||||
}
|
||||
|
||||
function saveCustomer(payload: Omit<Customer, 'id' | 'orders' | 'joinedAt'> & { id?: string }) {
|
||||
if (payload.id) {
|
||||
const index = customers.value.findIndex((item) => item.id === payload.id)
|
||||
if (index >= 0) customers.value[index] = { ...customers.value[index], ...payload } as Customer
|
||||
return payload.id
|
||||
}
|
||||
const id = nextId('T', customers.value)
|
||||
customers.value.unshift({ ...payload, id, orders: 0, joinedAt: new Date().toISOString().slice(0, 10) })
|
||||
return id
|
||||
}
|
||||
|
||||
function setCustomerStatus(id: string, status: Customer['status']) {
|
||||
const customer = customers.value.find((item) => item.id === id)
|
||||
if (customer) customer.status = status
|
||||
}
|
||||
|
||||
function saveWarehouse(payload: Omit<Warehouse, 'id' | 'updatedAt'> & { id?: string }) {
|
||||
const duplicate = warehouses.value.find((item) => item.code.toLowerCase() === payload.code.trim().toLowerCase() && item.id !== payload.id)
|
||||
if (duplicate) throw new Error('仓库编码已存在')
|
||||
const updatedAt = new Date().toISOString().slice(0, 16).replace('T', ' ')
|
||||
if (payload.id) {
|
||||
const index = warehouses.value.findIndex((item) => item.id === payload.id)
|
||||
if (index >= 0) warehouses.value[index] = { ...warehouses.value[index], ...payload, code: payload.code.trim().toUpperCase(), updatedAt } as Warehouse
|
||||
return payload.id
|
||||
}
|
||||
const id = nextId('WH', warehouses.value)
|
||||
warehouses.value.unshift({ ...payload, id, code: payload.code.trim().toUpperCase(), updatedAt })
|
||||
return id
|
||||
}
|
||||
|
||||
function setWarehouseStatus(id: string, status: Warehouse['status']) {
|
||||
const warehouse = warehouses.value.find((item) => item.id === id)
|
||||
if (warehouse) {
|
||||
warehouse.status = status
|
||||
warehouse.updatedAt = new Date().toISOString().slice(0, 16).replace('T', ' ')
|
||||
}
|
||||
}
|
||||
|
||||
function saveExpressCompany(payload: Omit<ExpressCompany, 'id' | 'updatedAt'> & { id?: string }) {
|
||||
const normalizedCode = payload.code.trim().toUpperCase()
|
||||
const duplicateCode = expressCompanies.value.find((item) => item.code.toLowerCase() === normalizedCode.toLowerCase() && item.id !== payload.id)
|
||||
if (duplicateCode) throw new Error('快递公司编码已存在')
|
||||
const duplicateName = expressCompanies.value.find((item) => item.name.trim().toLowerCase() === payload.name.trim().toLowerCase() && item.id !== payload.id)
|
||||
if (duplicateName) throw new Error('快递公司名称已存在')
|
||||
const updatedAt = new Date().toISOString().slice(0, 16).replace('T', ' ')
|
||||
if (payload.id) {
|
||||
const index = expressCompanies.value.findIndex((item) => item.id === payload.id)
|
||||
if (index >= 0) expressCompanies.value[index] = { ...expressCompanies.value[index], ...payload, code: normalizedCode, updatedAt } as ExpressCompany
|
||||
return payload.id
|
||||
}
|
||||
const id = nextId('E', expressCompanies.value)
|
||||
expressCompanies.value.unshift({ ...payload, id, code: normalizedCode, updatedAt })
|
||||
return id
|
||||
}
|
||||
|
||||
function setExpressCompanyStatus(id: string, status: ExpressCompany['status']) {
|
||||
const company = expressCompanies.value.find((item) => item.id === id)
|
||||
if (!company) return
|
||||
company.status = status
|
||||
company.updatedAt = new Date().toISOString().slice(0, 16).replace('T', ' ')
|
||||
}
|
||||
|
||||
function saveCounterparty(payload: Omit<Counterparty, 'id' | 'updatedAt'> & { id?: string }) {
|
||||
const normalizedCode = payload.code.trim().toUpperCase()
|
||||
const duplicate = counterparties.value.find((item) => item.code.toLowerCase() === normalizedCode.toLowerCase() && item.id !== payload.id)
|
||||
if (duplicate) throw new Error('往来单位编码已存在')
|
||||
const updatedAt = new Date().toISOString().slice(0, 16).replace('T', ' ')
|
||||
if (payload.id) {
|
||||
const index = counterparties.value.findIndex((item) => item.id === payload.id)
|
||||
if (index >= 0) counterparties.value[index] = { ...counterparties.value[index], ...payload, code: normalizedCode, types: [...payload.types], updatedAt } as Counterparty
|
||||
return payload.id
|
||||
}
|
||||
const id = nextId('CP', counterparties.value)
|
||||
counterparties.value.unshift({ ...payload, id, code: normalizedCode, types: [...payload.types], updatedAt })
|
||||
return id
|
||||
}
|
||||
|
||||
function setCounterpartyStatus(id: string, status: Counterparty['status']) {
|
||||
const counterparty = counterparties.value.find((item) => item.id === id)
|
||||
if (!counterparty) return
|
||||
counterparty.status = status
|
||||
counterparty.updatedAt = new Date().toISOString().slice(0, 16).replace('T', ' ')
|
||||
}
|
||||
|
||||
function saveUser(payload: Omit<InternalUser, 'id' | 'lastLogin' | 'createdAt'> & { id?: string }) {
|
||||
if (payload.id) {
|
||||
const index = users.value.findIndex((item) => item.id === payload.id)
|
||||
if (index >= 0) users.value[index] = { ...users.value[index], ...payload } as InternalUser
|
||||
return payload.id
|
||||
}
|
||||
const id = nextId('U', users.value)
|
||||
users.value.unshift({ ...payload, id, lastLogin: '-', createdAt: new Date().toISOString().slice(0, 10) })
|
||||
syncRoleCounts()
|
||||
return id
|
||||
}
|
||||
|
||||
function setUserStatus(id: string, status: InternalUser['status']) {
|
||||
const user = users.value.find((item) => item.id === id)
|
||||
if (user && user.account !== 'admin') user.status = status
|
||||
}
|
||||
|
||||
function saveRole(payload: Omit<Role, 'id' | 'userCount'> & { id?: string }) {
|
||||
if (payload.id) {
|
||||
const index = roles.value.findIndex((item) => item.id === payload.id)
|
||||
if (index >= 0) roles.value[index] = { ...roles.value[index], ...payload } as Role
|
||||
syncRoleCounts()
|
||||
return payload.id
|
||||
}
|
||||
const id = nextId('R', roles.value)
|
||||
roles.value.push({ ...payload, id, userCount: 0 })
|
||||
return id
|
||||
}
|
||||
|
||||
function removeRole(id: string) {
|
||||
const role = roles.value.find((item) => item.id === id)
|
||||
if (!role || role.builtIn || users.value.some((item) => item.roleIds.includes(id))) return false
|
||||
roles.value = roles.value.filter((item) => item.id !== id)
|
||||
return true
|
||||
}
|
||||
|
||||
function syncRoleCounts() {
|
||||
roles.value.forEach((role) => {
|
||||
role.userCount = users.value.filter((user) => user.roleIds.includes(role.id)).length
|
||||
})
|
||||
}
|
||||
|
||||
function savePermission(payload: Omit<Permission, 'id'> & { id?: string }) {
|
||||
if (payload.id) {
|
||||
const index = permissions.value.findIndex((item) => item.id === payload.id)
|
||||
if (index >= 0) permissions.value[index] = { ...permissions.value[index], ...payload } as Permission
|
||||
return payload.id
|
||||
}
|
||||
const id = nextId('P', permissions.value)
|
||||
permissions.value.push({ ...payload, id })
|
||||
return id
|
||||
}
|
||||
|
||||
function saveOrder(payload: Omit<FirstLegOrder, 'id' | 'no' | 'createdAt' | 'timeline' | 'receivingWarehouseSnapshot'> & { id?: string }) {
|
||||
const existing = payload.id ? orders.value.find((item) => item.id === payload.id) : undefined
|
||||
const warehouse = warehouses.value.find((item) => item.id === payload.receivingWarehouseId)
|
||||
const keepsExistingWarehouse = existing?.receivingWarehouseId === payload.receivingWarehouseId
|
||||
if (!warehouse || (warehouse.status !== 'ACTIVE' && !keepsExistingWarehouse)) throw new Error('所选收货仓当前不可用于新订单')
|
||||
const receivingWarehouseSnapshot = keepsExistingWarehouse && existing?.receivingWarehouseSnapshot
|
||||
? existing.receivingWarehouseSnapshot
|
||||
: createPlatformReceivingWarehouseSnapshot(warehouse)
|
||||
if (payload.id) {
|
||||
const index = orders.value.findIndex((item) => item.id === payload.id)
|
||||
if (index >= 0) {
|
||||
const previous = orders.value[index]
|
||||
const keepsPendingReview = previous.status === '待审核'
|
||||
const time = new Date().toISOString().slice(0, 16).replace('T', ' ')
|
||||
const timeline = [...previous.timeline]
|
||||
if (keepsPendingReview) timeline.push({
|
||||
time,
|
||||
title: '后台修改待审核订单',
|
||||
description: '平台已修正订单资料,订单继续保持待审核;本次修改已记录审计。',
|
||||
operator: payload.operator === '未分配' ? '平台管理员' : payload.operator,
|
||||
})
|
||||
orders.value[index] = {
|
||||
...previous,
|
||||
...payload,
|
||||
status: keepsPendingReview ? '待审核' : payload.status,
|
||||
receivingWarehouseSnapshot,
|
||||
timeline,
|
||||
} as FirstLegOrder
|
||||
}
|
||||
return payload.id
|
||||
}
|
||||
const id = nextId('O', orders.value)
|
||||
const no = `BY${new Date().toISOString().slice(0, 10).replace(/-/g, '')}${String(orders.value.length + 1).padStart(3, '0')}`
|
||||
orders.value.unshift({
|
||||
...payload,
|
||||
receivingWarehouseSnapshot,
|
||||
id,
|
||||
no,
|
||||
createdAt: new Date().toISOString().slice(0, 16).replace('T', ' '),
|
||||
timeline: [{ time: new Date().toISOString().slice(0, 16).replace('T', ' '), title: '后台创建订单', description: '平台工作人员创建头程订单。', operator: '平台管理员' }],
|
||||
})
|
||||
return id
|
||||
}
|
||||
|
||||
function updateOrderStatus(id: string, status: OrderStatus, operator: string, note: string) {
|
||||
const order = orders.value.find((item) => item.id === id)
|
||||
if (!order) return
|
||||
const transitions: Record<OrderStatus, OrderStatus[]> = { 草稿: ['待审核', '已取消'], 待审核: ['已受理', '已取消'], 已受理: ['已订舱', '已取消'], 已订舱: ['运输中', '已取消'], 运输中: ['已完成', '已取消'], 已完成: [], 已取消: [] }
|
||||
if (status !== order.status && !transitions[order.status].includes(status)) throw new Error(`订单不能从“${order.status}”直接变更为“${status}”`)
|
||||
order.status = status
|
||||
order.operator = operator || order.operator
|
||||
order.timeline.push({
|
||||
time: new Date().toISOString().slice(0, 16).replace('T', ' '),
|
||||
title: `状态更新为${status}`,
|
||||
description: note || '平台更新订单处理状态。',
|
||||
operator: operator || '平台管理员',
|
||||
})
|
||||
}
|
||||
|
||||
function recordCustomerModificationContact(id: string, operator: string) {
|
||||
const order = orders.value.find((item) => item.id === id)
|
||||
if (!order || order.status !== '待审核') throw new Error('仅待审核订单可记录联系客户修改')
|
||||
order.timeline.push({ time: new Date().toISOString().slice(0, 16).replace('T', ' '), title: '联系客户修改订单', description: '已联系客户补充或修正订单资料,订单继续保持待审核。', operator })
|
||||
}
|
||||
|
||||
function saveOrderDeliveryInfo(id: string, expressCompanyId: string, trackingNo: string, operator: string) {
|
||||
const order = orders.value.find((item) => item.id === id)
|
||||
const company = expressCompanies.value.find((item) => item.id === expressCompanyId)
|
||||
if (!order) throw new Error('订单不存在')
|
||||
if (!company) throw new Error('快递公司不存在')
|
||||
if (company.status !== '合作中' && order.deliveryInfo?.expressCompanyId !== company.id) throw new Error('停用快递公司不能新增到履约信息')
|
||||
const time = new Date().toISOString().slice(0, 16).replace('T', ' ')
|
||||
order.deliveryInfo = {
|
||||
expressCompanyId: company.id,
|
||||
expressCompanyCode: company.code,
|
||||
expressCompanyName: company.name,
|
||||
trackingNo: trackingNo.trim(),
|
||||
updatedAt: time,
|
||||
operator,
|
||||
}
|
||||
order.timeline.push({
|
||||
time,
|
||||
title: '更新履约快递信息',
|
||||
description: `${company.name} · ${trackingNo.trim()}`,
|
||||
operator,
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
customers,
|
||||
users,
|
||||
roles,
|
||||
permissions,
|
||||
warehouses,
|
||||
expressCompanies,
|
||||
counterparties,
|
||||
orders,
|
||||
activeUsers,
|
||||
pendingCustomers,
|
||||
activeWarehouses,
|
||||
activeExpressCompanies,
|
||||
activeCounterparties,
|
||||
pendingOrders,
|
||||
saveCustomer,
|
||||
setCustomerStatus,
|
||||
saveWarehouse,
|
||||
setWarehouseStatus,
|
||||
saveExpressCompany,
|
||||
setExpressCompanyStatus,
|
||||
saveCounterparty,
|
||||
setCounterpartyStatus,
|
||||
saveUser,
|
||||
setUserStatus,
|
||||
saveRole,
|
||||
removeRole,
|
||||
savePermission,
|
||||
saveOrder,
|
||||
updateOrderStatus,
|
||||
recordCustomerModificationContact,
|
||||
saveOrderDeliveryInfo,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,150 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
import type { CurrentUser } from '@chongchong/shared'
|
||||
import { http, registerAuthRefreshHandler, unwrapApiResponse, type ApiEnvelope } from '@/api/http'
|
||||
import { listPlatformPermissions, type PlatformPermissionResource } from '@/api/platformIam'
|
||||
import { decodeJwtClaims } from '@/utils/jwt'
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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 Error('登录状态已过期,请重新登录')
|
||||
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)
|
||||
|
||||
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 {
|
||||
token.value = ''
|
||||
refreshToken.value = ''
|
||||
localStorage.removeItem(TOKEN_KEY)
|
||||
localStorage.removeItem(REFRESH_TOKEN_KEY)
|
||||
localStorage.removeItem(PERMISSION_CODES_KEY)
|
||||
}
|
||||
}
|
||||
|
||||
const isAuthenticated = computed(() => Boolean(token.value))
|
||||
const permissionCodes = computed<ReadonlySet<string>>(() => new Set(permissions.value))
|
||||
|
||||
async function applySession(result: LoginResponse) {
|
||||
const session = sessionFromToken(result.accessToken)
|
||||
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 {
|
||||
permissionResources.value = await listPlatformPermissions()
|
||||
const activeResourceCodes = new Set(permissionResources.value
|
||||
.filter((resource) => resource.status === 'ACTIVE')
|
||||
.map((resource) => resource.permissionCode))
|
||||
permissions.value = session.permissions.filter((code) => activeResourceCodes.has(code))
|
||||
localStorage.setItem(PERMISSION_CODES_KEY, JSON.stringify(permissions.value))
|
||||
} catch (error) {
|
||||
clearSession()
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
function clearSession() {
|
||||
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 login(account: string, password: string) {
|
||||
const response = await http.post<ApiEnvelope<LoginResponse>, ApiEnvelope<LoginResponse>>(
|
||||
'/auth/login',
|
||||
{ username: account, password },
|
||||
)
|
||||
const result = unwrapApiResponse<LoginResponse>(response)
|
||||
await applySession(result)
|
||||
}
|
||||
|
||||
async function refreshSession() {
|
||||
if (!refreshToken.value) throw new Error('刷新令牌不存在,请重新登录')
|
||||
try {
|
||||
const response = await http.post<ApiEnvelope<LoginResponse>, ApiEnvelope<LoginResponse>>(
|
||||
'/auth/refresh',
|
||||
{ refreshToken: refreshToken.value },
|
||||
)
|
||||
const result = unwrapApiResponse<LoginResponse>(response)
|
||||
await applySession(result)
|
||||
} catch (error) {
|
||||
clearSession()
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
registerAuthRefreshHandler(async () => {
|
||||
await refreshSession()
|
||||
if (!token.value) throw new Error('登录状态刷新失败')
|
||||
return token.value
|
||||
})
|
||||
|
||||
function logout() {
|
||||
const accessToken = token.value
|
||||
const tokenToRevoke = refreshToken.value
|
||||
clearSession()
|
||||
if (accessToken && tokenToRevoke) {
|
||||
void http.post('/auth/logout', { refreshToken: tokenToRevoke }, {
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
}).catch(() => undefined)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
token,
|
||||
refreshToken,
|
||||
user,
|
||||
permissions,
|
||||
permissionResources,
|
||||
isServerSession,
|
||||
permissionCodes,
|
||||
isAuthenticated,
|
||||
login,
|
||||
refreshSession,
|
||||
logout,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,83 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { useUnsavedWorkStore } from '@/stores/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,351 @@
|
||||
:root {
|
||||
font-family: "PingFang SC", "Microsoft YaHei", "Segoe UI", sans-serif;
|
||||
color: #172033;
|
||||
background: #f4f6f8;
|
||||
font-synthesis: none;
|
||||
--cc-primary: #155eef;
|
||||
--cc-navy: #172a46;
|
||||
--cc-border: #e3e8ef;
|
||||
--cc-muted: #667085;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html, body, #app { width: 100%; min-width: 0; min-height: 100%; margin: 0; }
|
||||
body { min-height: 100vh; background: #f4f6f8; }
|
||||
button, input, textarea, select { font: inherit; }
|
||||
button { letter-spacing: 0; }
|
||||
:focus-visible { outline: 3px solid rgba(21, 94, 239, .28); outline-offset: 2px; }
|
||||
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
|
||||
|
||||
.app-shell { min-width: 0; min-height: 100vh; }
|
||||
.app-sidebar { position: fixed; inset: 0 auto 0 0; z-index: 20; display: flex; width: 240px; flex-direction: column; background: var(--cc-navy); color: #fff; }
|
||||
.is-collapsed .app-sidebar { width: 76px; }
|
||||
.brand-block { display: flex; height: 72px; align-items: center; gap: 12px; padding: 0 18px; border-bottom: 1px solid rgba(255,255,255,.1); }
|
||||
.brand-mark { display: grid; width: 40px; height: 40px; flex: 0 0 auto; place-items: center; border-radius: 8px; background: #fff; color: var(--cc-navy); font-weight: 800; letter-spacing: 0; }
|
||||
.brand-copy { display: flex; min-width: 0; flex-direction: column; gap: 2px; }
|
||||
.brand-copy strong { font-size: 16px; }
|
||||
.brand-copy span { color: #aebbd0; font-size: 12px; }
|
||||
.side-nav { flex: 1; min-height: 0; overflow-y: auto; padding: 14px 10px; }
|
||||
.side-nav-menu {
|
||||
width: 100%;
|
||||
border-right: 0 !important;
|
||||
background: transparent !important;
|
||||
--el-menu-bg-color: transparent;
|
||||
--el-menu-text-color: #bdc7d7;
|
||||
--el-menu-active-color: var(--cc-navy);
|
||||
--el-menu-hover-bg-color: rgba(255,255,255,.08);
|
||||
}
|
||||
.side-nav-menu.el-menu--collapse { width: 56px; }
|
||||
.side-nav-menu .el-menu-item,
|
||||
.side-nav-menu .el-sub-menu__title { height: 44px; margin: 3px 0; border-radius: 8px; line-height: 44px; }
|
||||
.side-nav-menu .el-menu-item:hover,
|
||||
.side-nav-menu .el-sub-menu__title:hover { color: #fff; }
|
||||
.side-nav-menu .el-menu-item.is-active { background: #fff; color: var(--cc-navy); font-weight: 650; }
|
||||
.side-nav-menu .el-sub-menu .el-menu-item { min-width: 0; padding-left: 48px !important; }
|
||||
.side-nav-menu .el-menu-item svg,
|
||||
.side-nav-menu .el-sub-menu__title svg { flex: 0 0 auto; margin-right: 12px; }
|
||||
.sidebar-toggle { display: flex; height: 52px; align-items: center; justify-content: center; gap: 8px; border: 0; border-top: 1px solid rgba(255,255,255,.1); background: transparent; color: #aebbd0; cursor: pointer; }
|
||||
.app-main { min-width: 0; min-height: 100vh; margin-left: 240px; }
|
||||
.is-collapsed .app-main { margin-left: 76px; }
|
||||
.app-header { position: sticky; top: 0; z-index: 15; display: flex; min-width: 0; min-height: 72px; align-items: center; gap: 16px; padding: 10px 28px; border-bottom: 1px solid var(--cc-border); background: rgba(255,255,255,.96); }
|
||||
.page-heading { min-width: 0; flex: 1; }
|
||||
.page-heading h1 { margin: 0; overflow: hidden; font-size: 20px; letter-spacing: 0; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.page-heading p { margin: 4px 0 0; overflow: hidden; color: var(--cc-muted); font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.header-user { display: flex; min-width: 0; flex: 0 0 auto; align-items: center; gap: 10px; }
|
||||
.user-avatar { display: grid; width: 36px; height: 36px; place-items: center; border-radius: 50%; background: #eaf1ff; color: var(--cc-primary); font-weight: 700; }
|
||||
.user-copy { display: flex; flex-direction: column; }
|
||||
.user-copy strong { font-size: 13px; }
|
||||
.user-copy span { color: var(--cc-muted); font-size: 11px; }
|
||||
.mobile-menu-button { display: none; width: 44px; height: 44px; padding: 0; border: 0; background: none; cursor: pointer; }
|
||||
.header-user .el-button { width: 44px; height: 44px; }
|
||||
.page-container { padding: 24px 28px 40px; }
|
||||
|
||||
/* Static permission preview */
|
||||
.app-header > .el-dropdown { min-width: 0; max-width: 320px; flex: 0 1 320px; }
|
||||
.role-preview-trigger { display: flex; width: 100%; min-width: 0; min-height: 44px; align-items: center; gap: 10px; padding: 6px 10px; border: 1px solid var(--cc-border); border-radius: 8px; background: #fff; color: #172033; text-align: left; cursor: pointer; }
|
||||
.role-preview-trigger:hover { border-color: #b8c6d9; background: #f8fafc; }
|
||||
.role-preview-trigger:focus-visible { border-color: var(--cc-primary); outline: 3px solid rgba(21, 94, 239, .2); outline-offset: 1px; }
|
||||
.role-preview-trigger:disabled, .role-preview-trigger[aria-disabled="true"] { cursor: not-allowed; opacity: .58; }
|
||||
.role-preview-trigger > svg { flex: 0 0 auto; }
|
||||
.role-preview-copy { display: flex; min-width: 0; flex: 1; flex-direction: column; gap: 2px; }
|
||||
.role-preview-copy strong, .role-preview-copy small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.role-preview-copy strong { font-size: 12px; font-weight: 700; }
|
||||
.role-preview-copy small { color: var(--cc-muted); font-size: 10px; line-height: 1.35; }
|
||||
|
||||
.forbidden-page { display: flex; min-height: min(560px, calc(100vh - 136px)); align-items: center; justify-content: center; flex-direction: column; padding: clamp(28px, 7vw, 64px) clamp(18px, 6vw, 48px); border: 1px solid var(--cc-border); border-radius: 8px; background: #fff; text-align: center; }
|
||||
.forbidden-icon { display: grid; width: 56px; height: 56px; place-items: center; margin-bottom: 18px; border-radius: 8px; background: #fef3f2; color: #b42318; }
|
||||
.forbidden-page .section-kicker { margin: 0 0 8px; }
|
||||
.forbidden-page h2 { max-width: 560px; margin: 0 0 10px; font-size: 20px; line-height: 1.35; }
|
||||
.forbidden-page > p:not(.section-kicker) { max-width: 560px; margin: 0 0 10px; color: var(--cc-muted); font-size: 13px; line-height: 1.65; overflow-wrap: anywhere; }
|
||||
.forbidden-page .forbidden-source { margin-bottom: 20px; color: #475467; font-size: 12px; }
|
||||
.forbidden-page .el-button { min-height: 44px; }
|
||||
|
||||
.login-page { display: grid; min-height: 100vh; grid-template-columns: minmax(420px, 1.05fr) minmax(420px, .95fr); background: #fff; }
|
||||
.login-intro { display: flex; flex-direction: column; justify-content: space-between; padding: 46px clamp(42px, 7vw, 100px); background: var(--cc-navy); color: #fff; }
|
||||
.login-brand { display: flex; align-items: center; gap: 12px; font-weight: 700; }
|
||||
.login-brand span { display: grid; width: 42px; height: 42px; place-items: center; border-radius: 10px; background: #fff; color: var(--cc-navy); }
|
||||
.eyebrow, .section-kicker { color: #3975e9; font-size: 11px; font-weight: 750; letter-spacing: 0; }
|
||||
.login-intro h1 { max-width: 680px; margin: 16px 0 22px; font-size: 52px; line-height: 1.16; letter-spacing: 0; }
|
||||
.intro-copy { max-width: 560px; color: #b9c6d8; font-size: 16px; line-height: 1.8; }
|
||||
.security-note { display: flex; align-items: center; gap: 10px; color: #cbd5e1; font-size: 13px; }
|
||||
.login-panel { display: grid; place-items: center; padding: 48px; }
|
||||
.login-card { width: min(410px, 100%); }
|
||||
.login-card-icon { display: grid; width: 48px; height: 48px; place-items: center; border-radius: 12px; background: #eaf1ff; color: var(--cc-primary); }
|
||||
.login-card h2 { margin: 22px 0 8px; font-size: 28px; }
|
||||
.login-card > p { margin: 0 0 30px; color: var(--cc-muted); }
|
||||
.login-submit { width: 100%; }
|
||||
.login-submit span { display: flex; align-items: center; gap: 8px; }
|
||||
.login-error { margin: 14px 0 0; color: #b42318; font-size: 13px; line-height: 1.5; }
|
||||
.login-error > p { margin: 0; }
|
||||
.api-error-alert-title { min-width: 0; }
|
||||
.demo-tip { margin-top: 18px; padding: 10px 12px; border: 1px solid #dce6f8; background: #f7faff; color: #64748b; font-size: 12px; }
|
||||
|
||||
.dashboard-stack { display: flex; flex-direction: column; gap: 20px; }
|
||||
.welcome-panel, .content-card, .module-card { border: 1px solid var(--cc-border); background: #fff; }
|
||||
.welcome-panel { display: flex; align-items: center; justify-content: space-between; gap: 24px; padding: 28px 30px; }
|
||||
.welcome-panel h2 { margin: 8px 0 7px; font-size: 23px; }
|
||||
.welcome-panel p { margin: 0; color: var(--cc-muted); }
|
||||
.stats-grid { display: grid; grid-template-columns: repeat(4, minmax(0,1fr)); gap: 16px; }
|
||||
.stat-card { padding: 20px; border: 1px solid var(--cc-border); border-radius: 8px; background: #fff; color: inherit; text-align: left; cursor: pointer; transition: border-color .16s ease, box-shadow .16s ease, transform .16s ease; }
|
||||
.stat-card:hover { border-color: #b8c6d9; box-shadow: 0 6px 16px rgba(23,42,70,.08); transform: translateY(-1px); }
|
||||
.stat-icon { display: grid; width: 38px; height: 38px; place-items: center; margin-bottom: 20px; background: #eaf1ff; color: var(--cc-primary); }
|
||||
.stat-icon.is-amber { background: #fff7e6; color: #b76e00; }
|
||||
.stat-icon.is-teal { background: #e8f7f4; color: #087d70; }
|
||||
.stat-icon.is-red { background: #fff0f0; color: #c2413b; }
|
||||
.stat-label { color: var(--cc-muted); font-size: 13px; }
|
||||
.stat-card > strong { display: block; margin: 4px 0; font-size: 30px; }
|
||||
.stat-card > span { color: #98a2b3; font-size: 11px; }
|
||||
.content-grid { display: grid; grid-template-columns: 1.1fr .9fr; gap: 20px; }
|
||||
.content-card { padding: 24px; }
|
||||
.card-heading h3 { margin: 5px 0 18px; font-size: 18px; }
|
||||
.milestone-list { border-top: 1px solid var(--cc-border); }
|
||||
.milestone-row { display: grid; grid-template-columns: 52px 1fr auto; align-items: center; gap: 12px; padding: 15px 0; border-bottom: 1px solid var(--cc-border); }
|
||||
.milestone-code { color: var(--cc-primary); font-size: 12px; font-weight: 750; }
|
||||
.next-list { display: flex; flex-direction: column; gap: 16px; margin: 0; padding: 0; list-style: none; }
|
||||
.next-list li { display: flex; gap: 12px; }
|
||||
.next-list li > span { display: grid; width: 28px; height: 28px; flex: 0 0 auto; place-items: center; border: 1px solid #cddaf0; color: var(--cc-primary); font-size: 12px; font-weight: 700; }
|
||||
.next-list strong { font-size: 14px; }
|
||||
.next-list p { margin: 4px 0 0; color: var(--cc-muted); font-size: 12px; line-height: 1.5; }
|
||||
.module-page { display: flex; flex-direction: column; gap: 16px; }
|
||||
.module-toolbar { display: flex; justify-content: flex-end; gap: 10px; }
|
||||
.module-toolbar .el-input { width: 280px; }
|
||||
.module-card { min-height: 430px; }
|
||||
.empty-state { display: flex; min-height: 430px; align-items: center; justify-content: center; flex-direction: column; padding: 40px; text-align: center; }
|
||||
.empty-icon { display: grid; width: 58px; height: 58px; place-items: center; margin-bottom: 18px; background: #eef3fa; color: #55708f; }
|
||||
.empty-state h2 { margin: 0 0 8px; font-size: 20px; }
|
||||
.empty-state p { max-width: 540px; margin: 0 0 18px; color: var(--cc-muted); }
|
||||
|
||||
/* Business modules */
|
||||
.business-page { display: flex; min-width: 0; flex-direction: column; gap: 16px; }
|
||||
.surface-panel { min-width: 0; border: 1px solid var(--cc-border); border-radius: 8px; background: #fff; }
|
||||
.metric-strip { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); overflow: hidden; border: 1px solid var(--cc-border); border-radius: 8px; background: #fff; }
|
||||
.metric-item { min-width: 0; padding: 18px 20px; border-right: 1px solid var(--cc-border); }
|
||||
.metric-item:last-child { border-right: 0; }
|
||||
.metric-item span, .metric-item small { display: block; color: var(--cc-muted); }
|
||||
.metric-item span { margin-bottom: 7px; font-size: 12px; }
|
||||
.metric-item strong { display: block; overflow-wrap: anywhere; font-size: 25px; line-height: 1.15; }
|
||||
.metric-item small { margin-top: 5px; font-size: 11px; }
|
||||
.filter-panel { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 14px 16px; }
|
||||
.filter-fields, .filter-actions { display: flex; align-items: center; gap: 10px; }
|
||||
.filter-fields { min-width: 0; flex: 1; }
|
||||
.filter-fields > .el-input { width: min(360px, 100%); }
|
||||
.filter-fields > .el-select { width: 150px; flex: 0 0 auto; }
|
||||
.filter-actions { flex: 0 0 auto; }
|
||||
.filter-actions .el-button + .el-button { margin-left: 0; }
|
||||
.filter-actions .el-button span, .table-actions .el-button span { gap: 5px; }
|
||||
.table-panel { overflow: hidden; }
|
||||
.panel-heading { display: flex; min-height: 76px; align-items: center; justify-content: space-between; gap: 18px; padding: 16px 20px; border-bottom: 1px solid var(--cc-border); }
|
||||
.panel-heading h2 { margin: 0; font-size: 16px; }
|
||||
.panel-heading p { margin: 5px 0 0; color: var(--cc-muted); font-size: 12px; line-height: 1.5; }
|
||||
.table-panel .el-table { --el-table-border-color: #edf0f4; --el-table-header-bg-color: #f8fafc; --el-table-row-hover-bg-color: #f8fbff; }
|
||||
.table-panel .el-table th.el-table__cell { height: 44px; color: #475467; font-size: 12px; font-weight: 650; }
|
||||
.table-panel .el-table td.el-table__cell { padding: 12px 0; }
|
||||
.primary-cell, .user-cell { display: flex; min-width: 0; flex-direction: column; gap: 4px; }
|
||||
.primary-cell strong, .user-cell strong { overflow: hidden; color: #172033; font-size: 13px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.primary-cell span, .user-cell span { overflow: hidden; color: var(--cc-muted); font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.user-cell { flex-direction: row; align-items: center; gap: 10px; }
|
||||
.user-cell > span:first-child, .detail-identity > span { display: grid; width: 34px; height: 34px; flex: 0 0 auto; place-items: center; border-radius: 50%; background: #eaf1ff; color: var(--cc-primary); font-weight: 700; }
|
||||
.user-cell > div { display: flex; min-width: 0; flex-direction: column; gap: 3px; }
|
||||
.table-actions { display: flex; align-items: center; white-space: nowrap; }
|
||||
.table-actions .el-button + .el-button { margin-left: 10px; }
|
||||
.table-footer { display: flex; min-height: 58px; align-items: center; justify-content: space-between; gap: 16px; padding: 10px 16px; border-top: 1px solid var(--cc-border); color: var(--cc-muted); font-size: 12px; }
|
||||
.status-badge { display: inline-flex; min-height: 24px; align-items: center; gap: 6px; padding: 2px 8px; border-radius: 999px; background: #f2f4f7; color: #475467; font-size: 11px; font-weight: 650; white-space: nowrap; }
|
||||
.status-badge i { width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
|
||||
.status-badge.is-success { background: #ecfdf3; color: #067647; }
|
||||
.status-badge.is-warning { background: #fffaeb; color: #b54708; }
|
||||
.status-badge.is-danger { background: #fef3f2; color: #b42318; }
|
||||
.status-badge.is-info { background: #eff4ff; color: #3538cd; }
|
||||
.tag-list { display: flex; flex-wrap: wrap; gap: 5px; }
|
||||
.tag-list .el-tag + .el-tag { margin-left: 0; }
|
||||
.muted-value { color: #98a2b3; }
|
||||
|
||||
/* Drawers and forms */
|
||||
.business-drawer { max-width: 100vw; }
|
||||
.business-drawer .el-drawer__header { margin-bottom: 0; padding: 20px 24px 16px; border-bottom: 1px solid var(--cc-border); color: #172033; font-weight: 700; }
|
||||
.business-drawer .el-drawer__body { padding: 0; }
|
||||
.editor-drawer { top: 72px; height: calc(100% - 72px); }
|
||||
.business-drawer .el-drawer__footer { padding: 14px 24px; border-top: 1px solid var(--cc-border); }
|
||||
.drawer-form { padding: 4px 24px 24px; }
|
||||
.form-section { padding: 22px 0; border-bottom: 1px solid var(--cc-border); }
|
||||
.form-section:last-child { border-bottom: 0; }
|
||||
.form-section h3, .detail-section h3 { margin: 0 0 16px; font-size: 14px; }
|
||||
.form-grid { display: grid; gap: 0 16px; }
|
||||
.form-grid.two-cols { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.form-grid.three-cols { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||
.drawer-form .el-form-item { min-width: 0; margin-bottom: 18px; }
|
||||
.drawer-form .el-select, .drawer-form .el-input-number, .drawer-form .el-date-editor { width: 100%; }
|
||||
.field-suffix { margin-left: 8px; color: var(--cc-muted); font-size: 12px; }
|
||||
.form-help { margin: -7px 0 18px; color: var(--cc-muted); font-size: 11px; line-height: 1.6; }
|
||||
.drawer-footer { display: flex; justify-content: flex-end; gap: 10px; }
|
||||
.drawer-footer .el-button + .el-button { margin-left: 0; }
|
||||
.detail-identity { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 12px; padding: 24px; border-bottom: 1px solid var(--cc-border); }
|
||||
.detail-identity > span { width: 44px; height: 44px; }
|
||||
.detail-identity h2, .order-detail-head h2 { margin: 0; font-size: 17px; }
|
||||
.detail-identity p, .order-detail-head p { margin: 5px 0 0; color: var(--cc-muted); font-size: 12px; overflow-wrap: anywhere; }
|
||||
.business-drawer .metric-strip { margin: 20px 24px 0; grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||
.detail-section { padding: 22px 24px 0; }
|
||||
.detail-note { margin: 12px 0 0; padding: 12px 14px; border: 1px solid #e2e8f0; border-radius: 6px; background: #f8fafc; color: #475467; font-size: 12px; line-height: 1.65; }
|
||||
|
||||
/* Role and permission management */
|
||||
.role-cell { display: flex; align-items: center; gap: 8px; }
|
||||
.section-heading-row { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; }
|
||||
.section-heading-row h3 { margin-bottom: 4px; }
|
||||
.section-heading-row p { margin: 0; color: var(--cc-muted); font-size: 12px; line-height: 1.5; }
|
||||
.section-heading-row > span { color: var(--cc-muted); font-size: 11px; white-space: nowrap; }
|
||||
.permission-tree { margin-top: 16px; padding: 8px 0; border-top: 1px solid var(--cc-border); }
|
||||
.permission-tree .el-tree-node__content { min-height: 36px; }
|
||||
.permission-node { display: flex; min-width: 0; flex: 1; align-items: center; justify-content: space-between; gap: 16px; padding-right: 12px; }
|
||||
.permission-node code, .code-value, .endpoint-cell code { color: #475467; font-family: "Cascadia Code", Consolas, monospace; font-size: 11px; overflow-wrap: anywhere; }
|
||||
.legend-list { display: flex; flex-wrap: wrap; gap: 14px; color: var(--cc-muted); font-size: 11px; }
|
||||
.legend-list span { display: inline-flex; align-items: center; gap: 5px; }
|
||||
.legend-list i { width: 8px; height: 8px; border-radius: 2px; background: #155eef; }
|
||||
.legend-list i.is-action { background: #087d70; }
|
||||
.legend-list i.is-api { background: #8b5cf6; }
|
||||
.permission-name { display: flex; align-items: center; gap: 8px; }
|
||||
.permission-name > span { display: grid; width: 28px; height: 28px; flex: 0 0 auto; place-items: center; border-radius: 6px; background: #eef4ff; color: #155eef; }
|
||||
.permission-name > span.is-action { background: #e8f7f4; color: #087d70; }
|
||||
.permission-name > span.is-api { background: #f3efff; color: #7047b8; }
|
||||
.endpoint-cell { display: flex; align-items: center; gap: 8px; }
|
||||
.endpoint-cell > span { padding: 2px 5px; border-radius: 3px; background: #edf2f7; color: #344054; font-size: 10px; font-weight: 700; }
|
||||
.form-callout { display: flex; gap: 10px; margin-top: 18px; padding: 13px 14px; border: 1px solid #dbe5f5; border-radius: 6px; background: #f7faff; color: #344054; }
|
||||
.form-callout > svg { flex: 0 0 auto; color: var(--cc-primary); }
|
||||
.form-callout strong { font-size: 12px; }
|
||||
.form-callout p { margin: 3px 0 0; color: #5c6f87; font-size: 11px; line-height: 1.6; }
|
||||
|
||||
/* First-leg orders */
|
||||
.order-filter .filter-fields > .el-input { width: 280px; }
|
||||
.order-no { color: var(--cc-primary); font-weight: 700; }
|
||||
.route-cell { display: grid; grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); align-items: center; gap: 6px; font-size: 12px; }
|
||||
.route-cell span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.route-cell i { width: 18px; height: 1px; background: #98a2b3; }
|
||||
.selection-note { color: var(--cc-primary); font-size: 12px; font-weight: 650; }
|
||||
.order-detail-head { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: start; gap: 12px; padding: 24px; border-bottom: 1px solid var(--cc-border); }
|
||||
.order-detail-head > div > span { color: var(--cc-primary); font-size: 11px; font-weight: 700; }
|
||||
.party-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
||||
.party-grid > div { padding: 14px; border: 1px solid var(--cc-border); border-radius: 6px; }
|
||||
.party-grid span { display: block; margin-bottom: 5px; color: var(--cc-muted); font-size: 11px; }
|
||||
.party-grid strong { font-size: 13px; line-height: 1.5; }
|
||||
.order-progress { margin: 4px 0 0; padding-left: 5px; }
|
||||
.order-progress .el-timeline-item__timestamp { color: #667085; font-size: 11px; }
|
||||
.order-progress h4 { margin: 0 0 4px; font-size: 13px; }
|
||||
.order-progress p { margin: 0; color: var(--cc-muted); font-size: 12px; line-height: 1.6; }
|
||||
.amount-field { display: flex; align-items: center; gap: 8px; }
|
||||
.amount-field .el-input-number { flex: 1; }
|
||||
.amount-field .el-select { width: 92px; flex: 0 0 auto; }
|
||||
.status-dialog .el-form-item { margin-bottom: 18px; }
|
||||
.status-dialog .el-select { width: 100%; }
|
||||
|
||||
/* Dashboard tasks */
|
||||
.task-card { padding-bottom: 10px; }
|
||||
.task-row { display: grid; width: 100%; grid-template-columns: 34px minmax(0, 1fr) auto; align-items: center; gap: 12px; padding: 13px 0; border: 0; border-top: 1px solid var(--cc-border); background: transparent; color: inherit; text-align: left; cursor: pointer; }
|
||||
.task-row:hover strong { color: var(--cc-primary); }
|
||||
.task-row > span:nth-child(2) { display: flex; min-width: 0; flex-direction: column; gap: 3px; }
|
||||
.task-row small { color: var(--cc-muted); font-size: 11px; }
|
||||
.task-count { display: grid; width: 30px; height: 30px; place-items: center; border-radius: 6px; background: #eef4ff; color: var(--cc-primary); font-size: 12px; font-weight: 700; }
|
||||
.milestone-row { grid-template-columns: 20px 1fr auto; }
|
||||
.milestone-state { width: 8px; height: 8px; border-radius: 50%; background: #12b76a; }
|
||||
.sidebar-scrim { display: none; }
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.stats-grid { grid-template-columns: repeat(2, 1fr); }
|
||||
.content-grid { grid-template-columns: 1fr; }
|
||||
.metric-strip { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.metric-item:nth-child(2) { border-right: 0; }
|
||||
.metric-item:nth-child(-n+2) { border-bottom: 1px solid var(--cc-border); }
|
||||
.filter-panel { align-items: stretch; flex-direction: column; }
|
||||
.filter-actions { justify-content: flex-end; }
|
||||
.form-grid.three-cols { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.login-page { grid-template-columns: 1fr; }
|
||||
.login-intro { display: none; }
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.app-sidebar { display: flex; width: min(280px, 86vw); transform: translateX(-100%); transition: transform .2s ease; }
|
||||
.is-mobile-open .app-sidebar { transform: translateX(0); }
|
||||
.is-collapsed .app-sidebar { width: min(280px, 86vw); }
|
||||
.is-collapsed .brand-copy, .is-collapsed .sidebar-toggle span { display: flex; }
|
||||
.side-nav-menu, .side-nav-menu.el-menu--collapse { width: 100%; }
|
||||
.sidebar-toggle { display: none; }
|
||||
.sidebar-scrim { position: fixed; inset: 0 0 0 min(280px, 86vw); z-index: 19; display: block; border: 0; background: rgba(15, 23, 42, .46); }
|
||||
.app-main, .is-collapsed .app-main { margin-left: 0; }
|
||||
.mobile-menu-button { display: inline-grid; place-items: center; }
|
||||
.app-header > .el-dropdown { width: 44px; max-width: 44px; flex: 0 0 44px; }
|
||||
.role-preview-trigger { width: 44px; height: 44px; justify-content: center; padding: 0; }
|
||||
.role-preview-copy, .role-preview-trigger > svg:last-child { display: none; }
|
||||
.user-copy { display: none; }
|
||||
.page-heading p { display: none; }
|
||||
.page-container { padding: 18px 14px 30px; }
|
||||
.app-header { padding: 10px 14px; }
|
||||
.stats-grid { grid-template-columns: 1fr; }
|
||||
.welcome-panel { align-items: flex-start; flex-direction: column; }
|
||||
.metric-strip { grid-template-columns: 1fr 1fr; }
|
||||
.metric-item { padding: 15px; }
|
||||
.metric-item strong { font-size: 22px; }
|
||||
.filter-fields { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.filter-fields > .el-input, .filter-fields > .el-select, .order-filter .filter-fields > .el-input { width: 100%; }
|
||||
.filter-fields > .el-input { grid-column: 1 / -1; }
|
||||
.filter-fields > .el-date-editor { width: 100%; min-width: 0; max-width: 100%; }
|
||||
.order-filter .filter-fields > .el-date-editor { grid-column: 1 / -1; }
|
||||
.filter-actions { overflow-x: auto; justify-content: flex-start; padding-bottom: 2px; }
|
||||
.filter-actions .el-button, .table-actions .el-button { min-height: 44px; }
|
||||
.panel-heading { align-items: flex-start; flex-direction: column; padding: 15px; }
|
||||
.table-footer { align-items: flex-start; flex-direction: column; }
|
||||
.table-footer .el-pagination { max-width: 100%; overflow-x: auto; }
|
||||
.business-drawer { width: 100% !important; }
|
||||
.drawer-form { padding: 4px 16px 20px; }
|
||||
.business-drawer .el-drawer__header { padding: 18px 16px 14px; }
|
||||
.business-drawer .el-drawer__footer { padding: 12px 16px; }
|
||||
.form-grid.two-cols, .form-grid.three-cols, .party-grid { grid-template-columns: 1fr; }
|
||||
.business-drawer .metric-strip { margin: 16px 16px 0; grid-template-columns: 1fr; }
|
||||
.business-drawer .metric-item { border-right: 0; border-bottom: 1px solid var(--cc-border); }
|
||||
.business-drawer .metric-item:last-child { border-bottom: 0; }
|
||||
.detail-section, .detail-identity, .order-detail-head { padding-right: 16px; padding-left: 16px; }
|
||||
.detail-identity { grid-template-columns: auto minmax(0, 1fr); }
|
||||
.detail-identity .status-badge { grid-column: 2; justify-self: start; }
|
||||
.section-heading-row { flex-direction: column; }
|
||||
.legend-list { display: none; }
|
||||
.login-panel { padding: 30px 20px; }
|
||||
}
|
||||
|
||||
@media (max-width: 420px) {
|
||||
.metric-strip { grid-template-columns: 1fr; }
|
||||
.metric-item { border-right: 0; border-bottom: 1px solid var(--cc-border); }
|
||||
.metric-item:last-child { border-bottom: 0; }
|
||||
.filter-fields { grid-template-columns: minmax(0, 1fr); }
|
||||
.filter-fields > .el-input { grid-column: auto; }
|
||||
.header-user .user-avatar { display: none; }
|
||||
.forbidden-page { min-height: calc(100vh - 120px); padding: 28px 16px; }
|
||||
.forbidden-page h2 { font-size: 18px; }
|
||||
}
|
||||
|
||||
@media (max-width: 360px) {
|
||||
.app-header { gap: 6px; padding: 8px; }
|
||||
.page-heading h1 { max-width: 100%; font-size: 17px; }
|
||||
.mobile-menu-button, .role-preview-trigger, .header-user .el-button { min-width: 44px; min-height: 44px; }
|
||||
.page-container { padding: 14px 8px 24px; }
|
||||
.forbidden-page { padding-right: 14px; padding-left: 14px; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.app-sidebar, .side-nav-item, .stat-card { transition: none !important; }
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import type {
|
||||
FirstLegOrderStatusLabel,
|
||||
PlatformReceivingWarehouse,
|
||||
PlatformReceivingWarehouseSnapshot,
|
||||
} from '@chongchong/shared'
|
||||
|
||||
export type RecordStatus = '正常' | '停用'
|
||||
export type CustomerStatus = '正常' | '待审核' | '停用'
|
||||
export type RoleStatus = '启用' | '停用'
|
||||
export type PermissionType = '目录' | '菜单' | '按钮' | '接口'
|
||||
export type DataScope = '全部数据' | '本部门数据' | '本人数据' | '自定义'
|
||||
export type OrderStatus = FirstLegOrderStatusLabel
|
||||
export type Warehouse = PlatformReceivingWarehouse
|
||||
export type ExpressCompanyStatus = '合作中' | '已终止'
|
||||
export type CounterpartyStatus = '启用' | '停用'
|
||||
export type CounterpartyType = '供应商' | '客户' | '承运商' | '报关行' | '发货单位' | '收货单位'
|
||||
|
||||
export interface ExpressCompany {
|
||||
id: string
|
||||
name: string
|
||||
code: string
|
||||
website: string
|
||||
phone: string
|
||||
status: ExpressCompanyStatus
|
||||
remark: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface Counterparty {
|
||||
id: string
|
||||
code: string
|
||||
name: string
|
||||
types: CounterpartyType[]
|
||||
contactName: string
|
||||
contactPhone: string
|
||||
email: string
|
||||
country: string
|
||||
province: string
|
||||
city: string
|
||||
postalCode: string
|
||||
address: string
|
||||
status: CounterpartyStatus
|
||||
remark: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface FirstLegDeliveryInfo {
|
||||
expressCompanyId: string
|
||||
expressCompanyCode: string
|
||||
expressCompanyName: string
|
||||
trackingNo: string
|
||||
updatedAt: string
|
||||
operator: string
|
||||
}
|
||||
|
||||
export interface Customer {
|
||||
id: string
|
||||
name: string
|
||||
creditCode: string
|
||||
contact: string
|
||||
phone: string
|
||||
email: string
|
||||
sales: string
|
||||
packageName: '基础版' | '专业版' | '企业版'
|
||||
period: number
|
||||
status: CustomerStatus
|
||||
orders: number
|
||||
joinedAt: string
|
||||
address: string
|
||||
remark: string
|
||||
}
|
||||
|
||||
export interface InternalUser {
|
||||
id: string
|
||||
account: string
|
||||
name: string
|
||||
department: string
|
||||
phone: string
|
||||
email: string
|
||||
roleIds: string[]
|
||||
status: RecordStatus
|
||||
lastLogin: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface Role {
|
||||
id: string
|
||||
name: string
|
||||
code: string
|
||||
description: string
|
||||
userCount: number
|
||||
dataScope: DataScope
|
||||
status: RoleStatus
|
||||
permissionIds: string[]
|
||||
builtIn?: boolean
|
||||
}
|
||||
|
||||
export interface Permission {
|
||||
id: string
|
||||
parentId: string | null
|
||||
name: string
|
||||
code: string
|
||||
type: PermissionType
|
||||
path: string
|
||||
method: string
|
||||
status: RoleStatus
|
||||
sort: number
|
||||
}
|
||||
|
||||
export interface OrderTimelineItem {
|
||||
time: string
|
||||
title: string
|
||||
description: string
|
||||
operator: string
|
||||
}
|
||||
|
||||
export interface FirstLegOrder {
|
||||
id: string
|
||||
no: string
|
||||
customerId: string
|
||||
customerName: string
|
||||
referenceNo: string
|
||||
transportMode: '海运整箱 FCL' | '海运拼箱 LCL' | '空运' | '陆运'
|
||||
businessType: '出口' | '进口'
|
||||
origin: string
|
||||
destination: string
|
||||
receivingWarehouseId: string
|
||||
receivingWarehouseSnapshot: PlatformReceivingWarehouseSnapshot | null
|
||||
container: string
|
||||
etd: string
|
||||
eta: string
|
||||
amount: number
|
||||
currency: 'CNY' | 'USD'
|
||||
status: OrderStatus
|
||||
priority: '普通' | '紧急' | '加急'
|
||||
operator: string
|
||||
shipper: string
|
||||
consignee: string
|
||||
cargoName: string
|
||||
pieces: number
|
||||
weight: number
|
||||
volume: number
|
||||
createdAt: string
|
||||
remark: string
|
||||
deliveryInfo?: FirstLegDeliveryInfo | null
|
||||
timeline: OrderTimelineItem[]
|
||||
}
|
||||
@@ -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,55 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { Permission } from '@/types/admin'
|
||||
import { buildPermissionTree, getDescendantIds, summarizePermissionSelection } from '@/utils/permissionTree'
|
||||
|
||||
const permissions: Permission[] = [
|
||||
{ id: 'action-b', parentId: 'menu', name: '操作 B', code: 'demo:b', type: '按钮', path: '', method: '-', status: '启用', sort: 20 },
|
||||
{ id: 'menu', parentId: 'directory', name: '页面', code: 'demo:list', type: '菜单', path: '/demo', method: '-', status: '启用', sort: 10 },
|
||||
{ id: 'directory', parentId: null, name: '目录', code: 'demo', type: '目录', path: '', method: '-', status: '启用', sort: 10 },
|
||||
{ id: 'action-a', parentId: 'menu', name: '操作 A', code: 'demo:a', type: '按钮', path: '', method: '-', status: '启用', sort: 10 },
|
||||
]
|
||||
|
||||
describe('permissionTree', () => {
|
||||
it('builds arbitrary-depth trees and sorts siblings', () => {
|
||||
const tree = buildPermissionTree(permissions)
|
||||
|
||||
expect(tree[0]?.children?.[0]?.children?.map((node) => node.id)).toEqual(['action-a', 'action-b'])
|
||||
})
|
||||
|
||||
it('keeps the ancestor chain when a deep descendant matches', () => {
|
||||
const tree = buildPermissionTree(permissions, (item) => item.code === 'demo:a')
|
||||
|
||||
expect(tree.map((node) => node.id)).toEqual(['directory'])
|
||||
expect(tree[0]?.children?.map((node) => node.id)).toEqual(['menu'])
|
||||
expect(tree[0]?.children?.[0]?.children?.map((node) => node.id)).toEqual(['action-a'])
|
||||
})
|
||||
|
||||
it('stops building a branch when malformed data repeats an ancestor id', () => {
|
||||
const malformed = [
|
||||
...permissions,
|
||||
{ id: 'loop', parentId: null, name: '循环目录', code: 'loop', type: '目录', path: '', method: '-', status: '启用', sort: 20 },
|
||||
{ id: 'loop', parentId: 'loop', name: '循环下级', code: 'loop:child', type: '目录', path: '', method: '-', status: '启用', sort: 10 },
|
||||
] satisfies Permission[]
|
||||
|
||||
const tree = buildPermissionTree(malformed)
|
||||
|
||||
expect(tree.find((node) => node.code === 'loop')).not.toHaveProperty('children')
|
||||
})
|
||||
|
||||
it('collects descendants once when malformed data contains a cycle', () => {
|
||||
const malformed = [
|
||||
...permissions,
|
||||
{ id: 'cycle-a', parentId: 'cycle-b', name: 'A', code: 'cycle:a', type: '目录', path: '', method: '-', status: '启用', sort: 1 },
|
||||
{ id: 'cycle-b', parentId: 'cycle-a', name: 'B', code: 'cycle:b', type: '目录', path: '', method: '-', status: '启用', sort: 2 },
|
||||
] satisfies Permission[]
|
||||
|
||||
expect(getDescendantIds(malformed, 'cycle-a')).toEqual(['cycle-b'])
|
||||
})
|
||||
|
||||
it('counts page menus and operations without treating directories as menus', () => {
|
||||
expect(summarizePermissionSelection(permissions, ['directory', 'menu', 'action-a'])).toEqual({
|
||||
menuCount: 1,
|
||||
actionCount: 1,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { Permission } from '@/types/admin'
|
||||
|
||||
export type PermissionTreeNode = Permission & { children?: PermissionTreeNode[] }
|
||||
export type PermissionMatcher = (permission: Permission) => boolean
|
||||
|
||||
function groupByParent(permissions: Permission[]) {
|
||||
const childrenByParent = new Map<string | null, Permission[]>()
|
||||
|
||||
permissions.forEach((permission) => {
|
||||
const siblings = childrenByParent.get(permission.parentId) ?? []
|
||||
siblings.push(permission)
|
||||
childrenByParent.set(permission.parentId, siblings)
|
||||
})
|
||||
|
||||
childrenByParent.forEach((siblings) => {
|
||||
siblings.sort((left, right) => left.sort - right.sort)
|
||||
})
|
||||
|
||||
return childrenByParent
|
||||
}
|
||||
|
||||
export function buildPermissionTree(
|
||||
permissions: Permission[],
|
||||
matches: PermissionMatcher = () => true,
|
||||
): PermissionTreeNode[] {
|
||||
const childrenByParent = groupByParent(permissions)
|
||||
|
||||
function buildNode(permission: Permission, ancestors: Set<string>): PermissionTreeNode | null {
|
||||
if (ancestors.has(permission.id)) return null
|
||||
|
||||
const nextAncestors = new Set(ancestors).add(permission.id)
|
||||
const children = (childrenByParent.get(permission.id) ?? [])
|
||||
.map((child) => buildNode(child, nextAncestors))
|
||||
.filter((child): child is PermissionTreeNode => child !== null)
|
||||
|
||||
if (!matches(permission) && children.length === 0) return null
|
||||
|
||||
return {
|
||||
...permission,
|
||||
...(children.length ? { children } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
return (childrenByParent.get(null) ?? [])
|
||||
.map((permission) => buildNode(permission, new Set()))
|
||||
.filter((node): node is PermissionTreeNode => node !== null)
|
||||
}
|
||||
|
||||
export function getDescendantIds(permissions: Permission[], permissionId: string): string[] {
|
||||
const childrenByParent = groupByParent(permissions)
|
||||
const visited = new Set([permissionId])
|
||||
const descendants: string[] = []
|
||||
const queue = [...(childrenByParent.get(permissionId) ?? [])]
|
||||
|
||||
while (queue.length) {
|
||||
const current = queue.shift()
|
||||
if (!current || visited.has(current.id)) continue
|
||||
|
||||
visited.add(current.id)
|
||||
descendants.push(current.id)
|
||||
queue.push(...(childrenByParent.get(current.id) ?? []))
|
||||
}
|
||||
|
||||
return descendants
|
||||
}
|
||||
|
||||
export function summarizePermissionSelection(permissions: Permission[], selectedIds: string[]) {
|
||||
const selected = new Set(selectedIds)
|
||||
const selectedPermissions = permissions.filter((permission) => selected.has(permission.id))
|
||||
|
||||
return {
|
||||
menuCount: selectedPermissions.filter((permission) => permission.type === '菜单').length,
|
||||
actionCount: selectedPermissions.filter((permission) => ['按钮', '接口'].includes(permission.type)).length,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
import ElementPlus, { type MessageBoxData } from 'element-plus'
|
||||
import { createPinia, setActivePinia, type Pinia } from 'pinia'
|
||||
import { enableAutoUnmount, flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
import { useAccessPreviewStore } from '@/stores/accessPreview'
|
||||
import { useUnsavedWorkStore } from '@/stores/unsavedWork'
|
||||
import CustomersView from './CustomersView.vue'
|
||||
import FirstLegOrdersView from './FirstLegOrdersView.vue'
|
||||
import InternalUsersView from './InternalUsersView.vue'
|
||||
import PermissionsView from './PermissionsView.vue'
|
||||
import RolesView from './RolesView.vue'
|
||||
import WarehousesView from './WarehousesView.vue'
|
||||
import { useAdminDataStore } from '@/stores/adminData'
|
||||
import { ElMessageBox } from 'element-plus'
|
||||
|
||||
class ResizeObserverStub implements ResizeObserver {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
|
||||
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),
|
||||
}
|
||||
}
|
||||
|
||||
async function mountForRole(
|
||||
component: typeof CustomersView | typeof FirstLegOrdersView | typeof InternalUsersView | typeof RolesView | typeof PermissionsView | typeof WarehousesView,
|
||||
roleId: string,
|
||||
) {
|
||||
const pinia: Pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
const access = useAccessPreviewStore()
|
||||
access.initializeAfterLogin()
|
||||
expect(access.commitRole(roleId)).toBe(true)
|
||||
const wrapper = mount(component, {
|
||||
attachTo: document.body,
|
||||
global: { plugins: [pinia, ElementPlus] },
|
||||
})
|
||||
await flushPromises()
|
||||
return { wrapper, access, unsaved: useUnsavedWorkStore() }
|
||||
}
|
||||
|
||||
function buttonTexts(wrapper: VueWrapper) {
|
||||
return wrapper.findAll('button').map((button) => button.text().trim())
|
||||
}
|
||||
|
||||
async function clickButton(wrapper: VueWrapper, text: string) {
|
||||
const button = wrapper.findAll('button').find((candidate) => candidate.text().trim() === text)
|
||||
if (!button) throw new Error(`Button not found: ${text}`)
|
||||
await button.trigger('click')
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
async function fillNativeInput(placeholder: string, value: string) {
|
||||
const input = document.querySelector<HTMLInputElement>(`input[placeholder="${placeholder}"]`)
|
||||
if (!input) throw new Error(`Input not found: ${placeholder}`)
|
||||
input.value = value
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
await nextTick()
|
||||
}
|
||||
|
||||
enableAutoUnmount(afterEach)
|
||||
afterAll(() => vi.unstubAllGlobals())
|
||||
|
||||
describe('business action permissions', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('localStorage', createMemoryStorage())
|
||||
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
it('keeps review actions out of customer records now that reviews use a dedicated route', async () => {
|
||||
const { wrapper } = await mountForRole(CustomersView, 'R002')
|
||||
const texts = buttonTexts(wrapper)
|
||||
|
||||
expect(texts).not.toContain('新增客户')
|
||||
expect(texts).not.toContain('审核')
|
||||
expect(texts).toContain('编辑')
|
||||
})
|
||||
|
||||
it('shows no audit or status action for a pending customer without audit permission', async () => {
|
||||
const { wrapper } = await mountForRole(CustomersView, 'R005')
|
||||
const pendingRow = wrapper.findAll('tr')
|
||||
.find((row) => row.text().includes('北京国贸跨境供应链有限公司'))
|
||||
expect(pendingRow).toBeDefined()
|
||||
|
||||
const actions = pendingRow?.findAll('button').map((button) => button.text().trim()) ?? []
|
||||
expect(actions).not.toContain('审核')
|
||||
expect(actions).not.toContain('停用')
|
||||
expect(actions).not.toContain('启用')
|
||||
})
|
||||
|
||||
it('lets sales create and edit customers while finance cannot update orders', async () => {
|
||||
const sales = await mountForRole(CustomersView, 'R005')
|
||||
expect(buttonTexts(sales.wrapper)).toContain('新增客户')
|
||||
sales.wrapper.unmount()
|
||||
document.body.innerHTML = ''
|
||||
|
||||
const finance = await mountForRole(FirstLegOrdersView, 'R004')
|
||||
const texts = buttonTexts(finance.wrapper)
|
||||
expect(texts).not.toContain('创建订单')
|
||||
expect(texts).not.toContain('批量更新')
|
||||
expect(texts).not.toContain('处理')
|
||||
expect(texts).toContain('详情')
|
||||
expect(texts).not.toContain('编辑')
|
||||
})
|
||||
|
||||
it('shows create actions to the super administrator but no order status actions to sales', async () => {
|
||||
const superAdmin = await mountForRole(FirstLegOrdersView, 'R001')
|
||||
expect(buttonTexts(superAdmin.wrapper)).toContain('创建订单')
|
||||
expect(buttonTexts(superAdmin.wrapper)).toContain('批量更新')
|
||||
expect(buttonTexts(superAdmin.wrapper)).toContain('处理')
|
||||
superAdmin.wrapper.unmount()
|
||||
document.body.innerHTML = ''
|
||||
|
||||
const sales = await mountForRole(FirstLegOrdersView, 'R005')
|
||||
const texts = buttonTexts(sales.wrapper)
|
||||
expect(texts).not.toContain('批量更新')
|
||||
expect(texts).not.toContain('处理')
|
||||
expect(texts).not.toContain('编辑')
|
||||
})
|
||||
|
||||
it('lets operations maintain warehouses while operators can only view them', async () => {
|
||||
const operations = await mountForRole(WarehousesView, 'R002')
|
||||
expect(buttonTexts(operations.wrapper)).toContain('新增仓库')
|
||||
expect(buttonTexts(operations.wrapper)).toContain('编辑')
|
||||
expect(buttonTexts(operations.wrapper)).toContain('停用')
|
||||
operations.wrapper.unmount()
|
||||
document.body.innerHTML = ''
|
||||
|
||||
const operator = await mountForRole(WarehousesView, 'R003')
|
||||
expect(buttonTexts(operator.wrapper)).not.toContain('新增仓库')
|
||||
expect(buttonTexts(operator.wrapper)).not.toContain('编辑')
|
||||
expect(operator.wrapper.text()).toContain('仅查看')
|
||||
})
|
||||
|
||||
it('registers a dirty customer draft and discards it when create permission is lost', async () => {
|
||||
const { wrapper, access, unsaved } = await mountForRole(CustomersView, 'R001')
|
||||
await clickButton(wrapper, '新增客户')
|
||||
await fillNativeInput('请输入营业执照上的企业名称', '测试客户')
|
||||
|
||||
expect(unsaved.entries).toContainEqual({
|
||||
key: 'customer-editor',
|
||||
label: '新增客户',
|
||||
dirty: true,
|
||||
requiredPermission: 'customer:create',
|
||||
})
|
||||
|
||||
expect(access.commitRole('R002')).toBe(true)
|
||||
await flushPromises()
|
||||
|
||||
expect(unsaved.entries.find((entry) => entry.key === 'customer-editor')).toBeUndefined()
|
||||
expect(document.body.textContent).not.toContain('请输入营业执照上的企业名称')
|
||||
})
|
||||
|
||||
it.each([
|
||||
[CustomersView, '新增客户'],
|
||||
[FirstLegOrdersView, '创建订单'],
|
||||
[InternalUsersView, '新增用户'],
|
||||
[RolesView, '新增角色'],
|
||||
[PermissionsView, '新增权限'],
|
||||
] as const)('keeps the role preview reachable while the %s editor is open', async (component, openText) => {
|
||||
const { wrapper } = await mountForRole(component, 'R001')
|
||||
await clickButton(wrapper, openText)
|
||||
|
||||
const editor = wrapper.findComponent({ name: 'ElDrawer' })
|
||||
expect(editor.exists()).toBe(true)
|
||||
expect(document.querySelector('.editor-drawer')).not.toBeNull()
|
||||
expect(editor.props('modal')).toBe(false)
|
||||
expect(editor.props('closeOnClickModal')).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps an existing customer editor open when create permission is lost', async () => {
|
||||
const { wrapper, access, unsaved } = await mountForRole(CustomersView, 'R001')
|
||||
await clickButton(wrapper, '编辑')
|
||||
|
||||
expect(unsaved.entries.find((entry) => entry.key === 'customer-editor')).toMatchObject({
|
||||
label: '编辑客户',
|
||||
requiredPermission: null,
|
||||
})
|
||||
|
||||
expect(access.commitRole('R002')).toBe(true)
|
||||
await flushPromises()
|
||||
|
||||
expect(unsaved.entries.find((entry) => entry.key === 'customer-editor')).toBeDefined()
|
||||
expect(document.body.textContent).toContain('编辑客户')
|
||||
})
|
||||
|
||||
it('discards only a new order editor when create permission is lost', async () => {
|
||||
const { wrapper, access, unsaved } = await mountForRole(FirstLegOrdersView, 'R001')
|
||||
await clickButton(wrapper, '创建订单')
|
||||
await fillNativeInput('如 宁波', '上海')
|
||||
|
||||
expect(unsaved.entries.find((entry) => entry.key === 'first-leg-order-editor')).toMatchObject({
|
||||
label: '创建头程订单',
|
||||
dirty: true,
|
||||
requiredPermission: 'first-leg:create',
|
||||
})
|
||||
|
||||
expect(access.commitRole('R004')).toBe(true)
|
||||
await flushPromises()
|
||||
expect(unsaved.entries.find((entry) => entry.key === 'first-leg-order-editor')).toBeUndefined()
|
||||
|
||||
access.commitRole('R001')
|
||||
await nextTick()
|
||||
await clickButton(wrapper, '编辑')
|
||||
expect(unsaved.entries.find((entry) => entry.key === 'first-leg-order-editor')).toMatchObject({
|
||||
label: '编辑头程订单',
|
||||
requiredPermission: null,
|
||||
})
|
||||
|
||||
expect(access.commitRole('R004')).toBe(true)
|
||||
await flushPromises()
|
||||
expect(unsaved.entries.find((entry) => entry.key === 'first-leg-order-editor')).toBeDefined()
|
||||
expect(document.body.textContent).toContain('编辑头程订单')
|
||||
})
|
||||
|
||||
it.each([
|
||||
[InternalUsersView, '系统管理员', ['编辑', '重置密码', '停用']],
|
||||
[RolesView, '超级管理员', ['配置', '停用', '删除']],
|
||||
[PermissionsView, '系统管理', ['编辑', '添加下级', '停用']],
|
||||
] as const)('hides every maintenance action on %s rows for the operations manager', async (component, rowText, actions) => {
|
||||
const { wrapper } = await mountForRole(component, 'R002')
|
||||
const row = wrapper.findAll('tr').find((candidate) => candidate.text().includes(rowText))
|
||||
expect(row).toBeDefined()
|
||||
const rowActions = row?.findAll('button').map((button) => button.text().trim()) ?? []
|
||||
for (const action of actions) expect(rowActions).not.toContain(action)
|
||||
})
|
||||
|
||||
it.each([
|
||||
[InternalUsersView, '新增用户', '系统管理员', '编辑'],
|
||||
[RolesView, '新增角色', '超级管理员', '配置'],
|
||||
[PermissionsView, '新增权限', '系统管理', '添加下级'],
|
||||
] as const)('shows toolbar and row maintenance actions on %s for the super administrator', async (component, toolbarAction, rowText, rowAction) => {
|
||||
const { wrapper } = await mountForRole(component, 'R001')
|
||||
expect(buttonTexts(wrapper)).toContain(toolbarAction)
|
||||
const row = wrapper.findAll('tr').find((candidate) => candidate.text().includes(rowText))
|
||||
expect(row?.findAll('button').map((button) => button.text().trim())).toContain(rowAction)
|
||||
})
|
||||
|
||||
it.each([
|
||||
[InternalUsersView, '新增用户', null, null, 'internal-user-editor', '新增内部用户', 'system:user:write'],
|
||||
[RolesView, '新增角色', null, null, 'role-editor', '新增角色', 'system:role:grant'],
|
||||
[PermissionsView, '新增权限', '用户可理解的中文名称', '测试权限', 'permission-editor', '新增权限', 'system:permission:write'],
|
||||
] as const)('registers system editor dirty work with the maintenance permission on %s', async (component, openText, placeholder, value, key, label, requiredPermission) => {
|
||||
const { wrapper, unsaved } = await mountForRole(component, 'R001')
|
||||
await clickButton(wrapper, openText)
|
||||
if (placeholder && value) await fillNativeInput(placeholder, value)
|
||||
if (!placeholder) {
|
||||
const nameInput = document.querySelector<HTMLInputElement>('.el-drawer input')
|
||||
expect(nameInput).not.toBeNull()
|
||||
if (nameInput) {
|
||||
nameInput.value = `${label}测试内容`
|
||||
nameInput.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
await nextTick()
|
||||
}
|
||||
}
|
||||
|
||||
expect(unsaved.entries.find((entry) => entry.key === key)).toMatchObject({
|
||||
label,
|
||||
dirty: true,
|
||||
requiredPermission,
|
||||
})
|
||||
expect(buttonTexts(wrapper)).toContain(component === InternalUsersView ? '保存用户' : component === RolesView ? '保存角色' : '保存权限')
|
||||
})
|
||||
|
||||
it('marks the role editor dirty when only a permission checkbox changes', async () => {
|
||||
const { wrapper, unsaved } = await mountForRole(RolesView, 'R001')
|
||||
await clickButton(wrapper, '配置')
|
||||
const permissionCheckbox = document.querySelector<HTMLElement>('.el-drawer .el-tree .el-checkbox')
|
||||
expect(permissionCheckbox).not.toBeNull()
|
||||
permissionCheckbox?.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(unsaved.entries.find((entry) => entry.key === 'role-editor')).toMatchObject({
|
||||
dirty: true,
|
||||
requiredPermission: 'system:role:grant',
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
[InternalUsersView, '保存用户'],
|
||||
[RolesView, '保存角色'],
|
||||
[PermissionsView, '保存权限'],
|
||||
] as const)('does not render a save entry on %s without maintenance permission', async (component, saveText) => {
|
||||
const { wrapper } = await mountForRole(component, 'R002')
|
||||
expect(buttonTexts(wrapper)).not.toContain(saveText)
|
||||
})
|
||||
|
||||
it('warns that disabling a directory is non-cascading and preserves descendant statuses', async () => {
|
||||
const confirm = vi.spyOn(ElMessageBox, 'confirm').mockResolvedValue({
|
||||
value: '',
|
||||
action: 'confirm',
|
||||
} as unknown as MessageBoxData)
|
||||
const { wrapper } = await mountForRole(PermissionsView, 'R001')
|
||||
const data = useAdminDataStore()
|
||||
const directory = data.permissions.find((permission) => permission.id === 'P020')
|
||||
const descendant = data.permissions.find((permission) => permission.id === 'P300')
|
||||
expect(directory?.status).toBe('启用')
|
||||
expect(descendant?.status).toBe('启用')
|
||||
|
||||
const row = wrapper.findAll('tr').find((candidate) => candidate.text().includes('系统管理'))
|
||||
const stop = row?.findAll('button').find((button) => button.text().trim() === '停用')
|
||||
expect(stop).toBeDefined()
|
||||
await stop?.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(confirm).toHaveBeenCalledWith(
|
||||
expect.stringContaining('目录仅用于组织结构,停用不会级联影响已启用的下级菜单和操作。'),
|
||||
'权限状态',
|
||||
expect.any(Object),
|
||||
)
|
||||
expect(data.permissions.find((permission) => permission.id === 'P020')?.status).toBe('停用')
|
||||
expect(data.permissions.find((permission) => permission.id === 'P300')?.status).toBe('启用')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,182 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Building2, Eye, FileUp, MapPin, Pencil, Plus, RotateCcw, Search } from '@lucide/vue'
|
||||
import MetricStrip from '@/components/MetricStrip.vue'
|
||||
import StaticBatchImportDialog from '@/components/StaticBatchImportDialog.vue'
|
||||
import StatusBadge from '@/components/StatusBadge.vue'
|
||||
import { useUnsavedWorkRegistration } from '@/composables/useUnsavedWorkRegistration'
|
||||
import { useAccessPreviewStore } from '@/stores/accessPreview'
|
||||
import { useAdminDataStore } from '@/stores/adminData'
|
||||
import type { Counterparty, CounterpartyStatus, CounterpartyType } from '@/types/admin'
|
||||
|
||||
const store = useAdminDataStore()
|
||||
const access = useAccessPreviewStore()
|
||||
const page = ref(1)
|
||||
const pageSize = 10
|
||||
const editorOpen = ref(false)
|
||||
const detailOpen = ref(false)
|
||||
const importOpen = ref(false)
|
||||
const selectedCounterparty = ref<Counterparty | null>(null)
|
||||
const filters = reactive({ keyword: '', type: '' as CounterpartyType | '', status: '' as CounterpartyStatus | '' })
|
||||
const counterpartyTypes: CounterpartyType[] = ['供应商', '客户', '承运商', '报关行', '发货单位', '收货单位']
|
||||
|
||||
const emptyForm = (): Omit<Counterparty, 'updatedAt'> => ({
|
||||
id: '', code: '', name: '', types: [], contactName: '', contactPhone: '', email: '',
|
||||
country: '中国', province: '', city: '', postalCode: '', address: '', status: '启用', remark: '',
|
||||
})
|
||||
const form = reactive(emptyForm())
|
||||
const editorLabel = computed(() => form.id ? '编辑往来单位' : '新增往来单位')
|
||||
const { captureBaseline } = useUnsavedWorkRegistration({
|
||||
key: 'counterparty-editor',
|
||||
label: editorLabel,
|
||||
isOpen: editorOpen,
|
||||
model: form,
|
||||
requiredPermission: computed(() => form.id ? null : 'counterparty:write'),
|
||||
})
|
||||
|
||||
const filteredRows = computed(() => store.counterparties.filter((item) => {
|
||||
const keyword = filters.keyword.trim().toLowerCase()
|
||||
const keywordMatches = !keyword || [item.name, item.code, item.contactName, item.contactPhone, item.email]
|
||||
.some((value) => value.toLowerCase().includes(keyword))
|
||||
return keywordMatches && (!filters.type || item.types.includes(filters.type)) && (!filters.status || item.status === filters.status)
|
||||
}))
|
||||
const pagedRows = computed(() => filteredRows.value.slice((page.value - 1) * pageSize, page.value * pageSize))
|
||||
const metrics = computed(() => [
|
||||
{ label: '全部单位', value: store.counterparties.length, note: '当前静态数据范围' },
|
||||
{ label: '启用单位', value: store.activeCounterparties, note: '可供业务引用' },
|
||||
{ label: '多类型单位', value: store.counterparties.filter((item) => item.types.length > 1).length, note: '一个档案承担多个角色' },
|
||||
{ label: '收发货单位', value: store.counterparties.filter((item) => item.types.some((type) => ['发货单位', '收货单位'].includes(type))).length, note: '客户端只读选择范围' },
|
||||
])
|
||||
|
||||
watch(filters, () => { page.value = 1 })
|
||||
|
||||
function resetFilters() { Object.assign(filters, { keyword: '', type: '', status: '' }) }
|
||||
function openCreate() { Object.assign(form, emptyForm()); captureBaseline(); editorOpen.value = true }
|
||||
function openEdit(item: Counterparty) { Object.assign(form, { ...item, types: [...item.types] }); captureBaseline(); detailOpen.value = false; editorOpen.value = true }
|
||||
function openDetail(item: Counterparty) { selectedCounterparty.value = item; detailOpen.value = true }
|
||||
|
||||
function saveCounterparty() {
|
||||
if (!form.code.trim() || !form.name.trim() || form.types.length === 0 || !form.contactName.trim() || !form.contactPhone.trim() || !form.country.trim() || !form.city.trim() || !form.address.trim()) {
|
||||
ElMessage.warning('请填写单位编码、名称、至少一种类型、联系人、电话及完整地址')
|
||||
return
|
||||
}
|
||||
if (form.email && !/^\S+@\S+\.\S+$/.test(form.email)) {
|
||||
ElMessage.warning('请输入正确的联系邮箱')
|
||||
return
|
||||
}
|
||||
try {
|
||||
store.saveCounterparty({ ...form, id: form.id || undefined, types: [...form.types] })
|
||||
editorOpen.value = false
|
||||
ElMessage.success(form.id ? '往来单位已更新' : '往来单位已创建')
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '往来单位保存失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleStatus(item: Counterparty) {
|
||||
const target: CounterpartyStatus = item.status === '启用' ? '停用' : '启用'
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
target === '停用' ? '停用后不能用于新订单,历史订单快照不受影响。' : '启用后可重新用于符合类型和数据范围的新订单。',
|
||||
`${target}往来单位“${item.name}”`,
|
||||
{ confirmButtonText: target, cancelButtonText: '取消', type: 'warning' },
|
||||
)
|
||||
store.setCounterpartyStatus(item.id, target)
|
||||
ElMessage.success(`往来单位已${target}`)
|
||||
} catch {
|
||||
// 用户取消,不需要反馈。
|
||||
}
|
||||
}
|
||||
|
||||
function addressText(item: Counterparty) {
|
||||
return [item.country, item.province, item.city, item.address, item.postalCode].filter(Boolean).join(' · ')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- THESIS: 把多角色往来单位整理成可扫描、可维护的主数据目录,而不是复制原型的宽表。
|
||||
OWN-WORLD: 延续 Admin 的海军蓝导航、白色数据面板、紧凑筛选区、状态标签与右侧抽屉。
|
||||
STORY: 平台人员按类型和状态定位单位,核对联系地址,维护多类型档案或执行整批导入。
|
||||
FIRST VIEWPORT: 四项运营摘要、三条件筛选、维护与导入动作、单位表格连续出现。
|
||||
FORM: 现有 Operate 页面结构的扩展,类型用标签聚合,完整资料在抽屉中展开。
|
||||
FINISH: unreviewed and undocumented is unfinished; this build ends with the finish review, the verdict, and DESIGN.md -->
|
||||
<div class="business-page counterparty-page">
|
||||
<MetricStrip :items="metrics" />
|
||||
|
||||
<section class="surface-panel filter-panel" aria-label="往来单位筛选">
|
||||
<div class="filter-fields">
|
||||
<el-input v-model="filters.keyword" clearable placeholder="单位名称、编码、联系人或电话" aria-label="往来单位关键词"><template #prefix><Search :size="16" /></template></el-input>
|
||||
<el-select v-model="filters.type" clearable placeholder="全部类型" aria-label="往来单位类型"><el-option v-for="item in counterpartyTypes" :key="item" :label="item" :value="item" /></el-select>
|
||||
<el-select v-model="filters.status" clearable placeholder="全部状态" aria-label="往来单位状态"><el-option label="启用" value="启用" /><el-option label="停用" value="停用" /></el-select>
|
||||
</div>
|
||||
<div class="filter-actions">
|
||||
<el-button @click="resetFilters"><RotateCcw :size="16" />重置</el-button>
|
||||
<el-button v-if="access.can('counterparty:import')" @click="importOpen = true"><FileUp :size="16" />批量导入</el-button>
|
||||
<el-button v-if="access.can('counterparty:write')" type="primary" @click="openCreate"><Plus :size="16" />新增单位</el-button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="surface-panel table-panel">
|
||||
<header class="panel-heading"><div><h2>往来单位档案</h2><p>平台维护完整主数据;企业客户端仅可只读选择已启用的发货单位和收货单位。</p></div></header>
|
||||
<el-table :data="pagedRows" row-key="id" empty-text="没有符合条件的往来单位">
|
||||
<el-table-column label="单位" min-width="230"><template #default="{ row }"><div class="counterparty-cell"><span><Building2 :size="17" /></span><div><strong>{{ (row as Counterparty).name }}</strong><small>{{ (row as Counterparty).code }}</small></div></div></template></el-table-column>
|
||||
<el-table-column label="类型" min-width="220"><template #default="{ row }"><div class="type-tags"><el-tag v-for="type in (row as Counterparty).types" :key="type" size="small" effect="plain">{{ type }}</el-tag></div></template></el-table-column>
|
||||
<el-table-column label="主要联系人" min-width="170"><template #default="{ row }"><div class="primary-cell"><strong>{{ (row as Counterparty).contactName }}</strong><span>{{ (row as Counterparty).contactPhone }}</span></div></template></el-table-column>
|
||||
<el-table-column label="城市 / 地址" min-width="210"><template #default="{ row }"><div class="primary-cell"><strong>{{ (row as Counterparty).country }} · {{ (row as Counterparty).city }}</strong><span>{{ (row as Counterparty).address }}</span></div></template></el-table-column>
|
||||
<el-table-column label="状态" width="90"><template #default="{ row }"><StatusBadge :status="(row as Counterparty).status" /></template></el-table-column>
|
||||
<el-table-column label="操作" width="220" fixed="right"><template #default="{ row }"><div class="table-actions"><el-button link type="primary" @click="openDetail(row as Counterparty)"><Eye :size="15" />详情</el-button><el-button v-if="access.can('counterparty:write')" link type="primary" @click="openEdit(row as Counterparty)"><Pencil :size="15" />编辑</el-button><el-button v-if="access.can('counterparty:write')" link :type="(row as Counterparty).status === '启用' ? 'danger' : 'success'" @click="toggleStatus(row as Counterparty)">{{ (row as Counterparty).status === '启用' ? '停用' : '启用' }}</el-button></div></template></el-table-column>
|
||||
</el-table>
|
||||
<footer class="table-footer"><span>显示 {{ pagedRows.length }} 条,共 {{ filteredRows.length }} 条</span><el-pagination v-model:current-page="page" :page-size="pageSize" :total="filteredRows.length" layout="prev, pager, next" /></footer>
|
||||
</section>
|
||||
|
||||
<el-drawer v-model="editorOpen" :title="form.id ? '编辑往来单位' : '新增往来单位'" size="760px" class="business-drawer editor-drawer" :modal="false" :close-on-click-modal="false">
|
||||
<el-form label-position="top" class="drawer-form">
|
||||
<div class="form-section"><h3>单位信息</h3><div class="form-grid two-cols">
|
||||
<el-form-item label="单位编码" required><el-input v-model="form.code" placeholder="如 CP-SUP-001" maxlength="40" /></el-form-item>
|
||||
<el-form-item label="单位名称" required><el-input v-model="form.name" placeholder="请输入营业执照或登记名称" maxlength="120" /></el-form-item>
|
||||
<el-form-item class="wide-field" label="单位类型" required><el-select v-model="form.types" multiple collapse-tags :max-collapse-tags="3" placeholder="可多选"><el-option v-for="item in counterpartyTypes" :key="item" :label="item" :value="item" /></el-select></el-form-item>
|
||||
<el-form-item label="状态"><el-radio-group v-model="form.status"><el-radio-button label="启用" value="启用" /><el-radio-button label="停用" value="停用" /></el-radio-group></el-form-item>
|
||||
</div></div>
|
||||
<div class="form-section"><h3>联系信息</h3><div class="form-grid two-cols">
|
||||
<el-form-item label="联系人" required><el-input v-model="form.contactName" maxlength="60" /></el-form-item>
|
||||
<el-form-item label="联系电话" required><el-input v-model="form.contactPhone" maxlength="32" /></el-form-item>
|
||||
<el-form-item label="联系邮箱"><el-input v-model="form.email" maxlength="120" /></el-form-item>
|
||||
</div></div>
|
||||
<div class="form-section"><h3>主要地址</h3><div class="form-grid three-cols">
|
||||
<el-form-item label="国家/地区" required><el-input v-model="form.country" maxlength="40" /></el-form-item>
|
||||
<el-form-item label="省/州"><el-input v-model="form.province" maxlength="60" /></el-form-item>
|
||||
<el-form-item label="城市" required><el-input v-model="form.city" maxlength="60" /></el-form-item>
|
||||
<el-form-item label="邮政编码"><el-input v-model="form.postalCode" maxlength="20" /></el-form-item>
|
||||
<el-form-item class="wide-field" label="详细地址" required><el-input v-model="form.address" maxlength="180" /></el-form-item>
|
||||
</div><el-form-item label="内部备注"><el-input v-model="form.remark" type="textarea" :rows="3" maxlength="300" show-word-limit /></el-form-item></div>
|
||||
</el-form>
|
||||
<template #footer><div class="drawer-footer"><el-button @click="editorOpen = false">取消</el-button><el-button v-if="access.can('counterparty:write')" type="primary" @click="saveCounterparty">保存往来单位</el-button></div></template>
|
||||
</el-drawer>
|
||||
|
||||
<el-drawer v-model="detailOpen" title="往来单位详情" size="620px" class="business-drawer">
|
||||
<template v-if="selectedCounterparty">
|
||||
<div class="detail-identity"><span><Building2 :size="20" /></span><div><h2>{{ selectedCounterparty.name }}</h2><p>{{ selectedCounterparty.code }} · {{ selectedCounterparty.id }}</p></div><StatusBadge :status="selectedCounterparty.status" /></div>
|
||||
<div class="detail-section"><h3>单位角色</h3><div class="type-tags"><el-tag v-for="type in selectedCounterparty.types" :key="type" effect="plain">{{ type }}</el-tag></div></div>
|
||||
<div class="detail-section"><h3>联系与地址</h3><el-descriptions :column="1" border><el-descriptions-item label="联系人">{{ selectedCounterparty.contactName }} · {{ selectedCounterparty.contactPhone }}</el-descriptions-item><el-descriptions-item label="邮箱">{{ selectedCounterparty.email || '—' }}</el-descriptions-item><el-descriptions-item label="完整地址"><span class="address-value"><MapPin :size="15" />{{ addressText(selectedCounterparty) }}</span></el-descriptions-item><el-descriptions-item label="更新时间">{{ selectedCounterparty.updatedAt }}</el-descriptions-item></el-descriptions></div>
|
||||
<div class="detail-section"><h3>内部备注</h3><p class="detail-note">{{ selectedCounterparty.remark || '暂无内部备注。' }}</p></div>
|
||||
</template>
|
||||
<template #footer><div class="drawer-footer"><el-button @click="detailOpen = false">关闭</el-button><el-button v-if="access.can('counterparty:write') && selectedCounterparty" type="primary" @click="openEdit(selectedCounterparty)">编辑资料</el-button></div></template>
|
||||
</el-drawer>
|
||||
|
||||
<StaticBatchImportDialog v-model="importOpen" entity-label="往来单位" template-url="/templates/counterparties-import-template.xlsx" template-file-name="往来单位导入标准模板-v1.xlsx" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.counterparty-cell { display: flex; min-width: 0; align-items: center; gap: 10px; }
|
||||
.counterparty-cell > span { display: grid; width: 34px; height: 34px; flex: 0 0 34px; place-items: center; background: #eef3fa; color: var(--cc-primary); }
|
||||
.counterparty-cell > div { display: flex; min-width: 0; flex-direction: column; gap: 3px; }
|
||||
.counterparty-cell strong, .counterparty-cell small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.counterparty-cell small { color: var(--cc-muted); font-size: 11px; }
|
||||
.type-tags { display: flex; flex-wrap: wrap; gap: 5px; }
|
||||
.wide-field { grid-column: span 2; }
|
||||
.address-value { display: inline-flex; align-items: flex-start; gap: 6px; line-height: 1.6; }
|
||||
.address-value svg { flex: 0 0 auto; margin-top: 2px; color: var(--cc-muted); }
|
||||
@media (max-width: 720px) { .wide-field { grid-column: auto; } }
|
||||
</style>
|
||||
@@ -0,0 +1,190 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Download, Eye, Pencil, Plus, RotateCcw, Search } from '@lucide/vue'
|
||||
import MetricStrip from '@/components/MetricStrip.vue'
|
||||
import StatusBadge from '@/components/StatusBadge.vue'
|
||||
import { useUnsavedWorkRegistration } from '@/composables/useUnsavedWorkRegistration'
|
||||
import { useAccessPreviewStore } from '@/stores/accessPreview'
|
||||
import { useAdminDataStore } from '@/stores/adminData'
|
||||
import type { Customer } from '@/types/admin'
|
||||
|
||||
const store = useAdminDataStore()
|
||||
const access = useAccessPreviewStore()
|
||||
const page = ref(1)
|
||||
const pageSize = 10
|
||||
const editorOpen = ref(false)
|
||||
const detailOpen = ref(false)
|
||||
const currentCustomer = ref<Customer | null>(null)
|
||||
const filters = reactive({ keyword: '', status: '', packageName: '' })
|
||||
|
||||
const emptyForm = (): Omit<Customer, 'orders' | 'joinedAt'> => ({
|
||||
id: '', name: '', creditCode: '', contact: '', phone: '', email: '', sales: '',
|
||||
packageName: '基础版', period: 30, status: '正常', address: '', remark: '',
|
||||
})
|
||||
const form = reactive(emptyForm())
|
||||
const editorLabel = computed(() => form.id ? '编辑客户' : '新增客户')
|
||||
const editorRequiredPermission = computed(() => form.id ? null : 'customer:create')
|
||||
const { captureBaseline } = useUnsavedWorkRegistration({
|
||||
key: 'customer-editor',
|
||||
label: editorLabel,
|
||||
isOpen: editorOpen,
|
||||
model: form,
|
||||
requiredPermission: editorRequiredPermission,
|
||||
})
|
||||
|
||||
const filteredRows = computed(() => store.customers.filter((item) => {
|
||||
const keyword = filters.keyword.trim().toLowerCase()
|
||||
const matchesKeyword = !keyword || [item.name, item.id, item.contact, item.phone, item.creditCode]
|
||||
.some((value) => value.toLowerCase().includes(keyword))
|
||||
return matchesKeyword && (!filters.status || item.status === filters.status) && (!filters.packageName || item.packageName === filters.packageName)
|
||||
}))
|
||||
const pagedRows = computed(() => filteredRows.value.slice((page.value - 1) * pageSize, page.value * pageSize))
|
||||
const metrics = computed(() => [
|
||||
{ label: '全部客户', value: store.customers.length, note: '已建立企业档案' },
|
||||
{ label: '正常合作', value: store.customers.filter((item) => item.status === '正常').length, note: '当前可正常下单' },
|
||||
{ label: '待审核', value: store.pendingCustomers, note: '需要平台处理' },
|
||||
{ label: '累计订单', value: store.customers.reduce((total, item) => total + item.orders, 0).toLocaleString(), note: '全部客户历史订单' },
|
||||
])
|
||||
|
||||
watch(filters, () => { page.value = 1 })
|
||||
watch(
|
||||
() => access.can('customer:create'),
|
||||
(canCreate) => {
|
||||
if (canCreate || !editorOpen.value || form.id) return
|
||||
editorOpen.value = false
|
||||
Object.assign(form, emptyForm())
|
||||
},
|
||||
{ flush: 'sync' },
|
||||
)
|
||||
|
||||
function resetFilters() {
|
||||
Object.assign(filters, { keyword: '', status: '', packageName: '' })
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
Object.assign(form, emptyForm())
|
||||
captureBaseline()
|
||||
editorOpen.value = true
|
||||
}
|
||||
|
||||
function openEdit(row: Customer) {
|
||||
Object.assign(form, row)
|
||||
captureBaseline()
|
||||
editorOpen.value = true
|
||||
}
|
||||
|
||||
function openDetail(row: Customer) {
|
||||
currentCustomer.value = row
|
||||
detailOpen.value = true
|
||||
}
|
||||
|
||||
function saveCustomer() {
|
||||
if (!form.name.trim() || !form.contact.trim() || !form.phone.trim()) {
|
||||
ElMessage.warning('请填写企业名称、联系人和联系电话')
|
||||
return
|
||||
}
|
||||
store.saveCustomer({ ...form, id: form.id || undefined })
|
||||
editorOpen.value = false
|
||||
ElMessage.success(form.id ? '客户资料已更新' : '客户已创建')
|
||||
}
|
||||
|
||||
async function toggleStatus(row: Customer) {
|
||||
const target = row.status === '停用' ? '正常' : '停用'
|
||||
try {
|
||||
await ElMessageBox.confirm(`确认将“${row.name}”设置为${target}吗?`, '变更客户状态', {
|
||||
confirmButtonText: '确认变更', cancelButtonText: '取消',
|
||||
})
|
||||
store.setCustomerStatus(row.id, target)
|
||||
ElMessage.success(`客户状态已更新为${target}`)
|
||||
} catch {
|
||||
// 用户取消,不需要反馈。
|
||||
}
|
||||
}
|
||||
|
||||
function exportCustomers() {
|
||||
ElMessage.success(`已生成 ${filteredRows.value.length} 条客户数据的导出任务`)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="business-page">
|
||||
<MetricStrip :items="metrics" />
|
||||
|
||||
<section class="surface-panel filter-panel" aria-label="客户筛选">
|
||||
<div class="filter-fields">
|
||||
<el-input v-model="filters.keyword" clearable placeholder="客户名称、编号、联系人或信用代码" aria-label="客户关键词">
|
||||
<template #prefix><Search :size="16" /></template>
|
||||
</el-input>
|
||||
<el-select v-model="filters.status" clearable placeholder="全部状态" aria-label="客户状态">
|
||||
<el-option label="正常" value="正常" /><el-option label="待审核" value="待审核" /><el-option label="停用" value="停用" />
|
||||
</el-select>
|
||||
<el-select v-model="filters.packageName" clearable placeholder="全部套餐" aria-label="客户套餐">
|
||||
<el-option label="基础版" value="基础版" /><el-option label="专业版" value="专业版" /><el-option label="企业版" value="企业版" />
|
||||
</el-select>
|
||||
</div>
|
||||
<div class="filter-actions">
|
||||
<el-button @click="resetFilters"><RotateCcw :size="16" />重置</el-button>
|
||||
<el-button v-if="!access.usesServerPermissions" @click="exportCustomers"><Download :size="16" />导出</el-button>
|
||||
<el-button v-if="access.can('customer:create')" type="primary" @click="openCreate"><Plus :size="16" />新增客户</el-button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="surface-panel table-panel">
|
||||
<header class="panel-heading">
|
||||
<div><h2>客户档案</h2><p>共 {{ filteredRows.length }} 家企业,客户资料与注册审核统一管理。</p></div>
|
||||
</header>
|
||||
<el-table :data="pagedRows" row-key="id" empty-text="没有符合条件的客户">
|
||||
<el-table-column prop="id" label="客户编号" width="96" />
|
||||
<el-table-column label="企业信息" min-width="250">
|
||||
<template #default="{ row }"><div class="primary-cell"><strong>{{ row.name }}</strong><span>{{ row.creditCode }}</span></div></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="联系人" min-width="150"><template #default="{ row }"><div class="primary-cell"><strong>{{ row.contact }}</strong><span>{{ row.phone }}</span></div></template></el-table-column>
|
||||
<el-table-column prop="sales" label="销售负责人" width="108" />
|
||||
<el-table-column label="套餐/账期" width="130"><template #default="{ row }"><div class="primary-cell"><strong>{{ row.packageName }}</strong><span>{{ row.period }} 天账期</span></div></template></el-table-column>
|
||||
<el-table-column prop="orders" label="订单数" width="86" align="right" />
|
||||
<el-table-column label="状态" width="96"><template #default="{ row }"><StatusBadge :status="row.status" /></template></el-table-column>
|
||||
<el-table-column label="操作" width="246" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<div class="table-actions">
|
||||
<el-button link type="primary" @click="openDetail(row as Customer)"><Eye :size="15" />详情</el-button>
|
||||
<el-button v-if="!access.usesServerPermissions" link type="primary" @click="openEdit(row as Customer)"><Pencil :size="15" />编辑</el-button>
|
||||
<el-button v-if="!access.usesServerPermissions && row.status !== '待审核'" link :type="row.status === '停用' ? 'success' : 'danger'" @click="toggleStatus(row as Customer)">{{ row.status === '停用' ? '启用' : '停用' }}</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<footer class="table-footer"><span>显示 {{ pagedRows.length }} 条,共 {{ filteredRows.length }} 条</span><el-pagination v-model:current-page="page" :page-size="pageSize" :total="filteredRows.length" layout="prev, pager, next" /></footer>
|
||||
</section>
|
||||
|
||||
<el-drawer v-model="editorOpen" :title="form.id ? '编辑客户' : '新增客户'" size="680px" class="business-drawer editor-drawer" :modal="false" :close-on-click-modal="false">
|
||||
<el-form label-position="top" class="drawer-form">
|
||||
<div class="form-section"><h3>企业信息</h3><div class="form-grid two-cols">
|
||||
<el-form-item label="企业名称" required><el-input v-model="form.name" placeholder="请输入营业执照上的企业名称" /></el-form-item>
|
||||
<el-form-item label="统一社会信用代码"><el-input v-model="form.creditCode" placeholder="18 位统一社会信用代码" /></el-form-item>
|
||||
<el-form-item label="套餐"><el-select v-model="form.packageName"><el-option label="基础版" value="基础版" /><el-option label="专业版" value="专业版" /><el-option label="企业版" value="企业版" /></el-select></el-form-item>
|
||||
<el-form-item label="账期"><el-input-number v-model="form.period" :min="0" :max="180" controls-position="right" /><span class="field-suffix">天</span></el-form-item>
|
||||
<el-form-item label="状态"><el-radio-group v-model="form.status"><el-radio-button label="正常" value="正常" /><el-radio-button label="待审核" value="待审核" /><el-radio-button label="停用" value="停用" /></el-radio-group></el-form-item>
|
||||
<el-form-item label="销售负责人"><el-select v-model="form.sales" filterable allow-create placeholder="请选择或输入负责人"><el-option v-for="name in ['王建国','李秀英','张志强','刘德海','陈美玲']" :key="name" :label="name" :value="name" /></el-select></el-form-item>
|
||||
</div></div>
|
||||
<div class="form-section"><h3>联系资料</h3><div class="form-grid two-cols">
|
||||
<el-form-item label="联系人" required><el-input v-model="form.contact" /></el-form-item>
|
||||
<el-form-item label="联系电话" required><el-input v-model="form.phone" /></el-form-item>
|
||||
<el-form-item label="联系邮箱"><el-input v-model="form.email" /></el-form-item>
|
||||
<el-form-item label="注册地址"><el-input v-model="form.address" /></el-form-item>
|
||||
</div><el-form-item label="内部备注"><el-input v-model="form.remark" type="textarea" :rows="3" placeholder="仅平台内部可见" /></el-form-item></div>
|
||||
</el-form>
|
||||
<template #footer><div class="drawer-footer"><el-button @click="editorOpen = false">取消</el-button><el-button v-if="!access.usesServerPermissions && (form.id || access.can('customer:create'))" type="primary" @click="saveCustomer">保存客户</el-button></div></template>
|
||||
</el-drawer>
|
||||
|
||||
<el-drawer v-model="detailOpen" title="客户详情" size="620px" class="business-drawer">
|
||||
<template v-if="currentCustomer">
|
||||
<div class="detail-identity"><span>{{ currentCustomer.name.slice(0, 1) }}</span><div><h2>{{ currentCustomer.name }}</h2><p>{{ currentCustomer.id }} · {{ currentCustomer.creditCode }}</p></div><StatusBadge :status="currentCustomer.status" /></div>
|
||||
<MetricStrip :items="[{ label: '历史订单', value: currentCustomer.orders }, { label: '账期', value: `${currentCustomer.period} 天` }, { label: '合作时长', value: currentCustomer.joinedAt.slice(0,4) + ' 至今' }]" />
|
||||
<div class="detail-section"><h3>基本资料</h3><el-descriptions :column="1" border><el-descriptions-item label="套餐">{{ currentCustomer.packageName }}</el-descriptions-item><el-descriptions-item label="销售负责人">{{ currentCustomer.sales }}</el-descriptions-item><el-descriptions-item label="联系人">{{ currentCustomer.contact }} · {{ currentCustomer.phone }}</el-descriptions-item><el-descriptions-item label="邮箱">{{ currentCustomer.email }}</el-descriptions-item><el-descriptions-item label="地址">{{ currentCustomer.address }}</el-descriptions-item><el-descriptions-item label="入驻时间">{{ currentCustomer.joinedAt }}</el-descriptions-item></el-descriptions></div>
|
||||
<div class="detail-section"><h3>内部备注</h3><p class="detail-note">{{ currentCustomer.remark || '暂无内部备注。' }}</p></div>
|
||||
</template>
|
||||
<template #footer><div class="drawer-footer"><el-button @click="detailOpen = false">关闭</el-button><el-button v-if="!access.usesServerPermissions" type="primary" @click="currentCustomer && openEdit(currentCustomer)">编辑资料</el-button></div></template>
|
||||
</el-drawer>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,160 @@
|
||||
import ElementPlus from 'element-plus'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
approveEnterpriseReview,
|
||||
downloadTenantQualification,
|
||||
listEnterpriseReviews,
|
||||
listTenantQualifications,
|
||||
rejectEnterpriseReview,
|
||||
type EnterpriseReview,
|
||||
type PlatformTenantQualification,
|
||||
} from '@/api/platformIam'
|
||||
import { useAccessPreviewStore } from '@/stores/accessPreview'
|
||||
import EnterpriseReviewsView from './EnterpriseReviewsView.vue'
|
||||
|
||||
vi.mock('@/api/platformIam', () => ({
|
||||
listEnterpriseReviews: vi.fn(),
|
||||
listTenantQualifications: vi.fn(),
|
||||
downloadTenantQualification: vi.fn(),
|
||||
qualificationFileName: vi.fn(() => 'test.png'),
|
||||
approveEnterpriseReview: vi.fn(),
|
||||
rejectEnterpriseReview: vi.fn(),
|
||||
}))
|
||||
|
||||
class ResizeObserverStub implements ResizeObserver {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
|
||||
const pendingReview: EnterpriseReview = {
|
||||
id: 202607290005,
|
||||
tenantId: 202607290002,
|
||||
tenantName: '深圳测试跨境科技有限公司',
|
||||
tenantCode: 'TEN202607290002',
|
||||
qualificationId: 202607290004,
|
||||
qualificationType: 'BUSINESS_LICENSE',
|
||||
certificateNo: '91440300XXXXXXXXXX',
|
||||
documentUrl: '/api/merchant/v1/files/qualifications/test.png',
|
||||
contactName: '张三',
|
||||
contactPhone: '13800138000',
|
||||
contactEmail: 'test01@example.com',
|
||||
submittedAt: '2026-07-29T10:00:00Z',
|
||||
status: 'PENDING',
|
||||
}
|
||||
|
||||
const pendingQualification: PlatformTenantQualification = {
|
||||
id: 202607290004,
|
||||
tenantId: pendingReview.tenantId,
|
||||
qualificationType: 'BUSINESS_LICENSE',
|
||||
certificateNo: '91440300XXXXXXXXXX',
|
||||
documentUrl: '/api/merchant/v1/files/qualifications/test.png',
|
||||
status: 'PENDING',
|
||||
reviewComment: null,
|
||||
}
|
||||
|
||||
async function mountView() {
|
||||
const pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
useAccessPreviewStore().initializeAfterLogin()
|
||||
const wrapper = mount(EnterpriseReviewsView, {
|
||||
attachTo: document.body,
|
||||
global: { plugins: [pinia, ElementPlus] },
|
||||
})
|
||||
await flushPromises()
|
||||
return wrapper
|
||||
}
|
||||
|
||||
function buttonByText(text: string) {
|
||||
return [...document.querySelectorAll<HTMLButtonElement>('button')]
|
||||
.find((button) => button.textContent?.trim() === text)
|
||||
}
|
||||
|
||||
enableAutoUnmount(afterEach)
|
||||
|
||||
describe('EnterpriseReviewsView', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
|
||||
vi.mocked(listEnterpriseReviews).mockReset()
|
||||
vi.mocked(listTenantQualifications).mockReset()
|
||||
vi.mocked(downloadTenantQualification).mockReset()
|
||||
vi.mocked(approveEnterpriseReview).mockReset()
|
||||
vi.mocked(rejectEnterpriseReview).mockReset()
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
it('renders an honest empty state from the real review endpoint', async () => {
|
||||
vi.mocked(listEnterpriseReviews).mockResolvedValue([])
|
||||
|
||||
const wrapper = await mountView()
|
||||
|
||||
expect(listEnterpriseReviews).toHaveBeenCalledTimes(1)
|
||||
expect(wrapper.text()).toContain('暂无待审核企业')
|
||||
expect(wrapper.text()).toContain('共 0 条待审记录')
|
||||
})
|
||||
|
||||
it('shows review identity and submits approval with the review id', async () => {
|
||||
vi.mocked(listEnterpriseReviews).mockResolvedValue([pendingReview])
|
||||
vi.mocked(approveEnterpriseReview).mockResolvedValue(undefined)
|
||||
|
||||
const wrapper = await mountView()
|
||||
expect(wrapper.text()).toContain('深圳测试跨境科技有限公司')
|
||||
expect(wrapper.text()).toContain('91440300XXXXXXXXXX')
|
||||
|
||||
const approve = buttonByText('通过')
|
||||
expect(approve).toBeDefined()
|
||||
approve?.click()
|
||||
await flushPromises()
|
||||
|
||||
const confirm = buttonByText('确认通过')
|
||||
expect(confirm).toBeDefined()
|
||||
confirm?.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(approveEnterpriseReview).toHaveBeenCalledWith(
|
||||
pendingReview.id,
|
||||
'营业执照清晰,企业资料审核通过',
|
||||
)
|
||||
expect(wrapper.text()).toContain('暂无待审核企业')
|
||||
expect(wrapper.text()).toContain('共 0 条待审记录')
|
||||
})
|
||||
|
||||
it('loads tenant qualifications when the reviewer opens the detail drawer', async () => {
|
||||
vi.mocked(listEnterpriseReviews).mockResolvedValue([pendingReview])
|
||||
vi.mocked(listTenantQualifications).mockResolvedValue([pendingQualification])
|
||||
|
||||
const wrapper = await mountView()
|
||||
buttonByText('详情')?.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(listTenantQualifications).toHaveBeenCalledWith(pendingReview.tenantId)
|
||||
expect(wrapper.text()).toContain('信用代码:91440300XXXXXXXXXX')
|
||||
expect(buttonByText('查看文件')).toBeDefined()
|
||||
})
|
||||
|
||||
it('keeps qualification loading recoverable without using a direct file link', async () => {
|
||||
vi.mocked(listEnterpriseReviews).mockResolvedValue([pendingReview])
|
||||
vi.mocked(listTenantQualifications).mockRejectedValue(new Error('qualification unavailable'))
|
||||
|
||||
const wrapper = await mountView()
|
||||
buttonByText('详情')?.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('资质暂时无法加载')
|
||||
expect(wrapper.text()).toContain('qualification unavailable')
|
||||
expect(wrapper.find('a[href*="qualifications"]').exists()).toBe(false)
|
||||
expect(buttonByText('重新加载')).toBeDefined()
|
||||
})
|
||||
|
||||
it('keeps the page recoverable when the endpoint fails', async () => {
|
||||
vi.mocked(listEnterpriseReviews).mockRejectedValue(new Error('IAM unavailable'))
|
||||
|
||||
const wrapper = await mountView()
|
||||
|
||||
expect(wrapper.get('[role="alert"]').text()).toContain('审核列表暂时无法加载')
|
||||
expect(wrapper.get('[role="alert"]').text()).toContain('IAM unavailable')
|
||||
expect(buttonByText('重新加载')).toBeDefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,360 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Eye, FileCheck2, RefreshCw, Search, ShieldCheck, ShieldX } from '@lucide/vue'
|
||||
import {
|
||||
approveEnterpriseReview,
|
||||
downloadTenantQualification,
|
||||
listEnterpriseReviews,
|
||||
listTenantQualifications,
|
||||
qualificationFileName,
|
||||
rejectEnterpriseReview,
|
||||
type EnterpriseReview,
|
||||
type PlatformTenantQualification,
|
||||
} from '@/api/platformIam'
|
||||
import { getApiErrorDetails, getApiErrorMessage, type ApiErrorDetails } from '@/api/http'
|
||||
import ApiErrorMeta from '@/components/ApiErrorMeta.vue'
|
||||
import MetricStrip from '@/components/MetricStrip.vue'
|
||||
import { useAccessPreviewStore } from '@/stores/accessPreview'
|
||||
|
||||
const access = useAccessPreviewStore()
|
||||
const loading = ref(false)
|
||||
const loadError = ref<ApiErrorDetails | null>(null)
|
||||
const reviews = ref<EnterpriseReview[]>([])
|
||||
const keyword = ref('')
|
||||
const detailOpen = ref(false)
|
||||
const selectedReview = ref<EnterpriseReview | null>(null)
|
||||
const qualifications = ref<PlatformTenantQualification[]>([])
|
||||
const qualificationsLoading = ref(false)
|
||||
const qualificationsError = ref<ApiErrorDetails | null>(null)
|
||||
const previewLoadingId = ref<string>()
|
||||
const previewOpen = ref(false)
|
||||
const previewUrl = ref('')
|
||||
const previewName = ref('')
|
||||
const decisionOpen = ref(false)
|
||||
const submitting = ref(false)
|
||||
const decision = reactive({ type: 'approve' as 'approve' | 'reject', comment: '' })
|
||||
|
||||
const filteredReviews = computed(() => {
|
||||
const query = keyword.value.trim().toLowerCase()
|
||||
if (!query) return reviews.value
|
||||
return reviews.value.filter((review) => [
|
||||
review.tenantName,
|
||||
review.tenantCode,
|
||||
review.certificateNo,
|
||||
String(review.tenantId),
|
||||
String(review.id),
|
||||
].some((value) => value?.toLowerCase().includes(query)))
|
||||
})
|
||||
|
||||
const metrics = computed(() => [
|
||||
{ label: '待审核企业', value: reviews.value.length, note: '当前需要平台处理' },
|
||||
{ label: '营业执照', value: reviews.value.filter((item) => item.qualificationType === 'BUSINESS_LICENSE').length, note: '本期待核验资质' },
|
||||
{ label: '可通过', value: access.can('platform:customer:approve') ? reviews.value.length : 0, note: '按当前账号权限' },
|
||||
{ label: '当前结果', value: filteredReviews.value.length, note: keyword.value ? '符合搜索条件' : '全部待审记录' },
|
||||
])
|
||||
|
||||
function displayText(value: unknown) {
|
||||
return typeof value === 'string' && value.trim() ? value : '—'
|
||||
}
|
||||
|
||||
function formatDate(value?: string | null) {
|
||||
if (!value) return '—'
|
||||
const date = new Date(value)
|
||||
return Number.isNaN(date.getTime()) ? value : date.toLocaleString('zh-CN', { hour12: false })
|
||||
}
|
||||
|
||||
function qualificationName(value?: string | null) {
|
||||
return value === 'BUSINESS_LICENSE' ? '营业执照' : displayText(value)
|
||||
}
|
||||
|
||||
function qualificationStatus(status: PlatformTenantQualification['status']) {
|
||||
if (status === 'APPROVED') return { label: '已通过', tone: 'is-success' }
|
||||
if (status === 'REJECTED') return { label: '已驳回', tone: 'is-danger' }
|
||||
return { label: '待审核', tone: 'is-warning' }
|
||||
}
|
||||
|
||||
async function loadReviews() {
|
||||
loading.value = true
|
||||
loadError.value = null
|
||||
try {
|
||||
reviews.value = await listEnterpriseReviews()
|
||||
} catch (error) {
|
||||
loadError.value = getApiErrorDetails(error, '审核列表加载失败,请稍后重试')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSelectedQualifications() {
|
||||
const tenantId = selectedReview.value?.tenantId
|
||||
if (!tenantId) return
|
||||
qualificationsLoading.value = true
|
||||
qualificationsError.value = null
|
||||
qualifications.value = []
|
||||
try {
|
||||
const result = await listTenantQualifications(tenantId)
|
||||
if (selectedReview.value?.tenantId === tenantId) qualifications.value = result
|
||||
} catch (error) {
|
||||
if (selectedReview.value?.tenantId === tenantId) {
|
||||
qualificationsError.value = getApiErrorDetails(error, '资质列表加载失败,请稍后重试')
|
||||
}
|
||||
} finally {
|
||||
if (selectedReview.value?.tenantId === tenantId) qualificationsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openDetail(review: EnterpriseReview) {
|
||||
selectedReview.value = review
|
||||
detailOpen.value = true
|
||||
void loadSelectedQualifications()
|
||||
}
|
||||
|
||||
function clearPreviewUrl() {
|
||||
if (previewUrl.value) URL.revokeObjectURL(previewUrl.value)
|
||||
previewUrl.value = ''
|
||||
previewName.value = ''
|
||||
}
|
||||
|
||||
async function previewQualification(qualification: PlatformTenantQualification) {
|
||||
const tenantId = selectedReview.value?.tenantId
|
||||
if (!tenantId || !qualification.documentUrl) return
|
||||
previewLoadingId.value = String(qualification.id)
|
||||
try {
|
||||
const blob = await downloadTenantQualification(tenantId, qualification.documentUrl)
|
||||
if (!blob.size) throw new Error('资质文件内容为空')
|
||||
clearPreviewUrl()
|
||||
previewUrl.value = URL.createObjectURL(blob)
|
||||
previewName.value = qualificationFileName(qualification.documentUrl)
|
||||
previewOpen.value = true
|
||||
} catch (error) {
|
||||
ElMessage.error(getApiErrorMessage(error, '资质文件加载失败,请稍后重试'))
|
||||
} finally {
|
||||
previewLoadingId.value = undefined
|
||||
}
|
||||
}
|
||||
|
||||
function openDecision(review: EnterpriseReview, type: 'approve' | 'reject') {
|
||||
selectedReview.value = review
|
||||
decision.type = type
|
||||
decision.comment = type === 'approve' ? '营业执照清晰,企业资料审核通过' : ''
|
||||
decisionOpen.value = true
|
||||
}
|
||||
|
||||
async function submitDecision() {
|
||||
const review = selectedReview.value
|
||||
if (!review || submitting.value) return
|
||||
if (!decision.comment.trim()) {
|
||||
ElMessage.warning(decision.type === 'approve' ? '请填写审核说明' : '请填写驳回原因')
|
||||
return
|
||||
}
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
if (decision.type === 'approve') {
|
||||
await approveEnterpriseReview(review.id, decision.comment.trim())
|
||||
} else {
|
||||
await rejectEnterpriseReview(review.id, decision.comment.trim())
|
||||
}
|
||||
reviews.value = reviews.value.filter((item) => item.id !== review.id)
|
||||
decisionOpen.value = false
|
||||
detailOpen.value = false
|
||||
ElMessage.success(decision.type === 'approve' ? '企业资料已审核通过' : '企业资料已驳回')
|
||||
} catch (error) {
|
||||
ElMessage.error(getApiErrorMessage(error, '审核提交失败,请稍后重试'))
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [access.can('platform:customer:approve'), access.can('platform:customer:reject')],
|
||||
([canApprove, canReject]) => {
|
||||
if ((decision.type === 'approve' && !canApprove) || (decision.type === 'reject' && !canReject)) {
|
||||
decisionOpen.value = false
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
watch(previewOpen, (open) => {
|
||||
if (!open) clearPreviewUrl()
|
||||
})
|
||||
|
||||
watch(detailOpen, (open) => {
|
||||
if (!open) previewOpen.value = false
|
||||
})
|
||||
|
||||
onMounted(loadReviews)
|
||||
onBeforeUnmount(clearPreviewUrl)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- THESIS: 把企业审核组织成一条清晰的待办队列,而不是混在客户档案的模拟状态里。
|
||||
OWN-WORLD: 延续 Admin 的海军蓝框架、白色数据面板、紧凑表格和克制状态色。
|
||||
STORY: 审核员先识别待审企业,再核对营业执照与联系人,最后留下明确的通过或驳回意见。
|
||||
FIRST VIEWPORT: 摘要、搜索和真实待审列表连续呈现,详情从右侧展开,决策在受保护的确认对话框中完成。
|
||||
FORM: 现有 Operate 页面体系的直接扩展,不改变全局导航与品牌语言。
|
||||
FINISH: unreviewed and undocumented is unfinished; this build ends with the finish review, the verdict, and DESIGN.md -->
|
||||
<div class="business-page enterprise-review-page">
|
||||
<MetricStrip :items="metrics" />
|
||||
|
||||
<section class="surface-panel review-toolbar" aria-label="企业审核筛选">
|
||||
<el-input v-model="keyword" clearable placeholder="企业名称、企业编号、信用代码或审核单号" aria-label="企业审核关键词">
|
||||
<template #prefix><Search :size="16" /></template>
|
||||
</el-input>
|
||||
<el-button :loading="loading" @click="loadReviews"><RefreshCw :size="16" />刷新</el-button>
|
||||
</section>
|
||||
|
||||
<section v-if="loadError" class="surface-panel review-error" role="alert">
|
||||
<ShieldX :size="22" />
|
||||
<div><strong>审核列表暂时无法加载</strong><p>{{ loadError.message }}</p><ApiErrorMeta :details="loadError" /></div>
|
||||
<el-button type="primary" @click="loadReviews">重新加载</el-button>
|
||||
</section>
|
||||
|
||||
<section v-else class="surface-panel table-panel" :aria-busy="loading">
|
||||
<header class="panel-heading">
|
||||
<div><h2>企业资料审核</h2><p>仅展示后端返回的待审核记录,提交审核时使用审核单 ID。</p></div>
|
||||
</header>
|
||||
<el-table v-loading="loading" :data="filteredReviews" row-key="id" empty-text="暂无待审核企业">
|
||||
<el-table-column label="企业" min-width="230">
|
||||
<template #default="{ row }">
|
||||
<div class="primary-cell"><strong>{{ displayText((row as EnterpriseReview).tenantName) }}</strong><span>{{ displayText((row as EnterpriseReview).tenantCode) }} · 租户 {{ (row as EnterpriseReview).tenantId }}</span></div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="资质" min-width="210">
|
||||
<template #default="{ row }">
|
||||
<div class="primary-cell"><strong>{{ qualificationName((row as EnterpriseReview).qualificationType) }}</strong><span>{{ displayText((row as EnterpriseReview).certificateNo) }}</span></div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="联系人" min-width="180">
|
||||
<template #default="{ row }">
|
||||
<div class="primary-cell"><strong>{{ displayText((row as EnterpriseReview).contactName) }}</strong><span>{{ displayText((row as EnterpriseReview).contactPhone) }}</span></div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="提交时间" min-width="175"><template #default="{ row }">{{ formatDate((row as EnterpriseReview).submittedAt ?? (row as EnterpriseReview).createdAt) }}</template></el-table-column>
|
||||
<el-table-column label="状态" width="110"><template #default><span class="status-badge is-warning"><i />待审核</span></template></el-table-column>
|
||||
<el-table-column label="操作" width="250" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<div class="table-actions">
|
||||
<el-button link type="primary" @click="openDetail(row as EnterpriseReview)"><Eye :size="15" />详情</el-button>
|
||||
<el-button v-if="access.can('platform:customer:approve')" link type="success" @click="openDecision(row as EnterpriseReview, 'approve')"><ShieldCheck :size="15" />通过</el-button>
|
||||
<el-button v-if="access.can('platform:customer:reject')" link type="danger" @click="openDecision(row as EnterpriseReview, 'reject')"><ShieldX :size="15" />驳回</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<footer class="table-footer"><span>共 {{ filteredReviews.length }} 条待审记录</span><span v-if="keyword">已按关键词筛选</span></footer>
|
||||
</section>
|
||||
|
||||
<el-drawer v-model="detailOpen" title="企业审核详情" size="620px" class="business-drawer">
|
||||
<template v-if="selectedReview">
|
||||
<div class="detail-identity">
|
||||
<span><FileCheck2 :size="20" /></span>
|
||||
<div><h2>{{ displayText(selectedReview.tenantName) }}</h2><p>审核单 {{ selectedReview.id }} · 租户 {{ selectedReview.tenantId }}</p></div>
|
||||
<span class="status-badge is-warning"><i />待审核</span>
|
||||
</div>
|
||||
<section class="detail-section">
|
||||
<h3>企业与联系人</h3>
|
||||
<el-descriptions :column="1" border>
|
||||
<el-descriptions-item label="企业编号">{{ displayText(selectedReview.tenantCode) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="联系人">{{ displayText(selectedReview.contactName) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="联系电话">{{ displayText(selectedReview.contactPhone) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="联系邮箱">{{ displayText(selectedReview.contactEmail) }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</section>
|
||||
<section class="detail-section">
|
||||
<h3>资质文件</h3>
|
||||
<div class="qualification-state" :aria-busy="qualificationsLoading">
|
||||
<div v-if="qualificationsLoading" v-loading="true" class="qualification-loading">正在读取该企业资质…</div>
|
||||
<div v-else-if="qualificationsError" class="qualification-error" role="alert">
|
||||
<div><strong>资质暂时无法加载</strong><p>{{ qualificationsError.message }}</p><ApiErrorMeta :details="qualificationsError" /></div>
|
||||
<el-button type="primary" plain @click="loadSelectedQualifications">重新加载</el-button>
|
||||
</div>
|
||||
<el-empty v-else-if="qualifications.length === 0" :image-size="64" description="该企业暂未提交资质" />
|
||||
<div v-else class="qualification-list">
|
||||
<article v-for="qualification in qualifications" :key="qualification.id" class="qualification-item">
|
||||
<div class="qualification-copy">
|
||||
<div><strong>{{ qualificationName(qualification.qualificationType) }}</strong><span class="status-badge" :class="qualificationStatus(qualification.status).tone"><i />{{ qualificationStatus(qualification.status).label }}</span></div>
|
||||
<p>信用代码:{{ displayText(qualification.certificateNo) }}</p>
|
||||
<p v-if="qualification.reviewComment">审核意见:{{ qualification.reviewComment }}</p>
|
||||
</div>
|
||||
<el-button
|
||||
type="primary"
|
||||
plain
|
||||
:disabled="!qualification.documentUrl"
|
||||
:loading="previewLoadingId === String(qualification.id)"
|
||||
@click="previewQualification(qualification)"
|
||||
><Eye :size="15" />查看文件</el-button>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
<p class="detail-note">请核对企业名称、统一社会信用代码和营业执照内容。通过后企业获得下单资格;驳回时必须说明原因。</p>
|
||||
</section>
|
||||
</template>
|
||||
<template #footer>
|
||||
<div class="drawer-footer">
|
||||
<el-button @click="detailOpen = false">关闭</el-button>
|
||||
<el-button v-if="selectedReview && access.can('platform:customer:reject')" type="danger" plain @click="openDecision(selectedReview, 'reject')">驳回</el-button>
|
||||
<el-button v-if="selectedReview && access.can('platform:customer:approve')" type="success" @click="openDecision(selectedReview, 'approve')">审核通过</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-drawer>
|
||||
|
||||
<el-dialog v-model="previewOpen" :title="previewName || '查看资质文件'" width="min(860px, calc(100vw - 28px))" class="qualification-preview-dialog" destroy-on-close>
|
||||
<div class="qualification-preview">
|
||||
<img v-if="previewUrl" :src="previewUrl" :alt="`${previewName}预览`" />
|
||||
</div>
|
||||
<template #footer><el-button @click="previewOpen = false">关闭预览</el-button></template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="decisionOpen" :title="decision.type === 'approve' ? '确认审核通过' : '确认驳回企业资料'" width="min(520px, calc(100vw - 28px))" class="review-decision-dialog" :close-on-click-modal="false">
|
||||
<div v-if="selectedReview" class="decision-summary">
|
||||
<strong>{{ displayText(selectedReview.tenantName) }}</strong>
|
||||
<span>审核单 {{ selectedReview.id }}</span>
|
||||
</div>
|
||||
<el-form label-position="top">
|
||||
<el-form-item :label="decision.type === 'approve' ? '审核说明' : '驳回原因'" required>
|
||||
<el-input v-model="decision.comment" type="textarea" :rows="4" maxlength="300" show-word-limit :placeholder="decision.type === 'approve' ? '请填写审核通过说明' : '请明确说明需要企业修改的内容'" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<div class="drawer-footer">
|
||||
<el-button :disabled="submitting" @click="decisionOpen = false">取消</el-button>
|
||||
<el-button :type="decision.type === 'approve' ? 'success' : 'danger'" :loading="submitting" @click="submitDecision">{{ decision.type === 'approve' ? '确认通过' : '确认驳回' }}</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.review-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 14px 16px; }
|
||||
.review-toolbar .el-input { width: min(460px, 100%); }
|
||||
.review-error { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 14px; padding: 18px 20px; border-color: #fecdca; background: #fffafa; color: #b42318; }
|
||||
.review-error strong { color: #7a271a; font-size: 13px; }
|
||||
.review-error p { margin: 4px 0 0; color: #b54708; font-size: 12px; }
|
||||
.qualification-state { min-height: 112px; }
|
||||
.qualification-loading { display: grid; min-height: 112px; place-items: center; color: var(--cc-muted); font-size: 12px; }
|
||||
.qualification-error { display: flex; min-width: 0; align-items: center; justify-content: space-between; gap: 16px; padding: 14px; border: 1px solid #fecdca; border-radius: 6px; background: #fffafa; }
|
||||
.qualification-error strong { color: #7a271a; font-size: 13px; }
|
||||
.qualification-error p { margin: 4px 0 0; color: #b54708; font-size: 12px; overflow-wrap: anywhere; }
|
||||
.qualification-list { display: flex; flex-direction: column; gap: 10px; }
|
||||
.qualification-item { display: flex; min-width: 0; align-items: center; justify-content: space-between; gap: 16px; padding: 14px; border: 1px solid var(--cc-border); border-radius: 6px; background: #fff; }
|
||||
.qualification-copy { min-width: 0; }
|
||||
.qualification-copy > div { display: flex; min-width: 0; align-items: center; gap: 8px; }
|
||||
.qualification-copy strong { overflow-wrap: anywhere; font-size: 13px; }
|
||||
.qualification-copy p { margin: 5px 0 0; color: var(--cc-muted); font-size: 11px; line-height: 1.55; overflow-wrap: anywhere; }
|
||||
.qualification-preview { display: grid; max-height: min(70vh, 760px); min-height: 240px; place-items: center; overflow: auto; border: 1px solid var(--cc-border); border-radius: 6px; background: #f8fafc; }
|
||||
.qualification-preview img { display: block; max-width: 100%; height: auto; }
|
||||
.decision-summary { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 18px; padding: 12px 14px; border-radius: 8px; background: #f8fafc; }
|
||||
.decision-summary strong { color: #172033; font-size: 13px; }
|
||||
.decision-summary span { color: var(--cc-muted); font-size: 11px; }
|
||||
@media (max-width: 720px) {
|
||||
.review-toolbar { align-items: stretch; flex-direction: column; }
|
||||
.review-toolbar .el-input { width: 100%; }
|
||||
.review-error { grid-template-columns: auto minmax(0, 1fr); }
|
||||
.review-error .el-button { grid-column: 1 / -1; min-height: 44px; }
|
||||
.qualification-error, .qualification-item { align-items: stretch; flex-direction: column; }
|
||||
.qualification-error .el-button, .qualification-item .el-button { min-height: 44px; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,78 @@
|
||||
import ElementPlus from 'element-plus'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
import ExpressCompaniesView from './ExpressCompaniesView.vue'
|
||||
|
||||
class ResizeObserverStub implements ResizeObserver {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
|
||||
async function mountView() {
|
||||
const pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
const wrapper = mount(ExpressCompaniesView, {
|
||||
attachTo: document.body,
|
||||
global: { plugins: [pinia, ElementPlus] },
|
||||
})
|
||||
await flushPromises()
|
||||
return wrapper
|
||||
}
|
||||
|
||||
async function filterByKeyword(value: string) {
|
||||
const input = document.querySelector<HTMLInputElement>('input[placeholder="公司名称、编码、官网或电话"]')
|
||||
expect(input).not.toBeNull()
|
||||
if (!input) return
|
||||
input.value = value
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
await nextTick()
|
||||
}
|
||||
|
||||
enableAutoUnmount(afterEach)
|
||||
|
||||
describe('ExpressCompaniesView', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
it('renders the prototype-backed company records and public fields', async () => {
|
||||
const wrapper = await mountView()
|
||||
|
||||
expect(wrapper.text()).toContain('DHL')
|
||||
expect(wrapper.text()).toContain('www.dhl.com')
|
||||
expect(wrapper.text()).toContain('95380')
|
||||
expect(wrapper.text()).toContain('已终止')
|
||||
const actions = wrapper.findAll('button').map((button) => button.text().trim())
|
||||
expect(actions).not.toContain('新增')
|
||||
expect(actions).not.toContain('编辑')
|
||||
})
|
||||
|
||||
it('filters by company name or code and renders an actionable empty state', async () => {
|
||||
const wrapper = await mountView()
|
||||
|
||||
await filterByKeyword('YTO')
|
||||
expect(wrapper.text()).toContain('圆通速递')
|
||||
expect(wrapper.text()).not.toContain('顺丰速运')
|
||||
|
||||
await filterByKeyword('不存在的公司')
|
||||
expect(wrapper.text()).toContain('没有符合条件的快递公司')
|
||||
expect(wrapper.text()).toContain('共 0 条')
|
||||
})
|
||||
|
||||
it('opens a detail drawer without exposing maintenance actions to an unauthorized role', async () => {
|
||||
const wrapper = await mountView()
|
||||
const detail = wrapper.findAll('button').find((button) => button.text().trim() === '详情')
|
||||
expect(detail).toBeDefined()
|
||||
|
||||
await detail?.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(document.body.textContent).toContain('快递公司详情')
|
||||
expect(document.body.textContent).toContain('公开联系信息')
|
||||
expect(document.body.textContent).not.toContain('保存快递公司')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,153 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { ExternalLink, FileUp, Pencil, Phone, Plus, RotateCcw, Search, Truck } from '@lucide/vue'
|
||||
import MetricStrip from '@/components/MetricStrip.vue'
|
||||
import StaticBatchImportDialog from '@/components/StaticBatchImportDialog.vue'
|
||||
import StatusBadge from '@/components/StatusBadge.vue'
|
||||
import { useUnsavedWorkRegistration } from '@/composables/useUnsavedWorkRegistration'
|
||||
import { useAccessPreviewStore } from '@/stores/accessPreview'
|
||||
import { useAdminDataStore } from '@/stores/adminData'
|
||||
import type { ExpressCompany, ExpressCompanyStatus } from '@/types/admin'
|
||||
|
||||
const store = useAdminDataStore()
|
||||
const access = useAccessPreviewStore()
|
||||
const detailOpen = ref(false)
|
||||
const editorOpen = ref(false)
|
||||
const importOpen = ref(false)
|
||||
const selectedCompany = ref<ExpressCompany | null>(null)
|
||||
const page = ref(1)
|
||||
const pageSize = 10
|
||||
const filters = reactive({ keyword: '', status: '' as ExpressCompanyStatus | '' })
|
||||
const emptyForm = (): Omit<ExpressCompany, 'updatedAt'> => ({ id: '', name: '', code: '', website: '', phone: '', status: '合作中', remark: '' })
|
||||
const form = reactive(emptyForm())
|
||||
const editorLabel = computed(() => form.id ? '编辑快递公司' : '新增快递公司')
|
||||
const { captureBaseline } = useUnsavedWorkRegistration({
|
||||
key: 'express-company-editor',
|
||||
label: editorLabel,
|
||||
isOpen: editorOpen,
|
||||
model: form,
|
||||
requiredPermission: computed(() => form.id ? null : 'express-company:write'),
|
||||
})
|
||||
|
||||
const filteredRows = computed(() => store.expressCompanies.filter((company) => {
|
||||
const keyword = filters.keyword.trim().toLowerCase()
|
||||
const matchesKeyword = !keyword || [company.name, company.code, company.website, company.phone]
|
||||
.some((value) => value.toLowerCase().includes(keyword))
|
||||
return matchesKeyword && (!filters.status || company.status === filters.status)
|
||||
}))
|
||||
const pagedRows = computed(() => filteredRows.value.slice((page.value - 1) * pageSize, page.value * pageSize))
|
||||
const metrics = computed(() => [
|
||||
{ label: '全部快递公司', value: store.expressCompanies.length, note: '平台快递档案' },
|
||||
{ label: '合作中', value: store.activeExpressCompanies, note: '可新增到履约信息' },
|
||||
{ label: '已终止', value: store.expressCompanies.length - store.activeExpressCompanies, note: '历史订单继续展示' },
|
||||
{ label: '官网已登记', value: store.expressCompanies.filter((item) => item.website).length, note: '公开查询入口' },
|
||||
])
|
||||
|
||||
watch(filters, () => { page.value = 1 })
|
||||
|
||||
function resetFilters() { filters.keyword = ''; filters.status = '' }
|
||||
function openDetail(company: ExpressCompany) { selectedCompany.value = company; detailOpen.value = true }
|
||||
function openCreate() { Object.assign(form, emptyForm()); captureBaseline(); editorOpen.value = true }
|
||||
function openEdit(company: ExpressCompany) { Object.assign(form, company); captureBaseline(); detailOpen.value = false; editorOpen.value = true }
|
||||
function websiteUrl(company: ExpressCompany) { return /^https?:\/\//i.test(company.website) ? company.website : `https://${company.website}` }
|
||||
|
||||
function saveCompany() {
|
||||
if (!form.name.trim() || !form.code.trim()) { ElMessage.warning('请填写快递公司名称和稳定编码'); return }
|
||||
try {
|
||||
store.saveExpressCompany({ ...form, id: form.id || undefined })
|
||||
editorOpen.value = false
|
||||
ElMessage.success(form.id ? '快递公司已更新' : '快递公司已创建')
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '快递公司保存失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleStatus(company: ExpressCompany) {
|
||||
const target: ExpressCompanyStatus = company.status === '合作中' ? '已终止' : '合作中'
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
target === '已终止' ? '停用后不能新增到订单履约信息,历史订单仍保留公司名称和编码。' : '启用后可重新用于订单履约信息。',
|
||||
`${target === '已终止' ? '停用' : '启用'}快递公司“${company.name}”`,
|
||||
{ confirmButtonText: target === '已终止' ? '确认停用' : '确认启用', cancelButtonText: '取消', type: 'warning' },
|
||||
)
|
||||
store.setExpressCompanyStatus(company.id, target)
|
||||
ElMessage.success(target === '已终止' ? '快递公司已停用' : '快递公司已启用')
|
||||
} catch {
|
||||
// 用户取消,不需要反馈。
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- THESIS: 把快递档案升级为可维护、可批量校验的履约主数据,而不是停留在只读名录。
|
||||
OWN-WORLD: 继承 Admin 的白色数据面板、海军蓝导航、紧凑表格、克制状态色和右侧抽屉。
|
||||
STORY: 平台人员筛选档案,维护唯一编码与名称,控制启停,并按标准模板执行整批导入。
|
||||
FIRST VIEWPORT: 摘要、双条件筛选、导入与新增动作、完整名录连续排列。
|
||||
FORM: 现有 Operate 页面结构的深化,详情与编辑分层,导入使用受保护对话框。
|
||||
FINISH: unreviewed and undocumented is unfinished; this build ends with the finish review, the verdict, and DESIGN.md -->
|
||||
<div class="business-page express-company-page">
|
||||
<MetricStrip :items="metrics" />
|
||||
|
||||
<section class="surface-panel filter-panel" aria-label="快递公司筛选">
|
||||
<div class="filter-fields">
|
||||
<el-input v-model="filters.keyword" clearable placeholder="公司名称、编码、官网或电话" aria-label="快递公司关键词"><template #prefix><Search :size="16" /></template></el-input>
|
||||
<el-select v-model="filters.status" clearable placeholder="全部状态" aria-label="合作状态"><el-option label="合作中" value="合作中" /><el-option label="已终止" value="已终止" /></el-select>
|
||||
</div>
|
||||
<div class="filter-actions">
|
||||
<el-button @click="resetFilters"><RotateCcw :size="16" />重置</el-button>
|
||||
<el-button v-if="access.can('express-company:import')" @click="importOpen = true"><FileUp :size="16" />批量导入</el-button>
|
||||
<el-button v-if="access.can('express-company:write')" type="primary" @click="openCreate"><Plus :size="16" />新增快递公司</el-button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="surface-panel table-panel">
|
||||
<div class="panel-heading"><div><h2>快递公司名录</h2><p>编码和名称必须唯一;停用后不允许新增引用,历史订单快照不受影响。</p></div></div>
|
||||
<el-table :data="pagedRows" row-key="id" empty-text="没有符合条件的快递公司">
|
||||
<el-table-column label="公司名称" min-width="190"><template #default="{ row }"><div class="express-company-cell"><span><Truck :size="18" /></span><div><strong>{{ (row as ExpressCompany).name }}</strong><small>{{ (row as ExpressCompany).id }} · {{ (row as ExpressCompany).updatedAt }}</small></div></div></template></el-table-column>
|
||||
<el-table-column label="编码" min-width="110"><template #default="{ row }"><code class="code-value">{{ (row as ExpressCompany).code }}</code></template></el-table-column>
|
||||
<el-table-column label="官网" min-width="220"><template #default="{ row }"><a v-if="(row as ExpressCompany).website" class="company-link" :href="websiteUrl(row as ExpressCompany)" target="_blank" rel="noopener noreferrer">{{ (row as ExpressCompany).website }}<ExternalLink :size="14" /></a><span v-else>—</span></template></el-table-column>
|
||||
<el-table-column label="客服电话" min-width="150"><template #default="{ row }"><span class="phone-value"><Phone :size="14" />{{ (row as ExpressCompany).phone || '—' }}</span></template></el-table-column>
|
||||
<el-table-column label="状态" width="100"><template #default="{ row }"><StatusBadge :status="(row as ExpressCompany).status" /></template></el-table-column>
|
||||
<el-table-column label="操作" width="220" fixed="right"><template #default="{ row }"><div class="table-actions"><el-button link type="primary" @click="openDetail(row as ExpressCompany)">详情</el-button><el-button v-if="access.can('express-company:write')" link type="primary" @click="openEdit(row as ExpressCompany)"><Pencil :size="15" />编辑</el-button><el-button v-if="access.can('express-company:write')" link :type="(row as ExpressCompany).status === '合作中' ? 'danger' : 'success'" @click="toggleStatus(row as ExpressCompany)">{{ (row as ExpressCompany).status === '合作中' ? '停用' : '启用' }}</el-button></div></template></el-table-column>
|
||||
</el-table>
|
||||
<div class="table-footer"><span>显示 {{ pagedRows.length }} 条,共 {{ filteredRows.length }} 条</span><el-pagination v-model:current-page="page" :page-size="pageSize" :total="filteredRows.length" layout="prev, pager, next" /></div>
|
||||
</section>
|
||||
|
||||
<el-drawer v-model="editorOpen" :title="form.id ? '编辑快递公司' : '新增快递公司'" size="620px" class="business-drawer editor-drawer" :modal="false" :close-on-click-modal="false">
|
||||
<el-form label-position="top" class="drawer-form"><div class="form-section"><h3>档案信息</h3><div class="form-grid two-cols">
|
||||
<el-form-item label="公司名称" required><el-input v-model="form.name" placeholder="请输入快递公司名称" maxlength="80" /></el-form-item>
|
||||
<el-form-item label="稳定编码" required><el-input v-model="form.code" placeholder="如 SF" maxlength="30" /></el-form-item>
|
||||
<el-form-item label="官方查询网址"><el-input v-model="form.website" placeholder="如 www.sf-express.com" maxlength="160" /></el-form-item>
|
||||
<el-form-item label="客服电话"><el-input v-model="form.phone" maxlength="40" /></el-form-item>
|
||||
<el-form-item label="状态"><el-radio-group v-model="form.status"><el-radio-button label="合作中" value="合作中" /><el-radio-button label="已终止" value="已终止" /></el-radio-group></el-form-item>
|
||||
</div><el-form-item label="内部备注"><el-input v-model="form.remark" type="textarea" :rows="3" maxlength="300" show-word-limit /></el-form-item></div></el-form>
|
||||
<template #footer><div class="drawer-footer"><el-button @click="editorOpen = false">取消</el-button><el-button v-if="access.can('express-company:write')" type="primary" @click="saveCompany">保存快递公司</el-button></div></template>
|
||||
</el-drawer>
|
||||
|
||||
<el-drawer v-model="detailOpen" title="快递公司详情" size="520px" class="business-drawer" :close-on-click-modal="false">
|
||||
<template v-if="selectedCompany"><div class="detail-identity"><span><Truck :size="20" /></span><div><h2>{{ selectedCompany.name }}</h2><p>{{ selectedCompany.code }} · {{ selectedCompany.id }}</p></div><StatusBadge :status="selectedCompany.status" /></div><section class="detail-section"><h3>公开联系信息</h3><dl class="company-detail-list"><div><dt>公司官网</dt><dd><a v-if="selectedCompany.website" class="company-link" :href="websiteUrl(selectedCompany)" target="_blank" rel="noopener noreferrer">{{ selectedCompany.website }}<ExternalLink :size="14" /></a><span v-else>—</span></dd></div><div><dt>客服电话</dt><dd>{{ selectedCompany.phone || '—' }}</dd></div><div><dt>更新时间</dt><dd>{{ selectedCompany.updatedAt }}</dd></div></dl><p class="detail-note">{{ selectedCompany.remark || '暂无内部备注。' }}</p></section></template>
|
||||
<template #footer><div class="drawer-footer"><el-button @click="detailOpen = false">关闭</el-button><el-button v-if="access.can('express-company:write') && selectedCompany" type="primary" @click="openEdit(selectedCompany)">编辑资料</el-button></div></template>
|
||||
</el-drawer>
|
||||
|
||||
<StaticBatchImportDialog v-model="importOpen" entity-label="快递公司" template-url="/templates/express-companies-import-template.xlsx" template-file-name="快递公司导入标准模板-v1.xlsx" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.express-company-cell { display: flex; min-width: 0; align-items: center; gap: 10px; }
|
||||
.express-company-cell > span { display: grid; width: 34px; height: 34px; flex: 0 0 34px; place-items: center; background: #eef3fa; color: var(--cc-primary); }
|
||||
.express-company-cell > div { display: flex; min-width: 0; flex-direction: column; gap: 3px; }
|
||||
.express-company-cell strong, .express-company-cell small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.express-company-cell small { color: var(--cc-muted); font-size: 11px; }
|
||||
.company-link { display: inline-flex; max-width: 100%; align-items: center; gap: 5px; color: var(--cc-primary); text-decoration: none; overflow-wrap: anywhere; }
|
||||
.company-link:hover { text-decoration: underline; }
|
||||
.company-link:focus-visible { outline: 3px solid rgba(21, 94, 239, .2); outline-offset: 2px; }
|
||||
.phone-value { display: inline-flex; align-items: center; gap: 6px; color: #344054; }
|
||||
.phone-value svg { color: var(--cc-muted); }
|
||||
.company-detail-list { margin: 0; }
|
||||
.company-detail-list > div { display: grid; grid-template-columns: 92px minmax(0, 1fr); gap: 16px; padding: 14px 0; border-bottom: 1px solid var(--cc-border); }
|
||||
.company-detail-list dt { color: var(--cc-muted); font-size: 12px; }
|
||||
.company-detail-list dd { min-width: 0; margin: 0; color: #172033; font-size: 13px; font-weight: 650; }
|
||||
@media (max-width: 420px) { .company-detail-list > div { grid-template-columns: 1fr; gap: 6px; } }
|
||||
</style>
|
||||
@@ -0,0 +1,241 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { ClipboardCheck, Download, Eye, PackageCheck, Pencil, Plus, RotateCcw, Search, Ship, UserRoundCheck } from '@lucide/vue'
|
||||
import { FIRST_LEG_ORDER_STATUSES } from '@chongchong/shared'
|
||||
import MetricStrip from '@/components/MetricStrip.vue'
|
||||
import StatusBadge from '@/components/StatusBadge.vue'
|
||||
import { useUnsavedWorkRegistration } from '@/composables/useUnsavedWorkRegistration'
|
||||
import { useAccessPreviewStore } from '@/stores/accessPreview'
|
||||
import { useAdminDataStore } from '@/stores/adminData'
|
||||
import type { FirstLegOrder, OrderStatus } from '@/types/admin'
|
||||
|
||||
type OrderForm = Omit<FirstLegOrder, 'no' | 'createdAt' | 'timeline'>
|
||||
|
||||
const store = useAdminDataStore()
|
||||
const access = useAccessPreviewStore()
|
||||
const page = ref(1)
|
||||
const pageSize = 10
|
||||
const editorOpen = ref(false)
|
||||
const detailOpen = ref(false)
|
||||
const statusDialogOpen = ref(false)
|
||||
const deliveryDialogOpen = ref(false)
|
||||
const currentOrder = ref<FirstLegOrder | null>(null)
|
||||
const selectedRows = ref<FirstLegOrder[]>([])
|
||||
const statusTargetIds = ref<string[]>([])
|
||||
const dateRange = ref<[string, string] | []>([])
|
||||
const filters = reactive({ keyword: '', status: '', mode: '', customerId: '' })
|
||||
const statusForm = reactive<{ status: OrderStatus; operator: string; note: string }>({ status: '已受理', operator: '', note: '' })
|
||||
const deliveryForm = reactive({ orderId: '', expressCompanyId: '', trackingNo: '', operator: '' })
|
||||
|
||||
const emptyForm = (): OrderForm => ({
|
||||
id: '', customerId: '', customerName: '', referenceNo: '', transportMode: '海运整箱 FCL', businessType: '出口',
|
||||
origin: '', destination: '', receivingWarehouseId: '', receivingWarehouseSnapshot: null, container: '40HQ x 1', etd: '', eta: '', amount: 0, currency: 'CNY', status: '草稿',
|
||||
priority: '普通', operator: '未分配', shipper: '', consignee: '', cargoName: '', pieces: 1, weight: 0, volume: 0, remark: '',
|
||||
})
|
||||
const form = reactive(emptyForm())
|
||||
const editorLabel = computed(() => form.id ? '编辑头程订单' : '创建头程订单')
|
||||
const editorRequiredPermission = computed(() => form.id ? null : 'first-leg:create')
|
||||
const { captureBaseline } = useUnsavedWorkRegistration({
|
||||
key: 'first-leg-order-editor',
|
||||
label: editorLabel,
|
||||
isOpen: editorOpen,
|
||||
model: form,
|
||||
requiredPermission: editorRequiredPermission,
|
||||
})
|
||||
const statusOptions: OrderStatus[] = FIRST_LEG_ORDER_STATUSES.map((item) => item.label)
|
||||
const statusTransitions: Record<OrderStatus, OrderStatus[]> = { 草稿: ['待审核', '已取消'], 待审核: ['已受理', '已取消'], 已受理: ['已订舱', '已取消'], 已订舱: ['运输中', '已取消'], 运输中: ['已完成', '已取消'], 已完成: [], 已取消: [] }
|
||||
const availableStatusOptions = computed(() => {
|
||||
if (statusTargetIds.value.length !== 1) return ['已取消'] as OrderStatus[]
|
||||
const order = store.orders.find((item) => item.id === statusTargetIds.value[0])
|
||||
return order ? [order.status, ...statusTransitions[order.status]] : []
|
||||
})
|
||||
const operatorOptions = computed(() => store.users.filter((item) => item.status === '正常' && ['运营部', '操作部', '平台管理'].includes(item.department)).map((item) => item.name))
|
||||
const selectableWarehouses = computed(() => store.warehouses.filter((item) => item.status === 'ACTIVE' || item.id === form.receivingWarehouseId))
|
||||
|
||||
const filteredRows = computed(() => store.orders.filter((item) => {
|
||||
const keyword = filters.keyword.trim().toLowerCase()
|
||||
const keywordMatch = !keyword || [item.no, item.customerName, item.referenceNo, item.origin, item.destination].some((value) => value.toLowerCase().includes(keyword))
|
||||
const dateMatch = dateRange.value.length !== 2 || (item.createdAt.slice(0, 10) >= dateRange.value[0] && item.createdAt.slice(0, 10) <= dateRange.value[1])
|
||||
return keywordMatch && dateMatch && (!filters.status || item.status === filters.status) && (!filters.mode || item.transportMode === filters.mode) && (!filters.customerId || item.customerId === filters.customerId)
|
||||
}))
|
||||
const pagedRows = computed(() => filteredRows.value.slice((page.value - 1) * pageSize, page.value * pageSize))
|
||||
const metrics = computed(() => [
|
||||
{ label: '全部订单', value: store.orders.length, note: '当前数据范围' },
|
||||
{ label: '待审核/受理', value: store.pendingOrders, note: '需要运营处理' },
|
||||
{ label: '运输中', value: store.orders.filter((item) => item.status === '运输中').length, note: '正在履约' },
|
||||
{ label: '应收金额', value: `¥${store.orders.reduce((total, item) => total + (item.currency === 'CNY' ? item.amount : 0), 0).toLocaleString()}`, note: '人民币订单合计' },
|
||||
])
|
||||
|
||||
watch(filters, () => { page.value = 1 })
|
||||
watch(dateRange, () => { page.value = 1 })
|
||||
watch(
|
||||
() => access.can('first-leg:create'),
|
||||
(canCreate) => {
|
||||
if (canCreate || !editorOpen.value || form.id) return
|
||||
editorOpen.value = false
|
||||
Object.assign(form, emptyForm())
|
||||
},
|
||||
{ flush: 'sync' },
|
||||
)
|
||||
|
||||
function resetFilters() { Object.assign(filters, { keyword: '', status: '', mode: '', customerId: '' }); dateRange.value = [] }
|
||||
function openCreate() { Object.assign(form, emptyForm()); captureBaseline(); editorOpen.value = true }
|
||||
function openEdit(row: FirstLegOrder) { Object.assign(form, row); captureBaseline(); detailOpen.value = false; editorOpen.value = true }
|
||||
function openDetail(row: FirstLegOrder) { currentOrder.value = row; detailOpen.value = true }
|
||||
function handleCustomerChange(customerId: string) {
|
||||
const customer = store.customers.find((item) => item.id === customerId)
|
||||
if (!customer) return
|
||||
form.customerName = customer.name
|
||||
if (!form.shipper) form.shipper = customer.name
|
||||
}
|
||||
|
||||
function saveOrder(status: '草稿' | '待审核') {
|
||||
if (!form.customerId || !form.origin.trim() || !form.destination.trim() || !form.receivingWarehouseId || !form.cargoName.trim()) {
|
||||
ElMessage.warning('请填写客户、起运地、目的地、平台收货仓和货物名称')
|
||||
return
|
||||
}
|
||||
const customer = store.customers.find((item) => item.id === form.customerId)
|
||||
try {
|
||||
store.saveOrder({ ...form, id: form.id || undefined, customerName: customer?.name || form.customerName, status: form.id ? form.status : status })
|
||||
editorOpen.value = false
|
||||
ElMessage.success(status === '草稿' ? '订单草稿已保存' : '头程订单已创建并提交审核')
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '订单保存失败')
|
||||
}
|
||||
}
|
||||
|
||||
function openStatusDialog(rows: FirstLegOrder[]) {
|
||||
if (rows.length === 0) return
|
||||
statusTargetIds.value = rows.map((item) => item.id)
|
||||
statusForm.status = rows.length === 1 ? rows[0].status : '已受理'
|
||||
statusForm.operator = rows.length === 1 && rows[0].operator !== '未分配' ? rows[0].operator : ''
|
||||
statusForm.note = ''
|
||||
statusDialogOpen.value = true
|
||||
}
|
||||
|
||||
function applyStatus() {
|
||||
if (!statusForm.operator && !['草稿', '待审核', '已取消'].includes(statusForm.status)) {
|
||||
ElMessage.warning('请指定订单负责人')
|
||||
return
|
||||
}
|
||||
try { statusTargetIds.value.forEach((id) => store.updateOrderStatus(id, statusForm.status, statusForm.operator, statusForm.note)) } catch (error) { ElMessage.error(error instanceof Error ? error.message : '订单状态更新失败'); return }
|
||||
statusDialogOpen.value = false
|
||||
selectedRows.value = []
|
||||
if (currentOrder.value) currentOrder.value = store.orders.find((item) => item.id === currentOrder.value?.id) || null
|
||||
ElMessage.success(statusTargetIds.value.length > 1 ? `已更新 ${statusTargetIds.value.length} 笔订单` : '订单状态已更新')
|
||||
}
|
||||
|
||||
function openDeliveryDialog(order: FirstLegOrder) {
|
||||
currentOrder.value = order
|
||||
Object.assign(deliveryForm, {
|
||||
orderId: order.id,
|
||||
expressCompanyId: order.deliveryInfo?.expressCompanyId || '',
|
||||
trackingNo: order.deliveryInfo?.trackingNo || '',
|
||||
operator: order.deliveryInfo?.operator || (order.operator === '未分配' ? '' : order.operator),
|
||||
})
|
||||
deliveryDialogOpen.value = true
|
||||
}
|
||||
|
||||
function saveDeliveryInfo() {
|
||||
if (!deliveryForm.expressCompanyId || !deliveryForm.trackingNo.trim() || !deliveryForm.operator) {
|
||||
ElMessage.warning('请选择快递公司,并填写快递单号和操作人')
|
||||
return
|
||||
}
|
||||
try {
|
||||
store.saveOrderDeliveryInfo(deliveryForm.orderId, deliveryForm.expressCompanyId, deliveryForm.trackingNo, deliveryForm.operator)
|
||||
currentOrder.value = store.orders.find((item) => item.id === deliveryForm.orderId) || null
|
||||
deliveryDialogOpen.value = false
|
||||
ElMessage.success('履约快递信息已保存')
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '履约快递信息保存失败')
|
||||
}
|
||||
}
|
||||
|
||||
function deliveryCompanies(order: FirstLegOrder | null) {
|
||||
return store.expressCompanies.filter((item) => item.status === '合作中' || item.id === order?.deliveryInfo?.expressCompanyId)
|
||||
}
|
||||
|
||||
function statusStep(status: OrderStatus) {
|
||||
const index = ['待审核', '已受理', '已订舱', '运输中', '已完成'].indexOf(status)
|
||||
return index < 0 ? 0 : index + 1
|
||||
}
|
||||
|
||||
function contactCustomerForModification(order: FirstLegOrder) {
|
||||
try { store.recordCustomerModificationContact(order.id, order.operator === '未分配' ? '平台管理员' : order.operator); currentOrder.value = store.orders.find((item) => item.id === order.id) || null; ElMessage.success('已记录联系客户修改,订单继续保持待审核') }
|
||||
catch (error) { ElMessage.error(error instanceof Error ? error.message : '记录失败') }
|
||||
}
|
||||
|
||||
function canEditOrder(order: FirstLegOrder) { return access.can('first-leg:status') || (order.status === '草稿' && access.can('first-leg:create')) }
|
||||
|
||||
function exportOrders() { ElMessage.success(`已生成 ${filteredRows.value.length} 笔订单的导出任务`) }
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="business-page">
|
||||
<MetricStrip :items="metrics" />
|
||||
<section class="surface-panel filter-panel order-filter" aria-label="头程订单筛选">
|
||||
<div class="filter-fields">
|
||||
<el-input v-model="filters.keyword" clearable placeholder="订单号、客户、参考号或港口" aria-label="订单关键词"><template #prefix><Search :size="16" /></template></el-input>
|
||||
<el-select v-model="filters.customerId" clearable filterable placeholder="全部客户" aria-label="订单客户"><el-option v-for="item in store.customers" :key="item.id" :label="item.name" :value="item.id" /></el-select>
|
||||
<el-select v-model="filters.status" clearable placeholder="全部状态" aria-label="订单状态"><el-option v-for="item in statusOptions" :key="item" :label="item" :value="item" /></el-select>
|
||||
<el-select v-model="filters.mode" clearable placeholder="全部运输方式" aria-label="运输方式"><el-option v-for="item in ['海运整箱 FCL','海运拼箱 LCL','空运','陆运']" :key="item" :label="item" :value="item" /></el-select>
|
||||
<el-date-picker v-model="dateRange" type="daterange" value-format="YYYY-MM-DD" range-separator="至" start-placeholder="创建开始" end-placeholder="创建结束" aria-label="订单创建日期" />
|
||||
</div>
|
||||
<div class="filter-actions"><el-button @click="resetFilters"><RotateCcw :size="16" />重置</el-button><el-button v-if="access.can('first-leg:status')" :disabled="selectedRows.length === 0" @click="openStatusDialog(selectedRows)"><ClipboardCheck :size="16" />批量更新</el-button><el-button v-if="!access.usesServerPermissions" @click="exportOrders"><Download :size="16" />导出</el-button><el-button v-if="access.can('first-leg:create')" type="primary" @click="openCreate"><Plus :size="16" />创建订单</el-button></div>
|
||||
</section>
|
||||
|
||||
<section class="surface-panel table-panel">
|
||||
<header class="panel-heading"><div><h2>头程订单</h2><p>从审核、受理、订舱到运输完成,统一跟进订单状态与负责人。</p></div><span v-if="selectedRows.length" class="selection-note">已选择 {{ selectedRows.length }} 笔</span></header>
|
||||
<el-table :data="pagedRows" row-key="id" empty-text="没有符合条件的头程订单" @selection-change="selectedRows = $event">
|
||||
<el-table-column type="selection" width="44" />
|
||||
<el-table-column label="订单信息" min-width="210"><template #default="{ row }"><div class="primary-cell"><strong class="order-no">{{ row.no }}</strong><span>{{ row.referenceNo || '无客户参考号' }} · {{ row.createdAt }}</span></div></template></el-table-column>
|
||||
<el-table-column label="客户" min-width="185" show-overflow-tooltip><template #default="{ row }"><div class="primary-cell"><strong>{{ row.customerName }}</strong><span>{{ row.businessType }} · {{ row.priority }}</span></div></template></el-table-column>
|
||||
<el-table-column label="运输方案" min-width="190"><template #default="{ row }"><div class="primary-cell"><strong>{{ row.transportMode }}</strong><span>{{ row.container }}</span></div></template></el-table-column>
|
||||
<el-table-column label="路线" min-width="160"><template #default="{ row }"><div class="route-cell"><span>{{ row.origin }}</span><i>→</i><span>{{ row.destination }}</span></div></template></el-table-column>
|
||||
<el-table-column label="平台收货仓" min-width="150"><template #default="{ row }"><div class="primary-cell"><strong>{{ row.receivingWarehouseSnapshot?.name || '-' }}</strong><span>{{ row.receivingWarehouseSnapshot?.code || '-' }}</span></div></template></el-table-column>
|
||||
<el-table-column label="履约快递" min-width="160"><template #default="{ row }"><div class="primary-cell"><strong>{{ row.deliveryInfo?.expressCompanyName || '未维护' }}</strong><span>{{ row.deliveryInfo?.trackingNo || '—' }}</span></div></template></el-table-column>
|
||||
<el-table-column label="应收" width="118" align="right"><template #default="{ row }"><strong>{{ row.currency === 'CNY' ? '¥' : '$' }}{{ row.amount.toLocaleString() }}</strong></template></el-table-column>
|
||||
<el-table-column label="负责人" width="100"><template #default="{ row }"><span :class="{ 'muted-value': row.operator === '未分配' }">{{ row.operator }}</span></template></el-table-column>
|
||||
<el-table-column label="状态" width="96"><template #default="{ row }"><StatusBadge :status="row.status" /></template></el-table-column>
|
||||
<el-table-column label="操作" width="290" fixed="right"><template #default="{ row }"><div class="table-actions"><el-button link type="primary" @click="openDetail(row as FirstLegOrder)"><Eye :size="15" />详情</el-button><el-button v-if="canEditOrder(row as FirstLegOrder)" link type="primary" @click="openEdit(row as FirstLegOrder)"><Pencil :size="15" />编辑</el-button><el-button v-if="access.can('first-leg:delivery')" link type="primary" @click="openDeliveryDialog(row as FirstLegOrder)"><PackageCheck :size="15" />快递</el-button><el-button v-if="access.can('first-leg:status')" link type="success" @click="openStatusDialog([row as FirstLegOrder])"><UserRoundCheck :size="15" />处理</el-button></div></template></el-table-column>
|
||||
</el-table>
|
||||
<footer class="table-footer"><span>显示 {{ pagedRows.length }} 条,共 {{ filteredRows.length }} 条</span><el-pagination v-model:current-page="page" :page-size="pageSize" :total="filteredRows.length" layout="prev, pager, next" /></footer>
|
||||
</section>
|
||||
|
||||
<el-drawer v-model="editorOpen" :title="form.id ? '编辑头程订单' : '创建头程订单'" size="780px" class="business-drawer editor-drawer order-editor" :modal="false" :close-on-click-modal="false">
|
||||
<el-form label-position="top" class="drawer-form">
|
||||
<div class="form-section"><h3>业务信息</h3><div class="form-grid three-cols"><el-form-item label="客户" required><el-select v-model="form.customerId" filterable placeholder="请选择客户" @change="handleCustomerChange"><el-option v-for="item in store.customers.filter((customer) => customer.status === '正常')" :key="item.id" :label="item.name" :value="item.id" /></el-select></el-form-item><el-form-item label="运输方式"><el-select v-model="form.transportMode"><el-option v-for="item in ['海运整箱 FCL','海运拼箱 LCL','空运','陆运']" :key="item" :label="item" :value="item" /></el-select></el-form-item><el-form-item label="业务类型"><el-radio-group v-model="form.businessType"><el-radio-button label="出口" value="出口" /><el-radio-button label="进口" value="进口" /></el-radio-group></el-form-item><el-form-item label="客户参考号"><el-input v-model="form.referenceNo" /></el-form-item><el-form-item label="优先级"><el-select v-model="form.priority"><el-option label="普通" value="普通" /><el-option label="紧急" value="紧急" /><el-option label="加急" value="加急" /></el-select></el-form-item><el-form-item label="负责人"><el-select v-model="form.operator" clearable placeholder="暂不分配"><el-option v-for="item in operatorOptions" :key="item" :label="item" :value="item" /></el-select></el-form-item></div></div>
|
||||
<div class="form-section"><h3>运输信息</h3><div class="form-grid three-cols"><el-form-item label="起运地 / POL" required><el-input v-model="form.origin" placeholder="如 宁波" /></el-form-item><el-form-item label="目的地 / POD" required><el-input v-model="form.destination" placeholder="如 洛杉矶" /></el-form-item><el-form-item label="平台收货仓" required><el-select v-model="form.receivingWarehouseId" filterable placeholder="请选择收货仓"><el-option v-for="warehouse in selectableWarehouses" :key="warehouse.id" :label="`${warehouse.name} · ${warehouse.city}`" :value="warehouse.id" :disabled="warehouse.status !== 'ACTIVE'" /></el-select></el-form-item><el-form-item label="柜型 / 箱量"><el-input v-model="form.container" placeholder="如 40HQ x 2" /></el-form-item><el-form-item label="预计出发 ETD"><el-date-picker v-model="form.etd" type="date" value-format="YYYY-MM-DD" placeholder="选择日期" /></el-form-item><el-form-item label="预计到达 ETA"><el-date-picker v-model="form.eta" type="date" value-format="YYYY-MM-DD" placeholder="选择日期" /></el-form-item><el-form-item label="应收金额"><div class="amount-field"><el-select v-model="form.currency"><el-option label="CNY" value="CNY" /><el-option label="USD" value="USD" /></el-select><el-input-number v-model="form.amount" :min="0" :precision="2" controls-position="right" /></div></el-form-item></div></div>
|
||||
<div class="form-section"><h3>货物与收发货方</h3><div class="form-grid three-cols"><el-form-item label="货物名称" required><el-input v-model="form.cargoName" /></el-form-item><el-form-item label="件数"><el-input-number v-model="form.pieces" :min="1" controls-position="right" /></el-form-item><el-form-item label="毛重 (kg)"><el-input-number v-model="form.weight" :min="0" :precision="2" controls-position="right" /></el-form-item><el-form-item label="体积 (m³)"><el-input-number v-model="form.volume" :min="0" :precision="2" controls-position="right" /></el-form-item><el-form-item label="发货方"><el-input v-model="form.shipper" /></el-form-item><el-form-item label="收货方"><el-input v-model="form.consignee" /></el-form-item></div><el-form-item label="内部备注"><el-input v-model="form.remark" type="textarea" :rows="3" placeholder="仅平台内部可见" /></el-form-item></div>
|
||||
</el-form>
|
||||
<template #footer><div class="drawer-footer"><el-button @click="editorOpen = false">取消</el-button><el-button v-if="!form.id && access.can('first-leg:create')" @click="saveOrder('草稿')">保存草稿</el-button><el-button v-if="form.id || access.can('first-leg:create')" type="primary" @click="saveOrder('待审核')">{{ form.id ? '保存修改' : '创建并提交' }}</el-button></div></template>
|
||||
</el-drawer>
|
||||
|
||||
<el-drawer v-model="detailOpen" title="订单详情" size="720px" class="business-drawer">
|
||||
<template v-if="currentOrder">
|
||||
<div class="order-detail-head"><div><span>{{ currentOrder.transportMode }}</span><h2>{{ currentOrder.no }}</h2><p>{{ currentOrder.customerName }} · {{ currentOrder.referenceNo }}</p></div><StatusBadge :status="currentOrder.status" /></div>
|
||||
<div class="order-progress"><el-steps :active="statusStep(currentOrder.status)" finish-status="success" align-center><el-step title="待审核" /><el-step title="已受理" /><el-step title="已订舱" /><el-step title="运输中" /><el-step title="已完成" /></el-steps></div>
|
||||
<div class="detail-section"><h3>订单概要</h3><el-descriptions :column="2" border><el-descriptions-item label="运输路线">{{ currentOrder.origin }} → {{ currentOrder.destination }}</el-descriptions-item><el-descriptions-item label="业务类型">{{ currentOrder.businessType }}</el-descriptions-item><el-descriptions-item label="平台收货仓"><strong>{{ currentOrder.receivingWarehouseSnapshot?.name || '-' }}</strong><span v-if="currentOrder.receivingWarehouseSnapshot" class="warehouse-snapshot-detail">{{ currentOrder.receivingWarehouseSnapshot.code }} · {{ currentOrder.receivingWarehouseSnapshot.province }}{{ currentOrder.receivingWarehouseSnapshot.city }}{{ currentOrder.receivingWarehouseSnapshot.address }}</span></el-descriptions-item><el-descriptions-item label="柜型/箱量">{{ currentOrder.container }}</el-descriptions-item><el-descriptions-item label="负责人">{{ currentOrder.operator }}</el-descriptions-item><el-descriptions-item label="ETD / ETA">{{ currentOrder.etd || '-' }} / {{ currentOrder.eta || '-' }}</el-descriptions-item><el-descriptions-item label="应收金额">{{ currentOrder.currency === 'CNY' ? '¥' : '$' }}{{ currentOrder.amount.toLocaleString() }}</el-descriptions-item></el-descriptions></div>
|
||||
<div class="detail-section"><h3>货物信息</h3><MetricStrip :items="[{ label: '品名', value: currentOrder.cargoName }, { label: '件数', value: currentOrder.pieces }, { label: '毛重', value: `${currentOrder.weight.toLocaleString()} kg` }, { label: '体积', value: `${currentOrder.volume} m³` }]" /><div class="party-grid"><div><span>发货方</span><strong>{{ currentOrder.shipper }}</strong></div><div><span>收货方</span><strong>{{ currentOrder.consignee }}</strong></div></div></div>
|
||||
<div class="detail-section"><h3>履约快递</h3><el-empty v-if="!currentOrder.deliveryInfo" description="尚未维护快递公司和快递单号" :image-size="64" /><el-descriptions v-else :column="2" border><el-descriptions-item label="快递公司">{{ currentOrder.deliveryInfo.expressCompanyName }}({{ currentOrder.deliveryInfo.expressCompanyCode }})</el-descriptions-item><el-descriptions-item label="快递单号">{{ currentOrder.deliveryInfo.trackingNo }}</el-descriptions-item><el-descriptions-item label="维护人">{{ currentOrder.deliveryInfo.operator }}</el-descriptions-item><el-descriptions-item label="更新时间">{{ currentOrder.deliveryInfo.updatedAt }}</el-descriptions-item></el-descriptions></div>
|
||||
<div class="detail-section"><h3>处理记录</h3><el-timeline><el-timeline-item v-for="item in [...currentOrder.timeline].reverse()" :key="`${item.time}-${item.title}`" :timestamp="`${item.time} · ${item.operator}`" placement="top"><strong>{{ item.title }}</strong><p>{{ item.description }}</p></el-timeline-item></el-timeline></div>
|
||||
</template>
|
||||
<template #footer><div class="drawer-footer"><el-button @click="detailOpen = false">关闭</el-button><el-button v-if="currentOrder && canEditOrder(currentOrder)" @click="openEdit(currentOrder)">编辑订单</el-button><el-button v-if="currentOrder?.status === '待审核' && access.can('first-leg:status')" @click="contactCustomerForModification(currentOrder)">联系客户修改</el-button><el-button v-if="access.can('first-leg:delivery')" @click="currentOrder && openDeliveryDialog(currentOrder)">维护快递</el-button><el-button v-if="access.can('first-leg:status')" type="primary" @click="currentOrder && openStatusDialog([currentOrder])">更新状态</el-button></div></template>
|
||||
</el-drawer>
|
||||
|
||||
<el-dialog v-model="statusDialogOpen" :title="statusTargetIds.length > 1 ? `批量更新 ${statusTargetIds.length} 笔订单` : '处理订单'" width="520px" class="status-dialog">
|
||||
<el-form label-position="top"><el-form-item label="订单状态"><el-select v-model="statusForm.status"><el-option v-for="item in availableStatusOptions" :key="item" :label="item" :value="item" /></el-select></el-form-item><el-form-item label="负责人"><el-select v-model="statusForm.operator" clearable placeholder="请选择负责人"><el-option v-for="item in operatorOptions" :key="item" :label="item" :value="item" /></el-select></el-form-item><el-form-item label="处理说明"><el-input v-model="statusForm.note" type="textarea" :rows="3" placeholder="记录本次状态变更的原因或下一步动作" /></el-form-item></el-form>
|
||||
<template #footer><el-button @click="statusDialogOpen = false">取消</el-button><el-button v-if="access.can('first-leg:status')" type="primary" @click="applyStatus">确认更新</el-button></template>
|
||||
</el-dialog>
|
||||
<el-dialog v-model="deliveryDialogOpen" title="维护履约快递" width="520px">
|
||||
<el-alert title="一期每个订单仅维护一组快递公司和快递单号;修改会覆盖当前值并追加审计记录。" type="info" :closable="false" show-icon />
|
||||
<el-form label-position="top" class="delivery-form"><el-form-item label="快递公司" required><el-select v-model="deliveryForm.expressCompanyId" filterable placeholder="请选择合作中的快递公司"><el-option v-for="item in deliveryCompanies(currentOrder)" :key="item.id" :label="`${item.name} · ${item.code}`" :value="item.id" :disabled="item.status !== '合作中'" /></el-select></el-form-item><el-form-item label="快递单号" required><el-input v-model="deliveryForm.trackingNo" maxlength="80" placeholder="请输入完整快递单号" /></el-form-item><el-form-item label="操作人" required><el-select v-model="deliveryForm.operator" filterable placeholder="请选择操作人"><el-option v-for="item in operatorOptions" :key="item" :label="item" :value="item" /></el-select></el-form-item></el-form>
|
||||
<template #footer><el-button @click="deliveryDialogOpen = false">取消</el-button><el-button v-if="access.can('first-leg:delivery')" type="primary" @click="saveDeliveryInfo">保存快递信息</el-button></template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.warehouse-snapshot-detail { display: block; margin-top: 4px; color: var(--cc-muted); font-size: 11px; line-height: 1.5; }
|
||||
</style>
|
||||
@@ -0,0 +1,80 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { createMemoryHistory, createRouter } from 'vue-router'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useAccessPreviewStore } from '@/stores/accessPreview'
|
||||
import { useAdminDataStore } from '@/stores/adminData'
|
||||
import ForbiddenView from './ForbiddenView.vue'
|
||||
|
||||
let pinia: ReturnType<typeof createPinia>
|
||||
|
||||
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),
|
||||
}
|
||||
}
|
||||
|
||||
async function mountForbidden(from: string) {
|
||||
const router = createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [
|
||||
{ path: '/customers', component: { template: '<p>customers</p>' } },
|
||||
{ path: '/403', component: ForbiddenView },
|
||||
],
|
||||
})
|
||||
await router.push({ path: '/403', query: { from } })
|
||||
await router.isReady()
|
||||
|
||||
const wrapper = mount(ForbiddenView, {
|
||||
global: {
|
||||
plugins: [pinia, router],
|
||||
stubs: {
|
||||
'el-button': {
|
||||
template: '<button type="button" @click="$emit(\'click\')"><slot /></button>',
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
return { wrapper, router }
|
||||
}
|
||||
|
||||
describe('ForbiddenView', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('localStorage', createMemoryStorage())
|
||||
pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
})
|
||||
|
||||
it('shows the preview role and replaces the route with the first accessible page', async () => {
|
||||
const access = useAccessPreviewStore()
|
||||
access.initializeAfterLogin()
|
||||
const { wrapper, router } = await mountForbidden('/customers?status=pending')
|
||||
|
||||
expect(wrapper.text()).toContain('权限预览:超级管理员')
|
||||
expect(wrapper.text()).toContain('来源页面:/customers?status=pending')
|
||||
await wrapper.get('button').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(router.currentRoute.value.fullPath).toBe('/customers')
|
||||
})
|
||||
|
||||
it('sanitizes an external-looking source and guides role switching when no role is available', async () => {
|
||||
const data = useAdminDataStore()
|
||||
data.roles.forEach((role) => { role.status = '停用' })
|
||||
const access = useAccessPreviewStore()
|
||||
access.initializeAfterLogin()
|
||||
const { wrapper } = await mountForbidden('//example.com')
|
||||
|
||||
expect(wrapper.text()).toContain('来源页面:/')
|
||||
expect(wrapper.text()).not.toContain('//example.com')
|
||||
expect(wrapper.text()).toContain('当前无可用角色')
|
||||
expect(wrapper.text()).toContain('请使用顶栏切换角色')
|
||||
expect(wrapper.find('button').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,37 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { ShieldX } from '@lucide/vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { sanitizeInternalPath } from '@/router/accessDecision'
|
||||
import { useAccessPreviewStore } from '@/stores/accessPreview'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const access = useAccessPreviewStore()
|
||||
|
||||
const sourcePath = computed(() => sanitizeInternalPath(route.query.from))
|
||||
const previewRoleLabel = computed(() => access.currentRole
|
||||
? `权限预览:${access.currentRole.name}`
|
||||
: '当前无可用角色')
|
||||
|
||||
async function returnToAccessiblePage() {
|
||||
if (!access.firstAccessiblePath) return
|
||||
await router.replace(access.firstAccessiblePath)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="forbidden-page" aria-labelledby="forbidden-title">
|
||||
<div class="forbidden-icon" aria-hidden="true">
|
||||
<ShieldX :size="32" />
|
||||
</div>
|
||||
<p class="section-kicker">403 · 权限不足</p>
|
||||
<h2 id="forbidden-title">当前预览角色无法访问此页面</h2>
|
||||
<p>{{ previewRoleLabel }}</p>
|
||||
<p class="forbidden-source">来源页面:{{ sourcePath }}</p>
|
||||
<el-button v-if="access.firstAccessiblePath" type="primary" @click="returnToAccessiblePage">
|
||||
返回可访问页面
|
||||
</el-button>
|
||||
<p v-else>请使用顶栏切换角色,选择具有可访问菜单的角色。</p>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,128 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { KeyRound, Pencil, Plus, RotateCcw, Search, UserRoundCog } from '@lucide/vue'
|
||||
import MetricStrip from '@/components/MetricStrip.vue'
|
||||
import StatusBadge from '@/components/StatusBadge.vue'
|
||||
import { useUnsavedWorkRegistration } from '@/composables/useUnsavedWorkRegistration'
|
||||
import { useAccessPreviewStore } from '@/stores/accessPreview'
|
||||
import { useAdminDataStore } from '@/stores/adminData'
|
||||
import type { InternalUser } from '@/types/admin'
|
||||
|
||||
const store = useAdminDataStore()
|
||||
const access = useAccessPreviewStore()
|
||||
const page = ref(1)
|
||||
const pageSize = 10
|
||||
const editorOpen = ref(false)
|
||||
const filters = reactive({ keyword: '', department: '', roleId: '', status: '' })
|
||||
const emptyForm = (): Omit<InternalUser, 'lastLogin' | 'createdAt'> => ({ id: '', account: '', name: '', department: '', phone: '', email: '', roleIds: [], status: '正常' })
|
||||
const form = reactive(emptyForm())
|
||||
const editorLabel = computed(() => form.id ? '编辑内部用户' : '新增内部用户')
|
||||
const requiredPermission = computed(() => 'system:user:write')
|
||||
const { captureBaseline } = useUnsavedWorkRegistration({
|
||||
key: 'internal-user-editor',
|
||||
label: editorLabel,
|
||||
isOpen: editorOpen,
|
||||
model: form,
|
||||
requiredPermission,
|
||||
})
|
||||
|
||||
const roleName = (id: string) => store.roles.find((role) => role.id === id)?.name || id
|
||||
const filteredRows = computed(() => store.users.filter((item) => {
|
||||
const keyword = filters.keyword.trim().toLowerCase()
|
||||
const matchesKeyword = !keyword || [item.account, item.name, item.phone, item.email].some((value) => value.toLowerCase().includes(keyword))
|
||||
return matchesKeyword && (!filters.department || item.department === filters.department) && (!filters.roleId || item.roleIds.includes(filters.roleId)) && (!filters.status || item.status === filters.status)
|
||||
}))
|
||||
const pagedRows = computed(() => filteredRows.value.slice((page.value - 1) * pageSize, page.value * pageSize))
|
||||
const departments = computed(() => [...new Set(store.users.map((item) => item.department))])
|
||||
const metrics = computed(() => [
|
||||
{ label: '内部用户', value: store.users.length, note: '平台员工账号' },
|
||||
{ label: '正常账号', value: store.activeUsers, note: '当前可登录' },
|
||||
{ label: '已停用', value: store.users.filter((item) => item.status === '停用').length, note: '无法登录平台' },
|
||||
{ label: '角色覆盖', value: store.roles.length, note: '已配置角色' },
|
||||
])
|
||||
|
||||
watch(filters, () => { page.value = 1 })
|
||||
|
||||
function resetFilters() { Object.assign(filters, { keyword: '', department: '', roleId: '', status: '' }) }
|
||||
function openCreate() { Object.assign(form, emptyForm()); captureBaseline(); editorOpen.value = true }
|
||||
function openEdit(row: InternalUser) { Object.assign(form, { ...row, roleIds: [...row.roleIds] }); captureBaseline(); editorOpen.value = true }
|
||||
|
||||
function saveUser() {
|
||||
if (!form.account.trim() || !form.name.trim() || !form.department.trim() || form.roleIds.length === 0) {
|
||||
ElMessage.warning('请填写账号、姓名、部门并至少分配一个角色')
|
||||
return
|
||||
}
|
||||
if (!form.id && store.users.some((item) => item.account === form.account)) {
|
||||
ElMessage.error('该登录账号已存在,请更换账号')
|
||||
return
|
||||
}
|
||||
store.saveUser({ ...form, id: form.id || undefined, roleIds: [...form.roleIds] })
|
||||
editorOpen.value = false
|
||||
ElMessage.success(form.id ? '用户资料已更新' : '内部用户已创建')
|
||||
}
|
||||
|
||||
async function resetPassword(row: InternalUser) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确认重置“${row.name}”的登录密码吗?`, '重置密码', { confirmButtonText: '确认重置', cancelButtonText: '取消', type: 'warning' })
|
||||
ElMessage.success('密码已重置,请通知用户首次登录后修改密码')
|
||||
} catch {
|
||||
// 用户取消,不需要反馈。
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleStatus(row: InternalUser) {
|
||||
if (row.account === 'admin') { ElMessage.warning('系统管理员账号不能停用'); return }
|
||||
const target = row.status === '正常' ? '停用' : '正常'
|
||||
try {
|
||||
await ElMessageBox.confirm(`确认${target === '停用' ? '停用' : '启用'}“${row.name}”的账号吗?`, '账号状态', { confirmButtonText: '确认', cancelButtonText: '取消' })
|
||||
store.setUserStatus(row.id, target)
|
||||
ElMessage.success(`账号已${target === '正常' ? '启用' : '停用'}`)
|
||||
} catch {
|
||||
// 用户取消,不需要反馈。
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="business-page">
|
||||
<MetricStrip :items="metrics" />
|
||||
<section class="surface-panel filter-panel" aria-label="内部用户筛选">
|
||||
<div class="filter-fields">
|
||||
<el-input v-model="filters.keyword" clearable placeholder="账号、姓名、手机或邮箱" aria-label="用户关键词"><template #prefix><Search :size="16" /></template></el-input>
|
||||
<el-select v-model="filters.department" clearable placeholder="全部部门" aria-label="用户部门"><el-option v-for="item in departments" :key="item" :label="item" :value="item" /></el-select>
|
||||
<el-select v-model="filters.roleId" clearable placeholder="全部角色" aria-label="用户角色"><el-option v-for="item in store.roles" :key="item.id" :label="item.name" :value="item.id" /></el-select>
|
||||
<el-select v-model="filters.status" clearable placeholder="全部状态" aria-label="用户状态"><el-option label="正常" value="正常" /><el-option label="停用" value="停用" /></el-select>
|
||||
</div>
|
||||
<div class="filter-actions"><el-button @click="resetFilters"><RotateCcw :size="16" />重置</el-button><el-button v-if="access.can('system:user:write')" type="primary" @click="openCreate"><Plus :size="16" />新增用户</el-button></div>
|
||||
</section>
|
||||
|
||||
<section class="surface-panel table-panel">
|
||||
<header class="panel-heading"><div><h2>内部用户</h2><p>维护平台员工账号,并通过角色控制菜单和操作权限。</p></div></header>
|
||||
<el-table :data="pagedRows" row-key="id" empty-text="没有符合条件的内部用户">
|
||||
<el-table-column label="用户" min-width="190"><template #default="{ row }"><div class="user-cell"><span>{{ row.name.slice(0,1) }}</span><div><strong>{{ row.name }}</strong><small>{{ row.account }}</small></div></div></template></el-table-column>
|
||||
<el-table-column prop="department" label="部门" width="110" />
|
||||
<el-table-column label="角色" min-width="190"><template #default="{ row }"><div class="tag-list"><el-tag v-for="roleId in row.roleIds" :key="roleId" effect="plain" size="small">{{ roleName(roleId) }}</el-tag></div></template></el-table-column>
|
||||
<el-table-column label="联系方式" min-width="190"><template #default="{ row }"><div class="primary-cell"><strong>{{ row.phone }}</strong><span>{{ row.email }}</span></div></template></el-table-column>
|
||||
<el-table-column prop="lastLogin" label="最后登录" width="150" />
|
||||
<el-table-column label="状态" width="88"><template #default="{ row }"><StatusBadge :status="row.status" /></template></el-table-column>
|
||||
<el-table-column label="操作" width="250" fixed="right"><template #default="{ row }"><div v-if="access.can('system:user:write')" class="table-actions"><el-button link type="primary" @click="openEdit(row as InternalUser)"><Pencil :size="15" />编辑</el-button><el-button link type="primary" @click="resetPassword(row as InternalUser)"><KeyRound :size="15" />重置密码</el-button><el-button link :type="row.status === '正常' ? 'danger' : 'success'" :disabled="row.account === 'admin'" @click="toggleStatus(row as InternalUser)">{{ row.status === '正常' ? '停用' : '启用' }}</el-button></div></template></el-table-column>
|
||||
</el-table>
|
||||
<footer class="table-footer"><span>显示 {{ pagedRows.length }} 条,共 {{ filteredRows.length }} 条</span><el-pagination v-model:current-page="page" :page-size="pageSize" :total="filteredRows.length" layout="prev, pager, next" /></footer>
|
||||
</section>
|
||||
|
||||
<el-drawer v-model="editorOpen" :title="form.id ? '编辑内部用户' : '新增内部用户'" size="620px" class="business-drawer editor-drawer" :modal="false" :close-on-click-modal="false">
|
||||
<el-form label-position="top" class="drawer-form">
|
||||
<div class="form-section"><h3>账号信息</h3><div class="form-grid two-cols">
|
||||
<el-form-item label="登录账号" required><el-input v-model="form.account" :disabled="Boolean(form.id)" placeholder="英文、数字或下划线" /></el-form-item>
|
||||
<el-form-item label="姓名" required><el-input v-model="form.name" /></el-form-item>
|
||||
<el-form-item label="所属部门" required><el-select v-model="form.department" filterable allow-create placeholder="请选择或输入部门"><el-option v-for="item in departments" :key="item" :label="item" :value="item" /></el-select></el-form-item>
|
||||
<el-form-item label="账号状态"><el-radio-group v-model="form.status"><el-radio-button label="正常" value="正常" /><el-radio-button label="停用" value="停用" /></el-radio-group></el-form-item>
|
||||
</div></div>
|
||||
<div class="form-section"><h3>联系方式</h3><div class="form-grid two-cols"><el-form-item label="手机号"><el-input v-model="form.phone" /></el-form-item><el-form-item label="邮箱"><el-input v-model="form.email" /></el-form-item></div></div>
|
||||
<div class="form-section"><h3>角色分配</h3><el-form-item label="平台角色" required><el-select v-model="form.roleIds" multiple collapse-tags placeholder="至少选择一个角色"><el-option v-for="item in store.roles.filter((role) => role.status === '启用')" :key="item.id" :label="item.name" :value="item.id" /></el-select></el-form-item><p class="form-help"><UserRoundCog :size="16" />用户最终权限为所分配角色权限的合集。</p></div>
|
||||
</el-form>
|
||||
<template #footer><div class="drawer-footer"><el-button @click="editorOpen = false">取消</el-button><el-button v-if="access.can('system:user:write')" type="primary" @click="saveUser">保存用户</el-button></div></template>
|
||||
</el-drawer>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,150 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { createMemoryHistory, createRouter } from 'vue-router'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useAccessPreviewStore } from '@/stores/accessPreview'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import LoginView from './LoginView.vue'
|
||||
|
||||
const loginSource = readFileSync(resolve(process.cwd(), 'src/views/LoginView.vue'), 'utf8')
|
||||
|
||||
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),
|
||||
}
|
||||
}
|
||||
|
||||
describe('LoginView access preview lifecycle', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('localStorage', createMemoryStorage())
|
||||
})
|
||||
|
||||
it('only exposes the integration account hint through the development environment branch', () => {
|
||||
expect(loginSource).toContain('const isDevelopment = import.meta.env.DEV')
|
||||
expect(loginSource).toContain('v-if="developmentHint" class="demo-tip"')
|
||||
expect(loginSource).toContain("account: isDevelopment ? 'platform_admin' : ''")
|
||||
expect(loginSource).toContain("password: isDevelopment ? 'ChangeMe123!' : ''")
|
||||
})
|
||||
|
||||
it('initializes preview access after login and before redirecting', async () => {
|
||||
const pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
const auth = useAuthStore()
|
||||
const access = useAccessPreviewStore()
|
||||
const router = createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [
|
||||
{ path: '/login', component: LoginView },
|
||||
{ path: '/', component: { template: '<p>home</p>' } },
|
||||
],
|
||||
})
|
||||
await router.push('/login')
|
||||
await router.isReady()
|
||||
|
||||
const calls: string[] = []
|
||||
vi.spyOn(auth, 'login').mockImplementation(async () => { calls.push('login') })
|
||||
vi.spyOn(access, 'initializeAfterLogin').mockImplementation(() => { calls.push('access') })
|
||||
vi.spyOn(router, 'replace').mockImplementation(async () => { calls.push('replace') })
|
||||
const wrapper = mount(LoginView, {
|
||||
global: {
|
||||
plugins: [pinia, router],
|
||||
stubs: {
|
||||
'el-form': { template: '<form><slot /></form>' },
|
||||
'el-form-item': { template: '<label><slot /></label>' },
|
||||
'el-input': { template: '<input />' },
|
||||
'el-button': { emits: ['click'], template: '<button type="button" @click="$emit(\'click\')"><slot /></button>' },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
await wrapper.get('.login-submit').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(calls).toEqual(['login', 'access', 'replace'])
|
||||
})
|
||||
|
||||
it.each([
|
||||
['/customers?page=2', '/customers?page=2'],
|
||||
['/customers/reviews', '/customers/reviews'],
|
||||
['//example.com', '/customers'],
|
||||
['https://example.com', '/customers'],
|
||||
[['/customers', '/orders/first-leg'], '/customers'],
|
||||
[null, '/customers'],
|
||||
])('sanitizes the post-login redirect %j to %s', async (redirect, expected) => {
|
||||
const pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
const auth = useAuthStore()
|
||||
const router = createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [
|
||||
{ path: '/login', component: LoginView },
|
||||
{ path: '/', component: { template: '<p>home</p>' } },
|
||||
],
|
||||
})
|
||||
await router.push({ path: '/login', query: { redirect } })
|
||||
await router.isReady()
|
||||
vi.spyOn(auth, 'login').mockResolvedValue()
|
||||
const replace = vi.spyOn(router, 'replace').mockResolvedValue()
|
||||
const wrapper = mount(LoginView, {
|
||||
global: {
|
||||
plugins: [pinia, router],
|
||||
stubs: {
|
||||
'el-form': { template: '<form><slot /></form>' },
|
||||
'el-form-item': { template: '<label><slot /></label>' },
|
||||
'el-input': { template: '<input />' },
|
||||
'el-button': { emits: ['click'], template: '<button type="button" @click="$emit(\'click\')"><slot /></button>' },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
await wrapper.get('.login-submit').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(replace).toHaveBeenCalledWith(expected)
|
||||
})
|
||||
|
||||
it('preserves the form and reports a recoverable error when login fails', async () => {
|
||||
const pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
const auth = useAuthStore()
|
||||
const access = useAccessPreviewStore()
|
||||
const router = createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [
|
||||
{ path: '/login', component: LoginView },
|
||||
{ path: '/customers', component: { template: '<p>customers</p>' } },
|
||||
],
|
||||
})
|
||||
await router.push('/login')
|
||||
await router.isReady()
|
||||
vi.spyOn(auth, 'login').mockRejectedValue(new Error('network unavailable'))
|
||||
const initialize = vi.spyOn(access, 'initializeAfterLogin')
|
||||
const replace = vi.spyOn(router, 'replace')
|
||||
const wrapper = mount(LoginView, {
|
||||
global: {
|
||||
plugins: [pinia, router],
|
||||
stubs: {
|
||||
'el-form': { template: '<form><slot /></form>' },
|
||||
'el-form-item': { template: '<label><slot /></label>' },
|
||||
'el-input': { template: '<input />' },
|
||||
'el-button': { emits: ['click'], template: '<button type="button" @click="$emit(\'click\')"><slot /></button>' },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
await wrapper.get('.login-submit').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.get('[role="alert"]').text()).toBe('network unavailable')
|
||||
expect(initialize).not.toHaveBeenCalled()
|
||||
expect(replace).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,87 @@
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { ArrowRight, LockKeyhole, ShieldCheck, UserRound } from '@lucide/vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/api/http'
|
||||
import ApiErrorMeta from '@/components/ApiErrorMeta.vue'
|
||||
import { sanitizeInternalPath } from '@/router/accessDecision'
|
||||
import { useAccessPreviewStore } from '@/stores/accessPreview'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
const access = useAccessPreviewStore()
|
||||
const loading = ref(false)
|
||||
const loginError = ref<ApiErrorDetails | null>(null)
|
||||
const isDevelopment = import.meta.env.DEV
|
||||
const developmentHint = isDevelopment ? '当前为前端开发环境,可使用预填账号进入。' : ''
|
||||
const form = reactive({
|
||||
account: isDevelopment ? 'platform_admin' : '',
|
||||
password: isDevelopment ? 'ChangeMe123!' : '',
|
||||
})
|
||||
|
||||
async function submit() {
|
||||
loginError.value = null
|
||||
if (!form.account || !form.password) {
|
||||
loginError.value = { message: '请输入账号和密码' }
|
||||
ElMessage.warning(loginError.value.message)
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
await auth.login(form.account, form.password)
|
||||
access.initializeAfterLogin()
|
||||
ElMessage.success('登录成功')
|
||||
const requestedPath = sanitizeInternalPath(route.query.redirect)
|
||||
const targetPath = requestedPath === '/'
|
||||
? access.firstAccessiblePath ?? '/403'
|
||||
: requestedPath
|
||||
await router.replace(targetPath)
|
||||
} catch (error) {
|
||||
loginError.value = getApiErrorDetails(error, '登录失败,请检查账号和密码后重试')
|
||||
ElMessage.error(loginError.value.message)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="login-page">
|
||||
<section class="login-intro">
|
||||
<div class="login-brand"><span>CC</span> 虫虫出海</div>
|
||||
<div>
|
||||
<p class="eyebrow">CHONGCHONG GLOBAL</p>
|
||||
<h1>让跨境物流管理<br />清晰、可靠、可追踪</h1>
|
||||
<p class="intro-copy">统一管理企业入驻、内部权限和头程订单,让每一次操作都有边界、有记录。</p>
|
||||
</div>
|
||||
<div class="security-note">
|
||||
<ShieldCheck :size="22" />
|
||||
<span>平台内部系统,请使用授权账号登录</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="login-panel">
|
||||
<div class="login-card">
|
||||
<div class="login-card-icon"><LockKeyhole :size="24" /></div>
|
||||
<h2>登录管理后台</h2>
|
||||
<p>使用平台内部员工账号登录</p>
|
||||
<el-form label-position="top" size="large" @submit.prevent="submit">
|
||||
<el-form-item label="账号">
|
||||
<el-input v-model.trim="form.account" placeholder="请输入账号" :prefix-icon="UserRound" autocomplete="username" maxlength="100" />
|
||||
</el-form-item>
|
||||
<el-form-item label="密码">
|
||||
<el-input v-model="form.password" type="password" show-password placeholder="请输入密码" :prefix-icon="LockKeyhole" autocomplete="current-password" maxlength="128" @keyup.enter="submit" />
|
||||
</el-form-item>
|
||||
<el-button class="login-submit" type="primary" :loading="loading" @click="submit">
|
||||
登录 <ArrowRight :size="17" />
|
||||
</el-button>
|
||||
</el-form>
|
||||
<div v-if="loginError" class="login-error" role="alert"><p>{{ loginError.message }}</p><ApiErrorMeta :details="loginError" /></div>
|
||||
<div v-if="developmentHint" class="demo-tip" role="note">{{ developmentHint }}</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,29 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { Construction, Plus, Search } from '@lucide/vue'
|
||||
|
||||
const route = useRoute()
|
||||
const title = computed(() => route.meta.title || '功能模块')
|
||||
const description = computed(() => route.meta.description || '')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="module-page">
|
||||
<div class="module-toolbar">
|
||||
<el-input placeholder="输入关键词搜索" clearable>
|
||||
<template #prefix><Search :size="17" /></template>
|
||||
</el-input>
|
||||
<el-button type="primary"><Plus :size="17" /> 新增</el-button>
|
||||
</div>
|
||||
|
||||
<div class="module-card">
|
||||
<div class="empty-state">
|
||||
<div class="empty-icon"><Construction :size="28" /></div>
|
||||
<h2>{{ title }}</h2>
|
||||
<p>{{ description }}</p>
|
||||
<el-tag effect="plain">页面骨架已完成,业务接口待接入</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,80 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { flushPromises, shallowMount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { listPlatformPermissions, type PlatformPermissionResource } from '@/api/platformIam'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import PermissionsView from './PermissionsView.vue'
|
||||
|
||||
vi.mock('@/api/platformIam', () => ({
|
||||
createPlatformPermission: vi.fn(),
|
||||
listPlatformPermissions: vi.fn(),
|
||||
}))
|
||||
|
||||
const permission: PlatformPermissionResource = {
|
||||
id: 5100010,
|
||||
tenantId: 0,
|
||||
appCode: 'PLATFORM',
|
||||
permissionType: 'MENU',
|
||||
parentId: 5100000,
|
||||
permissionCode: 'platform:customer:list',
|
||||
permissionName: '企业客户',
|
||||
routePath: '/customers',
|
||||
httpMethod: null,
|
||||
sortNo: 10,
|
||||
status: 'ACTIVE',
|
||||
}
|
||||
|
||||
function mountServerView() {
|
||||
const pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
const auth = useAuthStore()
|
||||
auth.isServerSession = true
|
||||
auth.permissions = ['platform:permission:list', 'platform:permission:create']
|
||||
return shallowMount(PermissionsView, {
|
||||
global: {
|
||||
plugins: [pinia],
|
||||
directives: { loading: () => undefined },
|
||||
stubs: {
|
||||
'el-alert': { props: ['title'], template: '<div role="alert">{{ title }}<slot name="title" /></div>' },
|
||||
'el-input': true,
|
||||
'el-option': true,
|
||||
'el-select': true,
|
||||
'el-button': true,
|
||||
'el-table': true,
|
||||
'el-table-column': true,
|
||||
'el-input-number': true,
|
||||
'el-radio-group': true,
|
||||
'el-radio-button': true,
|
||||
'el-drawer': { template: '<section><slot /><slot name="footer" /></section>' },
|
||||
'el-form': { template: '<form><slot /></form>' },
|
||||
'el-form-item': { template: '<label><slot /></label>' },
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
describe('PermissionsView IAM mode', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(listPlatformPermissions).mockReset()
|
||||
})
|
||||
|
||||
it('loads the platform permission resource tree from IAM', async () => {
|
||||
vi.mocked(listPlatformPermissions).mockResolvedValue([permission])
|
||||
|
||||
const wrapper = mountServerView()
|
||||
await flushPromises()
|
||||
|
||||
expect(listPlatformPermissions).toHaveBeenCalledTimes(1)
|
||||
expect(wrapper.text()).toContain('权限资源列表和新增已接入 IAM')
|
||||
expect(wrapper.text()).toContain('一期真实资源仅使用菜单和按钮类型')
|
||||
})
|
||||
|
||||
it('shows a recoverable state when the permission list fails', async () => {
|
||||
vi.mocked(listPlatformPermissions).mockRejectedValue(new Error('IAM unavailable'))
|
||||
|
||||
const wrapper = mountServerView()
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.findAll('[role="alert"]').map((item) => item.text()).join(' ')).toContain('IAM unavailable')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,180 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Braces, FolderTree, Pencil, Plus, Search, ShieldCheck } from '@lucide/vue'
|
||||
import MetricStrip from '@/components/MetricStrip.vue'
|
||||
import StatusBadge from '@/components/StatusBadge.vue'
|
||||
import { createPlatformPermission, listPlatformPermissions, type PlatformPermissionResource } from '@/api/platformIam'
|
||||
import { getApiErrorDetails, getApiErrorMessage, type ApiErrorDetails } from '@/api/http'
|
||||
import ApiErrorMeta from '@/components/ApiErrorMeta.vue'
|
||||
import { useUnsavedWorkRegistration } from '@/composables/useUnsavedWorkRegistration'
|
||||
import { useAccessPreviewStore } from '@/stores/accessPreview'
|
||||
import { useAdminDataStore } from '@/stores/adminData'
|
||||
import type { Permission, PermissionType } from '@/types/admin'
|
||||
import { buildPermissionTree, getDescendantIds } from '@/utils/permissionTree'
|
||||
|
||||
const store = useAdminDataStore()
|
||||
const access = useAccessPreviewStore()
|
||||
const editorOpen = ref(false)
|
||||
const serverPermissions = ref<Permission[]>([])
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const loadError = ref<ApiErrorDetails | null>(null)
|
||||
const filters = reactive({ keyword: '', type: '', status: '' })
|
||||
const emptyForm = (): Permission => ({ id: '', parentId: null, name: '', code: '', type: '菜单', path: '', method: '-', status: '启用', sort: 10 })
|
||||
const form = reactive(emptyForm())
|
||||
const editorLabel = computed(() => form.id ? '编辑权限' : '新增权限')
|
||||
const requiredPermission = computed(() => access.usesServerPermissions ? 'platform:permission:create' : 'system:permission:write')
|
||||
const { captureBaseline } = useUnsavedWorkRegistration({
|
||||
key: 'permission-editor',
|
||||
label: editorLabel,
|
||||
isOpen: editorOpen,
|
||||
model: form,
|
||||
requiredPermission,
|
||||
})
|
||||
const permissionTypes: PermissionType[] = ['目录', '菜单', '按钮', '接口']
|
||||
const availablePermissionTypes = computed<PermissionType[]>(() => access.usesServerPermissions ? ['菜单', '按钮'] : permissionTypes)
|
||||
const permissions = computed(() => access.usesServerPermissions ? serverPermissions.value : store.permissions)
|
||||
const canCreate = computed(() => access.can('platform:permission:create'))
|
||||
|
||||
function matches(item: Permission) {
|
||||
const keyword = filters.keyword.trim().toLowerCase()
|
||||
return (!keyword || [item.name, item.code, item.path].some((value) => value.toLowerCase().includes(keyword))) && (!filters.type || item.type === filters.type) && (!filters.status || item.status === filters.status)
|
||||
}
|
||||
const treeRows = computed(() => buildPermissionTree(permissions.value, matches))
|
||||
const blockedParentIds = computed(() => new Set(
|
||||
form.id ? [form.id, ...getDescendantIds(permissions.value, form.id)] : [],
|
||||
))
|
||||
const parentOptions = computed(() => permissions.value.filter((item) => (
|
||||
(access.usesServerPermissions ? item.type === '菜单' : ['目录', '菜单'].includes(item.type)) && !blockedParentIds.value.has(item.id)
|
||||
)))
|
||||
const metrics = computed(() => [
|
||||
{ label: '权限资源', value: permissions.value.length, note: access.usesServerPermissions ? 'IAM 返回的资源' : '全部权限编码' },
|
||||
{ label: '导航资源', value: permissions.value.filter((item) => ['目录','菜单'].includes(item.type)).length, note: '目录与页面入口' },
|
||||
{ label: '操作权限', value: permissions.value.filter((item) => item.type === '按钮').length, note: '控制页面操作' },
|
||||
{ label: '停用资源', value: permissions.value.filter((item) => item.status === '停用').length, note: '当前不可分配' },
|
||||
])
|
||||
|
||||
function mapPlatformPermission(item: PlatformPermissionResource): Permission {
|
||||
return {
|
||||
id: String(item.id),
|
||||
parentId: item.parentId === 0 ? null : String(item.parentId),
|
||||
name: item.permissionName,
|
||||
code: item.permissionCode,
|
||||
type: item.permissionType === 'BUTTON' ? '按钮' : '菜单',
|
||||
path: item.routePath ?? '',
|
||||
method: item.httpMethod ?? '-',
|
||||
status: item.status === 'ACTIVE' ? '启用' : '停用',
|
||||
sort: item.sortNo,
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPermissions() {
|
||||
if (!access.usesServerPermissions) return
|
||||
loading.value = true
|
||||
loadError.value = null
|
||||
serverPermissions.value = []
|
||||
try {
|
||||
serverPermissions.value = (await listPlatformPermissions()).map(mapPlatformPermission)
|
||||
} catch (error) {
|
||||
loadError.value = getApiErrorDetails(error, '权限资源加载失败,请稍后重试')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openCreate(parentId: string | null = null) { Object.assign(form, emptyForm(), { parentId }); captureBaseline(); editorOpen.value = true }
|
||||
function openEdit(row: Permission) { if (access.usesServerPermissions) return; Object.assign(form, row); captureBaseline(); editorOpen.value = true }
|
||||
async function savePermission() {
|
||||
if (!form.name.trim() || !form.code.trim()) { ElMessage.warning('请填写权限名称和权限编码'); return }
|
||||
if (permissions.value.some((item) => item.code.toLowerCase() === form.code.toLowerCase() && item.id !== form.id)) { ElMessage.error('权限编码已经存在'); return }
|
||||
if (access.usesServerPermissions) {
|
||||
if (form.type === '菜单' && !form.path.trim()) { ElMessage.warning('请填写菜单路由'); return }
|
||||
if (form.type === '按钮' && !form.parentId) { ElMessage.warning('按钮权限必须选择上级菜单'); return }
|
||||
saving.value = true
|
||||
try {
|
||||
const created = await createPlatformPermission({
|
||||
type: form.type === '按钮' ? 'BUTTON' : 'MENU',
|
||||
code: form.code.trim(),
|
||||
name: form.name.trim(),
|
||||
parentId: form.parentId ? Number(form.parentId) : 0,
|
||||
routePath: form.type === '菜单' ? form.path.trim() : null,
|
||||
httpMethod: null,
|
||||
sortNo: form.sort,
|
||||
})
|
||||
serverPermissions.value.push(mapPlatformPermission(created))
|
||||
editorOpen.value = false
|
||||
ElMessage.success('权限资源已创建')
|
||||
} catch (error) {
|
||||
ElMessage.error(getApiErrorMessage(error, '权限资源创建失败,请稍后重试'))
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
return
|
||||
}
|
||||
store.savePermission({ ...form, id: form.id || undefined })
|
||||
editorOpen.value = false
|
||||
ElMessage.success(form.id ? '权限资源已更新' : '权限资源已创建')
|
||||
}
|
||||
async function toggleStatus(row: Permission) {
|
||||
const target = row.status === '启用' ? '停用' : '启用'
|
||||
const directoryNotice = row.type === '目录' && target === '停用'
|
||||
? ' 目录仅用于组织结构,停用不会级联影响已启用的下级菜单和操作。'
|
||||
: ''
|
||||
try {
|
||||
await ElMessageBox.confirm(`确认${target}权限“${row.name}”吗?${directoryNotice}`, '权限状态', { confirmButtonText: '确认', cancelButtonText: '取消' })
|
||||
store.savePermission({ ...row, status: target })
|
||||
ElMessage.success(`权限已${target}`)
|
||||
} catch {
|
||||
// 用户取消,不需要反馈。
|
||||
}
|
||||
}
|
||||
function typeClass(type: PermissionType) { return { '目录': 'directory', '菜单': 'menu', '按钮': 'action', '接口': 'api' }[type] }
|
||||
|
||||
onMounted(loadPermissions)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="business-page">
|
||||
<MetricStrip :items="metrics" />
|
||||
<el-alert v-if="access.usesServerPermissions" type="warning" :closable="false" show-icon title="权限资源列表和新增已接入 IAM;一期真实资源仅使用菜单和按钮类型,编辑与启停接口尚未提供。" />
|
||||
<el-alert v-if="loadError" type="error" :closable="false" show-icon role="alert"><template #title><div class="api-error-alert-title"><span>{{ loadError.message }}</span><ApiErrorMeta :details="loadError" /></div><el-button link type="danger" @click="loadPermissions">重新加载</el-button></template></el-alert>
|
||||
<section class="surface-panel filter-panel" aria-label="权限筛选">
|
||||
<div class="filter-fields"><el-input v-model="filters.keyword" clearable placeholder="权限名称、编码或路径" aria-label="权限关键词"><template #prefix><Search :size="16" /></template></el-input><el-select v-model="filters.type" clearable placeholder="全部类型" aria-label="权限类型"><el-option v-for="item in availablePermissionTypes" :key="item" :label="item" :value="item" /></el-select><el-select v-model="filters.status" clearable placeholder="全部状态" aria-label="权限状态"><el-option label="启用" value="启用" /><el-option label="停用" value="停用" /></el-select></div>
|
||||
<div class="filter-actions"><el-button v-if="canCreate" type="primary" @click="openCreate(null)"><Plus :size="16" />新增权限</el-button></div>
|
||||
</section>
|
||||
|
||||
<section class="surface-panel table-panel">
|
||||
<header class="panel-heading"><div><h2>权限资源树</h2><p>目录组织菜单,按钮控制页面操作;接口权限将在服务端契约确定后补充。</p></div><div class="legend-list"><span><i class="is-menu" />目录 / 菜单</span><span><i class="is-action" />操作</span><span><i class="is-api" />接口预留</span></div></header>
|
||||
<el-table v-loading="loading" :data="treeRows" row-key="id" default-expand-all :tree-props="{ children: 'children' }" empty-text="没有符合条件的权限资源">
|
||||
<el-table-column label="权限名称" min-width="260"><template #default="{ row }"><div class="permission-name"><span :class="`is-${typeClass(row.type)}`"><FolderTree v-if="row.type === '目录' || row.type === '菜单'" :size="16" /><ShieldCheck v-else-if="row.type === '按钮'" :size="16" /><Braces v-else :size="16" /></span><strong>{{ row.name }}</strong></div></template></el-table-column>
|
||||
<el-table-column label="权限编码" min-width="220"><template #default="{ row }"><code class="code-value">{{ row.code }}</code></template></el-table-column>
|
||||
<el-table-column prop="type" label="类型" width="80" />
|
||||
<el-table-column label="路由 / 接口" min-width="260"><template #default="{ row }"><div class="endpoint-cell"><span v-if="row.method !== '-'">{{ row.method }}</span><code>{{ row.path || '-' }}</code></div></template></el-table-column>
|
||||
<el-table-column prop="sort" label="排序" width="72" align="right" />
|
||||
<el-table-column label="状态" width="88"><template #default="{ row }"><StatusBadge :status="row.status" /></template></el-table-column>
|
||||
<el-table-column label="操作" width="235" fixed="right"><template #default="{ row }"><div v-if="access.usesServerPermissions && canCreate" class="table-actions"><el-button v-if="row.type === '菜单'" link type="primary" @click="openCreate(row.id)"><Plus :size="15" />添加下级</el-button><span class="secondary-text">编辑接口待补齐</span></div><div v-else-if="access.can('system:permission:write')" class="table-actions"><el-button link type="primary" @click="openEdit(row as Permission)"><Pencil :size="15" />编辑</el-button><el-button v-if="row.type === '目录' || row.type === '菜单'" link type="primary" @click="openCreate(row.id)"><Plus :size="15" />添加下级</el-button><el-button link :type="row.status === '启用' ? 'danger' : 'success'" @click="toggleStatus(row as Permission)">{{ row.status === '启用' ? '停用' : '启用' }}</el-button></div></template></el-table-column>
|
||||
</el-table>
|
||||
</section>
|
||||
|
||||
<el-drawer v-model="editorOpen" :title="form.id ? '编辑权限资源' : '新增权限资源'" size="600px" class="business-drawer editor-drawer" :modal="false" :close-on-click-modal="false">
|
||||
<el-form label-position="top" class="drawer-form">
|
||||
<div class="form-section">
|
||||
<h3>资源定义</h3>
|
||||
<div class="form-grid two-cols">
|
||||
<el-form-item label="权限名称" required><el-input v-model="form.name" placeholder="用户可理解的中文名称" maxlength="64" /></el-form-item>
|
||||
<el-form-item label="权限编码" required><el-input v-model="form.code" placeholder="如 platform:customer:list" maxlength="128" /></el-form-item>
|
||||
<el-form-item label="资源类型"><el-select v-model="form.type"><el-option v-for="item in availablePermissionTypes" :key="item" :label="item" :value="item" /></el-select></el-form-item>
|
||||
<el-form-item label="上级资源"><el-select v-model="form.parentId" clearable placeholder="作为一级资源"><el-option v-for="item in parentOptions" :key="item.id" :label="`${item.name} (${item.code})`" :value="item.id" /></el-select></el-form-item>
|
||||
<el-form-item v-if="form.type === '菜单' || !access.usesServerPermissions" label="路由或接口路径"><el-input v-model="form.path" placeholder="例如 /customers" /></el-form-item>
|
||||
<el-form-item v-if="!access.usesServerPermissions" label="请求方法"><el-select v-model="form.method"><el-option v-for="item in ['-','GET','POST','PUT','PATCH','DELETE']" :key="item" :label="item" :value="item" /></el-select></el-form-item>
|
||||
<el-form-item label="显示排序"><el-input-number v-model="form.sort" :min="0" :max="999" controls-position="right" /></el-form-item>
|
||||
<el-form-item v-if="!access.usesServerPermissions" label="状态"><el-radio-group v-model="form.status"><el-radio-button label="启用" value="启用" /><el-radio-button label="停用" value="停用" /></el-radio-group></el-form-item>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-callout"><Braces :size="18" /><div><strong>权限编码约定</strong><p>使用稳定的“业务域:资源:动作”编码;一期真实资源只创建菜单和按钮,接口鉴权由服务端实现。</p></div></div>
|
||||
</el-form>
|
||||
<template #footer><div class="drawer-footer"><el-button @click="editorOpen = false">取消</el-button><el-button v-if="access.usesServerPermissions ? canCreate : access.can('system:permission:write')" type="primary" :loading="saving" @click="savePermission">保存权限</el-button></div></template>
|
||||
</el-drawer>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,98 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { flushPromises, shallowMount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createPlatformRole, listPlatformRoles, type PlatformRoleResource } from '@/api/platformIam'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import RolesView from './RolesView.vue'
|
||||
|
||||
vi.mock('@/api/platformIam', () => ({
|
||||
createPlatformRole: vi.fn(),
|
||||
listPlatformRoles: vi.fn(),
|
||||
}))
|
||||
|
||||
const platformRole: PlatformRoleResource = {
|
||||
id: 4000002,
|
||||
tenantId: 0,
|
||||
appCode: 'PLATFORM',
|
||||
roleCode: 'CUSTOMER_REVIEWER',
|
||||
roleName: '企业审核员',
|
||||
roleType: 'SYSTEM',
|
||||
status: 'ACTIVE',
|
||||
description: '企业资料与入驻审核',
|
||||
}
|
||||
|
||||
function mountView(pinia = createPinia()) {
|
||||
setActivePinia(pinia)
|
||||
return shallowMount(RolesView, {
|
||||
global: {
|
||||
plugins: [pinia],
|
||||
directives: { loading: () => undefined },
|
||||
stubs: {
|
||||
'el-alert': { props: ['title'], template: '<div role="alert">{{ title }}<slot name="title" /></div>' },
|
||||
'el-input': true,
|
||||
'el-option': true,
|
||||
'el-select': true,
|
||||
'el-button': true,
|
||||
'el-table': true,
|
||||
'el-table-column': true,
|
||||
'el-tag': true,
|
||||
'el-radio-group': true,
|
||||
'el-radio-button': true,
|
||||
'el-drawer': {
|
||||
props: ['modelValue'],
|
||||
template: '<section><slot /><slot name="footer" /></section>',
|
||||
},
|
||||
'el-form': { template: '<form><slot /></form>' },
|
||||
'el-form-item': { template: '<label><slot /></label>' },
|
||||
'el-tree': {
|
||||
props: { checkStrictly: Boolean },
|
||||
template: '<div data-testid="permission-tree" :data-check-strictly="String(checkStrictly)" />',
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
describe('RolesView', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.mocked(listPlatformRoles).mockReset()
|
||||
vi.mocked(createPlatformRole).mockReset()
|
||||
})
|
||||
|
||||
it('keeps directory, menu, and action permissions independently checkable', () => {
|
||||
const wrapper = mountView()
|
||||
|
||||
expect(wrapper.get('[data-testid="permission-tree"]').attributes('data-check-strictly')).toBe('true')
|
||||
})
|
||||
|
||||
it('loads platform roles from IAM for a server session', async () => {
|
||||
const pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
const auth = useAuthStore()
|
||||
auth.isServerSession = true
|
||||
auth.permissions = ['platform:role:list', 'platform:role:create']
|
||||
vi.mocked(listPlatformRoles).mockResolvedValue([platformRole])
|
||||
|
||||
const wrapper = mountView(pinia)
|
||||
await flushPromises()
|
||||
|
||||
expect(listPlatformRoles).toHaveBeenCalledTimes(1)
|
||||
expect(wrapper.text()).toContain('角色列表和新增角色已接入 IAM')
|
||||
expect(wrapper.find('[data-testid="permission-tree"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps a failed IAM role list recoverable', async () => {
|
||||
const pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
const auth = useAuthStore()
|
||||
auth.isServerSession = true
|
||||
auth.permissions = ['platform:role:list']
|
||||
vi.mocked(listPlatformRoles).mockRejectedValue(new Error('IAM unavailable'))
|
||||
|
||||
const wrapper = mountView(pinia)
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.findAll('[role="alert"]').map((item) => item.text()).join(' ')).toContain('IAM unavailable')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,207 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Pencil, Plus, RotateCcw, Search, ShieldCheck, Trash2 } from '@lucide/vue'
|
||||
import { createPlatformRole, listPlatformRoles, type PlatformRoleResource } from '@/api/platformIam'
|
||||
import { getApiErrorDetails, getApiErrorMessage, type ApiErrorDetails } from '@/api/http'
|
||||
import ApiErrorMeta from '@/components/ApiErrorMeta.vue'
|
||||
import MetricStrip from '@/components/MetricStrip.vue'
|
||||
import StatusBadge from '@/components/StatusBadge.vue'
|
||||
import { useUnsavedWorkRegistration } from '@/composables/useUnsavedWorkRegistration'
|
||||
import { useAccessPreviewStore } from '@/stores/accessPreview'
|
||||
import { useAdminDataStore } from '@/stores/adminData'
|
||||
import type { DataScope, Role } from '@/types/admin'
|
||||
import { buildPermissionTree, summarizePermissionSelection } from '@/utils/permissionTree'
|
||||
|
||||
interface TreeController {
|
||||
getCheckedKeys: (leafOnly?: boolean) => Array<string | number>
|
||||
getHalfCheckedKeys: () => Array<string | number>
|
||||
setCheckedKeys: (keys: string[]) => void
|
||||
}
|
||||
|
||||
const store = useAdminDataStore()
|
||||
const access = useAccessPreviewStore()
|
||||
const editorOpen = ref(false)
|
||||
const treeRef = ref<TreeController | null>(null)
|
||||
const serverRoles = ref<Role[]>([])
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const loadError = ref<ApiErrorDetails | null>(null)
|
||||
const filters = reactive({ keyword: '', status: '', dataScope: '' })
|
||||
const emptyForm = (): Omit<Role, 'userCount'> => ({ id: '', name: '', code: '', description: '', dataScope: '本部门数据', status: '启用', permissionIds: [], builtIn: false })
|
||||
const form = reactive(emptyForm())
|
||||
const editorLabel = computed(() => form.id ? '配置角色' : '新增角色')
|
||||
const requiredPermission = computed(() => access.usesServerPermissions ? 'platform:role:create' : 'system:role:grant')
|
||||
const { captureBaseline } = useUnsavedWorkRegistration({
|
||||
key: 'role-editor',
|
||||
label: editorLabel,
|
||||
isOpen: editorOpen,
|
||||
model: form,
|
||||
requiredPermission,
|
||||
})
|
||||
const dataScopes: DataScope[] = ['全部数据', '本部门数据', '本人数据', '自定义']
|
||||
|
||||
const roles = computed(() => access.usesServerPermissions ? serverRoles.value : store.roles)
|
||||
const canCreate = computed(() => access.can('platform:role:create'))
|
||||
const filteredRows = computed(() => roles.value.filter((item) => {
|
||||
const keyword = filters.keyword.trim().toLowerCase()
|
||||
return (!keyword || [item.name, item.code, item.description].some((value) => value.toLowerCase().includes(keyword))) && (!filters.status || item.status === filters.status) && (!filters.dataScope || item.dataScope === filters.dataScope)
|
||||
}))
|
||||
const metrics = computed(() => [
|
||||
{ label: '角色总数', value: roles.value.length, note: access.usesServerPermissions ? 'IAM 返回的角色' : '平台角色模型' },
|
||||
{ label: '启用角色', value: roles.value.filter((item) => item.status === '启用').length, note: '当前可分配' },
|
||||
{ label: '授权用户', value: access.usesServerPermissions ? '—' : store.users.length, note: access.usesServerPermissions ? '成员关系待接口回显' : '已分配平台角色' },
|
||||
{ label: '权限资源', value: access.usesServerPermissions ? '—' : store.permissions.length, note: access.usesServerPermissions ? '角色授权待接口回显' : '菜单、按钮与接口' },
|
||||
])
|
||||
const permissionTree = computed(() => buildPermissionTree(store.permissions))
|
||||
|
||||
watch(() => form.dataScope, () => undefined)
|
||||
|
||||
function resetFilters() { Object.assign(filters, { keyword: '', status: '', dataScope: '' }) }
|
||||
|
||||
function mapPlatformRole(role: PlatformRoleResource): Role {
|
||||
return {
|
||||
id: String(role.id),
|
||||
name: role.roleName,
|
||||
code: role.roleCode,
|
||||
description: role.description ?? '暂无角色说明',
|
||||
userCount: 0,
|
||||
dataScope: '全部数据',
|
||||
status: role.status === 'ACTIVE' ? '启用' : '停用',
|
||||
permissionIds: [],
|
||||
builtIn: role.roleType === 'SYSTEM',
|
||||
}
|
||||
}
|
||||
|
||||
async function loadRoles() {
|
||||
if (!access.usesServerPermissions) return
|
||||
loading.value = true
|
||||
loadError.value = null
|
||||
serverRoles.value = []
|
||||
try {
|
||||
serverRoles.value = (await listPlatformRoles()).map(mapPlatformRole)
|
||||
} catch (error) {
|
||||
loadError.value = getApiErrorDetails(error, '平台角色列表加载失败,请稍后重试')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
Object.assign(form, emptyForm())
|
||||
captureBaseline()
|
||||
editorOpen.value = true
|
||||
nextTick(() => treeRef.value?.setCheckedKeys([]))
|
||||
}
|
||||
function openEdit(row: Role) {
|
||||
if (access.usesServerPermissions) return
|
||||
Object.assign(form, { ...row, permissionIds: [...row.permissionIds] })
|
||||
captureBaseline()
|
||||
editorOpen.value = true
|
||||
nextTick(() => treeRef.value?.setCheckedKeys([...row.permissionIds]))
|
||||
}
|
||||
|
||||
function selectedPermissionIds() {
|
||||
const checked = treeRef.value?.getCheckedKeys(false).map(String) || []
|
||||
const halfChecked = treeRef.value?.getHalfCheckedKeys().map(String) || []
|
||||
return [...new Set([...checked, ...halfChecked])]
|
||||
}
|
||||
|
||||
function syncPermissionIds() {
|
||||
form.permissionIds = selectedPermissionIds()
|
||||
}
|
||||
|
||||
async function saveRole() {
|
||||
if (!form.name.trim() || !form.code.trim()) { ElMessage.warning('请填写角色名称和角色编码'); return }
|
||||
const normalizedCode = form.code.trim().toUpperCase()
|
||||
const duplicate = roles.value.some((item) => item.code.toLowerCase() === normalizedCode.toLowerCase() && item.id !== form.id)
|
||||
if (duplicate) { ElMessage.error('角色编码已经存在'); return }
|
||||
if (access.usesServerPermissions) {
|
||||
saving.value = true
|
||||
try {
|
||||
const created = await createPlatformRole({
|
||||
roleCode: normalizedCode,
|
||||
roleName: form.name.trim(),
|
||||
description: form.description.trim(),
|
||||
})
|
||||
serverRoles.value.unshift(mapPlatformRole(created))
|
||||
editorOpen.value = false
|
||||
ElMessage.success('角色已创建')
|
||||
} catch (error) {
|
||||
ElMessage.error(getApiErrorMessage(error, '角色创建失败,请稍后重试'))
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
return
|
||||
}
|
||||
const permissionIds = selectedPermissionIds()
|
||||
if (permissionIds.length === 0) { ElMessage.warning('请至少选择一项菜单权限'); return }
|
||||
store.saveRole({ ...form, id: form.id || undefined, permissionIds })
|
||||
editorOpen.value = false
|
||||
ElMessage.success(form.id ? '角色及权限已更新' : '角色已创建')
|
||||
}
|
||||
|
||||
async function removeRole(row: Role) {
|
||||
if (row.builtIn) { ElMessage.warning('系统内置角色不能删除'); return }
|
||||
if (row.userCount > 0) { ElMessage.warning('请先移除该角色下的用户'); return }
|
||||
try {
|
||||
await ElMessageBox.confirm(`确认删除角色“${row.name}”吗?`, '删除角色', { confirmButtonText: '删除', cancelButtonText: '取消', type: 'warning' })
|
||||
if (store.removeRole(row.id)) ElMessage.success('角色已删除')
|
||||
} catch {
|
||||
// 用户取消,不需要反馈。
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleStatus(row: Role) {
|
||||
if (row.builtIn) { ElMessage.warning('系统内置角色不能停用'); return }
|
||||
const target = row.status === '启用' ? '停用' : '启用'
|
||||
try {
|
||||
await ElMessageBox.confirm(`确认${target}角色“${row.name}”吗?`, '角色状态', { confirmButtonText: '确认', cancelButtonText: '取消' })
|
||||
store.saveRole({ ...row, status: target })
|
||||
ElMessage.success(`角色已${target}`)
|
||||
} catch {
|
||||
// 用户取消,不需要反馈。
|
||||
}
|
||||
}
|
||||
|
||||
function permissionSummary(row: Role) {
|
||||
if (access.usesServerPermissions) return '待授权回显接口'
|
||||
const { menuCount, actionCount } = summarizePermissionSelection(store.permissions, row.permissionIds)
|
||||
return `${menuCount} 菜单 · ${actionCount} 操作`
|
||||
}
|
||||
|
||||
onMounted(loadRoles)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="business-page">
|
||||
<MetricStrip :items="metrics" />
|
||||
<el-alert v-if="access.usesServerPermissions" type="warning" :closable="false" show-icon title="角色列表和新增角色已接入 IAM;编辑、启停、删除及已有权限回显接口尚未提供,相关操作暂不开放。" />
|
||||
<el-alert v-if="loadError" type="error" :closable="false" show-icon role="alert"><template #title><div class="api-error-alert-title"><span>{{ loadError.message }}</span><ApiErrorMeta :details="loadError" /></div><el-button link type="danger" @click="loadRoles">重新加载</el-button></template></el-alert>
|
||||
<section class="surface-panel filter-panel" aria-label="角色筛选">
|
||||
<div class="filter-fields"><el-input v-model="filters.keyword" clearable placeholder="角色名称、编码或描述" aria-label="角色关键词"><template #prefix><Search :size="16" /></template></el-input><el-select v-if="!access.usesServerPermissions" v-model="filters.dataScope" clearable placeholder="全部数据范围" aria-label="数据范围"><el-option v-for="item in ['全部数据','本部门数据','本人数据','自定义']" :key="item" :label="item" :value="item" /></el-select><el-select v-model="filters.status" clearable placeholder="全部状态" aria-label="角色状态"><el-option label="启用" value="启用" /><el-option label="停用" value="停用" /></el-select></div>
|
||||
<div class="filter-actions"><el-button @click="resetFilters"><RotateCcw :size="16" />重置</el-button><el-button v-if="canCreate" type="primary" @click="openCreate"><Plus :size="16" />新增角色</el-button></div>
|
||||
</section>
|
||||
|
||||
<section class="surface-panel table-panel">
|
||||
<header class="panel-heading"><div><h2>角色与授权</h2><p>角色负责聚合菜单、操作和数据范围,内部用户可分配多个角色。</p></div></header>
|
||||
<el-table v-loading="loading" :data="filteredRows" row-key="id" empty-text="没有符合条件的角色">
|
||||
<el-table-column label="角色" min-width="210"><template #default="{ row }"><div class="role-cell"><span><ShieldCheck :size="18" /></span><div><strong>{{ row.name }}</strong><small>{{ row.code }}</small></div></div></template></el-table-column>
|
||||
<el-table-column prop="description" label="说明" min-width="240" show-overflow-tooltip />
|
||||
<el-table-column label="授权范围" min-width="170"><template #default="{ row }"><div class="primary-cell"><strong>{{ permissionSummary(row as Role) }}</strong><span>{{ access.usesServerPermissions ? '数据范围待接口提供' : row.dataScope }}</span></div></template></el-table-column>
|
||||
<el-table-column label="用户数" width="86" align="right"><template #default="{ row }">{{ access.usesServerPermissions ? '—' : row.userCount }}</template></el-table-column>
|
||||
<el-table-column label="类型" width="95"><template #default="{ row }"><el-tag :type="row.builtIn ? 'warning' : 'info'" effect="plain" size="small">{{ row.builtIn ? '系统内置' : '自定义' }}</el-tag></template></el-table-column>
|
||||
<el-table-column label="状态" width="88"><template #default="{ row }"><StatusBadge :status="row.status" /></template></el-table-column>
|
||||
<el-table-column label="操作" width="235" fixed="right"><template #default="{ row }"><span v-if="access.usesServerPermissions" class="secondary-text">接口待补齐</span><div v-else-if="access.can('system:role:grant')" class="table-actions"><el-button link type="primary" @click="openEdit(row as Role)"><Pencil :size="15" />配置</el-button><el-button link :type="row.status === '启用' ? 'danger' : 'success'" :disabled="row.builtIn" @click="toggleStatus(row as Role)">{{ row.status === '启用' ? '停用' : '启用' }}</el-button><el-button link type="danger" :disabled="row.builtIn || row.userCount > 0" @click="removeRole(row as Role)"><Trash2 :size="15" />删除</el-button></div></template></el-table-column>
|
||||
</el-table>
|
||||
</section>
|
||||
|
||||
<el-drawer v-model="editorOpen" :title="form.id ? '配置角色' : '新增角色'" size="760px" class="business-drawer editor-drawer" :modal="false" :close-on-click-modal="false">
|
||||
<el-form label-position="top" class="drawer-form">
|
||||
<div class="form-section"><h3>基本信息</h3><div class="form-grid two-cols"><el-form-item label="角色名称" required><el-input v-model="form.name" maxlength="64" /></el-form-item><el-form-item label="角色编码" required><el-input v-model="form.code" :disabled="Boolean(form.builtIn)" placeholder="如 OPS_MANAGER" maxlength="64" /></el-form-item><el-form-item v-if="!access.usesServerPermissions" label="数据范围"><el-select v-model="form.dataScope"><el-option v-for="item in dataScopes" :key="item" :label="item" :value="item" /></el-select></el-form-item><el-form-item v-if="!access.usesServerPermissions" label="状态"><el-radio-group v-model="form.status" :disabled="Boolean(form.builtIn)"><el-radio-button label="启用" value="启用" /><el-radio-button label="停用" value="停用" /></el-radio-group></el-form-item></div><el-form-item label="角色说明"><el-input v-model="form.description" type="textarea" :rows="2" maxlength="200" show-word-limit /></el-form-item></div>
|
||||
<div v-if="!access.usesServerPermissions" class="form-section permission-section"><div class="section-heading-row"><div><h3>菜单与操作权限</h3><p>目录仅用于组织结构且独立授权;停用目录不会级联停用已启用的菜单和操作。</p></div><span>{{ form.permissionIds.length }} 项原有授权</span></div><el-tree ref="treeRef" class="permission-tree" :data="permissionTree" node-key="id" show-checkbox check-strictly default-expand-all :props="{ label: 'name', children: 'children' }" @check="syncPermissionIds"><template #default="{ data }"><div class="permission-node"><span>{{ data.name }}</span><code>{{ data.code }}</code></div></template></el-tree></div>
|
||||
</el-form>
|
||||
<template #footer><div class="drawer-footer"><el-button @click="editorOpen = false">取消</el-button><el-button v-if="access.usesServerPermissions ? canCreate : access.can('system:role:grant')" type="primary" :loading="saving" @click="saveRole">保存角色</el-button></div></template>
|
||||
</el-drawer>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,165 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { MapPin, Pencil, Plus, Power, RotateCcw, Search, Warehouse as WarehouseIcon } from '@lucide/vue'
|
||||
import MetricStrip from '@/components/MetricStrip.vue'
|
||||
import { useUnsavedWorkRegistration } from '@/composables/useUnsavedWorkRegistration'
|
||||
import { useAccessPreviewStore } from '@/stores/accessPreview'
|
||||
import { useAdminDataStore } from '@/stores/adminData'
|
||||
import type { Warehouse } from '@/types/admin'
|
||||
|
||||
const store = useAdminDataStore()
|
||||
const access = useAccessPreviewStore()
|
||||
const editorOpen = ref(false)
|
||||
const page = ref(1)
|
||||
const pageSize = 10
|
||||
const filters = reactive({ keyword: '', status: '' as Warehouse['status'] | '' })
|
||||
|
||||
const emptyForm = (): Omit<Warehouse, 'updatedAt'> => ({
|
||||
id: '', code: '', name: '', province: '', city: '', address: '', contactName: '', contactPhone: '',
|
||||
businessHours: '周一至周六 08:30-18:00', status: 'ACTIVE', remark: '',
|
||||
})
|
||||
const form = reactive(emptyForm())
|
||||
const editorLabel = computed(() => form.id ? '编辑收货仓' : '新增收货仓')
|
||||
const editorPermission = computed(() => 'warehouse:write')
|
||||
const { captureBaseline } = useUnsavedWorkRegistration({
|
||||
key: 'warehouse-editor',
|
||||
label: editorLabel,
|
||||
isOpen: editorOpen,
|
||||
model: form,
|
||||
requiredPermission: editorPermission,
|
||||
})
|
||||
|
||||
const filteredRows = computed(() => store.warehouses.filter((warehouse) => {
|
||||
const keyword = filters.keyword.trim().toLowerCase()
|
||||
const matchesKeyword = !keyword || [warehouse.code, warehouse.name, warehouse.province, warehouse.city, warehouse.address]
|
||||
.some((value) => value.toLowerCase().includes(keyword))
|
||||
return matchesKeyword && (!filters.status || warehouse.status === filters.status)
|
||||
}))
|
||||
const pagedRows = computed(() => filteredRows.value.slice((page.value - 1) * pageSize, page.value * pageSize))
|
||||
const metrics = computed(() => [
|
||||
{ label: '全部收货仓', value: store.warehouses.length, note: '平台仓库档案' },
|
||||
{ label: '启用仓库', value: store.activeWarehouses, note: '客户当前可选择' },
|
||||
{ label: '暂停接单', value: store.warehouses.filter((item) => item.status === 'INACTIVE').length, note: '不出现在客户下单选项' },
|
||||
{ label: '覆盖城市', value: new Set(store.warehouses.map((item) => item.city)).size, note: '国内收货区域' },
|
||||
])
|
||||
|
||||
watch(filters, () => { page.value = 1 })
|
||||
watch(
|
||||
() => access.can('warehouse:write'),
|
||||
(canWrite) => {
|
||||
if (canWrite || !editorOpen.value) return
|
||||
editorOpen.value = false
|
||||
Object.assign(form, emptyForm())
|
||||
},
|
||||
{ flush: 'sync' },
|
||||
)
|
||||
|
||||
function resetFilters() {
|
||||
filters.keyword = ''
|
||||
filters.status = ''
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
Object.assign(form, emptyForm())
|
||||
captureBaseline()
|
||||
editorOpen.value = true
|
||||
}
|
||||
|
||||
function openEdit(warehouse: Warehouse) {
|
||||
Object.assign(form, warehouse)
|
||||
captureBaseline()
|
||||
editorOpen.value = true
|
||||
}
|
||||
|
||||
function saveWarehouse() {
|
||||
if (!form.code.trim() || !form.name.trim() || !form.city.trim() || !form.address.trim()) {
|
||||
ElMessage.warning('请填写仓库编码、名称、城市和详细地址')
|
||||
return
|
||||
}
|
||||
try {
|
||||
store.saveWarehouse({ ...form, id: form.id || undefined })
|
||||
editorOpen.value = false
|
||||
ElMessage.success(form.id ? '仓库资料已更新' : '收货仓已创建')
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '仓库保存失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleStatus(warehouse: Warehouse) {
|
||||
const target = warehouse.status === 'ACTIVE' ? 'INACTIVE' : 'ACTIVE'
|
||||
const targetText = target === 'ACTIVE' ? '启用' : '停用'
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
target === 'INACTIVE'
|
||||
? `停用“${warehouse.name}”后,客户新建头程订单时将无法选择该仓库。`
|
||||
: `确认重新启用“${warehouse.name}”供客户下单选择吗?`,
|
||||
`${targetText}收货仓`,
|
||||
{ confirmButtonText: `确认${targetText}`, cancelButtonText: '取消', type: target === 'INACTIVE' ? 'warning' : 'info' },
|
||||
)
|
||||
store.setWarehouseStatus(warehouse.id, target)
|
||||
ElMessage.success(`仓库已${targetText}`)
|
||||
} catch (error) {
|
||||
if (error === 'cancel' || error === 'close') return
|
||||
throw error
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="business-page">
|
||||
<MetricStrip :items="metrics" />
|
||||
|
||||
<section class="surface-panel filter-panel" aria-label="仓库筛选">
|
||||
<div class="filter-fields">
|
||||
<el-input v-model="filters.keyword" clearable placeholder="仓库名称、编码、城市或地址" aria-label="仓库关键词">
|
||||
<template #prefix><Search :size="16" /></template>
|
||||
</el-input>
|
||||
<el-select v-model="filters.status" clearable placeholder="全部状态" aria-label="仓库状态">
|
||||
<el-option label="启用" value="ACTIVE" /><el-option label="停用" value="INACTIVE" />
|
||||
</el-select>
|
||||
</div>
|
||||
<div class="filter-actions">
|
||||
<el-button @click="resetFilters"><RotateCcw :size="16" />重置</el-button>
|
||||
<el-button v-if="access.can('warehouse:write')" type="primary" @click="openCreate"><Plus :size="16" />新增仓库</el-button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="surface-panel table-panel">
|
||||
<div class="panel-heading"><div><h2>平台收货仓</h2><p>仅启用仓库会进入客户头程下单选项</p></div></div>
|
||||
<el-table v-if="pagedRows.length" :data="pagedRows" row-key="id">
|
||||
<el-table-column label="仓库" min-width="200">
|
||||
<template #default="{ row }"><div class="warehouse-cell"><span><WarehouseIcon :size="18" /></span><div><strong>{{ (row as Warehouse).name }}</strong><small>{{ (row as Warehouse).code }}</small></div></div></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="所在城市" min-width="130"><template #default="{ row }">{{ (row as Warehouse).province }} · {{ (row as Warehouse).city }}</template></el-table-column>
|
||||
<el-table-column label="收货地址" min-width="260"><template #default="{ row }"><div class="warehouse-address"><MapPin :size="15" />{{ (row as Warehouse).address }}</div></template></el-table-column>
|
||||
<el-table-column label="联系人" min-width="150"><template #default="{ row }"><strong>{{ (row as Warehouse).contactName || '-' }}</strong><span class="warehouse-subline">{{ (row as Warehouse).contactPhone || '-' }}</span></template></el-table-column>
|
||||
<el-table-column label="收货时间" prop="businessHours" min-width="190" />
|
||||
<el-table-column label="状态" width="100"><template #default="{ row }"><el-tag :type="(row as Warehouse).status === 'ACTIVE' ? 'success' : 'info'" effect="light">{{ (row as Warehouse).status === 'ACTIVE' ? '启用' : '停用' }}</el-tag></template></el-table-column>
|
||||
<el-table-column label="操作" width="180">
|
||||
<template #default="{ row }"><div v-if="access.can('warehouse:write')" class="table-actions"><el-button link type="primary" @click="openEdit(row as Warehouse)"><Pencil :size="15" />编辑</el-button><el-button link :type="(row as Warehouse).status === 'ACTIVE' ? 'danger' : 'success'" @click="toggleStatus(row as Warehouse)"><Power :size="15" />{{ (row as Warehouse).status === 'ACTIVE' ? '停用' : '启用' }}</el-button></div><span v-else class="warehouse-subline">仅查看</span></template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<el-empty v-else description="没有符合条件的仓库" />
|
||||
<div class="table-footer"><span>共 {{ filteredRows.length }} 个收货仓</span><el-pagination v-model:current-page="page" :page-size="pageSize" :total="filteredRows.length" layout="prev, pager, next" /></div>
|
||||
</section>
|
||||
|
||||
<el-drawer v-model="editorOpen" :title="editorLabel" size="620px" class="business-drawer editor-drawer" :close-on-click-modal="false">
|
||||
<el-form label-position="top" class="drawer-form">
|
||||
<div class="form-section"><h3>仓库标识</h3><div class="form-grid two-cols"><el-form-item label="仓库编码" required><el-input v-model="form.code" maxlength="32" placeholder="例如 CN-NB-01" /></el-form-item><el-form-item label="仓库名称" required><el-input v-model="form.name" maxlength="100" placeholder="客户下单时显示的名称" /></el-form-item><el-form-item label="状态"><el-radio-group v-model="form.status"><el-radio-button value="ACTIVE">启用</el-radio-button><el-radio-button value="INACTIVE">停用</el-radio-button></el-radio-group></el-form-item></div></div>
|
||||
<div class="form-section"><h3>地址与收货</h3><div class="form-grid two-cols"><el-form-item label="省份"><el-input v-model="form.province" maxlength="40" /></el-form-item><el-form-item label="城市" required><el-input v-model="form.city" maxlength="40" /></el-form-item><el-form-item label="联系人"><el-input v-model="form.contactName" maxlength="50" /></el-form-item><el-form-item label="联系电话"><el-input v-model="form.contactPhone" maxlength="30" /></el-form-item><el-form-item label="收货时间"><el-input v-model="form.businessHours" maxlength="100" /></el-form-item></div><el-form-item label="详细地址" required><el-input v-model="form.address" maxlength="300" type="textarea" :rows="3" /></el-form-item></div>
|
||||
<div class="form-section"><h3>备注</h3><el-form-item label="客户可理解的收货说明"><el-input v-model="form.remark" maxlength="500" type="textarea" :rows="3" show-word-limit /></el-form-item></div>
|
||||
</el-form>
|
||||
<template #footer><div class="drawer-footer"><el-button @click="editorOpen = false">取消</el-button><el-button type="primary" @click="saveWarehouse">保存仓库</el-button></div></template>
|
||||
</el-drawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.warehouse-cell { display: flex; align-items: center; gap: 10px; }
|
||||
.warehouse-cell > span { display: grid; width: 34px; height: 34px; flex: 0 0 34px; place-items: center; border-radius: 7px; background: #eef3fa; color: var(--cc-primary); }
|
||||
.warehouse-cell > div { display: flex; min-width: 0; flex-direction: column; gap: 3px; }
|
||||
.warehouse-cell small, .warehouse-subline { display: block; color: var(--cc-muted); font-size: 11px; }
|
||||
.warehouse-address { display: flex; align-items: flex-start; gap: 7px; line-height: 1.55; }
|
||||
.warehouse-address svg { flex: 0 0 15px; margin-top: 2px; color: var(--cc-muted); }
|
||||
</style>
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"isolatedModules": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"resolveJsonModule": true,
|
||||
"jsx": "preserve",
|
||||
"types": ["vite/client", "node"],
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue", "vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { fileURLToPath, URL } from 'node:url'
|
||||
import { defineConfig, loadEnv } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import AutoImport from 'unplugin-auto-import/vite'
|
||||
import Components from 'unplugin-vue-components/vite'
|
||||
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
|
||||
|
||||
export default defineConfig(({ mode }) => {
|
||||
const env = loadEnv(mode, process.cwd(), '')
|
||||
const proxyTarget = env.API_PROXY_TARGET || 'http://localhost:8080'
|
||||
|
||||
return {
|
||||
plugins: [
|
||||
vue(),
|
||||
AutoImport({
|
||||
resolvers: [ElementPlusResolver()],
|
||||
dts: 'src/auto-imports.d.ts',
|
||||
}),
|
||||
Components({
|
||||
resolvers: [ElementPlusResolver()],
|
||||
dts: 'src/components.d.ts',
|
||||
}),
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: proxyTarget,
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,17 @@
|
||||
import { fileURLToPath, URL } from 'node:url'
|
||||
import { defineConfig } from 'vitest/config'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
},
|
||||
},
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
include: ['src/**/*.test.ts'],
|
||||
clearMocks: true,
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user