chore: initial commit
This commit is contained in:
@@ -0,0 +1,2 @@
|
|||||||
|
VITE_API_BASE_URL=/api
|
||||||
|
API_PROXY_TARGET=http://127.0.0.1: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,58 @@
|
|||||||
|
# Client Web Instructions
|
||||||
|
|
||||||
|
## 项目定位
|
||||||
|
|
||||||
|
- 本项目是入驻企业及其成员使用的客户工作台,所有业务数据都处于企业租户上下文中。
|
||||||
|
- 当前范围包括注册、业务总览、企业资料、企业成员、企业角色与权限、头程订单。
|
||||||
|
- 不在本项目实现平台内部运营管理、品牌官网、仓库 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/layouts/`:租户工作台壳层、导航和全局页面结构。
|
||||||
|
- `src/router/`:公开页、受保护路由、页面标题和权限元数据;导航与路由必须同步。
|
||||||
|
- `src/stores/`:认证、当前租户和跨页面业务状态;局部表单状态留在页面或组件内。
|
||||||
|
- `src/api/`:统一处理基础地址、超时、认证头、响应解包和错误映射;页面不得直接创建独立 Axios 客户端。
|
||||||
|
- `VITE_API_BASE_URL` 控制浏览器请求前缀,`API_PROXY_TARGET` 控制 Vite 开发代理上游;后者只由开发服务器读取,不进入客户端构建。新增或变更环境变量时同步更新 `.env.example`,不得提交真实密钥或环境专属凭据。
|
||||||
|
- `src/styles/`:全局设计令牌和布局;优先复用现有 `--primary`、`--navy`、`--border`、`--muted`。
|
||||||
|
- 跨应用稳定模型放到 `packages/shared`,客户工作台专属类型保留在本项目。
|
||||||
|
|
||||||
|
## 租户、认证与数据安全
|
||||||
|
|
||||||
|
- 任何查询和变更都必须以服务端确认的当前租户为边界;绝不从可篡改的前端参数推断授权租户。
|
||||||
|
- 企业成员、角色和权限只能管理当前企业范围。前端隐藏不是安全控制,服务端必须重复校验租户与权限。
|
||||||
|
- 认证统一经过 `src/stores/auth.ts` 和 `src/api/http.ts`;不要在页面中复制令牌逻辑或持久化密码、敏感资质内容。
|
||||||
|
- 当前认证仍包含模拟数据。接入真实 API 后移除对应模拟分支,生产环境不得回退为模拟成功。
|
||||||
|
- 注册、上传资质、成员邀请和订单操作必须提供明确校验、幂等保护、提交中状态和可恢复的失败反馈。
|
||||||
|
|
||||||
|
## UI 与交互
|
||||||
|
|
||||||
|
- 延续现有客户工作台的可信、简洁和服务型视觉语言,与内部管理后台保持品牌关联但不照搬高密度管理界面。
|
||||||
|
- 优先使用 Element Plus 和 Lucide 图标;不使用表情符号代替功能图标,不引入第二套组件库。
|
||||||
|
- 业务页按实际场景覆盖适用的加载、空数据、失败、无权限和数据过期状态;危险操作需要确认和明确结果。
|
||||||
|
- 保持键盘可达、可见焦点、语义化标签、足够对比度和清晰错误提示。
|
||||||
|
- 响应式变更至少检查 320px、720px 和桌面布局;避免只能横向滚动才能完成核心任务。
|
||||||
|
|
||||||
|
## 本地命令与质量门禁
|
||||||
|
|
||||||
|
在 `apps/client-web` 目录执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm install
|
||||||
|
pnpm dev
|
||||||
|
pnpm test
|
||||||
|
pnpm typecheck
|
||||||
|
pnpm build
|
||||||
|
```
|
||||||
|
|
||||||
|
- 日常变更至少运行 `pnpm typecheck`;交付前运行 `pnpm build`。
|
||||||
|
- 当前测试使用 Node.js 内置测试运行器,执行 `pnpm test`;项目仍未声明 lint 脚本,不得声称 lint 已通过。
|
||||||
|
- 不手工修改 `dist/` 或 `node_modules/`;依赖变更必须同步当前项目依赖清单和锁文件。
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# 虫虫出海客户工作台
|
||||||
|
|
||||||
|
入驻企业及其成员使用的业务应用,使用 Vue 3、TypeScript、Vite、Vue Router、Pinia 和 Element Plus。
|
||||||
|
|
||||||
|
## 本地运行
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd apps/client-web
|
||||||
|
pnpm install
|
||||||
|
pnpm dev
|
||||||
|
```
|
||||||
|
|
||||||
|
生产构建:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm typecheck
|
||||||
|
pnpm build
|
||||||
|
```
|
||||||
|
|
||||||
|
自动化测试:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm test
|
||||||
|
```
|
||||||
|
|
||||||
|
依赖和 `pnpm-lock.yaml` 均保存在当前前端项目中,不需要在仓库根目录安装 Node 依赖。
|
||||||
|
|
||||||
|
一期路由:登录、注册、工作台、我的信息、企业资料、企业成员、角色权限和头程订单。登录、注册、退出、令牌刷新、个人资料、修改密码、企业资料、企业资质、权限资源、角色列表/新增/授权和成员完整维护已经接入一期后端接口;开发代理默认转发到 `http://127.0.0.1:9000`,可通过 `API_PROXY_TARGET` 覆盖。登录使用后端约定的用户名和 8–72 位密码,注册成功后可立即登录,企业资料审核只影响订单提交资格。
|
||||||
|
|
||||||
|
认证会话保存 Access Token 和轮换后的 Refresh Token,并对并发 401 请求执行单次刷新。客户端只接受 `app_code` 为 `MERCHANT` 的 JWT;页面权限取 JWT 声明与后端启用权限资源的交集。前端权限仅用于导航和交互控制,服务端仍须按用户、租户和权限编码完成最终授权。
|
||||||
|
|
||||||
|
本地联调账号提示和默认用户名只在 Vite 开发模式下启用,URL 显式传入的登录用户名仍优先。生产登录表单默认留空,生产构建产物不得包含本地联调账号或开发提示文案。
|
||||||
|
|
||||||
|
“我的信息”使用真实个人资料和修改密码接口,姓名、电话、邮箱按服务端约束维护;修改密码成功后清理旧会话并返回登录页。成员列表返回姓名、联系方式和 `roleIds`,新增、编辑、角色全量保存、启停和管理员重置密码均使用真实接口,不回退静态成功。角色列表返回 `permissionIds`,页面可回显并全量保存角色权限;角色编辑、启停、删除虽在回归报告中通过,但路径尚未进入当前 Gateway OpenAPI,前端继续保持禁用。
|
||||||
|
|
||||||
|
企业资料和资质列表使用真实租户接口,覆盖 `PROFILE_REQUIRED`、`PENDING`、`ACTIVE`、`REJECTED`、`DISABLED` 五种企业状态。企业名称、法定名称和联系人信息支持真实保存;营业执照上传使用文件服务,仅接受 JPG、PNG 且不超过 5MB,文件预览通过当前商户 Token 临时下载并在关闭弹窗后释放。
|
||||||
|
|
||||||
|
头程订单列表、详情、仓库查询、创建/编辑草稿和提交已经接入真实 API,并显式映射后端 `transportMode`、`containerMode` 和订单状态。创建与提交请求携带幂等键,`merchant:order:create`、`merchant:order:edit`、`merchant:order:submit` 分别映射到独立前端操作权限。页面补齐渠道、提货方式、报关模式、目的仓类型、包装类型等后端必填字段;API 模式先清空本地记录,请求失败不会回退模拟成功。收发货单位通过 `/merchant/v1/partners/candidates` 按类型读取启用候选并保存订单快照,不再使用演示单位兜底;快递信息仍由平台维护、客户只读。订单取消不属于本轮本地一期验收,客户端不暴露取消写操作。订单产品状态仍按已确认的七状态执行,不设置“已驳回”;若兼容旧接口数据收到 `REJECTED`,只按待审核展示且不开放编辑。
|
||||||
|
|
||||||
|
联调远端 Gateway 时可在启动命令前设置 `API_PROXY_TARGET=http://192.168.110.149:9000`;进程环境变量优先于 `.env` 文件。浏览器仍统一请求同源 `/api`,源码中不写死业务服务地址。
|
||||||
+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,31 @@
|
|||||||
|
{
|
||||||
|
"name": "@chongchong/client-web",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite --host 0.0.0.0 --port 5174",
|
||||||
|
"build": "vue-tsc --noEmit && vite build",
|
||||||
|
"typecheck": "vue-tsc --noEmit",
|
||||||
|
"test": "node --test src/**/*.test.mjs",
|
||||||
|
"preview": "vite preview --port 4174"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@chongchong/shared": "link:../../packages/shared",
|
||||||
|
"axios": "^1.18.1",
|
||||||
|
"element-plus": "^2.14.3",
|
||||||
|
"@lucide/vue": "^1.27.0",
|
||||||
|
"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",
|
||||||
|
"typescript": "^5.9.3",
|
||||||
|
"unplugin-auto-import": "^21.0.0",
|
||||||
|
"unplugin-vue-components": "^32.1.0",
|
||||||
|
"vite": "^8.1.5",
|
||||||
|
"vue-tsc": "^3.3.8"
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+1719
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,4 @@
|
|||||||
|
<template>
|
||||||
|
<RouterView />
|
||||||
|
</template>
|
||||||
|
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { apiRequest } from '@/api/http'
|
||||||
|
import type { TokenPair } from '@/api/session'
|
||||||
|
|
||||||
|
export interface RegisterMerchantRequest {
|
||||||
|
username: string
|
||||||
|
password: string
|
||||||
|
displayName: string
|
||||||
|
companyName: string
|
||||||
|
legalPerson: string
|
||||||
|
legalPhone: string
|
||||||
|
contactEmail?: string
|
||||||
|
countryCode?: string
|
||||||
|
address?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RegisterMerchantResult {
|
||||||
|
accountId: number | string
|
||||||
|
tenantId: number | string
|
||||||
|
membershipId: number | string
|
||||||
|
tenantStatus: 'PROFILE_REQUIRED'
|
||||||
|
nextStep: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loginMerchant(username: string, password: string) {
|
||||||
|
return apiRequest<TokenPair>({ method: 'POST', url: '/auth/login', data: { username, password } })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerMerchant(payload: RegisterMerchantRequest) {
|
||||||
|
return apiRequest<RegisterMerchantResult>({ method: 'POST', url: '/auth/register', data: payload })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function logoutMerchant(refreshToken: string) {
|
||||||
|
return apiRequest<void>({ method: 'POST', url: '/auth/logout', data: { refreshToken } })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function changeCurrentPassword(oldPassword: string, newPassword: string) {
|
||||||
|
return apiRequest<void>({ method: 'PUT', url: '/auth/password', data: { oldPassword, newPassword } })
|
||||||
|
}
|
||||||
@@ -0,0 +1,248 @@
|
|||||||
|
import { apiRequest } from '@/api/http'
|
||||||
|
|
||||||
|
export type ApiFirstLegStatus = 'DRAFT' | 'SUBMITTED' | 'APPROVED' | 'ACCEPTED' | 'REJECTED' | 'CANCELLED' | 'PROCESSING' | 'COMPLETED'
|
||||||
|
export type ApiTransportMode = 'SEA' | 'AIR' | 'RAIL' | 'TRUCK'
|
||||||
|
export type ApiPickupMethod = 'SELF_EXPRESS' | 'PICKUP' | 'SELF_TRUCK'
|
||||||
|
export type ApiTaxMode = 'BUY_DECLARATION' | 'TAX_REFUND'
|
||||||
|
export type ApiContainerMode = 'FULL_CONTAINER' | 'LCL'
|
||||||
|
export type ApiDestinationType = 'FBA' | 'OVERSEAS' | 'PRIVATE'
|
||||||
|
|
||||||
|
export interface PageResult<T> {
|
||||||
|
records: T[]
|
||||||
|
page: number
|
||||||
|
size: number
|
||||||
|
total: number
|
||||||
|
pages: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MerchantWarehouse {
|
||||||
|
id: number | string
|
||||||
|
warehouseCode: string
|
||||||
|
warehouseName: string
|
||||||
|
warehouseType: string
|
||||||
|
countryCode: string
|
||||||
|
timezone: string | null
|
||||||
|
contactName: string | null
|
||||||
|
contactPhone: string | null
|
||||||
|
contactEmail: string | null
|
||||||
|
status: 'ACTIVE' | 'INACTIVE'
|
||||||
|
remark: string | null
|
||||||
|
updatedAt: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MerchantWarehouseAddress {
|
||||||
|
countryCode: string
|
||||||
|
stateProvince: string | null
|
||||||
|
city: string | null
|
||||||
|
district: string | null
|
||||||
|
postalCode: string | null
|
||||||
|
addressLine1: string
|
||||||
|
addressLine2: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MerchantWarehouseCapability {
|
||||||
|
capabilityCode: string
|
||||||
|
capabilityName: string
|
||||||
|
enabled: boolean
|
||||||
|
cutoffTime: string | null
|
||||||
|
description: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MerchantWarehouseDetail {
|
||||||
|
warehouse: MerchantWarehouse
|
||||||
|
addresses: MerchantWarehouseAddress[]
|
||||||
|
capabilities: MerchantWarehouseCapability[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MerchantPartner {
|
||||||
|
id: number | string
|
||||||
|
partnerCode: string
|
||||||
|
partnerName: string
|
||||||
|
partnerType: string
|
||||||
|
contactName: string | null
|
||||||
|
contactPhone: string | null
|
||||||
|
contactEmail: string | null
|
||||||
|
countryCode: string
|
||||||
|
province: string | null
|
||||||
|
city: string | null
|
||||||
|
district: string | null
|
||||||
|
addressLine: string
|
||||||
|
postalCode: string | null
|
||||||
|
status: 'ACTIVE' | 'INACTIVE'
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FirstLegOrderPartyPayload {
|
||||||
|
companyName: string
|
||||||
|
contactName: string
|
||||||
|
phone: string
|
||||||
|
email?: string
|
||||||
|
countryCode: string
|
||||||
|
province?: string
|
||||||
|
city: string
|
||||||
|
district?: string
|
||||||
|
addressLine: string
|
||||||
|
postalCode?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FirstLegOrderCargoPayload {
|
||||||
|
cargoName: string
|
||||||
|
hsCode?: string
|
||||||
|
packageType: string
|
||||||
|
packageCount: number
|
||||||
|
grossWeightKg: number
|
||||||
|
volumeCbm: number
|
||||||
|
declaredValue?: number
|
||||||
|
currency?: string
|
||||||
|
dangerousGoods?: boolean
|
||||||
|
cargoRemark?: string
|
||||||
|
skuNo?: string
|
||||||
|
cargoNameEn?: string
|
||||||
|
totalQuantity?: number
|
||||||
|
netWeightKg?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FirstLegOrderSaveRequest {
|
||||||
|
customerId?: number | string
|
||||||
|
customerName: string
|
||||||
|
transportMode: ApiTransportMode
|
||||||
|
tradeTerm?: string
|
||||||
|
destinationCountryCode: string
|
||||||
|
destinationWarehouseId?: number | string
|
||||||
|
destinationWarehouseName: string
|
||||||
|
expectedShipDate?: string
|
||||||
|
customerRemark?: string
|
||||||
|
sender: FirstLegOrderPartyPayload
|
||||||
|
receiver: FirstLegOrderPartyPayload
|
||||||
|
cargoes: FirstLegOrderCargoPayload[]
|
||||||
|
pickupMethod: ApiPickupMethod
|
||||||
|
receivingWarehouseId: number | string
|
||||||
|
receivingWarehouseName: string
|
||||||
|
receivingWarehouseAddress?: string | null
|
||||||
|
receivingTime?: string | null
|
||||||
|
channelCode: string
|
||||||
|
channelName: string
|
||||||
|
taxMode: ApiTaxMode
|
||||||
|
containerMode?: ApiContainerMode
|
||||||
|
customsInspection: boolean
|
||||||
|
destinationType: ApiDestinationType
|
||||||
|
amazonWarehouseCode?: string
|
||||||
|
shipmentId?: string
|
||||||
|
referenceId?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MerchantFirstLegOrder {
|
||||||
|
id: number | string
|
||||||
|
orderNo: string | null
|
||||||
|
customerId: number | string | null
|
||||||
|
customerName: string
|
||||||
|
status: ApiFirstLegStatus
|
||||||
|
transportMode: ApiTransportMode
|
||||||
|
tradeTerm: string | null
|
||||||
|
destinationCountryCode: string
|
||||||
|
destinationWarehouseId: number | string | null
|
||||||
|
destinationWarehouseName: string
|
||||||
|
expectedShipDate: string | null
|
||||||
|
customerRemark: string | null
|
||||||
|
pickupMethod: ApiPickupMethod
|
||||||
|
receivingWarehouseId: number | string
|
||||||
|
receivingWarehouseName: string
|
||||||
|
channelCode: string
|
||||||
|
channelName: string
|
||||||
|
taxMode: ApiTaxMode
|
||||||
|
containerMode: ApiContainerMode | null
|
||||||
|
customsInspection: boolean
|
||||||
|
destinationType: ApiDestinationType
|
||||||
|
amazonWarehouseCode: string | null
|
||||||
|
shipmentId: string | null
|
||||||
|
referenceId: string | null
|
||||||
|
expressCompany?: string | null
|
||||||
|
expressTrackingNo?: string | null
|
||||||
|
version: number
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MerchantFirstLegOrderCargo extends FirstLegOrderCargoPayload {
|
||||||
|
id: number | string
|
||||||
|
itemNo: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MerchantFirstLegOrderParty extends FirstLegOrderPartyPayload {
|
||||||
|
id: number | string
|
||||||
|
partyType: 'SENDER' | 'RECEIVER' | string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MerchantOrderStatusHistory {
|
||||||
|
id: number | string
|
||||||
|
fromStatus: ApiFirstLegStatus | null
|
||||||
|
toStatus: ApiFirstLegStatus
|
||||||
|
action: string
|
||||||
|
reason: string | null
|
||||||
|
operatorName: string | null
|
||||||
|
occurredAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MerchantFirstLegOrderDetail {
|
||||||
|
order: MerchantFirstLegOrder
|
||||||
|
cargoes: MerchantFirstLegOrderCargo[]
|
||||||
|
parties: MerchantFirstLegOrderParty[]
|
||||||
|
internalNotes: []
|
||||||
|
statusHistory: MerchantOrderStatusHistory[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FirstLegOrderQuery {
|
||||||
|
keyword?: string
|
||||||
|
status?: ApiFirstLegStatus
|
||||||
|
customerId?: number | string
|
||||||
|
destinationWarehouseId?: number | string
|
||||||
|
createdFrom?: string
|
||||||
|
createdTo?: string
|
||||||
|
page?: number
|
||||||
|
size?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMerchantWarehouses() {
|
||||||
|
return apiRequest<PageResult<MerchantWarehouse>>({ method: 'GET', url: '/merchant/v1/warehouses', params: { page: 1, size: 200, status: 'ACTIVE' } })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMerchantWarehouseDetail(id: number | string) {
|
||||||
|
return apiRequest<MerchantWarehouseDetail>({ method: 'GET', url: `/merchant/v1/warehouses/${id}` })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMerchantPartnerCandidates(type: 'SHIPPER' | 'CONSIGNEE') {
|
||||||
|
return apiRequest<MerchantPartner[]>({ method: 'GET', url: '/merchant/v1/partners/candidates', params: { type } })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMerchantFirstLegOrders(params: FirstLegOrderQuery = {}) {
|
||||||
|
return apiRequest<PageResult<MerchantFirstLegOrder>>({ method: 'GET', url: '/merchant/v1/orders/first-leg', params: { page: 1, size: 200, ...params } })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMerchantFirstLegOrder(id: number | string) {
|
||||||
|
return apiRequest<MerchantFirstLegOrderDetail>({ method: 'GET', url: `/merchant/v1/orders/first-leg/${id}` })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createMerchantFirstLegOrder(payload: FirstLegOrderSaveRequest) {
|
||||||
|
return apiRequest<number | string>({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/merchant/v1/orders/first-leg',
|
||||||
|
data: payload,
|
||||||
|
headers: { 'Idempotency-Key': createIdempotencyKey('first-leg-create') },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateMerchantFirstLegOrder(id: number | string, payload: FirstLegOrderSaveRequest) {
|
||||||
|
return apiRequest<void>({ method: 'PUT', url: `/merchant/v1/orders/first-leg/${id}`, data: payload })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function submitMerchantFirstLegOrder(id: number | string) {
|
||||||
|
return apiRequest<void>({
|
||||||
|
method: 'POST',
|
||||||
|
url: `/merchant/v1/orders/first-leg/${id}/submit`,
|
||||||
|
headers: { 'Idempotency-Key': createIdempotencyKey(`first-leg-submit-${id}`) },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function createIdempotencyKey(scope: string) {
|
||||||
|
const randomPart = globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(36).slice(2)}`
|
||||||
|
return `${scope}-${randomPart}`
|
||||||
|
}
|
||||||
+107
@@ -0,0 +1,107 @@
|
|||||||
|
import axios, { AxiosError, type AxiosRequestConfig, type InternalAxiosRequestConfig } from 'axios'
|
||||||
|
import { clearSessionTokens, getAccessToken, getRefreshToken, persistTokenPair, type TokenPair } from '@/api/session'
|
||||||
|
import { ApiError, asFieldErrors, isApiEnvelope, type ApiEnvelope } from '@/api/types'
|
||||||
|
|
||||||
|
const transport = axios.create({
|
||||||
|
baseURL: import.meta.env.VITE_API_BASE_URL || '/api',
|
||||||
|
timeout: 15_000,
|
||||||
|
})
|
||||||
|
|
||||||
|
interface RetryableRequest extends InternalAxiosRequestConfig {
|
||||||
|
_retriedAfterRefresh?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function unwrap<T>(envelope: ApiEnvelope<T>) {
|
||||||
|
if (envelope.code !== '0') {
|
||||||
|
throw new ApiError(envelope.message || '请求失败', envelope.code, undefined, asFieldErrors(envelope.data), envelope.traceId)
|
||||||
|
}
|
||||||
|
return envelope.data
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapError(error: unknown) {
|
||||||
|
if (error instanceof ApiError) return error
|
||||||
|
if (!axios.isAxiosError(error)) return new ApiError(error instanceof Error ? error.message : '请求失败')
|
||||||
|
|
||||||
|
const body = error.response?.data
|
||||||
|
if (isApiEnvelope(body)) {
|
||||||
|
return new ApiError(
|
||||||
|
body.message || '请求失败',
|
||||||
|
body.code,
|
||||||
|
error.response?.status,
|
||||||
|
asFieldErrors(body.data),
|
||||||
|
typeof body.traceId === 'string' ? body.traceId : null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (error.code === AxiosError.ERR_NETWORK) return new ApiError('无法连接服务器,请确认后端服务已启动', 'NETWORK_ERROR')
|
||||||
|
if (error.code === AxiosError.ECONNABORTED) return new ApiError('请求超时,请稍后重试', 'TIMEOUT')
|
||||||
|
return new ApiError(error.message || '请求失败', 'HTTP_ERROR', error.response?.status)
|
||||||
|
}
|
||||||
|
|
||||||
|
let refreshPromise: Promise<TokenPair> | null = null
|
||||||
|
|
||||||
|
async function refreshSession() {
|
||||||
|
const refreshToken = getRefreshToken()
|
||||||
|
if (!refreshToken) throw new ApiError('登录已过期,请重新登录', 'A0401', 401)
|
||||||
|
if (!refreshPromise) {
|
||||||
|
refreshPromise = transport
|
||||||
|
.post<ApiEnvelope<TokenPair>>('/auth/refresh', { refreshToken })
|
||||||
|
.then((response) => unwrap(response.data))
|
||||||
|
.then((tokens) => {
|
||||||
|
persistTokenPair(tokens)
|
||||||
|
return tokens
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
refreshPromise = null
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return refreshPromise
|
||||||
|
}
|
||||||
|
|
||||||
|
transport.interceptors.request.use((config) => {
|
||||||
|
const token = getAccessToken()
|
||||||
|
if (token) config.headers.Authorization = `Bearer ${token}`
|
||||||
|
return config
|
||||||
|
})
|
||||||
|
|
||||||
|
transport.interceptors.response.use(
|
||||||
|
(response) => response,
|
||||||
|
async (error: AxiosError) => {
|
||||||
|
const request = error.config as RetryableRequest | undefined
|
||||||
|
const publicAuthPaths = ['/auth/login', '/auth/register', '/auth/refresh']
|
||||||
|
const isPublicAuthRequest = publicAuthPaths.some((path) => request?.url?.endsWith(path))
|
||||||
|
if (error.response?.status !== 401 || !request || request._retriedAfterRefresh || isPublicAuthRequest) {
|
||||||
|
throw mapError(error)
|
||||||
|
}
|
||||||
|
|
||||||
|
request._retriedAfterRefresh = true
|
||||||
|
try {
|
||||||
|
const tokens = await refreshSession()
|
||||||
|
request.headers.Authorization = `Bearer ${tokens.accessToken}`
|
||||||
|
return await transport.request(request)
|
||||||
|
} catch (refreshError) {
|
||||||
|
clearSessionTokens()
|
||||||
|
window.dispatchEvent(new CustomEvent('cc-client-session-expired'))
|
||||||
|
throw mapError(refreshError)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
export async function apiRequest<T>(config: AxiosRequestConfig) {
|
||||||
|
try {
|
||||||
|
const response = await transport.request<ApiEnvelope<T>>(config)
|
||||||
|
return unwrap(response.data)
|
||||||
|
} catch (error) {
|
||||||
|
throw mapError(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiBlobRequest(config: AxiosRequestConfig) {
|
||||||
|
try {
|
||||||
|
const response = await transport.request<Blob>({ ...config, responseType: 'blob' })
|
||||||
|
return response.data
|
||||||
|
} catch (error) {
|
||||||
|
throw mapError(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { ApiError }
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { readFileSync } from 'node:fs'
|
||||||
|
import test from 'node:test'
|
||||||
|
|
||||||
|
const source = readFileSync(new URL('./merchantIam.ts', import.meta.url), 'utf8')
|
||||||
|
const storeSource = readFileSync(new URL('../stores/tenantAdministration.ts', import.meta.url), 'utf8')
|
||||||
|
|
||||||
|
test('declares the documented merchant role and member endpoints', () => {
|
||||||
|
assert.match(source, /url: '\/merchant\/v1\/iam\/roles'/)
|
||||||
|
assert.match(source, /url: '\/merchant\/v1\/iam\/roles\/permissions'/)
|
||||||
|
assert.match(source, /url: '\/merchant\/v1\/iam\/members'/)
|
||||||
|
assert.match(source, /url: '\/merchant\/v1\/iam\/members\/roles'/)
|
||||||
|
assert.match(source, /url: `\/merchant\/v1\/iam\/members\/\$\{membershipId\}`/)
|
||||||
|
assert.match(source, /url: `\/merchant\/v1\/iam\/members\/\$\{membershipId\}\/status`/)
|
||||||
|
assert.match(source, /url: `\/merchant\/v1\/iam\/members\/\$\{membershipId\}\/password`/)
|
||||||
|
assert.match(source, /url: '\/merchant\/v1\/iam\/profile'/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('supports tenant profile updates and authenticated qualification downloads', () => {
|
||||||
|
assert.match(source, /method: 'PUT', url: '\/merchant\/v1\/iam\/tenant\/profile'/)
|
||||||
|
assert.match(source, /apiBlobRequest\(\{ method: 'GET', url \}\)/)
|
||||||
|
assert.match(source, /url\.startsWith\('\/merchant\/v1\/files\/qualifications\/'\)/)
|
||||||
|
assert.match(storeSource, /await updateTenantProfile/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('uses membershipId and full replacement arrays in authorization writes', () => {
|
||||||
|
assert.match(source, /data: \{ roleId, permissionIds \}/)
|
||||||
|
assert.match(source, /data: \{ membershipId, roleIds \}/)
|
||||||
|
assert.doesNotMatch(source, /data: \{ userId, roleIds \}/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('clears mock records before loading server role and member lists', () => {
|
||||||
|
assert.match(storeSource, /rolesLoadedFromApi\.value = true\s+roles\.value = \[\]/)
|
||||||
|
assert.match(storeSource, /membersLoadedFromApi\.value = true\s+members\.value = \[\]/)
|
||||||
|
assert.match(storeSource, /await createMerchantRole/)
|
||||||
|
assert.match(storeSource, /await createMerchantMember/)
|
||||||
|
assert.match(storeSource, /await updateMerchantMember/)
|
||||||
|
assert.match(storeSource, /await changeMerchantMemberStatus/)
|
||||||
|
assert.match(storeSource, /await resetMerchantMemberPassword/)
|
||||||
|
})
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
import { apiBlobRequest, apiRequest } from '@/api/http'
|
||||||
|
|
||||||
|
export type MerchantTenantStatus = 'PROFILE_REQUIRED' | 'PENDING' | 'ACTIVE' | 'REJECTED' | 'DISABLED'
|
||||||
|
export type QualificationStatus = 'PENDING' | 'APPROVED' | 'REJECTED'
|
||||||
|
|
||||||
|
export interface MerchantTenantProfile {
|
||||||
|
id: number | string
|
||||||
|
tenantCode: string
|
||||||
|
tenantName: string
|
||||||
|
status: MerchantTenantStatus
|
||||||
|
legalName: string
|
||||||
|
unifiedSocialCreditCode: string | null
|
||||||
|
contactName: string
|
||||||
|
contactPhone: string
|
||||||
|
contactEmail: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateMerchantTenantProfileRequest {
|
||||||
|
tenantName: string
|
||||||
|
legalName: string
|
||||||
|
contactName: string
|
||||||
|
contactPhone: string
|
||||||
|
contactEmail: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MerchantQualification {
|
||||||
|
id: number | string
|
||||||
|
tenantId: number | string
|
||||||
|
qualificationType: 'BUSINESS_LICENSE'
|
||||||
|
certificateNo: string | null
|
||||||
|
documentUrl: string
|
||||||
|
status: QualificationStatus
|
||||||
|
reviewComment: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MerchantPermissionResource {
|
||||||
|
id: number | string
|
||||||
|
tenantId: number | string
|
||||||
|
appCode: 'MERCHANT'
|
||||||
|
permissionType: 'MENU' | 'BUTTON'
|
||||||
|
parentId: number | string
|
||||||
|
permissionCode: string
|
||||||
|
permissionName: string
|
||||||
|
routePath: string | null
|
||||||
|
httpMethod: string | null
|
||||||
|
sortNo: number
|
||||||
|
status: 'ACTIVE' | string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QualificationUploadResult {
|
||||||
|
qualificationId: number | string
|
||||||
|
reviewId: number | string
|
||||||
|
tenantStatus: 'PENDING'
|
||||||
|
documentUrl: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MerchantRole {
|
||||||
|
id: number | string
|
||||||
|
tenantId: number | string
|
||||||
|
appCode: 'MERCHANT'
|
||||||
|
roleCode: string
|
||||||
|
roleName: string
|
||||||
|
roleType: 'SYSTEM' | 'CUSTOM' | string
|
||||||
|
status: 'ACTIVE' | 'INACTIVE' | string
|
||||||
|
description: string | null
|
||||||
|
permissionIds: Array<number | string>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateMerchantRoleRequest {
|
||||||
|
roleCode: string
|
||||||
|
roleName: string
|
||||||
|
description: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MerchantMember {
|
||||||
|
membershipId: number | string
|
||||||
|
userId: number | string
|
||||||
|
authUserId: number | string
|
||||||
|
displayName: string
|
||||||
|
phone: string | null
|
||||||
|
email: string | null
|
||||||
|
memberType: 'OWNER' | string
|
||||||
|
memberNo: string
|
||||||
|
status: 'ACTIVE' | 'INACTIVE' | string
|
||||||
|
roleIds: Array<number | string>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MerchantPersonalProfile {
|
||||||
|
userId: number | string
|
||||||
|
authUserId: number | string
|
||||||
|
username: string
|
||||||
|
displayName: string
|
||||||
|
phone: string | null
|
||||||
|
email: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateMerchantMemberRequest {
|
||||||
|
username: string
|
||||||
|
password: string
|
||||||
|
displayName: string
|
||||||
|
phone: string
|
||||||
|
email: string
|
||||||
|
memberType: 'EMPLOYEE'
|
||||||
|
memberNo: string
|
||||||
|
roleIds: Array<number | string>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateMerchantMemberRequest {
|
||||||
|
displayName: string
|
||||||
|
phone: string
|
||||||
|
email: string
|
||||||
|
memberType: string
|
||||||
|
memberNo: string
|
||||||
|
roleIds: Array<number | string>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTenantProfile() {
|
||||||
|
return apiRequest<MerchantTenantProfile>({ method: 'GET', url: '/merchant/v1/iam/tenant/profile' })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateTenantProfile(payload: UpdateMerchantTenantProfileRequest) {
|
||||||
|
return apiRequest<MerchantTenantProfile>({ method: 'PUT', url: '/merchant/v1/iam/tenant/profile', data: payload })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTenantQualifications() {
|
||||||
|
return apiRequest<MerchantQualification[]>({ method: 'GET', url: '/merchant/v1/iam/tenant/qualifications' })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMerchantPermissionResources() {
|
||||||
|
return apiRequest<MerchantPermissionResource[]>({ method: 'GET', url: '/merchant/v1/iam/permissions' })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMerchantPersonalProfile() {
|
||||||
|
return apiRequest<MerchantPersonalProfile>({ method: 'GET', url: '/merchant/v1/iam/profile' })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateMerchantPersonalProfile(payload: Pick<MerchantPersonalProfile, 'displayName' | 'phone' | 'email'>) {
|
||||||
|
return apiRequest<MerchantPersonalProfile>({ method: 'PUT', url: '/merchant/v1/iam/profile', data: payload })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function uploadBusinessLicense(file: File, certificateNo?: string) {
|
||||||
|
const data = new FormData()
|
||||||
|
data.append('file', file)
|
||||||
|
if (certificateNo) data.append('certificateNo', certificateNo)
|
||||||
|
return apiRequest<QualificationUploadResult>({ method: 'POST', url: '/merchant/v1/files/qualifications', data })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function downloadQualificationImage(documentUrl: string) {
|
||||||
|
const url = documentUrl.startsWith('/api/') ? documentUrl.slice(4) : documentUrl
|
||||||
|
if (!url.startsWith('/merchant/v1/files/qualifications/')) throw new Error('资质文件地址无效')
|
||||||
|
return apiBlobRequest({ method: 'GET', url })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMerchantRoles() {
|
||||||
|
return apiRequest<MerchantRole[]>({ method: 'GET', url: '/merchant/v1/iam/roles' })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createMerchantRole(payload: CreateMerchantRoleRequest) {
|
||||||
|
return apiRequest<MerchantRole>({ method: 'POST', url: '/merchant/v1/iam/roles', data: payload })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function replaceMerchantRolePermissions(roleId: number | string, permissionIds: Array<number | string>) {
|
||||||
|
return apiRequest<void>({ method: 'PUT', url: '/merchant/v1/iam/roles/permissions', data: { roleId, permissionIds } })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMerchantMembers() {
|
||||||
|
return apiRequest<MerchantMember[]>({ method: 'GET', url: '/merchant/v1/iam/members' })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createMerchantMember(payload: CreateMerchantMemberRequest) {
|
||||||
|
return apiRequest<MerchantMember>({ method: 'POST', url: '/merchant/v1/iam/members', data: payload })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateMerchantMember(membershipId: number | string, payload: UpdateMerchantMemberRequest) {
|
||||||
|
return apiRequest<MerchantMember>({ method: 'PUT', url: `/merchant/v1/iam/members/${membershipId}`, data: payload })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function changeMerchantMemberStatus(membershipId: number | string, status: 'ACTIVE' | 'DISABLED') {
|
||||||
|
return apiRequest<MerchantMember>({ method: 'PUT', url: `/merchant/v1/iam/members/${membershipId}/status`, data: { status } })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resetMerchantMemberPassword(membershipId: number | string, newPassword: string) {
|
||||||
|
return apiRequest<void>({ method: 'PUT', url: `/merchant/v1/iam/members/${membershipId}/password`, data: { newPassword } })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function replaceMerchantMemberRoles(membershipId: number | string, roleIds: Array<number | string>) {
|
||||||
|
return apiRequest<void>({ method: 'PUT', url: '/merchant/v1/iam/members/roles', data: { membershipId, roleIds } })
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { apiRequest } from '@/api/http'
|
||||||
|
|
||||||
|
export type MerchantPartnerType = 'SENDER' | 'RECEIVER'
|
||||||
|
|
||||||
|
export interface MerchantPartner {
|
||||||
|
id: number | string
|
||||||
|
partnerCode: string
|
||||||
|
partnerName: string
|
||||||
|
partnerType: MerchantPartnerType | string
|
||||||
|
contactName: string | null
|
||||||
|
contactPhone: string | null
|
||||||
|
contactEmail: string | null
|
||||||
|
countryCode: string | null
|
||||||
|
province: string | null
|
||||||
|
city: string | null
|
||||||
|
district: string | null
|
||||||
|
addressLine: string | null
|
||||||
|
postalCode: string | null
|
||||||
|
status: 'ACTIVE' | 'INACTIVE' | 'DISABLED' | string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMerchantPartnerCandidates(type: MerchantPartnerType, keyword?: string) {
|
||||||
|
return apiRequest<MerchantPartner[]>({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/merchant/v1/partners/candidates',
|
||||||
|
params: { type, keyword: keyword?.trim() || undefined },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMerchantPartner(id: number | string) {
|
||||||
|
return apiRequest<MerchantPartner>({ method: 'GET', url: `/merchant/v1/partners/${id}` })
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
export const ACCESS_TOKEN_KEY = 'cc-client-access-token'
|
||||||
|
export const REFRESH_TOKEN_KEY = 'cc-client-refresh-token'
|
||||||
|
|
||||||
|
export interface TokenPair {
|
||||||
|
tokenType: string
|
||||||
|
accessToken: string
|
||||||
|
expiresIn: number
|
||||||
|
refreshToken: string
|
||||||
|
refreshExpiresIn: number
|
||||||
|
}
|
||||||
|
|
||||||
|
function activeStorage() {
|
||||||
|
if (localStorage.getItem(ACCESS_TOKEN_KEY)) return localStorage
|
||||||
|
return sessionStorage
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAccessToken() {
|
||||||
|
return localStorage.getItem(ACCESS_TOKEN_KEY) || sessionStorage.getItem(ACCESS_TOKEN_KEY) || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRefreshToken() {
|
||||||
|
return localStorage.getItem(REFRESH_TOKEN_KEY) || sessionStorage.getItem(REFRESH_TOKEN_KEY) || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
export function persistTokenPair(tokens: TokenPair, remember?: boolean) {
|
||||||
|
const storage = remember === undefined ? activeStorage() : remember ? localStorage : sessionStorage
|
||||||
|
clearSessionTokens()
|
||||||
|
storage.setItem(ACCESS_TOKEN_KEY, tokens.accessToken)
|
||||||
|
storage.setItem(REFRESH_TOKEN_KEY, tokens.refreshToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearSessionTokens() {
|
||||||
|
for (const storage of [localStorage, sessionStorage]) {
|
||||||
|
storage.removeItem(ACCESS_TOKEN_KEY)
|
||||||
|
storage.removeItem(REFRESH_TOKEN_KEY)
|
||||||
|
storage.removeItem('cc-client-token')
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
export interface ApiEnvelope<T> {
|
||||||
|
code: string
|
||||||
|
message: string
|
||||||
|
data: T
|
||||||
|
requestId: string | null
|
||||||
|
traceId: string | null
|
||||||
|
timestamp: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ApiFieldErrors = Record<string, string>
|
||||||
|
|
||||||
|
export class ApiError extends Error {
|
||||||
|
constructor(
|
||||||
|
message: string,
|
||||||
|
readonly code = 'NETWORK_ERROR',
|
||||||
|
readonly status?: number,
|
||||||
|
readonly fieldErrors?: ApiFieldErrors,
|
||||||
|
readonly traceId?: string | null,
|
||||||
|
) {
|
||||||
|
super(message)
|
||||||
|
this.name = 'ApiError'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isApiEnvelope(value: unknown): value is ApiEnvelope<unknown> {
|
||||||
|
if (!value || typeof value !== 'object') return false
|
||||||
|
const record = value as Record<string, unknown>
|
||||||
|
return typeof record.code === 'string' && typeof record.message === 'string' && 'data' in record
|
||||||
|
}
|
||||||
|
|
||||||
|
export function asFieldErrors(value: unknown): ApiFieldErrors | undefined {
|
||||||
|
if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined
|
||||||
|
const entries = Object.entries(value).filter((entry): entry is [string, string] => typeof entry[1] === 'string')
|
||||||
|
return entries.length > 0 ? Object.fromEntries(entries) : undefined
|
||||||
|
}
|
||||||
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
+46
@@ -0,0 +1,46 @@
|
|||||||
|
/* 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 {
|
||||||
|
ClientSidebarMenu: typeof import('./components/ClientSidebarMenu.vue')['default']
|
||||||
|
ClientSidebarMenuNode: typeof import('./components/ClientSidebarMenuNode.vue')['default']
|
||||||
|
ElButton: typeof import('element-plus/es')['ElButton']
|
||||||
|
ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
|
||||||
|
ElCheckboxGroup: typeof import('element-plus/es')['ElCheckboxGroup']
|
||||||
|
ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
|
||||||
|
ElDialog: typeof import('element-plus/es')['ElDialog']
|
||||||
|
ElDrawer: typeof import('element-plus/es')['ElDrawer']
|
||||||
|
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']
|
||||||
|
ElOption: typeof import('element-plus/es')['ElOption']
|
||||||
|
ElRadioButton: typeof import('element-plus/es')['ElRadioButton']
|
||||||
|
ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
|
||||||
|
ElSelect: typeof import('element-plus/es')['ElSelect']
|
||||||
|
ElSwitch: typeof import('element-plus/es')['ElSwitch']
|
||||||
|
ElTable: typeof import('element-plus/es')['ElTable']
|
||||||
|
ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
|
||||||
|
ElTabPane: typeof import('element-plus/es')['ElTabPane']
|
||||||
|
ElTabs: typeof import('element-plus/es')['ElTabs']
|
||||||
|
ElTag: typeof import('element-plus/es')['ElTag']
|
||||||
|
ElTimeline: typeof import('element-plus/es')['ElTimeline']
|
||||||
|
ElTimelineItem: typeof import('element-plus/es')['ElTimelineItem']
|
||||||
|
ElUpload: typeof import('element-plus/es')['ElUpload']
|
||||||
|
RouterLink: typeof import('vue-router')['RouterLink']
|
||||||
|
RouterView: typeof import('vue-router')['RouterView']
|
||||||
|
}
|
||||||
|
export interface GlobalDirectives {
|
||||||
|
vLoading: typeof import('element-plus/es')['ElLoadingDirective']
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref, watch } from 'vue'
|
||||||
|
import ClientSidebarMenuNode from '@/components/ClientSidebarMenuNode.vue'
|
||||||
|
import {
|
||||||
|
clientNavigation,
|
||||||
|
filterNavigationByPermissions,
|
||||||
|
getNavigationParentKeys,
|
||||||
|
} from '@/navigation/clientNavigation'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
activePath: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
navigate: [path: string]
|
||||||
|
}>()
|
||||||
|
const auth = useAuthStore()
|
||||||
|
const visibleNavigation = computed(() => filterNavigationByPermissions(clientNavigation, auth.permissionCodes))
|
||||||
|
|
||||||
|
const activeParentKeys = computed(() => new Set(getNavigationParentKeys(visibleNavigation.value, props.activePath)))
|
||||||
|
const openedKeys = ref<ReadonlySet<string>>(new Set(activeParentKeys.value))
|
||||||
|
|
||||||
|
function handleToggle(key: string) {
|
||||||
|
if (activeParentKeys.value.has(key)) return
|
||||||
|
|
||||||
|
const next = new Set(openedKeys.value)
|
||||||
|
if (next.has(key)) next.delete(key)
|
||||||
|
else next.add(key)
|
||||||
|
openedKeys.value = next
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.activePath,
|
||||||
|
() => {
|
||||||
|
openedKeys.value = new Set([...openedKeys.value, ...activeParentKeys.value])
|
||||||
|
},
|
||||||
|
{ flush: 'sync' },
|
||||||
|
)
|
||||||
|
|
||||||
|
function handleSelect(path: string) {
|
||||||
|
emit('navigate', path)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ul class="client-nav-menu">
|
||||||
|
<ClientSidebarMenuNode
|
||||||
|
v-for="node in visibleNavigation"
|
||||||
|
:key="node.key"
|
||||||
|
:node="node"
|
||||||
|
:active-path="activePath"
|
||||||
|
:opened-keys="openedKeys"
|
||||||
|
:active-parent-keys="activeParentKeys"
|
||||||
|
@navigate="handleSelect"
|
||||||
|
@toggle="handleToggle"
|
||||||
|
/>
|
||||||
|
</ul>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { ChevronDown } from '@lucide/vue'
|
||||||
|
import type { ClientNavigationNode } from '@/navigation/clientNavigation'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
node: ClientNavigationNode
|
||||||
|
activePath: string
|
||||||
|
openedKeys: ReadonlySet<string>
|
||||||
|
activeParentKeys: ReadonlySet<string>
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
navigate: [path: string]
|
||||||
|
toggle: [key: string]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const groupId = computed(() => `client-nav-group-${props.node.key}`)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<li
|
||||||
|
class="client-nav-node"
|
||||||
|
v-if="node.children"
|
||||||
|
:class="{ 'is-active-parent': activeParentKeys.has(node.key) }"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="client-sidebar-group-trigger"
|
||||||
|
:aria-expanded="openedKeys.has(node.key)"
|
||||||
|
:aria-controls="groupId"
|
||||||
|
@click="emit('toggle', node.key)"
|
||||||
|
>
|
||||||
|
<component :is="node.icon" :size="18" />
|
||||||
|
<span>{{ node.title }}</span>
|
||||||
|
<ChevronDown class="client-sidebar-group-chevron" :size="16" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<ul
|
||||||
|
v-show="openedKeys.has(node.key)"
|
||||||
|
:id="groupId"
|
||||||
|
class="client-nav-submenu"
|
||||||
|
>
|
||||||
|
<ClientSidebarMenuNode
|
||||||
|
v-for="child in node.children"
|
||||||
|
:key="child.key"
|
||||||
|
:node="child"
|
||||||
|
:active-path="activePath"
|
||||||
|
:opened-keys="openedKeys"
|
||||||
|
:active-parent-keys="activeParentKeys"
|
||||||
|
@navigate="emit('navigate', $event)"
|
||||||
|
@toggle="emit('toggle', $event)"
|
||||||
|
/>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li v-else class="client-nav-node">
|
||||||
|
<RouterLink
|
||||||
|
:to="node.path"
|
||||||
|
class="client-sidebar-link"
|
||||||
|
:aria-current="node.path === activePath ? 'page' : undefined"
|
||||||
|
@click="emit('navigate', node.path)"
|
||||||
|
>
|
||||||
|
<component :is="node.icon" :size="18" />
|
||||||
|
<span>{{ node.title }}</span>
|
||||||
|
</RouterLink>
|
||||||
|
</li>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
export type FirstLegPartyRole = 'SHIPPER' | 'CONSIGNEE'
|
||||||
|
|
||||||
|
export interface FirstLegParty {
|
||||||
|
id: string
|
||||||
|
code: string
|
||||||
|
name: string
|
||||||
|
roles: FirstLegPartyRole[]
|
||||||
|
contactName: string
|
||||||
|
phone: string
|
||||||
|
email: string
|
||||||
|
countryRegion: string
|
||||||
|
province: string
|
||||||
|
city: string
|
||||||
|
postalCode: string
|
||||||
|
address: string
|
||||||
|
status: 'ACTIVE' | 'INACTIVE'
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FirstLegPartySnapshot extends Omit<FirstLegParty, 'roles' | 'status'> {
|
||||||
|
role: FirstLegPartyRole
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createFirstLegPartySnapshot(party: FirstLegParty, role: FirstLegPartyRole): FirstLegPartySnapshot {
|
||||||
|
return {
|
||||||
|
id: party.id,
|
||||||
|
code: party.code,
|
||||||
|
name: party.name,
|
||||||
|
role,
|
||||||
|
contactName: party.contactName,
|
||||||
|
phone: party.phone,
|
||||||
|
email: party.email,
|
||||||
|
countryRegion: party.countryRegion,
|
||||||
|
province: party.province,
|
||||||
|
city: party.city,
|
||||||
|
postalCode: party.postalCode,
|
||||||
|
address: party.address,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// API 接入前供客户端订单静态流程使用,不代表已确认的往来单位类型或数据范围契约。
|
||||||
|
export const DEMO_FIRST_LEG_PARTIES: readonly FirstLegParty[] = [
|
||||||
|
{ id: 'CP001', code: 'CN-SHIP-001', name: '示例贸易有限公司', roles: ['SHIPPER'], contactName: '王经理', phone: '138****1201', email: 'shipping@example.com', countryRegion: '中国', province: '浙江省', city: '杭州市', postalCode: '310000', address: '滨江区演示产业园 A 座', status: 'ACTIVE' },
|
||||||
|
{ id: 'CP002', code: 'CN-SHIP-002', name: '华南演示供应链有限公司', roles: ['SHIPPER'], contactName: '陈主管', phone: '139****8820', email: 'ops@example.cn', countryRegion: '中国', province: '广东省', city: '深圳市', postalCode: '518000', address: '宝安区演示供应链园 2 号楼', status: 'ACTIVE' },
|
||||||
|
{ id: 'CP003', code: 'US-CONS-001', name: 'DEMO GLOBAL LLC', roles: ['CONSIGNEE'], contactName: 'Alex Morgan', phone: '+1 *** *** 2108', email: 'receiving@example.us', countryRegion: '美国', province: 'California', city: 'Los Angeles', postalCode: '90001', address: '100 Demo Logistics Ave', status: 'ACTIVE' },
|
||||||
|
{ id: 'CP004', code: 'DE-CONS-001', name: 'DEMO EUROPE GMBH', roles: ['CONSIGNEE'], contactName: 'Lena Fischer', phone: '+49 *** *** 4810', email: 'warehouse@example.de', countryRegion: '德国', province: 'Hamburg', city: 'Hamburg', postalCode: '20095', address: '8 Demo Hafenstrasse', status: 'ACTIVE' },
|
||||||
|
{ id: 'CP005', code: 'US-CONS-OLD', name: '停用演示收货单位', roles: ['CONSIGNEE'], contactName: 'Demo Contact', phone: '', email: '', countryRegion: '美国', province: '', city: '', postalCode: '', address: '', status: 'INACTIVE' },
|
||||||
|
]
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { readFileSync } from 'node:fs'
|
||||||
|
import test from 'node:test'
|
||||||
|
|
||||||
|
const source = readFileSync(new URL('./firstLegPartySource.ts', import.meta.url), 'utf8')
|
||||||
|
const viewSource = readFileSync(new URL('../views/FirstLegOrdersView.vue', import.meta.url), 'utf8')
|
||||||
|
const apiSource = readFileSync(new URL('../api/partners.ts', import.meta.url), 'utf8')
|
||||||
|
|
||||||
|
test('keeps counterparty availability behind a replaceable read-only source', () => {
|
||||||
|
assert.match(source, /export interface FirstLegPartySource/)
|
||||||
|
assert.match(source, /listAvailable\(role/)
|
||||||
|
assert.match(source, /findAvailable\(id, role/)
|
||||||
|
assert.match(source, /async load\(\)/)
|
||||||
|
assert.match(source, /party\.status === 'ACTIVE' && party\.roles\.includes\(role\)/)
|
||||||
|
assert.match(source, /getMerchantPartnerCandidates/)
|
||||||
|
assert.match(viewSource, /firstLegPartySource\.listAvailable\('SHIPPER'\)/)
|
||||||
|
assert.match(viewSource, /firstLegPartySource\.listAvailable\('CONSIGNEE'\)/)
|
||||||
|
assert.match(viewSource, /firstLegPartySource\.findAvailable\(id, role\)/)
|
||||||
|
assert.match(viewSource, /firstLegPartySource\.load\(\)/)
|
||||||
|
assert.doesNotMatch(viewSource, /DEMO_FIRST_LEG_PARTIES/)
|
||||||
|
assert.match(apiSource, /url: '\/merchant\/v1\/partners\/candidates'/)
|
||||||
|
assert.match(apiSource, /url: `\/merchant\/v1\/partners\/\$\{id\}`/)
|
||||||
|
})
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { getMerchantPartnerCandidates, type MerchantPartner, type MerchantPartnerType } from '@/api/partners'
|
||||||
|
import type { FirstLegParty, FirstLegPartyRole } from '@/data/firstLegParties'
|
||||||
|
|
||||||
|
export interface FirstLegPartySource {
|
||||||
|
listAvailable(role: FirstLegPartyRole): readonly FirstLegParty[]
|
||||||
|
findAvailable(id: string, role: FirstLegPartyRole): FirstLegParty | undefined
|
||||||
|
load(): Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAvailableForRole(party: FirstLegParty, role: FirstLegPartyRole) {
|
||||||
|
return party.status === 'ACTIVE' && party.roles.includes(role)
|
||||||
|
}
|
||||||
|
|
||||||
|
const parties: FirstLegParty[] = []
|
||||||
|
|
||||||
|
function mapPartner(partner: MerchantPartner, role: FirstLegPartyRole): FirstLegParty {
|
||||||
|
return {
|
||||||
|
id: String(partner.id),
|
||||||
|
code: partner.partnerCode,
|
||||||
|
name: partner.partnerName,
|
||||||
|
roles: [role],
|
||||||
|
contactName: partner.contactName ?? '',
|
||||||
|
phone: partner.contactPhone ?? '',
|
||||||
|
email: partner.contactEmail ?? '',
|
||||||
|
countryRegion: partner.countryCode ?? '',
|
||||||
|
province: partner.province ?? '',
|
||||||
|
city: partner.city ?? '',
|
||||||
|
postalCode: partner.postalCode ?? '',
|
||||||
|
address: [partner.district, partner.addressLine].filter(Boolean).join(''),
|
||||||
|
status: partner.status === 'ACTIVE' ? 'ACTIVE' : 'INACTIVE',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadCandidates(type: MerchantPartnerType, role: FirstLegPartyRole) {
|
||||||
|
return (await getMerchantPartnerCandidates(type)).map((partner) => mapPartner(partner, role))
|
||||||
|
}
|
||||||
|
|
||||||
|
export const firstLegPartySource: FirstLegPartySource = {
|
||||||
|
listAvailable(role) {
|
||||||
|
return parties.filter((party) => isAvailableForRole(party, role))
|
||||||
|
},
|
||||||
|
findAvailable(id, role) {
|
||||||
|
return parties.find((party) => party.id === id && isAvailableForRole(party, role))
|
||||||
|
},
|
||||||
|
async load() {
|
||||||
|
parties.splice(0, parties.length, ...await Promise.all([
|
||||||
|
loadCandidates('SENDER', 'SHIPPER'),
|
||||||
|
loadCandidates('RECEIVER', 'CONSIGNEE'),
|
||||||
|
]).then((groups) => groups.flat()))
|
||||||
|
},
|
||||||
|
}
|
||||||
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,106 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { readFileSync } from 'node:fs'
|
||||||
|
import test from 'node:test'
|
||||||
|
|
||||||
|
const layoutSource = readFileSync(new URL('./AppLayout.vue', import.meta.url), 'utf8')
|
||||||
|
const routerSource = readFileSync(new URL('../router/index.ts', import.meta.url), 'utf8')
|
||||||
|
const sidebarMenuSource = readFileSync(new URL('../components/ClientSidebarMenu.vue', import.meta.url), 'utf8')
|
||||||
|
const sidebarMenuNodeSource = readFileSync(new URL('../components/ClientSidebarMenuNode.vue', import.meta.url), 'utf8')
|
||||||
|
|
||||||
|
test('source contract: renders the grouped sidebar menu and mobile drawer controls', () => {
|
||||||
|
assert.match(layoutSource, /import \{ computed, nextTick, onBeforeUnmount, onMounted, ref, watch \} from 'vue'/)
|
||||||
|
assert.match(layoutSource, /import \{[\s\S]*?\bX,[\s\S]*?\} from '@lucide\/vue'/)
|
||||||
|
assert.match(layoutSource, /import ClientSidebarMenu from '@\/components\/ClientSidebarMenu\.vue'/)
|
||||||
|
assert.match(layoutSource, /:class="\{ 'mobile-menu-open': mobileMenuOpen \}"/)
|
||||||
|
assert.match(layoutSource, /<button\s+v-if="mobileMenuOpen"[^>]*class="client-sidebar-scrim"[^>]*aria-label="关闭导航"[^>]*@click="closeMobileMenu"/s)
|
||||||
|
assert.match(layoutSource, /<aside\s+ref="sidebarRef"\s+id="client-sidebar"\s+class="client-sidebar"\s+:inert="!desktopViewport && !mobileMenuOpen"\s+:aria-hidden="!desktopViewport && !mobileMenuOpen \? 'true' : undefined"\s*>/)
|
||||||
|
assert.doesNotMatch(layoutSource, /<aside[^>]*aria-label=/)
|
||||||
|
assert.match(layoutSource, /<nav aria-label="客户工作台导航">/)
|
||||||
|
assert.match(layoutSource, /<div class="client-sidebar-header">\s*<strong>功能导航<\/strong>/s)
|
||||||
|
assert.match(layoutSource, /<button[^>]*ref="sidebarCloseButtonRef"[^>]*aria-label="关闭导航"[^>]*@click="closeMobileMenu"[^>]*>\s*<X :size="20" \/>/s)
|
||||||
|
assert.match(layoutSource, /<ClientSidebarMenu\s+:active-path="route\.path"\s+@navigate="closeMobileMenu"\s*\/>/s)
|
||||||
|
assert.doesNotMatch(layoutSource, /v-for="item in clientNavigation"/)
|
||||||
|
assert.doesNotMatch(layoutSource, /from '@\/router'/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('source contract: exposes and opens the controlled mobile sidebar', () => {
|
||||||
|
assert.match(layoutSource, /const mobileMenuOpen = ref\(false\)/)
|
||||||
|
assert.match(layoutSource, /const menuButtonRef = ref<HTMLButtonElement>\(\)/)
|
||||||
|
assert.match(layoutSource, /const sidebarRef = ref<HTMLElement>\(\)/)
|
||||||
|
assert.match(layoutSource, /const sidebarCloseButtonRef = ref<HTMLButtonElement>\(\)/)
|
||||||
|
assert.match(layoutSource, /ref="menuButtonRef"[^>]*aria-label="打开导航"[^>]*aria-controls="client-sidebar"[^>]*:aria-expanded="mobileMenuOpen"[^>]*@click="openMobileMenu"/s)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('source contract: moves focus into the drawer and restores it only after an open-to-closed transition', () => {
|
||||||
|
assert.match(layoutSource, /watch\(mobileMenuOpen, async \(open, wasOpen\) =>/)
|
||||||
|
assert.match(layoutSource, /await nextTick\(\)\s*if \(mobileMenuOpen\.value\) sidebarCloseButtonRef\.value\?\.focus\(\)/)
|
||||||
|
assert.match(layoutSource, /if \(wasOpen\) \{\s*await nextTick\(\)\s*if \(!mobileMenuOpen\.value && !desktopViewport\.value\) menuButtonRef\.value\?\.focus\(\)/s)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('source contract: removes the header and main content from the accessibility tree while the drawer is open', () => {
|
||||||
|
assert.match(layoutSource, /<header\s+class="top-header"\s+:inert="mobileMenuOpen"\s+:aria-hidden="mobileMenuOpen \? 'true' : undefined"\s*>/)
|
||||||
|
assert.match(layoutSource, /<main\s+class="client-main"\s+:inert="mobileMenuOpen"\s+:aria-hidden="mobileMenuOpen \? 'true' : undefined"\s*>/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('source contract: closes the mobile menu on route changes and Escape', () => {
|
||||||
|
assert.match(layoutSource, /watch\(\(\) => route\.fullPath, closeMobileMenu\)/)
|
||||||
|
assert.match(layoutSource, /event\.key === 'Escape'/)
|
||||||
|
assert.match(layoutSource, /onMounted\(\(\) => \{[\s\S]*document\.addEventListener\('keydown', handleKeydown\)[\s\S]*\}\)/)
|
||||||
|
assert.match(layoutSource, /document\.removeEventListener\('keydown', handleKeydown\)/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('source contract: closes and unlocks the mobile drawer when the viewport enters desktop', () => {
|
||||||
|
assert.match(layoutSource, /const desktopViewport = ref\(true\)/)
|
||||||
|
assert.match(layoutSource, /let desktopMediaQuery: MediaQueryList \| undefined/)
|
||||||
|
assert.match(layoutSource, /function handleDesktopBreakpoint\(event: MediaQueryList \| MediaQueryListEvent\) \{\s*desktopViewport\.value = event\.matches\s*if \(event\.matches\) closeMobileMenu\(\)\s*\}/)
|
||||||
|
assert.match(layoutSource, /desktopMediaQuery = window\.matchMedia\('\(min-width: 721px\)'\)/)
|
||||||
|
assert.match(layoutSource, /desktopMediaQuery\.addEventListener\('change', handleDesktopBreakpoint\)/)
|
||||||
|
assert.match(layoutSource, /handleDesktopBreakpoint\(desktopMediaQuery\)/)
|
||||||
|
assert.match(layoutSource, /desktopMediaQuery\?\.removeEventListener\('change', handleDesktopBreakpoint\)/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('source contract: locks body scrolling while open and restores it on close or unmount', () => {
|
||||||
|
assert.match(layoutSource, /watch\(mobileMenuOpen, async \(open, wasOpen\) =>/)
|
||||||
|
assert.match(layoutSource, /previousBodyOverflow = document\.body\.style\.overflow/)
|
||||||
|
assert.match(layoutSource, /document\.body\.style\.overflow = 'hidden'/)
|
||||||
|
assert.match(layoutSource, /document\.body\.style\.overflow = previousBodyOverflow/)
|
||||||
|
assert.match(layoutSource, /onBeforeUnmount\(\(\) => \{[\s\S]*restoreBodyOverflow\(\)[\s\S]*\}\)/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('source contract: handles confirmation cancellation but rethrows unexpected logout errors', () => {
|
||||||
|
assert.match(layoutSource, /async function handleLogout\(\) \{\s*try \{/)
|
||||||
|
assert.match(layoutSource, /\} catch \(error\) \{\s*if \(error === 'cancel' \|\| error === 'close'\) return\s*throw error\s*\}/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('source contract: uses a native recursive disclosure list without Element menu roles', () => {
|
||||||
|
assert.doesNotMatch(sidebarMenuSource, /MenuInstance|<el-menu|openNavigationParentKeys/)
|
||||||
|
assert.doesNotMatch(sidebarMenuNodeSource, /<el-(?:menu-item|sub-menu)|role="(?:menubar|menu|menuitem)"/)
|
||||||
|
assert.match(sidebarMenuSource, /filterNavigationByPermissions\(clientNavigation, auth\.permissionCodes\)/)
|
||||||
|
assert.match(sidebarMenuSource, /const activeParentKeys = computed\(\(\) => new Set\(getNavigationParentKeys\(visibleNavigation\.value, props\.activePath\)\)\)/)
|
||||||
|
assert.match(sidebarMenuSource, /const openedKeys = ref<ReadonlySet<string>>\(new Set\(activeParentKeys\.value\)\)/)
|
||||||
|
assert.match(sidebarMenuSource, /openedKeys\.value = new Set\(\[\.\.\.openedKeys\.value, \.\.\.activeParentKeys\.value\]\)/)
|
||||||
|
assert.match(sidebarMenuSource, /if \(activeParentKeys\.value\.has\(key\)\) return/)
|
||||||
|
assert.match(sidebarMenuSource, /<ul class="client-nav-menu">/)
|
||||||
|
assert.match(sidebarMenuSource, /:active-path="activePath"/)
|
||||||
|
assert.match(sidebarMenuSource, /:opened-keys="openedKeys"/)
|
||||||
|
assert.match(sidebarMenuSource, /:active-parent-keys="activeParentKeys"/)
|
||||||
|
assert.match(sidebarMenuSource, /@toggle="handleToggle"/)
|
||||||
|
assert.match(sidebarMenuNodeSource, /<li\s+class="client-nav-node"[\s\S]*?v-if="node\.children"/)
|
||||||
|
assert.match(sidebarMenuNodeSource, /<button[\s\S]*?:aria-expanded="openedKeys\.has\(node\.key\)"[\s\S]*?:aria-controls="groupId"[\s\S]*?@click="emit\('toggle', node\.key\)"/)
|
||||||
|
assert.match(sidebarMenuNodeSource, /<ul\s+v-show="openedKeys\.has\(node\.key\)"\s+:id="groupId"\s+class="client-nav-submenu"\s*>/)
|
||||||
|
assert.match(sidebarMenuNodeSource, /<RouterLink[\s\S]*?:aria-current="node\.path === activePath \? 'page' : undefined"[\s\S]*?@click="emit\('navigate', node\.path\)"/)
|
||||||
|
assert.match(sidebarMenuNodeSource, /@navigate="emit\('navigate', \$event\)"/)
|
||||||
|
assert.match(sidebarMenuNodeSource, /@toggle="emit\('toggle', \$event\)"/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('source contract: keeps navigation data out of the router module', () => {
|
||||||
|
assert.doesNotMatch(routerSource, /clientNavigationTree/)
|
||||||
|
assert.doesNotMatch(routerSource, /getNavigationLeaves/)
|
||||||
|
assert.doesNotMatch(routerSource, /export const clientNavigation/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('source contract: routes denied pages to an explicit 403 state', () => {
|
||||||
|
assert.match(routerSource, /name: 'forbidden'/)
|
||||||
|
assert.match(routerSource, /!auth\.hasPermission\(permission\)/)
|
||||||
|
assert.match(routerSource, /getFirstAccessiblePath\(auth\.permissionCodes\)/)
|
||||||
|
})
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { ElMessageBox } from 'element-plus'
|
||||||
|
import {
|
||||||
|
Building2,
|
||||||
|
LogOut,
|
||||||
|
Menu,
|
||||||
|
X,
|
||||||
|
} from '@lucide/vue'
|
||||||
|
import { PRODUCT_NAME } from '@chongchong/shared'
|
||||||
|
import ClientSidebarMenu from '@/components/ClientSidebarMenu.vue'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const auth = useAuthStore()
|
||||||
|
const pageTitle = computed(() => route.meta.title || '业务总览')
|
||||||
|
const pageDescription = computed(() => route.meta.description || '')
|
||||||
|
const mobileMenuOpen = ref(false)
|
||||||
|
const desktopViewport = ref(true)
|
||||||
|
const menuButtonRef = ref<HTMLButtonElement>()
|
||||||
|
const sidebarRef = ref<HTMLElement>()
|
||||||
|
const sidebarCloseButtonRef = ref<HTMLButtonElement>()
|
||||||
|
let previousBodyOverflow: string | undefined
|
||||||
|
let desktopMediaQuery: MediaQueryList | undefined
|
||||||
|
|
||||||
|
function openMobileMenu() {
|
||||||
|
mobileMenuOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeMobileMenu() {
|
||||||
|
mobileMenuOpen.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function restoreBodyOverflow() {
|
||||||
|
if (previousBodyOverflow === undefined) return
|
||||||
|
|
||||||
|
document.body.style.overflow = previousBodyOverflow
|
||||||
|
previousBodyOverflow = undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeydown(event: KeyboardEvent) {
|
||||||
|
if (event.key === 'Escape') closeMobileMenu()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDesktopBreakpoint(event: MediaQueryList | MediaQueryListEvent) {
|
||||||
|
desktopViewport.value = event.matches
|
||||||
|
if (event.matches) closeMobileMenu()
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => route.fullPath, closeMobileMenu)
|
||||||
|
|
||||||
|
watch(mobileMenuOpen, async (open, wasOpen) => {
|
||||||
|
if (open) {
|
||||||
|
previousBodyOverflow = document.body.style.overflow
|
||||||
|
document.body.style.overflow = 'hidden'
|
||||||
|
|
||||||
|
await nextTick()
|
||||||
|
if (mobileMenuOpen.value) sidebarCloseButtonRef.value?.focus()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
restoreBodyOverflow()
|
||||||
|
|
||||||
|
if (wasOpen) {
|
||||||
|
await nextTick()
|
||||||
|
if (!mobileMenuOpen.value && !desktopViewport.value) menuButtonRef.value?.focus()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
document.addEventListener('keydown', handleKeydown)
|
||||||
|
desktopMediaQuery = window.matchMedia('(min-width: 721px)')
|
||||||
|
desktopMediaQuery.addEventListener('change', handleDesktopBreakpoint)
|
||||||
|
handleDesktopBreakpoint(desktopMediaQuery)
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
document.removeEventListener('keydown', handleKeydown)
|
||||||
|
desktopMediaQuery?.removeEventListener('change', handleDesktopBreakpoint)
|
||||||
|
restoreBodyOverflow()
|
||||||
|
})
|
||||||
|
|
||||||
|
async function handleLogout() {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm('确定退出客户工作台吗?', '退出登录', {
|
||||||
|
confirmButtonText: '退出',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
if (error === 'cancel' || error === 'close') return
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
await auth.logout()
|
||||||
|
await router.replace('/login')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="client-shell" :class="{ 'mobile-menu-open': mobileMenuOpen }">
|
||||||
|
<header
|
||||||
|
class="top-header"
|
||||||
|
:inert="mobileMenuOpen"
|
||||||
|
:aria-hidden="mobileMenuOpen ? 'true' : undefined"
|
||||||
|
>
|
||||||
|
<RouterLink class="client-brand" to="/">
|
||||||
|
<span class="client-brand-mark">CC</span>
|
||||||
|
<span><strong>{{ PRODUCT_NAME }}</strong><small>客户工作台</small></span>
|
||||||
|
</RouterLink>
|
||||||
|
<div class="tenant-name"><Building2 :size="17" /> {{ auth.user?.tenantName }}</div>
|
||||||
|
<div class="client-user">
|
||||||
|
<span class="client-avatar">{{ auth.user?.name.slice(0, 1) }}</span>
|
||||||
|
<span class="client-user-copy"><strong>{{ auth.user?.name }}</strong><small>{{ auth.user?.roles.join('、') }}</small></span>
|
||||||
|
<el-button text aria-label="退出登录" @click="handleLogout"><LogOut :size="18" /></el-button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<button
|
||||||
|
v-if="mobileMenuOpen"
|
||||||
|
type="button"
|
||||||
|
class="client-sidebar-scrim"
|
||||||
|
aria-label="关闭导航"
|
||||||
|
@click="closeMobileMenu"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<aside
|
||||||
|
ref="sidebarRef"
|
||||||
|
id="client-sidebar"
|
||||||
|
class="client-sidebar"
|
||||||
|
:inert="!desktopViewport && !mobileMenuOpen"
|
||||||
|
:aria-hidden="!desktopViewport && !mobileMenuOpen ? 'true' : undefined"
|
||||||
|
>
|
||||||
|
<div class="client-sidebar-header">
|
||||||
|
<strong>功能导航</strong>
|
||||||
|
<button
|
||||||
|
ref="sidebarCloseButtonRef"
|
||||||
|
type="button"
|
||||||
|
aria-label="关闭导航"
|
||||||
|
@click="closeMobileMenu"
|
||||||
|
>
|
||||||
|
<X :size="20" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<nav aria-label="客户工作台导航">
|
||||||
|
<ClientSidebarMenu
|
||||||
|
:active-path="route.path"
|
||||||
|
@navigate="closeMobileMenu"
|
||||||
|
/>
|
||||||
|
</nav>
|
||||||
|
<div class="sidebar-help"><strong>需要帮助?</strong><span>联系您的专属客户经理</span></div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main
|
||||||
|
class="client-main"
|
||||||
|
:inert="mobileMenuOpen"
|
||||||
|
:aria-hidden="mobileMenuOpen ? 'true' : undefined"
|
||||||
|
>
|
||||||
|
<div class="client-page-heading">
|
||||||
|
<button
|
||||||
|
ref="menuButtonRef"
|
||||||
|
type="button"
|
||||||
|
aria-label="打开导航"
|
||||||
|
aria-controls="client-sidebar"
|
||||||
|
:aria-expanded="mobileMenuOpen"
|
||||||
|
@click="openMobileMenu"
|
||||||
|
>
|
||||||
|
<Menu :size="20" />
|
||||||
|
</button>
|
||||||
|
<div><h1>{{ pageTitle }}</h1><p>{{ pageDescription }}</p></div>
|
||||||
|
</div>
|
||||||
|
<RouterView />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { createApp } from 'vue'
|
||||||
|
import { createPinia } from 'pinia'
|
||||||
|
import App from './App.vue'
|
||||||
|
import router from './router'
|
||||||
|
import './styles/index.css'
|
||||||
|
|
||||||
|
createApp(App).use(createPinia()).use(router).mount('#app')
|
||||||
@@ -0,0 +1,326 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { spawnSync } from 'node:child_process'
|
||||||
|
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
import test from 'node:test'
|
||||||
|
import {
|
||||||
|
Building2,
|
||||||
|
CircleUserRound,
|
||||||
|
ClipboardList,
|
||||||
|
KeyRound,
|
||||||
|
LayoutGrid,
|
||||||
|
Settings,
|
||||||
|
ShieldCheck,
|
||||||
|
Truck,
|
||||||
|
Users,
|
||||||
|
} from '@lucide/vue'
|
||||||
|
|
||||||
|
import {
|
||||||
|
clientNavigation,
|
||||||
|
filterNavigationByPermissions,
|
||||||
|
getFirstAccessiblePath,
|
||||||
|
getNavigationLeaves,
|
||||||
|
getNavigationParentKeys,
|
||||||
|
openNavigationParentKeys,
|
||||||
|
} from './clientNavigation.ts'
|
||||||
|
|
||||||
|
function collectNavigationNodes(nodes) {
|
||||||
|
return nodes.flatMap((node) => [
|
||||||
|
node,
|
||||||
|
...(node.children ? collectNavigationNodes(node.children) : []),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
test('keeps the confirmed personal entries at the top level', () => {
|
||||||
|
assert.deepEqual(
|
||||||
|
clientNavigation.filter((node) => node.path).map(({ title, path, permission }) => ({
|
||||||
|
title,
|
||||||
|
path,
|
||||||
|
permission,
|
||||||
|
})),
|
||||||
|
[
|
||||||
|
{ title: '业务总览', path: '/', permission: 'dashboard:view' },
|
||||||
|
{ title: '我的信息', path: '/account/profile', permission: 'account:profile:view' },
|
||||||
|
],
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('keeps the confirmed navigation groups and leaf order', () => {
|
||||||
|
assert.deepEqual(
|
||||||
|
clientNavigation.map((node) => ({
|
||||||
|
title: node.title,
|
||||||
|
children: node.children?.map(({ title, path, permission }) => ({
|
||||||
|
title,
|
||||||
|
path,
|
||||||
|
permission,
|
||||||
|
})),
|
||||||
|
})),
|
||||||
|
[
|
||||||
|
{ title: '业务总览', children: undefined },
|
||||||
|
{ title: '我的信息', children: undefined },
|
||||||
|
{
|
||||||
|
title: '头程业务',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
title: '头程订单',
|
||||||
|
path: '/orders/first-leg',
|
||||||
|
permission: 'order:first-leg:list',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '企业管理',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
title: '企业资料',
|
||||||
|
path: '/company/profile',
|
||||||
|
permission: 'company:profile:view',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '企业成员',
|
||||||
|
path: '/organization/members',
|
||||||
|
permission: 'company:member:list',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '角色管理',
|
||||||
|
path: '/organization/roles',
|
||||||
|
permission: 'company:role:list',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '菜单权限',
|
||||||
|
path: '/organization/permissions',
|
||||||
|
permission: 'company:permission:list',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('provides confirmed icons and unique keys recursively', () => {
|
||||||
|
const expectedIcons = new Map([
|
||||||
|
['dashboard', LayoutGrid],
|
||||||
|
['account-profile', CircleUserRound],
|
||||||
|
['first-leg', Truck],
|
||||||
|
['first-leg-orders', ClipboardList],
|
||||||
|
['company', Settings],
|
||||||
|
['company-profile', Building2],
|
||||||
|
['company-members', Users],
|
||||||
|
['company-roles', ShieldCheck],
|
||||||
|
['company-permissions', KeyRound],
|
||||||
|
])
|
||||||
|
const nodes = collectNavigationNodes(clientNavigation)
|
||||||
|
|
||||||
|
for (const node of nodes) {
|
||||||
|
assert.equal(node.icon, expectedIcons.get(node.key), `${node.key} has the confirmed icon`)
|
||||||
|
}
|
||||||
|
assert.equal(new Set(nodes.map((node) => node.key)).size, nodes.length)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('flattens leaves with unique paths and non-empty permissions', () => {
|
||||||
|
const leaves = getNavigationLeaves(clientNavigation)
|
||||||
|
const paths = leaves.map((leaf) => leaf.path)
|
||||||
|
|
||||||
|
assert.deepEqual(paths, [
|
||||||
|
'/',
|
||||||
|
'/account/profile',
|
||||||
|
'/orders/first-leg',
|
||||||
|
'/company/profile',
|
||||||
|
'/organization/members',
|
||||||
|
'/organization/roles',
|
||||||
|
'/organization/permissions',
|
||||||
|
])
|
||||||
|
assert.equal(new Set(paths).size, paths.length)
|
||||||
|
assert.ok(leaves.every((leaf) => leaf.permission.trim().length > 0))
|
||||||
|
assert.equal(new Set(leaves.map((leaf) => leaf.permission)).size, leaves.length)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('filters inaccessible leaves and empty navigation groups', () => {
|
||||||
|
const visible = filterNavigationByPermissions(clientNavigation, ['account:profile:view', 'order:first-leg:list'])
|
||||||
|
|
||||||
|
assert.deepEqual(getNavigationLeaves(visible).map((item) => item.path), ['/account/profile', '/orders/first-leg'])
|
||||||
|
assert.deepEqual(visible.map((item) => item.title), ['我的信息', '头程业务'])
|
||||||
|
assert.equal(getFirstAccessiblePath(['account:profile:view']), '/account/profile')
|
||||||
|
assert.equal(getFirstAccessiblePath([]), '/forbidden')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('flattens a synthetic three-level navigation tree recursively', () => {
|
||||||
|
const nestedLeaf = {
|
||||||
|
key: 'nested-leaf',
|
||||||
|
title: '嵌套叶子',
|
||||||
|
icon: ClipboardList,
|
||||||
|
path: '/nested/leaf',
|
||||||
|
permission: 'nested:leaf:view',
|
||||||
|
}
|
||||||
|
const syntheticTree = [
|
||||||
|
{
|
||||||
|
key: 'level-one',
|
||||||
|
title: '一级分组',
|
||||||
|
icon: Settings,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
key: 'level-two',
|
||||||
|
title: '二级分组',
|
||||||
|
icon: Truck,
|
||||||
|
children: [nestedLeaf],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
assert.deepEqual(getNavigationLeaves(syntheticTree), [nestedLeaf])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns navigation parent keys from outermost to innermost', () => {
|
||||||
|
const syntheticTree = [
|
||||||
|
{
|
||||||
|
key: 'level-one',
|
||||||
|
title: '一级分组',
|
||||||
|
icon: Settings,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
key: 'level-two',
|
||||||
|
title: '二级分组',
|
||||||
|
icon: Truck,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
key: 'nested-leaf',
|
||||||
|
title: '嵌套叶子',
|
||||||
|
icon: ClipboardList,
|
||||||
|
path: '/nested/leaf',
|
||||||
|
permission: 'nested:leaf:view',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
assert.deepEqual(getNavigationParentKeys(clientNavigation, '/'), [])
|
||||||
|
assert.deepEqual(getNavigationParentKeys(clientNavigation, '/orders/first-leg'), ['first-leg'])
|
||||||
|
assert.deepEqual(getNavigationParentKeys(clientNavigation, '/organization/members'), ['company'])
|
||||||
|
assert.deepEqual(getNavigationParentKeys(syntheticTree, '/nested/leaf'), ['level-one', 'level-two'])
|
||||||
|
assert.deepEqual(getNavigationParentKeys(clientNavigation, '/not-found'), [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('opens the company parent group for a company route', () => {
|
||||||
|
const openedKeys = []
|
||||||
|
|
||||||
|
openNavigationParentKeys('/organization/members', clientNavigation, (key) => openedKeys.push(key))
|
||||||
|
|
||||||
|
assert.deepEqual(openedKeys, ['company'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('opens nested parent groups from outermost to innermost', () => {
|
||||||
|
const syntheticTree = [
|
||||||
|
{
|
||||||
|
key: 'level-one',
|
||||||
|
title: '一级分组',
|
||||||
|
icon: Settings,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
key: 'level-two',
|
||||||
|
title: '二级分组',
|
||||||
|
icon: Truck,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
key: 'nested-leaf',
|
||||||
|
title: '嵌套叶子',
|
||||||
|
icon: ClipboardList,
|
||||||
|
path: '/nested/leaf',
|
||||||
|
permission: 'nested:leaf:view',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
const openedKeys = []
|
||||||
|
|
||||||
|
openNavigationParentKeys('/nested/leaf', syntheticTree, (key) => openedKeys.push(key))
|
||||||
|
|
||||||
|
assert.deepEqual(openedKeys, ['level-one', 'level-two'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('does not open a group when the navigation path does not exist', () => {
|
||||||
|
const openedKeys = []
|
||||||
|
|
||||||
|
openNavigationParentKeys('/not-found', clientNavigation, (key) => openedKeys.push(key))
|
||||||
|
|
||||||
|
assert.deepEqual(openedKeys, [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('enforces mutually exclusive leaf and non-empty group node types', () => {
|
||||||
|
const testDirectory = mkdtempSync(join(fileURLToPath(new URL('.', import.meta.url)), '.client-navigation-types-'))
|
||||||
|
const fixturePath = join(testDirectory, 'fixture.ts')
|
||||||
|
const compilerPath = fileURLToPath(new URL('../../node_modules/typescript/bin/tsc', import.meta.url))
|
||||||
|
|
||||||
|
try {
|
||||||
|
writeFileSync(fixturePath, `
|
||||||
|
import type { Component } from 'vue'
|
||||||
|
import {
|
||||||
|
getNavigationLeaves,
|
||||||
|
type ClientNavigationGroup,
|
||||||
|
type ClientNavigationLeaf,
|
||||||
|
type ClientNavigationNode,
|
||||||
|
} from '../clientNavigation.ts'
|
||||||
|
|
||||||
|
declare const icon: Component
|
||||||
|
|
||||||
|
const leaf: ClientNavigationLeaf = {
|
||||||
|
key: 'leaf',
|
||||||
|
title: 'Leaf',
|
||||||
|
icon,
|
||||||
|
path: '/leaf',
|
||||||
|
permission: 'leaf:view',
|
||||||
|
}
|
||||||
|
const group: ClientNavigationGroup = {
|
||||||
|
key: 'group',
|
||||||
|
title: 'Group',
|
||||||
|
icon,
|
||||||
|
children: [leaf],
|
||||||
|
}
|
||||||
|
const nodes: readonly ClientNavigationNode[] = [leaf, group]
|
||||||
|
getNavigationLeaves(nodes)
|
||||||
|
|
||||||
|
// @ts-expect-error groups require at least one child
|
||||||
|
const emptyGroup: ClientNavigationGroup = { key: 'empty', title: 'Empty', icon, children: [] }
|
||||||
|
// @ts-expect-error groups cannot be route leaves
|
||||||
|
const linkedGroup: ClientNavigationGroup = { key: 'linked', title: 'Linked', icon, children: [leaf], path: '/linked' }
|
||||||
|
// @ts-expect-error leaves cannot contain child nodes
|
||||||
|
const parentLeaf: ClientNavigationLeaf = { key: 'parent', title: 'Parent', icon, path: '/parent', permission: 'parent:view', children: [leaf] }
|
||||||
|
|
||||||
|
void emptyGroup
|
||||||
|
void linkedGroup
|
||||||
|
void parentLeaf
|
||||||
|
`)
|
||||||
|
|
||||||
|
const result = spawnSync(process.execPath, [
|
||||||
|
compilerPath,
|
||||||
|
'--noEmit',
|
||||||
|
'--strict',
|
||||||
|
'--skipLibCheck',
|
||||||
|
'--target',
|
||||||
|
'ES2022',
|
||||||
|
'--module',
|
||||||
|
'ESNext',
|
||||||
|
'--moduleResolution',
|
||||||
|
'Bundler',
|
||||||
|
'--allowImportingTsExtensions',
|
||||||
|
fixturePath,
|
||||||
|
], { encoding: 'utf8' })
|
||||||
|
|
||||||
|
assert.equal(result.status, 0, result.stdout || result.stderr)
|
||||||
|
} finally {
|
||||||
|
rmSync(testDirectory, { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('keeps the dashboard route permission synchronized with navigation', () => {
|
||||||
|
const routerSource = readFileSync(new URL('../router/index.ts', import.meta.url), 'utf8')
|
||||||
|
const dashboardRoute = routerSource.match(/\{ path: '', name: 'dashboard'.*?\}/)?.[0]
|
||||||
|
|
||||||
|
assert.ok(dashboardRoute)
|
||||||
|
assert.match(dashboardRoute, /permission: 'dashboard:view'/)
|
||||||
|
})
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import type { Component } from 'vue'
|
||||||
|
import {
|
||||||
|
Building2,
|
||||||
|
CircleUserRound,
|
||||||
|
ClipboardList,
|
||||||
|
KeyRound,
|
||||||
|
LayoutGrid,
|
||||||
|
Settings,
|
||||||
|
ShieldCheck,
|
||||||
|
Truck,
|
||||||
|
Users,
|
||||||
|
} from '@lucide/vue'
|
||||||
|
|
||||||
|
export interface ClientNavigationBaseNode {
|
||||||
|
key: string
|
||||||
|
title: string
|
||||||
|
icon: Component
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ClientNavigationLeaf extends ClientNavigationBaseNode {
|
||||||
|
path: string
|
||||||
|
permission: string
|
||||||
|
children?: never
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ClientNavigationGroup extends ClientNavigationBaseNode {
|
||||||
|
path?: never
|
||||||
|
permission?: never
|
||||||
|
children: readonly [ClientNavigationNode, ...ClientNavigationNode[]]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ClientNavigationNode = ClientNavigationLeaf | ClientNavigationGroup
|
||||||
|
|
||||||
|
export const clientNavigation: ClientNavigationNode[] = [
|
||||||
|
{ key: 'dashboard', title: '业务总览', icon: LayoutGrid, path: '/', permission: 'dashboard:view' },
|
||||||
|
{ key: 'account-profile', title: '我的信息', icon: CircleUserRound, path: '/account/profile', permission: 'account:profile:view' },
|
||||||
|
{
|
||||||
|
key: 'first-leg',
|
||||||
|
title: '头程业务',
|
||||||
|
icon: Truck,
|
||||||
|
children: [
|
||||||
|
{ key: 'first-leg-orders', title: '头程订单', icon: ClipboardList, path: '/orders/first-leg', permission: 'order:first-leg:list' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'company',
|
||||||
|
title: '企业管理',
|
||||||
|
icon: Settings,
|
||||||
|
children: [
|
||||||
|
{ key: 'company-profile', title: '企业资料', icon: Building2, path: '/company/profile', permission: 'company:profile:view' },
|
||||||
|
{ key: 'company-members', title: '企业成员', icon: Users, path: '/organization/members', permission: 'company:member:list' },
|
||||||
|
{ key: 'company-roles', title: '角色管理', icon: ShieldCheck, path: '/organization/roles', permission: 'company:role:list' },
|
||||||
|
{ key: 'company-permissions', title: '菜单权限', icon: KeyRound, path: '/organization/permissions', permission: 'company:permission:list' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
function isNavigationGroup(node: ClientNavigationNode): node is ClientNavigationGroup {
|
||||||
|
return 'children' in node && node.children !== undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getNavigationLeaves(nodes: readonly ClientNavigationNode[]): ClientNavigationLeaf[] {
|
||||||
|
return nodes.flatMap((node) => {
|
||||||
|
if (isNavigationGroup(node)) return getNavigationLeaves(node.children)
|
||||||
|
return [node]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function filterNavigationByPermissions(
|
||||||
|
nodes: readonly ClientNavigationNode[],
|
||||||
|
permissions: readonly string[],
|
||||||
|
): ClientNavigationNode[] {
|
||||||
|
const allowed = new Set(permissions)
|
||||||
|
const result: ClientNavigationNode[] = []
|
||||||
|
for (const node of nodes) {
|
||||||
|
if (!isNavigationGroup(node)) {
|
||||||
|
if (allowed.has(node.permission)) result.push(node)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const children = filterNavigationByPermissions(node.children, permissions)
|
||||||
|
if (children.length > 0) result.push({ ...node, children: children as [ClientNavigationNode, ...ClientNavigationNode[]] })
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFirstAccessiblePath(permissions: readonly string[]) {
|
||||||
|
return getNavigationLeaves(filterNavigationByPermissions(clientNavigation, permissions))[0]?.path ?? '/forbidden'
|
||||||
|
}
|
||||||
|
|
||||||
|
function findNavigationParentKeys(
|
||||||
|
nodes: readonly ClientNavigationNode[],
|
||||||
|
path: string,
|
||||||
|
parentKeys: readonly string[],
|
||||||
|
): string[] | undefined {
|
||||||
|
for (const node of nodes) {
|
||||||
|
if (isNavigationGroup(node)) {
|
||||||
|
const result = findNavigationParentKeys(node.children, path, [...parentKeys, node.key])
|
||||||
|
if (result) return result
|
||||||
|
} else if (node.path === path) {
|
||||||
|
return [...parentKeys]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getNavigationParentKeys(nodes: readonly ClientNavigationNode[], path: string): string[] {
|
||||||
|
return findNavigationParentKeys(nodes, path, []) ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
export function openNavigationParentKeys(
|
||||||
|
path: string,
|
||||||
|
nodes: readonly ClientNavigationNode[],
|
||||||
|
open: (key: string) => void,
|
||||||
|
) {
|
||||||
|
for (const key of getNavigationParentKeys(nodes, path)) {
|
||||||
|
open(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { createRouter, createWebHistory } from 'vue-router'
|
||||||
|
import AppLayout from '@/layouts/AppLayout.vue'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
import { getFirstAccessiblePath } from '@/navigation/clientNavigation'
|
||||||
|
|
||||||
|
const router = createRouter({
|
||||||
|
history: createWebHistory(),
|
||||||
|
routes: [
|
||||||
|
{ path: '/login', name: 'login', component: () => import('@/views/LoginView.vue'), meta: { title: '登录' } },
|
||||||
|
{ path: '/register', name: 'register', component: () => import('@/views/RegisterView.vue'), meta: { title: '企业注册' } },
|
||||||
|
{
|
||||||
|
path: '/',
|
||||||
|
component: AppLayout,
|
||||||
|
meta: { requiresAuth: true },
|
||||||
|
children: [
|
||||||
|
{ path: '', name: 'dashboard', component: () => import('@/views/DashboardView.vue'), meta: { title: '业务总览', description: '企业账户与头程业务概览', permission: 'dashboard:view' } },
|
||||||
|
{ path: 'account/profile', name: 'account-profile', component: () => import('@/views/AccountProfileView.vue'), meta: { title: '我的信息', description: '维护个人联系方式和登录密码', permission: 'account:profile:view' } },
|
||||||
|
{ path: 'company/profile', name: 'company-profile', component: () => import('@/views/CompanyProfileView.vue'), meta: { title: '企业资料', description: '维护企业基本信息、联系人和资质文件', permission: 'company:profile:view' } },
|
||||||
|
{ path: 'organization/members', name: 'members', component: () => import('@/views/MembersView.vue'), meta: { title: '企业成员', description: '管理本企业成员账号、状态和角色', permission: 'company:member:list' } },
|
||||||
|
{ path: 'organization/roles', name: 'roles', component: () => import('@/views/RolesView.vue'), meta: { title: '角色管理', description: '建立符合企业分工的成员角色', permission: 'company:role:list' } },
|
||||||
|
{ path: 'organization/permissions', name: 'permissions', component: () => import('@/views/PermissionsView.vue'), meta: { title: '菜单权限', description: '为本企业角色分配菜单及操作权限', permission: 'company:permission:list' } },
|
||||||
|
{ path: 'orders/first-leg', name: 'first-leg-orders', component: () => import('@/views/FirstLegOrdersView.vue'), meta: { title: '头程订单', description: '创建、查询并跟进本企业头程订单', permission: 'order:first-leg:list' } },
|
||||||
|
{ path: 'forbidden', name: 'forbidden', component: () => import('@/views/ForbiddenView.vue'), meta: { title: '无权访问', description: '当前账号没有访问该页面的权限' } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ path: '/:pathMatch(.*)*', redirect: '/' },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
router.beforeEach(async (to) => {
|
||||||
|
document.title = to.meta.title ? `${to.meta.title} - 虫虫出海客户工作台` : '虫虫出海客户工作台'
|
||||||
|
const auth = useAuthStore()
|
||||||
|
if (!auth.sessionRestored) {
|
||||||
|
try {
|
||||||
|
await auth.restoreSession()
|
||||||
|
} catch {
|
||||||
|
return { name: 'login', query: { redirect: to.fullPath } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (to.meta.requiresAuth && !auth.isAuthenticated) {
|
||||||
|
return { name: 'login', query: { redirect: to.fullPath } }
|
||||||
|
}
|
||||||
|
if ((to.name === 'login' || to.name === 'register') && auth.isAuthenticated) {
|
||||||
|
return getFirstAccessiblePath(auth.permissionCodes)
|
||||||
|
}
|
||||||
|
const permission = typeof to.meta.permission === 'string' ? to.meta.permission : undefined
|
||||||
|
if (auth.isAuthenticated && permission && !auth.hasPermission(permission)) {
|
||||||
|
return { name: 'forbidden', query: { from: to.fullPath } }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export default router
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import type { CurrentUser } from '@chongchong/shared'
|
||||||
|
import { loginMerchant, logoutMerchant } from '@/api/auth'
|
||||||
|
import { getMerchantPermissionResources, getMerchantPersonalProfile, getTenantProfile, updateMerchantPersonalProfile, type MerchantPermissionResource } from '@/api/merchantIam'
|
||||||
|
import { clearSessionTokens, getAccessToken, getRefreshToken, persistTokenPair } from '@/api/session'
|
||||||
|
import { TENANT_PERMISSION_GROUPS } from '@/stores/tenantAdministration'
|
||||||
|
import { decodeMerchantToken, type MerchantJwtPayload } from '@/utils/jwt'
|
||||||
|
|
||||||
|
const frontendPermissionCodes = new Set(TENANT_PERMISSION_GROUPS.flatMap((group) => group.items.map((item) => item.code)))
|
||||||
|
const backendPermissionMapping: Readonly<Record<string, readonly string[]>> = {
|
||||||
|
'merchant:profile:view': ['company:profile:view'],
|
||||||
|
'merchant:profile:edit': ['company:profile:edit'],
|
||||||
|
'merchant:qualification:upload': ['company:qualification:upload'],
|
||||||
|
'merchant:member:list': ['company:member:list'],
|
||||||
|
'merchant:member:create': ['company:member:write'],
|
||||||
|
'merchant:member:edit': ['company:member:write'],
|
||||||
|
'merchant:member:grant-role': ['company:member:write'],
|
||||||
|
'merchant:member:status': ['company:member:write'],
|
||||||
|
'merchant:member:reset-password': ['company:member:reset-password'],
|
||||||
|
'merchant:role:list': ['company:role:list', 'company:permission:list'],
|
||||||
|
'merchant:role:create': ['company:role:write'],
|
||||||
|
'merchant:role:edit': ['company:role:write'],
|
||||||
|
'merchant:role:status': ['company:role:write'],
|
||||||
|
'merchant:role:delete': ['company:role:write'],
|
||||||
|
'merchant:role:grant': ['company:permission:list', 'company:permission:assign'],
|
||||||
|
'merchant:order:list': ['order:first-leg:list'],
|
||||||
|
'merchant:order:view': ['order:first-leg:list'],
|
||||||
|
'merchant:order:create': ['order:first-leg:create'],
|
||||||
|
'merchant:order:edit': ['order:first-leg:edit'],
|
||||||
|
'merchant:order:submit': ['order:first-leg:submit'],
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ClientAccountProfile {
|
||||||
|
username: string
|
||||||
|
realName: string
|
||||||
|
phone: string
|
||||||
|
email: string
|
||||||
|
tenantName: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const emptyProfile: ClientAccountProfile = {
|
||||||
|
username: '',
|
||||||
|
realName: '',
|
||||||
|
phone: '',
|
||||||
|
email: '',
|
||||||
|
tenantName: '',
|
||||||
|
}
|
||||||
|
|
||||||
|
function effectiveBackendPermissions(payload: MerchantJwtPayload, resources?: MerchantPermissionResource[]) {
|
||||||
|
const jwtPermissions = new Set(payload.permissions ?? [])
|
||||||
|
if (!resources) return jwtPermissions
|
||||||
|
const activeResources = new Set(resources.filter((item) => item.status === 'ACTIVE').map((item) => item.permissionCode))
|
||||||
|
return new Set([...jwtPermissions].filter((permission) => activeResources.has(permission)))
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapPermissions(payload: MerchantJwtPayload, resources?: MerchantPermissionResource[]) {
|
||||||
|
const backendPermissions = effectiveBackendPermissions(payload, resources)
|
||||||
|
const result = new Set<string>(['dashboard:view', 'account:profile:view'])
|
||||||
|
for (const permission of backendPermissions) {
|
||||||
|
if (frontendPermissionCodes.has(permission)) result.add(permission)
|
||||||
|
for (const mapped of backendPermissionMapping[permission] ?? []) result.add(mapped)
|
||||||
|
}
|
||||||
|
return [...result]
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentUser(payload: MerchantJwtPayload, tenantName?: string): CurrentUser {
|
||||||
|
return {
|
||||||
|
id: String(payload.user_id),
|
||||||
|
name: payload.username,
|
||||||
|
account: payload.username,
|
||||||
|
tenantName: tenantName || `企业租户 ${payload.tenant_id}`,
|
||||||
|
roles: payload.roles?.length ? [...payload.roles] : ['企业成员'],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useAuthStore = defineStore('client-auth', () => {
|
||||||
|
const token = ref(getAccessToken())
|
||||||
|
const payload = ref<MerchantJwtPayload | null>(null)
|
||||||
|
const user = ref<CurrentUser | null>(null)
|
||||||
|
const profile = ref<ClientAccountProfile>({ ...emptyProfile })
|
||||||
|
const permissionCodes = ref<string[]>([])
|
||||||
|
const sessionRestored = ref(false)
|
||||||
|
|
||||||
|
function applyToken(accessToken: string, resources?: MerchantPermissionResource[]) {
|
||||||
|
const decoded = decodeMerchantToken(accessToken)
|
||||||
|
payload.value = decoded
|
||||||
|
token.value = accessToken
|
||||||
|
user.value = currentUser(decoded, user.value?.tenantName)
|
||||||
|
profile.value.username = decoded.username
|
||||||
|
profile.value.realName = decoded.username
|
||||||
|
permissionCodes.value = mapPermissions(decoded, resources)
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearSession() {
|
||||||
|
clearSessionTokens()
|
||||||
|
token.value = ''
|
||||||
|
payload.value = null
|
||||||
|
user.value = null
|
||||||
|
permissionCodes.value = []
|
||||||
|
profile.value = { ...emptyProfile }
|
||||||
|
sessionRestored.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (token.value) {
|
||||||
|
try {
|
||||||
|
applyToken(token.value)
|
||||||
|
} catch {
|
||||||
|
clearSession()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isAuthenticated = computed(() => Boolean(token.value && payload.value))
|
||||||
|
|
||||||
|
async function loadSessionContext() {
|
||||||
|
if (!payload.value) return
|
||||||
|
const [resources, tenant, personalProfile] = await Promise.all([
|
||||||
|
getMerchantPermissionResources(),
|
||||||
|
getTenantProfile(),
|
||||||
|
getMerchantPersonalProfile(),
|
||||||
|
])
|
||||||
|
permissionCodes.value = mapPermissions(payload.value, resources)
|
||||||
|
user.value = currentUser(payload.value, tenant.tenantName)
|
||||||
|
profile.value = {
|
||||||
|
...profile.value,
|
||||||
|
realName: personalProfile.displayName || payload.value.username,
|
||||||
|
phone: personalProfile.phone || '',
|
||||||
|
email: personalProfile.email || '',
|
||||||
|
tenantName: tenant.tenantName,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function restoreSession() {
|
||||||
|
if (sessionRestored.value) return
|
||||||
|
if (!isAuthenticated.value) {
|
||||||
|
sessionRestored.value = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await loadSessionContext()
|
||||||
|
} catch (error) {
|
||||||
|
clearSession()
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
sessionRestored.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function login(username: string, password: string, remember = false) {
|
||||||
|
const tokens = await loginMerchant(username, password)
|
||||||
|
const decoded = decodeMerchantToken(tokens.accessToken)
|
||||||
|
persistTokenPair(tokens, remember)
|
||||||
|
token.value = tokens.accessToken
|
||||||
|
payload.value = decoded
|
||||||
|
user.value = currentUser(decoded)
|
||||||
|
profile.value = { ...emptyProfile, username: decoded.username, realName: decoded.username }
|
||||||
|
permissionCodes.value = mapPermissions(decoded)
|
||||||
|
sessionRestored.value = false
|
||||||
|
try {
|
||||||
|
await loadSessionContext()
|
||||||
|
sessionRestored.value = true
|
||||||
|
} catch (error) {
|
||||||
|
clearSession()
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function logout() {
|
||||||
|
const refreshToken = getRefreshToken()
|
||||||
|
try {
|
||||||
|
if (refreshToken) await logoutMerchant(refreshToken)
|
||||||
|
} finally {
|
||||||
|
clearSession()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasPermission(permission?: string) {
|
||||||
|
return !permission || permissionCodes.value.includes(permission)
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateProfile(next: Pick<ClientAccountProfile, 'realName' | 'phone' | 'email'>) {
|
||||||
|
profile.value = { ...profile.value, ...structuredClone(next) }
|
||||||
|
if (user.value) user.value = { ...user.value, name: next.realName }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function savePersonalProfile(next: Pick<ClientAccountProfile, 'realName' | 'phone' | 'email'>) {
|
||||||
|
const updated = await updateMerchantPersonalProfile({
|
||||||
|
displayName: next.realName.trim(),
|
||||||
|
phone: next.phone.trim(),
|
||||||
|
email: next.email.trim(),
|
||||||
|
})
|
||||||
|
updateProfile({
|
||||||
|
realName: updated.displayName,
|
||||||
|
phone: updated.phone || '',
|
||||||
|
email: updated.email || '',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('cc-client-session-expired', clearSession)
|
||||||
|
|
||||||
|
return { token, user, profile, permissionCodes, isAuthenticated, sessionRestored, hasPermission, login, logout, restoreSession, updateProfile, savePersonalProfile }
|
||||||
|
})
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { readFileSync } from 'node:fs'
|
||||||
|
import test from 'node:test'
|
||||||
|
|
||||||
|
const storeSource = readFileSync(new URL('./firstLegOrders.ts', import.meta.url), 'utf8')
|
||||||
|
const apiSource = readFileSync(new URL('../api/firstLeg.ts', import.meta.url), 'utf8')
|
||||||
|
|
||||||
|
test('declares the documented merchant warehouse and first-leg endpoints', () => {
|
||||||
|
assert.match(apiSource, /url: '\/merchant\/v1\/warehouses'/)
|
||||||
|
assert.match(apiSource, /url: '\/merchant\/v1\/orders\/first-leg'/)
|
||||||
|
assert.match(apiSource, /`\/merchant\/v1\/orders\/first-leg\/\$\{id\}`/)
|
||||||
|
assert.match(apiSource, /`\/merchant\/v1\/orders\/first-leg\/\$\{id\}\/submit`/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('maps transport and status fields instead of treating container mode as transport mode', () => {
|
||||||
|
assert.match(storeSource, /order\.transportMode === 'SEA'.*order\.containerMode === 'FULL_CONTAINER' \? 'FCL' : 'LCL'/s)
|
||||||
|
assert.match(storeSource, /draft\.transportMode === 'ROAD'\) return 'TRUCK'/)
|
||||||
|
assert.match(storeSource, /status === 'SUBMITTED'\) return 'PENDING_REVIEW'/)
|
||||||
|
assert.match(storeSource, /status === 'REJECTED'\) return 'PENDING_REVIEW'/)
|
||||||
|
assert.match(storeSource, /status === 'PROCESSING'\) return 'IN_TRANSIT'/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('uses real API data without falling back to demo warehouses or orders', () => {
|
||||||
|
assert.match(storeSource, /orders\.value = \[\]/)
|
||||||
|
assert.match(storeSource, /warehouses\.value = \[\]/)
|
||||||
|
assert.match(storeSource, /Promise\.all\(\[getMerchantFirstLegOrders\(\), getMerchantWarehouses\(\)\]\)/)
|
||||||
|
assert.doesNotMatch(storeSource, /DEMO_PLATFORM_RECEIVING_WAREHOUSES|initialOrders/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('sends backend-required channel, customs, destination and cargo fields', () => {
|
||||||
|
for (const field of ['pickupMethod', 'channelCode', 'channelName', 'taxMode', 'customsInspection', 'destinationType', 'packageType']) {
|
||||||
|
assert.match(storeSource, new RegExp(field))
|
||||||
|
}
|
||||||
|
assert.match(storeSource, /containerMode: draft\.transportMode === 'FCL' \? 'FULL_CONTAINER'/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('does not expose the out-of-scope cancellation write', () => {
|
||||||
|
assert.doesNotMatch(storeSource, /cancelMerchant|function cancel|\/cancel/)
|
||||||
|
assert.doesNotMatch(apiSource, /\/cancel/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('sends an idempotency key for create and submit writes', () => {
|
||||||
|
assert.match(apiSource, /'Idempotency-Key': createIdempotencyKey\('first-leg-create'\)/)
|
||||||
|
assert.match(apiSource, /'Idempotency-Key': createIdempotencyKey\(`first-leg-submit-\$\{id\}`\)/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('does not expose the contradictory backend rejected state in the client model', () => {
|
||||||
|
assert.match(storeSource, /status === 'REJECTED'\) return 'PENDING_REVIEW'/)
|
||||||
|
assert.match(storeSource, /item\.toStatus === 'SUBMITTED' \|\| item\.toStatus === 'REJECTED' \? 'SUBMITTED'/)
|
||||||
|
})
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import {
|
||||||
|
createMerchantFirstLegOrder,
|
||||||
|
getMerchantFirstLegOrder,
|
||||||
|
getMerchantFirstLegOrders,
|
||||||
|
getMerchantWarehouses,
|
||||||
|
submitMerchantFirstLegOrder,
|
||||||
|
updateMerchantFirstLegOrder,
|
||||||
|
type ApiFirstLegStatus,
|
||||||
|
type FirstLegOrderPartyPayload,
|
||||||
|
type FirstLegOrderSaveRequest,
|
||||||
|
type MerchantFirstLegOrder,
|
||||||
|
type MerchantFirstLegOrderDetail,
|
||||||
|
type MerchantWarehouse,
|
||||||
|
} from '@/api/firstLeg'
|
||||||
|
import type { MerchantTenantStatus } from '@/api/merchantIam'
|
||||||
|
import type { PlatformReceivingWarehouse, PlatformReceivingWarehouseSnapshot } from '@chongchong/shared'
|
||||||
|
import type { FirstLegPartySnapshot } from '@/data/firstLegParties'
|
||||||
|
import type { FirstLegOrder, FirstLegOrderDraft, FirstLegOrderTimelineItem } from '@/types/firstLegOrder'
|
||||||
|
import { canClientEditFirstLegOrder } from '@/utils/firstLegOrder'
|
||||||
|
|
||||||
|
const countryCodes: Readonly<Record<string, string>> = {
|
||||||
|
中国: 'CN', 美国: 'US', 德国: 'DE', 英国: 'GB', 加拿大: 'CA', 日本: 'JP', 澳大利亚: 'AU',
|
||||||
|
}
|
||||||
|
|
||||||
|
function clientStatus(status: ApiFirstLegStatus): FirstLegOrder['status'] {
|
||||||
|
if (status === 'SUBMITTED') return 'PENDING_REVIEW'
|
||||||
|
// 后端联调任务曾出现 REJECTED,但一期已确认订单不设驳回状态;兼容旧数据时仍按待审核展示。
|
||||||
|
if (status === 'REJECTED') return 'PENDING_REVIEW'
|
||||||
|
if (status === 'APPROVED' || status === 'ACCEPTED') return 'ACCEPTED'
|
||||||
|
if (status === 'PROCESSING') return 'IN_TRANSIT'
|
||||||
|
return status
|
||||||
|
}
|
||||||
|
|
||||||
|
function clientTransport(order: MerchantFirstLegOrder): FirstLegOrder['transportMode'] {
|
||||||
|
if (order.transportMode === 'SEA') return order.containerMode === 'FULL_CONTAINER' ? 'FCL' : 'LCL'
|
||||||
|
if (order.transportMode === 'AIR') return 'AIR'
|
||||||
|
return 'ROAD'
|
||||||
|
}
|
||||||
|
|
||||||
|
function apiTransport(draft: FirstLegOrderDraft): FirstLegOrderSaveRequest['transportMode'] {
|
||||||
|
if (draft.transportMode === 'AIR') return 'AIR'
|
||||||
|
if (draft.transportMode === 'ROAD') return 'TRUCK'
|
||||||
|
return 'SEA'
|
||||||
|
}
|
||||||
|
|
||||||
|
function partyPayload(snapshot: FirstLegPartySnapshot): FirstLegOrderPartyPayload {
|
||||||
|
return {
|
||||||
|
companyName: snapshot.name,
|
||||||
|
contactName: snapshot.contactName,
|
||||||
|
phone: snapshot.phone,
|
||||||
|
email: snapshot.email || undefined,
|
||||||
|
countryCode: countryCodes[snapshot.countryRegion] ?? snapshot.countryRegion.toUpperCase().slice(0, 2),
|
||||||
|
province: snapshot.province || undefined,
|
||||||
|
city: snapshot.city,
|
||||||
|
addressLine: snapshot.address,
|
||||||
|
postalCode: snapshot.postalCode || undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function savePayload(draft: FirstLegOrderDraft, warehouse: PlatformReceivingWarehouse, customerName: string): FirstLegOrderSaveRequest {
|
||||||
|
if (!draft.shipperPartySnapshot || !draft.consigneePartySnapshot) throw new Error('请完整填写收发货信息')
|
||||||
|
return {
|
||||||
|
customerName,
|
||||||
|
transportMode: apiTransport(draft),
|
||||||
|
tradeTerm: draft.tradeTerm || undefined,
|
||||||
|
destinationCountryCode: draft.destinationCountryCode.trim().toUpperCase(),
|
||||||
|
destinationWarehouseName: draft.destinationWarehouseName,
|
||||||
|
expectedShipDate: draft.expectedDepartureDate || undefined,
|
||||||
|
customerRemark: draft.customerRemark || undefined,
|
||||||
|
sender: partyPayload(draft.shipperPartySnapshot),
|
||||||
|
receiver: partyPayload(draft.consigneePartySnapshot),
|
||||||
|
cargoes: draft.items.map((item) => ({
|
||||||
|
cargoName: item.cargoNameCn,
|
||||||
|
cargoNameEn: item.cargoNameEn || undefined,
|
||||||
|
hsCode: item.hsCode || undefined,
|
||||||
|
packageType: item.packageType,
|
||||||
|
packageCount: item.packageCount,
|
||||||
|
grossWeightKg: item.grossWeightKg,
|
||||||
|
volumeCbm: item.volumeCbm,
|
||||||
|
declaredValue: item.declaredValue || undefined,
|
||||||
|
currency: item.currency || undefined,
|
||||||
|
dangerousGoods: item.dangerousGoods,
|
||||||
|
cargoRemark: item.remark || undefined,
|
||||||
|
skuNo: item.sku || undefined,
|
||||||
|
totalQuantity: item.pieces,
|
||||||
|
})),
|
||||||
|
pickupMethod: draft.pickupMethod,
|
||||||
|
receivingWarehouseId: warehouse.id,
|
||||||
|
receivingWarehouseName: warehouse.name,
|
||||||
|
channelCode: draft.channelCode,
|
||||||
|
channelName: draft.channelName,
|
||||||
|
taxMode: draft.taxMode,
|
||||||
|
containerMode: draft.transportMode === 'FCL' ? 'FULL_CONTAINER' : draft.transportMode === 'LCL' ? 'LCL' : undefined,
|
||||||
|
customsInspection: draft.customsInspection,
|
||||||
|
destinationType: draft.destinationType,
|
||||||
|
amazonWarehouseCode: draft.amazonWarehouseCode || undefined,
|
||||||
|
shipmentId: draft.shipmentId || undefined,
|
||||||
|
referenceId: draft.referenceNo || undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function warehouseSnapshot(warehouse?: PlatformReceivingWarehouse): PlatformReceivingWarehouseSnapshot | null {
|
||||||
|
return warehouse ? { id: warehouse.id, code: warehouse.code, name: warehouse.name, province: warehouse.province, city: warehouse.city, address: warehouse.address } : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function partySnapshot(detail: MerchantFirstLegOrderDetail, type: 'SENDER' | 'RECEIVER'): FirstLegPartySnapshot | null {
|
||||||
|
const party = detail.parties.find((item) => item.partyType === type)
|
||||||
|
if (!party) return null
|
||||||
|
return {
|
||||||
|
id: String(party.id), code: type, name: party.companyName, role: type === 'SENDER' ? 'SHIPPER' : 'CONSIGNEE',
|
||||||
|
contactName: party.contactName, phone: party.phone, email: party.email ?? '', countryRegion: party.countryCode,
|
||||||
|
province: party.province ?? '', city: party.city, postalCode: party.postalCode ?? '', address: party.addressLine,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function timeline(detail: MerchantFirstLegOrderDetail): FirstLegOrderTimelineItem[] {
|
||||||
|
return detail.statusHistory.map((item) => ({
|
||||||
|
id: String(item.id),
|
||||||
|
event: item.toStatus === 'SUBMITTED' || item.toStatus === 'REJECTED' ? 'SUBMITTED' : item.toStatus === 'CANCELLED' ? 'CANCELLED' : item.toStatus === 'COMPLETED' ? 'COMPLETED' : item.toStatus === 'ACCEPTED' || item.toStatus === 'APPROVED' ? 'ACCEPTED' : 'IN_TRANSIT',
|
||||||
|
title: item.toStatus === 'REJECTED' ? '平台要求补充订单信息' : item.action || `状态更新为 ${item.toStatus}`,
|
||||||
|
description: item.reason || '订单状态已更新。',
|
||||||
|
operatorName: item.operatorName || '系统',
|
||||||
|
occurredAt: item.occurredAt,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapOrder(order: MerchantFirstLegOrder, warehouses: PlatformReceivingWarehouse[], detail?: MerchantFirstLegOrderDetail): FirstLegOrder {
|
||||||
|
const warehouse = warehouses.find((item) => item.id === String(order.receivingWarehouseId))
|
||||||
|
const shipper = detail ? partySnapshot(detail, 'SENDER') : null
|
||||||
|
const consignee = detail ? partySnapshot(detail, 'RECEIVER') : null
|
||||||
|
return {
|
||||||
|
id: String(order.id), orderNo: order.orderNo, referenceNo: order.referenceId ?? '', transportMode: clientTransport(order),
|
||||||
|
businessType: 'EXPORT', origin: shipper?.city ?? '', destination: order.destinationWarehouseName || order.destinationCountryCode,
|
||||||
|
receivingWarehouseId: String(order.receivingWarehouseId), receivingWarehouseName: order.receivingWarehouseName,
|
||||||
|
receivingWarehouseSnapshot: warehouseSnapshot(warehouse), expectedDepartureDate: order.expectedShipDate ?? '', etd: '', eta: '',
|
||||||
|
shipperPartyId: shipper?.id ?? '', shipperPartySnapshot: shipper, shipper: shipper?.name ?? '',
|
||||||
|
consigneePartyId: consignee?.id ?? '', consigneePartySnapshot: consignee, consignee: consignee?.name ?? '',
|
||||||
|
expressCompanyName: '', trackingNumber: '', priority: 'NORMAL', customerRemark: order.customerRemark ?? '', operatorName: '待平台分配',
|
||||||
|
status: clientStatus(order.status), version: order.version ?? 0,
|
||||||
|
items: (detail?.cargoes ?? []).map((item) => ({
|
||||||
|
id: String(item.id), sku: item.skuNo ?? '', cargoNameCn: item.cargoName, cargoNameEn: item.cargoNameEn ?? '',
|
||||||
|
pieces: item.totalQuantity ?? item.packageCount, packageCount: item.packageCount, grossWeightKg: item.grossWeightKg,
|
||||||
|
volumeCbm: item.volumeCbm, containerType: '', containerCount: 0, remark: item.cargoRemark ?? '',
|
||||||
|
packageType: item.packageType, hsCode: item.hsCode ?? '', declaredValue: item.declaredValue ?? 0,
|
||||||
|
currency: item.currency ?? 'USD', dangerousGoods: item.dangerousGoods ?? false,
|
||||||
|
})),
|
||||||
|
timeline: detail ? timeline(detail) : [], createdAt: order.createdAt, updatedAt: order.updatedAt,
|
||||||
|
tradeTerm: order.tradeTerm ?? '', destinationCountryCode: order.destinationCountryCode,
|
||||||
|
destinationWarehouseName: order.destinationWarehouseName, pickupMethod: order.pickupMethod, channelCode: order.channelCode,
|
||||||
|
channelName: order.channelName, taxMode: order.taxMode, customsInspection: order.customsInspection,
|
||||||
|
destinationType: order.destinationType, amazonWarehouseCode: order.amazonWarehouseCode ?? '', shipmentId: order.shipmentId ?? '',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapWarehouse(item: MerchantWarehouse): PlatformReceivingWarehouse {
|
||||||
|
return {
|
||||||
|
id: String(item.id), code: item.warehouseCode, name: item.warehouseName, province: item.countryCode,
|
||||||
|
city: item.countryCode, address: '详情地址待选择后加载', contactName: item.contactName ?? '', contactPhone: item.contactPhone ?? '',
|
||||||
|
businessHours: item.timezone ?? '以仓库通知为准', status: item.status, remark: item.remark ?? '', updatedAt: item.updatedAt ?? '',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useFirstLegOrdersStore = defineStore('client-first-leg-orders', () => {
|
||||||
|
const orders = ref<FirstLegOrder[]>([])
|
||||||
|
const warehouses = ref<PlatformReceivingWarehouse[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const loadedFromApi = ref(false)
|
||||||
|
const loadError = ref('')
|
||||||
|
const activeCount = computed(() => orders.value.filter((item) => ['PENDING_REVIEW', 'ACCEPTED', 'BOOKED', 'IN_TRANSIT'].includes(item.status)).length)
|
||||||
|
const pendingCount = computed(() => orders.value.filter((item) => item.status === 'PENDING_REVIEW').length)
|
||||||
|
|
||||||
|
function getById(id: string) { return orders.value.find((item) => item.id === id) }
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading.value = true
|
||||||
|
loadedFromApi.value = true
|
||||||
|
loadError.value = ''
|
||||||
|
orders.value = []
|
||||||
|
warehouses.value = []
|
||||||
|
try {
|
||||||
|
const [orderPage, warehousePage] = await Promise.all([getMerchantFirstLegOrders(), getMerchantWarehouses()])
|
||||||
|
warehouses.value = warehousePage.records.filter((item) => item.status === 'ACTIVE').map(mapWarehouse)
|
||||||
|
orders.value = orderPage.records.map((item) => mapOrder(item, warehouses.value))
|
||||||
|
} catch (error) {
|
||||||
|
loadError.value = error instanceof Error ? error.message : '订单与仓库数据加载失败'
|
||||||
|
throw error
|
||||||
|
} finally { loading.value = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadDetail(id: string) {
|
||||||
|
const detail = await getMerchantFirstLegOrder(id)
|
||||||
|
const mapped = mapOrder(detail.order, warehouses.value, detail)
|
||||||
|
const index = orders.value.findIndex((item) => item.id === id)
|
||||||
|
if (index >= 0) orders.value[index] = mapped
|
||||||
|
else orders.value.unshift(mapped)
|
||||||
|
return mapped
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveDraft(draft: FirstLegOrderDraft, customerName: string, id?: string) {
|
||||||
|
const warehouse = warehouses.value.find((item) => item.id === draft.receivingWarehouseId)
|
||||||
|
if (!warehouse) throw new Error('所选收货仓当前不可用')
|
||||||
|
if (id) {
|
||||||
|
const order = getById(id)
|
||||||
|
if (!order || !canClientEditFirstLegOrder(order.status)) throw new Error('当前订单状态不允许编辑')
|
||||||
|
await updateMerchantFirstLegOrder(id, savePayload(draft, warehouse, customerName))
|
||||||
|
return loadDetail(id)
|
||||||
|
}
|
||||||
|
const createdId = await createMerchantFirstLegOrder(savePayload(draft, warehouse, customerName))
|
||||||
|
return loadDetail(String(createdId))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submit(draft: FirstLegOrderDraft, customerName: string, id?: string, companyAuditStatus: MerchantTenantStatus = 'ACTIVE') {
|
||||||
|
if (companyAuditStatus !== 'ACTIVE') throw new Error('企业资料尚未审核通过,请先完善企业资料并等待平台审核')
|
||||||
|
const order = await saveDraft(draft, customerName, id)
|
||||||
|
await submitMerchantFirstLegOrder(order.id)
|
||||||
|
return loadDetail(order.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { orders, warehouses, loading, loadedFromApi, loadError, activeCount, pendingCount, getById, load, loadDetail, saveDraft, submit }
|
||||||
|
})
|
||||||
@@ -0,0 +1,422 @@
|
|||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import {
|
||||||
|
changeMerchantMemberStatus,
|
||||||
|
createMerchantMember,
|
||||||
|
createMerchantRole,
|
||||||
|
getMerchantMembers,
|
||||||
|
getMerchantPermissionResources,
|
||||||
|
getMerchantRoles,
|
||||||
|
getTenantProfile,
|
||||||
|
getTenantQualifications,
|
||||||
|
resetMerchantMemberPassword,
|
||||||
|
replaceMerchantRolePermissions,
|
||||||
|
updateMerchantMember,
|
||||||
|
updateTenantProfile,
|
||||||
|
type MerchantRole,
|
||||||
|
type MerchantMember,
|
||||||
|
type MerchantPermissionResource,
|
||||||
|
type MerchantTenantStatus,
|
||||||
|
} from '@/api/merchantIam'
|
||||||
|
|
||||||
|
export type TenantRecordStatus = 'ACTIVE' | 'INACTIVE'
|
||||||
|
|
||||||
|
export interface TenantCompanyProfile {
|
||||||
|
companyName: string
|
||||||
|
creditCode: string
|
||||||
|
companyType: string
|
||||||
|
registeredAddress: string
|
||||||
|
establishedDate: string
|
||||||
|
registeredCapital: string
|
||||||
|
legalRepresentative: string
|
||||||
|
legalRepresentativePhone: string
|
||||||
|
contactName: string
|
||||||
|
contactPhone: string
|
||||||
|
contactEmail: string
|
||||||
|
contactWechat: string
|
||||||
|
businessAddress: string
|
||||||
|
auditStatus: MerchantTenantStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TenantQualification {
|
||||||
|
id: string
|
||||||
|
type: string
|
||||||
|
fileName: string
|
||||||
|
status: 'VERIFIED' | 'PENDING' | 'REJECTED' | 'EXPIRED'
|
||||||
|
expiresAt: string
|
||||||
|
updatedAt: string
|
||||||
|
documentUrl: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TenantMember {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
account: string
|
||||||
|
phone: string
|
||||||
|
email: string
|
||||||
|
roleIds: string[]
|
||||||
|
status: TenantRecordStatus
|
||||||
|
lastLoginAt: string
|
||||||
|
isAdministrator: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TenantRole {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
code: string
|
||||||
|
description: string
|
||||||
|
status: TenantRecordStatus
|
||||||
|
builtin: boolean
|
||||||
|
permissionCodes: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TenantPermissionItem {
|
||||||
|
code: string
|
||||||
|
label: string
|
||||||
|
description: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TenantPermissionGroup {
|
||||||
|
key: string
|
||||||
|
label: string
|
||||||
|
items: TenantPermissionItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TENANT_PERMISSION_GROUPS: readonly TenantPermissionGroup[] = [
|
||||||
|
{
|
||||||
|
key: 'account',
|
||||||
|
label: '个人账号',
|
||||||
|
items: [{ code: 'account:profile:view', label: '维护我的信息', description: '查看并维护当前成员自己的资料和登录密码' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'dashboard',
|
||||||
|
label: '业务总览',
|
||||||
|
items: [{ code: 'dashboard:view', label: '查看总览', description: '查看企业统计和最近订单' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'orders',
|
||||||
|
label: '头程订单',
|
||||||
|
items: [
|
||||||
|
{ code: 'order:first-leg:list', label: '查看订单', description: '查看本企业头程订单列表和详情' },
|
||||||
|
{ code: 'order:first-leg:create', label: '创建订单', description: '新建并保存订单草稿' },
|
||||||
|
{ code: 'order:first-leg:edit', label: '编辑订单', description: '编辑本企业尚未提交的订单草稿' },
|
||||||
|
{ code: 'order:first-leg:submit', label: '提交订单', description: '提交草稿进入平台审核' },
|
||||||
|
{ code: 'order:first-leg:cancel', label: '取消订单', description: '在允许的状态下取消订单' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'company',
|
||||||
|
label: '企业资料',
|
||||||
|
items: [
|
||||||
|
{ code: 'company:profile:view', label: '查看资料', description: '查看企业、联系人和资质资料' },
|
||||||
|
{ code: 'company:profile:edit', label: '编辑资料', description: '维护企业联系信息和业务地址' },
|
||||||
|
{ code: 'company:qualification:upload', label: '更新资质', description: '上传或更新企业资质文件' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'members',
|
||||||
|
label: '企业成员',
|
||||||
|
items: [
|
||||||
|
{ code: 'company:member:list', label: '查看成员', description: '查看本企业成员列表' },
|
||||||
|
{ code: 'company:member:write', label: '维护成员', description: '新增、编辑、启停企业成员' },
|
||||||
|
{ code: 'company:member:reset-password', label: '重置密码', description: '重置企业成员登录密码' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'roles',
|
||||||
|
label: '角色与权限',
|
||||||
|
items: [
|
||||||
|
{ code: 'company:role:list', label: '查看角色', description: '查看本企业角色列表' },
|
||||||
|
{ code: 'company:role:write', label: '维护角色', description: '新增、编辑、启停企业角色' },
|
||||||
|
{ code: 'company:permission:list', label: '查看权限', description: '查看角色拥有的菜单和操作权限' },
|
||||||
|
{ code: 'company:permission:assign', label: '分配权限', description: '为本企业角色分配菜单及操作权限' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const allPermissionCodes = TENANT_PERMISSION_GROUPS.flatMap((group) => group.items.map((item) => item.code))
|
||||||
|
|
||||||
|
const frontendToBackendPermissionCodes: Readonly<Record<string, readonly string[]>> = {
|
||||||
|
'order:first-leg:list': ['merchant:order:list', 'merchant:order:view'],
|
||||||
|
'order:first-leg:create': ['merchant:order:create'],
|
||||||
|
'order:first-leg:edit': ['merchant:order:edit'],
|
||||||
|
'order:first-leg:submit': ['merchant:order:submit'],
|
||||||
|
'order:first-leg:cancel': ['merchant:order:cancel'],
|
||||||
|
'company:profile:view': ['merchant:profile:view'],
|
||||||
|
'company:profile:edit': ['merchant:profile:edit'],
|
||||||
|
'company:qualification:upload': ['merchant:qualification:upload'],
|
||||||
|
'company:member:list': ['merchant:member:list'],
|
||||||
|
'company:member:write': ['merchant:member:create', 'merchant:member:edit', 'merchant:member:grant-role', 'merchant:member:status'],
|
||||||
|
'company:member:reset-password': ['merchant:member:reset-password'],
|
||||||
|
'company:role:list': ['merchant:role:list'],
|
||||||
|
'company:role:write': ['merchant:role:create', 'merchant:role:edit', 'merchant:role:status', 'merchant:role:delete'],
|
||||||
|
'company:permission:list': ['merchant:role:list'],
|
||||||
|
'company:permission:assign': ['merchant:role:grant'],
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialRoles: TenantRole[] = [
|
||||||
|
{ id: 'ROLE-ADMIN', name: '企业管理员', code: 'TENANT_ADMIN', description: '企业内置管理员,拥有全部客户端权限', status: 'ACTIVE', builtin: true, permissionCodes: [...allPermissionCodes] },
|
||||||
|
{ id: 'ROLE-ORDER', name: '订单专员', code: 'ORDER_OPERATOR', description: '负责头程订单创建、提交和跟进', status: 'ACTIVE', builtin: false, permissionCodes: ['account:profile:view', 'dashboard:view', 'order:first-leg:list', 'order:first-leg:create', 'order:first-leg:edit', 'order:first-leg:submit', 'order:first-leg:cancel', 'company:profile:view'] },
|
||||||
|
{ id: 'ROLE-VIEWER', name: '业务查看员', code: 'BUSINESS_VIEWER', description: '只读查看企业资料和业务订单', status: 'ACTIVE', builtin: false, permissionCodes: ['account:profile:view', 'dashboard:view', 'order:first-leg:list', 'company:profile:view'] },
|
||||||
|
]
|
||||||
|
|
||||||
|
const initialMembers: TenantMember[] = [
|
||||||
|
{ id: 'MEMBER-001', name: '企业管理员', account: 'customer', phone: '138****6601', email: 'admin@example.com', roleIds: ['ROLE-ADMIN'], status: 'ACTIVE', lastLoginAt: '2026-07-28 16:42', isAdministrator: true },
|
||||||
|
{ id: 'MEMBER-002', name: '张伟', account: 'zhangwei', phone: '138****6789', email: 'zhangwei@example.com', roleIds: ['ROLE-ORDER'], status: 'ACTIVE', lastLoginAt: '2026-07-28 09:18', isAdministrator: false },
|
||||||
|
{ id: 'MEMBER-003', name: '李娜', account: 'lina', phone: '137****2210', email: 'lina@example.com', roleIds: ['ROLE-VIEWER'], status: 'INACTIVE', lastLoginAt: '2026-07-12 11:30', isAdministrator: false },
|
||||||
|
]
|
||||||
|
|
||||||
|
export const useTenantAdministrationStore = defineStore('tenant-administration', () => {
|
||||||
|
const company = ref<TenantCompanyProfile>({
|
||||||
|
companyName: '示例贸易有限公司',
|
||||||
|
creditCode: '91330100MA2XXXXXXX',
|
||||||
|
companyType: '有限责任公司',
|
||||||
|
registeredAddress: '浙江省杭州市滨江区',
|
||||||
|
establishedDate: '2018-06-15',
|
||||||
|
registeredCapital: '500 万人民币',
|
||||||
|
legalRepresentative: '陈明',
|
||||||
|
legalRepresentativePhone: '139****8866',
|
||||||
|
contactName: '张伟',
|
||||||
|
contactPhone: '138****6789',
|
||||||
|
contactEmail: 'business@example.com',
|
||||||
|
contactWechat: 'cc_business',
|
||||||
|
businessAddress: '浙江省杭州市滨江区演示大道 88 号',
|
||||||
|
auditStatus: 'PROFILE_REQUIRED',
|
||||||
|
})
|
||||||
|
|
||||||
|
const qualifications = ref<TenantQualification[]>([
|
||||||
|
{ id: 'QUAL-001', type: '营业执照', fileName: '示例贸易有限公司营业执照.pdf', status: 'VERIFIED', expiresAt: '2028-06-15', updatedAt: '2026-07-27 14:20', documentUrl: '' },
|
||||||
|
{ id: 'QUAL-002', type: '法人身份证', fileName: '法人身份证明.pdf', status: 'VERIFIED', expiresAt: '2030-01-01', updatedAt: '2026-07-27 14:22', documentUrl: '' },
|
||||||
|
{ id: 'QUAL-003', type: '货代资质证书', fileName: '货代资质证书.pdf', status: 'PENDING', expiresAt: '2027-03-01', updatedAt: '2026-07-28 10:08', documentUrl: '' },
|
||||||
|
])
|
||||||
|
|
||||||
|
const members = ref<TenantMember[]>(structuredClone(initialMembers))
|
||||||
|
const roles = ref<TenantRole[]>(structuredClone(initialRoles))
|
||||||
|
const rolesLoadedFromApi = ref(false)
|
||||||
|
const membersLoadedFromApi = ref(false)
|
||||||
|
const permissionResources = ref<MerchantPermissionResource[]>([])
|
||||||
|
const activeMemberCount = computed(() => members.value.filter((member) => member.status === 'ACTIVE').length)
|
||||||
|
|
||||||
|
async function loadCompanyContext() {
|
||||||
|
const [profile, qualificationRecords] = await Promise.all([
|
||||||
|
getTenantProfile(),
|
||||||
|
getTenantQualifications(),
|
||||||
|
])
|
||||||
|
company.value = {
|
||||||
|
...company.value,
|
||||||
|
companyName: profile.tenantName,
|
||||||
|
creditCode: profile.unifiedSocialCreditCode ?? '',
|
||||||
|
legalRepresentative: profile.legalName,
|
||||||
|
contactName: profile.contactName,
|
||||||
|
contactPhone: profile.contactPhone,
|
||||||
|
contactEmail: profile.contactEmail ?? '',
|
||||||
|
auditStatus: profile.status,
|
||||||
|
}
|
||||||
|
qualifications.value = qualificationRecords.map((item) => ({
|
||||||
|
id: String(item.id),
|
||||||
|
type: item.qualificationType === 'BUSINESS_LICENSE' ? '营业执照' : item.qualificationType,
|
||||||
|
fileName: item.documentUrl.split('/').pop() || '营业执照',
|
||||||
|
status: item.status === 'APPROVED' ? 'VERIFIED' : item.status,
|
||||||
|
expiresAt: '长期',
|
||||||
|
updatedAt: '以服务端记录为准',
|
||||||
|
documentUrl: item.documentUrl,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapMerchantRole(role: MerchantRole): TenantRole {
|
||||||
|
const grantedIds = (role.permissionIds ?? []).map(String)
|
||||||
|
const grantedBackendCodes = new Set(permissionResources.value
|
||||||
|
.filter((resource) => grantedIds.includes(String(resource.id)))
|
||||||
|
.map((resource) => resource.permissionCode))
|
||||||
|
const permissionCodes = Object.entries(frontendToBackendPermissionCodes)
|
||||||
|
.filter(([, backendCodes]) => backendCodes.some((code) => grantedBackendCodes.has(code)))
|
||||||
|
.map(([frontendCode]) => frontendCode)
|
||||||
|
return {
|
||||||
|
id: String(role.id),
|
||||||
|
name: role.roleName,
|
||||||
|
code: role.roleCode,
|
||||||
|
description: role.description ?? '暂无角色说明',
|
||||||
|
status: role.status === 'ACTIVE' ? 'ACTIVE' : 'INACTIVE',
|
||||||
|
builtin: role.roleType === 'SYSTEM',
|
||||||
|
permissionCodes: role.roleType === 'SYSTEM' && role.roleCode === 'MERCHANT_OWNER' ? [...allPermissionCodes] : permissionCodes,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapMerchantMember(member: MerchantMember): TenantMember {
|
||||||
|
return {
|
||||||
|
id: String(member.membershipId),
|
||||||
|
name: member.displayName || `企业成员 ${member.membershipId}`,
|
||||||
|
account: member.memberNo || `用户 ${member.authUserId}`,
|
||||||
|
phone: member.phone ?? '',
|
||||||
|
email: member.email ?? '',
|
||||||
|
roleIds: member.roleIds.map(String),
|
||||||
|
status: member.status === 'ACTIVE' ? 'ACTIVE' : 'INACTIVE',
|
||||||
|
lastLoginAt: '服务端未提供',
|
||||||
|
isAdministrator: member.memberType === 'OWNER',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadRoles() {
|
||||||
|
rolesLoadedFromApi.value = true
|
||||||
|
roles.value = []
|
||||||
|
const [roleRecords, resources] = await Promise.all([getMerchantRoles(), getMerchantPermissionResources()])
|
||||||
|
permissionResources.value = resources.filter((item) => item.status === 'ACTIVE')
|
||||||
|
roles.value = roleRecords.map(mapMerchantRole)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadMembers() {
|
||||||
|
membersLoadedFromApi.value = true
|
||||||
|
members.value = []
|
||||||
|
members.value = (await getMerchantMembers()).map(mapMerchantMember)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateCompany(payload: TenantCompanyProfile) {
|
||||||
|
const updated = await updateTenantProfile({
|
||||||
|
tenantName: payload.companyName.trim(),
|
||||||
|
legalName: payload.legalRepresentative.trim(),
|
||||||
|
contactName: payload.contactName.trim(),
|
||||||
|
contactPhone: payload.contactPhone.trim(),
|
||||||
|
contactEmail: payload.contactEmail.trim(),
|
||||||
|
})
|
||||||
|
company.value = {
|
||||||
|
...company.value,
|
||||||
|
companyName: updated.tenantName,
|
||||||
|
creditCode: updated.unifiedSocialCreditCode ?? '',
|
||||||
|
legalRepresentative: updated.legalName,
|
||||||
|
contactName: updated.contactName,
|
||||||
|
contactPhone: updated.contactPhone,
|
||||||
|
contactEmail: updated.contactEmail ?? '',
|
||||||
|
auditStatus: updated.status,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setCompanyAuditStatusForPreview(status: TenantCompanyProfile['auditStatus']) {
|
||||||
|
company.value.auditStatus = status
|
||||||
|
}
|
||||||
|
|
||||||
|
function upsertQualification(payload: Omit<TenantQualification, 'id' | 'status' | 'updatedAt'>) {
|
||||||
|
const existing = qualifications.value.find((item) => item.type === payload.type)
|
||||||
|
const next = {
|
||||||
|
...payload,
|
||||||
|
status: 'PENDING' as const,
|
||||||
|
updatedAt: new Date().toLocaleString('zh-CN', { hour12: false }),
|
||||||
|
}
|
||||||
|
if (existing) Object.assign(existing, next)
|
||||||
|
else qualifications.value.unshift({ id: `QUAL-${Date.now()}`, ...next })
|
||||||
|
company.value.auditStatus = 'PENDING'
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createMember(payload: { name: string; account: string; password: string; phone: string; email: string; roleIds: string[]; memberNo: string }) {
|
||||||
|
const created = await createMerchantMember({
|
||||||
|
username: payload.account.trim(),
|
||||||
|
password: payload.password,
|
||||||
|
displayName: payload.name.trim(),
|
||||||
|
phone: payload.phone.trim(),
|
||||||
|
email: payload.email.trim(),
|
||||||
|
memberType: 'EMPLOYEE',
|
||||||
|
memberNo: payload.memberNo.trim(),
|
||||||
|
roleIds: payload.roleIds,
|
||||||
|
})
|
||||||
|
members.value.unshift(mapMerchantMember(created))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateMember(id: string, payload: Pick<TenantMember, 'name' | 'phone' | 'email' | 'roleIds'> & { memberNo: string }) {
|
||||||
|
const member = members.value.find((item) => item.id === id)
|
||||||
|
if (!member) throw new Error('成员不存在')
|
||||||
|
const updated = await updateMerchantMember(id, {
|
||||||
|
displayName: payload.name.trim(),
|
||||||
|
phone: payload.phone.trim(),
|
||||||
|
email: payload.email.trim(),
|
||||||
|
memberType: member.isAdministrator ? 'OWNER' : 'EMPLOYEE',
|
||||||
|
memberNo: payload.memberNo.trim(),
|
||||||
|
roleIds: payload.roleIds,
|
||||||
|
})
|
||||||
|
Object.assign(member, mapMerchantMember(updated))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleMemberStatus(id: string) {
|
||||||
|
const member = members.value.find((item) => item.id === id)
|
||||||
|
if (!member) throw new Error('成员不存在')
|
||||||
|
if (member.isAdministrator) throw new Error('企业管理员不能停用')
|
||||||
|
const updated = await changeMerchantMemberStatus(id, member.status === 'ACTIVE' ? 'DISABLED' : 'ACTIVE')
|
||||||
|
Object.assign(member, mapMerchantMember(updated))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resetMemberPassword(id: string, newPassword: string) {
|
||||||
|
await resetMerchantMemberPassword(id, newPassword)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createRole(payload: Pick<TenantRole, 'name' | 'code' | 'description'>) {
|
||||||
|
if (roles.value.some((role) => role.code === payload.code)) throw new Error('角色编码已存在')
|
||||||
|
const created = await createMerchantRole({ roleCode: payload.code, roleName: payload.name, description: payload.description })
|
||||||
|
roles.value.push(mapMerchantRole(created))
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateRole(id: string, payload: Pick<TenantRole, 'name' | 'description'>) {
|
||||||
|
const role = roles.value.find((item) => item.id === id)
|
||||||
|
if (!role) throw new Error('角色不存在')
|
||||||
|
Object.assign(role, payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleRoleStatus(id: string) {
|
||||||
|
const role = roles.value.find((item) => item.id === id)
|
||||||
|
if (!role) throw new Error('角色不存在')
|
||||||
|
if (role.builtin) throw new Error('内置角色不能停用')
|
||||||
|
role.status = role.status === 'ACTIVE' ? 'INACTIVE' : 'ACTIVE'
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteRole(id: string) {
|
||||||
|
const role = roles.value.find((item) => item.id === id)
|
||||||
|
if (!role) throw new Error('角色不存在')
|
||||||
|
if (role.builtin) throw new Error('内置角色不能删除')
|
||||||
|
if (members.value.some((member) => member.roleIds.includes(id))) throw new Error('该角色仍有成员使用,不能删除')
|
||||||
|
roles.value = roles.value.filter((item) => item.id !== id)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setRolePermissions(id: string, permissionCodes: string[]) {
|
||||||
|
const role = roles.value.find((item) => item.id === id)
|
||||||
|
if (!role) throw new Error('角色不存在')
|
||||||
|
if (role.builtin) throw new Error('内置管理员权限不能修改')
|
||||||
|
const backendCodes = new Set(permissionCodes.flatMap((code) => frontendToBackendPermissionCodes[code] ?? []))
|
||||||
|
const permissionIds = permissionResources.value
|
||||||
|
.filter((resource) => backendCodes.has(resource.permissionCode))
|
||||||
|
.map((resource) => resource.id)
|
||||||
|
await replaceMerchantRolePermissions(id, permissionIds)
|
||||||
|
role.permissionCodes = [...new Set(permissionCodes)]
|
||||||
|
}
|
||||||
|
|
||||||
|
function roleMemberCount(roleId: string) {
|
||||||
|
return members.value.filter((member) => member.roleIds.includes(roleId)).length
|
||||||
|
}
|
||||||
|
|
||||||
|
function roleNames(roleIds: readonly string[]) {
|
||||||
|
return roleIds.map((id) => roles.value.find((role) => role.id === id)?.name).filter((name): name is string => Boolean(name))
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
company,
|
||||||
|
qualifications,
|
||||||
|
members,
|
||||||
|
roles,
|
||||||
|
activeMemberCount,
|
||||||
|
rolesLoadedFromApi,
|
||||||
|
membersLoadedFromApi,
|
||||||
|
loadCompanyContext,
|
||||||
|
loadRoles,
|
||||||
|
loadMembers,
|
||||||
|
updateCompany,
|
||||||
|
setCompanyAuditStatusForPreview,
|
||||||
|
upsertQualification,
|
||||||
|
createMember,
|
||||||
|
updateMember,
|
||||||
|
toggleMemberStatus,
|
||||||
|
resetMemberPassword,
|
||||||
|
createRole,
|
||||||
|
updateRole,
|
||||||
|
toggleRoleStatus,
|
||||||
|
deleteRole,
|
||||||
|
setRolePermissions,
|
||||||
|
roleMemberCount,
|
||||||
|
roleNames,
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
:root { font-family: Inter, "PingFang SC", "Microsoft YaHei", sans-serif; color: #172033; background: #f5f7f8; font-synthesis: none; --primary: #075e54; --primary-dark: #064b44; --navy: #17324d; --border: #e1e7ea; --muted: #687780; }
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
html, body, #app { min-width: 320px; min-height: 100%; margin: 0; }
|
||||||
|
body { min-height: 100vh; background: #f5f7f8; }
|
||||||
|
button, input { font: inherit; }
|
||||||
|
a { color: inherit; }
|
||||||
|
|
||||||
|
.client-shell { min-height: 100vh; overflow-x: hidden; }
|
||||||
|
.top-header { position: fixed; inset: 0 0 auto 0; z-index: 20; display: flex; width: 100%; max-width: 100vw; height: 68px; align-items: center; padding: 0 28px; border-bottom: 1px solid var(--border); background: #fff; }
|
||||||
|
.client-brand { display: flex; width: 232px; min-width: 0; align-items: center; gap: 11px; color: var(--navy); text-decoration: none; }
|
||||||
|
.client-brand-mark, .auth-brand span { display: grid; width: 38px; height: 38px; place-items: center; border-radius: 8px; background: var(--primary); color: #fff; font-size: 13px; font-weight: 800; }
|
||||||
|
.client-brand > span:last-child { display: flex; flex-direction: column; }
|
||||||
|
.client-brand strong { font-size: 15px; }
|
||||||
|
.client-brand small { margin-top: 1px; color: var(--muted); font-size: 10px; }
|
||||||
|
.tenant-name { display: flex; flex: 1; align-items: center; gap: 8px; color: #475467; font-size: 13px; }
|
||||||
|
.client-user { display: flex; align-items: center; gap: 10px; }
|
||||||
|
.client-avatar { display: grid; width: 34px; height: 34px; place-items: center; border-radius: 50%; background: #e7f3f1; color: var(--primary); font-weight: 750; }
|
||||||
|
.client-user-copy { display: flex; flex-direction: column; }
|
||||||
|
.client-user-copy strong { font-size: 12px; }
|
||||||
|
.client-user-copy small { color: var(--muted); font-size: 10px; }
|
||||||
|
.client-sidebar { position: fixed; inset: 68px auto 0 0; display: flex; width: 232px; flex-direction: column; overflow: hidden; padding: 20px 14px; border-right: 1px solid var(--border); background: #fff; }
|
||||||
|
.client-sidebar nav { display: flex; flex: 1; min-width: 0; min-height: 0; flex-direction: column; overflow-x: hidden; overflow-y: auto; }
|
||||||
|
.client-nav-menu,
|
||||||
|
.client-nav-submenu { min-width: 0; margin: 0; padding: 0; list-style: none; }
|
||||||
|
.client-nav-menu { flex: 1; background: transparent; }
|
||||||
|
.client-nav-submenu { padding-left: 37px; }
|
||||||
|
.client-nav-node { min-width: 0; }
|
||||||
|
.client-sidebar-group-trigger,
|
||||||
|
.client-sidebar-link { display: flex; width: 100%; height: 44px; min-width: 0; align-items: center; gap: 11px; margin: 2px 0; padding: 0 10px; border-radius: 8px; color: #52616b; }
|
||||||
|
.client-sidebar-group-trigger { border: 0; background: transparent; cursor: pointer; text-align: left; }
|
||||||
|
.client-sidebar-link { text-decoration: none; }
|
||||||
|
.client-sidebar-group-trigger:hover,
|
||||||
|
.client-sidebar-link:hover { background: #f2f7f6; color: var(--primary); }
|
||||||
|
.client-sidebar-link[aria-current="page"] { background: #e9f4f2; color: var(--primary); font-weight: 650; }
|
||||||
|
.client-nav-node.is-active-parent > .client-sidebar-group-trigger { background: #f2f7f6; color: var(--primary); font-weight: 600; }
|
||||||
|
.client-sidebar .client-sidebar-group-trigger svg,
|
||||||
|
.client-sidebar .client-sidebar-link svg { flex: 0 0 18px; }
|
||||||
|
.client-sidebar .client-sidebar-group-chevron { width: 16px; height: 16px; flex: 0 0 16px; margin-left: auto; transition: transform .2s ease; }
|
||||||
|
.client-sidebar-group-trigger[aria-expanded="true"] .client-sidebar-group-chevron { transform: rotate(180deg); }
|
||||||
|
.client-sidebar-group-trigger span,
|
||||||
|
.client-sidebar-link span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.client-sidebar-group-trigger:focus-visible,
|
||||||
|
.client-sidebar-link:focus-visible,
|
||||||
|
.client-page-heading > button:focus-visible,
|
||||||
|
.client-sidebar-header button:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; }
|
||||||
|
.client-sidebar-header,
|
||||||
|
.client-sidebar-scrim { display: none; }
|
||||||
|
.sidebar-help { display: flex; flex: 0 0 auto; flex-direction: column; gap: 4px; margin-top: 16px; padding: 14px; border-left: 3px solid var(--primary); background: #f2f7f6; }
|
||||||
|
.sidebar-help strong { font-size: 12px; }
|
||||||
|
.sidebar-help span { color: var(--muted); font-size: 11px; }
|
||||||
|
.client-main { width: calc(100% - 232px); max-width: 100%; min-height: 100vh; margin-left: 232px; padding: 92px 28px 40px; }
|
||||||
|
.client-page-heading { display: flex; min-width: 0; align-items: center; gap: 12px; margin-bottom: 22px; }
|
||||||
|
.client-page-heading > div { min-width: 0; }
|
||||||
|
.client-page-heading button { display: none; width: 44px; height: 44px; flex: 0 0 44px; padding: 0; border: 0; border-radius: 8px; background: none; color: var(--navy); }
|
||||||
|
.client-page-heading h1 { margin: 0; font-size: 22px; }
|
||||||
|
.client-page-heading p { margin: 5px 0 0; color: var(--muted); font-size: 12px; }
|
||||||
|
|
||||||
|
.auth-page { min-height: 100vh; background: #eef3f3; }
|
||||||
|
.auth-header { display: flex; height: 72px; align-items: center; justify-content: space-between; padding: 0 clamp(24px, 6vw, 90px); border-bottom: 1px solid var(--border); background: #fff; color: var(--muted); font-size: 13px; }
|
||||||
|
.auth-brand { display: flex; align-items: center; gap: 10px; color: var(--navy); text-decoration: none; }
|
||||||
|
.auth-header a { color: var(--primary); text-decoration: none; }
|
||||||
|
.auth-main { display: grid; min-height: calc(100vh - 72px); grid-template-columns: 1fr 440px; align-items: center; gap: clamp(60px, 10vw, 150px); width: min(1180px, calc(100% - 48px)); margin: 0 auto; padding: 50px 0; }
|
||||||
|
.auth-kicker, .section-label { color: var(--primary); font-size: 11px; font-weight: 750; letter-spacing: .13em; }
|
||||||
|
.auth-copy h1 { margin: 15px 0 20px; color: var(--navy); font-size: clamp(38px, 4.5vw, 62px); line-height: 1.14; letter-spacing: -.04em; }
|
||||||
|
.auth-copy > p { max-width: 520px; color: var(--muted); font-size: 16px; line-height: 1.8; }
|
||||||
|
.auth-facts { display: flex; gap: 8px; margin-top: 32px; flex-wrap: wrap; }
|
||||||
|
.auth-facts span { padding: 8px 12px; border: 1px solid #cbdad7; background: rgba(255,255,255,.7); color: #40534f; font-size: 12px; }
|
||||||
|
.auth-card { padding: 38px; border: 1px solid var(--border); background: #fff; box-shadow: 0 14px 36px rgba(23,50,77,.08); }
|
||||||
|
.auth-card h2 { margin: 0 0 8px; font-size: 26px; }
|
||||||
|
.auth-card > p { margin: 0 0 28px; color: var(--muted); }
|
||||||
|
.auth-form-options { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin: -2px 0 18px; }
|
||||||
|
.auth-form-options button { min-height: 44px; padding: 0; border: 0; background: transparent; color: var(--primary); cursor: pointer; }
|
||||||
|
.auth-form-options button:hover { text-decoration: underline; }
|
||||||
|
.auth-form-options button:focus-visible { outline: 2px solid var(--primary); outline-offset: 3px; }
|
||||||
|
.auth-submit { width: 100%; }
|
||||||
|
.auth-submit span { display: flex; align-items: center; gap: 7px; }
|
||||||
|
.demo-tip { display: flex; flex-direction: column; gap: 3px; margin-top: 18px; padding: 10px 12px; background: #f2f7f6; color: var(--muted); font-size: 12px; line-height: 1.55; }
|
||||||
|
.demo-tip strong { color: #40534f; }
|
||||||
|
|
||||||
|
.register-page { min-height: 100vh; background: #f5f7f8; }
|
||||||
|
.register-header { display: flex; height: 68px; align-items: center; justify-content: space-between; padding: 0 clamp(20px, 5vw, 72px); border-bottom: 1px solid var(--border); background: #fff; }
|
||||||
|
.register-header > a { display: flex; align-items: center; gap: 6px; color: var(--muted); font-size: 13px; text-decoration: none; }
|
||||||
|
.register-main { width: min(920px, calc(100% - 32px)); margin: 36px auto; padding: 34px 38px; border: 1px solid var(--border); background: #fff; }
|
||||||
|
.register-title { display: flex; align-items: center; gap: 14px; padding-bottom: 24px; border-bottom: 1px solid var(--border); }
|
||||||
|
.register-title > div { display: grid; width: 44px; height: 44px; place-items: center; background: #e7f3f1; color: var(--primary); }
|
||||||
|
.register-title h1 { margin: 0 0 5px; font-size: 23px; }
|
||||||
|
.register-title p { margin: 0; color: var(--muted); font-size: 12px; }
|
||||||
|
.register-form { margin-top: 28px; }
|
||||||
|
.form-grid { display: grid; grid-template-columns: 1fr 1fr; column-gap: 28px; }
|
||||||
|
.agreement-item { margin-bottom: 8px; }
|
||||||
|
.agreement-control { display: flex; min-height: 44px; align-items: center; gap: 4px; flex-wrap: wrap; color: var(--muted); }
|
||||||
|
.agreement-item .el-checkbox { min-height: 44px; white-space: normal; }
|
||||||
|
.agreement-item button { padding: 0; border: 0; background: transparent; color: var(--primary); cursor: pointer; }
|
||||||
|
.agreement-item button:hover { text-decoration: underline; }
|
||||||
|
.agreement-item button:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; }
|
||||||
|
.register-security-note { margin: 0; color: var(--muted); font-size: 12px; line-height: 1.6; }
|
||||||
|
.register-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 28px; padding-top: 20px; border-top: 1px solid var(--border); }
|
||||||
|
.registration-result { display: flex; min-height: 470px; align-items: center; justify-content: center; flex-direction: column; color: var(--primary); text-align: center; }
|
||||||
|
.registration-result h1 { margin: 18px 0 8px; color: var(--navy); font-size: 26px; }
|
||||||
|
.registration-result > p { max-width: 600px; margin: 0; color: var(--muted); line-height: 1.7; overflow-wrap: anywhere; }
|
||||||
|
.registration-result > p strong { color: var(--navy); }
|
||||||
|
.registration-result dl { display: grid; width: min(620px, 100%); grid-template-columns: repeat(2, minmax(0, 1fr)); margin: 28px 0 0; border: 1px solid var(--border); color: var(--navy); text-align: left; }
|
||||||
|
.registration-result dl div { min-width: 0; padding: 14px 16px; border-bottom: 1px solid var(--border); }
|
||||||
|
.registration-result dl div:nth-child(odd) { border-right: 1px solid var(--border); }
|
||||||
|
.registration-result dt { color: var(--muted); font-size: 11px; }
|
||||||
|
.registration-result dd { margin: 5px 0 0; overflow-wrap: anywhere; font-size: 13px; font-weight: 650; }
|
||||||
|
.registration-next-step { display: flex; width: min(620px, 100%); flex-direction: column; gap: 5px; margin-top: 18px; padding: 14px 16px; border: 1px solid #d9cfad; background: #fff9e9; color: var(--muted); font-size: 12px; line-height: 1.65; text-align: left; }
|
||||||
|
.registration-next-step strong { color: var(--navy); font-size: 13px; }
|
||||||
|
.registration-result-actions { display: flex; justify-content: center; gap: 10px; margin-top: 28px; }
|
||||||
|
|
||||||
|
.client-dashboard { display: flex; flex-direction: column; gap: 18px; }
|
||||||
|
.quick-order-band { display: flex; align-items: center; justify-content: space-between; gap: 30px; padding: 26px 28px; border-left: 4px solid var(--primary); background: var(--navy); color: #fff; }
|
||||||
|
.quick-order-band span { color: #84c3ba; font-size: 11px; font-weight: 700; }
|
||||||
|
.quick-order-band h2 { margin: 6px 0; font-size: 22px; }
|
||||||
|
.quick-order-band p { margin: 0; color: #c6d0db; font-size: 13px; }
|
||||||
|
.client-stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; }
|
||||||
|
.client-stats article { display: flex; align-items: center; gap: 14px; padding: 20px; border: 1px solid var(--border); background: #fff; color: var(--primary); }
|
||||||
|
.client-stats article div { display: flex; flex-direction: column; gap: 3px; }
|
||||||
|
.client-stats span { color: var(--muted); font-size: 12px; }
|
||||||
|
.client-stats strong { color: var(--navy); font-size: 24px; }
|
||||||
|
.client-content-grid { display: grid; grid-template-columns: 1.35fr .65fr; gap: 18px; }
|
||||||
|
.client-card { padding: 22px; border: 1px solid var(--border); background: #fff; box-shadow: 0 3px 12px rgba(23,50,77,.035); }
|
||||||
|
.client-card header { display: flex; align-items: center; justify-content: space-between; }
|
||||||
|
.client-card h3 { margin: 5px 0 14px; font-size: 17px; }
|
||||||
|
.client-empty { display: grid; min-height: 190px; place-items: center; border-top: 1px solid var(--border); color: #98a2a9; font-size: 13px; text-align: center; }
|
||||||
|
.recent-orders { border-top: 1px solid var(--border); }
|
||||||
|
.recent-orders button { display: flex; width: 100%; min-height: 62px; align-items: center; justify-content: space-between; gap: 12px; padding: 10px 0; border: 0; border-bottom: 1px solid var(--border); background: transparent; cursor: pointer; text-align: left; }
|
||||||
|
.recent-orders button:hover { color: var(--primary); }
|
||||||
|
.recent-orders button:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; }
|
||||||
|
.recent-orders button > span { display: flex; min-width: 0; flex-direction: column; gap: 4px; }
|
||||||
|
.recent-orders strong { font-size: 13px; }
|
||||||
|
.recent-orders small { overflow: hidden; color: var(--muted); text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.profile-summary { margin: 0 0 20px; }
|
||||||
|
.profile-summary div { display: flex; justify-content: space-between; padding: 12px 0; border-top: 1px solid var(--border); font-size: 12px; }
|
||||||
|
.profile-summary dt { color: var(--muted); }
|
||||||
|
.profile-summary dd { margin: 0; font-weight: 600; }
|
||||||
|
.module-stack { display: flex; flex-direction: column; gap: 14px; }
|
||||||
|
.module-actions { display: flex; justify-content: flex-end; gap: 10px; }
|
||||||
|
.module-actions .el-input { width: 280px; }
|
||||||
|
.module-empty { display: flex; min-height: 430px; align-items: center; justify-content: center; flex-direction: column; text-align: center; }
|
||||||
|
.module-empty-icon { display: grid; width: 56px; height: 56px; place-items: center; margin-bottom: 17px; background: #eaf2f1; color: var(--primary); }
|
||||||
|
.module-empty h2 { margin: 0 0 7px; font-size: 20px; }
|
||||||
|
.module-empty p { max-width: 540px; margin: 0 0 18px; color: var(--muted); }
|
||||||
|
|
||||||
|
.el-button--primary { --el-button-bg-color: var(--primary); --el-button-border-color: var(--primary); --el-button-hover-bg-color: var(--primary-dark); --el-button-hover-border-color: var(--primary-dark); }
|
||||||
|
|
||||||
|
@media (max-width: 960px) {
|
||||||
|
.auth-main { grid-template-columns: 1fr; width: min(520px, calc(100% - 32px)); }
|
||||||
|
.auth-copy { display: none; }
|
||||||
|
.client-content-grid { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.top-header { padding: 0 14px; }
|
||||||
|
.client-sidebar { display: flex; width: min(286px, 86vw); padding: 0 14px 18px; visibility: hidden; pointer-events: none; transform: translateX(-100%); transition: transform .2s ease, visibility 0s linear .2s; z-index: 19; }
|
||||||
|
.mobile-menu-open .client-sidebar { visibility: visible; pointer-events: auto; transform: translateX(0); transition-delay: 0s; }
|
||||||
|
.client-sidebar-scrim { position: fixed; inset: 68px 0 0; z-index: 18; display: block; padding: 0; border: 0; background: rgba(23, 50, 77, .48); }
|
||||||
|
.client-sidebar-header { display: flex; height: 52px; flex: 0 0 52px; align-items: center; justify-content: space-between; color: var(--navy); }
|
||||||
|
.client-sidebar-header strong { font-size: 13px; }
|
||||||
|
.client-sidebar-header button { display: grid; width: 44px; height: 44px; padding: 0; border: 0; border-radius: 8px; background: transparent; color: var(--navy); place-items: center; }
|
||||||
|
.client-main { width: 100%; max-width: 100%; margin-left: 0; padding: 88px 14px 30px; }
|
||||||
|
.client-brand { width: auto; }
|
||||||
|
.tenant-name, .client-user-copy { display: none; }
|
||||||
|
.client-user { margin-left: auto; }
|
||||||
|
.client-page-heading > button { display: grid; width: 44px; height: 44px; place-items: center; }
|
||||||
|
.client-stats { grid-template-columns: 1fr; }
|
||||||
|
.quick-order-band { align-items: flex-start; flex-direction: column; }
|
||||||
|
.form-grid { grid-template-columns: 1fr; }
|
||||||
|
.auth-header { padding: 0 18px; }
|
||||||
|
.auth-header > div:last-child { display: none; }
|
||||||
|
.auth-card, .register-main { padding: 26px 20px; }
|
||||||
|
.registration-result dl { grid-template-columns: 1fr; }
|
||||||
|
.registration-result dl div:nth-child(odd) { border-right: 0; }
|
||||||
|
.registration-result-actions { width: 100%; }
|
||||||
|
.registration-result-actions .el-button { flex: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.client-sidebar,
|
||||||
|
.client-sidebar-group-chevron { transition: none; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { readFileSync } from 'node:fs'
|
||||||
|
import test from 'node:test'
|
||||||
|
|
||||||
|
const styles = readFileSync(new URL('./index.css', import.meta.url), 'utf8')
|
||||||
|
const compact = styles.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\s+/g, ' ')
|
||||||
|
const mobileStyles = compact.slice(compact.indexOf('@media (max-width: 720px)'))
|
||||||
|
|
||||||
|
test('style source contract: constrains the desktop sidebar and gives the menu its own scroll region', () => {
|
||||||
|
assert.match(compact, /\.client-shell\s*\{[^}]*overflow-x:\s*hidden/)
|
||||||
|
assert.match(compact, /\.client-sidebar\s*\{[^}]*overflow:\s*hidden/)
|
||||||
|
assert.match(compact, /\.client-sidebar nav\s*\{[^}]*flex:\s*1[^}]*min-height:\s*0[^}]*overflow-y:\s*auto/)
|
||||||
|
assert.match(compact, /\.client-nav-menu,[^\{]*\.client-nav-submenu\s*\{[^}]*min-width:\s*0/)
|
||||||
|
assert.match(compact, /\.client-nav-menu\s*\{[^}]*flex:\s*1[^}]*background:\s*transparent/)
|
||||||
|
assert.match(compact, /\.client-sidebar-header,\s*\.client-sidebar-scrim\s*\{[^}]*display:\s*none/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('style source contract: presents native grouped rows with restrained active states and truncation', () => {
|
||||||
|
assert.doesNotMatch(compact, /\.client-sidebar \.el-menu|\.el-menu-item|\.el-sub-menu/)
|
||||||
|
assert.match(compact, /\.client-nav-menu,[^\{]*\.client-nav-submenu\s*\{[^}]*margin:\s*0[^}]*padding:\s*0[^}]*list-style:\s*none/)
|
||||||
|
assert.match(compact, /\.client-sidebar-group-trigger,[^\{]*\.client-sidebar-link\s*\{[^}]*height:\s*44px[^}]*border-radius:\s*8px/)
|
||||||
|
assert.match(compact, /\.client-sidebar-link\[aria-current="page"\]\s*\{[^}]*background:\s*#e9f4f2[^}]*color:\s*var\(--primary\)/)
|
||||||
|
assert.match(compact, /\.client-nav-node\.is-active-parent > \.client-sidebar-group-trigger\s*\{[^}]*background:\s*#f2f7f6[^}]*color:\s*var\(--primary\)/)
|
||||||
|
assert.match(compact, /\.client-nav-submenu\s*\{[^}]*padding-left:\s*3[6-8]px/)
|
||||||
|
assert.match(compact, /\.client-sidebar-group-trigger\s*\{[^}]*border:\s*0[^}]*background:\s*transparent/)
|
||||||
|
assert.match(compact, /\.client-sidebar-link\s*\{[^}]*text-decoration:\s*none/)
|
||||||
|
assert.match(compact, /\.client-sidebar (?:\.client-sidebar-group-trigger|\.client-sidebar-link) svg[^\{]*\{[^}]*flex:\s*0 0 18px/)
|
||||||
|
assert.match(compact, /\.client-sidebar-group-trigger span,[^\{]*\.client-sidebar-link span\s*\{[^}]*min-width:\s*0[^}]*overflow:\s*hidden[^}]*text-overflow:\s*ellipsis[^}]*white-space:\s*nowrap/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('style source contract: makes navigation and drawer controls touch sized with visible keyboard focus', () => {
|
||||||
|
assert.match(compact, /\.client-sidebar-group-trigger,[^\{]*\.client-sidebar-link\s*\{[^}]*height:\s*44px/)
|
||||||
|
assert.match(compact, /\.client-sidebar-group-trigger:focus-visible,[^\{]*\.client-sidebar-link:focus-visible,[^\{]*\.client-page-heading > button:focus-visible,[^\{]*\.client-sidebar-header button:focus-visible\s*\{[^}]*outline:\s*2px solid var\(--primary\)[^}]*outline-offset:\s*2px/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('style source contract: turns the sidebar into a layered drawer at 720px and below', () => {
|
||||||
|
assert.doesNotMatch(mobileStyles, /\.client-sidebar\s*\{[^}]*display:\s*none/)
|
||||||
|
assert.match(mobileStyles, /\.client-sidebar\s*\{[^}]*display:\s*flex[^}]*width:\s*min\(286px,\s*86vw\)[^}]*visibility:\s*hidden[^}]*pointer-events:\s*none[^}]*transform:\s*translateX\(-100%\)[^}]*transition:\s*transform \.2s ease, visibility 0s linear \.2s[^}]*z-index:\s*19/)
|
||||||
|
assert.match(mobileStyles, /\.mobile-menu-open \.client-sidebar\s*\{[^}]*visibility:\s*visible[^}]*pointer-events:\s*auto[^}]*transform:\s*translateX\(0\)[^}]*transition-delay:\s*0s/)
|
||||||
|
assert.match(mobileStyles, /\.client-sidebar-scrim\s*\{[^}]*position:\s*fixed[^}]*inset:\s*68px 0 0[^}]*z-index:\s*18[^}]*background:\s*rgba\(23,\s*50,\s*77,\s*\.48\)/)
|
||||||
|
assert.match(mobileStyles, /\.client-sidebar-header\s*\{[^}]*display:\s*flex[^}]*height:\s*52px/)
|
||||||
|
assert.match(mobileStyles, /\.client-main\s*\{[^}]*max-width:\s*100%[^}]*margin-left:\s*0/)
|
||||||
|
assert.match(mobileStyles, /\.client-page-heading > button\s*\{[^}]*display:\s*grid[^}]*width:\s*44px[^}]*height:\s*44px/)
|
||||||
|
assert.match(mobileStyles, /\.client-user\s*\{[^}]*margin-left:\s*auto/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('style source contract: removes drawer motion when reduced motion is preferred', () => {
|
||||||
|
assert.match(compact, /@media \(prefers-reduced-motion: reduce\)\s*\{\s*\.client-sidebar,[^\{]*\.client-sidebar-group-chevron\s*\{[^}]*transition:\s*none/)
|
||||||
|
})
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import type {
|
||||||
|
FirstLegOrderStatus,
|
||||||
|
FirstLegTransportMode,
|
||||||
|
PlatformReceivingWarehouseSnapshot,
|
||||||
|
} from '@chongchong/shared'
|
||||||
|
import type { FirstLegPartySnapshot } from '../data/firstLegParties.ts'
|
||||||
|
|
||||||
|
export type FirstLegBusinessType = 'EXPORT' | 'IMPORT'
|
||||||
|
export type FirstLegPriority = 'NORMAL' | 'URGENT' | 'EXPEDITED'
|
||||||
|
|
||||||
|
export interface FirstLegCargoItem {
|
||||||
|
id: string
|
||||||
|
sku: string
|
||||||
|
cargoNameCn: string
|
||||||
|
cargoNameEn: string
|
||||||
|
pieces: number
|
||||||
|
packageCount: number
|
||||||
|
grossWeightKg: number
|
||||||
|
volumeCbm: number
|
||||||
|
containerType: string
|
||||||
|
containerCount: number
|
||||||
|
remark: string
|
||||||
|
packageType: string
|
||||||
|
hsCode: string
|
||||||
|
declaredValue: number
|
||||||
|
currency: string
|
||||||
|
dangerousGoods: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FirstLegOrderTimelineItem {
|
||||||
|
id: string
|
||||||
|
event: 'DRAFT_SAVED' | 'SUBMITTED' | 'CANCELLED' | 'ACCEPTED' | 'BOOKED' | 'IN_TRANSIT' | 'COMPLETED'
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
operatorName: string
|
||||||
|
occurredAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FirstLegOrder {
|
||||||
|
id: string
|
||||||
|
orderNo: string | null
|
||||||
|
referenceNo: string
|
||||||
|
transportMode: FirstLegTransportMode
|
||||||
|
businessType: FirstLegBusinessType
|
||||||
|
origin: string
|
||||||
|
destination: string
|
||||||
|
receivingWarehouseId: string
|
||||||
|
receivingWarehouseName: string
|
||||||
|
receivingWarehouseSnapshot: PlatformReceivingWarehouseSnapshot | null
|
||||||
|
expectedDepartureDate: string
|
||||||
|
etd: string
|
||||||
|
eta: string
|
||||||
|
shipperPartyId: string
|
||||||
|
shipperPartySnapshot: FirstLegPartySnapshot | null
|
||||||
|
shipper: string
|
||||||
|
consigneePartyId: string
|
||||||
|
consigneePartySnapshot: FirstLegPartySnapshot | null
|
||||||
|
consignee: string
|
||||||
|
expressCompanyName: string
|
||||||
|
trackingNumber: string
|
||||||
|
priority: FirstLegPriority
|
||||||
|
customerRemark: string
|
||||||
|
operatorName: string
|
||||||
|
status: FirstLegOrderStatus
|
||||||
|
version: number
|
||||||
|
items: FirstLegCargoItem[]
|
||||||
|
timeline: FirstLegOrderTimelineItem[]
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
tradeTerm: string
|
||||||
|
destinationCountryCode: string
|
||||||
|
destinationWarehouseName: string
|
||||||
|
pickupMethod: 'SELF_EXPRESS' | 'PICKUP' | 'SELF_TRUCK'
|
||||||
|
channelCode: string
|
||||||
|
channelName: string
|
||||||
|
taxMode: 'BUY_DECLARATION' | 'TAX_REFUND'
|
||||||
|
customsInspection: boolean
|
||||||
|
destinationType: 'FBA' | 'OVERSEAS' | 'PRIVATE'
|
||||||
|
amazonWarehouseCode: string
|
||||||
|
shipmentId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FirstLegOrderDraft = Pick<
|
||||||
|
FirstLegOrder,
|
||||||
|
| 'referenceNo'
|
||||||
|
| 'transportMode'
|
||||||
|
| 'businessType'
|
||||||
|
| 'origin'
|
||||||
|
| 'destination'
|
||||||
|
| 'receivingWarehouseId'
|
||||||
|
| 'expectedDepartureDate'
|
||||||
|
| 'shipperPartyId'
|
||||||
|
| 'shipperPartySnapshot'
|
||||||
|
| 'shipper'
|
||||||
|
| 'consigneePartyId'
|
||||||
|
| 'consigneePartySnapshot'
|
||||||
|
| 'consignee'
|
||||||
|
| 'priority'
|
||||||
|
| 'customerRemark'
|
||||||
|
| 'items'
|
||||||
|
| 'tradeTerm'
|
||||||
|
| 'destinationCountryCode'
|
||||||
|
| 'destinationWarehouseName'
|
||||||
|
| 'pickupMethod'
|
||||||
|
| 'channelCode'
|
||||||
|
| 'channelName'
|
||||||
|
| 'taxMode'
|
||||||
|
| 'customsInspection'
|
||||||
|
| 'destinationType'
|
||||||
|
| 'amazonWarehouseCode'
|
||||||
|
| 'shipmentId'
|
||||||
|
>
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import test from 'node:test'
|
||||||
|
import {
|
||||||
|
getPasswordStrength,
|
||||||
|
validateEditableAccountProfile,
|
||||||
|
validatePasswordChange,
|
||||||
|
} from './accountProfile.ts'
|
||||||
|
|
||||||
|
test('accepts a complete editable account profile', () => {
|
||||||
|
assert.deepEqual(validateEditableAccountProfile({
|
||||||
|
realName: '企业管理员',
|
||||||
|
phone: '13800006601',
|
||||||
|
email: 'admin@example.com',
|
||||||
|
}), [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('reports invalid profile fields together', () => {
|
||||||
|
const errors = validateEditableAccountProfile({ realName: '', phone: '1'.repeat(33), email: 'bad' })
|
||||||
|
assert.equal(errors.length, 3)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('validates password length, equality and confirmation', () => {
|
||||||
|
assert.deepEqual(validatePasswordChange({
|
||||||
|
currentPassword: 'OldPass123',
|
||||||
|
newPassword: 'NewPass456',
|
||||||
|
confirmPassword: 'NewPass456',
|
||||||
|
}), [])
|
||||||
|
assert.equal(validatePasswordChange({ currentPassword: '', newPassword: '123', confirmPassword: '456' }).length, 3)
|
||||||
|
assert.match(validatePasswordChange({ currentPassword: 'SamePass1', newPassword: 'SamePass1', confirmPassword: 'SamePass1' }).join('|'), /不能与当前密码相同/)
|
||||||
|
assert.match(validatePasswordChange({ currentPassword: 'OldPass1', newPassword: 'abcdefgh', confirmPassword: 'abcdefgh' }).join('|'), /同时包含字母和数字/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('grades password strength without making extra complexity mandatory', () => {
|
||||||
|
assert.equal(getPasswordStrength('short'), 'weak')
|
||||||
|
assert.equal(getPasswordStrength('Password1'), 'medium')
|
||||||
|
assert.equal(getPasswordStrength('Stronger@2026'), 'strong')
|
||||||
|
})
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
export interface EditableAccountProfile {
|
||||||
|
realName: string
|
||||||
|
phone: string
|
||||||
|
email: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PasswordChangePayload {
|
||||||
|
currentPassword: string
|
||||||
|
newPassword: string
|
||||||
|
confirmPassword: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||||
|
|
||||||
|
export function validateEditableAccountProfile(profile: EditableAccountProfile): string[] {
|
||||||
|
const errors: string[] = []
|
||||||
|
const realName = profile.realName.trim()
|
||||||
|
const phone = profile.phone.trim()
|
||||||
|
const email = profile.email.trim()
|
||||||
|
|
||||||
|
if (realName.length < 1 || realName.length > 64) errors.push('姓名需为 1–64 个字符')
|
||||||
|
if (phone.length > 32) errors.push('联系电话不能超过 32 个字符')
|
||||||
|
if (email && !EMAIL_PATTERN.test(email)) errors.push('请输入有效的邮箱地址')
|
||||||
|
|
||||||
|
return errors
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validatePasswordChange(payload: PasswordChangePayload): string[] {
|
||||||
|
const errors: string[] = []
|
||||||
|
|
||||||
|
if (!payload.currentPassword) errors.push('请输入当前密码')
|
||||||
|
if (payload.newPassword.length < 8 || payload.newPassword.length > 64) {
|
||||||
|
errors.push('新密码需为 8–64 个字符')
|
||||||
|
}
|
||||||
|
if (payload.newPassword.length >= 8 && (!/[a-zA-Z]/.test(payload.newPassword) || !/\d/.test(payload.newPassword))) {
|
||||||
|
errors.push('新密码必须同时包含字母和数字')
|
||||||
|
}
|
||||||
|
if (payload.newPassword && payload.newPassword === payload.currentPassword) {
|
||||||
|
errors.push('新密码不能与当前密码相同')
|
||||||
|
}
|
||||||
|
if (payload.confirmPassword !== payload.newPassword) errors.push('两次输入的新密码不一致')
|
||||||
|
|
||||||
|
return errors
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PasswordStrength = 'weak' | 'medium' | 'strong'
|
||||||
|
|
||||||
|
export function getPasswordStrength(password: string): PasswordStrength {
|
||||||
|
let categoryCount = 0
|
||||||
|
if (/[a-zA-Z]/.test(password)) categoryCount += 1
|
||||||
|
if (/\d/.test(password)) categoryCount += 1
|
||||||
|
if (/[^a-zA-Z\d]/.test(password)) categoryCount += 1
|
||||||
|
|
||||||
|
if (password.length >= 12 && categoryCount >= 3) return 'strong'
|
||||||
|
if (password.length >= 8 && categoryCount >= 2) return 'medium'
|
||||||
|
return 'weak'
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import test from 'node:test'
|
||||||
|
import { createFirstLegPartySnapshot, DEMO_FIRST_LEG_PARTIES } from '../data/firstLegParties.ts'
|
||||||
|
import {
|
||||||
|
canClientCancelFirstLegOrder,
|
||||||
|
canClientEditFirstLegOrder,
|
||||||
|
createEmptyCargoItem,
|
||||||
|
getFirstLegStatusLabel,
|
||||||
|
getFirstLegTransportModeLabel,
|
||||||
|
summarizeFirstLegCargo,
|
||||||
|
validateFirstLegOrderDraft,
|
||||||
|
} from './firstLegOrder.ts'
|
||||||
|
|
||||||
|
function validDraft() {
|
||||||
|
return {
|
||||||
|
referenceNo: 'REF-001',
|
||||||
|
transportMode: 'FCL',
|
||||||
|
businessType: 'EXPORT',
|
||||||
|
origin: '宁波',
|
||||||
|
destination: '洛杉矶',
|
||||||
|
receivingWarehouseId: 'WH005',
|
||||||
|
expectedDepartureDate: '2026-08-08',
|
||||||
|
shipperPartyId: 'CP001',
|
||||||
|
shipperPartySnapshot: createFirstLegPartySnapshot(DEMO_FIRST_LEG_PARTIES[0], 'SHIPPER'),
|
||||||
|
shipper: '示例贸易有限公司',
|
||||||
|
consigneePartyId: 'CP003',
|
||||||
|
consigneePartySnapshot: createFirstLegPartySnapshot(DEMO_FIRST_LEG_PARTIES[2], 'CONSIGNEE'),
|
||||||
|
consignee: 'DEMO GLOBAL LLC',
|
||||||
|
priority: 'NORMAL',
|
||||||
|
customerRemark: '',
|
||||||
|
tradeTerm: 'FOB',
|
||||||
|
destinationCountryCode: 'US',
|
||||||
|
destinationWarehouseName: 'ONT8',
|
||||||
|
pickupMethod: 'SELF_EXPRESS',
|
||||||
|
channelCode: 'SEA-US-FBA',
|
||||||
|
channelName: '美国海运渠道',
|
||||||
|
taxMode: 'BUY_DECLARATION',
|
||||||
|
customsInspection: false,
|
||||||
|
destinationType: 'FBA',
|
||||||
|
amazonWarehouseCode: 'ONT8',
|
||||||
|
shipmentId: 'FBA-001',
|
||||||
|
items: [{ ...createEmptyCargoItem('cargo-1'), cargoNameCn: '家居用品', pieces: 100, grossWeightKg: 1200, volumeCbm: 3.5, containerType: '40HQ', containerCount: 1 }],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test('uses the confirmed status and transport labels', () => {
|
||||||
|
assert.equal(getFirstLegStatusLabel('BOOKED'), '已订舱')
|
||||||
|
assert.equal(getFirstLegStatusLabel('IN_TRANSIT'), '运输中')
|
||||||
|
assert.equal(getFirstLegTransportModeLabel('FCL'), '海运整箱 FCL')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('only allows draft orders to be edited in the confirmed phase-one flow', () => {
|
||||||
|
assert.equal(canClientEditFirstLegOrder('DRAFT'), true)
|
||||||
|
assert.equal(canClientEditFirstLegOrder('PENDING_REVIEW'), false)
|
||||||
|
assert.equal(canClientCancelFirstLegOrder('DRAFT'), true)
|
||||||
|
assert.equal(canClientCancelFirstLegOrder('PENDING_REVIEW'), true)
|
||||||
|
assert.equal(canClientCancelFirstLegOrder('ACCEPTED'), false)
|
||||||
|
assert.equal(canClientCancelFirstLegOrder('BOOKED'), false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('validates the confirmed minimum submission fields', () => {
|
||||||
|
assert.deepEqual(validateFirstLegOrderDraft(validDraft()), [])
|
||||||
|
|
||||||
|
const invalid = validDraft()
|
||||||
|
invalid.origin = ''
|
||||||
|
invalid.receivingWarehouseId = ''
|
||||||
|
invalid.items[0].grossWeightKg = 0
|
||||||
|
invalid.items[0].containerType = ''
|
||||||
|
invalid.items[0].containerCount = 0
|
||||||
|
|
||||||
|
const errors = validateFirstLegOrderDraft(invalid)
|
||||||
|
assert.ok(errors.includes('请填写起运地'))
|
||||||
|
assert.ok(errors.includes('请选择平台收货仓'))
|
||||||
|
assert.ok(errors.includes('第 1 条货物毛重必须大于 0'))
|
||||||
|
assert.ok(errors.includes('第 1 条货物需要填写箱型和柜量'))
|
||||||
|
})
|
||||||
|
|
||||||
|
test('requires enabled-party snapshots and order addresses before submission', () => {
|
||||||
|
const draft = validDraft()
|
||||||
|
draft.shipperPartyId = ''
|
||||||
|
draft.shipperPartySnapshot = null
|
||||||
|
draft.consigneePartySnapshot.address = ''
|
||||||
|
|
||||||
|
const errors = validateFirstLegOrderDraft(draft)
|
||||||
|
assert.ok(errors.includes('请选择发货单位'))
|
||||||
|
assert.ok(errors.includes('请填写本单收货地址'))
|
||||||
|
})
|
||||||
|
|
||||||
|
test('does not require container details for non-FCL cargo', () => {
|
||||||
|
const draft = validDraft()
|
||||||
|
draft.transportMode = 'AIR'
|
||||||
|
draft.items[0].containerType = ''
|
||||||
|
draft.items[0].containerCount = 0
|
||||||
|
|
||||||
|
assert.deepEqual(validateFirstLegOrderDraft(draft), [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('summarizes cargo values from line items', () => {
|
||||||
|
const first = validDraft().items[0]
|
||||||
|
const second = { ...first, id: 'cargo-2', pieces: 40, packageCount: 4, grossWeightKg: 300, volumeCbm: 1.25 }
|
||||||
|
|
||||||
|
assert.deepEqual(summarizeFirstLegCargo([first, second]), {
|
||||||
|
pieces: 140,
|
||||||
|
packageCount: 5,
|
||||||
|
grossWeightKg: 1500,
|
||||||
|
volumeCbm: 4.75,
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import {
|
||||||
|
FIRST_LEG_ORDER_STATUSES,
|
||||||
|
FIRST_LEG_TRANSPORT_MODES,
|
||||||
|
type FirstLegOrderStatus,
|
||||||
|
type FirstLegTransportMode,
|
||||||
|
} from '@chongchong/shared'
|
||||||
|
import type { FirstLegCargoItem, FirstLegOrderDraft } from '../types/firstLegOrder.ts'
|
||||||
|
|
||||||
|
const statusLabels = new Map<FirstLegOrderStatus, string>(
|
||||||
|
FIRST_LEG_ORDER_STATUSES.map((item) => [item.code, item.label]),
|
||||||
|
)
|
||||||
|
|
||||||
|
const transportModeLabels = new Map<FirstLegTransportMode, string>(
|
||||||
|
FIRST_LEG_TRANSPORT_MODES.map((item) => [item.code, item.label]),
|
||||||
|
)
|
||||||
|
|
||||||
|
export function getFirstLegStatusLabel(status: FirstLegOrderStatus) {
|
||||||
|
return statusLabels.get(status) ?? status
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFirstLegTransportModeLabel(mode: FirstLegTransportMode) {
|
||||||
|
return transportModeLabels.get(mode) ?? mode
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFirstLegStatusTone(status: FirstLegOrderStatus): 'success' | 'warning' | 'info' | 'danger' {
|
||||||
|
if (status === 'COMPLETED') return 'success'
|
||||||
|
if (status === 'DRAFT' || status === 'PENDING_REVIEW') return 'warning'
|
||||||
|
if (status === 'CANCELLED') return 'danger'
|
||||||
|
return 'info'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canClientEditFirstLegOrder(status: FirstLegOrderStatus) {
|
||||||
|
return status === 'DRAFT'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canClientCancelFirstLegOrder(status: FirstLegOrderStatus) {
|
||||||
|
return status === 'DRAFT' || status === 'PENDING_REVIEW'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createEmptyCargoItem(id = `cargo-${Date.now()}`): FirstLegCargoItem {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
sku: '',
|
||||||
|
cargoNameCn: '',
|
||||||
|
cargoNameEn: '',
|
||||||
|
pieces: 1,
|
||||||
|
packageCount: 1,
|
||||||
|
grossWeightKg: 0,
|
||||||
|
volumeCbm: 0,
|
||||||
|
containerType: '',
|
||||||
|
containerCount: 0,
|
||||||
|
remark: '',
|
||||||
|
packageType: 'CARTON',
|
||||||
|
hsCode: '',
|
||||||
|
declaredValue: 0,
|
||||||
|
currency: 'USD',
|
||||||
|
dangerousGoods: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateFirstLegOrderDraft(draft: FirstLegOrderDraft) {
|
||||||
|
const errors: string[] = []
|
||||||
|
|
||||||
|
if (!draft.origin.trim()) errors.push('请填写起运地')
|
||||||
|
if (!draft.destination.trim()) errors.push('请填写目的地')
|
||||||
|
if (!draft.destinationCountryCode.trim()) errors.push('请填写目的国两位代码')
|
||||||
|
if (!draft.destinationWarehouseName.trim()) errors.push('请填写目的仓名称')
|
||||||
|
if (!draft.channelCode.trim() || !draft.channelName.trim()) errors.push('请填写渠道编码和渠道名称')
|
||||||
|
if (!draft.receivingWarehouseId) errors.push('请选择平台收货仓')
|
||||||
|
if (!draft.shipperPartyId || !draft.shipperPartySnapshot || !draft.shipper.trim()) errors.push('请选择发货单位')
|
||||||
|
if (draft.shipperPartySnapshot && !draft.shipperPartySnapshot.address.trim()) errors.push('请填写本单发货地址')
|
||||||
|
if (!draft.consigneePartyId || !draft.consigneePartySnapshot || !draft.consignee.trim()) errors.push('请选择收货单位')
|
||||||
|
if (draft.consigneePartySnapshot && !draft.consigneePartySnapshot.address.trim()) errors.push('请填写本单收货地址')
|
||||||
|
if (draft.items.length === 0) errors.push('请至少添加一条货物明细')
|
||||||
|
|
||||||
|
draft.items.forEach((item, index) => {
|
||||||
|
const line = `第 ${index + 1} 条货物`
|
||||||
|
if (!item.cargoNameCn.trim()) errors.push(`${line}缺少中文品名`)
|
||||||
|
if (!item.packageType.trim()) errors.push(`${line}缺少包装类型`)
|
||||||
|
if (!Number.isInteger(item.pieces) || item.pieces <= 0) errors.push(`${line}件数必须为正整数`)
|
||||||
|
if (item.grossWeightKg <= 0) errors.push(`${line}毛重必须大于 0`)
|
||||||
|
if (item.volumeCbm <= 0) errors.push(`${line}体积必须大于 0`)
|
||||||
|
if (draft.transportMode === 'FCL' && (!item.containerType || item.containerCount <= 0)) {
|
||||||
|
errors.push(`${line}需要填写箱型和柜量`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return errors
|
||||||
|
}
|
||||||
|
|
||||||
|
export function summarizeFirstLegCargo(items: FirstLegCargoItem[]) {
|
||||||
|
return items.reduce(
|
||||||
|
(summary, item) => ({
|
||||||
|
pieces: summary.pieces + item.pieces,
|
||||||
|
packageCount: summary.packageCount + item.packageCount,
|
||||||
|
grossWeightKg: summary.grossWeightKg + item.grossWeightKg,
|
||||||
|
volumeCbm: summary.volumeCbm + item.volumeCbm,
|
||||||
|
}),
|
||||||
|
{ pieces: 0, packageCount: 0, grossWeightKg: 0, volumeCbm: 0 },
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import test from 'node:test'
|
||||||
|
import { decodeMerchantToken } from './jwt.ts'
|
||||||
|
|
||||||
|
function tokenFor(payload) {
|
||||||
|
const encoded = Buffer.from(JSON.stringify(payload)).toString('base64url')
|
||||||
|
return `header.${encoded}.signature`
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
user_id: 1001,
|
||||||
|
username: 'merchant_admin',
|
||||||
|
tenant_id: 2001,
|
||||||
|
membership_id: 3001,
|
||||||
|
app_code: 'MERCHANT',
|
||||||
|
roles: ['MERCHANT_OWNER'],
|
||||||
|
permissions: ['merchant:profile:view'],
|
||||||
|
exp: 1900000000,
|
||||||
|
}
|
||||||
|
|
||||||
|
test('decodes a merchant JWT payload for UI identity and permissions', () => {
|
||||||
|
assert.deepEqual(decodeMerchantToken(tokenFor(payload)), payload)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('rejects tokens from the platform application domain', () => {
|
||||||
|
assert.throws(() => decodeMerchantToken(tokenFor({ ...payload, app_code: 'PLATFORM' })), /账号不属于当前应用端/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('rejects malformed tokens', () => {
|
||||||
|
assert.throws(() => decodeMerchantToken('not-a-jwt'), /登录凭证格式无效/)
|
||||||
|
})
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
export interface MerchantJwtPayload {
|
||||||
|
user_id: number | string
|
||||||
|
username: string
|
||||||
|
tenant_id: number | string
|
||||||
|
operator_tenant_id?: number | string
|
||||||
|
membership_id: number | string
|
||||||
|
app_code: string
|
||||||
|
client_type?: string
|
||||||
|
roles?: string[]
|
||||||
|
permissions?: string[]
|
||||||
|
exp: number
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeBase64Url(value: string) {
|
||||||
|
const normalized = value.replace(/-/g, '+').replace(/_/g, '/')
|
||||||
|
const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, '=')
|
||||||
|
const binary = atob(padded)
|
||||||
|
const bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0))
|
||||||
|
return new TextDecoder().decode(bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decodeMerchantToken(token: string): MerchantJwtPayload {
|
||||||
|
const payloadPart = token.split('.')[1]
|
||||||
|
if (!payloadPart) throw new Error('登录凭证格式无效')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const value: unknown = JSON.parse(decodeBase64Url(payloadPart))
|
||||||
|
if (!value || typeof value !== 'object') throw new Error()
|
||||||
|
const payload = value as Partial<MerchantJwtPayload>
|
||||||
|
if (!payload.user_id || !payload.username || !payload.tenant_id || !payload.membership_id || !payload.exp) throw new Error()
|
||||||
|
if (payload.app_code !== 'MERCHANT') throw new Error('账号不属于当前应用端')
|
||||||
|
return payload as MerchantJwtPayload
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error && error.message === '账号不属于当前应用端') throw error
|
||||||
|
throw new Error('登录凭证无法解析,请重新登录')
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import test from 'node:test'
|
||||||
|
import { formatQualificationFileSize, QUALIFICATION_MAX_FILE_SIZE, validateQualificationFile } from './qualificationFile.ts'
|
||||||
|
|
||||||
|
test('accepts the backend-confirmed JPG and PNG formats up to 5MB', () => {
|
||||||
|
assert.equal(validateQualificationFile({ name: '营业执照.jpeg', size: QUALIFICATION_MAX_FILE_SIZE, type: 'image/jpeg' }), '')
|
||||||
|
assert.equal(validateQualificationFile({ name: '营业执照.PNG', size: 2048, type: 'image/png' }), '')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('rejects PDF, empty, mismatched, and oversized files', () => {
|
||||||
|
assert.match(validateQualificationFile({ name: '营业执照.pdf', size: 1024, type: 'application/pdf' }), /格式不支持/)
|
||||||
|
assert.match(validateQualificationFile({ name: '伪装图片.png', size: 1024, type: 'text/plain' }), /内容类型/)
|
||||||
|
assert.match(validateQualificationFile({ name: '空文件.png', size: 0, type: 'image/png' }), /内容为空/)
|
||||||
|
assert.match(validateQualificationFile({ name: '超大文件.jpg', size: QUALIFICATION_MAX_FILE_SIZE + 1, type: 'image/jpeg' }), /超过 5MB/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('formats selected file sizes for review', () => {
|
||||||
|
assert.equal(formatQualificationFileSize(512), '512 B')
|
||||||
|
assert.equal(formatQualificationFileSize(1536), '1.5 KB')
|
||||||
|
assert.equal(formatQualificationFileSize(2 * 1024 * 1024), '2.0 MB')
|
||||||
|
})
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
export const QUALIFICATION_MAX_FILE_SIZE = 5 * 1024 * 1024
|
||||||
|
|
||||||
|
const allowedExtensions = new Set(['jpg', 'jpeg', 'png'])
|
||||||
|
const allowedMimeTypes = new Set(['image/jpeg', 'image/png'])
|
||||||
|
|
||||||
|
export interface QualificationFileLike {
|
||||||
|
name: string
|
||||||
|
size: number
|
||||||
|
type?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateQualificationFile(file: QualificationFileLike) {
|
||||||
|
const extension = file.name.includes('.') ? file.name.split('.').pop()?.toLowerCase() ?? '' : ''
|
||||||
|
if (!allowedExtensions.has(extension)) return '文件格式不支持,请选择 JPG 或 PNG 图片'
|
||||||
|
if (file.type && !allowedMimeTypes.has(file.type)) return '文件内容类型与支持的资质格式不一致'
|
||||||
|
if (file.size <= 0) return '文件内容为空,请重新选择'
|
||||||
|
if (file.size > QUALIFICATION_MAX_FILE_SIZE) return '文件超过 5MB,请压缩后重新选择'
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatQualificationFileSize(bytes: number) {
|
||||||
|
if (bytes < 1024) return `${bytes} B`
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||||||
|
return `${(bytes / 1024 / 1024).toFixed(1)} MB`
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import fs from 'node:fs'
|
||||||
|
import test from 'node:test'
|
||||||
|
|
||||||
|
const routerSource = fs.readFileSync(new URL('../router/index.ts', import.meta.url), 'utf8')
|
||||||
|
const navigationSource = fs.readFileSync(new URL('../navigation/clientNavigation.ts', import.meta.url), 'utf8')
|
||||||
|
const viewSource = fs.readFileSync(new URL('./AccountProfileView.vue', import.meta.url), 'utf8')
|
||||||
|
const authSource = fs.readFileSync(new URL('../stores/auth.ts', import.meta.url), 'utf8')
|
||||||
|
const authApiSource = fs.readFileSync(new URL('../api/auth.ts', import.meta.url), 'utf8')
|
||||||
|
|
||||||
|
test('registers the account profile route and navigation entry', () => {
|
||||||
|
assert.match(routerSource, /account\/profile'.*AccountProfileView\.vue/)
|
||||||
|
assert.match(routerSource, /permission: 'account:profile:view'/)
|
||||||
|
assert.match(navigationSource, /title: '我的信息'.*path: '\/account\/profile'/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('implements profile view, edit, save and cancel states', () => {
|
||||||
|
assert.match(viewSource, /基本信息/)
|
||||||
|
assert.match(viewSource, /function startEditing\(\)/)
|
||||||
|
assert.match(viewSource, /function cancelEditing\(\)/)
|
||||||
|
assert.match(viewSource, /async function saveProfile\(\)/)
|
||||||
|
assert.match(viewSource, /账号标识暂不支持在客户端修改/)
|
||||||
|
assert.match(viewSource, /企业归属由平台审核关系确定/)
|
||||||
|
assert.match(authSource, /async function savePersonalProfile\(/)
|
||||||
|
assert.match(viewSource, /await auth\.savePersonalProfile/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('implements password validation, strength feedback and double-submit protection', () => {
|
||||||
|
assert.match(viewSource, /async function changePassword\(\)/)
|
||||||
|
assert.match(viewSource, /validatePasswordChange/)
|
||||||
|
assert.match(viewSource, /getPasswordStrength/)
|
||||||
|
assert.match(viewSource, /passwordStrengthState/)
|
||||||
|
assert.match(viewSource, /if \(passwordSaving\.value\) return/)
|
||||||
|
assert.match(viewSource, /await changeCurrentPassword/)
|
||||||
|
assert.match(authApiSource, /method: 'PUT', url: '\/auth\/password'/)
|
||||||
|
assert.match(viewSource, /安全记录已更新/)
|
||||||
|
assert.match(viewSource, /await router\.replace\('\/login'\)/)
|
||||||
|
assert.doesNotMatch(viewSource, /DEMO_CURRENT_PASSWORD|静态演示/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('provides explicit responsive account layouts', () => {
|
||||||
|
assert.match(viewSource, /@media \(max-width: 820px\)/)
|
||||||
|
assert.match(viewSource, /@media \(max-width: 620px\)/)
|
||||||
|
assert.match(viewSource, /\.account-info-grid, \.account-form-grid \{ grid-template-columns: 1fr; \}/)
|
||||||
|
assert.match(viewSource, /min-height: 44px/)
|
||||||
|
})
|
||||||
@@ -0,0 +1,350 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, reactive, ref } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import type { FormInstance, FormRules } from 'element-plus'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import {
|
||||||
|
Building2,
|
||||||
|
CheckCircle2,
|
||||||
|
KeyRound,
|
||||||
|
Pencil,
|
||||||
|
Save,
|
||||||
|
ShieldCheck,
|
||||||
|
UserRound,
|
||||||
|
X,
|
||||||
|
} from '@lucide/vue'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
import { changeCurrentPassword } from '@/api/auth'
|
||||||
|
import {
|
||||||
|
getPasswordStrength,
|
||||||
|
validateEditableAccountProfile,
|
||||||
|
validatePasswordChange,
|
||||||
|
type PasswordChangePayload,
|
||||||
|
} from '@/utils/accountProfile'
|
||||||
|
|
||||||
|
type AccountTab = 'profile' | 'password'
|
||||||
|
|
||||||
|
interface ProfileForm {
|
||||||
|
username: string
|
||||||
|
realName: string
|
||||||
|
phone: string
|
||||||
|
email: string
|
||||||
|
tenantName: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const auth = useAuthStore()
|
||||||
|
const router = useRouter()
|
||||||
|
const activeTab = ref<AccountTab>('profile')
|
||||||
|
const editing = ref(false)
|
||||||
|
const profileSaving = ref(false)
|
||||||
|
const passwordSaving = ref(false)
|
||||||
|
const passwordChangedAt = ref('')
|
||||||
|
const profileFormRef = ref<FormInstance>()
|
||||||
|
const passwordFormRef = ref<FormInstance>()
|
||||||
|
|
||||||
|
const profileForm = reactive<ProfileForm>({ ...auth.profile })
|
||||||
|
const passwordForm = reactive<PasswordChangePayload>({
|
||||||
|
currentPassword: '',
|
||||||
|
newPassword: '',
|
||||||
|
confirmPassword: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const profileRules: FormRules<ProfileForm> = {
|
||||||
|
realName: [
|
||||||
|
{ required: true, message: '请输入真实姓名', trigger: 'blur' },
|
||||||
|
{ min: 1, max: 64, message: '姓名需为 1–64 个字符', trigger: 'blur' },
|
||||||
|
],
|
||||||
|
phone: [
|
||||||
|
{ max: 32, message: '联系电话不能超过 32 个字符', trigger: 'blur' },
|
||||||
|
],
|
||||||
|
email: [
|
||||||
|
{ type: 'email', message: '请输入有效的邮箱地址', trigger: 'blur' },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
const passwordRules: FormRules<PasswordChangePayload> = {
|
||||||
|
currentPassword: [{
|
||||||
|
required: true,
|
||||||
|
validator: (_rule, value: string, callback) => {
|
||||||
|
if (!value) callback(new Error('请输入当前密码'))
|
||||||
|
else callback()
|
||||||
|
},
|
||||||
|
trigger: 'blur',
|
||||||
|
}],
|
||||||
|
newPassword: [{
|
||||||
|
required: true,
|
||||||
|
validator: (_rule, value: string, callback) => {
|
||||||
|
const errors = validatePasswordChange({
|
||||||
|
currentPassword: passwordForm.currentPassword,
|
||||||
|
newPassword: value,
|
||||||
|
confirmPassword: value,
|
||||||
|
})
|
||||||
|
const relevantError = errors.find((error) => error.includes('8–64') || error.includes('同时包含') || error.includes('当前密码相同'))
|
||||||
|
if (relevantError) callback(new Error(relevantError))
|
||||||
|
else callback()
|
||||||
|
},
|
||||||
|
trigger: 'blur',
|
||||||
|
}],
|
||||||
|
confirmPassword: [{
|
||||||
|
required: true,
|
||||||
|
validator: (_rule, value: string, callback) => {
|
||||||
|
if (!value) callback(new Error('请再次输入新密码'))
|
||||||
|
else if (value !== passwordForm.newPassword) callback(new Error('两次输入的新密码不一致'))
|
||||||
|
else callback()
|
||||||
|
},
|
||||||
|
trigger: 'blur',
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
|
||||||
|
const passwordStrength = computed(() => getPasswordStrength(passwordForm.newPassword))
|
||||||
|
const passwordStrengthState = computed(() => passwordForm.newPassword ? passwordStrength.value : 'empty')
|
||||||
|
const strengthLabel = computed(() => passwordForm.newPassword
|
||||||
|
? ({ weak: '较弱', medium: '中等', strong: '较强' })[passwordStrength.value]
|
||||||
|
: '未输入')
|
||||||
|
|
||||||
|
function syncProfileForm() {
|
||||||
|
Object.assign(profileForm, auth.profile)
|
||||||
|
profileFormRef.value?.clearValidate()
|
||||||
|
}
|
||||||
|
|
||||||
|
function startEditing() {
|
||||||
|
syncProfileForm()
|
||||||
|
editing.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelEditing() {
|
||||||
|
syncProfileForm()
|
||||||
|
editing.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveProfile() {
|
||||||
|
if (profileSaving.value) return
|
||||||
|
const valid = await profileFormRef.value?.validate().catch(() => false)
|
||||||
|
const errors = validateEditableAccountProfile(profileForm)
|
||||||
|
if (!valid || errors.length > 0) return
|
||||||
|
|
||||||
|
profileSaving.value = true
|
||||||
|
try {
|
||||||
|
await auth.savePersonalProfile({
|
||||||
|
realName: profileForm.realName.trim(),
|
||||||
|
phone: profileForm.phone.trim(),
|
||||||
|
email: profileForm.email.trim(),
|
||||||
|
})
|
||||||
|
syncProfileForm()
|
||||||
|
editing.value = false
|
||||||
|
ElMessage.success('个人信息已保存')
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(error instanceof Error ? error.message : '个人信息保存失败,请重试')
|
||||||
|
} finally {
|
||||||
|
profileSaving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetPasswordForm() {
|
||||||
|
passwordForm.currentPassword = ''
|
||||||
|
passwordForm.newPassword = ''
|
||||||
|
passwordForm.confirmPassword = ''
|
||||||
|
passwordFormRef.value?.clearValidate()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function changePassword() {
|
||||||
|
if (passwordSaving.value) return
|
||||||
|
const valid = await passwordFormRef.value?.validate().catch(() => false)
|
||||||
|
if (!valid || validatePasswordChange(passwordForm).length > 0) return
|
||||||
|
|
||||||
|
passwordSaving.value = true
|
||||||
|
try {
|
||||||
|
await changeCurrentPassword(passwordForm.currentPassword, passwordForm.newPassword)
|
||||||
|
passwordChangedAt.value = new Date().toLocaleString('zh-CN', { hour12: false })
|
||||||
|
resetPasswordForm()
|
||||||
|
ElMessage.success('密码已修改,请使用新密码重新登录')
|
||||||
|
await auth.logout().catch(() => undefined)
|
||||||
|
await router.replace('/login')
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(error instanceof Error ? error.message : '密码修改失败,请重试')
|
||||||
|
} finally {
|
||||||
|
passwordSaving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="account-page">
|
||||||
|
<div class="client-card account-identity">
|
||||||
|
<div class="account-avatar" aria-hidden="true"><UserRound :size="28" /></div>
|
||||||
|
<div class="account-identity-copy">
|
||||||
|
<h2>{{ auth.profile.realName }}</h2>
|
||||||
|
<p>{{ auth.profile.username }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="account-context">
|
||||||
|
<span><Building2 :size="16" />{{ auth.profile.tenantName }}</span>
|
||||||
|
<span><ShieldCheck :size="16" />{{ auth.user?.roles.join('、') }}</span>
|
||||||
|
</div>
|
||||||
|
<el-tag type="success" effect="plain"><CheckCircle2 :size="14" />账号正常</el-tag>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="client-card account-workspace">
|
||||||
|
<el-tabs v-model="activeTab" class="account-tabs">
|
||||||
|
<el-tab-pane name="profile">
|
||||||
|
<template #label><span class="account-tab-label"><UserRound :size="16" />基本信息</span></template>
|
||||||
|
|
||||||
|
<header class="account-section-heading">
|
||||||
|
<div><h2>基本信息</h2><p>维护当前登录人的联系方式;账号和所属企业由系统身份关系确定。</p></div>
|
||||||
|
<el-button v-if="!editing" type="primary" @click="startEditing"><Pencil :size="16" />编辑信息</el-button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<el-form
|
||||||
|
v-if="editing"
|
||||||
|
ref="profileFormRef"
|
||||||
|
:model="profileForm"
|
||||||
|
:rules="profileRules"
|
||||||
|
label-position="top"
|
||||||
|
class="account-form-grid"
|
||||||
|
@submit.prevent="saveProfile"
|
||||||
|
>
|
||||||
|
<el-form-item label="用户名">
|
||||||
|
<el-input v-model="profileForm.username" disabled />
|
||||||
|
<span class="field-help">账号标识暂不支持在客户端修改</span>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="姓名" prop="realName"><el-input v-model="profileForm.realName" maxlength="64" show-word-limit /></el-form-item>
|
||||||
|
<el-form-item label="联系电话" prop="phone"><el-input v-model="profileForm.phone" maxlength="32" inputmode="tel" placeholder="选填" /></el-form-item>
|
||||||
|
<el-form-item label="邮箱" prop="email"><el-input v-model="profileForm.email" maxlength="128" inputmode="email" placeholder="选填" /></el-form-item>
|
||||||
|
<el-form-item label="所属公司">
|
||||||
|
<el-input v-model="profileForm.tenantName" disabled />
|
||||||
|
<span class="field-help">企业归属由平台审核关系确定</span>
|
||||||
|
</el-form-item>
|
||||||
|
<div class="account-form-actions">
|
||||||
|
<el-button :disabled="profileSaving" @click="cancelEditing"><X :size="16" />取消</el-button>
|
||||||
|
<el-button type="primary" native-type="submit" :loading="profileSaving"><Save :size="16" />保存修改</el-button>
|
||||||
|
</div>
|
||||||
|
</el-form>
|
||||||
|
|
||||||
|
<dl v-else class="account-info-grid">
|
||||||
|
<div><dt>用户名</dt><dd>{{ auth.profile.username }}</dd></div>
|
||||||
|
<div><dt>姓名</dt><dd>{{ auth.profile.realName }}</dd></div>
|
||||||
|
<div><dt>联系电话</dt><dd>{{ auth.profile.phone || '未填写' }}</dd></div>
|
||||||
|
<div><dt>邮箱</dt><dd>{{ auth.profile.email || '未填写' }}</dd></div>
|
||||||
|
<div><dt>所属公司</dt><dd>{{ auth.profile.tenantName }}</dd></div>
|
||||||
|
</dl>
|
||||||
|
</el-tab-pane>
|
||||||
|
|
||||||
|
<el-tab-pane name="password">
|
||||||
|
<template #label><span class="account-tab-label"><KeyRound :size="16" />修改密码</span></template>
|
||||||
|
|
||||||
|
<div class="password-layout">
|
||||||
|
<div>
|
||||||
|
<header class="account-section-heading password-heading">
|
||||||
|
<div><h2>修改密码</h2><p>输入当前密码并设置 8–64 位、同时包含字母和数字的新密码。</p></div>
|
||||||
|
</header>
|
||||||
|
<el-form
|
||||||
|
ref="passwordFormRef"
|
||||||
|
:model="passwordForm"
|
||||||
|
:rules="passwordRules"
|
||||||
|
label-position="top"
|
||||||
|
class="password-form"
|
||||||
|
@submit.prevent="changePassword"
|
||||||
|
>
|
||||||
|
<el-form-item label="当前密码" prop="currentPassword">
|
||||||
|
<el-input v-model="passwordForm.currentPassword" type="password" show-password autocomplete="current-password" placeholder="请输入当前密码" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="新密码" prop="newPassword">
|
||||||
|
<el-input v-model="passwordForm.newPassword" type="password" show-password autocomplete="new-password" maxlength="64" placeholder="请输入新密码" />
|
||||||
|
<div class="password-strength" :data-strength="passwordStrengthState">
|
||||||
|
<span aria-hidden="true"><i /><i /><i /></span>
|
||||||
|
<small>密码强度:{{ strengthLabel }}</small>
|
||||||
|
</div>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="确认新密码" prop="confirmPassword">
|
||||||
|
<el-input v-model="passwordForm.confirmPassword" type="password" show-password autocomplete="new-password" maxlength="64" placeholder="请再次输入新密码" />
|
||||||
|
</el-form-item>
|
||||||
|
<div class="password-actions">
|
||||||
|
<el-button :disabled="passwordSaving" @click="resetPasswordForm">清空</el-button>
|
||||||
|
<el-button type="primary" native-type="submit" :loading="passwordSaving"><KeyRound :size="16" />确认修改</el-button>
|
||||||
|
</div>
|
||||||
|
</el-form>
|
||||||
|
<div v-if="passwordChangedAt" class="password-result" role="status">
|
||||||
|
<CheckCircle2 :size="18" />
|
||||||
|
<div><strong>安全记录已更新</strong><p>{{ passwordChangedAt }};服务端已使旧会话失效。</p></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<aside class="security-note">
|
||||||
|
<ShieldCheck :size="22" />
|
||||||
|
<div><strong>账户安全提示</strong><p>建议组合使用字母、数字和符号,不要与其他网站共用密码。</p></div>
|
||||||
|
<p>当前密码仅用于本次服务端校验,前端不会持久化密码。修改成功后需要重新登录。</p>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</el-tab-pane>
|
||||||
|
</el-tabs>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.account-page { display: flex; flex-direction: column; gap: 14px; }
|
||||||
|
.account-identity { display: flex; min-width: 0; align-items: center; gap: 14px; padding: 18px 22px; }
|
||||||
|
.account-avatar { display: grid; width: 52px; height: 52px; flex: 0 0 52px; place-items: center; border-radius: 14px; background: #e7f3f1; color: var(--primary); }
|
||||||
|
.account-identity-copy { min-width: 140px; }
|
||||||
|
.account-identity h2 { margin: 0 0 4px; color: var(--navy); font-size: 18px; }
|
||||||
|
.account-identity p { margin: 0; color: var(--muted); font-size: 12px; }
|
||||||
|
.account-context { display: flex; min-width: 0; flex: 1; gap: 22px; color: #52616b; font-size: 12px; }
|
||||||
|
.account-context span { display: flex; min-width: 0; align-items: center; gap: 7px; overflow-wrap: anywhere; }
|
||||||
|
.account-identity :deep(.el-tag) { display: flex; align-items: center; gap: 5px; }
|
||||||
|
.account-workspace { min-height: 520px; padding: 0 24px 28px; }
|
||||||
|
.account-tabs :deep(.el-tabs__header) { margin-bottom: 0; }
|
||||||
|
.account-tabs :deep(.el-tabs__item) { min-height: 52px; }
|
||||||
|
.account-tab-label { display: inline-flex; align-items: center; gap: 7px; }
|
||||||
|
.account-section-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 24px; padding: 24px 0 20px; }
|
||||||
|
.account-section-heading h2 { margin: 0 0 6px; color: var(--navy); font-size: 18px; }
|
||||||
|
.account-section-heading p { max-width: 620px; margin: 0; color: var(--muted); font-size: 12px; line-height: 1.7; }
|
||||||
|
.account-actions { display: flex; gap: 8px; }
|
||||||
|
.account-info-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); margin: 0; border-top: 1px solid var(--border); border-left: 1px solid var(--border); }
|
||||||
|
.account-info-grid div { min-width: 0; min-height: 94px; padding: 18px 20px; border-right: 1px solid var(--border); border-bottom: 1px solid var(--border); }
|
||||||
|
.account-info-grid dt { color: var(--muted); font-size: 11px; }
|
||||||
|
.account-info-grid dd { margin: 9px 0 0; overflow-wrap: anywhere; color: var(--navy); font-size: 14px; font-weight: 650; }
|
||||||
|
.account-form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 24px; max-width: 900px; }
|
||||||
|
.account-form-grid :deep(.el-form-item) { align-content: start; margin-bottom: 21px; }
|
||||||
|
.account-form-actions { display: flex; grid-column: 1 / -1; justify-content: flex-end; gap: 8px; padding-top: 2px; }
|
||||||
|
.field-help { margin-top: 6px; color: var(--muted); font-size: 10px; line-height: 1.5; }
|
||||||
|
.password-layout { display: grid; grid-template-columns: minmax(320px, 520px) minmax(240px, 1fr); align-items: start; gap: clamp(36px, 7vw, 90px); }
|
||||||
|
.password-heading { padding-bottom: 18px; }
|
||||||
|
.password-form { max-width: 440px; }
|
||||||
|
.password-form :deep(.el-form-item) { margin-bottom: 20px; }
|
||||||
|
.password-actions { display: flex; justify-content: flex-end; gap: 8px; padding-top: 2px; }
|
||||||
|
.password-result { display: flex; align-items: flex-start; gap: 9px; margin-top: 18px; padding: 14px; background: #edf6f4; color: var(--primary); }
|
||||||
|
.password-result strong { color: var(--navy); font-size: 12px; }
|
||||||
|
.password-result p { margin: 3px 0 0; color: #526b67; font-size: 11px; line-height: 1.6; }
|
||||||
|
.password-strength { display: flex; width: 100%; align-items: center; justify-content: space-between; gap: 12px; margin-top: 8px; color: var(--muted); }
|
||||||
|
.password-strength > span { display: grid; flex: 1; grid-template-columns: repeat(3, 1fr); gap: 4px; }
|
||||||
|
.password-strength i { height: 3px; background: #dce3e6; }
|
||||||
|
.password-strength[data-strength="weak"] i:first-child { background: #c2413b; }
|
||||||
|
.password-strength[data-strength="medium"] i:nth-child(-n+2) { background: #c47913; }
|
||||||
|
.password-strength[data-strength="strong"] i { background: var(--primary); }
|
||||||
|
.password-strength small { white-space: nowrap; font-size: 10px; }
|
||||||
|
.security-note { display: grid; grid-template-columns: auto 1fr; gap: 10px 12px; margin-top: 24px; padding: 22px; background: #f2f7f6; color: var(--primary); }
|
||||||
|
.security-note strong { color: var(--navy); font-size: 13px; }
|
||||||
|
.security-note p { margin: 5px 0 0; color: #526b67; font-size: 11px; line-height: 1.7; }
|
||||||
|
.security-note > p { grid-column: 1 / -1; margin-top: 8px; padding-top: 14px; border-top: 1px solid #d7e5e2; }
|
||||||
|
.security-note code { color: var(--navy); font-weight: 700; }
|
||||||
|
|
||||||
|
@media (max-width: 820px) {
|
||||||
|
.account-context { align-items: flex-start; flex-direction: column; gap: 7px; }
|
||||||
|
.password-layout { grid-template-columns: 1fr; gap: 14px; }
|
||||||
|
.security-note { margin-top: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 620px) {
|
||||||
|
.account-identity { align-items: flex-start; flex-wrap: wrap; }
|
||||||
|
.account-identity-copy { flex: 1; }
|
||||||
|
.account-context { order: 3; width: 100%; flex-basis: 100%; padding-top: 12px; border-top: 1px solid var(--border); }
|
||||||
|
.account-workspace { min-height: 0; padding: 0 16px 22px; }
|
||||||
|
.account-section-heading { align-items: stretch; flex-direction: column; gap: 14px; padding-top: 20px; }
|
||||||
|
.account-section-heading > .el-button { width: 100%; }
|
||||||
|
.account-form-actions { width: 100%; }
|
||||||
|
.account-form-actions .el-button { min-height: 44px; flex: 1; }
|
||||||
|
.account-info-grid, .account-form-grid { grid-template-columns: 1fr; }
|
||||||
|
.account-info-grid div { min-height: 82px; }
|
||||||
|
.password-actions { width: 100%; }
|
||||||
|
.password-actions .el-button { min-height: 44px; flex: 1; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { readFileSync } from 'node:fs'
|
||||||
|
import test from 'node:test'
|
||||||
|
|
||||||
|
const loginSource = readFileSync(new URL('./LoginView.vue', import.meta.url), 'utf8')
|
||||||
|
const registerSource = readFileSync(new URL('./RegisterView.vue', import.meta.url), 'utf8')
|
||||||
|
const authStoreSource = readFileSync(new URL('../stores/auth.ts', import.meta.url), 'utf8')
|
||||||
|
const httpSource = readFileSync(new URL('../api/http.ts', import.meta.url), 'utf8')
|
||||||
|
const sessionSource = readFileSync(new URL('../api/session.ts', import.meta.url), 'utf8')
|
||||||
|
const authApiSource = readFileSync(new URL('../api/auth.ts', import.meta.url), 'utf8')
|
||||||
|
|
||||||
|
test('matches the backend username login contract', () => {
|
||||||
|
assert.match(loginSource, /label="用户名"/)
|
||||||
|
assert.match(loginSource, /\^\[A-Za-z0-9_\]/)
|
||||||
|
assert.match(loginSource, /const isDevelopment = import\.meta\.env\.DEV/)
|
||||||
|
assert.match(loginSource, /v-if="developmentHint" class="demo-tip"/)
|
||||||
|
assert.match(loginSource, /merchant_admin/)
|
||||||
|
assert.match(loginSource, /form\.remember/)
|
||||||
|
assert.match(loginSource, /忘记密码/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('persists both rotating tokens according to the remember choice', () => {
|
||||||
|
assert.match(sessionSource, /ACCESS_TOKEN_KEY/)
|
||||||
|
assert.match(sessionSource, /REFRESH_TOKEN_KEY/)
|
||||||
|
assert.match(sessionSource, /remember \? localStorage : sessionStorage/)
|
||||||
|
assert.match(sessionSource, /clearSessionTokens/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('implements every backend registration field and real submission', () => {
|
||||||
|
for (const field of ['username', 'companyName', 'displayName', 'legalPerson', 'legalPhone', 'contactEmail', 'countryCode', 'address', 'password', 'agreement']) {
|
||||||
|
assert.match(registerSource, new RegExp(`prop="${field}"`))
|
||||||
|
}
|
||||||
|
assert.match(registerSource, /registerMerchant\(/)
|
||||||
|
assert.match(registerSource, /4–64 位/)
|
||||||
|
assert.match(registerSource, /8–72 位/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('shows immediate login after registration and separates company review from account activation', () => {
|
||||||
|
assert.match(registerSource, /企业账号注册成功/)
|
||||||
|
assert.match(registerSource, /无需等待注册审核/)
|
||||||
|
assert.match(registerSource, /提交订单前还需完成一步/)
|
||||||
|
assert.match(registerSource, /立即登录/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('uses real auth endpoints and single-flight refresh handling', () => {
|
||||||
|
assert.match(authApiSource, /\/auth\/login/)
|
||||||
|
assert.match(authApiSource, /\/auth\/register/)
|
||||||
|
assert.match(authApiSource, /\/auth\/logout/)
|
||||||
|
assert.match(httpSource, /refreshPromise/)
|
||||||
|
assert.match(httpSource, /\/auth\/refresh/)
|
||||||
|
assert.match(httpSource, /envelope\.code !== '0'/)
|
||||||
|
assert.match(authStoreSource, /decodeMerchantToken/)
|
||||||
|
assert.doesNotMatch(authStoreSource, /mock-client:/)
|
||||||
|
})
|
||||||
@@ -0,0 +1,372 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, reactive, ref } from 'vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import type { UploadFile, UploadInstance } from 'element-plus'
|
||||||
|
import { Building2, CheckCircle2, Clock3, FileCheck2, FileText, Pencil, Trash2, Upload, UserRound } from '@lucide/vue'
|
||||||
|
import { useTenantAdministrationStore, type TenantCompanyProfile, type TenantQualification } from '@/stores/tenantAdministration'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
import { formatQualificationFileSize, validateQualificationFile } from '@/utils/qualificationFile'
|
||||||
|
import { downloadQualificationImage, uploadBusinessLicense } from '@/api/merchantIam'
|
||||||
|
|
||||||
|
const store = useTenantAdministrationStore()
|
||||||
|
const auth = useAuthStore()
|
||||||
|
const companyApproved = computed(() => store.company.auditStatus === 'ACTIVE')
|
||||||
|
const auditStatusCopy = computed(() => ({
|
||||||
|
PROFILE_REQUIRED: { label: '待完善资料', title: '请上传营业执照', detail: '营业执照提交审核前不能提交头程订单。' },
|
||||||
|
PENDING: { label: '审核中', title: '企业资料正在等待平台审核', detail: '审核期间仍可维护资料和保存订单草稿;审核通过前不能提交订单。' },
|
||||||
|
ACTIVE: { label: '已认证', title: '企业资料已审核,下单资格已开通', detail: '您可以创建、保存并提交头程订单。' },
|
||||||
|
REJECTED: { label: '审核驳回', title: '企业资料审核未通过', detail: '请根据平台反馈修正资料;当前后端暂不支持同一企业重新上传。' },
|
||||||
|
DISABLED: { label: '企业已停用', title: '企业账号已被平台停用', detail: '当前不能提交订单,请联系平台客服核实。' },
|
||||||
|
})[store.company.auditStatus])
|
||||||
|
const activeTab = ref('basic')
|
||||||
|
const editing = ref(false)
|
||||||
|
const qualificationOpen = ref(false)
|
||||||
|
const previewOpen = ref(false)
|
||||||
|
const previewRecord = ref<TenantQualification | null>(null)
|
||||||
|
const previewUrl = ref('')
|
||||||
|
const previewLoading = ref(false)
|
||||||
|
const uploadRef = ref<UploadInstance>()
|
||||||
|
const selectedFileSize = ref(0)
|
||||||
|
const selectedFileType = ref('')
|
||||||
|
const qualificationFileError = ref('')
|
||||||
|
const qualificationSubmitting = ref(false)
|
||||||
|
const profileSubmitting = ref(false)
|
||||||
|
const loading = ref(false)
|
||||||
|
const selectedFile = ref<File | null>(null)
|
||||||
|
const draft = reactive<TenantCompanyProfile>({ ...store.company })
|
||||||
|
const qualificationDraft = reactive({ type: '营业执照', fileName: '', certificateNo: '' })
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
await store.loadCompanyContext()
|
||||||
|
Object.assign(draft, store.company)
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(error instanceof Error ? error.message : '企业资料加载失败')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function startEdit() {
|
||||||
|
Object.assign(draft, store.company)
|
||||||
|
editing.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelEdit() {
|
||||||
|
Object.assign(draft, store.company)
|
||||||
|
editing.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveProfile() {
|
||||||
|
if (profileSubmitting.value) return
|
||||||
|
if (!draft.companyName || !draft.legalRepresentative || !draft.contactName || !draft.contactPhone) {
|
||||||
|
ElMessage.warning('请完整填写企业名称、法人及联系人信息')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
profileSubmitting.value = true
|
||||||
|
try {
|
||||||
|
await store.updateCompany({ ...draft })
|
||||||
|
Object.assign(draft, store.company)
|
||||||
|
editing.value = false
|
||||||
|
ElMessage.success('企业资料已保存')
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(error instanceof Error ? error.message : '企业资料保存失败')
|
||||||
|
} finally {
|
||||||
|
profileSubmitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openQualificationDialog() {
|
||||||
|
resetQualificationDraft()
|
||||||
|
qualificationOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFileChange(file: UploadFile) {
|
||||||
|
const raw = file.raw
|
||||||
|
if (!raw) {
|
||||||
|
qualificationFileError.value = '未能读取所选文件,请重新选择'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const error = validateQualificationFile({ name: raw.name, size: raw.size, type: raw.type })
|
||||||
|
if (error) {
|
||||||
|
qualificationFileError.value = error
|
||||||
|
qualificationDraft.fileName = ''
|
||||||
|
selectedFileSize.value = 0
|
||||||
|
selectedFileType.value = ''
|
||||||
|
selectedFile.value = null
|
||||||
|
uploadRef.value?.clearFiles()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
qualificationFileError.value = ''
|
||||||
|
selectedFile.value = raw
|
||||||
|
qualificationDraft.fileName = raw.name
|
||||||
|
selectedFileSize.value = raw.size
|
||||||
|
selectedFileType.value = raw.type || raw.name.split('.').pop()?.toUpperCase() || '文件'
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeSelectedFile() {
|
||||||
|
uploadRef.value?.clearFiles()
|
||||||
|
qualificationDraft.fileName = ''
|
||||||
|
selectedFileSize.value = 0
|
||||||
|
selectedFileType.value = ''
|
||||||
|
qualificationFileError.value = ''
|
||||||
|
selectedFile.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetQualificationDraft() {
|
||||||
|
uploadRef.value?.clearFiles()
|
||||||
|
qualificationDraft.type = '营业执照'
|
||||||
|
qualificationDraft.fileName = ''
|
||||||
|
qualificationDraft.certificateNo = store.company.creditCode
|
||||||
|
selectedFileSize.value = 0
|
||||||
|
selectedFileType.value = ''
|
||||||
|
qualificationFileError.value = ''
|
||||||
|
selectedFile.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitQualification() {
|
||||||
|
if (qualificationSubmitting.value) return
|
||||||
|
if (!qualificationDraft.fileName || !selectedFile.value) {
|
||||||
|
ElMessage.warning('请选择营业执照图片')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (qualificationFileError.value) {
|
||||||
|
ElMessage.warning(qualificationFileError.value)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
qualificationSubmitting.value = true
|
||||||
|
try {
|
||||||
|
await uploadBusinessLicense(selectedFile.value, qualificationDraft.certificateNo.trim() || undefined)
|
||||||
|
await store.loadCompanyContext()
|
||||||
|
qualificationOpen.value = false
|
||||||
|
ElMessage.success('营业执照已提交审核')
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(error instanceof Error ? error.message : '营业执照上传失败')
|
||||||
|
} finally {
|
||||||
|
qualificationSubmitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function previewQualification(record: TenantQualification) {
|
||||||
|
previewRecord.value = record
|
||||||
|
previewOpen.value = true
|
||||||
|
if (!record.documentUrl) return
|
||||||
|
previewLoading.value = true
|
||||||
|
try {
|
||||||
|
const blob = await downloadQualificationImage(record.documentUrl)
|
||||||
|
previewUrl.value = URL.createObjectURL(blob)
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(error instanceof Error ? error.message : '资质文件加载失败')
|
||||||
|
} finally {
|
||||||
|
previewLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeQualificationPreview() {
|
||||||
|
if (previewUrl.value) URL.revokeObjectURL(previewUrl.value)
|
||||||
|
previewUrl.value = ''
|
||||||
|
previewRecord.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
function qualificationStatus(status: string) {
|
||||||
|
return status === 'VERIFIED' ? '已验证' : status === 'PENDING' ? '待审核' : status === 'REJECTED' ? '已驳回' : '已过期'
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section v-loading="loading" class="profile-page">
|
||||||
|
<div class="profile-summary-card client-card">
|
||||||
|
<div class="company-mark"><Building2 :size="28" /></div>
|
||||||
|
<div class="company-summary">
|
||||||
|
<div><h2>{{ store.company.companyName }}</h2><el-tag :type="companyApproved ? 'success' : store.company.auditStatus === 'REJECTED' || store.company.auditStatus === 'DISABLED' ? 'danger' : 'warning'" effect="light">{{ auditStatusCopy.label }}</el-tag></div>
|
||||||
|
<p>{{ store.company.creditCode }} · {{ store.company.companyType }}</p>
|
||||||
|
</div>
|
||||||
|
<el-button v-if="auth.hasPermission('company:profile:edit')" type="primary" @click="startEdit"><Pencil :size="16" />编辑企业资料</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="company-audit-notice" :class="companyApproved ? 'is-approved' : 'is-pending'" role="status">
|
||||||
|
<component :is="companyApproved ? CheckCircle2 : Clock3" :size="21" />
|
||||||
|
<div><strong>{{ auditStatusCopy.title }}</strong><span>{{ auditStatusCopy.detail }}</span></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-tabs v-model="activeTab" class="profile-tabs">
|
||||||
|
<el-tab-pane name="basic">
|
||||||
|
<template #label><span class="tab-label"><Building2 :size="16" />企业基本信息</span></template>
|
||||||
|
<div class="client-card info-panel">
|
||||||
|
<dl class="profile-grid">
|
||||||
|
<div><dt>公司名称</dt><dd>{{ store.company.companyName }}</dd></div>
|
||||||
|
<div><dt>统一社会信用代码</dt><dd>{{ store.company.creditCode }}</dd></div>
|
||||||
|
<div><dt>企业类型</dt><dd>{{ store.company.companyType }}</dd></div>
|
||||||
|
<div><dt>注册地</dt><dd>{{ store.company.registeredAddress }}</dd></div>
|
||||||
|
<div><dt>成立日期</dt><dd>{{ store.company.establishedDate }}</dd></div>
|
||||||
|
<div><dt>注册资本</dt><dd>{{ store.company.registeredCapital }}</dd></div>
|
||||||
|
<div><dt>法人姓名</dt><dd>{{ store.company.legalRepresentative }}</dd></div>
|
||||||
|
<div><dt>法人电话</dt><dd>{{ store.company.legalRepresentativePhone }}</dd></div>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</el-tab-pane>
|
||||||
|
|
||||||
|
<el-tab-pane name="contact">
|
||||||
|
<template #label><span class="tab-label"><UserRound :size="16" />联系信息</span></template>
|
||||||
|
<div class="client-card info-panel">
|
||||||
|
<dl class="profile-grid">
|
||||||
|
<div><dt>联系人</dt><dd>{{ store.company.contactName }}</dd></div>
|
||||||
|
<div><dt>联系电话</dt><dd>{{ store.company.contactPhone }}</dd></div>
|
||||||
|
<div><dt>联系邮箱</dt><dd>{{ store.company.contactEmail }}</dd></div>
|
||||||
|
<div><dt>微信</dt><dd>{{ store.company.contactWechat }}</dd></div>
|
||||||
|
<div class="full-row"><dt>业务地址</dt><dd>{{ store.company.businessAddress }}</dd></div>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</el-tab-pane>
|
||||||
|
|
||||||
|
<el-tab-pane name="qualification">
|
||||||
|
<template #label><span class="tab-label"><FileCheck2 :size="16" />资质文件</span></template>
|
||||||
|
<div class="client-card qualification-panel">
|
||||||
|
<header><div><h3>企业资质</h3><p>文件更新后需等待平台重新审核,历史业务不受影响。</p></div><el-button v-if="auth.hasPermission('company:qualification:upload')" type="primary" @click="openQualificationDialog"><Upload :size="16" />更新资质</el-button></header>
|
||||||
|
<el-table class="desktop-qualification-table" :data="store.qualifications" row-key="id">
|
||||||
|
<el-table-column prop="type" label="文件类型" min-width="150" />
|
||||||
|
<el-table-column prop="fileName" label="文件名称" min-width="220" show-overflow-tooltip />
|
||||||
|
<el-table-column label="状态" width="110"><template #default="{ row }"><el-tag :type="row.status === 'VERIFIED' ? 'success' : row.status === 'PENDING' ? 'warning' : 'danger'" effect="light">{{ qualificationStatus(row.status) }}</el-tag></template></el-table-column>
|
||||||
|
<el-table-column prop="expiresAt" label="有效期" width="130" />
|
||||||
|
<el-table-column prop="updatedAt" label="更新时间" min-width="160" />
|
||||||
|
<el-table-column label="操作" width="90" fixed="right"><template #default="{ row }"><el-button link type="primary" @click="previewQualification(row as TenantQualification)">查看</el-button></template></el-table-column>
|
||||||
|
</el-table>
|
||||||
|
<div class="mobile-qualification-list">
|
||||||
|
<article v-for="record in store.qualifications" :key="record.id">
|
||||||
|
<header><strong>{{ record.type }}</strong><el-tag :type="record.status === 'VERIFIED' ? 'success' : record.status === 'PENDING' ? 'warning' : 'danger'" effect="light">{{ qualificationStatus(record.status) }}</el-tag></header>
|
||||||
|
<div class="qualification-file-name"><FileText :size="16" /><span>{{ record.fileName }}</span></div>
|
||||||
|
<dl><div><dt>有效期</dt><dd>{{ record.expiresAt }}</dd></div><div><dt>更新时间</dt><dd>{{ record.updatedAt }}</dd></div></dl>
|
||||||
|
<el-button link type="primary" @click="previewQualification(record)">查看文件信息</el-button>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-tab-pane>
|
||||||
|
</el-tabs>
|
||||||
|
|
||||||
|
<el-drawer v-model="editing" title="编辑企业资料" size="min(620px, 94vw)" @closed="cancelEdit">
|
||||||
|
<el-form label-position="top" class="profile-form">
|
||||||
|
<h3>企业基本信息</h3>
|
||||||
|
<div class="form-grid-two">
|
||||||
|
<el-form-item label="公司名称" required><el-input v-model="draft.companyName" /></el-form-item>
|
||||||
|
<el-form-item label="统一社会信用代码"><el-input v-model="draft.creditCode" disabled /></el-form-item>
|
||||||
|
<el-form-item label="企业类型"><el-input v-model="draft.companyType" /></el-form-item>
|
||||||
|
<el-form-item label="注册地"><el-input v-model="draft.registeredAddress" /></el-form-item>
|
||||||
|
<el-form-item label="法人姓名" required><el-input v-model="draft.legalRepresentative" /></el-form-item>
|
||||||
|
<el-form-item label="法人电话"><el-input v-model="draft.legalRepresentativePhone" /></el-form-item>
|
||||||
|
</div>
|
||||||
|
<h3>联系信息</h3>
|
||||||
|
<div class="form-grid-two">
|
||||||
|
<el-form-item label="联系人" required><el-input v-model="draft.contactName" /></el-form-item>
|
||||||
|
<el-form-item label="联系电话" required><el-input v-model="draft.contactPhone" /></el-form-item>
|
||||||
|
<el-form-item label="联系邮箱"><el-input v-model="draft.contactEmail" /></el-form-item>
|
||||||
|
<el-form-item label="微信"><el-input v-model="draft.contactWechat" /></el-form-item>
|
||||||
|
<el-form-item label="业务地址" class="full-row"><el-input v-model="draft.businessAddress" /></el-form-item>
|
||||||
|
</div>
|
||||||
|
</el-form>
|
||||||
|
<template #footer><div class="drawer-actions"><el-button :disabled="profileSubmitting" @click="cancelEdit">取消</el-button><el-button type="primary" :loading="profileSubmitting" @click="saveProfile">保存修改</el-button></div></template>
|
||||||
|
</el-drawer>
|
||||||
|
|
||||||
|
<el-dialog v-model="qualificationOpen" title="更新资质文件" width="min(520px, 92vw)" @closed="resetQualificationDraft">
|
||||||
|
<el-form label-position="top">
|
||||||
|
<el-form-item label="文件类型"><el-input v-model="qualificationDraft.type" disabled /></el-form-item>
|
||||||
|
<el-form-item label="选择文件" required :error="qualificationFileError">
|
||||||
|
<el-upload ref="uploadRef" :auto-upload="false" :limit="1" :show-file-list="false" accept=".jpg,.jpeg,.png" @change="handleFileChange">
|
||||||
|
<el-button><Upload :size="16" />选择文件</el-button>
|
||||||
|
<template #tip><div class="upload-tip">仅支持 JPG、PNG,单张图片不超过 5MB。</div></template>
|
||||||
|
</el-upload>
|
||||||
|
<div v-if="qualificationDraft.fileName" class="selected-qualification-file" aria-live="polite">
|
||||||
|
<FileText :size="20" />
|
||||||
|
<div><strong>{{ qualificationDraft.fileName }}</strong><span>{{ selectedFileType }} · {{ formatQualificationFileSize(selectedFileSize) }}</span></div>
|
||||||
|
<el-button link type="danger" aria-label="移除所选资质文件" @click="removeSelectedFile"><Trash2 :size="16" />移除</el-button>
|
||||||
|
</div>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="统一社会信用代码"><el-input v-model="qualificationDraft.certificateNo" maxlength="18" placeholder="建议填写 18 位统一社会信用代码" /></el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer><el-button :disabled="qualificationSubmitting" @click="qualificationOpen = false">取消</el-button><el-button type="primary" :loading="qualificationSubmitting" @click="submitQualification">提交审核</el-button></template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<el-dialog v-model="previewOpen" :title="`${previewRecord?.type || '资质文件'}详情`" width="min(560px, 92vw)" @closed="closeQualificationPreview">
|
||||||
|
<div v-if="previewRecord" v-loading="previewLoading" class="document-preview">
|
||||||
|
<img v-if="previewUrl" :src="previewUrl" :alt="`${previewRecord.type}预览`" class="qualification-preview-image" />
|
||||||
|
<FileCheck2 :size="42" />
|
||||||
|
<strong>{{ previewRecord.fileName }}</strong>
|
||||||
|
<el-tag :type="previewRecord.status === 'VERIFIED' ? 'success' : previewRecord.status === 'PENDING' ? 'warning' : 'danger'" effect="light">{{ qualificationStatus(previewRecord.status) }}</el-tag>
|
||||||
|
<dl><div><dt>文件类型</dt><dd>{{ previewRecord.type }}</dd></div><div><dt>有效期</dt><dd>{{ previewRecord.expiresAt }}</dd></div><div><dt>更新时间</dt><dd>{{ previewRecord.updatedAt }}</dd></div></dl>
|
||||||
|
<span>{{ previewUrl ? '文件通过当前商户会话临时加载,关闭弹窗后立即释放。' : '当前文件暂不可预览。' }}</span>
|
||||||
|
</div>
|
||||||
|
</el-dialog>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.profile-page { display: flex; flex-direction: column; gap: 16px; }
|
||||||
|
.company-audit-notice { display: flex; align-items: flex-start; gap: 12px; padding: 15px 17px; border: 1px solid; }
|
||||||
|
.company-audit-notice svg { flex: 0 0 21px; margin-top: 1px; }
|
||||||
|
.company-audit-notice div { display: flex; min-width: 0; flex-direction: column; gap: 4px; }
|
||||||
|
.company-audit-notice strong { color: var(--navy); font-size: 13px; }
|
||||||
|
.company-audit-notice span { color: var(--muted); font-size: 12px; line-height: 1.6; }
|
||||||
|
.company-audit-notice.is-approved { border-color: #bcd9d2; background: #eff8f5; color: #20745f; }
|
||||||
|
.qualification-preview-image { display: block; max-width: 100%; max-height: 360px; margin: 0 auto 14px; object-fit: contain; }
|
||||||
|
.company-audit-notice.is-pending { border-color: #e5d2a5; background: #fff9eb; color: #9a6a12; }
|
||||||
|
.profile-summary-card { display: flex; align-items: center; gap: 16px; }
|
||||||
|
.company-mark { display: grid; width: 58px; height: 58px; flex: 0 0 58px; place-items: center; background: #e7f3f1; color: var(--primary); }
|
||||||
|
.company-summary { flex: 1; min-width: 0; }
|
||||||
|
.company-summary > div { display: flex; align-items: center; gap: 10px; }
|
||||||
|
.company-summary h2 { overflow: hidden; margin: 0; color: var(--navy); font-size: 20px; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.company-summary p, .qualification-panel header p { margin: 6px 0 0; color: var(--muted); font-size: 12px; }
|
||||||
|
.profile-tabs :deep(.el-tabs__header) { margin-bottom: 14px; padding: 0 16px; border: 1px solid var(--border); background: #fff; }
|
||||||
|
.profile-tabs :deep(.el-tabs__nav-wrap::after) { display: none; }
|
||||||
|
.tab-label { display: inline-flex; align-items: center; gap: 7px; }
|
||||||
|
.info-panel { padding: 0; }
|
||||||
|
.profile-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); margin: 0; }
|
||||||
|
.profile-grid > div { min-height: 86px; padding: 20px 22px; border-bottom: 1px solid var(--border); }
|
||||||
|
.profile-grid > div:nth-child(odd) { border-right: 1px solid var(--border); }
|
||||||
|
.profile-grid dt { color: var(--muted); font-size: 11px; }
|
||||||
|
.profile-grid dd { margin: 9px 0 0; color: var(--navy); font-size: 14px; font-weight: 650; }
|
||||||
|
.profile-grid .full-row { grid-column: 1 / -1; border-right: 0; }
|
||||||
|
.qualification-panel { padding: 0; overflow: hidden; }
|
||||||
|
.qualification-panel > header { padding: 20px 22px; border-bottom: 1px solid var(--border); }
|
||||||
|
.qualification-panel h3 { margin: 0; }
|
||||||
|
.mobile-qualification-list { display: none; }
|
||||||
|
.profile-form h3 { margin: 4px 0 18px; padding-bottom: 10px; border-bottom: 1px solid var(--border); font-size: 15px; }
|
||||||
|
.profile-form h3:not(:first-child) { margin-top: 22px; }
|
||||||
|
.form-grid-two { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 16px; }
|
||||||
|
.form-grid-two .full-row { grid-column: 1 / -1; }
|
||||||
|
.drawer-actions { display: flex; justify-content: flex-end; gap: 8px; }
|
||||||
|
.upload-tip { margin-top: 8px; color: var(--muted); font-size: 11px; line-height: 1.55; }
|
||||||
|
.selected-qualification-file { display: flex; width: 100%; min-width: 0; align-items: center; gap: 10px; margin-top: 12px; padding: 12px; border: 1px solid #cfe1dd; background: #f3f8f7; color: var(--primary); }
|
||||||
|
.selected-qualification-file > svg { flex: 0 0 20px; }
|
||||||
|
.selected-qualification-file > div { display: flex; min-width: 0; flex: 1; flex-direction: column; gap: 3px; }
|
||||||
|
.selected-qualification-file strong { overflow: hidden; color: var(--navy); font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.selected-qualification-file span { color: var(--muted); font-size: 10px; }
|
||||||
|
.document-preview { display: grid; min-height: 260px; place-items: center; align-content: center; gap: 12px; border: 1px dashed #b8c8c5; background: #f6faf9; color: var(--primary); }
|
||||||
|
.document-preview > strong { max-width: min(420px, calc(100% - 32px)); overflow-wrap: anywhere; color: var(--navy); text-align: center; }
|
||||||
|
.document-preview dl { display: grid; width: min(420px, calc(100% - 32px)); grid-template-columns: repeat(3, minmax(0, 1fr)); margin: 6px 0; border-top: 1px solid var(--border); border-bottom: 1px solid var(--border); }
|
||||||
|
.document-preview dl div { min-width: 0; padding: 12px; text-align: center; }
|
||||||
|
.document-preview dt { color: var(--muted); font-size: 10px; }
|
||||||
|
.document-preview dd { margin: 5px 0 0; overflow-wrap: anywhere; color: var(--navy); font-size: 12px; font-weight: 650; }
|
||||||
|
.document-preview > span { max-width: 440px; padding: 0 16px; color: var(--muted); font-size: 11px; line-height: 1.6; text-align: center; }
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.profile-summary-card { align-items: flex-start; flex-wrap: wrap; }
|
||||||
|
.profile-summary-card > .el-button { width: 100%; }
|
||||||
|
.profile-grid, .form-grid-two { grid-template-columns: 1fr; }
|
||||||
|
.profile-grid > div:nth-child(odd) { border-right: 0; }
|
||||||
|
.qualification-panel > header { align-items: flex-start; gap: 12px; }
|
||||||
|
.qualification-panel > header .el-button { flex: 0 0 auto; }
|
||||||
|
.desktop-qualification-table { display: none; }
|
||||||
|
.mobile-qualification-list { display: grid; gap: 10px; padding: 10px; }
|
||||||
|
.mobile-qualification-list article { min-width: 0; padding: 14px; border: 1px solid var(--border); background: #fff; }
|
||||||
|
.mobile-qualification-list article > header { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
|
||||||
|
.qualification-file-name { display: flex; min-width: 0; align-items: center; gap: 8px; margin: 14px 0; color: var(--primary); }
|
||||||
|
.qualification-file-name svg { flex: 0 0 16px; }
|
||||||
|
.qualification-file-name span { min-width: 0; overflow: hidden; color: var(--navy); font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.mobile-qualification-list dl { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; margin: 0 0 10px; padding-top: 12px; border-top: 1px solid var(--border); }
|
||||||
|
.mobile-qualification-list dt { color: var(--muted); font-size: 10px; }
|
||||||
|
.mobile-qualification-list dd { margin: 4px 0 0; overflow-wrap: anywhere; font-size: 11px; font-weight: 600; }
|
||||||
|
.document-preview dl { grid-template-columns: 1fr; }
|
||||||
|
.document-preview dl div + div { border-top: 1px solid var(--border); }
|
||||||
|
.selected-qualification-file { align-items: flex-start; flex-wrap: wrap; }
|
||||||
|
.selected-qualification-file > .el-button { margin-left: 30px; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { ArrowRight, ClipboardCheck, Clock3, FilePlus2, Users } from '@lucide/vue'
|
||||||
|
import { useFirstLegOrdersStore } from '@/stores/firstLegOrders'
|
||||||
|
import { useTenantAdministrationStore } from '@/stores/tenantAdministration'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
import { getFirstLegStatusLabel, getFirstLegStatusTone } from '@/utils/firstLegOrder'
|
||||||
|
|
||||||
|
const orders = useFirstLegOrdersStore()
|
||||||
|
const tenant = useTenantAdministrationStore()
|
||||||
|
const auth = useAuthStore()
|
||||||
|
const companyApproved = computed(() => tenant.company.auditStatus === 'ACTIVE')
|
||||||
|
const stats = computed(() => [
|
||||||
|
{ label: '进行中订单', value: String(orders.activeCount), icon: Clock3 },
|
||||||
|
{ label: '待平台审核', value: String(orders.pendingCount), icon: ClipboardCheck },
|
||||||
|
{ label: '企业成员', value: String(tenant.activeMemberCount), icon: Users },
|
||||||
|
])
|
||||||
|
const recentOrders = computed(() => orders.orders.slice(0, 3))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="client-dashboard">
|
||||||
|
<section class="quick-order-band">
|
||||||
|
<div><span>头程物流</span><h2>{{ auth.hasPermission('order:first-leg:create') ? '创建新的头程订单' : '跟进企业头程订单' }}</h2><p>{{ companyApproved ? '企业资料已审核,可正常提交并跟进订单。' : '企业资料待平台审核,当前可以维护并保存订单草稿。' }}</p></div>
|
||||||
|
<el-button v-if="auth.hasPermission('order:first-leg:list')" type="primary" size="large" @click="$router.push('/orders/first-leg')"><FilePlus2 :size="18" /> {{ auth.hasPermission('order:first-leg:create') ? '开始下单' : '查看订单' }}</el-button>
|
||||||
|
</section>
|
||||||
|
<section class="client-stats">
|
||||||
|
<article v-for="item in stats" :key="item.label"><component :is="item.icon" :size="20" /><div><span>{{ item.label }}</span><strong>{{ item.value }}</strong></div></article>
|
||||||
|
</section>
|
||||||
|
<section class="client-content-grid">
|
||||||
|
<article class="client-card"><header><div><span class="section-label">ORDERS</span><h3>最近订单</h3></div><el-button text @click="$router.push('/orders/first-leg')">查看全部 <ArrowRight :size="16" /></el-button></header><div v-if="recentOrders.length" class="recent-orders"><button v-for="order in recentOrders" :key="order.id" type="button" @click="$router.push('/orders/first-leg')"><span><strong>{{ order.orderNo || '未提交草稿' }}</strong><small>{{ order.origin }} → {{ order.destination }}</small></span><el-tag :type="getFirstLegStatusTone(order.status)" effect="light">{{ getFirstLegStatusLabel(order.status) }}</el-tag></button></div><div v-else class="client-empty">暂无订单,提交第一笔头程订单后将在这里展示。</div></article>
|
||||||
|
<article class="client-card"><header><div><span class="section-label">ACCOUNT</span><h3>企业资料</h3></div></header><dl class="profile-summary"><div><dt>企业名称</dt><dd>{{ tenant.company.companyName }}</dd></div><div><dt>审核状态</dt><dd><el-tag :type="companyApproved ? 'success' : 'warning'" effect="light">{{ companyApproved ? '已通过' : '待审核' }}</el-tag></dd></div><div><dt>当前成员</dt><dd>{{ auth.user?.name }}</dd></div></dl><el-button v-if="auth.hasPermission('company:profile:view')" plain @click="$router.push('/company/profile')">{{ companyApproved ? '查看企业资料' : '完善企业资料' }}</el-button></article>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { readFileSync } from 'node:fs'
|
||||||
|
import test from 'node:test'
|
||||||
|
|
||||||
|
const source = readFileSync(new URL('./FirstLegOrdersView.vue', import.meta.url), 'utf8')
|
||||||
|
const routerSource = readFileSync(new URL('../router/index.ts', import.meta.url), 'utf8')
|
||||||
|
|
||||||
|
test('routes the confirmed first-leg path to the implemented order view', () => {
|
||||||
|
assert.match(routerSource, /path: 'orders\/first-leg'.*FirstLegOrdersView\.vue/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('exposes real list, draft, submit, and detail workflow entries without cancellation', () => {
|
||||||
|
assert.match(source, /function openCreate\(\)/)
|
||||||
|
assert.match(source, /async function saveDraft\(\)/)
|
||||||
|
assert.match(source, /function submitOrder\(\)/)
|
||||||
|
assert.match(source, /async function openDetail\(order: FirstLegOrder\)/)
|
||||||
|
assert.match(source, /Promise\.all\(\[store\.load\(\), firstLegPartySource\.load\(\)\]\)/)
|
||||||
|
assert.match(source, /await store\.loadDetail\(order\.id\)/)
|
||||||
|
assert.doesNotMatch(source, /cancelOrder|取消订单/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('blocks order submission before company approval while preserving draft actions', () => {
|
||||||
|
assert.match(source, /companyApproved/)
|
||||||
|
assert.match(source, /企业资料待审核,当前仅可保存订单草稿/)
|
||||||
|
assert.match(source, /暂时无法提交订单/)
|
||||||
|
assert.match(source, /router\.push\('\/company\/profile'\)/)
|
||||||
|
assert.match(source, /auth\.hasPermission\('order:first-leg:submit'\)/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('renders dynamic cargo lines and responsive drawers', () => {
|
||||||
|
assert.match(source, /v-for="\(item, index\) in form\.items"/)
|
||||||
|
assert.match(source, /function addCargoItem\(\)/)
|
||||||
|
assert.match(source, /function removeCargoItem\(index: number\)/)
|
||||||
|
assert.match(source, /:global\(\.first-leg-editor\).*width: 100% !important/)
|
||||||
|
assert.match(source, /class="mobile-order-list"/)
|
||||||
|
assert.match(source, /\.desktop-order-table \{ display: none; \}/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('keeps primary mobile order actions touch sized', () => {
|
||||||
|
assert.match(source, /\.order-toolbar-actions :deep\(\.el-button\) \{ min-height: 44px;/)
|
||||||
|
assert.match(source, /\.editor-actions :deep\(\.el-button\) \{ min-height: 44px;/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('does not expose arbitrary status mutation controls to customers', () => {
|
||||||
|
assert.doesNotMatch(source, /setStatusForPreview/)
|
||||||
|
assert.doesNotMatch(source, /状态更新|更新状态/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('requires an enabled platform receiving warehouse in the order form', () => {
|
||||||
|
assert.match(source, /computed\(\(\) => store\.warehouses\)/)
|
||||||
|
assert.match(source, /availableWarehouses\.value\.length === 0/)
|
||||||
|
assert.match(source, /label="平台收货仓" required/)
|
||||||
|
assert.match(source, /v-model="form\.receivingWarehouseId"/)
|
||||||
|
assert.match(source, /selectedWarehouse\.businessHours/)
|
||||||
|
assert.match(source, /detailOrder\.receivingWarehouseSnapshot\?\.name/)
|
||||||
|
assert.match(source, /detailOrder\.receivingWarehouseSnapshot\.code/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('collects the backend-required first-leg fields', () => {
|
||||||
|
for (const model of ['destinationCountryCode', 'destinationWarehouseName', 'channelCode', 'channelName', 'pickupMethod', 'taxMode', 'destinationType', 'packageType']) {
|
||||||
|
assert.match(source, new RegExp(`v-model="(?:form|item)\\.${model}"`))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('selects enabled counterparties and stores editable order snapshots', () => {
|
||||||
|
assert.match(source, /firstLegPartySource\.listAvailable\('SHIPPER'\)/)
|
||||||
|
assert.match(source, /firstLegPartySource\.findAvailable\(id, role\)/)
|
||||||
|
assert.match(source, /firstLegPartySource\.load\(\)/)
|
||||||
|
assert.match(source, /v-model="form\.shipperPartyId"/)
|
||||||
|
assert.match(source, /v-model="form\.consigneePartyId"/)
|
||||||
|
assert.match(source, /form\.shipperPartySnapshot\.contactName/)
|
||||||
|
assert.match(source, /form\.consigneePartySnapshot\.address/)
|
||||||
|
assert.match(source, /只影响本订单快照/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('shows fulfillment courier information as read-only client detail', () => {
|
||||||
|
assert.match(source, /履约快递信息/)
|
||||||
|
assert.match(source, /detailOrder\.expressCompanyName/)
|
||||||
|
assert.match(source, /detailOrder\.trackingNumber/)
|
||||||
|
assert.match(source, /客户端仅只读展示/)
|
||||||
|
assert.doesNotMatch(source, /v-model="form\.expressCompanyName"/)
|
||||||
|
assert.doesNotMatch(source, /v-model="form\.trackingNumber"/)
|
||||||
|
})
|
||||||
@@ -0,0 +1,605 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
|
||||||
|
import { onBeforeRouteLeave, useRouter } from 'vue-router'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import { AlertTriangle, Box, Building2, CheckCircle2, Eye, FilePlus2, MapPin, PackageCheck, Pencil, Plus, RotateCcw, Search, Send, Trash2 } from '@lucide/vue'
|
||||||
|
import { FIRST_LEG_ORDER_STATUSES, FIRST_LEG_TRANSPORT_MODES, type FirstLegOrderStatus } from '@chongchong/shared'
|
||||||
|
import { useFirstLegOrdersStore } from '@/stores/firstLegOrders'
|
||||||
|
import { useTenantAdministrationStore } from '@/stores/tenantAdministration'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
import { createFirstLegPartySnapshot, type FirstLegPartyRole } from '@/data/firstLegParties'
|
||||||
|
import { firstLegPartySource } from '@/data/firstLegPartySource'
|
||||||
|
import type { FirstLegOrder, FirstLegOrderDraft } from '@/types/firstLegOrder'
|
||||||
|
import {
|
||||||
|
canClientEditFirstLegOrder,
|
||||||
|
createEmptyCargoItem,
|
||||||
|
getFirstLegStatusLabel,
|
||||||
|
getFirstLegStatusTone,
|
||||||
|
getFirstLegTransportModeLabel,
|
||||||
|
summarizeFirstLegCargo,
|
||||||
|
validateFirstLegOrderDraft,
|
||||||
|
} from '@/utils/firstLegOrder'
|
||||||
|
|
||||||
|
const store = useFirstLegOrdersStore()
|
||||||
|
const tenantStore = useTenantAdministrationStore()
|
||||||
|
const auth = useAuthStore()
|
||||||
|
const router = useRouter()
|
||||||
|
const companyApproved = computed(() => tenantStore.company.auditStatus === 'ACTIVE')
|
||||||
|
const canCreateOrder = computed(() => auth.hasPermission('order:first-leg:create'))
|
||||||
|
const canEditOrder = computed(() => auth.hasPermission('order:first-leg:edit'))
|
||||||
|
const availableWarehouses = computed(() => store.warehouses)
|
||||||
|
const availableShippers = ref(firstLegPartySource.listAvailable('SHIPPER'))
|
||||||
|
const availableConsignees = ref(firstLegPartySource.listAvailable('CONSIGNEE'))
|
||||||
|
const filters = reactive({ keyword: '', status: '' as FirstLegOrderStatus | '', transportMode: '' })
|
||||||
|
const dateRange = ref<[string, string] | []>([])
|
||||||
|
const editorOpen = ref(false)
|
||||||
|
const detailOpen = ref(false)
|
||||||
|
const editingId = ref<string>()
|
||||||
|
const detailId = ref<string>()
|
||||||
|
const saving = ref(false)
|
||||||
|
const detailLoading = ref(false)
|
||||||
|
const detailError = ref('')
|
||||||
|
const editorBaseline = ref('')
|
||||||
|
let cargoSequence = 0
|
||||||
|
|
||||||
|
function emptyDraft(): FirstLegOrderDraft {
|
||||||
|
return {
|
||||||
|
referenceNo: '',
|
||||||
|
transportMode: 'FCL',
|
||||||
|
businessType: 'EXPORT',
|
||||||
|
origin: '',
|
||||||
|
destination: '',
|
||||||
|
receivingWarehouseId: '',
|
||||||
|
expectedDepartureDate: '',
|
||||||
|
shipperPartyId: '',
|
||||||
|
shipperPartySnapshot: null,
|
||||||
|
shipper: '',
|
||||||
|
consigneePartyId: '',
|
||||||
|
consigneePartySnapshot: null,
|
||||||
|
consignee: '',
|
||||||
|
priority: 'NORMAL',
|
||||||
|
customerRemark: '',
|
||||||
|
tradeTerm: 'FOB',
|
||||||
|
destinationCountryCode: 'US',
|
||||||
|
destinationWarehouseName: '',
|
||||||
|
pickupMethod: 'SELF_EXPRESS',
|
||||||
|
channelCode: 'SEA-US-FBA',
|
||||||
|
channelName: '美国头程渠道',
|
||||||
|
taxMode: 'BUY_DECLARATION',
|
||||||
|
customsInspection: false,
|
||||||
|
destinationType: 'FBA',
|
||||||
|
amazonWarehouseCode: '',
|
||||||
|
shipmentId: '',
|
||||||
|
items: [createEmptyCargoItem(`new-cargo-${++cargoSequence}`)],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const form = reactive<FirstLegOrderDraft>(emptyDraft())
|
||||||
|
|
||||||
|
const filteredOrders = computed(() => store.orders.filter((order) => {
|
||||||
|
const keyword = filters.keyword.trim().toLowerCase()
|
||||||
|
const matchesKeyword = !keyword || [order.orderNo, order.referenceNo, order.origin, order.destination, order.items.map((item) => item.cargoNameCn).join(' ')]
|
||||||
|
.filter(Boolean)
|
||||||
|
.some((value) => String(value).toLowerCase().includes(keyword))
|
||||||
|
const matchesStatus = !filters.status || order.status === filters.status
|
||||||
|
const matchesMode = !filters.transportMode || order.transportMode === filters.transportMode
|
||||||
|
const matchesDate = dateRange.value.length !== 2 || (order.createdAt.slice(0, 10) >= dateRange.value[0] && order.createdAt.slice(0, 10) <= dateRange.value[1])
|
||||||
|
return matchesKeyword && matchesStatus && matchesMode && matchesDate
|
||||||
|
}))
|
||||||
|
|
||||||
|
const detailOrder = computed(() => detailId.value ? store.getById(detailId.value) : undefined)
|
||||||
|
const detailSummary = computed(() => detailOrder.value ? summarizeFirstLegCargo(detailOrder.value.items) : null)
|
||||||
|
const selectedWarehouse = computed(() => availableWarehouses.value.find((item) => item.id === form.receivingWarehouseId))
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
await Promise.all([store.load(), firstLegPartySource.load()])
|
||||||
|
availableShippers.value = firstLegPartySource.listAvailable('SHIPPER')
|
||||||
|
availableConsignees.value = firstLegPartySource.listAvailable('CONSIGNEE')
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(error instanceof Error ? error.message : '订单与往来单位数据加载失败')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function replaceForm(draft: FirstLegOrderDraft) {
|
||||||
|
Object.assign(form, structuredClone(draft))
|
||||||
|
}
|
||||||
|
|
||||||
|
function draftFromOrder(order: FirstLegOrder): FirstLegOrderDraft {
|
||||||
|
return {
|
||||||
|
referenceNo: order.referenceNo,
|
||||||
|
transportMode: order.transportMode,
|
||||||
|
businessType: order.businessType,
|
||||||
|
origin: order.origin,
|
||||||
|
destination: order.destination,
|
||||||
|
receivingWarehouseId: order.receivingWarehouseId,
|
||||||
|
expectedDepartureDate: order.expectedDepartureDate,
|
||||||
|
shipperPartyId: order.shipperPartyId,
|
||||||
|
shipperPartySnapshot: order.shipperPartySnapshot ? structuredClone(order.shipperPartySnapshot) : null,
|
||||||
|
shipper: order.shipper,
|
||||||
|
consigneePartyId: order.consigneePartyId,
|
||||||
|
consigneePartySnapshot: order.consigneePartySnapshot ? structuredClone(order.consigneePartySnapshot) : null,
|
||||||
|
consignee: order.consignee,
|
||||||
|
priority: order.priority,
|
||||||
|
customerRemark: order.customerRemark,
|
||||||
|
tradeTerm: order.tradeTerm,
|
||||||
|
destinationCountryCode: order.destinationCountryCode,
|
||||||
|
destinationWarehouseName: order.destinationWarehouseName,
|
||||||
|
pickupMethod: order.pickupMethod,
|
||||||
|
channelCode: order.channelCode,
|
||||||
|
channelName: order.channelName,
|
||||||
|
taxMode: order.taxMode,
|
||||||
|
customsInspection: order.customsInspection,
|
||||||
|
destinationType: order.destinationType,
|
||||||
|
amazonWarehouseCode: order.amazonWarehouseCode,
|
||||||
|
shipmentId: order.shipmentId,
|
||||||
|
items: structuredClone(order.items),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectParty(role: FirstLegPartyRole, id: string) {
|
||||||
|
const party = firstLegPartySource.findAvailable(id, role)
|
||||||
|
if (!party) return
|
||||||
|
const snapshot = createFirstLegPartySnapshot(party, role)
|
||||||
|
if (role === 'SHIPPER') {
|
||||||
|
form.shipperPartyId = party.id
|
||||||
|
form.shipperPartySnapshot = snapshot
|
||||||
|
form.shipper = party.name
|
||||||
|
} else {
|
||||||
|
form.consigneePartyId = party.id
|
||||||
|
form.consigneePartySnapshot = snapshot
|
||||||
|
form.consignee = party.name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearParty(role: FirstLegPartyRole) {
|
||||||
|
if (role === 'SHIPPER') {
|
||||||
|
form.shipperPartyId = ''
|
||||||
|
form.shipperPartySnapshot = null
|
||||||
|
form.shipper = ''
|
||||||
|
} else {
|
||||||
|
form.consigneePartyId = ''
|
||||||
|
form.consigneePartySnapshot = null
|
||||||
|
form.consignee = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectShipper(id: string) {
|
||||||
|
selectParty('SHIPPER', id)
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectConsignee(id: string) {
|
||||||
|
selectParty('CONSIGNEE', id)
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearShipper() {
|
||||||
|
clearParty('SHIPPER')
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearConsignee() {
|
||||||
|
clearParty('CONSIGNEE')
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreate() {
|
||||||
|
if (availableWarehouses.value.length === 0) {
|
||||||
|
ElMessage.warning('当前没有可用的平台收货仓,请联系平台维护仓库后再创建订单')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
editingId.value = undefined
|
||||||
|
replaceForm(emptyDraft())
|
||||||
|
editorOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(order: FirstLegOrder) {
|
||||||
|
if (!canEditOrder.value || !canClientEditFirstLegOrder(order.status)) return
|
||||||
|
editingId.value = order.id
|
||||||
|
replaceForm(draftFromOrder(order))
|
||||||
|
detailOpen.value = false
|
||||||
|
editorOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openDetail(order: FirstLegOrder) {
|
||||||
|
detailId.value = order.id
|
||||||
|
detailOpen.value = true
|
||||||
|
detailLoading.value = true
|
||||||
|
try {
|
||||||
|
await store.loadDetail(order.id)
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(error instanceof Error ? error.message : '订单详情加载失败')
|
||||||
|
} finally {
|
||||||
|
detailLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addCargoItem() {
|
||||||
|
form.items.push(createEmptyCargoItem(`new-cargo-${++cargoSequence}`))
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeCargoItem(index: number) {
|
||||||
|
if (form.items.length === 1) {
|
||||||
|
ElMessage.warning('订单至少需要保留一条货物明细')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
form.items.splice(index, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveDraft() {
|
||||||
|
if (saving.value) return
|
||||||
|
const errors = validateFirstLegOrderDraft(form)
|
||||||
|
if (errors.length > 0) {
|
||||||
|
ElMessage.warning(errors[0])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
const order = await store.saveDraft(structuredClone(form), tenantStore.company.companyName, editingId.value)
|
||||||
|
editingId.value = order.id
|
||||||
|
editorOpen.value = false
|
||||||
|
ElMessage.success('订单草稿已保存')
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(error instanceof Error ? error.message : '草稿保存失败')
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitOrder() {
|
||||||
|
if (saving.value) return
|
||||||
|
if (!companyApproved.value) {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm('企业资料审核通过后才能提交订单。您可以继续保存草稿,或前往企业资料核对并补充信息。', '暂时无法提交订单', {
|
||||||
|
type: 'warning',
|
||||||
|
confirmButtonText: '前往企业资料',
|
||||||
|
cancelButtonText: '继续编辑草稿',
|
||||||
|
})
|
||||||
|
editorOpen.value = false
|
||||||
|
await router.push('/company/profile')
|
||||||
|
} catch (error) {
|
||||||
|
if (error === 'cancel' || error === 'close') return
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const errors = validateFirstLegOrderDraft(form)
|
||||||
|
if (errors.length > 0) {
|
||||||
|
ElMessage.warning(errors[0])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
saving.value = true
|
||||||
|
const order = await store.submit(structuredClone(form), tenantStore.company.companyName, editingId.value, tenantStore.company.auditStatus)
|
||||||
|
editorOpen.value = false
|
||||||
|
detailId.value = order.id
|
||||||
|
detailOpen.value = true
|
||||||
|
ElMessage.success('订单已提交,等待平台审核')
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(error instanceof Error ? error.message : '订单提交失败')
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetFilters() {
|
||||||
|
filters.keyword = ''
|
||||||
|
filters.status = ''
|
||||||
|
filters.transportMode = ''
|
||||||
|
dateRange.value = []
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="first-leg-page">
|
||||||
|
<div class="order-eligibility" :class="companyApproved ? 'is-approved' : 'is-pending'" role="status">
|
||||||
|
<component :is="companyApproved ? CheckCircle2 : AlertTriangle" :size="20" />
|
||||||
|
<div><strong>{{ companyApproved ? '企业资料已审核,可正常提交订单' : canCreateOrder ? '企业资料待审核,当前仅可保存订单草稿' : '企业资料待审核,当前订单为只读' }}</strong><span>{{ companyApproved ? '提交后订单将进入平台审核。' : canCreateOrder ? '平台审核通过后即可提交;您仍可创建、编辑和保存草稿。' : '您当前拥有只读权限,可以继续查看订单进展。' }}</span></div>
|
||||||
|
<el-button v-if="!companyApproved && auth.hasPermission('company:profile:view')" link type="primary" @click="router.push('/company/profile')">查看企业资料</el-button>
|
||||||
|
</div>
|
||||||
|
<div class="order-metrics" aria-label="订单概览">
|
||||||
|
<article><span>全部订单</span><strong>{{ store.orders.length }}</strong></article>
|
||||||
|
<article><span>进行中</span><strong>{{ store.activeCount }}</strong></article>
|
||||||
|
<article><span>待平台审核</span><strong>{{ store.pendingCount }}</strong></article>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="order-toolbar client-card">
|
||||||
|
<div class="order-filters">
|
||||||
|
<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 v-for="item in FIRST_LEG_ORDER_STATUSES" :key="item.code" :label="item.label" :value="item.code" />
|
||||||
|
</el-select>
|
||||||
|
<el-select v-model="filters.transportMode" clearable placeholder="全部运输方式" aria-label="运输方式">
|
||||||
|
<el-option v-for="item in FIRST_LEG_TRANSPORT_MODES" :key="item.code" :label="item.label" :value="item.code" />
|
||||||
|
</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="order-toolbar-actions">
|
||||||
|
<el-button @click="resetFilters"><RotateCcw :size="16" />重置</el-button>
|
||||||
|
<el-button v-if="auth.hasPermission('order:first-leg:create')" type="primary" :disabled="availableWarehouses.length === 0" @click="openCreate"><FilePlus2 :size="17" />创建订单</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-loading="store.loading" class="client-card order-table-card">
|
||||||
|
<el-table v-if="filteredOrders.length" class="desktop-order-table" :data="filteredOrders" row-key="id">
|
||||||
|
<el-table-column label="订单" min-width="190">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<button class="order-number-link" type="button" @click="openDetail(row as FirstLegOrder)">{{ (row as FirstLegOrder).orderNo || '未提交草稿' }}</button>
|
||||||
|
<span class="order-reference">{{ (row as FirstLegOrder).referenceNo || '无客户参考号' }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="运输路线" min-width="190">
|
||||||
|
<template #default="{ row }"><strong>{{ (row as FirstLegOrder).origin }} → {{ (row as FirstLegOrder).destination }}</strong><span class="order-reference">{{ getFirstLegTransportModeLabel((row as FirstLegOrder).transportMode) }}</span></template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="收货仓" min-width="120" prop="receivingWarehouseName" />
|
||||||
|
<el-table-column label="货物" min-width="150">
|
||||||
|
<template #default="{ row }">{{ (row as FirstLegOrder).items[0]?.cargoNameCn || '-' }}<span v-if="(row as FirstLegOrder).items.length > 1" class="order-reference">等 {{ (row as FirstLegOrder).items.length }} 项</span></template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="状态" width="120">
|
||||||
|
<template #default="{ row }"><el-tag :type="getFirstLegStatusTone((row as FirstLegOrder).status)" effect="light">{{ getFirstLegStatusLabel((row as FirstLegOrder).status) }}</el-tag></template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="负责人" prop="operatorName" width="110" />
|
||||||
|
<el-table-column label="更新时间" prop="updatedAt" width="155" />
|
||||||
|
<el-table-column label="操作" width="180">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div class="order-row-actions">
|
||||||
|
<el-button link type="primary" @click="openDetail(row as FirstLegOrder)"><Eye :size="15" />详情</el-button>
|
||||||
|
<el-button v-if="canEditOrder && canClientEditFirstLegOrder((row as FirstLegOrder).status)" link type="primary" @click="openEdit(row as FirstLegOrder)"><Pencil :size="15" />编辑</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
<div v-if="filteredOrders.length" class="mobile-order-list">
|
||||||
|
<article v-for="order in filteredOrders" :key="order.id" class="mobile-order-card">
|
||||||
|
<header><button type="button" @click="openDetail(order)">{{ order.orderNo || '未提交草稿' }}</button><el-tag :type="getFirstLegStatusTone(order.status)" effect="light">{{ getFirstLegStatusLabel(order.status) }}</el-tag></header>
|
||||||
|
<span class="order-reference">{{ order.referenceNo || '无客户参考号' }}</span>
|
||||||
|
<dl><div><dt>运输路线</dt><dd>{{ order.origin }} → {{ order.destination }}</dd></div><div><dt>收货仓</dt><dd>{{ order.receivingWarehouseName }}</dd></div><div><dt>货物</dt><dd>{{ order.items[0]?.cargoNameCn || '-' }}<span v-if="order.items.length > 1">等 {{ order.items.length }} 项</span></dd></div><div><dt>负责人</dt><dd>{{ order.operatorName }}</dd></div></dl>
|
||||||
|
<footer><span>更新于 {{ order.updatedAt }}</span><div><el-button link type="primary" @click="openDetail(order)"><Eye :size="15" />详情</el-button><el-button v-if="canEditOrder && canClientEditFirstLegOrder(order.status)" link type="primary" @click="openEdit(order)"><Pencil :size="15" />编辑</el-button></div></footer>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
<el-empty v-else description="没有符合条件的头程订单">
|
||||||
|
<el-button v-if="auth.hasPermission('order:first-leg:create')" type="primary" :disabled="availableWarehouses.length === 0" @click="openCreate"><Plus :size="16" />{{ availableWarehouses.length ? '创建第一笔订单' : '暂无可用收货仓' }}</el-button>
|
||||||
|
</el-empty>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-drawer v-model="editorOpen" class="first-leg-editor" :title="editingId ? '编辑订单草稿' : '创建头程订单'" size="760px" destroy-on-close>
|
||||||
|
<el-form label-position="top" class="order-form">
|
||||||
|
<section class="order-form-section">
|
||||||
|
<h3>运输信息</h3>
|
||||||
|
<div class="order-form-grid">
|
||||||
|
<el-form-item label="运输方式" required><el-select v-model="form.transportMode"><el-option v-for="item in FIRST_LEG_TRANSPORT_MODES" :key="item.code" :label="item.label" :value="item.code" /></el-select></el-form-item>
|
||||||
|
<el-form-item label="业务类型" required><el-radio-group v-model="form.businessType"><el-radio-button value="EXPORT">出口</el-radio-button><el-radio-button value="IMPORT">进口</el-radio-button></el-radio-group></el-form-item>
|
||||||
|
<el-form-item label="起运地" required><el-input v-model="form.origin" placeholder="例如:宁波" /></el-form-item>
|
||||||
|
<el-form-item label="目的地" 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 availableWarehouses" :key="warehouse.id" :label="`${warehouse.name} · ${warehouse.city}`" :value="warehouse.id" /></el-select></el-form-item>
|
||||||
|
<el-form-item label="期望出发日期"><el-date-picker v-model="form.expectedDepartureDate" type="date" value-format="YYYY-MM-DD" placeholder="请选择日期" /></el-form-item>
|
||||||
|
<el-form-item label="客户参考号"><el-input v-model="form.referenceNo" maxlength="100" placeholder="贵司内部单号(选填)" /></el-form-item>
|
||||||
|
<el-form-item label="优先级"><el-select v-model="form.priority"><el-option label="普通" value="NORMAL" /><el-option label="紧急" value="URGENT" /><el-option label="加急" value="EXPEDITED" /></el-select></el-form-item>
|
||||||
|
<el-form-item label="目的国代码" required><el-input v-model="form.destinationCountryCode" maxlength="2" placeholder="例如:US" /></el-form-item>
|
||||||
|
<el-form-item label="目的仓名称" required><el-input v-model="form.destinationWarehouseName" maxlength="128" placeholder="例如:ONT8" /></el-form-item>
|
||||||
|
<el-form-item label="渠道编码" required><el-input v-model="form.channelCode" maxlength="64" /></el-form-item>
|
||||||
|
<el-form-item label="渠道名称" required><el-input v-model="form.channelName" maxlength="128" /></el-form-item>
|
||||||
|
<el-form-item label="提货方式" required><el-select v-model="form.pickupMethod"><el-option label="自送快递" value="SELF_EXPRESS" /><el-option label="上门提货" value="PICKUP" /><el-option label="自送卡车" value="SELF_TRUCK" /></el-select></el-form-item>
|
||||||
|
<el-form-item label="报关模式" required><el-select v-model="form.taxMode"><el-option label="买单报关" value="BUY_DECLARATION" /><el-option label="退税报关" value="TAX_REFUND" /></el-select></el-form-item>
|
||||||
|
<el-form-item label="目的仓类型" required><el-select v-model="form.destinationType"><el-option label="FBA" value="FBA" /><el-option label="海外仓" value="OVERSEAS" /><el-option label="私人地址" value="PRIVATE" /></el-select></el-form-item>
|
||||||
|
<el-form-item label="贸易术语"><el-input v-model="form.tradeTerm" maxlength="16" placeholder="例如:FOB" /></el-form-item>
|
||||||
|
<el-form-item v-if="form.destinationType === 'FBA'" label="Amazon 仓库编码"><el-input v-model="form.amazonWarehouseCode" maxlength="32" placeholder="例如:ONT8" /></el-form-item>
|
||||||
|
<el-form-item v-if="form.destinationType === 'FBA'" label="Shipment ID"><el-input v-model="form.shipmentId" maxlength="64" /></el-form-item>
|
||||||
|
</div>
|
||||||
|
<div v-if="selectedWarehouse" class="selected-warehouse"><MapPin :size="17" /><div><strong>{{ selectedWarehouse.name }}</strong><span>{{ selectedWarehouse.province }}{{ selectedWarehouse.city }} · {{ selectedWarehouse.address }}</span><small>收货时间:{{ selectedWarehouse.businessHours }}</small></div></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="order-form-section">
|
||||||
|
<div class="order-section-heading"><div><h3>收发货单位</h3><p>仅可选择平台已启用的往来单位;下方联系人和地址调整只影响本订单快照。</p></div></div>
|
||||||
|
<div class="party-editor-grid">
|
||||||
|
<article class="party-editor">
|
||||||
|
<header><span><Building2 :size="17" /><strong>发货单位</strong></span><small>必填</small></header>
|
||||||
|
<el-form-item label="选择发货单位" required>
|
||||||
|
<el-select v-model="form.shipperPartyId" filterable clearable placeholder="请选择已启用的发货单位" @change="selectShipper" @clear="clearShipper">
|
||||||
|
<el-option v-for="party in availableShippers" :key="party.id" :label="`${party.name} · ${party.code}`" :value="party.id" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<div v-if="form.shipperPartySnapshot" class="party-snapshot-fields">
|
||||||
|
<div class="party-name"><strong>{{ form.shipperPartySnapshot.name }}</strong><span>{{ form.shipperPartySnapshot.code }}</span></div>
|
||||||
|
<el-form-item label="联系人"><el-input v-model="form.shipperPartySnapshot.contactName" maxlength="50" /></el-form-item>
|
||||||
|
<el-form-item label="联系电话"><el-input v-model="form.shipperPartySnapshot.phone" maxlength="30" /></el-form-item>
|
||||||
|
<el-form-item label="联系邮箱"><el-input v-model="form.shipperPartySnapshot.email" maxlength="100" /></el-form-item>
|
||||||
|
<el-form-item label="本单发货地址"><el-input v-model="form.shipperPartySnapshot.address" maxlength="300" /></el-form-item>
|
||||||
|
</div>
|
||||||
|
<div v-else class="party-empty">选择后可核对本单联系人和发货地址</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="party-editor">
|
||||||
|
<header><span><Building2 :size="17" /><strong>收货单位</strong></span><small>必填</small></header>
|
||||||
|
<el-form-item label="选择收货单位" required>
|
||||||
|
<el-select v-model="form.consigneePartyId" filterable clearable placeholder="请选择已启用的收货单位" @change="selectConsignee" @clear="clearConsignee">
|
||||||
|
<el-option v-for="party in availableConsignees" :key="party.id" :label="`${party.name} · ${party.code}`" :value="party.id" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<div v-if="form.consigneePartySnapshot" class="party-snapshot-fields">
|
||||||
|
<div class="party-name"><strong>{{ form.consigneePartySnapshot.name }}</strong><span>{{ form.consigneePartySnapshot.code }}</span></div>
|
||||||
|
<el-form-item label="联系人"><el-input v-model="form.consigneePartySnapshot.contactName" maxlength="50" /></el-form-item>
|
||||||
|
<el-form-item label="联系电话"><el-input v-model="form.consigneePartySnapshot.phone" maxlength="30" /></el-form-item>
|
||||||
|
<el-form-item label="联系邮箱"><el-input v-model="form.consigneePartySnapshot.email" maxlength="100" /></el-form-item>
|
||||||
|
<el-form-item label="本单收货地址"><el-input v-model="form.consigneePartySnapshot.address" maxlength="300" /></el-form-item>
|
||||||
|
</div>
|
||||||
|
<div v-else class="party-empty">选择后可核对本单联系人和收货地址</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="order-form-section">
|
||||||
|
<div class="order-section-heading"><div><h3>货物明细</h3><p>总件数、重量和体积将在提交时由服务端汇总。</p></div><el-button @click="addCargoItem"><Plus :size="16" />添加货物</el-button></div>
|
||||||
|
<article v-for="(item, index) in form.items" :key="item.id" class="cargo-editor-card">
|
||||||
|
<header><strong>货物 {{ index + 1 }}</strong><el-button link type="danger" :aria-label="`删除货物 ${index + 1}`" @click="removeCargoItem(index)"><Trash2 :size="16" />删除</el-button></header>
|
||||||
|
<div class="cargo-form-grid">
|
||||||
|
<el-form-item label="中文品名" required><el-input v-model="item.cargoNameCn" /></el-form-item>
|
||||||
|
<el-form-item label="英文品名"><el-input v-model="item.cargoNameEn" /></el-form-item>
|
||||||
|
<el-form-item label="客户 SKU"><el-input v-model="item.sku" /></el-form-item>
|
||||||
|
<el-form-item label="包装类型" required><el-select v-model="item.packageType"><el-option label="纸箱" value="CARTON" /><el-option label="托盘" value="PALLET" /><el-option label="木箱" value="WOODEN_CASE" /></el-select></el-form-item>
|
||||||
|
<el-form-item label="HS Code"><el-input v-model="item.hsCode" maxlength="20" /></el-form-item>
|
||||||
|
<el-form-item label="件数" required><el-input-number v-model="item.pieces" :min="1" controls-position="right" /></el-form-item>
|
||||||
|
<el-form-item label="箱数"><el-input-number v-model="item.packageCount" :min="0" controls-position="right" /></el-form-item>
|
||||||
|
<el-form-item label="毛重 (kg)" required><el-input-number v-model="item.grossWeightKg" :min="0" :precision="2" controls-position="right" /></el-form-item>
|
||||||
|
<el-form-item label="体积 (m³)" required><el-input-number v-model="item.volumeCbm" :min="0" :precision="3" controls-position="right" /></el-form-item>
|
||||||
|
<el-form-item label="申报货值"><el-input-number v-model="item.declaredValue" :min="0" :precision="2" controls-position="right" /></el-form-item>
|
||||||
|
<el-form-item label="币种"><el-input v-model="item.currency" maxlength="3" /></el-form-item>
|
||||||
|
<el-form-item label="危险品"><el-switch v-model="item.dangerousGoods" /></el-form-item>
|
||||||
|
<template v-if="form.transportMode === 'FCL'">
|
||||||
|
<el-form-item label="箱型" required><el-select v-model="item.containerType"><el-option v-for="type in ['20GP', '40GP', '40HQ']" :key="type" :label="type" :value="type" /></el-select></el-form-item>
|
||||||
|
<el-form-item label="柜量" required><el-input-number v-model="item.containerCount" :min="0" controls-position="right" /></el-form-item>
|
||||||
|
</template>
|
||||||
|
<el-form-item label="货物备注" class="cargo-remark"><el-input v-model="item.remark" maxlength="300" /></el-form-item>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="order-form-section">
|
||||||
|
<h3>订单备注</h3>
|
||||||
|
<el-form-item label="客户备注"><el-input v-model="form.customerRemark" type="textarea" :rows="3" maxlength="1000" show-word-limit placeholder="平台工作人员可以看到该备注" /></el-form-item>
|
||||||
|
</section>
|
||||||
|
</el-form>
|
||||||
|
<template #footer><div class="editor-actions"><el-button :disabled="saving" @click="editorOpen = false">返回</el-button><el-button v-if="editingId ? canEditOrder : canCreateOrder" :loading="saving" @click="saveDraft">保存草稿</el-button><el-button v-if="auth.hasPermission('order:first-leg:submit')" type="primary" :loading="saving" @click="submitOrder"><Send :size="16" />{{ companyApproved ? '提交审核' : '提交前需完成资料审核' }}</el-button></div></template>
|
||||||
|
</el-drawer>
|
||||||
|
|
||||||
|
<el-drawer v-model="detailOpen" v-loading="detailLoading" class="first-leg-detail" title="订单详情" size="680px">
|
||||||
|
<template v-if="detailOrder">
|
||||||
|
<div class="order-detail-heading">
|
||||||
|
<div><span>{{ getFirstLegTransportModeLabel(detailOrder.transportMode) }}</span><h2>{{ detailOrder.orderNo || '未提交草稿' }}</h2><p>{{ detailOrder.origin }} → {{ detailOrder.destination }}</p></div>
|
||||||
|
<el-tag :type="getFirstLegStatusTone(detailOrder.status)" effect="light">{{ getFirstLegStatusLabel(detailOrder.status) }}</el-tag>
|
||||||
|
</div>
|
||||||
|
<dl class="order-detail-grid">
|
||||||
|
<div><dt>客户参考号</dt><dd>{{ detailOrder.referenceNo || '-' }}</dd></div><div><dt>运输方式</dt><dd>{{ getFirstLegTransportModeLabel(detailOrder.transportMode) }}</dd></div>
|
||||||
|
<div><dt>发货方</dt><dd>{{ detailOrder.shipperPartySnapshot?.name || detailOrder.shipper }}<span v-if="detailOrder.shipperPartySnapshot" class="detail-subline">{{ detailOrder.shipperPartySnapshot.code }} · {{ detailOrder.shipperPartySnapshot.contactName || '未填写联系人' }} · {{ detailOrder.shipperPartySnapshot.phone || '未填写电话' }}<br />{{ detailOrder.shipperPartySnapshot.countryRegion }} {{ detailOrder.shipperPartySnapshot.province }} {{ detailOrder.shipperPartySnapshot.city }} {{ detailOrder.shipperPartySnapshot.address }}</span></dd></div>
|
||||||
|
<div><dt>收货方</dt><dd>{{ detailOrder.consigneePartySnapshot?.name || detailOrder.consignee }}<span v-if="detailOrder.consigneePartySnapshot" class="detail-subline">{{ detailOrder.consigneePartySnapshot.code }} · {{ detailOrder.consigneePartySnapshot.contactName || '未填写联系人' }} · {{ detailOrder.consigneePartySnapshot.phone || '未填写电话' }}<br />{{ detailOrder.consigneePartySnapshot.countryRegion }} {{ detailOrder.consigneePartySnapshot.province }} {{ detailOrder.consigneePartySnapshot.city }} {{ detailOrder.consigneePartySnapshot.address }}</span></dd></div>
|
||||||
|
<div><dt>平台收货仓</dt><dd>{{ detailOrder.receivingWarehouseSnapshot?.name || detailOrder.receivingWarehouseName }}<span v-if="detailOrder.receivingWarehouseSnapshot" class="detail-subline">{{ detailOrder.receivingWarehouseSnapshot.code }} · {{ detailOrder.receivingWarehouseSnapshot.province }}{{ detailOrder.receivingWarehouseSnapshot.city }}{{ detailOrder.receivingWarehouseSnapshot.address }}</span></dd></div><div><dt>负责人</dt><dd>{{ detailOrder.operatorName }}</dd></div>
|
||||||
|
<div><dt>期望出发</dt><dd>{{ detailOrder.expectedDepartureDate || '-' }}</dd></div><div><dt>ETD / ETA</dt><dd>{{ detailOrder.etd || '-' }} / {{ detailOrder.eta || '-' }}</dd></div>
|
||||||
|
</dl>
|
||||||
|
<section class="detail-section">
|
||||||
|
<h3>履约快递信息</h3>
|
||||||
|
<div v-if="detailOrder.expressCompanyName || detailOrder.trackingNumber" class="express-information"><PackageCheck :size="20" /><div><span>快递公司</span><strong>{{ detailOrder.expressCompanyName || '待平台补充' }}</strong></div><div><span>快递单号</span><strong>{{ detailOrder.trackingNumber || '待平台补充' }}</strong></div></div>
|
||||||
|
<div v-else class="express-empty"><PackageCheck :size="20" /><div><strong>平台尚未维护快递信息</strong><span>快递公司和快递单号由平台后台维护,客户端仅只读展示。</span></div></div>
|
||||||
|
</section>
|
||||||
|
<section class="detail-section"><h3>货物汇总</h3><div v-if="detailSummary" class="cargo-summary"><div><Box :size="18" /><span>明细</span><strong>{{ detailOrder.items.length }} 项</strong></div><div><span>件数</span><strong>{{ detailSummary.pieces }}</strong></div><div><span>毛重</span><strong>{{ detailSummary.grossWeightKg.toLocaleString() }} kg</strong></div><div><span>体积</span><strong>{{ detailSummary.volumeCbm }} m³</strong></div></div><ul class="cargo-detail-list"><li v-for="item in detailOrder.items" :key="item.id"><strong>{{ item.cargoNameCn }}</strong><span>{{ item.sku || '无 SKU' }} · {{ item.pieces }} 件 · {{ item.grossWeightKg }} kg</span></li></ul></section>
|
||||||
|
<section class="detail-section"><h3>处理时间线</h3><el-timeline><el-timeline-item v-for="item in [...detailOrder.timeline].reverse()" :key="item.id" :timestamp="item.occurredAt" placement="top"><strong>{{ item.title }}</strong><p>{{ item.description }}</p><span>{{ item.operatorName }}</span></el-timeline-item></el-timeline></section>
|
||||||
|
</template>
|
||||||
|
<template #footer><div v-if="detailOrder" class="editor-actions"><el-button @click="detailOpen = false">关闭</el-button><el-button v-if="canEditOrder && canClientEditFirstLegOrder(detailOrder.status)" @click="openEdit(detailOrder)"><Pencil :size="16" />编辑草稿</el-button></div></template>
|
||||||
|
</el-drawer>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.first-leg-page { display: flex; flex-direction: column; gap: 14px; }
|
||||||
|
.order-eligibility { display: flex; align-items: flex-start; gap: 12px; padding: 14px 16px; border: 1px solid; }
|
||||||
|
.order-eligibility svg { flex: 0 0 20px; margin-top: 1px; }
|
||||||
|
.order-eligibility div { display: flex; min-width: 0; flex: 1; flex-direction: column; gap: 3px; }
|
||||||
|
.order-eligibility strong { color: var(--navy); font-size: 13px; }
|
||||||
|
.order-eligibility span { color: var(--muted); font-size: 11px; line-height: 1.55; }
|
||||||
|
.order-eligibility.is-approved { border-color: #bcd9d2; background: #eff8f5; color: #20745f; }
|
||||||
|
.order-eligibility.is-pending { border-color: #e5d2a5; background: #fff9eb; color: #9a6a12; }
|
||||||
|
.order-metrics { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; }
|
||||||
|
.order-metrics article { display: flex; align-items: center; justify-content: space-between; min-height: 76px; padding: 16px 20px; border: 1px solid var(--border); background: #fff; }
|
||||||
|
.order-metrics span { color: var(--muted); font-size: 12px; }
|
||||||
|
.order-metrics strong { color: var(--navy); font-size: 25px; }
|
||||||
|
.order-toolbar { display: flex; align-items: stretch; flex-direction: column; gap: 12px; padding: 16px; }
|
||||||
|
.order-filters { display: grid; width: 100%; grid-template-columns: minmax(190px, 1.35fr) minmax(130px, .65fr) minmax(150px, .75fr) minmax(230px, 1fr); gap: 10px; }
|
||||||
|
.order-toolbar-actions, .order-row-actions, .editor-actions { display: flex; justify-content: flex-end; gap: 8px; }
|
||||||
|
.order-table-card { padding: 0; overflow: hidden; }
|
||||||
|
.mobile-order-list { display: none; }
|
||||||
|
.order-number-link { display: block; padding: 0; border: 0; background: none; color: var(--primary); cursor: pointer; font-weight: 700; text-align: left; }
|
||||||
|
.order-number-link:focus-visible { outline: 2px solid var(--primary); outline-offset: 3px; }
|
||||||
|
.order-reference { display: block; margin-top: 4px; color: var(--muted); font-size: 11px; font-weight: 400; }
|
||||||
|
.order-form { padding: 0 2px 28px; }
|
||||||
|
.order-form-section + .order-form-section { margin-top: 24px; padding-top: 22px; border-top: 1px solid var(--border); }
|
||||||
|
.order-form-section h3 { margin: 0 0 16px; color: var(--navy); font-size: 16px; }
|
||||||
|
.order-form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); column-gap: 18px; }
|
||||||
|
.order-form-grid :deep(.el-select), .order-form-grid :deep(.el-date-editor), .cargo-form-grid :deep(.el-select), .cargo-form-grid :deep(.el-input-number) { width: 100%; }
|
||||||
|
.selected-warehouse { display: flex; gap: 10px; margin-top: -2px; padding: 12px 14px; border: 1px solid #cfe1dd; background: #f3f8f7; color: var(--primary); }
|
||||||
|
.selected-warehouse svg { flex: 0 0 17px; margin-top: 2px; }
|
||||||
|
.selected-warehouse div { display: flex; min-width: 0; flex-direction: column; gap: 4px; }
|
||||||
|
.selected-warehouse span, .selected-warehouse small { color: var(--muted); font-size: 11px; line-height: 1.5; }
|
||||||
|
.party-editor-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; }
|
||||||
|
.party-editor { min-width: 0; padding: 16px; border: 1px solid var(--border); background: #f9fbfb; }
|
||||||
|
.party-editor > header { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 14px; }
|
||||||
|
.party-editor > header span { display: flex; min-width: 0; align-items: center; gap: 8px; color: var(--navy); }
|
||||||
|
.party-editor > header svg { flex: 0 0 17px; color: var(--primary); }
|
||||||
|
.party-editor > header small { color: var(--muted); font-size: 11px; }
|
||||||
|
.party-editor :deep(.el-select) { width: 100%; }
|
||||||
|
.party-snapshot-fields { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); column-gap: 12px; padding-top: 12px; border-top: 1px solid var(--border); }
|
||||||
|
.party-name { display: flex; min-width: 0; grid-column: 1 / -1; align-items: baseline; justify-content: space-between; gap: 12px; margin-bottom: 12px; }
|
||||||
|
.party-name strong { min-width: 0; overflow-wrap: anywhere; color: var(--navy); font-size: 13px; }
|
||||||
|
.party-name span { flex: 0 0 auto; color: var(--muted); font-size: 10px; }
|
||||||
|
.party-snapshot-fields .el-form-item:last-child { grid-column: 1 / -1; }
|
||||||
|
.party-empty { display: grid; min-height: 150px; place-items: center; border-top: 1px solid var(--border); color: var(--muted); font-size: 12px; text-align: center; }
|
||||||
|
.order-section-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; margin-bottom: 12px; }
|
||||||
|
.order-section-heading h3 { margin-bottom: 5px; }
|
||||||
|
.order-section-heading p { margin: 0; color: var(--muted); font-size: 11px; }
|
||||||
|
.cargo-editor-card { padding: 16px; border: 1px solid var(--border); background: #f9fbfb; }
|
||||||
|
.cargo-editor-card + .cargo-editor-card { margin-top: 12px; }
|
||||||
|
.cargo-editor-card header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; }
|
||||||
|
.cargo-form-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); column-gap: 14px; }
|
||||||
|
.cargo-remark { grid-column: 1 / -1; }
|
||||||
|
.order-detail-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; padding-bottom: 20px; border-bottom: 1px solid var(--border); }
|
||||||
|
.order-detail-heading span { color: var(--muted); font-size: 11px; }
|
||||||
|
.order-detail-heading h2 { margin: 6px 0; font-size: 21px; }
|
||||||
|
.order-detail-heading p { margin: 0; color: var(--muted); }
|
||||||
|
.order-detail-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0; margin: 18px 0; border: 1px solid var(--border); }
|
||||||
|
.order-detail-grid div { padding: 13px; border-bottom: 1px solid var(--border); }
|
||||||
|
.order-detail-grid div:nth-child(odd) { border-right: 1px solid var(--border); }
|
||||||
|
.order-detail-grid dt { color: var(--muted); font-size: 11px; }
|
||||||
|
.order-detail-grid dd { margin: 5px 0 0; font-size: 13px; font-weight: 600; }
|
||||||
|
.detail-subline { display: block; margin-top: 4px; color: var(--muted); font-size: 11px; font-weight: 400; line-height: 1.5; }
|
||||||
|
.detail-section { margin-top: 22px; }
|
||||||
|
.detail-section h3 { margin: 0 0 12px; font-size: 15px; }
|
||||||
|
.express-information { display: grid; grid-template-columns: auto repeat(2, minmax(0, 1fr)); align-items: center; gap: 16px; padding: 15px 16px; background: #f2f7f6; color: var(--primary); }
|
||||||
|
.express-information > div { display: flex; min-width: 0; flex-direction: column; gap: 4px; }
|
||||||
|
.express-information span { color: var(--muted); font-size: 11px; }
|
||||||
|
.express-information strong { overflow-wrap: anywhere; color: var(--navy); font-size: 13px; }
|
||||||
|
.express-empty { display: flex; align-items: flex-start; gap: 12px; padding: 15px 16px; border: 1px dashed #cbd5da; color: var(--muted); }
|
||||||
|
.express-empty svg { flex: 0 0 20px; color: var(--primary); }
|
||||||
|
.express-empty div { display: flex; min-width: 0; flex-direction: column; gap: 4px; }
|
||||||
|
.express-empty strong { color: var(--navy); font-size: 13px; }
|
||||||
|
.express-empty span { font-size: 11px; line-height: 1.5; }
|
||||||
|
.cargo-summary { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); border: 1px solid var(--border); }
|
||||||
|
.cargo-summary div { display: flex; min-height: 74px; align-items: center; justify-content: center; flex-direction: column; gap: 4px; border-right: 1px solid var(--border); }
|
||||||
|
.cargo-summary div:last-child { border-right: 0; }
|
||||||
|
.cargo-summary span { color: var(--muted); font-size: 11px; }
|
||||||
|
.cargo-detail-list { margin: 10px 0 0; padding: 0; list-style: none; }
|
||||||
|
.cargo-detail-list li { display: flex; justify-content: space-between; gap: 12px; padding: 11px 0; border-bottom: 1px solid var(--border); font-size: 12px; }
|
||||||
|
.cargo-detail-list span, :deep(.el-timeline-item__content p), :deep(.el-timeline-item__content > span) { color: var(--muted); }
|
||||||
|
:deep(.el-timeline-item__content p) { margin: 5px 0; font-size: 12px; }
|
||||||
|
:deep(.el-timeline-item__content > span) { font-size: 11px; }
|
||||||
|
@media (max-width: 1100px) { .order-toolbar-actions { align-self: flex-end; } .order-filters { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.order-eligibility { flex-wrap: wrap; }
|
||||||
|
.order-eligibility .el-button { width: 100%; min-height: 44px; }
|
||||||
|
.order-metrics { grid-template-columns: 1fr; }
|
||||||
|
.order-metrics article { min-height: 60px; }
|
||||||
|
.order-filters, .order-form-grid, .cargo-form-grid, .order-detail-grid, .party-editor-grid, .party-snapshot-fields { grid-template-columns: 1fr; }
|
||||||
|
.order-toolbar-actions { width: 100%; align-self: stretch; }
|
||||||
|
.order-toolbar-actions :deep(.el-button) { min-height: 44px; flex: 1; }
|
||||||
|
.editor-actions :deep(.el-button) { min-height: 44px; }
|
||||||
|
.desktop-order-table { display: none; }
|
||||||
|
.mobile-order-list { display: grid; gap: 10px; padding: 10px; }
|
||||||
|
.mobile-order-card { padding: 14px; border: 1px solid var(--border); background: #fff; }
|
||||||
|
.mobile-order-card header, .mobile-order-card footer { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
|
||||||
|
.mobile-order-card header button { padding: 0; border: 0; background: none; color: var(--primary); font-weight: 750; }
|
||||||
|
.mobile-order-card header button:focus-visible { outline: 2px solid var(--primary); outline-offset: 3px; }
|
||||||
|
.mobile-order-card dl { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; margin: 14px 0; }
|
||||||
|
.mobile-order-card dl div { min-width: 0; }
|
||||||
|
.mobile-order-card dt, .mobile-order-card footer > span { color: var(--muted); font-size: 10px; }
|
||||||
|
.mobile-order-card dd { overflow: hidden; margin: 4px 0 0; font-size: 12px; font-weight: 600; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.mobile-order-card dd span { margin-left: 4px; color: var(--muted); font-weight: 400; }
|
||||||
|
.mobile-order-card footer { align-items: flex-end; padding-top: 10px; border-top: 1px solid var(--border); }
|
||||||
|
.mobile-order-card footer > div { display: flex; }
|
||||||
|
.order-detail-grid div:nth-child(odd) { border-right: 0; }
|
||||||
|
.party-name, .party-snapshot-fields .el-form-item:last-child { grid-column: auto; }
|
||||||
|
.express-information { grid-template-columns: auto 1fr; }
|
||||||
|
.express-information > div:last-child { grid-column: 2; }
|
||||||
|
.cargo-summary { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||||
|
.cargo-summary div:nth-child(2) { border-right: 0; }
|
||||||
|
.cargo-summary div:nth-child(-n+2) { border-bottom: 1px solid var(--border); }
|
||||||
|
.cargo-detail-list li { flex-direction: column; }
|
||||||
|
:global(.first-leg-editor), :global(.first-leg-detail) { width: 100% !important; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { ArrowLeft, ShieldX } from '@lucide/vue'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
import { getFirstAccessiblePath } from '@/navigation/clientNavigation'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const auth = useAuthStore()
|
||||||
|
const firstAccessiblePath = computed(() => getFirstAccessiblePath(auth.permissionCodes))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="forbidden-state client-card" aria-labelledby="forbidden-title">
|
||||||
|
<div class="forbidden-icon"><ShieldX :size="30" /></div>
|
||||||
|
<span class="section-label">403 · ACCESS DENIED</span>
|
||||||
|
<h2 id="forbidden-title">当前账号无权访问此页面</h2>
|
||||||
|
<p>页面权限由企业管理员分配。如需处理这项业务,请联系企业管理员调整您的角色权限。</p>
|
||||||
|
<el-button type="primary" @click="router.replace(firstAccessiblePath)"><ArrowLeft :size="16" />返回可访问页面</el-button>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.forbidden-state { display: grid; min-height: 420px; place-items: center; align-content: center; padding: 48px 24px; text-align: center; }
|
||||||
|
.forbidden-icon { display: grid; width: 64px; height: 64px; margin-bottom: 18px; place-items: center; background: #f4e9e8; color: #a2413b; }
|
||||||
|
.forbidden-state h2 { margin: 10px 0 8px; color: var(--navy); font-size: clamp(22px, 3vw, 30px); }
|
||||||
|
.forbidden-state p { max-width: 520px; margin: 0 0 24px; color: var(--muted); line-height: 1.75; }
|
||||||
|
@media (max-width: 720px) { .forbidden-state { min-height: 360px; } .forbidden-state :deep(.el-button) { min-height: 44px; } }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { reactive, ref } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
|
||||||
|
import { ArrowRight, LockKeyhole, UserRound } from '@lucide/vue'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
|
||||||
|
interface LoginForm {
|
||||||
|
account: string
|
||||||
|
password: string
|
||||||
|
remember: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const auth = useAuthStore()
|
||||||
|
const formRef = ref<FormInstance>()
|
||||||
|
const loading = ref(false)
|
||||||
|
const isDevelopment = import.meta.env.DEV
|
||||||
|
const developmentHint = isDevelopment
|
||||||
|
? {
|
||||||
|
title: '一期联调账号',
|
||||||
|
message: '本地后端启动后可使用商户账号 merchant_admin 登录;密码请使用后端联调环境提供的初始密码。',
|
||||||
|
}
|
||||||
|
: null
|
||||||
|
const form = reactive<LoginForm>({
|
||||||
|
account: typeof route.query.account === 'string'
|
||||||
|
? route.query.account
|
||||||
|
: isDevelopment ? 'merchant_admin' : '',
|
||||||
|
password: '',
|
||||||
|
remember: false,
|
||||||
|
})
|
||||||
|
const accountPattern = /^[A-Za-z0-9_]{4,64}$/
|
||||||
|
|
||||||
|
const rules: FormRules<LoginForm> = {
|
||||||
|
account: [
|
||||||
|
{ required: true, message: '请输入用户名', trigger: 'blur' },
|
||||||
|
{ pattern: accountPattern, message: '用户名为 4–64 位字母、数字或下划线', trigger: 'blur' },
|
||||||
|
],
|
||||||
|
password: [
|
||||||
|
{ required: true, message: '请输入密码', trigger: 'blur' },
|
||||||
|
{ min: 8, max: 72, message: '密码长度应为 8–72 位', trigger: 'blur' },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
if (!formRef.value || loading.value) return
|
||||||
|
const valid = await formRef.value.validate().catch(() => false)
|
||||||
|
if (!valid) return
|
||||||
|
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
await auth.login(form.account.trim(), form.password, form.remember)
|
||||||
|
ElMessage.success('登录成功')
|
||||||
|
await router.replace(String(route.query.redirect || '/'))
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(error instanceof Error ? error.message : '登录失败,请检查账号后重试')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showResetHint() {
|
||||||
|
ElMessage.info('请联系平台客服重置密码')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="auth-page">
|
||||||
|
<header class="auth-header">
|
||||||
|
<RouterLink to="/login" class="auth-brand"><span>CC</span><strong>虫虫出海</strong></RouterLink>
|
||||||
|
<div>还没有企业账号? <RouterLink to="/register">申请入驻</RouterLink></div>
|
||||||
|
</header>
|
||||||
|
<main class="auth-main">
|
||||||
|
<section class="auth-copy">
|
||||||
|
<h1>跨境物流业务<br />一个工作台完成</h1>
|
||||||
|
<p>从企业成员协作到头程订单跟进,统一管理业务与权限。</p>
|
||||||
|
<div class="auth-facts"><span>企业级权限</span><span>订单全程跟进</span><span>多端业务协同</span></div>
|
||||||
|
</section>
|
||||||
|
<section class="auth-card" aria-labelledby="login-title">
|
||||||
|
<h2 id="login-title">登录客户工作台</h2>
|
||||||
|
<p>企业注册成功后即可登录;提交订单前需完成企业资料审核</p>
|
||||||
|
<el-form ref="formRef" :model="form" :rules="rules" label-position="top" size="large" @submit.prevent="submit">
|
||||||
|
<el-form-item label="用户名" prop="account">
|
||||||
|
<el-input v-model="form.account" autocomplete="username" maxlength="64" placeholder="请输入用户名" :prefix-icon="UserRound" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="密码" prop="password">
|
||||||
|
<el-input v-model="form.password" autocomplete="current-password" maxlength="72" type="password" show-password placeholder="请输入 8–72 位密码" :prefix-icon="LockKeyhole" @keyup.enter="submit" />
|
||||||
|
</el-form-item>
|
||||||
|
<div class="auth-form-options">
|
||||||
|
<el-checkbox v-model="form.remember">记住我</el-checkbox>
|
||||||
|
<button type="button" @click="showResetHint">忘记密码?</button>
|
||||||
|
</div>
|
||||||
|
<el-button native-type="submit" class="auth-submit" type="primary" :loading="loading">登录 <ArrowRight :size="17" /></el-button>
|
||||||
|
</el-form>
|
||||||
|
<div v-if="developmentHint" class="demo-tip" role="note"><strong>{{ developmentHint.title }}</strong><span>{{ developmentHint.message }}</span></div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, reactive, ref } from 'vue'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import { KeyRound, Pencil, Plus, RotateCcw, Search, ShieldCheck, UserCheck, UserRoundX } from '@lucide/vue'
|
||||||
|
import { useTenantAdministrationStore, type TenantMember, type TenantRecordStatus } from '@/stores/tenantAdministration'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
|
||||||
|
const store = useTenantAdministrationStore()
|
||||||
|
const auth = useAuthStore()
|
||||||
|
const keyword = ref('')
|
||||||
|
const statusFilter = ref<TenantRecordStatus | ''>('')
|
||||||
|
const editorOpen = ref(false)
|
||||||
|
const editingId = ref('')
|
||||||
|
const loading = ref(false)
|
||||||
|
const saving = ref(false)
|
||||||
|
const form = reactive({ name: '', account: '', password: '', phone: '', email: '', roleIds: [] as string[] })
|
||||||
|
|
||||||
|
const filteredMembers = computed(() => store.members.filter((member) => {
|
||||||
|
const key = keyword.value.trim().toLowerCase()
|
||||||
|
const matchesKeyword = !key || [member.name, member.account, member.phone, member.email].some((value) => value.toLowerCase().includes(key))
|
||||||
|
return matchesKeyword && (!statusFilter.value || member.status === statusFilter.value)
|
||||||
|
}))
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
await Promise.all([store.loadRoles(), store.loadMembers()])
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(error instanceof Error ? error.message : '企业成员加载失败')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function resetForm() {
|
||||||
|
editingId.value = ''
|
||||||
|
Object.assign(form, { name: '', account: '', password: '', phone: '', email: '', roleIds: [] })
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreate() {
|
||||||
|
resetForm()
|
||||||
|
editorOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(member: TenantMember) {
|
||||||
|
editingId.value = member.id
|
||||||
|
Object.assign(form, { name: member.name, account: member.account, password: '', phone: member.phone, email: member.email, roleIds: [...member.roleIds] })
|
||||||
|
editorOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveMember() {
|
||||||
|
if (saving.value) return
|
||||||
|
if (!form.name.trim() || !/^[A-Za-z0-9_.@-]{4,64}$/.test(form.account) || form.roleIds.length === 0) {
|
||||||
|
ElMessage.warning('请填写姓名、4–64 位合法账号并至少选择一个角色')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (form.phone.length > 32 || (form.email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email))) {
|
||||||
|
ElMessage.warning('请检查联系电话长度和邮箱格式')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!editingId.value && (!/^(?=.*[A-Za-z])(?=.*\d).{8,64}$/.test(form.password))) {
|
||||||
|
ElMessage.warning('初始密码需为 8–64 位,并同时包含字母和数字')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
if (editingId.value) {
|
||||||
|
await store.updateMember(editingId.value, { name: form.name, memberNo: form.account, phone: form.phone, email: form.email, roleIds: [...form.roleIds] })
|
||||||
|
ElMessage.success('成员信息已更新')
|
||||||
|
} else {
|
||||||
|
await store.createMember({ ...form, memberNo: form.account, roleIds: [...form.roleIds] })
|
||||||
|
ElMessage.success('成员已新增')
|
||||||
|
}
|
||||||
|
editorOpen.value = false
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(error instanceof Error ? error.message : '成员保存失败,请重试')
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleStatus(member: TenantMember) {
|
||||||
|
const nextLabel = member.status === 'ACTIVE' ? '停用' : '启用'
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(`${nextLabel}后将立即影响该成员的客户工作台访问。`, `${nextLabel}成员 ${member.name}`, { type: 'warning', confirmButtonText: nextLabel, cancelButtonText: '取消' })
|
||||||
|
await store.toggleMemberStatus(member.id)
|
||||||
|
ElMessage.success(`成员已${nextLabel}`)
|
||||||
|
} catch (error) {
|
||||||
|
if (error === 'cancel' || error === 'close') return
|
||||||
|
ElMessage.error(error instanceof Error ? error.message : '操作失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resetPassword(member: TenantMember) {
|
||||||
|
try {
|
||||||
|
const result = await ElMessageBox.prompt('请输入 8–64 位、同时包含字母和数字的新密码。重置后成员需要重新登录。', `重置 ${member.name} 的密码`, {
|
||||||
|
confirmButtonText: '确认重置',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
inputType: 'password',
|
||||||
|
inputPlaceholder: '请输入新密码',
|
||||||
|
inputValidator: (value) => /^(?=.*[A-Za-z])(?=.*\d).{8,64}$/.test(value) || '密码需为 8–64 位,并同时包含字母和数字',
|
||||||
|
})
|
||||||
|
await store.resetMemberPassword(member.id, result.value)
|
||||||
|
ElMessage.success('成员密码已重置')
|
||||||
|
} catch (error) {
|
||||||
|
if (error === 'cancel' || error === 'close') return
|
||||||
|
ElMessage.error(error instanceof Error ? error.message : '密码重置失败,请重试')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearFilters() {
|
||||||
|
keyword.value = ''
|
||||||
|
statusFilter.value = ''
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section v-loading="loading" class="members-page">
|
||||||
|
<div v-if="store.membersLoadedFromApi" class="member-contract-notice client-card">成员资料、角色回显、新增、编辑、启停和密码重置均使用当前企业的真实接口;企业所有者保护和租户隔离由服务端最终校验。</div>
|
||||||
|
<div class="member-metrics">
|
||||||
|
<article><UserCheck :size="20" /><div><span>成员总数</span><strong>{{ store.members.length }}</strong></div></article>
|
||||||
|
<article><ShieldCheck :size="20" /><div><span>正常成员</span><strong>{{ store.activeMemberCount }}</strong></div></article>
|
||||||
|
<article><UserRoundX :size="20" /><div><span>已停用</span><strong>{{ store.members.length - store.activeMemberCount }}</strong></div></article>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="member-toolbar client-card">
|
||||||
|
<div class="member-filters">
|
||||||
|
<el-input v-model="keyword" clearable placeholder="姓名、账号、手机或邮箱"><template #prefix><Search :size="16" /></template></el-input>
|
||||||
|
<el-select v-model="statusFilter" clearable placeholder="全部状态"><el-option label="正常" value="ACTIVE" /><el-option label="已停用" value="INACTIVE" /></el-select>
|
||||||
|
<el-button @click="clearFilters"><RotateCcw :size="16" />重置</el-button>
|
||||||
|
</div>
|
||||||
|
<el-button v-if="auth.hasPermission('company:member:write')" type="primary" @click="openCreate"><Plus :size="17" />新增成员</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="client-card member-table-card">
|
||||||
|
<el-table v-if="filteredMembers.length" class="desktop-member-table" :data="filteredMembers" row-key="id">
|
||||||
|
<el-table-column label="成员" min-width="180"><template #default="{ row }"><div class="member-identity"><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 label="联系方式" min-width="190"><template #default="{ row }"><div class="member-contact"><span>{{ row.phone }}</span><small>{{ row.email || '-' }}</small></div></template></el-table-column>
|
||||||
|
<el-table-column label="角色" min-width="180"><template #default="{ row }"><div class="role-tags"><el-tag v-for="name in store.roleNames(row.roleIds)" :key="name" effect="plain">{{ name }}</el-tag><span v-if="store.roleNames(row.roleIds).length === 0" class="readonly-action">未分配</span></div></template></el-table-column>
|
||||||
|
<el-table-column prop="lastLoginAt" label="登录记录" min-width="150" />
|
||||||
|
<el-table-column label="状态" width="100"><template #default="{ row }"><el-tag :type="row.status === 'ACTIVE' ? 'success' : 'info'" effect="light">{{ row.status === 'ACTIVE' ? '正常' : '已停用' }}</el-tag></template></el-table-column>
|
||||||
|
<el-table-column label="操作" width="250" fixed="right"><template #default="{ row }"><el-button v-if="auth.hasPermission('company:member:write')" link type="primary" @click="openEdit(row as TenantMember)"><Pencil :size="15" />编辑</el-button><el-button v-if="auth.hasPermission('company:member:reset-password')" link type="primary" @click="resetPassword(row as TenantMember)"><KeyRound :size="15" />重置密码</el-button><el-button v-if="auth.hasPermission('company:member:write') && !row.isAdministrator" link :type="row.status === 'ACTIVE' ? 'danger' : 'success'" @click="toggleStatus(row as TenantMember)">{{ row.status === 'ACTIVE' ? '停用' : '启用' }}</el-button><span v-if="!auth.hasPermission('company:member:write') && !auth.hasPermission('company:member:reset-password')" class="readonly-action">仅查看</span></template></el-table-column>
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<div v-if="filteredMembers.length" class="mobile-member-list">
|
||||||
|
<article v-for="member in filteredMembers" :key="member.id">
|
||||||
|
<header><div class="member-identity"><span>{{ member.name.slice(0, 1) }}</span><div><strong>{{ member.name }}</strong><small>{{ member.account }}</small></div></div><el-tag :type="member.status === 'ACTIVE' ? 'success' : 'info'" effect="light">{{ member.status === 'ACTIVE' ? '正常' : '已停用' }}</el-tag></header>
|
||||||
|
<dl><div><dt>手机</dt><dd>{{ member.phone }}</dd></div><div><dt>最近登录</dt><dd>{{ member.lastLoginAt }}</dd></div><div class="full"><dt>角色</dt><dd>{{ store.roleNames(member.roleIds).join('、') }}</dd></div></dl>
|
||||||
|
<footer><el-button v-if="auth.hasPermission('company:member:write')" link type="primary" @click="openEdit(member)">编辑</el-button><el-button v-if="auth.hasPermission('company:member:reset-password')" link type="primary" @click="resetPassword(member)">重置密码</el-button><el-button v-if="auth.hasPermission('company:member:write') && !member.isAdministrator" link :type="member.status === 'ACTIVE' ? 'danger' : 'success'" @click="toggleStatus(member)">{{ member.status === 'ACTIVE' ? '停用' : '启用' }}</el-button><span v-if="!auth.hasPermission('company:member:write') && !auth.hasPermission('company:member:reset-password')" class="readonly-action">仅查看</span></footer>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="client-empty">没有匹配的企业成员,请调整筛选条件。</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-drawer v-model="editorOpen" :title="editingId ? '编辑成员' : '新增成员'" size="min(520px, 94vw)" @closed="resetForm">
|
||||||
|
<el-form label-position="top">
|
||||||
|
<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.account" :disabled="Boolean(editingId)" maxlength="64" placeholder="4–64 位字母、数字或 _ . @ -" /></el-form-item>
|
||||||
|
<el-form-item v-if="!editingId" label="初始密码" required><el-input v-model="form.password" type="password" show-password autocomplete="new-password" maxlength="64" placeholder="8–64 位,同时包含字母和数字" /></el-form-item>
|
||||||
|
<div class="member-form-grid"><el-form-item label="联系电话"><el-input v-model="form.phone" maxlength="32" placeholder="选填" /></el-form-item><el-form-item label="邮箱"><el-input v-model="form.email" maxlength="128" placeholder="选填" /></el-form-item></div>
|
||||||
|
<el-form-item label="成员角色" required><el-select v-model="form.roleIds" multiple placeholder="请选择角色"><el-option v-for="role in store.roles.filter((item) => item.status === 'ACTIVE')" :key="role.id" :label="role.name" :value="role.id" /></el-select></el-form-item>
|
||||||
|
<div v-if="!editingId" class="member-form-tip"><KeyRound :size="17" /><span>请通过安全渠道将初始密码交给成员;前端不会保存或再次展示该密码。</span></div>
|
||||||
|
</el-form>
|
||||||
|
<template #footer><div class="drawer-actions"><el-button :disabled="saving" @click="editorOpen = false">取消</el-button><el-button type="primary" :loading="saving" @click="saveMember">保存</el-button></div></template>
|
||||||
|
</el-drawer>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.members-page { display: flex; flex-direction: column; gap: 14px; }
|
||||||
|
.member-contract-notice { padding: 13px 16px; border-left: 3px solid #d97706; background: #fff8eb; color: #8a5b10; font-size: 11px; line-height: 1.6; }
|
||||||
|
.member-metrics { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; }
|
||||||
|
.member-metrics article { display: flex; align-items: center; gap: 14px; padding: 18px 20px; border: 1px solid var(--border); background: #fff; color: var(--primary); }
|
||||||
|
.member-metrics article div { display: flex; flex-direction: column; gap: 2px; }
|
||||||
|
.member-metrics span { color: var(--muted); font-size: 11px; }
|
||||||
|
.member-metrics strong { color: var(--navy); font-size: 23px; }
|
||||||
|
.member-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 14px; padding: 16px; }
|
||||||
|
.member-filters { display: grid; flex: 1; grid-template-columns: minmax(210px, 1fr) 150px auto; gap: 10px; }
|
||||||
|
.member-table-card { padding: 0; overflow: hidden; }
|
||||||
|
.member-identity { display: flex; align-items: center; gap: 10px; }
|
||||||
|
.member-identity > span { display: grid; width: 34px; height: 34px; flex: 0 0 34px; place-items: center; border-radius: 50%; background: #e7f3f1; color: var(--primary); font-weight: 750; }
|
||||||
|
.member-identity > div, .member-contact { display: flex; min-width: 0; flex-direction: column; gap: 3px; }
|
||||||
|
.member-identity small, .member-contact small { color: var(--muted); font-size: 10px; }
|
||||||
|
.role-tags { display: flex; gap: 5px; flex-wrap: wrap; }
|
||||||
|
.mobile-member-list { display: none; }
|
||||||
|
.member-form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
|
||||||
|
.member-form-tip { display: flex; gap: 10px; padding: 13px; border-left: 3px solid var(--primary); background: #f2f7f6; color: var(--muted); font-size: 11px; line-height: 1.6; }
|
||||||
|
.member-form-tip svg { flex: 0 0 17px; color: var(--primary); }
|
||||||
|
.drawer-actions { display: flex; justify-content: flex-end; gap: 8px; }
|
||||||
|
@media (max-width: 900px) { .member-toolbar { align-items: stretch; flex-direction: column; } .member-toolbar > .el-button { align-self: flex-end; } }
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.member-metrics { grid-template-columns: 1fr; }
|
||||||
|
.member-filters, .member-form-grid { grid-template-columns: 1fr; }
|
||||||
|
.member-toolbar > .el-button { width: 100%; }
|
||||||
|
.desktop-member-table { display: none; }
|
||||||
|
.mobile-member-list { display: grid; gap: 10px; padding: 10px; }
|
||||||
|
.mobile-member-list article { padding: 14px; border: 1px solid var(--border); background: #fff; }
|
||||||
|
.mobile-member-list header { display: flex; align-items: center; justify-content: space-between; }
|
||||||
|
.mobile-member-list dl { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin: 16px 0; }
|
||||||
|
.mobile-member-list dl .full { grid-column: 1 / -1; }
|
||||||
|
.mobile-member-list dt { color: var(--muted); font-size: 10px; }
|
||||||
|
.mobile-member-list dd { margin: 4px 0 0; font-size: 12px; }
|
||||||
|
.mobile-member-list footer { display: flex; justify-content: flex-end; padding-top: 10px; border-top: 1px solid var(--border); }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<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-stack">
|
||||||
|
<div class="module-actions"><el-input placeholder="输入关键词搜索" clearable><template #prefix><Search :size="17" /></template></el-input><el-button type="primary"><Plus :size="17" /> 新增</el-button></div>
|
||||||
|
<div class="client-card module-empty"><div class="module-empty-icon"><Construction :size="28" /></div><h2>{{ title }}</h2><p>{{ description }}</p><el-tag effect="plain">页面骨架已完成,业务接口待接入</el-tag></div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref, watch } from 'vue'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { Check, LockKeyhole, RotateCcw, Save, ShieldCheck } from '@lucide/vue'
|
||||||
|
import { TENANT_PERMISSION_GROUPS, useTenantAdministrationStore } from '@/stores/tenantAdministration'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
|
||||||
|
const store = useTenantAdministrationStore()
|
||||||
|
const auth = useAuthStore()
|
||||||
|
const route = useRoute()
|
||||||
|
const initialRoleId = typeof route.query.role === 'string' && store.roles.some((role) => role.id === route.query.role) ? route.query.role : store.roles[0]?.id ?? ''
|
||||||
|
const selectedRoleId = ref(initialRoleId)
|
||||||
|
const selectedCodes = ref<string[]>([])
|
||||||
|
const savedCodes = ref<string[]>([])
|
||||||
|
|
||||||
|
const selectedRole = computed(() => store.roles.find((role) => role.id === selectedRoleId.value))
|
||||||
|
const editable = computed(() => Boolean(auth.hasPermission('company:permission:assign') && selectedRole.value && !selectedRole.value.builtin && selectedRole.value.status === 'ACTIVE'))
|
||||||
|
const hasChanges = computed(() => [...selectedCodes.value].sort().join('|') !== [...savedCodes.value].sort().join('|'))
|
||||||
|
|
||||||
|
function loadRolePermissions() {
|
||||||
|
const codes = selectedRole.value?.permissionCodes ?? []
|
||||||
|
selectedCodes.value = [...codes]
|
||||||
|
savedCodes.value = [...codes]
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(selectedRoleId, loadRolePermissions, { immediate: true })
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
await store.loadRoles()
|
||||||
|
if (!store.roles.some((role) => role.id === selectedRoleId.value)) selectedRoleId.value = store.roles[0]?.id ?? ''
|
||||||
|
loadRolePermissions()
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(error instanceof Error ? error.message : '角色列表加载失败')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function groupCodes(groupKey: string) {
|
||||||
|
return TENANT_PERMISSION_GROUPS.find((group) => group.key === groupKey)?.items.map((item) => item.code) ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupChecked(groupKey: string) {
|
||||||
|
const codes = groupCodes(groupKey)
|
||||||
|
return codes.length > 0 && codes.every((code) => selectedCodes.value.includes(code))
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupIndeterminate(groupKey: string) {
|
||||||
|
const codes = groupCodes(groupKey)
|
||||||
|
const count = codes.filter((code) => selectedCodes.value.includes(code)).length
|
||||||
|
return count > 0 && count < codes.length
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleGroup(groupKey: string, checked: boolean) {
|
||||||
|
const codes = groupCodes(groupKey)
|
||||||
|
if (checked) selectedCodes.value = [...new Set([...selectedCodes.value, ...codes])]
|
||||||
|
else selectedCodes.value = selectedCodes.value.filter((code) => !codes.includes(code))
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetPermissions() {
|
||||||
|
selectedCodes.value = [...savedCodes.value]
|
||||||
|
}
|
||||||
|
|
||||||
|
async function savePermissions() {
|
||||||
|
if (!selectedRole.value || !editable.value) return
|
||||||
|
if (!selectedCodes.value.includes('account:profile:view')) selectedCodes.value.unshift('account:profile:view')
|
||||||
|
if (!selectedCodes.value.includes('dashboard:view')) selectedCodes.value.unshift('dashboard:view')
|
||||||
|
try {
|
||||||
|
await store.setRolePermissions(selectedRole.value.id, selectedCodes.value)
|
||||||
|
savedCodes.value = [...selectedCodes.value]
|
||||||
|
ElMessage.success('角色菜单及操作权限已保存')
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(error instanceof Error ? error.message : '保存失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="permissions-page">
|
||||||
|
<aside class="client-card role-selector">
|
||||||
|
<header><div><span class="section-label">ROLES</span><h3>选择角色</h3></div></header>
|
||||||
|
<button v-for="role in store.roles" :key="role.id" type="button" :class="{ active: role.id === selectedRoleId }" @click="selectedRoleId = role.id">
|
||||||
|
<span class="role-select-icon"><LockKeyhole v-if="role.builtin" :size="17" /><ShieldCheck v-else :size="17" /></span>
|
||||||
|
<span><strong>{{ role.name }}</strong><small>{{ role.code }}</small></span>
|
||||||
|
<el-tag :type="role.status === 'ACTIVE' ? 'success' : 'info'" effect="plain" size="small">{{ role.status === 'ACTIVE' ? '启用' : '停用' }}</el-tag>
|
||||||
|
</button>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<div class="permission-content">
|
||||||
|
<div class="permission-heading client-card">
|
||||||
|
<div><span class="section-label">PERMISSIONS</span><h2>{{ selectedRole?.name }}</h2><p>{{ selectedRole?.description }}</p></div>
|
||||||
|
<div class="permission-actions"><el-button :disabled="!editable || !hasChanges" @click="resetPermissions"><RotateCcw :size="16" />撤销修改</el-button><el-button type="primary" :disabled="!editable || !hasChanges" @click="savePermissions"><Save :size="16" />保存权限</el-button></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="selectedRole?.builtin" class="permission-notice"><LockKeyhole :size="18" /><span>企业管理员为内置角色,始终拥有全部客户工作台权限,不允许修改。</span></div>
|
||||||
|
<div v-else-if="selectedRole?.status === 'INACTIVE'" class="permission-notice warning"><span>该角色已停用,请先启用角色后再调整权限。</span></div>
|
||||||
|
<div v-else class="permission-notice"><ShieldCheck :size="18" /><span>保存时会全量替换该角色的权限,请确认所有需要保留的菜单和操作均已勾选。</span></div>
|
||||||
|
|
||||||
|
<div class="permission-groups">
|
||||||
|
<article v-for="group in TENANT_PERMISSION_GROUPS" :key="group.key" class="client-card permission-group">
|
||||||
|
<header>
|
||||||
|
<el-checkbox :model-value="groupChecked(group.key)" :indeterminate="groupIndeterminate(group.key)" :disabled="!editable" @change="toggleGroup(group.key, Boolean($event))"><strong>{{ group.label }}</strong></el-checkbox>
|
||||||
|
<span>{{ group.items.filter((item) => selectedCodes.includes(item.code)).length }} / {{ group.items.length }}</span>
|
||||||
|
</header>
|
||||||
|
<el-checkbox-group v-model="selectedCodes" :disabled="!editable">
|
||||||
|
<label v-for="item in group.items" :key="item.code" class="permission-item" :class="{ selected: selectedCodes.includes(item.code) }">
|
||||||
|
<el-checkbox :value="item.code"><span class="permission-check"><Check :size="14" /></span></el-checkbox>
|
||||||
|
<span><strong>{{ item.label }}</strong><small>{{ item.description }}</small><code>{{ item.code }}</code></span>
|
||||||
|
</label>
|
||||||
|
</el-checkbox-group>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.permissions-page { display: grid; grid-template-columns: 250px minmax(0, 1fr); align-items: start; gap: 14px; }
|
||||||
|
.role-selector { position: sticky; top: 84px; padding: 12px; }
|
||||||
|
.role-selector header { padding: 8px 8px 12px; }
|
||||||
|
.role-selector h3 { margin-bottom: 0; }
|
||||||
|
.role-selector > button { display: flex; width: 100%; min-height: 58px; align-items: center; gap: 9px; margin: 3px 0; padding: 9px; border: 0; border-radius: 8px; background: transparent; color: var(--navy); cursor: pointer; text-align: left; }
|
||||||
|
.role-selector > button:hover, .role-selector > button.active { background: #edf6f4; }
|
||||||
|
.role-selector > button.active { color: var(--primary); }
|
||||||
|
.role-select-icon { display: grid; width: 32px; height: 32px; flex: 0 0 32px; place-items: center; background: #f0f5f4; }
|
||||||
|
.role-selector > button > span:nth-child(2) { display: flex; min-width: 0; flex: 1; flex-direction: column; gap: 3px; }
|
||||||
|
.role-selector strong { overflow: hidden; font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.role-selector small { color: var(--muted); font-size: 9px; }
|
||||||
|
.permission-content { display: flex; min-width: 0; flex-direction: column; gap: 12px; }
|
||||||
|
.permission-heading { display: flex; align-items: center; justify-content: space-between; gap: 20px; }
|
||||||
|
.permission-heading h2 { margin: 5px 0; font-size: 19px; }
|
||||||
|
.permission-heading p { margin: 0; color: var(--muted); font-size: 11px; }
|
||||||
|
.permission-actions { display: flex; gap: 8px; }
|
||||||
|
.permission-notice { display: flex; align-items: center; gap: 9px; padding: 12px 15px; border-left: 3px solid var(--primary); background: #edf6f4; color: #45615d; font-size: 11px; }
|
||||||
|
.permission-notice.warning { border-left-color: #d97706; background: #fff7ed; color: #9a5a09; }
|
||||||
|
.permission-groups { display: grid; gap: 12px; }
|
||||||
|
.permission-group { padding: 0; overflow: hidden; }
|
||||||
|
.permission-group > header { padding: 14px 18px; border-bottom: 1px solid var(--border); background: #fafcfc; }
|
||||||
|
.permission-group > header > span { color: var(--muted); font-size: 11px; }
|
||||||
|
.permission-group :deep(.el-checkbox-group) { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||||
|
.permission-item { display: flex; min-height: 92px; align-items: flex-start; gap: 9px; padding: 16px 18px; border-bottom: 1px solid var(--border); cursor: pointer; }
|
||||||
|
.permission-item:nth-child(odd) { border-right: 1px solid var(--border); }
|
||||||
|
.permission-item.selected { background: #f5faf9; }
|
||||||
|
.permission-item :deep(.el-checkbox__label) { display: none; }
|
||||||
|
.permission-check { display: none; }
|
||||||
|
.permission-item > span { display: flex; min-width: 0; flex-direction: column; gap: 4px; }
|
||||||
|
.permission-item strong { color: var(--navy); font-size: 12px; }
|
||||||
|
.permission-item small { color: var(--muted); font-size: 10px; line-height: 1.5; }
|
||||||
|
.permission-item code { color: #86939a; font-size: 9px; }
|
||||||
|
@media (max-width: 900px) { .permissions-page { grid-template-columns: 1fr; } .role-selector { position: static; display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); } .role-selector header { grid-column: 1 / -1; } }
|
||||||
|
@media (max-width: 720px) { .permission-heading { align-items: flex-start; flex-direction: column; } .permission-actions { width: 100%; } .permission-actions .el-button { flex: 1; } .permission-group :deep(.el-checkbox-group) { grid-template-columns: 1fr; } .permission-item:nth-child(odd) { border-right: 0; } }
|
||||||
|
@media (max-width: 480px) { .role-selector { grid-template-columns: 1fr; } .role-selector header { grid-column: auto; } }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { reactive, ref } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
|
||||||
|
import { ArrowLeft, Building2, CheckCircle2, Mail, Phone } from '@lucide/vue'
|
||||||
|
import { registerMerchant } from '@/api/auth'
|
||||||
|
|
||||||
|
interface RegisterForm {
|
||||||
|
username: string
|
||||||
|
companyName: string
|
||||||
|
displayName: string
|
||||||
|
legalPerson: string
|
||||||
|
legalPhone: string
|
||||||
|
contactEmail: string
|
||||||
|
countryCode: string
|
||||||
|
address: string
|
||||||
|
password: string
|
||||||
|
agreement: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const formRef = ref<FormInstance>()
|
||||||
|
const submitting = ref(false)
|
||||||
|
const submitted = ref(false)
|
||||||
|
const submittedAt = ref('')
|
||||||
|
const form = reactive<RegisterForm>({ username: '', companyName: '', displayName: '', legalPerson: '', legalPhone: '', contactEmail: '', countryCode: 'CN', address: '', password: '', agreement: false })
|
||||||
|
|
||||||
|
const rules: FormRules<RegisterForm> = {
|
||||||
|
username: [
|
||||||
|
{ required: true, message: '请输入登录用户名', trigger: 'blur' },
|
||||||
|
{ pattern: /^[A-Za-z0-9_]{4,64}$/, message: '用户名为 4–64 位字母、数字或下划线', trigger: 'blur' },
|
||||||
|
],
|
||||||
|
companyName: [
|
||||||
|
{ required: true, message: '请输入企业名称', trigger: 'blur' },
|
||||||
|
{ min: 2, max: 100, message: '企业名称长度应为 2–100 个字符', trigger: 'blur' },
|
||||||
|
],
|
||||||
|
displayName: [
|
||||||
|
{ required: true, message: '请输入联系人姓名', trigger: 'blur' },
|
||||||
|
{ max: 64, message: '联系人姓名最多 64 个字符', trigger: 'blur' },
|
||||||
|
],
|
||||||
|
legalPerson: [
|
||||||
|
{ required: true, message: '请输入法定代表人', trigger: 'blur' },
|
||||||
|
{ max: 64, message: '法定代表人最多 64 个字符', trigger: 'blur' },
|
||||||
|
],
|
||||||
|
contactEmail: [
|
||||||
|
{ type: 'email', message: '请输入有效的邮箱地址', trigger: 'blur' },
|
||||||
|
],
|
||||||
|
legalPhone: [
|
||||||
|
{ required: true, message: '请输入手机号', trigger: 'blur' },
|
||||||
|
{ pattern: /^1\d{10}$/, message: '请输入有效的 11 位手机号', trigger: 'blur' },
|
||||||
|
],
|
||||||
|
password: [
|
||||||
|
{ required: true, message: '请设置登录密码', trigger: 'blur' },
|
||||||
|
{ min: 8, max: 72, message: '密码长度应为 8–72 位', trigger: 'blur' },
|
||||||
|
],
|
||||||
|
agreement: [{ validator: (_rule, value, callback) => value ? callback() : callback(new Error('请先阅读并同意服务协议和隐私政策')), trigger: 'change' }],
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
if (!formRef.value || submitting.value) return
|
||||||
|
const valid = await formRef.value.validate().catch(() => false)
|
||||||
|
if (!valid) return
|
||||||
|
|
||||||
|
submitting.value = true
|
||||||
|
try {
|
||||||
|
await registerMerchant({
|
||||||
|
username: form.username.trim(),
|
||||||
|
password: form.password,
|
||||||
|
displayName: form.displayName.trim(),
|
||||||
|
companyName: form.companyName.trim(),
|
||||||
|
legalPerson: form.legalPerson.trim(),
|
||||||
|
legalPhone: form.legalPhone.trim(),
|
||||||
|
contactEmail: form.contactEmail.trim() || undefined,
|
||||||
|
countryCode: form.countryCode,
|
||||||
|
address: form.address.trim() || undefined,
|
||||||
|
})
|
||||||
|
submittedAt.value = new Intl.DateTimeFormat('zh-CN', {
|
||||||
|
year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false,
|
||||||
|
}).format(new Date())
|
||||||
|
submitted.value = true
|
||||||
|
ElMessage.success('企业账号注册成功')
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(error instanceof Error ? error.message : '注册失败,请检查信息后重试')
|
||||||
|
} finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showDocument(name: string) {
|
||||||
|
ElMessage.info(`${name}将在正式接入前由业务方确认内容`)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="register-page">
|
||||||
|
<header class="register-header">
|
||||||
|
<RouterLink to="/login"><ArrowLeft :size="18" /> 返回登录</RouterLink>
|
||||||
|
<div class="auth-brand"><span>CC</span><strong>虫虫出海</strong></div>
|
||||||
|
</header>
|
||||||
|
<main class="register-main">
|
||||||
|
<template v-if="!submitted">
|
||||||
|
<div class="register-title"><div><Building2 :size="22" /></div><span><h1>企业注册</h1><p>注册成功后即可登录并使用客户工作台,提交订单前需完成企业资料审核。</p></span></div>
|
||||||
|
<el-form ref="formRef" :model="form" :rules="rules" class="register-form" label-position="top" size="large" @submit.prevent="submit">
|
||||||
|
<div class="form-grid">
|
||||||
|
<el-form-item label="登录用户名" prop="username" required><el-input v-model="form.username" autocomplete="username" maxlength="64" placeholder="4–64 位字母、数字或下划线" /></el-form-item>
|
||||||
|
<el-form-item label="企业名称" prop="companyName" required><el-input v-model="form.companyName" maxlength="100" placeholder="请输入企业营业执照上的名称" /></el-form-item>
|
||||||
|
<el-form-item label="联系人" prop="displayName" required><el-input v-model="form.displayName" maxlength="64" placeholder="请输入联系人姓名" /></el-form-item>
|
||||||
|
<el-form-item label="法定代表人" prop="legalPerson" required><el-input v-model="form.legalPerson" maxlength="64" placeholder="请输入营业执照法定代表人" /></el-form-item>
|
||||||
|
<el-form-item label="法人手机号" prop="legalPhone" required><el-input v-model="form.legalPhone" type="tel" autocomplete="tel" inputmode="numeric" maxlength="11" placeholder="请输入手机号" :prefix-icon="Phone" /></el-form-item>
|
||||||
|
<el-form-item label="联系邮箱" prop="contactEmail"><el-input v-model="form.contactEmail" type="email" autocomplete="email" maxlength="128" placeholder="选填,用于接收业务通知" :prefix-icon="Mail" /></el-form-item>
|
||||||
|
<el-form-item label="国家/地区" prop="countryCode"><el-select v-model="form.countryCode"><el-option label="中国大陆(CN)" value="CN" /></el-select></el-form-item>
|
||||||
|
<el-form-item label="企业地址" prop="address" class="full-row"><el-input v-model="form.address" maxlength="255" placeholder="选填,最多 255 个字符" /></el-form-item>
|
||||||
|
<el-form-item label="登录密码" prop="password" required><el-input v-model="form.password" type="password" autocomplete="new-password" maxlength="72" show-password placeholder="请设置 8–72 位密码" /></el-form-item>
|
||||||
|
</div>
|
||||||
|
<el-form-item prop="agreement" class="agreement-item">
|
||||||
|
<div class="agreement-control">
|
||||||
|
<el-checkbox v-model="form.agreement">我已阅读并同意</el-checkbox>
|
||||||
|
<span><button type="button" @click="showDocument('《用户服务协议》')">《用户服务协议》</button> 和 <button type="button" @click="showDocument('《隐私政策》')">《隐私政策》</button></span>
|
||||||
|
</div>
|
||||||
|
</el-form-item>
|
||||||
|
<p class="register-security-note">注册成功后账号可立即登录;首次登录需上传营业执照并等待平台审核,审核通过后才能提交订单。</p>
|
||||||
|
<div class="register-actions"><el-button @click="$router.push('/login')">取消</el-button><el-button native-type="submit" type="primary" :loading="submitting">注册企业账号</el-button></div>
|
||||||
|
</el-form>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<section v-else class="registration-result" aria-live="polite">
|
||||||
|
<CheckCircle2 :size="42" />
|
||||||
|
<h1>企业账号注册成功</h1>
|
||||||
|
<p><strong>{{ form.username }}</strong> 现在可以直接登录客户工作台,无需等待注册审核。</p>
|
||||||
|
<dl><div><dt>企业名称</dt><dd>{{ form.companyName }}</dd></div><div><dt>登录账号</dt><dd>{{ form.username }}</dd></div><div><dt>注册时间</dt><dd>{{ submittedAt }}</dd></div><div><dt>账号状态</dt><dd><el-tag type="success" effect="light">可登录</el-tag></dd></div></dl>
|
||||||
|
<div class="registration-next-step" role="note"><strong>提交订单前还需完成一步</strong><span>登录后请在“企业资料”补充公司和资质信息,等待平台人工审核通过。审核期间仍可维护资料、成员和订单草稿。</span></div>
|
||||||
|
<div class="registration-result-actions"><el-button type="primary" @click="router.push({ path: '/login', query: { account: form.username } })">立即登录</el-button></div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, reactive, ref } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import { LockKeyhole, Pencil, Plus, Search, ShieldCheck, Trash2, Users } from '@lucide/vue'
|
||||||
|
import { useTenantAdministrationStore, type TenantRole } from '@/stores/tenantAdministration'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
|
||||||
|
const store = useTenantAdministrationStore()
|
||||||
|
const auth = useAuthStore()
|
||||||
|
const router = useRouter()
|
||||||
|
const keyword = ref('')
|
||||||
|
const editorOpen = ref(false)
|
||||||
|
const editingId = ref('')
|
||||||
|
const loading = ref(false)
|
||||||
|
const form = reactive({ name: '', code: '', description: '' })
|
||||||
|
|
||||||
|
const filteredRoles = computed(() => {
|
||||||
|
const key = keyword.value.trim().toLowerCase()
|
||||||
|
return store.roles.filter((role) => !key || [role.name, role.code, role.description].some((value) => value.toLowerCase().includes(key)))
|
||||||
|
})
|
||||||
|
|
||||||
|
function resetForm() {
|
||||||
|
editingId.value = ''
|
||||||
|
Object.assign(form, { name: '', code: '', description: '' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreate() {
|
||||||
|
resetForm()
|
||||||
|
editorOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(role: TenantRole) {
|
||||||
|
editingId.value = role.id
|
||||||
|
Object.assign(form, { name: role.name, code: role.code, description: role.description })
|
||||||
|
editorOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
await store.loadRoles()
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(error instanceof Error ? error.message : '角色列表加载失败')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
async function saveRole() {
|
||||||
|
if (!form.name || !form.code || !form.description) {
|
||||||
|
ElMessage.warning('请完整填写角色名称、编码和说明')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (editingId.value) store.updateRole(editingId.value, { name: form.name, description: form.description })
|
||||||
|
else await store.createRole({ ...form, code: form.code.trim().toUpperCase() })
|
||||||
|
editorOpen.value = false
|
||||||
|
ElMessage.success(editingId.value ? '角色已更新' : '角色已新增')
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(error instanceof Error ? error.message : '保存失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleStatus(role: TenantRole) {
|
||||||
|
const action = role.status === 'ACTIVE' ? '停用' : '启用'
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(`${action}后将影响拥有该角色的成员权限。`, `${action}角色 ${role.name}`, { type: 'warning', confirmButtonText: action, cancelButtonText: '取消' })
|
||||||
|
store.toggleRoleStatus(role.id)
|
||||||
|
ElMessage.success(`角色已${action}`)
|
||||||
|
} catch (error) {
|
||||||
|
if (error === 'cancel' || error === 'close') return
|
||||||
|
ElMessage.error(error instanceof Error ? error.message : '操作失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeRole(role: TenantRole) {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm('删除后不能恢复,仅未被成员使用的自定义角色可以删除。', `删除角色 ${role.name}`, { type: 'warning', confirmButtonText: '删除', cancelButtonText: '取消' })
|
||||||
|
store.deleteRole(role.id)
|
||||||
|
ElMessage.success('角色已删除')
|
||||||
|
} catch (error) {
|
||||||
|
if (error === 'cancel' || error === 'close') return
|
||||||
|
ElMessage.error(error instanceof Error ? error.message : '删除失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assignPermissions(role: TenantRole) {
|
||||||
|
router.push({ name: 'permissions', query: { role: role.id } })
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section v-loading="loading" class="roles-page">
|
||||||
|
<div class="role-guidance client-card"><ShieldCheck :size="22" /><div><strong>角色用于约束企业内部成员可见菜单和可执行操作</strong><span>企业管理员为系统内置角色,不能停用或删除;平台后台权限不会出现在客户工作台。</span></div></div>
|
||||||
|
<div v-if="store.rolesLoadedFromApi" class="role-contract-notice client-card">角色列表、新增和已授权权限回显已接入后端;编辑、启停、删除路径尚未进入当前 Gateway OpenAPI,相关写操作暂不开放。</div>
|
||||||
|
|
||||||
|
<div class="role-toolbar client-card">
|
||||||
|
<el-input v-model="keyword" clearable placeholder="搜索角色名称、编码或说明"><template #prefix><Search :size="16" /></template></el-input>
|
||||||
|
<el-button v-if="auth.hasPermission('company:role:write')" type="primary" @click="openCreate"><Plus :size="17" />新增角色</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="filteredRoles.length" class="role-grid">
|
||||||
|
<article v-for="role in filteredRoles" :key="role.id" class="client-card role-card" :class="{ inactive: role.status === 'INACTIVE' }">
|
||||||
|
<header><div class="role-icon"><LockKeyhole v-if="role.builtin" :size="20" /><ShieldCheck v-else :size="20" /></div><div class="role-heading"><div><h3>{{ role.name }}</h3><el-tag v-if="role.builtin" effect="plain">内置</el-tag><el-tag :type="role.status === 'ACTIVE' ? 'success' : 'info'" effect="light">{{ role.status === 'ACTIVE' ? '启用' : '停用' }}</el-tag></div><code>{{ role.code }}</code></div></header>
|
||||||
|
<p>{{ role.description }}</p>
|
||||||
|
<dl><div><dt><Users :size="15" />成员数</dt><dd>{{ store.roleMemberCount(role.id) }}</dd></div><div><dt><ShieldCheck :size="15" />权限数</dt><dd>{{ role.permissionCodes.length }}</dd></div></dl>
|
||||||
|
<footer><el-button v-if="auth.hasPermission('company:permission:list')" link type="primary" @click="assignPermissions(role)">{{ auth.hasPermission('company:permission:assign') && !role.builtin ? '分配权限' : '查看权限' }}</el-button><el-button v-if="!store.rolesLoadedFromApi && auth.hasPermission('company:role:write')" link type="primary" @click="openEdit(role)"><Pencil :size="15" />编辑</el-button><el-button v-if="!store.rolesLoadedFromApi && auth.hasPermission('company:role:write') && !role.builtin" link :type="role.status === 'ACTIVE' ? 'warning' : 'success'" @click="toggleStatus(role)">{{ role.status === 'ACTIVE' ? '停用' : '启用' }}</el-button><el-button v-if="!store.rolesLoadedFromApi && auth.hasPermission('company:role:write') && !role.builtin" link type="danger" @click="removeRole(role)"><Trash2 :size="15" />删除</el-button></footer>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
<div v-else class="client-card client-empty">没有匹配的企业角色。</div>
|
||||||
|
|
||||||
|
<el-drawer v-model="editorOpen" :title="editingId ? '编辑角色' : '新增角色'" size="min(500px, 94vw)" @closed="resetForm">
|
||||||
|
<el-form label-position="top">
|
||||||
|
<el-form-item label="角色名称" required><el-input v-model="form.name" maxlength="30" /></el-form-item>
|
||||||
|
<el-form-item label="角色编码" required><el-input v-model="form.code" :disabled="Boolean(editingId)" placeholder="例如 ORDER_OPERATOR" maxlength="40" /></el-form-item>
|
||||||
|
<el-form-item label="角色说明" required><el-input v-model="form.description" type="textarea" :rows="4" maxlength="200" show-word-limit /></el-form-item>
|
||||||
|
<div class="role-form-tip">保存后请前往“菜单权限”为角色分配可见菜单和操作权限。</div>
|
||||||
|
</el-form>
|
||||||
|
<template #footer><div class="drawer-actions"><el-button @click="editorOpen = false">取消</el-button><el-button type="primary" @click="saveRole">保存</el-button></div></template>
|
||||||
|
</el-drawer>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.roles-page { display: flex; flex-direction: column; gap: 14px; }
|
||||||
|
.role-guidance { display: flex; align-items: center; gap: 13px; padding: 17px 20px; border-left: 4px solid var(--primary); color: var(--primary); }
|
||||||
|
.role-guidance > div { display: flex; flex-direction: column; gap: 4px; }
|
||||||
|
.role-guidance strong { color: var(--navy); font-size: 13px; }
|
||||||
|
.role-guidance span { color: var(--muted); font-size: 11px; }
|
||||||
|
.role-contract-notice { padding: 13px 16px; border-left: 3px solid #d97706; background: #fff8eb; color: #8a5b10; font-size: 11px; line-height: 1.6; }
|
||||||
|
.role-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 14px; padding: 16px; }
|
||||||
|
.role-toolbar .el-input { max-width: 360px; }
|
||||||
|
.role-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; }
|
||||||
|
.role-card { display: flex; min-width: 0; flex-direction: column; padding: 20px; }
|
||||||
|
.role-card.inactive { opacity: .72; }
|
||||||
|
.role-card header { justify-content: flex-start; gap: 12px; }
|
||||||
|
.role-icon { display: grid; width: 42px; height: 42px; flex: 0 0 42px; place-items: center; background: #e7f3f1; color: var(--primary); }
|
||||||
|
.role-heading { min-width: 0; }
|
||||||
|
.role-heading > div { display: flex; align-items: center; gap: 7px; }
|
||||||
|
.role-heading h3 { margin: 0; font-size: 16px; }
|
||||||
|
.role-heading code { display: block; margin-top: 5px; color: var(--muted); font-size: 10px; }
|
||||||
|
.role-card > p { flex: 1; min-height: 42px; margin: 18px 0; color: var(--muted); font-size: 12px; line-height: 1.7; }
|
||||||
|
.role-card dl { display: grid; grid-template-columns: 1fr 1fr; margin: 0; border: 1px solid var(--border); }
|
||||||
|
.role-card dl div { display: flex; align-items: center; justify-content: space-between; padding: 11px 13px; }
|
||||||
|
.role-card dl div:first-child { border-right: 1px solid var(--border); }
|
||||||
|
.role-card dt { display: flex; align-items: center; gap: 6px; color: var(--muted); font-size: 11px; }
|
||||||
|
.role-card dd { margin: 0; color: var(--navy); font-weight: 750; }
|
||||||
|
.role-card footer { display: flex; justify-content: flex-end; gap: 4px; margin-top: 16px; padding-top: 12px; border-top: 1px solid var(--border); flex-wrap: wrap; }
|
||||||
|
.role-form-tip { padding: 12px; background: #f2f7f6; color: var(--muted); font-size: 11px; }
|
||||||
|
.drawer-actions { display: flex; justify-content: flex-end; gap: 8px; }
|
||||||
|
@media (max-width: 800px) { .role-grid { grid-template-columns: 1fr; } }
|
||||||
|
@media (max-width: 600px) { .role-toolbar { align-items: stretch; flex-direction: column; } .role-toolbar .el-input { max-width: none; } .role-toolbar .el-button { width: 100%; } }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import fs from 'node:fs'
|
||||||
|
import test from 'node:test'
|
||||||
|
|
||||||
|
const routerSource = fs.readFileSync(new URL('../router/index.ts', import.meta.url), 'utf8')
|
||||||
|
const profileSource = fs.readFileSync(new URL('./CompanyProfileView.vue', import.meta.url), 'utf8')
|
||||||
|
const membersSource = fs.readFileSync(new URL('./MembersView.vue', import.meta.url), 'utf8')
|
||||||
|
const rolesSource = fs.readFileSync(new URL('./RolesView.vue', import.meta.url), 'utf8')
|
||||||
|
const permissionsSource = fs.readFileSync(new URL('./PermissionsView.vue', import.meta.url), 'utf8')
|
||||||
|
const storeSource = fs.readFileSync(new URL('../stores/tenantAdministration.ts', import.meta.url), 'utf8')
|
||||||
|
|
||||||
|
test('routes tenant administration modules to implemented views instead of the placeholder', () => {
|
||||||
|
assert.match(routerSource, /company\/profile'.*CompanyProfileView\.vue/)
|
||||||
|
assert.match(routerSource, /organization\/members'.*MembersView\.vue/)
|
||||||
|
assert.match(routerSource, /organization\/roles'.*RolesView\.vue/)
|
||||||
|
assert.match(routerSource, /organization\/permissions'.*PermissionsView\.vue/)
|
||||||
|
assert.doesNotMatch(routerSource, /ModuleView\.vue/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('implements company details, contact information and qualification workflow', () => {
|
||||||
|
assert.match(profileSource, /企业基本信息/)
|
||||||
|
assert.match(profileSource, /联系信息/)
|
||||||
|
assert.match(profileSource, /资质文件/)
|
||||||
|
assert.match(profileSource, /async function saveProfile\(\)/)
|
||||||
|
assert.match(profileSource, /function submitQualification\(\)/)
|
||||||
|
assert.match(profileSource, /validateQualificationFile/)
|
||||||
|
assert.match(profileSource, /function removeSelectedFile\(\)/)
|
||||||
|
assert.match(profileSource, /qualificationSubmitting/)
|
||||||
|
assert.match(profileSource, /class="mobile-qualification-list"/)
|
||||||
|
assert.match(profileSource, /downloadQualificationImage/)
|
||||||
|
assert.match(profileSource, /URL\.createObjectURL/)
|
||||||
|
assert.match(profileSource, /URL\.revokeObjectURL/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('implements member list, role assignment, password reset and status control', () => {
|
||||||
|
assert.match(membersSource, /async function saveMember\(\)/)
|
||||||
|
assert.match(membersSource, /function resetPassword\(member: TenantMember\)/)
|
||||||
|
assert.match(membersSource, /function toggleStatus\(member: TenantMember\)/)
|
||||||
|
assert.match(membersSource, /v-model="form\.roleIds" multiple/)
|
||||||
|
assert.match(membersSource, /class="mobile-member-list"/)
|
||||||
|
assert.match(membersSource, /store\.loadMembers\(\)/)
|
||||||
|
assert.match(membersSource, /await store\.resetMemberPassword/)
|
||||||
|
assert.match(membersSource, /await store\.toggleMemberStatus/)
|
||||||
|
assert.match(membersSource, /v-model="form\.password"/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('protects built-in roles and exposes role permission assignment', () => {
|
||||||
|
assert.match(rolesSource, /auth\.hasPermission\('company:role:write'\) && !role\.builtin/)
|
||||||
|
assert.match(rolesSource, /function assignPermissions\(role: TenantRole\)/)
|
||||||
|
assert.match(storeSource, /if \(role\.builtin\) throw new Error\('内置角色不能删除'\)/)
|
||||||
|
assert.match(storeSource, /if \(members\.value\.some\(\(member\) => member\.roleIds\.includes\(id\)\)\)/)
|
||||||
|
assert.match(rolesSource, /store\.loadRoles\(\)/)
|
||||||
|
assert.match(rolesSource, /await store\.createRole/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('shows company audit status and applies button permissions', () => {
|
||||||
|
assert.match(profileSource, /companyApproved/)
|
||||||
|
assert.match(profileSource, /企业资料正在等待平台审核/)
|
||||||
|
assert.match(profileSource, /auth\.hasPermission\('company:profile:edit'\)/)
|
||||||
|
assert.match(membersSource, /auth\.hasPermission\('company:member:write'\)/)
|
||||||
|
assert.match(permissionsSource, /auth\.hasPermission\('company:permission:assign'\)/)
|
||||||
|
assert.match(storeSource, /auditStatus: 'PROFILE_REQUIRED'/)
|
||||||
|
assert.match(storeSource, /loadCompanyContext/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('implements grouped menu and action permission editing', () => {
|
||||||
|
assert.match(permissionsSource, /TENANT_PERMISSION_GROUPS/)
|
||||||
|
assert.match(permissionsSource, /function toggleGroup\(groupKey: string, checked: boolean\)/)
|
||||||
|
assert.match(permissionsSource, /function savePermissions\(\)/)
|
||||||
|
assert.match(permissionsSource, /内置角色,始终拥有全部客户工作台权限/)
|
||||||
|
assert.match(permissionsSource, /全量替换/)
|
||||||
|
assert.match(permissionsSource, /await store\.setRolePermissions/)
|
||||||
|
assert.doesNotMatch(permissionsSource, /当前只读展示权限资源/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('keeps tenant administration state scoped to the client store', () => {
|
||||||
|
assert.match(storeSource, /defineStore\('tenant-administration'/)
|
||||||
|
assert.match(storeSource, /function createMember/)
|
||||||
|
assert.match(storeSource, /function createRole/)
|
||||||
|
assert.match(storeSource, /function setRolePermissions/)
|
||||||
|
assert.doesNotMatch(storeSource, /tenantId\s*:/)
|
||||||
|
})
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"allowImportingTsExtensions": 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 = process.env.API_PROXY_TARGET || env.API_PROXY_TARGET || 'http://127.0.0.1:9000'
|
||||||
|
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user