This commit is contained in:
2026-07-30 21:23:06 +08:00
parent 4e24d381c2
commit 4cef53e842
23 changed files with 493 additions and 54 deletions
+3 -3
View File
@@ -27,14 +27,14 @@ pnpm test
一期路由:登录、注册、工作台、我的信息、企业资料、企业成员、角色权限和头程订单。登录、注册、退出、令牌刷新、个人资料、修改密码、企业资料、企业资质、权限资源、角色列表/新增/授权和成员完整维护已经接入一期后端接口;开发代理默认转发到 `http://127.0.0.1:9000`,可通过 `API_PROXY_TARGET` 覆盖。登录使用后端约定的用户名和 8–72 位密码,注册成功后可立即登录,企业资料审核只影响订单提交资格。 一期路由:登录、注册、工作台、我的信息、企业资料、企业成员、角色权限和头程订单。登录、注册、退出、令牌刷新、个人资料、修改密码、企业资料、企业资质、权限资源、角色列表/新增/授权和成员完整维护已经接入一期后端接口;开发代理默认转发到 `http://127.0.0.1:9000`,可通过 `API_PROXY_TARGET` 覆盖。登录使用后端约定的用户名和 8–72 位密码,注册成功后可立即登录,企业资料审核只影响订单提交资格。
认证会话保存 Access Token 和轮换后的 Refresh Token,并对并发 401 请求执行单次刷新。客户端只接受 `app_code` 为 `MERCHANT` 的 JWT;页面权限取 JWT 声明与后端启用权限资源的交集。前端权限仅用于导航和交互控制,服务端仍须按用户、租户和权限编码完成最终授权。 认证会话保存 Access Token 和轮换后的 Refresh Token,并对并发 401 请求执行单次刷新。刷新或会话上下文加载遇到临时网络故障、超时等非认证错误时保留当前有效 Token,只有服务端明确返回 401/会话失效才清理登录状态。客户端只接受 `app_code` 为 `MERCHANT` 的 JWT;页面权限取 JWT 声明与后端启用权限资源的交集。前端权限仅用于导航和交互控制,服务端仍须按用户、租户和权限编码完成最终授权。
本地联调账号提示和默认用户名只在 Vite 开发模式下启用,URL 显式传入的登录用户名仍优先。生产登录表单默认留空,生产构建产物不得包含本地联调账号或开发提示文案。 本地联调账号提示和默认用户名只在 Vite 开发模式下启用,URL 显式传入的登录用户名仍优先。生产登录表单默认留空,生产构建产物不得包含本地联调账号或开发提示文案。
“我的信息”使用真实个人资料和修改密码接口,姓名、电话、邮箱按服务端约束维护;修改密码成功后清理旧会话并返回登录页。成员列表返回姓名、联系方式和 `roleIds`,新增、编辑、角色全量保存、启停和管理员重置密码均使用真实接口,不回退静态成功。角色列表返回 `permissionIds`,页面可回显并全量保存角色权限;角色编辑、启停、删除虽在回归报告中通过,但路径尚未进入当前 Gateway OpenAPI,前端继续保持禁用。 “我的信息”使用真实个人资料和修改密码接口,姓名、电话、邮箱按服务端约束维护;修改密码成功后清理旧会话并返回登录页。成员列表返回姓名、联系方式和 `roleIds`,新增、编辑、角色全量保存、启停和管理员重置密码均使用真实接口,不回退静态成功。角色页同时加载成员关系,直接打开或刷新后成员数仍按真实 `roleIds` 统计。角色列表返回 `permissionIds`,页面可回显并全量保存角色权限;角色编辑、启停、删除虽在回归报告中通过,但路径尚未进入当前 Gateway OpenAPI,前端继续保持禁用,避免出现仅修改本地状态的假成功。
企业资料和资质列表使用真实租户接口,覆盖 `PROFILE_REQUIRED`、`PENDING`、`ACTIVE`、`REJECTED`、`DISABLED` 五种企业状态。企业名称、法定名称和联系人信息支持真实保存;营业执照上传使用文件服务,仅接受 JPG、PNG 且不超过 5MB,文件预览通过当前商户 Token 临时下载并在关闭弹窗后释放。 企业资料和资质列表使用真实租户接口,覆盖 `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`,只按待审核展示且不开放编辑。 头程订单列表、详情、仓库查询、创建/编辑草稿和提交已经接入真实 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`,源码中不写死业务服务地址。 联调远端 Gateway 时可在启动命令前设置 `API_PROXY_TARGET=http://192.168.110.149:9000`;进程环境变量优先于 `.env` 文件。浏览器仍统一请求同源 `/api`,源码中不写死业务服务地址。2026-07-30 Gateway 恢复后已完成业务总览、企业资料、成员/角色权限、头程订单列表与详情、仓库详情和快递空态的真实浏览器验收;当前后端样本仍存在部分中文字段为 `????`、旧订单引用失效仓库 `2001`、新建订单往来单位候选为空及缺少快递有值样本的问题。客户端已对失效仓库和历史往来单位快照提供可恢复处理,写操作仍待准备干净样本后验收。
+1 -1
View File
@@ -11,7 +11,7 @@
"preview": "vite preview --port 4174" "preview": "vite preview --port 4174"
}, },
"dependencies": { "dependencies": {
"@chongchong/shared": "link:../../packages/shared", "@chongchong/shared": "link:./shared",
"axios": "^1.18.1", "axios": "^1.18.1",
"element-plus": "^2.14.3", "element-plus": "^2.14.3",
"@lucide/vue": "^1.27.0", "@lucide/vue": "^1.27.0",
+2 -2
View File
@@ -9,8 +9,8 @@ importers:
.: .:
dependencies: dependencies:
'@chongchong/shared': '@chongchong/shared':
specifier: link:../../packages/shared specifier: link:./shared
version: link:../../packages/shared version: link:./shared
'@lucide/vue': '@lucide/vue':
specifier: ^1.27.0 specifier: ^1.27.0
version: 1.27.0(vue@3.5.40(typescript@5.9.3)) version: 1.27.0(vue@3.5.40(typescript@5.9.3))
+28
View File
@@ -0,0 +1,28 @@
# Shared Package Instructions
## 项目定位
- 本包提供跨应用共享的 TypeScript 类型、常量、轻量工具和可复用资源。
- 当前公共能力包括产品名称、用户模型、导航模型、API 响应模型和头程订单状态常量。
- 只有具有明确跨项目语义,或已经被两个或以上项目稳定复用的能力才进入本包;单一应用的页面状态、UI 组件和临时业务逻辑留在对应项目。尚无消费者的契约型导出必须标记为暂定,并有对应 API 或产品决策依据。
## 公共 API 与依赖边界
- `src/index.ts` 是当前唯一公共入口。新增导出应有清晰命名和稳定语义,不让消费者依赖未导出的内部实现。
- 公共类型保持平台无关,不依赖 DOM、Vue、微信、uni-app 或 Node 专属运行时,除非任务明确改变本包定位。
- 不在共享类型中编码客户端鉴权结论;租户隔离、权限和数据合法性仍由 `services/api` 最终校验。
- 避免以“复用”为由建立宽泛的万能类型。优先使用小而明确的模型,并区分请求、响应、领域状态和展示模型。
- 新增依赖前先确认原生 TypeScript 能力或现有依赖无法满足需求;本包不应成为所有应用依赖的重量级入口。
## 兼容性与变更原则
- 修改导出名称、字段类型、枚举值或常量语义前,搜索所有消费者并评估兼容性。
- 优先采用向后兼容的新增;删除或破坏性修改必须同步更新全部消费者,并在交付说明中明确迁移影响。
- API 契约文档是服务端落地后的唯一权威来源;共享包只映射经确认的客户端模型,不反向定义服务端行为。API 尚未落地前,`ApiResponse` 和订单状态等无消费者导出视为暂定,不得当作已发布契约。
- 状态码、权限码和业务常量应有唯一来源,不在多个应用中复制维护相同列表。
## 质量门禁
- 当前包未声明独立的安装、测试、类型检查或构建脚本,不得声称这些检查已在本包内通过。
- 变更公共导出后,至少运行现有消费者的类型检查;当前直接消费者包括 `apps/admin-web` 和 `apps/client-web`。
- 若新增本包自己的质量脚本,应同步更新 `package.json`、`README.md` 和本文件,并为公共工具及兼容性边界增加测试。
+7
View File
@@ -0,0 +1,7 @@
# 共享模块
用于存放跨应用共享的类型定义、常量、工具和可复用资源。
当前已提供产品名称、用户模型、导航模型、API 响应模型,以及经一期产品决策确认的头程订单状态与运输方式常量。
当前还提供平台收货仓模型、订单仓库快照模型和供 Admin、Client 静态原型共用的演示仓库数据。订单仓库快照在下单时复制仓库 ID、编码、名称及国内地址,避免仓库主数据后续改名或停用影响历史订单展示;真实仓库列表、状态、下单可用性和快照持久化最终由服务端 API 保证。
+11
View File
@@ -0,0 +1,11 @@
{
"name": "@chongchong/shared",
"version": "0.1.0",
"private": true,
"type": "module",
"exports": {
".": "./src/index.ts"
},
"types": "./src/index.ts"
}
+155
View File
@@ -0,0 +1,155 @@
export const PRODUCT_NAME = '虫虫出海'
const MAX_SAFE_INTEGER = BigInt(Number.MAX_SAFE_INTEGER)
function quoteUnsafeIntegerTokens(source: string) {
let output = ''
let index = 0
let inString = false
let escaped = false
while (index < source.length) {
const character = source[index]
if (inString) {
output += character
if (escaped) escaped = false
else if (character === '\\') escaped = true
else if (character === '"') inString = false
index += 1
continue
}
if (character === '"') {
inString = true
output += character
index += 1
continue
}
if (character === '-' || (character >= '0' && character <= '9')) {
const token = source.slice(index).match(/^-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?/)?.[0]
if (token) {
const isInteger = !token.includes('.') && !/[eE]/.test(token)
if (isInteger && (BigInt(token) > MAX_SAFE_INTEGER || BigInt(token) < -MAX_SAFE_INTEGER)) output += JSON.stringify(token)
else output += token
index += token.length
continue
}
}
output += character
index += 1
}
return output
}
export function parseJsonWithSafeIntegers(source: string) {
return JSON.parse(quoteUnsafeIntegerTokens(source)) as unknown
}
export function parseApiJsonResponse(data: unknown) {
if (typeof data !== 'string') return data
const trimmed = data.trim()
if (!trimmed || (!trimmed.startsWith('{') && !trimmed.startsWith('['))) return data
try {
return parseJsonWithSafeIntegers(data)
} catch {
return data
}
}
export type AppCode = 'admin' | 'client'
export interface CurrentUser {
id: string
name: string
account: string
tenantName?: string
roles: string[]
}
export interface NavigationItem {
path: string
title: string
permission?: string
}
export interface ApiResponse<T> {
code: number
message: string
data: T
}
export const FIRST_LEG_ORDER_STATUSES = [
{ code: 'DRAFT', label: '草稿' },
{ code: 'PENDING_REVIEW', label: '待审核' },
{ code: 'ACCEPTED', label: '已受理' },
{ code: 'BOOKED', label: '已订舱' },
{ code: 'IN_TRANSIT', label: '运输中' },
{ code: 'COMPLETED', label: '已完成' },
{ code: 'CANCELLED', label: '已取消' },
] as const
export type FirstLegOrderStatus = (typeof FIRST_LEG_ORDER_STATUSES)[number]['code']
export type FirstLegOrderStatusLabel = (typeof FIRST_LEG_ORDER_STATUSES)[number]['label']
export const FIRST_LEG_TRANSPORT_MODES = [
{ code: 'FCL', label: '海运整箱 FCL' },
{ code: 'LCL', label: '海运拼箱 LCL' },
{ code: 'AIR', label: '空运' },
{ code: 'ROAD', label: '陆运' },
] as const
export type FirstLegTransportMode = (typeof FIRST_LEG_TRANSPORT_MODES)[number]['code']
export type FirstLegTransportModeLabel = (typeof FIRST_LEG_TRANSPORT_MODES)[number]['label']
export type PlatformWarehouseStatus = 'ACTIVE' | 'INACTIVE'
export interface PlatformReceivingWarehouse {
id: string
code: string
name: string
province: string
city: string
address: string
contactName: string
contactPhone: string
businessHours: string
status: PlatformWarehouseStatus
remark: string
updatedAt: string
}
export interface PlatformReceivingWarehouseSnapshot {
id: string
code: string
name: string
province: string
city: string
address: string
}
export function createPlatformReceivingWarehouseSnapshot(
warehouse: PlatformReceivingWarehouse,
): PlatformReceivingWarehouseSnapshot {
return {
id: warehouse.id,
code: warehouse.code,
name: warehouse.name,
province: warehouse.province,
city: warehouse.city,
address: warehouse.address,
}
}
// API 接入前供 Admin 与 Client 静态原型共用,不能作为服务端真实仓库数据。
export const DEMO_PLATFORM_RECEIVING_WAREHOUSES: readonly PlatformReceivingWarehouse[] = [
{ id: 'WH001', code: 'CN-YW-01', name: '义乌仓', province: '浙江省', city: '义乌市', address: '北苑街道演示物流园 1 号库', contactName: '仓库客服', contactPhone: '0579-****001', businessHours: '周一至周六 08:30-18:00', status: 'ACTIVE', remark: '支持普通货物收货。', updatedAt: '2026-07-28 10:20' },
{ id: 'WH002', code: 'CN-SZ-01', name: '深圳仓', province: '广东省', city: '深圳市', address: '盐田区演示物流园 2 号库', contactName: '仓库客服', contactPhone: '0755-****002', businessHours: '周一至周六 08:30-18:00', status: 'ACTIVE', remark: '华南区域主要收货仓。', updatedAt: '2026-07-28 10:20' },
{ id: 'WH003', code: 'CN-QD-01', name: '青岛仓', province: '山东省', city: '青岛市', address: '黄岛区演示物流园 3 号库', contactName: '仓库客服', contactPhone: '0532-****003', businessHours: '周一至周五 09:00-17:30', status: 'ACTIVE', remark: '', updatedAt: '2026-07-28 10:20' },
{ id: 'WH004', code: 'CN-SH-01', name: '上海仓', province: '上海市', city: '上海市', address: '浦东新区演示物流园 4 号库', contactName: '仓库客服', contactPhone: '021-****004', businessHours: '周一至周六 08:30-18:00', status: 'ACTIVE', remark: '华东区域综合收货仓。', updatedAt: '2026-07-28 10:20' },
{ id: 'WH005', code: 'CN-NB-01', name: '宁波仓', province: '浙江省', city: '宁波市', address: '北仑区演示物流园 5 号库', contactName: '仓库客服', contactPhone: '0574-****005', businessHours: '周一至周六 08:30-18:00', status: 'ACTIVE', remark: '临近宁波港。', updatedAt: '2026-07-28 10:20' },
{ id: 'WH006', code: 'CN-XM-01', name: '厦门仓', province: '福建省', city: '厦门市', address: '湖里区演示物流园 6 号库', contactName: '仓库客服', contactPhone: '0592-****006', businessHours: '周一至周五 09:00-17:30', status: 'INACTIVE', remark: '静态演示:暂停接收新订单。', updatedAt: '2026-07-28 10:20' },
]
+8 -3
View File
@@ -1,10 +1,12 @@
import axios, { AxiosError, type AxiosRequestConfig, type InternalAxiosRequestConfig } from 'axios' import axios, { AxiosError, type AxiosRequestConfig, type InternalAxiosRequestConfig } from 'axios'
import { parseApiJsonResponse } from '@chongchong/shared'
import { clearSessionTokens, getAccessToken, getRefreshToken, persistTokenPair, type TokenPair } from '@/api/session' import { clearSessionTokens, getAccessToken, getRefreshToken, persistTokenPair, type TokenPair } from '@/api/session'
import { ApiError, asFieldErrors, isApiEnvelope, type ApiEnvelope } from '@/api/types' import { ApiError, asFieldErrors, isApiEnvelope, type ApiEnvelope } from '@/api/types'
const transport = axios.create({ const transport = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL || '/api', baseURL: import.meta.env.VITE_API_BASE_URL || '/api',
timeout: 15_000, timeout: 15_000,
transformResponse: [(data: unknown) => parseApiJsonResponse(data)],
}) })
interface RetryableRequest extends InternalAxiosRequestConfig { interface RetryableRequest extends InternalAxiosRequestConfig {
@@ -79,9 +81,12 @@ transport.interceptors.response.use(
request.headers.Authorization = `Bearer ${tokens.accessToken}` request.headers.Authorization = `Bearer ${tokens.accessToken}`
return await transport.request(request) return await transport.request(request)
} catch (refreshError) { } catch (refreshError) {
clearSessionTokens() const mappedError = mapError(refreshError)
window.dispatchEvent(new CustomEvent('cc-client-session-expired')) if (mappedError.status === 401 || mappedError.code === 'A0401') {
throw mapError(refreshError) clearSessionTokens()
window.dispatchEvent(new CustomEvent('cc-client-session-expired'))
}
throw mappedError
} }
}, },
) )
+26
View File
@@ -0,0 +1,26 @@
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
import test from 'node:test'
import ts from 'typescript'
const source = await readFile(new URL('../../../../packages/shared/src/index.ts', import.meta.url), 'utf8')
const compiled = ts.transpileModule(source, {
compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022 },
}).outputText
const parser = await import(`data:text/javascript;base64,${Buffer.from(compiled).toString('base64')}`)
test('preserves integers outside the JavaScript safe range as strings', () => {
const value = parser.parseJsonWithSafeIntegers('{"id":2082674456475418625,"nested":[-2082674456475418625]}')
assert.deepEqual(value, { id: '2082674456475418625', nested: ['-2082674456475418625'] })
})
test('keeps safe integers and decimals numeric without changing numeric strings', () => {
const value = parser.parseJsonWithSafeIntegers('{"page":1,"weight":12.5,"id":"2082674456475418625"}')
assert.deepEqual(value, { page: 1, weight: 12.5, id: '2082674456475418625' })
})
test('leaves non-JSON and binary response values untouched', () => {
assert.equal(parser.parseApiJsonResponse('plain text'), 'plain text')
const binary = new Uint8Array([1, 2, 3])
assert.equal(parser.parseApiJsonResponse(binary), binary)
})
+2
View File
@@ -2,6 +2,8 @@ import { createApp } from 'vue'
import { createPinia } from 'pinia' import { createPinia } from 'pinia'
import App from './App.vue' import App from './App.vue'
import router from './router' import router from './router'
import 'element-plus/es/components/message/style/css'
import 'element-plus/es/components/message-box/style/css'
import './styles/index.css' import './styles/index.css'
createApp(App).use(createPinia()).use(router).mount('#app') createApp(App).use(createPinia()).use(router).mount('#app')
+16 -4
View File
@@ -2,6 +2,7 @@ import { computed, ref } from 'vue'
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import type { CurrentUser } from '@chongchong/shared' import type { CurrentUser } from '@chongchong/shared'
import { loginMerchant, logoutMerchant } from '@/api/auth' import { loginMerchant, logoutMerchant } from '@/api/auth'
import { ApiError } from '@/api/http'
import { getMerchantPermissionResources, getMerchantPersonalProfile, getTenantProfile, updateMerchantPersonalProfile, type MerchantPermissionResource } from '@/api/merchantIam' import { getMerchantPermissionResources, getMerchantPersonalProfile, getTenantProfile, updateMerchantPersonalProfile, type MerchantPermissionResource } from '@/api/merchantIam'
import { clearSessionTokens, getAccessToken, getRefreshToken, persistTokenPair } from '@/api/session' import { clearSessionTokens, getAccessToken, getRefreshToken, persistTokenPair } from '@/api/session'
import { TENANT_PERMISSION_GROUPS } from '@/stores/tenantAdministration' import { TENANT_PERMISSION_GROUPS } from '@/stores/tenantAdministration'
@@ -74,6 +75,10 @@ function currentUser(payload: MerchantJwtPayload, tenantName?: string): CurrentU
} }
} }
function isExpiredSessionError(error: unknown) {
return error instanceof ApiError && (error.status === 401 || error.code === 'A0401')
}
export const useAuthStore = defineStore('client-auth', () => { export const useAuthStore = defineStore('client-auth', () => {
const token = ref(getAccessToken()) const token = ref(getAccessToken())
const payload = ref<MerchantJwtPayload | null>(null) const payload = ref<MerchantJwtPayload | null>(null)
@@ -139,8 +144,12 @@ export const useAuthStore = defineStore('client-auth', () => {
try { try {
await loadSessionContext() await loadSessionContext()
} catch (error) { } catch (error) {
clearSession() if (isExpiredSessionError(error)) {
throw error clearSession()
throw error
}
sessionRestored.value = true
return
} }
sessionRestored.value = true sessionRestored.value = true
} }
@@ -159,8 +168,11 @@ export const useAuthStore = defineStore('client-auth', () => {
await loadSessionContext() await loadSessionContext()
sessionRestored.value = true sessionRestored.value = true
} catch (error) { } catch (error) {
clearSession() if (isExpiredSessionError(error)) {
throw error clearSession()
throw error
}
sessionRestored.value = true
} }
} }
+15
View File
@@ -10,6 +10,7 @@ test('declares the documented merchant warehouse and first-leg endpoints', () =>
assert.match(apiSource, /url: '\/merchant\/v1\/orders\/first-leg'/) 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\}`/)
assert.match(apiSource, /`\/merchant\/v1\/orders\/first-leg\/\$\{id\}\/submit`/) assert.match(apiSource, /`\/merchant\/v1\/orders\/first-leg\/\$\{id\}\/submit`/)
assert.match(apiSource, /`\/merchant\/v1\/warehouses\/\$\{id\}`/)
}) })
test('maps transport and status fields instead of treating container mode as transport mode', () => { test('maps transport and status fields instead of treating container mode as transport mode', () => {
@@ -48,3 +49,17 @@ test('does not expose the contradictory backend rejected state in the client mod
assert.match(storeSource, /status === 'REJECTED'\) return 'PENDING_REVIEW'/) assert.match(storeSource, /status === 'REJECTED'\) return 'PENDING_REVIEW'/)
assert.match(storeSource, /item\.toStatus === 'SUBMITTED' \|\| item\.toStatus === 'REJECTED' \? 'SUBMITTED'/) assert.match(storeSource, /item\.toStatus === 'SUBMITTED' \|\| item\.toStatus === 'REJECTED' \? 'SUBMITTED'/)
}) })
test('maps backend warehouse snapshots and fulfillment courier fields', () => {
assert.match(storeSource, /order\.receivingWarehouseAddress/)
assert.match(storeSource, /order\.expressCompany \?\? ''/)
assert.match(storeSource, /order\.expressTrackingNo \?\? ''/)
assert.match(storeSource, /getMerchantWarehouseDetail\(id\)/)
assert.match(storeSource, /receivingCapability\.cutoffTime/)
})
test('keeps failed order loads distinct from empty results', () => {
assert.match(storeSource, /const loadError = ref\(''\)/)
assert.match(storeSource, /loadError\.value = error instanceof Error/)
assert.match(storeSource, /return \{ orders, warehouses, loading, loadedFromApi, loadError/)
})
+6
View File
@@ -109,6 +109,10 @@ a { color: inherit; }
.registration-result-actions { display: flex; justify-content: center; gap: 10px; margin-top: 28px; } .registration-result-actions { display: flex; justify-content: center; gap: 10px; margin-top: 28px; }
.client-dashboard { display: flex; flex-direction: column; gap: 18px; } .client-dashboard { display: flex; flex-direction: column; gap: 18px; }
.dashboard-load-error { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 12px; padding: 14px 16px; border: 1px solid #f1d4a8; border-radius: 12px; background: #fff9ef; color: #9a5a09; }
.dashboard-load-error > div { display: flex; min-width: 0; flex-direction: column; gap: 3px; }
.dashboard-load-error strong { color: var(--navy); font-size: 14px; }
.dashboard-load-error span { overflow-wrap: anywhere; color: var(--muted); font-size: 12px; line-height: 1.5; }
.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 { 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 span { color: #84c3ba; font-size: 11px; font-weight: 700; }
.quick-order-band h2 { margin: 6px 0; font-size: 22px; } .quick-order-band h2 { margin: 6px 0; font-size: 22px; }
@@ -162,6 +166,8 @@ a { color: inherit; }
.tenant-name, .client-user-copy { display: none; } .tenant-name, .client-user-copy { display: none; }
.client-user { margin-left: auto; } .client-user { margin-left: auto; }
.client-page-heading > button { display: grid; width: 44px; height: 44px; place-items: center; } .client-page-heading > button { display: grid; width: 44px; height: 44px; place-items: center; }
.dashboard-load-error { grid-template-columns: 1fr; justify-items: start; }
.dashboard-load-error .el-button { width: 100%; min-height: 44px; }
.client-stats { grid-template-columns: 1fr; } .client-stats { grid-template-columns: 1fr; }
.quick-order-band { align-items: flex-start; flex-direction: column; } .quick-order-band { align-items: flex-start; flex-direction: column; }
.form-grid { grid-template-columns: 1fr; } .form-grid { grid-template-columns: 1fr; }
+14
View File
@@ -8,6 +8,7 @@ const authStoreSource = readFileSync(new URL('../stores/auth.ts', import.meta.ur
const httpSource = readFileSync(new URL('../api/http.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 sessionSource = readFileSync(new URL('../api/session.ts', import.meta.url), 'utf8')
const authApiSource = readFileSync(new URL('../api/auth.ts', import.meta.url), 'utf8') const authApiSource = readFileSync(new URL('../api/auth.ts', import.meta.url), 'utf8')
const mainSource = readFileSync(new URL('../main.ts', import.meta.url), 'utf8')
test('matches the backend username login contract', () => { test('matches the backend username login contract', () => {
assert.match(loginSource, /label="用户名"/) assert.match(loginSource, /label="用户名"/)
@@ -52,3 +53,16 @@ test('uses real auth endpoints and single-flight refresh handling', () => {
assert.match(authStoreSource, /decodeMerchantToken/) assert.match(authStoreSource, /decodeMerchantToken/)
assert.doesNotMatch(authStoreSource, /mock-client:/) assert.doesNotMatch(authStoreSource, /mock-client:/)
}) })
test('keeps a valid local session during temporary context and refresh outages', () => {
assert.match(authStoreSource, /function isExpiredSessionError/)
assert.match(authStoreSource, /if \(isExpiredSessionError\(error\)\)/)
assert.match(authStoreSource, /sessionRestored\.value = true\s+return/)
assert.match(httpSource, /if \(mappedError\.status === 401 \|\| mappedError\.code === 'A0401'\)/)
assert.doesNotMatch(httpSource, /catch \(refreshError\) \{\s+clearSessionTokens\(\)/)
})
test('loads styles for programmatic Element Plus messages and dialogs', () => {
assert.match(mainSource, /element-plus\/es\/components\/message\/style\/css/)
assert.match(mainSource, /element-plus\/es\/components\/message-box\/style\/css/)
})
+1 -1
View File
@@ -225,12 +225,12 @@ function qualificationStatus(status: string) {
<div class="client-card qualification-panel"> <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> <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 class="desktop-qualification-table" :data="store.qualifications" row-key="id">
<el-table-column label="操作" width="90" fixed="left"><template #default="{ row }"><el-button link type="primary" @click="previewQualification(row as TenantQualification)">查看</el-button></template></el-table-column>
<el-table-column prop="type" label="文件类型" min-width="150" /> <el-table-column prop="type" label="文件类型" min-width="150" />
<el-table-column prop="fileName" label="文件名称" min-width="220" show-overflow-tooltip /> <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 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="expiresAt" label="有效期" width="130" />
<el-table-column prop="updatedAt" label="更新时间" min-width="160" /> <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> </el-table>
<div class="mobile-qualification-list"> <div class="mobile-qualification-list">
<article v-for="record in store.qualifications" :key="record.id"> <article v-for="record in store.qualifications" :key="record.id">
+18
View File
@@ -0,0 +1,18 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
const source = readFileSync(new URL('./DashboardView.vue', import.meta.url), 'utf8')
test('loads real dashboard data allowed by the current permissions', () => {
assert.match(source, /auth\.hasPermission\('company:profile:view'\).*tenant\.loadCompanyContext\(\)/)
assert.match(source, /auth\.hasPermission\('company:member:list'\).*tenant\.loadMembers\(\)/)
assert.match(source, /auth\.hasPermission\('order:first-leg:list'\).*orders\.load\(\)/)
assert.match(source, /onMounted\(\(\) => void loadDashboardData\(\)\)/)
})
test('shows a recoverable dashboard load failure', () => {
assert.match(source, /role="alert"/)
assert.match(source, /业务总览加载失败/)
assert.match(source, /@click="loadDashboardData"/)
})
+28 -3
View File
@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed, onMounted, ref } from 'vue'
import { ArrowRight, ClipboardCheck, Clock3, FilePlus2, Users } from '@lucide/vue' import { AlertTriangle, ArrowRight, ClipboardCheck, Clock3, FilePlus2, Users } from '@lucide/vue'
import { useFirstLegOrdersStore } from '@/stores/firstLegOrders' import { useFirstLegOrdersStore } from '@/stores/firstLegOrders'
import { useTenantAdministrationStore } from '@/stores/tenantAdministration' import { useTenantAdministrationStore } from '@/stores/tenantAdministration'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
@@ -9,6 +9,8 @@ import { getFirstLegStatusLabel, getFirstLegStatusTone } from '@/utils/firstLegO
const orders = useFirstLegOrdersStore() const orders = useFirstLegOrdersStore()
const tenant = useTenantAdministrationStore() const tenant = useTenantAdministrationStore()
const auth = useAuthStore() const auth = useAuthStore()
const loading = ref(false)
const loadError = ref('')
const companyApproved = computed(() => tenant.company.auditStatus === 'ACTIVE') const companyApproved = computed(() => tenant.company.auditStatus === 'ACTIVE')
const stats = computed(() => [ const stats = computed(() => [
{ label: '进行中订单', value: String(orders.activeCount), icon: Clock3 }, { label: '进行中订单', value: String(orders.activeCount), icon: Clock3 },
@@ -16,10 +18,33 @@ const stats = computed(() => [
{ label: '企业成员', value: String(tenant.activeMemberCount), icon: Users }, { label: '企业成员', value: String(tenant.activeMemberCount), icon: Users },
]) ])
const recentOrders = computed(() => orders.orders.slice(0, 3)) const recentOrders = computed(() => orders.orders.slice(0, 3))
async function loadDashboardData() {
loading.value = true
loadError.value = ''
const requests: Promise<unknown>[] = []
if (auth.hasPermission('company:profile:view')) requests.push(tenant.loadCompanyContext())
if (auth.hasPermission('company:member:list')) requests.push(tenant.loadMembers())
if (auth.hasPermission('order:first-leg:list')) requests.push(orders.load())
try {
await Promise.all(requests)
} catch (error) {
loadError.value = error instanceof Error ? error.message : '业务总览加载失败'
} finally {
loading.value = false
}
}
onMounted(() => void loadDashboardData())
</script> </script>
<template> <template>
<div class="client-dashboard"> <div v-loading="loading" class="client-dashboard">
<div v-if="loadError" class="dashboard-load-error" role="alert">
<AlertTriangle :size="20" />
<div><strong>业务总览加载失败</strong><span>{{ loadError }}</span></div>
<el-button :loading="loading" @click="loadDashboardData">重新加载</el-button>
</div>
<section class="quick-order-band"> <section class="quick-order-band">
<div><span>头程物流</span><h2>{{ auth.hasPermission('order:first-leg:create') ? '创建新的头程订单' : '跟进企业头程订单' }}</h2><p>{{ companyApproved ? '企业资料已审核,可正常提交并跟进订单。' : '企业资料待平台审核,当前可以维护并保存订单草稿。' }}</p></div> <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> <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>
+33 -1
View File
@@ -14,7 +14,8 @@ test('exposes real list, draft, submit, and detail workflow entries without canc
assert.match(source, /async function saveDraft\(\)/) assert.match(source, /async function saveDraft\(\)/)
assert.match(source, /function submitOrder\(\)/) assert.match(source, /function submitOrder\(\)/)
assert.match(source, /async function openDetail\(order: FirstLegOrder\)/) assert.match(source, /async function openDetail\(order: FirstLegOrder\)/)
assert.match(source, /Promise\.all\(\[store\.load\(\), firstLegPartySource\.load\(\)\]\)/) assert.match(source, /\[store\.load\(\), firstLegPartySource\.load\(\)\]/)
assert.match(source, /tenantStore\.loadCompanyContext\(\)/)
assert.match(source, /await store\.loadDetail\(order\.id\)/) assert.match(source, /await store\.loadDetail\(order\.id\)/)
assert.doesNotMatch(source, /cancelOrder|取消订单/) assert.doesNotMatch(source, /cancelOrder|取消订单/)
}) })
@@ -54,6 +55,7 @@ test('requires an enabled platform receiving warehouse in the order form', () =>
assert.match(source, /selectedWarehouse\.businessHours/) assert.match(source, /selectedWarehouse\.businessHours/)
assert.match(source, /detailOrder\.receivingWarehouseSnapshot\?\.name/) assert.match(source, /detailOrder\.receivingWarehouseSnapshot\?\.name/)
assert.match(source, /detailOrder\.receivingWarehouseSnapshot\.code/) assert.match(source, /detailOrder\.receivingWarehouseSnapshot\.code/)
assert.match(source, /loadSelectedWarehouse\(detailedOrder\.receivingWarehouseId\)/)
}) })
test('collects the backend-required first-leg fields', () => { test('collects the backend-required first-leg fields', () => {
@@ -71,6 +73,8 @@ test('selects enabled counterparties and stores editable order snapshots', () =>
assert.match(source, /form\.shipperPartySnapshot\.contactName/) assert.match(source, /form\.shipperPartySnapshot\.contactName/)
assert.match(source, /form\.consigneePartySnapshot\.address/) assert.match(source, /form\.consigneePartySnapshot\.address/)
assert.match(source, /只影响本订单快照/) assert.match(source, /只影响本订单快照/)
assert.match(source, /form\.shipperPartySnapshot\.name\} · 历史快照/)
assert.match(source, /form\.consigneePartySnapshot\.name\} · 历史快照/)
}) })
test('shows fulfillment courier information as read-only client detail', () => { test('shows fulfillment courier information as read-only client detail', () => {
@@ -81,3 +85,31 @@ test('shows fulfillment courier information as read-only client detail', () => {
assert.doesNotMatch(source, /v-model="form\.expressCompanyName"/) assert.doesNotMatch(source, /v-model="form\.expressCompanyName"/)
assert.doesNotMatch(source, /v-model="form\.trackingNumber"/) assert.doesNotMatch(source, /v-model="form\.trackingNumber"/)
}) })
test('protects unsaved drafts and exposes recoverable load failures', () => {
assert.match(source, /onBeforeRouteLeave/)
assert.match(source, /beforeunload/)
assert.match(source, /confirmDiscardChanges/)
assert.match(source, /:before-close="requestCloseEditor"/)
assert.match(source, /store\.loadError/)
assert.match(source, /订单数据加载失败/)
assert.match(source, /订单详情加载失败/)
assert.match(source, /@click="loadPageData"/)
assert.match(source, /@click="retryDetail"/)
})
test('copies reactive order drafts into plain request data', () => {
assert.match(source, /function cloneDraft\(draft: FirstLegOrderDraft\)/)
assert.match(source, /items: draft\.items\.map\(\(item\) => \(\{ \.\.\.item \}\)\)/)
assert.match(source, /store\.saveDraft\(cloneDraft\(form\)/)
assert.match(source, /store\.submit\(cloneDraft\(form\)/)
assert.doesNotMatch(source, /structuredClone\((?:form|order\.)/)
})
test('loads complete order detail before editing and rejects an unavailable historical warehouse', () => {
assert.match(source, /async function openEdit\(order: FirstLegOrder\)/)
assert.match(source, /const detailedOrder = await store\.loadDetail\(order\.id\)/)
assert.match(source, /draftFromOrder\(detailedOrder\)/)
assert.match(source, /draft\.receivingWarehouseId = ''/)
assert.match(source, /原平台收货仓当前不可用,请重新选择可用收货仓/)
})
+110 -31
View File
@@ -35,6 +35,7 @@ const dateRange = ref<[string, string] | []>([])
const editorOpen = ref(false) const editorOpen = ref(false)
const detailOpen = ref(false) const detailOpen = ref(false)
const editingId = ref<string>() const editingId = ref<string>()
const openingEditor = ref(false)
const detailId = ref<string>() const detailId = ref<string>()
const saving = ref(false) const saving = ref(false)
const detailLoading = ref(false) const detailLoading = ref(false)
@@ -42,6 +43,19 @@ const detailError = ref('')
const editorBaseline = ref('') const editorBaseline = ref('')
let cargoSequence = 0 let cargoSequence = 0
function formatDateTime(value: string) {
const date = new Date(value)
if (Number.isNaN(date.getTime())) return value || '-'
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false,
})
}
function emptyDraft(): FirstLegOrderDraft { function emptyDraft(): FirstLegOrderDraft {
return { return {
referenceNo: '', referenceNo: '',
@@ -97,7 +111,9 @@ const emptyDescription = computed(() => hasActiveFilters.value ? '没有符合
async function loadPageData() { async function loadPageData() {
try { try {
await Promise.all([store.load(), firstLegPartySource.load()]) const requests: Promise<unknown>[] = [store.load(), firstLegPartySource.load()]
if (auth.hasPermission('company:profile:view')) requests.push(tenantStore.loadCompanyContext())
await Promise.all(requests)
availableShippers.value = firstLegPartySource.listAvailable('SHIPPER') availableShippers.value = firstLegPartySource.listAvailable('SHIPPER')
availableConsignees.value = firstLegPartySource.listAvailable('CONSIGNEE') availableConsignees.value = firstLegPartySource.listAvailable('CONSIGNEE')
} catch (error) { } catch (error) {
@@ -127,8 +143,17 @@ function markEditorClean() {
editorBaseline.value = serializedDraft.value editorBaseline.value = serializedDraft.value
} }
function cloneDraft(draft: FirstLegOrderDraft): FirstLegOrderDraft {
return {
...draft,
shipperPartySnapshot: draft.shipperPartySnapshot ? { ...draft.shipperPartySnapshot } : null,
consigneePartySnapshot: draft.consigneePartySnapshot ? { ...draft.consigneePartySnapshot } : null,
items: draft.items.map((item) => ({ ...item })),
}
}
function replaceForm(draft: FirstLegOrderDraft) { function replaceForm(draft: FirstLegOrderDraft) {
Object.assign(form, structuredClone(draft)) Object.assign(form, cloneDraft(draft))
markEditorClean() markEditorClean()
} }
@@ -142,10 +167,10 @@ function draftFromOrder(order: FirstLegOrder): FirstLegOrderDraft {
receivingWarehouseId: order.receivingWarehouseId, receivingWarehouseId: order.receivingWarehouseId,
expectedDepartureDate: order.expectedDepartureDate, expectedDepartureDate: order.expectedDepartureDate,
shipperPartyId: order.shipperPartyId, shipperPartyId: order.shipperPartyId,
shipperPartySnapshot: order.shipperPartySnapshot ? structuredClone(order.shipperPartySnapshot) : null, shipperPartySnapshot: order.shipperPartySnapshot ? { ...order.shipperPartySnapshot } : null,
shipper: order.shipper, shipper: order.shipper,
consigneePartyId: order.consigneePartyId, consigneePartyId: order.consigneePartyId,
consigneePartySnapshot: order.consigneePartySnapshot ? structuredClone(order.consigneePartySnapshot) : null, consigneePartySnapshot: order.consigneePartySnapshot ? { ...order.consigneePartySnapshot } : null,
consignee: order.consignee, consignee: order.consignee,
priority: order.priority, priority: order.priority,
customerRemark: order.customerRemark, customerRemark: order.customerRemark,
@@ -160,7 +185,7 @@ function draftFromOrder(order: FirstLegOrder): FirstLegOrderDraft {
destinationType: order.destinationType, destinationType: order.destinationType,
amazonWarehouseCode: order.amazonWarehouseCode, amazonWarehouseCode: order.amazonWarehouseCode,
shipmentId: order.shipmentId, shipmentId: order.shipmentId,
items: structuredClone(order.items), items: order.items.map((item) => ({ ...item })),
} }
} }
@@ -226,12 +251,27 @@ function openCreate() {
editorOpen.value = true editorOpen.value = true
} }
function openEdit(order: FirstLegOrder) { async function openEdit(order: FirstLegOrder) {
if (!canEditOrder.value || !canClientEditFirstLegOrder(order.status)) return if (openingEditor.value || !canEditOrder.value || !canClientEditFirstLegOrder(order.status)) return
editingId.value = order.id openingEditor.value = true
replaceForm(draftFromOrder(order)) try {
detailOpen.value = false const detailedOrder = await store.loadDetail(order.id)
editorOpen.value = true const draft = draftFromOrder(detailedOrder)
const warehouseAvailable = availableWarehouses.value.some((item) => item.id === detailedOrder.receivingWarehouseId)
if (!warehouseAvailable) {
draft.receivingWarehouseId = ''
ElMessage.warning('原平台收货仓当前不可用,请重新选择可用收货仓')
}
editingId.value = detailedOrder.id
replaceForm(draft)
if (warehouseAvailable) void loadSelectedWarehouse(detailedOrder.receivingWarehouseId)
detailOpen.value = false
editorOpen.value = true
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '订单详情加载失败,暂时无法编辑')
} finally {
openingEditor.value = false
}
} }
async function openDetail(order: FirstLegOrder) { async function openDetail(order: FirstLegOrder) {
@@ -300,7 +340,7 @@ async function saveDraft() {
} }
saving.value = true saving.value = true
try { try {
const order = await store.saveDraft(structuredClone(form), tenantStore.company.companyName, editingId.value) const order = await store.saveDraft(cloneDraft(form), tenantStore.company.companyName, editingId.value)
editingId.value = order.id editingId.value = order.id
markEditorClean() markEditorClean()
editorOpen.value = false editorOpen.value = false
@@ -338,7 +378,7 @@ async function submitOrder() {
try { try {
saving.value = true saving.value = true
const order = await store.submit(structuredClone(form), tenantStore.company.companyName, editingId.value, tenantStore.company.auditStatus) const order = await store.submit(cloneDraft(form), tenantStore.company.companyName, editingId.value, tenantStore.company.auditStatus)
markEditorClean() markEditorClean()
editorOpen.value = false editorOpen.value = false
detailId.value = order.id detailId.value = order.id
@@ -392,7 +432,21 @@ function resetFilters() {
</div> </div>
<div v-loading="store.loading" class="client-card order-table-card"> <div v-loading="store.loading" class="client-card order-table-card">
<div v-if="store.loadError" class="order-load-state" role="alert">
<AlertTriangle :size="24" />
<div><strong>订单数据加载失败</strong><span>{{ store.loadError }}</span></div>
<el-button :loading="store.loading" @click="loadPageData">重新加载</el-button>
</div>
<template v-else>
<el-table v-if="filteredOrders.length" class="desktop-order-table" :data="filteredOrders" row-key="id"> <el-table v-if="filteredOrders.length" class="desktop-order-table" :data="filteredOrders" row-key="id">
<el-table-column label="操作" width="180" fixed="left">
<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" :loading="openingEditor" @click="openEdit(row as FirstLegOrder)"><Pencil :size="15" />编辑</el-button>
</div>
</template>
</el-table-column>
<el-table-column label="订单" min-width="190"> <el-table-column label="订单" min-width="190">
<template #default="{ row }"> <template #default="{ row }">
<button class="order-number-link" type="button" @click="openDetail(row as FirstLegOrder)">{{ (row as FirstLegOrder).orderNo || '未提交草稿' }}</button> <button class="order-number-link" type="button" @click="openDetail(row as FirstLegOrder)">{{ (row as FirstLegOrder).orderNo || '未提交草稿' }}</button>
@@ -410,30 +464,23 @@ function resetFilters() {
<template #default="{ row }"><el-tag :type="getFirstLegStatusTone((row as FirstLegOrder).status)" effect="light">{{ getFirstLegStatusLabel((row as FirstLegOrder).status) }}</el-tag></template> <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>
<el-table-column label="负责人" prop="operatorName" width="110" /> <el-table-column label="负责人" prop="operatorName" width="110" />
<el-table-column label="更新时间" prop="updatedAt" width="155" /> <el-table-column label="更新时间" width="155"><template #default="{ row }">{{ formatDateTime((row as FirstLegOrder).updatedAt) }}</template></el-table-column>
<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> </el-table>
<div v-if="filteredOrders.length" class="mobile-order-list"> <div v-if="filteredOrders.length" class="mobile-order-list">
<article v-for="order in filteredOrders" :key="order.id" class="mobile-order-card"> <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> <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> <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> <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> <footer><span>更新于 {{ formatDateTime(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" :loading="openingEditor" @click="openEdit(order)"><Pencil :size="15" />编辑</el-button></div></footer>
</article> </article>
</div> </div>
<el-empty v-else description="没有符合条件的头程订单"> <el-empty v-else :description="emptyDescription">
<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-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> </el-empty>
</template>
</div> </div>
<el-drawer v-model="editorOpen" class="first-leg-editor" :title="editingId ? '编辑订单草稿' : '创建头程订单'" size="760px" destroy-on-close> <el-drawer v-model="editorOpen" class="first-leg-editor" :title="editingId ? '编辑订单草稿' : '创建头程订单'" size="760px" destroy-on-close :before-close="requestCloseEditor">
<el-form label-position="top" class="order-form"> <el-form label-position="top" class="order-form">
<section class="order-form-section"> <section class="order-form-section">
<h3>运输信息</h3> <h3>运输信息</h3>
@@ -442,7 +489,7 @@ function resetFilters() {
<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-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.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-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="平台收货仓" required><el-select v-model="form.receivingWarehouseId" filterable placeholder="请选择送货仓库" @change="loadSelectedWarehouse"><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-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-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="优先级"><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>
@@ -467,6 +514,7 @@ function resetFilters() {
<header><span><Building2 :size="17" /><strong>发货单位</strong></span><small>必填</small></header> <header><span><Building2 :size="17" /><strong>发货单位</strong></span><small>必填</small></header>
<el-form-item label="选择发货单位" required> <el-form-item label="选择发货单位" required>
<el-select v-model="form.shipperPartyId" filterable clearable placeholder="请选择已启用的发货单位" @change="selectShipper" @clear="clearShipper"> <el-select v-model="form.shipperPartyId" filterable clearable placeholder="请选择已启用的发货单位" @change="selectShipper" @clear="clearShipper">
<el-option v-if="form.shipperPartySnapshot && !availableShippers.some((party) => party.id === form.shipperPartyId)" :label="`${form.shipperPartySnapshot.name} · 历史快照`" :value="form.shipperPartyId" disabled />
<el-option v-for="party in availableShippers" :key="party.id" :label="`${party.name} · ${party.code}`" :value="party.id" /> <el-option v-for="party in availableShippers" :key="party.id" :label="`${party.name} · ${party.code}`" :value="party.id" />
</el-select> </el-select>
</el-form-item> </el-form-item>
@@ -484,6 +532,7 @@ function resetFilters() {
<header><span><Building2 :size="17" /><strong>收货单位</strong></span><small>必填</small></header> <header><span><Building2 :size="17" /><strong>收货单位</strong></span><small>必填</small></header>
<el-form-item label="选择收货单位" required> <el-form-item label="选择收货单位" required>
<el-select v-model="form.consigneePartyId" filterable clearable placeholder="请选择已启用的收货单位" @change="selectConsignee" @clear="clearConsignee"> <el-select v-model="form.consigneePartyId" filterable clearable placeholder="请选择已启用的收货单位" @change="selectConsignee" @clear="clearConsignee">
<el-option v-if="form.consigneePartySnapshot && !availableConsignees.some((party) => party.id === form.consigneePartyId)" :label="`${form.consigneePartySnapshot.name} · 历史快照`" :value="form.consigneePartyId" disabled />
<el-option v-for="party in availableConsignees" :key="party.id" :label="`${party.name} · ${party.code}`" :value="party.id" /> <el-option v-for="party in availableConsignees" :key="party.id" :label="`${party.name} · ${party.code}`" :value="party.id" />
</el-select> </el-select>
</el-form-item> </el-form-item>
@@ -530,11 +579,17 @@ function resetFilters() {
<el-form-item label="客户备注"><el-input v-model="form.customerRemark" type="textarea" :rows="3" maxlength="1000" show-word-limit placeholder="平台工作人员可以看到该备注" /></el-form-item> <el-form-item label="客户备注"><el-input v-model="form.customerRemark" type="textarea" :rows="3" maxlength="1000" show-word-limit placeholder="平台工作人员可以看到该备注" /></el-form-item>
</section> </section>
</el-form> </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> <template #footer><div class="editor-actions"><el-button :disabled="saving" @click="closeEditor">返回</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>
<el-drawer v-model="detailOpen" v-loading="detailLoading" class="first-leg-detail" title="订单详情" size="680px"> <el-drawer v-model="detailOpen" class="first-leg-detail" title="订单详情" size="680px">
<template v-if="detailOrder"> <div v-loading="detailLoading" class="order-detail-content">
<div v-if="detailError" class="order-load-state is-compact" role="alert">
<AlertTriangle :size="24" />
<div><strong>订单详情加载失败</strong><span>{{ detailError }}</span></div>
<el-button :loading="detailLoading" @click="retryDetail">重新加载</el-button>
</div>
<template v-else-if="detailOrder">
<div class="order-detail-heading"> <div class="order-detail-heading">
<div><span>{{ getFirstLegTransportModeLabel(detailOrder.transportMode) }}</span><h2>{{ detailOrder.orderNo || '未提交草稿' }}</h2><p>{{ detailOrder.origin }} → {{ detailOrder.destination }}</p></div> <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> <el-tag :type="getFirstLegStatusTone(detailOrder.status)" effect="light">{{ getFirstLegStatusLabel(detailOrder.status) }}</el-tag>
@@ -554,7 +609,8 @@ function resetFilters() {
<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><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> <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>
<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> </div>
<template #footer><div v-if="detailOrder" class="editor-actions"><el-button @click="detailOpen = false">关闭</el-button><el-button v-if="canEditOrder && canClientEditFirstLegOrder(detailOrder.status)" :loading="openingEditor" @click="openEdit(detailOrder)"><Pencil :size="16" />编辑草稿</el-button></div></template>
</el-drawer> </el-drawer>
</section> </section>
</template> </template>
@@ -576,6 +632,12 @@ function resetFilters() {
.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-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-toolbar-actions, .order-row-actions, .editor-actions { display: flex; justify-content: flex-end; gap: 8px; }
.order-table-card { padding: 0; overflow: hidden; } .order-table-card { padding: 0; overflow: hidden; }
.order-load-state { display: grid; min-height: 220px; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 14px; padding: 28px; color: #9a5a09; }
.order-load-state > div { display: flex; min-width: 0; flex-direction: column; gap: 5px; }
.order-load-state strong { color: var(--navy); font-size: 14px; }
.order-load-state span { overflow-wrap: anywhere; color: var(--muted); font-size: 12px; line-height: 1.55; }
.order-load-state.is-compact { min-height: 180px; }
.order-detail-content { min-height: 180px; }
.mobile-order-list { display: none; } .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 { 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-number-link:focus-visible { outline: 2px solid var(--primary); outline-offset: 3px; }
@@ -641,6 +703,21 @@ function resetFilters() {
:deep(.el-timeline-item__content p) { margin: 5px 0; font-size: 12px; } :deep(.el-timeline-item__content p) { margin: 5px 0; font-size: 12px; }
:deep(.el-timeline-item__content > span) { font-size: 11px; } :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: 1100px) { .order-toolbar-actions { align-self: flex-end; } .order-filters { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (max-width: 1280px) {
.desktop-order-table { display: none; }
.mobile-order-list { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; padding: 10px; }
.mobile-order-card { min-width: 0; 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 { min-width: 0; overflow: hidden; padding: 0; border: 0; background: none; color: var(--primary); font-weight: 750; text-overflow: ellipsis; white-space: nowrap; }
.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; }
}
@media (max-width: 720px) { @media (max-width: 720px) {
.order-eligibility { flex-wrap: wrap; } .order-eligibility { flex-wrap: wrap; }
.order-eligibility .el-button { width: 100%; min-height: 44px; } .order-eligibility .el-button { width: 100%; min-height: 44px; }
@@ -651,7 +728,9 @@ function resetFilters() {
.order-toolbar-actions :deep(.el-button) { min-height: 44px; flex: 1; } .order-toolbar-actions :deep(.el-button) { min-height: 44px; flex: 1; }
.editor-actions :deep(.el-button) { min-height: 44px; } .editor-actions :deep(.el-button) { min-height: 44px; }
.desktop-order-table { display: none; } .desktop-order-table { display: none; }
.mobile-order-list { display: grid; gap: 10px; padding: 10px; } .order-load-state { min-height: 240px; grid-template-columns: 1fr; justify-items: start; }
.order-load-state .el-button { width: 100%; min-height: 44px; }
.mobile-order-list { grid-template-columns: 1fr; }
.mobile-order-card { padding: 14px; border: 1px solid var(--border); background: #fff; } .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, .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 { padding: 0; border: 0; background: none; color: var(--primary); font-weight: 750; }
+1 -1
View File
@@ -134,12 +134,12 @@ function clearFilters() {
<div class="client-card member-table-card"> <div class="client-card member-table-card">
<el-table v-if="filteredMembers.length" class="desktop-member-table" :data="filteredMembers" row-key="id"> <el-table v-if="filteredMembers.length" class="desktop-member-table" :data="filteredMembers" row-key="id">
<el-table-column label="操作" width="230" fixed="left"><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-column label="成员" min-width="170"><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="170"><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="200"><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="200"><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="130"><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 label="角色" min-width="130"><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="130" /> <el-table-column prop="lastLoginAt" label="登录记录" min-width="130" />
<el-table-column label="状态" width="90"><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="90"><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="230"><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> </el-table>
<div v-if="filteredMembers.length" class="mobile-member-list"> <div v-if="filteredMembers.length" class="mobile-member-list">
+1 -1
View File
@@ -92,7 +92,7 @@ async function savePermissions() {
<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 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>
<div v-if="selectedRole?.builtin" class="permission-notice"><LockKeyhole :size="18" /><span>企业管理员为内置角色,始终拥有全部客户工作台权限,不允许修改。</span></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-if="selectedRole?.status === 'INACTIVE'" class="permission-notice warning"><span>该角色已停用,请先启用角色后再调整权限。</span></div>
<div v-else class="permission-notice"><ShieldCheck :size="18" /><span>保存时会全量替换该角色的权限,请确认所有需要保留的菜单和操作均已勾选。</span></div> <div v-else class="permission-notice"><ShieldCheck :size="18" /><span>保存时会全量替换该角色的权限,请确认所有需要保留的菜单和操作均已勾选。</span></div>
+4 -2
View File
@@ -39,9 +39,11 @@ function openEdit(role: TenantRole) {
onMounted(async () => { onMounted(async () => {
loading.value = true loading.value = true
try { try {
await store.loadRoles() const requests = [store.loadRoles()]
if (auth.hasPermission('company:member:list')) requests.push(store.loadMembers())
await Promise.all(requests)
} catch (error) { } catch (error) {
ElMessage.error(error instanceof Error ? error.message : '角色列表加载失败') ElMessage.error(error instanceof Error ? error.message : '角色与成员统计加载失败')
} finally { } finally {
loading.value = false loading.value = false
} }
+3 -1
View File
@@ -50,6 +50,8 @@ test('protects built-in roles and exposes role permission assignment', () => {
assert.match(storeSource, /if \(role\.builtin\) throw new Error\('内置角色不能删除'\)/) assert.match(storeSource, /if \(role\.builtin\) throw new Error\('内置角色不能删除'\)/)
assert.match(storeSource, /if \(members\.value\.some\(\(member\) => member\.roleIds\.includes\(id\)\)\)/) assert.match(storeSource, /if \(members\.value\.some\(\(member\) => member\.roleIds\.includes\(id\)\)\)/)
assert.match(rolesSource, /store\.loadRoles\(\)/) assert.match(rolesSource, /store\.loadRoles\(\)/)
assert.match(rolesSource, /auth\.hasPermission\('company:member:list'\).*store\.loadMembers\(\)/)
assert.match(rolesSource, /await Promise\.all\(requests\)/)
assert.match(rolesSource, /await store\.createRole/) assert.match(rolesSource, /await store\.createRole/)
}) })
@@ -67,7 +69,7 @@ test('implements grouped menu and action permission editing', () => {
assert.match(permissionsSource, /TENANT_PERMISSION_GROUPS/) assert.match(permissionsSource, /TENANT_PERMISSION_GROUPS/)
assert.match(permissionsSource, /function toggleGroup\(groupKey: string, checked: boolean\)/) assert.match(permissionsSource, /function toggleGroup\(groupKey: string, checked: boolean\)/)
assert.match(permissionsSource, /function savePermissions\(\)/) assert.match(permissionsSource, /function savePermissions\(\)/)
assert.match(permissionsSource, /内置角色,始终拥有全部客户工作台权限/) assert.match(permissionsSource, /系统内置角色,权限由平台统一维护,不允许修改/)
assert.match(permissionsSource, /全量替换/) assert.match(permissionsSource, /全量替换/)
assert.match(permissionsSource, /await store\.setRolePermissions/) assert.match(permissionsSource, /await store\.setRolePermissions/)
assert.doesNotMatch(permissionsSource, /当前只读展示权限资源/) assert.doesNotMatch(permissionsSource, /当前只读展示权限资源/)