From 4cef53e842a6c629012cbfbf4b1b281e0bb5b2f6 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Thu, 30 Jul 2026 21:23:06 +0800 Subject: [PATCH 001/204] ` --- README.md | 6 +- package.json | 2 +- pnpm-lock.yaml | 4 +- shared/AGENTS.md | 28 ++++ shared/README.md | 7 + shared/package.json | 11 ++ shared/src/index.ts | 155 +++++++++++++++++++ src/api/http.ts | 11 +- src/api/parseJson.test.mjs | 26 ++++ src/main.ts | 2 + src/stores/auth.ts | 20 ++- src/stores/firstLegOrders.test.mjs | 15 ++ src/styles/index.css | 6 + src/views/AuthViews.test.mjs | 14 ++ src/views/CompanyProfileView.vue | 2 +- src/views/DashboardView.test.mjs | 18 +++ src/views/DashboardView.vue | 31 +++- src/views/FirstLegOrdersView.test.mjs | 34 +++- src/views/FirstLegOrdersView.vue | 141 +++++++++++++---- src/views/MembersView.vue | 2 +- src/views/PermissionsView.vue | 2 +- src/views/RolesView.vue | 6 +- src/views/TenantAdministrationViews.test.mjs | 4 +- 23 files changed, 493 insertions(+), 54 deletions(-) create mode 100644 shared/AGENTS.md create mode 100644 shared/README.md create mode 100644 shared/package.json create mode 100644 shared/src/index.ts create mode 100644 src/api/parseJson.test.mjs create mode 100644 src/views/DashboardView.test.mjs diff --git a/README.md b/README.md index e840607..69304cf 100644 --- a/README.md +++ b/README.md @@ -27,14 +27,14 @@ pnpm test 一期路由:登录、注册、工作台、我的信息、企业资料、企业成员、角色权限和头程订单。登录、注册、退出、令牌刷新、个人资料、修改密码、企业资料、企业资质、权限资源、角色列表/新增/授权和成员完整维护已经接入一期后端接口;开发代理默认转发到 `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 显式传入的登录用户名仍优先。生产登录表单默认留空,生产构建产物不得包含本地联调账号或开发提示文案。 -“我的信息”使用真实个人资料和修改密码接口,姓名、电话、邮箱按服务端约束维护;修改密码成功后清理旧会话并返回登录页。成员列表返回姓名、联系方式和 `roleIds`,新增、编辑、角色全量保存、启停和管理员重置密码均使用真实接口,不回退静态成功。角色列表返回 `permissionIds`,页面可回显并全量保存角色权限;角色编辑、启停、删除虽在回归报告中通过,但路径尚未进入当前 Gateway OpenAPI,前端继续保持禁用。 +“我的信息”使用真实个人资料和修改密码接口,姓名、电话、邮箱按服务端约束维护;修改密码成功后清理旧会话并返回登录页。成员列表返回姓名、联系方式和 `roleIds`,新增、编辑、角色全量保存、启停和管理员重置密码均使用真实接口,不回退静态成功。角色页同时加载成员关系,直接打开或刷新后成员数仍按真实 `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`,源码中不写死业务服务地址。 +联调远端 Gateway 时可在启动命令前设置 `API_PROXY_TARGET=http://192.168.110.149:9000`;进程环境变量优先于 `.env` 文件。浏览器仍统一请求同源 `/api`,源码中不写死业务服务地址。2026-07-30 Gateway 恢复后已完成业务总览、企业资料、成员/角色权限、头程订单列表与详情、仓库详情和快递空态的真实浏览器验收;当前后端样本仍存在部分中文字段为 `????`、旧订单引用失效仓库 `2001`、新建订单往来单位候选为空及缺少快递有值样本的问题。客户端已对失效仓库和历史往来单位快照提供可恢复处理,写操作仍待准备干净样本后验收。 diff --git a/package.json b/package.json index f5e7a83..960bb9f 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "preview": "vite preview --port 4174" }, "dependencies": { - "@chongchong/shared": "link:../../packages/shared", + "@chongchong/shared": "link:./shared", "axios": "^1.18.1", "element-plus": "^2.14.3", "@lucide/vue": "^1.27.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 115db38..9cb2478 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,8 +9,8 @@ importers: .: dependencies: '@chongchong/shared': - specifier: link:../../packages/shared - version: link:../../packages/shared + specifier: link:./shared + version: link:./shared '@lucide/vue': specifier: ^1.27.0 version: 1.27.0(vue@3.5.40(typescript@5.9.3)) diff --git a/shared/AGENTS.md b/shared/AGENTS.md new file mode 100644 index 0000000..e25fc86 --- /dev/null +++ b/shared/AGENTS.md @@ -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` 和本文件,并为公共工具及兼容性边界增加测试。 diff --git a/shared/README.md b/shared/README.md new file mode 100644 index 0000000..ec46228 --- /dev/null +++ b/shared/README.md @@ -0,0 +1,7 @@ +# 共享模块 + +用于存放跨应用共享的类型定义、常量、工具和可复用资源。 + +当前已提供产品名称、用户模型、导航模型、API 响应模型,以及经一期产品决策确认的头程订单状态与运输方式常量。 + +当前还提供平台收货仓模型、订单仓库快照模型和供 Admin、Client 静态原型共用的演示仓库数据。订单仓库快照在下单时复制仓库 ID、编码、名称及国内地址,避免仓库主数据后续改名或停用影响历史订单展示;真实仓库列表、状态、下单可用性和快照持久化最终由服务端 API 保证。 diff --git a/shared/package.json b/shared/package.json new file mode 100644 index 0000000..5e802c2 --- /dev/null +++ b/shared/package.json @@ -0,0 +1,11 @@ +{ + "name": "@chongchong/shared", + "version": "0.1.0", + "private": true, + "type": "module", + "exports": { + ".": "./src/index.ts" + }, + "types": "./src/index.ts" +} + diff --git a/shared/src/index.ts b/shared/src/index.ts new file mode 100644 index 0000000..5bf0aef --- /dev/null +++ b/shared/src/index.ts @@ -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 { + 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' }, +] diff --git a/src/api/http.ts b/src/api/http.ts index 1bbd964..7ca074b 100644 --- a/src/api/http.ts +++ b/src/api/http.ts @@ -1,10 +1,12 @@ 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 { ApiError, asFieldErrors, isApiEnvelope, type ApiEnvelope } from '@/api/types' const transport = axios.create({ baseURL: import.meta.env.VITE_API_BASE_URL || '/api', timeout: 15_000, + transformResponse: [(data: unknown) => parseApiJsonResponse(data)], }) interface RetryableRequest extends InternalAxiosRequestConfig { @@ -79,9 +81,12 @@ transport.interceptors.response.use( 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) + const mappedError = mapError(refreshError) + if (mappedError.status === 401 || mappedError.code === 'A0401') { + clearSessionTokens() + window.dispatchEvent(new CustomEvent('cc-client-session-expired')) + } + throw mappedError } }, ) diff --git a/src/api/parseJson.test.mjs b/src/api/parseJson.test.mjs new file mode 100644 index 0000000..863d5d5 --- /dev/null +++ b/src/api/parseJson.test.mjs @@ -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) +}) diff --git a/src/main.ts b/src/main.ts index 3309bd8..8f4f9b6 100644 --- a/src/main.ts +++ b/src/main.ts @@ -2,6 +2,8 @@ import { createApp } from 'vue' import { createPinia } from 'pinia' import App from './App.vue' 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' createApp(App).use(createPinia()).use(router).mount('#app') diff --git a/src/stores/auth.ts b/src/stores/auth.ts index 764d26d..107ac65 100644 --- a/src/stores/auth.ts +++ b/src/stores/auth.ts @@ -2,6 +2,7 @@ import { computed, ref } from 'vue' import { defineStore } from 'pinia' import type { CurrentUser } from '@chongchong/shared' import { loginMerchant, logoutMerchant } from '@/api/auth' +import { ApiError } from '@/api/http' 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' @@ -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', () => { const token = ref(getAccessToken()) const payload = ref(null) @@ -139,8 +144,12 @@ export const useAuthStore = defineStore('client-auth', () => { try { await loadSessionContext() } catch (error) { - clearSession() - throw error + if (isExpiredSessionError(error)) { + clearSession() + throw error + } + sessionRestored.value = true + return } sessionRestored.value = true } @@ -159,8 +168,11 @@ export const useAuthStore = defineStore('client-auth', () => { await loadSessionContext() sessionRestored.value = true } catch (error) { - clearSession() - throw error + if (isExpiredSessionError(error)) { + clearSession() + throw error + } + sessionRestored.value = true } } diff --git a/src/stores/firstLegOrders.test.mjs b/src/stores/firstLegOrders.test.mjs index f291861..307d0cd 100644 --- a/src/stores/firstLegOrders.test.mjs +++ b/src/stores/firstLegOrders.test.mjs @@ -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, /`\/merchant\/v1\/orders\/first-leg\/\$\{id\}`/) 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', () => { @@ -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, /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/) +}) diff --git a/src/styles/index.css b/src/styles/index.css index 562d997..e8cab7e 100644 --- a/src/styles/index.css +++ b/src/styles/index.css @@ -109,6 +109,10 @@ a { color: inherit; } .registration-result-actions { display: flex; justify-content: center; gap: 10px; margin-top: 28px; } .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 span { color: #84c3ba; font-size: 11px; font-weight: 700; } .quick-order-band h2 { margin: 6px 0; font-size: 22px; } @@ -162,6 +166,8 @@ a { color: inherit; } .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; } + .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; } .quick-order-band { align-items: flex-start; flex-direction: column; } .form-grid { grid-template-columns: 1fr; } diff --git a/src/views/AuthViews.test.mjs b/src/views/AuthViews.test.mjs index fbab66b..8516307 100644 --- a/src/views/AuthViews.test.mjs +++ b/src/views/AuthViews.test.mjs @@ -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 sessionSource = readFileSync(new URL('../api/session.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', () => { assert.match(loginSource, /label="用户名"/) @@ -52,3 +53,16 @@ test('uses real auth endpoints and single-flight refresh handling', () => { assert.match(authStoreSource, /decodeMerchantToken/) 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/) +}) diff --git a/src/views/CompanyProfileView.vue b/src/views/CompanyProfileView.vue index 6ebe114..4646bfc 100644 --- a/src/views/CompanyProfileView.vue +++ b/src/views/CompanyProfileView.vue @@ -225,12 +225,12 @@ function qualificationStatus(status: string) {

企业资质

文件更新后需等待平台重新审核,历史业务不受影响。

更新资质
+ -
diff --git a/src/views/DashboardView.test.mjs b/src/views/DashboardView.test.mjs new file mode 100644 index 0000000..4b220f0 --- /dev/null +++ b/src/views/DashboardView.test.mjs @@ -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"/) +}) diff --git a/src/views/DashboardView.vue b/src/views/DashboardView.vue index c2e971b..0b7eaba 100644 --- a/src/views/DashboardView.vue +++ b/src/views/DashboardView.vue @@ -1,6 +1,6 @@