From d0f94d95d7145f2e2c0b92543fa97735484755cb Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Fri, 31 Jul 2026 13:43:16 +0800 Subject: [PATCH] feat: complete admin API integration --- .env.example | 2 +- README.md | 8 +- src/api/http.test.ts | 44 ++- src/api/http.ts | 17 +- src/api/platformFirstLegOrders.test.ts | 72 ++++ src/api/platformFirstLegOrders.ts | 171 +++++++++ src/api/platformIam.test.ts | 32 +- src/api/platformIam.ts | 40 +- src/api/platformWarehouses.test.ts | 92 +++++ src/api/platformWarehouses.ts | 138 +++++++ src/components.d.ts | 1 + .../PlatformFirstLegOrdersPanel.vue | 354 +++++++++++++++++ src/layouts/AppLayout.test.ts | 22 +- src/layouts/AppLayout.vue | 9 +- src/main.ts | 26 +- src/stores/auth.test.ts | 154 ++++++++ src/stores/auth.ts | 105 +++-- src/views/AccessControlledActions.test.ts | 29 ++ src/views/FirstLegOrdersView.vue | 4 +- src/views/RolesView.test.ts | 11 +- src/views/RolesView.vue | 101 +++-- src/views/WarehousesView.test.ts | 152 ++++++++ src/views/WarehousesView.vue | 358 ++++++++++++++++-- vite.config.ts | 5 + 24 files changed, 1834 insertions(+), 113 deletions(-) create mode 100644 src/api/platformFirstLegOrders.test.ts create mode 100644 src/api/platformFirstLegOrders.ts create mode 100644 src/api/platformWarehouses.test.ts create mode 100644 src/api/platformWarehouses.ts create mode 100644 src/components/PlatformFirstLegOrdersPanel.vue create mode 100644 src/stores/auth.test.ts create mode 100644 src/views/WarehousesView.test.ts diff --git a/.env.example b/.env.example index a39d43e3..b43cf89c 100644 --- a/.env.example +++ b/.env.example @@ -1,2 +1,2 @@ VITE_API_BASE_URL=/api -API_PROXY_TARGET=http://localhost:9000 +API_PROXY_TARGET=http://8.163.30.128:9000 diff --git a/README.md b/README.md index be666787..20b415e1 100644 --- a/README.md +++ b/README.md @@ -35,14 +35,14 @@ pnpm test ```dotenv VITE_API_BASE_URL=/api -API_PROXY_TARGET=http://192.168.110.149:9000 +API_PROXY_TARGET=http://8.163.30.128:9000 ``` -认证请求使用 `/api/auth/**`,平台 IAM 请求使用 `/api/platform/v1/iam/**`。登录后从 JWT 读取平台身份和授权快照,再与 IAM 返回的启用权限资源取交集。企业审核调用 `/reviews`,打开审核详情后按租户调用 `/tenants/{tenantId}/qualifications`;营业执照通过 `/api/platform/v1/files/qualifications/{tenantId}/{fileName}` 携带平台 Token 临时读取,关闭预览后释放本地对象地址。平台角色调用 `/roles`,权限资源调用 `/permissions`;接口未提供的编辑、删除、启停和已有授权回显不会模拟成功。一期真实权限资源只创建 `MENU` 与 `BUTTON`,不创建 `PAGE` 或 `API` 类型。其余业务页面需在对应服务契约确认后逐项替换,生产逻辑不得静默回退到模拟数据。 +认证请求使用 `/api/auth/**`,平台 IAM 请求使用 `/api/platform/v1/iam/**`。登录后从 JWT 读取平台身份和授权快照,再与 IAM 返回的启用权限资源取交集。企业审核调用 `/reviews`,打开审核详情后按租户调用 `/tenants/{tenantId}/qualifications`;营业执照通过 `/api/platform/v1/files/qualifications/{tenantId}/{fileName}` 携带平台 Token 临时读取,关闭预览后释放本地对象地址。平台角色查询、新增、编辑、启停、删除和权限全量保存均已按 `RbacController` 接入真实 `/roles` 契约,角色返回的 `permissionIds` 用于授权回显;系统内置角色及仍被成员使用的角色继续由服务端保护。权限资源查询和新增调用 `/permissions`,尚未提供的权限编辑、删除和启停不会模拟成功。一期真实权限资源只创建 `MENU` 与 `BUTTON`,不创建 `PAGE` 或 `API` 类型。其余业务页面需在对应服务契约确认后逐项替换,生产逻辑不得静默回退到模拟数据。 -仓库管理当前维护义乌、深圳、青岛、上海、宁波、厦门等平台国内收货仓,支持筛选、新增、编辑和启停。停用仓库保留历史数据,但不再提供给客户新建头程订单选择;订单详情使用下单时保存的仓库编码、名称和地址快照,仓库主数据后续修改不改变历史展示。本阶段不包含 FBA/海外目的仓、库存、库位或出入库作业,快照仍为 Pinia 模拟数据,服务端持久化待接入。 +仓库管理已接入真实平台 Warehouse API,支持按租户筛选、详情、新增、编辑和启停,并在更新时保留后端返回的地址、能力和截单时间等完整字段。停用仓库保留历史数据,但不再提供给客户新建头程订单选择;订单详情使用下单时保存的仓库编码、名称和地址快照,仓库主数据后续修改不改变历史展示。本阶段不包含 FBA/海外目的仓、库存、库位或出入库作业。 -快递公司和往来单位一期静态维护已完成:支持筛选、详情、新增、编辑、启停、唯一校验、标准模板下载和导入文件基础检查;往来单位支持六类多选角色、联系人和完整地址。两类主数据只导入不导出,导入只新增,任一行错误整批失败。工作表内容、模板版本、重复数据和整批事务必须由后端解析校验;接口提供前,前端不模拟校验或导入成功。头程订单可维护一组快递公司和快递单号;订单不设“已驳回”,后台修改待审核订单后仍保持待审核并写入审计。上述业务数据仍使用 Pinia,等待后端新架构明确契约后接入。 +快递公司和往来单位一期静态维护已完成:支持筛选、详情、新增、编辑、启停、唯一校验、标准模板下载和导入文件基础检查;往来单位支持六类多选角色、联系人和完整地址。两类主数据只导入不导出,导入只新增,任一行错误整批失败。工作表内容、模板版本、重复数据和整批事务必须由后端解析校验;接口提供前,前端不模拟校验或导入成功。头程订单列表、详情、状态历史、内部备注和快递信息已经接入真实平台 Order API;订单不设“已驳回”,后台修改待审核订单后仍保持待审核并写入审计。快递公司和平台往来单位仍使用 Pinia,等待后端正式契约后接入。 真实接口错误会保留后端返回的业务错误码、`requestId` 和 `traceId`。登录、企业审核、平台角色及权限资源的主要失败态支持直接复制追踪标识,便于与后端日志关联。 diff --git a/src/api/http.test.ts b/src/api/http.test.ts index cbee3a30..6da21f60 100644 --- a/src/api/http.test.ts +++ b/src/api/http.test.ts @@ -1,6 +1,21 @@ import axios, { AxiosHeaders } from 'axios' -import { describe, expect, it } from 'vitest' -import { ApiResponseError, getApiErrorDetails, unwrapApiResponse } from './http' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + ApiResponseError, + getApiErrorDetails, + http, + registerAuthRefreshHandler, + registerAuthSessionExpiredHandler, + unwrapApiResponse, +} from './http' + +const originalAdapter = http.defaults.adapter + +afterEach(() => { + http.defaults.adapter = originalAdapter + registerAuthRefreshHandler(async () => { throw new Error('refresh handler is not configured') }) + registerAuthSessionExpiredHandler(() => undefined) +}) describe('API error diagnostics', () => { it('keeps business request and trace identifiers when unwrapping fails', () => { @@ -40,4 +55,29 @@ describe('API error diagnostics', () => { traceId: 'trace-2', }) }) + + it('notifies session expiry once when concurrent 401 requests share a failed refresh', async () => { + const refresh = vi.fn(async () => { throw new Error('refresh token expired') }) + const expired = vi.fn() + registerAuthRefreshHandler(refresh) + registerAuthSessionExpiredHandler(expired) + http.defaults.adapter = async (config) => { + throw new axios.AxiosError('Unauthorized', 'ERR_BAD_REQUEST', config, undefined, { + status: 401, + statusText: 'Unauthorized', + headers: {}, + config, + data: { code: 'A0401', message: '登录状态已过期' }, + }) + } + + const results = await Promise.allSettled([ + http.get('/platform/v1/orders'), + http.get('/platform/v1/warehouses'), + ]) + + expect(results.every((result) => result.status === 'rejected')).toBe(true) + expect(refresh).toHaveBeenCalledTimes(1) + expect(expired).toHaveBeenCalledTimes(1) + }) }) diff --git a/src/api/http.ts b/src/api/http.ts index 81d14c6a..33019cfb 100644 --- a/src/api/http.ts +++ b/src/api/http.ts @@ -43,14 +43,20 @@ export const http = axios.create({ type RetryableRequestConfig = InternalAxiosRequestConfig & { _authRetry?: boolean } type AuthRefreshHandler = () => Promise +type AuthSessionExpiredHandler = () => void | Promise let authRefreshHandler: AuthRefreshHandler | null = null +let authSessionExpiredHandler: AuthSessionExpiredHandler | null = null let activeRefresh: Promise | null = null export function registerAuthRefreshHandler(handler: AuthRefreshHandler) { authRefreshHandler = handler } +export function registerAuthSessionExpiredHandler(handler: AuthSessionExpiredHandler) { + authSessionExpiredHandler = handler +} + http.interceptors.request.use((config) => { const token = localStorage.getItem('cc-admin-token') if (token) { @@ -71,7 +77,16 @@ http.interceptors.response.use( config._authRetry = true try { if (!activeRefresh) { - activeRefresh = authRefreshHandler().finally(() => { activeRefresh = null }) + activeRefresh = authRefreshHandler() + .catch(async (refreshError) => { + try { + await authSessionExpiredHandler?.() + } catch { + // 会话已清理,跳转失败不应覆盖原始认证错误。 + } + throw refreshError + }) + .finally(() => { activeRefresh = null }) } const nextToken = await activeRefresh config.headers.Authorization = `Bearer ${nextToken}` diff --git a/src/api/platformFirstLegOrders.test.ts b/src/api/platformFirstLegOrders.test.ts new file mode 100644 index 00000000..43b0d90f --- /dev/null +++ b/src/api/platformFirstLegOrders.test.ts @@ -0,0 +1,72 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { http } from '@/api/http' +import { + acceptPlatformFirstLegOrder, + addPlatformFirstLegOrderNote, + approvePlatformFirstLegOrder, + cancelPlatformFirstLegOrder, + completePlatformFirstLegOrder, + getPlatformFirstLegOrder, + listPlatformFirstLegOrders, + startPlatformFirstLegOrder, + updatePlatformFirstLegOrderExpress, +} from './platformFirstLegOrders' + +vi.mock('@/api/http', () => ({ + http: { get: vi.fn(), post: vi.fn(), put: vi.fn() }, + unwrapApiResponse: (response: { data: T }) => response.data, +})) + +describe('platform first-leg order API', () => { + beforeEach(() => { + vi.mocked(http.get).mockReset() + vi.mocked(http.post).mockReset() + vi.mocked(http.put).mockReset() + }) + + it('queries a tenant-scoped order page with server pagination and filters', async () => { + const page = { records: [], page: 2, size: 20, total: 0, pages: 0 } + vi.mocked(http.get).mockResolvedValue({ code: '0', message: 'success', data: page }) + + await expect(listPlatformFirstLegOrders('10001', { + keyword: 'FL2026', status: 'SUBMITTED', createdFrom: '2026-07-01T00:00:00', createdTo: '2026-07-31T23:59:59', page: 2, size: 20, + })).resolves.toEqual(page) + expect(http.get).toHaveBeenCalledWith('/platform/v1/orders/first-leg', { + params: { + tenantId: '10001', keyword: 'FL2026', status: 'SUBMITTED', createdFrom: '2026-07-01T00:00:00', createdTo: '2026-07-31T23:59:59', page: 2, size: 20, + }, + }) + }) + + it('uses the platform lifecycle routes and target tenant query parameter', async () => { + vi.mocked(http.get).mockResolvedValue({ code: '0', message: 'success', data: { order: {} } }) + vi.mocked(http.post).mockResolvedValue({ code: '0', message: 'success', data: null }) + + await getPlatformFirstLegOrder('10001', '90001') + await approvePlatformFirstLegOrder('10001', '90001') + await acceptPlatformFirstLegOrder('10001', '90001') + await startPlatformFirstLegOrder('10001', '90001') + await completePlatformFirstLegOrder('10001', '90001') + await cancelPlatformFirstLegOrder('10001', '90001', '客户取消') + await addPlatformFirstLegOrderNote('10001', '90001', '已联系客户确认') + + expect(http.get).toHaveBeenCalledWith('/platform/v1/orders/first-leg/90001', { params: { tenantId: '10001' } }) + expect(http.post).toHaveBeenNthCalledWith(1, '/platform/v1/orders/first-leg/90001/approve', undefined, { params: { tenantId: '10001' } }) + expect(http.post).toHaveBeenNthCalledWith(2, '/platform/v1/orders/first-leg/90001/accept', undefined, { params: { tenantId: '10001' } }) + expect(http.post).toHaveBeenNthCalledWith(3, '/platform/v1/orders/first-leg/90001/start', undefined, { params: { tenantId: '10001' } }) + expect(http.post).toHaveBeenNthCalledWith(4, '/platform/v1/orders/first-leg/90001/complete', undefined, { params: { tenantId: '10001' } }) + expect(http.post).toHaveBeenNthCalledWith(5, '/platform/v1/orders/first-leg/90001/cancel', { reason: '客户取消' }, { params: { tenantId: '10001' } }) + expect(http.post).toHaveBeenNthCalledWith(6, '/platform/v1/orders/first-leg/90001/internal-notes', { content: '已联系客户确认' }, { params: { tenantId: '10001' } }) + }) + + it('writes the backend express contract as names, not a guessed master-data ID', async () => { + vi.mocked(http.put).mockResolvedValue({ code: '0', message: 'success', data: null }) + + await updatePlatformFirstLegOrderExpress('10001', '90001', { expressCompany: '顺丰速运', expressTrackingNo: 'SF123456' }) + expect(http.put).toHaveBeenCalledWith( + '/platform/v1/orders/first-leg/90001/express', + { expressCompany: '顺丰速运', expressTrackingNo: 'SF123456' }, + { params: { tenantId: '10001' } }, + ) + }) +}) diff --git a/src/api/platformFirstLegOrders.ts b/src/api/platformFirstLegOrders.ts new file mode 100644 index 00000000..71364b25 --- /dev/null +++ b/src/api/platformFirstLegOrders.ts @@ -0,0 +1,171 @@ +import { http, unwrapApiResponse, type ApiEnvelope } from '@/api/http' + +export type PlatformFirstLegOrderStatus = + | 'DRAFT' + | 'SUBMITTED' + | 'APPROVED' + | 'ACCEPTED' + | 'REJECTED' + | 'CANCELLED' + | 'PROCESSING' + | 'COMPLETED' + +export interface PlatformFirstLegOrderResource { + id: number | string + tenantId: number | string + orderNo: string | null + customerId: number | string | null + customerName: string + status: PlatformFirstLegOrderStatus + transportMode: 'SEA' | 'AIR' | 'RAIL' | 'TRUCK' + tradeTerm: string | null + destinationCountryCode: string + destinationWarehouseId: number | string | null + destinationWarehouseName: string + expectedShipDate: string | null + customerRemark: string | null + pickupMethod: string + receivingWarehouseId: number | string + receivingWarehouseName: string + receivingWarehouseAddress: string | null + receivingTime: string | null + channelCode: string + channelName: string + taxMode: string + containerMode: string | null + customsInspection: boolean | null + destinationType: string + amazonWarehouseCode: string | null + shipmentId: string | null + referenceId: string | null + expressCompany: string | null + expressTrackingNo: string | null + createdAt: string + updatedAt: string +} + +export interface PlatformFirstLegCargoResource { + id: number | string + itemNo: number + cargoName: string + cargoNameEn: string | null + packageType: string + packageCount: number + grossWeightKg: number + volumeCbm: number + totalQuantity: number | null + cargoRemark: string | null +} + +export interface PlatformFirstLegPartyResource { + id: number | string + partyType: 'SENDER' | 'RECEIVER' | string + companyName: string + contactName: string + phone: string + email: string | null + countryCode: string + province: string | null + city: string + district: string | null + addressLine: string + postalCode: string | null +} + +export interface PlatformOrderStatusHistoryResource { + id: number | string + fromStatus: PlatformFirstLegOrderStatus | null + toStatus: PlatformFirstLegOrderStatus + action: string + reason: string | null + operatorName: string | null + occurredAt: string +} + +export interface PlatformOrderInternalNoteResource { + id: number | string + content: string + createdAt: string + createdBy: number | string | null +} + +export interface PlatformFirstLegOrderDetailResource { + order: PlatformFirstLegOrderResource + cargoes: PlatformFirstLegCargoResource[] + parties: PlatformFirstLegPartyResource[] + internalNotes: PlatformOrderInternalNoteResource[] + statusHistory: PlatformOrderStatusHistoryResource[] +} + +export interface PlatformFirstLegOrderPageResource { + records: PlatformFirstLegOrderResource[] + page: number + size: number + total: number + pages: number +} + +export interface PlatformFirstLegOrderQuery { + keyword?: string + status?: PlatformFirstLegOrderStatus + customerId?: string + destinationWarehouseId?: string + createdFrom?: string + createdTo?: string + page: number + size: number +} + +async function getData(path: string, params?: Record) { + const response = await http.get, ApiEnvelope>(path, { params }) + return unwrapApiResponse(response) +} + +async function postData(path: string, body?: unknown, params?: Record) { + const response = await http.post, ApiEnvelope>(path, body, { params }) + return unwrapApiResponse(response) +} + +export function listPlatformFirstLegOrders(tenantId: string, query: PlatformFirstLegOrderQuery) { + return getData('/platform/v1/orders/first-leg', { tenantId, ...query }) +} + +export function getPlatformFirstLegOrder(tenantId: string, orderId: string) { + return getData(`/platform/v1/orders/first-leg/${orderId}`, { tenantId }) +} + +export function approvePlatformFirstLegOrder(tenantId: string, orderId: string) { + return postData(`/platform/v1/orders/first-leg/${orderId}/approve`, undefined, { tenantId }) +} + +export function acceptPlatformFirstLegOrder(tenantId: string, orderId: string) { + return postData(`/platform/v1/orders/first-leg/${orderId}/accept`, undefined, { tenantId }) +} + +export function startPlatformFirstLegOrder(tenantId: string, orderId: string) { + return postData(`/platform/v1/orders/first-leg/${orderId}/start`, undefined, { tenantId }) +} + +export function completePlatformFirstLegOrder(tenantId: string, orderId: string) { + return postData(`/platform/v1/orders/first-leg/${orderId}/complete`, undefined, { tenantId }) +} + +export function cancelPlatformFirstLegOrder(tenantId: string, orderId: string, reason: string) { + return postData(`/platform/v1/orders/first-leg/${orderId}/cancel`, { reason }, { tenantId }) +} + +export function addPlatformFirstLegOrderNote(tenantId: string, orderId: string, content: string) { + return postData(`/platform/v1/orders/first-leg/${orderId}/internal-notes`, { content }, { tenantId }) +} + +export function updatePlatformFirstLegOrderExpress( + tenantId: string, + orderId: string, + payload: { expressCompany: string; expressTrackingNo: string }, +) { + return http.put, ApiEnvelope>( + `/platform/v1/orders/first-leg/${orderId}/express`, + payload, + { params: { tenantId } }, + ).then(unwrapApiResponse) +} diff --git a/src/api/platformIam.test.ts b/src/api/platformIam.test.ts index f76601d8..cf3e4207 100644 --- a/src/api/platformIam.test.ts +++ b/src/api/platformIam.test.ts @@ -1,19 +1,24 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { http } from '@/api/http' import { + changePlatformRoleStatus, createPlatformPermission, createPlatformRole, + deletePlatformRole, downloadTenantQualification, listTenantQualifications, + listCurrentPlatformPermissionCodes, listPlatformPermissions, listPlatformRoles, qualificationFileName, + replacePlatformRolePermissions, + updatePlatformRole, type PlatformPermissionResource, type PlatformRoleResource, } from './platformIam' vi.mock('@/api/http', () => ({ - http: { get: vi.fn(), post: vi.fn() }, + http: { get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn() }, unwrapApiResponse: (response: { data: T }) => response.data, })) @@ -26,6 +31,7 @@ const role: PlatformRoleResource = { roleType: 'SYSTEM', status: 'ACTIVE', description: '企业资料与入驻审核', + permissionIds: ['5100010'], } const permission: PlatformPermissionResource = { @@ -46,6 +52,8 @@ describe('platform IAM roles', () => { beforeEach(() => { vi.mocked(http.get).mockReset() vi.mocked(http.post).mockReset() + vi.mocked(http.put).mockReset() + vi.mocked(http.delete).mockReset() }) it('loads roles from the platform IAM prefix', async () => { @@ -67,6 +75,21 @@ describe('platform IAM roles', () => { expect(http.post).toHaveBeenCalledWith('/platform/v1/iam/roles', payload) }) + it('updates status, permissions, and deletes roles with the implemented IAM routes', async () => { + vi.mocked(http.put).mockResolvedValue({ code: '0', message: 'success', data: role }) + vi.mocked(http.delete).mockResolvedValue({ code: '0', message: 'success', data: null }) + + await expect(updatePlatformRole(role.id, { roleName: '审核主管', description: '负责企业审核' })).resolves.toEqual(role) + await expect(changePlatformRoleStatus(role.id, 'DISABLED')).resolves.toEqual(role) + await expect(replacePlatformRolePermissions(role.id, ['5100010'])).resolves.toEqual(role) + await expect(deletePlatformRole(role.id)).resolves.toBeNull() + + expect(http.put).toHaveBeenNthCalledWith(1, `/platform/v1/iam/roles/${role.id}`, { roleName: '审核主管', description: '负责企业审核' }) + expect(http.put).toHaveBeenNthCalledWith(2, `/platform/v1/iam/roles/${role.id}/status`, { status: 'DISABLED' }) + expect(http.put).toHaveBeenNthCalledWith(3, '/platform/v1/iam/roles/permissions', { roleId: role.id, permissionIds: ['5100010'] }) + expect(http.delete).toHaveBeenCalledWith(`/platform/v1/iam/roles/${role.id}`) + }) + it('loads and creates permission resources under the platform IAM prefix', async () => { vi.mocked(http.get).mockResolvedValue({ code: '0', message: 'success', data: [permission] }) vi.mocked(http.post).mockResolvedValue({ code: '0', message: 'success', data: permission }) @@ -86,6 +109,13 @@ describe('platform IAM roles', () => { expect(http.post).toHaveBeenCalledWith('/platform/v1/iam/permissions', payload) }) + it('loads the current users permission snapshot without calling the role-management endpoint', async () => { + vi.mocked(http.get).mockResolvedValue({ code: '0', message: 'success', data: ['platform:warehouse:list'] }) + + await expect(listCurrentPlatformPermissionCodes()).resolves.toEqual(['platform:warehouse:list']) + expect(http.get).toHaveBeenCalledWith('/platform/v1/iam/authorization/permissions') + }) + it('loads qualifications for the selected tenant', async () => { const qualification = { id: 12, diff --git a/src/api/platformIam.ts b/src/api/platformIam.ts index 8c1f2c87..556a167b 100644 --- a/src/api/platformIam.ts +++ b/src/api/platformIam.ts @@ -44,14 +44,15 @@ export interface PlatformTenantQualification { } export interface PlatformRoleResource { - id: number - tenantId: number + id: number | string + tenantId: number | string appCode: 'PLATFORM' roleCode: string roleName: string roleType: 'SYSTEM' | 'CUSTOM' | string status: 'ACTIVE' | 'INACTIVE' | string description: string | null + permissionIds?: Array } export interface CreatePlatformRoleRequest { @@ -60,6 +61,11 @@ export interface CreatePlatformRoleRequest { description: string } +export interface UpdatePlatformRoleRequest { + roleName: string + description: string +} + export interface CreatePlatformPermissionRequest { type: 'MENU' | 'BUTTON' code: string @@ -80,10 +86,24 @@ async function postData(path: string, body: unknown): Promise { return unwrapApiResponse(response) } +async function putData(path: string, body: unknown): Promise { + const response = await http.put, ApiEnvelope>(path, body) + return unwrapApiResponse(response) +} + +async function deleteData(path: string): Promise { + const response = await http.delete, ApiEnvelope>(path) + return unwrapApiResponse(response) +} + export function listPlatformPermissions() { return getData('/platform/v1/iam/permissions') } +export function listCurrentPlatformPermissionCodes() { + return getData('/platform/v1/iam/authorization/permissions') +} + export function listEnterpriseReviews() { return getData('/platform/v1/iam/reviews') } @@ -123,6 +143,22 @@ export function createPlatformRole(payload: CreatePlatformRoleRequest) { return postData('/platform/v1/iam/roles', payload) } +export function updatePlatformRole(roleId: number | string, payload: UpdatePlatformRoleRequest) { + return putData(`/platform/v1/iam/roles/${roleId}`, payload) +} + +export function changePlatformRoleStatus(roleId: number | string, status: 'ACTIVE' | 'DISABLED') { + return putData(`/platform/v1/iam/roles/${roleId}/status`, { status }) +} + +export function deletePlatformRole(roleId: number | string) { + return deleteData(`/platform/v1/iam/roles/${roleId}`) +} + +export function replacePlatformRolePermissions(roleId: number | string, permissionIds: Array) { + return putData('/platform/v1/iam/roles/permissions', { roleId, permissionIds }) +} + export function createPlatformPermission(payload: CreatePlatformPermissionRequest) { return postData('/platform/v1/iam/permissions', payload) } diff --git a/src/api/platformWarehouses.test.ts b/src/api/platformWarehouses.test.ts new file mode 100644 index 00000000..f6fe8221 --- /dev/null +++ b/src/api/platformWarehouses.test.ts @@ -0,0 +1,92 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { http } from '@/api/http' +import { + changePlatformWarehouseStatus, + createPlatformWarehouse, + getPlatformWarehouse, + listPlatformWarehouses, + updatePlatformWarehouse, + type SavePlatformWarehouseRequest, +} from './platformWarehouses' + +vi.mock('@/api/http', () => ({ + http: { get: vi.fn(), post: vi.fn(), put: vi.fn(), patch: vi.fn() }, + unwrapApiResponse: (response: { data: T }) => response.data, +})) + +const payload: SavePlatformWarehouseRequest = { + tenantId: 10001, + warehouseCode: 'CN-YW-01', + warehouseName: '义乌收货仓', + warehouseType: 'DOMESTIC', + countryCode: 'CN', + timezone: 'Asia/Shanghai', + contactName: '仓库客服', + contactPhone: '0579-12345678', + contactEmail: null, + status: 'ACTIVE', + remark: null, + address: { + addressType: 'OPERATING', + countryCode: 'CN', + stateProvince: '浙江省', + city: '义乌市', + district: null, + postalCode: null, + addressLine1: '北苑街道 1 号库', + addressLine2: null, + latitude: null, + longitude: null, + }, + capabilities: [{ + capabilityCode: 'RECEIVING', + capabilityName: '收货服务', + enabled: true, + cutoffTime: null, + description: '周一至周六 08:30-18:00', + }], +} + +describe('platform warehouse API', () => { + beforeEach(() => { + vi.mocked(http.get).mockReset() + vi.mocked(http.post).mockReset() + vi.mocked(http.put).mockReset() + vi.mocked(http.patch).mockReset() + }) + + it('lists and loads warehouse details for the target tenant', async () => { + const page = { records: [], page: 1, size: 200, total: 0, pages: 0 } + const detail = { warehouse: {}, addresses: [], capabilities: [] } + vi.mocked(http.get) + .mockResolvedValueOnce({ code: '0', message: 'success', data: page }) + .mockResolvedValueOnce({ code: '0', message: 'success', data: detail }) + + await expect(listPlatformWarehouses(10001)).resolves.toEqual(page) + await expect(getPlatformWarehouse(10001, '19001')).resolves.toEqual(detail) + expect(http.get).toHaveBeenNthCalledWith(1, '/platform/v1/warehouse-admin', { + params: { tenantId: 10001, page: 1, size: 200 }, + }) + expect(http.get).toHaveBeenNthCalledWith(2, '/platform/v1/warehouse-admin/19001', { + params: { tenantId: 10001 }, + }) + }) + + it('uses the implemented create, update, and status routes', async () => { + vi.mocked(http.post).mockResolvedValue({ code: '0', message: 'success', data: '19001' }) + vi.mocked(http.put).mockResolvedValue({ code: '0', message: 'success', data: null }) + vi.mocked(http.patch).mockResolvedValue({ code: '0', message: 'success', data: null }) + + await expect(createPlatformWarehouse(payload)).resolves.toBe('19001') + await expect(updatePlatformWarehouse(10001, '19001', payload)).resolves.toBeNull() + await expect(changePlatformWarehouseStatus(10001, '19001', 'INACTIVE')).resolves.toBeNull() + + expect(http.post).toHaveBeenCalledWith('/platform/v1/warehouse-admin', payload) + expect(http.put).toHaveBeenCalledWith('/platform/v1/warehouse-admin/19001', payload, { + params: { tenantId: 10001 }, + }) + expect(http.patch).toHaveBeenCalledWith('/platform/v1/warehouse-admin/19001/status', undefined, { + params: { tenantId: 10001, status: 'INACTIVE' }, + }) + }) +}) diff --git a/src/api/platformWarehouses.ts b/src/api/platformWarehouses.ts new file mode 100644 index 00000000..cea5ac36 --- /dev/null +++ b/src/api/platformWarehouses.ts @@ -0,0 +1,138 @@ +import { http, unwrapApiResponse, type ApiEnvelope } from '@/api/http' + +export type PlatformWarehouseStatus = 'ACTIVE' | 'INACTIVE' + +export interface PlatformWarehouseResource { + id: number | string + tenantId: number | string + warehouseCode: string + warehouseName: string + warehouseType: string + countryCode: string + timezone: string + contactName: string | null + contactPhone: string | null + contactEmail: string | null + status: PlatformWarehouseStatus + remark: string | null + createdAt?: string | null + updatedAt?: string | null +} + +export interface PlatformWarehouseAddressResource { + id: number | string + tenantId: number | string + warehouseId: number | string + addressType: string + countryCode: string + stateProvince: string | null + city: string + district: string | null + postalCode: string | null + addressLine1: string + addressLine2: string | null + latitude: number | string | null + longitude: number | string | null +} + +export interface PlatformWarehouseCapabilityResource { + id: number | string + tenantId: number | string + warehouseId: number | string + capabilityCode: string + capabilityName: string + enabled: boolean | null + cutoffTime: string | null + description: string | null +} + +export interface PlatformWarehouseDetailResource { + warehouse: PlatformWarehouseResource + addresses: PlatformWarehouseAddressResource[] + capabilities: PlatformWarehouseCapabilityResource[] +} + +export interface PlatformWarehousePage { + records: PlatformWarehouseResource[] + page: number + size: number + total: number + pages: number +} + +export interface PlatformWarehouseAddressRequest { + addressType: string + countryCode: string + stateProvince: string | null + city: string + district: string | null + postalCode: string | null + addressLine1: string + addressLine2: string | null + latitude: number | string | null + longitude: number | string | null +} + +export interface PlatformWarehouseCapabilityRequest { + capabilityCode: string + capabilityName: string + enabled: boolean + cutoffTime: string | null + description: string | null +} + +export interface SavePlatformWarehouseRequest { + tenantId: number + warehouseCode: string + warehouseName: string + warehouseType: string + countryCode: string + timezone: string + contactName: string | null + contactPhone: string | null + contactEmail: string | null + status: PlatformWarehouseStatus + remark: string | null + address: PlatformWarehouseAddressRequest + capabilities: PlatformWarehouseCapabilityRequest[] +} + +const basePath = '/platform/v1/warehouse-admin' + +export async function listPlatformWarehouses(tenantId: number, page = 1, size = 200) { + const response = await http.get, ApiEnvelope>(basePath, { + params: { tenantId, page, size }, + }) + return unwrapApiResponse(response) +} + +export async function getPlatformWarehouse(tenantId: number, warehouseId: number | string) { + const response = await http.get, ApiEnvelope>( + `${basePath}/${warehouseId}`, + { params: { tenantId } }, + ) + return unwrapApiResponse(response) +} + +export async function createPlatformWarehouse(payload: SavePlatformWarehouseRequest) { + const response = await http.post, ApiEnvelope>(basePath, payload) + return unwrapApiResponse(response) +} + +export async function updatePlatformWarehouse(tenantId: number, warehouseId: number | string, payload: SavePlatformWarehouseRequest) { + const response = await http.put, ApiEnvelope>(`${basePath}/${warehouseId}`, payload, { + params: { tenantId }, + }) + return unwrapApiResponse(response) +} + +export async function changePlatformWarehouseStatus( + tenantId: number, + warehouseId: number | string, + status: PlatformWarehouseStatus, +) { + const response = await http.patch, ApiEnvelope>(`${basePath}/${warehouseId}/status`, undefined, { + params: { tenantId, status }, + }) + return unwrapApiResponse(response) +} diff --git a/src/components.d.ts b/src/components.d.ts index c9d95df8..d0a482ab 100644 --- a/src/components.d.ts +++ b/src/components.d.ts @@ -46,6 +46,7 @@ declare module 'vue' { ElTimelineItem: typeof import('element-plus/es')['ElTimelineItem'] ElTree: typeof import('element-plus/es')['ElTree'] MetricStrip: typeof import('./components/MetricStrip.vue')['default'] + PlatformFirstLegOrdersPanel: typeof import('./components/PlatformFirstLegOrdersPanel.vue')['default'] RolePreviewSwitcher: typeof import('./components/RolePreviewSwitcher.vue')['default'] RouterLink: typeof import('vue-router')['RouterLink'] RouterView: typeof import('vue-router')['RouterView'] diff --git a/src/components/PlatformFirstLegOrdersPanel.vue b/src/components/PlatformFirstLegOrdersPanel.vue new file mode 100644 index 00000000..3ea8c131 --- /dev/null +++ b/src/components/PlatformFirstLegOrdersPanel.vue @@ -0,0 +1,354 @@ + + + + + diff --git a/src/layouts/AppLayout.test.ts b/src/layouts/AppLayout.test.ts index cfa83d1f..9d89313b 100644 --- a/src/layouts/AppLayout.test.ts +++ b/src/layouts/AppLayout.test.ts @@ -4,6 +4,7 @@ import { createMemoryHistory, createRouter } from 'vue-router' import { nextTick } from 'vue' import { beforeEach, describe, expect, it, vi } from 'vitest' import { ElMessage, ElMessageBox } from 'element-plus' +import { ApiResponseError } from '@/api/http' import { useAccessPreviewStore } from '@/stores/accessPreview' import { useAdminDataStore } from '@/stores/adminData' import { useAuthStore } from '@/stores/auth' @@ -14,7 +15,7 @@ vi.mock('element-plus', async (importOriginal) => { const actual = await importOriginal() return { ...actual, - ElMessage: { success: vi.fn(), error: vi.fn() }, + ElMessage: { success: vi.fn(), warning: vi.fn(), error: vi.fn() }, ElMessageBox: { confirm: vi.fn() }, } }) @@ -86,6 +87,7 @@ describe('AppLayout role preview coordination', () => { vi.stubGlobal('localStorage', createMemoryStorage()) vi.mocked(ElMessageBox.confirm).mockReset() vi.mocked(ElMessage.success).mockReset() + vi.mocked(ElMessage.warning).mockReset() vi.mocked(ElMessage.error).mockReset() document.body.innerHTML = '' }) @@ -294,7 +296,7 @@ describe('AppLayout role preview coordination', () => { const logout = auth.logout vi.spyOn(access, 'reset').mockImplementation(() => { calls.push('access'); resetAccess() }) vi.spyOn(unsaved, 'clear').mockImplementation(() => { calls.push('unsaved'); clearUnsaved() }) - vi.spyOn(auth, 'logout').mockImplementation(() => { calls.push('auth'); logout() }) + vi.spyOn(auth, 'logout').mockImplementation(() => { calls.push('auth'); return logout() }) const replace = vi.spyOn(router, 'replace') await wrapper.get('[aria-label="退出登录"]').trigger('click') @@ -320,4 +322,20 @@ describe('AppLayout role preview coordination', () => { expect(router.currentRoute.value.path).toBe('/') expect(router.replace).toHaveBeenCalledTimes(1) }) + + it('keeps the local logout and reports server revocation failure with a tracking id', async () => { + vi.mocked(ElMessageBox.confirm).mockResolvedValueOnce({} as never) + const { wrapper, router, auth } = await mountLayout('/') + vi.spyOn(auth, 'logout').mockImplementation(async () => { + auth.token = '' + auth.refreshToken = '' + throw new ApiResponseError('服务不可用', 'S0503', 503, undefined, 'request-logout') + }) + + await wrapper.get('[aria-label="退出登录"]').trigger('click') + await flushPromises() + + expect(router.currentRoute.value.path).toBe('/login') + expect(ElMessage.warning).toHaveBeenCalledWith('本地已退出,但服务器会话撤销失败(追踪标识:request-logout)') + }) }) diff --git a/src/layouts/AppLayout.vue b/src/layouts/AppLayout.vue index 6e11027e..29449e49 100644 --- a/src/layouts/AppLayout.vue +++ b/src/layouts/AppLayout.vue @@ -9,6 +9,7 @@ import { Menu, } from '@lucide/vue' import { PRODUCT_NAME } from '@chongchong/shared' +import { getApiErrorDetails } from '@/api/http' import AdminSidebarMenu from '@/components/AdminSidebarMenu.vue' import RolePreviewSwitcher from '@/components/RolePreviewSwitcher.vue' import { resolvePreviewTarget } from '@/router/accessDecision' @@ -169,7 +170,13 @@ async function handleLogout() { loggingOut.value = true access.reset() unsaved.clear() - auth.logout() + try { + await auth.logout() + } catch (error) { + const details = getApiErrorDetails(error, '服务器会话撤销失败') + const trackingId = details.requestId || details.traceId + ElMessage.warning(`本地已退出,但服务器会话撤销失败${trackingId ? `(追踪标识:${trackingId})` : ''}`) + } try { await router.replace('/login') } catch { diff --git a/src/main.ts b/src/main.ts index 709df638..cb734465 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,11 +1,35 @@ import { createApp } from 'vue' import { createPinia } from 'pinia' +import { ElMessage } from 'element-plus' import 'element-plus/es/components/message/style/css' import 'element-plus/es/components/message-box/style/css' import App from './App.vue' +import { registerAuthSessionExpiredHandler } from '@/api/http' import router from './router' +import { sanitizeInternalPath } from '@/router/accessDecision' +import { useAccessPreviewStore } from '@/stores/accessPreview' +import { useAuthStore } from '@/stores/auth' +import { useUnsavedWorkStore } from '@/stores/unsavedWork' import './styles/index.css' -createApp(App).use(createPinia()).use(router).mount('#app') +const pinia = createPinia() +const auth = useAuthStore(pinia) +await auth.restoreSession() + +registerAuthSessionExpiredHandler(async () => { + const currentRoute = router.currentRoute.value + if (currentRoute.name === 'login') return + + useAccessPreviewStore(pinia).reset() + useUnsavedWorkStore(pinia).clear() + const redirect = sanitizeInternalPath(currentRoute.fullPath) + ElMessage.warning('登录状态已过期,请重新登录') + await router.replace({ + name: 'login', + query: redirect === '/' ? undefined : { redirect }, + }) +}) + +createApp(App).use(pinia).use(router).mount('#app') diff --git a/src/stores/auth.test.ts b/src/stores/auth.test.ts new file mode 100644 index 00000000..3876421b --- /dev/null +++ b/src/stores/auth.test.ts @@ -0,0 +1,154 @@ +import { createPinia, setActivePinia } from 'pinia' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + post: vi.fn(), + listCurrentPlatformPermissionCodes: vi.fn(), + refreshHandler: null as null | (() => Promise), +})) + +vi.mock('@/api/http', () => ({ + http: { post: mocks.post }, + registerAuthRefreshHandler: (handler: () => Promise) => { mocks.refreshHandler = handler }, + unwrapApiResponse: (response: { code: string; message: string; data: T }) => { + if (response.code !== '0') throw new Error(response.message) + return response.data + }, + getApiErrorDetails: (error: { code?: string; message?: string }) => ({ + message: error.message || '接口请求失败', + code: error.code, + }), +})) + +vi.mock('@/api/platformIam', () => ({ + listCurrentPlatformPermissionCodes: mocks.listCurrentPlatformPermissionCodes, +})) + +import { useAuthStore } from './auth' + +function createMemoryStorage(): Storage { + const values = new Map() + return { + get length() { return values.size }, + clear: () => values.clear(), + getItem: (key) => values.get(key) ?? null, + key: (index) => [...values.keys()][index] ?? null, + removeItem: (key) => values.delete(key), + setItem: (key, value) => values.set(key, value), + } +} + +function token(overrides: Record = {}) { + const claims = { + user_id: '1000001', + username: 'platform_admin', + app_code: 'PLATFORM', + roles: ['PLATFORM_ADMIN'], + permissions: ['platform:warehouse:list', 'platform:order:list'], + exp: Math.floor(Date.now() / 1000) + 1800, + ...overrides, + } + const payload = Buffer.from(JSON.stringify(claims)).toString('base64url') + return `header.${payload}.signature` +} + +function loginResponse(accessToken = token(), refreshToken = 'refresh-next') { + return { + code: '0', + message: 'success', + data: { + tokenType: 'Bearer', + accessToken, + expiresIn: 1800, + refreshToken, + refreshExpiresIn: 604800, + }, + } +} + +describe('admin auth session', () => { + beforeEach(() => { + vi.stubGlobal('localStorage', createMemoryStorage()) + setActivePinia(createPinia()) + mocks.post.mockReset() + mocks.listCurrentPlatformPermissionCodes.mockReset() + mocks.refreshHandler = null + }) + + it('intersects JWT permissions with the current authenticated permission snapshot', async () => { + mocks.post.mockResolvedValue(loginResponse()) + mocks.listCurrentPlatformPermissionCodes.mockResolvedValue(['platform:warehouse:list']) + const auth = useAuthStore() + + await auth.login('warehouse_operator', 'password') + + expect(auth.isAuthenticated).toBe(true) + expect([...auth.permissionCodes]).toEqual(['platform:warehouse:list']) + expect(auth.permissionResources).toEqual([]) + expect(localStorage.getItem('cc-admin-permission-codes')) + .toBe('["platform:warehouse:list"]') + }) + + it('rejects login and clears the session when the current permission snapshot is unavailable', async () => { + mocks.post.mockResolvedValue(loginResponse()) + mocks.listCurrentPlatformPermissionCodes.mockRejectedValue({ code: 'S0503', message: 'IAM 服务不可用' }) + const auth = useAuthStore() + + await expect(auth.login('platform_admin', 'password')).rejects.toMatchObject({ code: 'S0503' }) + expect(auth.isAuthenticated).toBe(false) + expect(localStorage.getItem('cc-admin-token')).toBeNull() + expect(localStorage.getItem('cc-admin-refresh-token')).toBeNull() + }) + + it('revokes an issued token pair when the permission snapshot prevents login completion', async () => { + mocks.post + .mockResolvedValueOnce(loginResponse()) + .mockResolvedValueOnce({ code: '0', message: 'success', data: null }) + mocks.listCurrentPlatformPermissionCodes.mockRejectedValue({ code: 'S0503', message: 'IAM 服务不可用' }) + const auth = useAuthStore() + + await expect(auth.login('platform_admin', 'password')).rejects.toMatchObject({ code: 'S0503' }) + await Promise.resolve() + + expect(mocks.post).toHaveBeenNthCalledWith(2, '/auth/logout', { refreshToken: 'refresh-next' }, { + headers: { Authorization: expect.stringContaining('Bearer header.') }, + }) + }) + + it('restores an expired access session with the persisted refresh token', async () => { + localStorage.setItem('cc-admin-token', token({ exp: Math.floor(Date.now() / 1000) - 30 })) + localStorage.setItem('cc-admin-refresh-token', 'refresh-old') + mocks.post.mockResolvedValue(loginResponse(token(), 'refresh-rotated')) + mocks.listCurrentPlatformPermissionCodes.mockResolvedValue(['platform:warehouse:list']) + const auth = useAuthStore() + + await expect(auth.restoreSession()).resolves.toBe(true) + + expect(mocks.post).toHaveBeenCalledWith('/auth/refresh', { refreshToken: 'refresh-old' }) + expect(auth.refreshToken).toBe('refresh-rotated') + expect(auth.isAuthenticated).toBe(true) + }) + + it('does not let an in-flight refresh restore a session after logout', async () => { + localStorage.setItem('cc-admin-token', token()) + localStorage.setItem('cc-admin-refresh-token', 'refresh-old') + let resolveRefresh: ((value: ReturnType) => void) | undefined + mocks.post.mockImplementation((url: string) => { + if (url === '/auth/refresh') { + return new Promise((resolve) => { resolveRefresh = resolve }) + } + return Promise.resolve({ code: '0', message: 'success', data: null }) + }) + const auth = useAuthStore() + + const refreshing = auth.refreshSession() + await Promise.resolve() + await auth.logout() + resolveRefresh?.(loginResponse(token(), 'refresh-rotated')) + + await expect(refreshing).rejects.toThrow('登录会话已被后续操作替代') + expect(auth.isAuthenticated).toBe(false) + expect(auth.refreshToken).toBe('') + expect(localStorage.getItem('cc-admin-token')).toBeNull() + }) +}) diff --git a/src/stores/auth.ts b/src/stores/auth.ts index c126bff6..b5e46b8b 100644 --- a/src/stores/auth.ts +++ b/src/stores/auth.ts @@ -1,8 +1,13 @@ import { computed, ref } from 'vue' import { defineStore } from 'pinia' import type { CurrentUser } from '@chongchong/shared' -import { http, registerAuthRefreshHandler, unwrapApiResponse, type ApiEnvelope } from '@/api/http' -import { listPlatformPermissions, type PlatformPermissionResource } from '@/api/platformIam' +import { + http, + registerAuthRefreshHandler, + unwrapApiResponse, + type ApiEnvelope, +} from '@/api/http' +import { listCurrentPlatformPermissionCodes, type PlatformPermissionResource } from '@/api/platformIam' import { decodeJwtClaims } from '@/utils/jwt' const TOKEN_KEY = 'cc-admin-token' @@ -17,10 +22,13 @@ interface LoginResponse { refreshExpiresIn: number } +class ExpiredAccessTokenError extends Error {} +class SupersededSessionError extends Error {} + function sessionFromToken(token: string) { const claims = decodeJwtClaims(token) if (claims.app_code !== 'PLATFORM') throw new Error('账号不属于当前应用端') - if (claims.exp && claims.exp * 1000 <= Date.now()) throw new Error('登录状态已过期,请重新登录') + if (claims.exp && claims.exp * 1000 <= Date.now()) throw new ExpiredAccessTokenError('登录状态已过期') return { user: { id: String(claims.user_id ?? ''), @@ -39,6 +47,7 @@ export const useAuthStore = defineStore('admin-auth', () => { const permissions = ref([]) const permissionResources = ref([]) const isServerSession = ref(false) + let sessionGeneration = 0 if (token.value) { try { @@ -48,20 +57,34 @@ export const useAuthStore = defineStore('admin-auth', () => { user.value = session.user permissions.value = session.permissions.filter((code) => cachedPermissions.includes(code)) isServerSession.value = true - } catch { + } catch (error) { token.value = '' - refreshToken.value = '' localStorage.removeItem(TOKEN_KEY) - localStorage.removeItem(REFRESH_TOKEN_KEY) localStorage.removeItem(PERMISSION_CODES_KEY) + if (!(error instanceof ExpiredAccessTokenError) || !refreshToken.value) { + refreshToken.value = '' + localStorage.removeItem(REFRESH_TOKEN_KEY) + } } } const isAuthenticated = computed(() => Boolean(token.value)) const permissionCodes = computed>(() => new Set(permissions.value)) - async function applySession(result: LoginResponse) { + function ensureCurrentGeneration(expectedGeneration: number) { + if (expectedGeneration !== sessionGeneration) { + throw new SupersededSessionError('登录会话已被后续操作替代') + } + } + + function applyPermissionCodes(codes: string[]) { + permissions.value = codes + localStorage.setItem(PERMISSION_CODES_KEY, JSON.stringify(codes)) + } + + async function applySession(result: LoginResponse, expectedGeneration: number) { const session = sessionFromToken(result.accessToken) + ensureCurrentGeneration(expectedGeneration) token.value = result.accessToken refreshToken.value = result.refreshToken user.value = session.user @@ -69,19 +92,19 @@ export const useAuthStore = defineStore('admin-auth', () => { localStorage.setItem(TOKEN_KEY, token.value) localStorage.setItem(REFRESH_TOKEN_KEY, refreshToken.value) try { - permissionResources.value = await listPlatformPermissions() - const activeResourceCodes = new Set(permissionResources.value - .filter((resource) => resource.status === 'ACTIVE') - .map((resource) => resource.permissionCode)) - permissions.value = session.permissions.filter((code) => activeResourceCodes.has(code)) - localStorage.setItem(PERMISSION_CODES_KEY, JSON.stringify(permissions.value)) + const activePermissionCodes = await listCurrentPlatformPermissionCodes() + ensureCurrentGeneration(expectedGeneration) + permissionResources.value = [] + const activePermissionCodeSet = new Set(activePermissionCodes) + applyPermissionCodes(session.permissions.filter((code) => activePermissionCodeSet.has(code))) } catch (error) { - clearSession() + ensureCurrentGeneration(expectedGeneration) throw error } } function clearSession() { + sessionGeneration += 1 token.value = '' refreshToken.value = '' user.value = null @@ -93,44 +116,73 @@ export const useAuthStore = defineStore('admin-auth', () => { localStorage.removeItem(PERMISSION_CODES_KEY) } + async function revokeTokenPair(accessToken: string, refreshTokenToRevoke: string) { + await http.post('/auth/logout', { refreshToken: refreshTokenToRevoke }, { + headers: { Authorization: `Bearer ${accessToken}` }, + }) + } + async function login(account: string, password: string) { - const response = await http.post, ApiEnvelope>( - '/auth/login', - { username: account, password }, - ) - const result = unwrapApiResponse(response) - await applySession(result) + const expectedGeneration = ++sessionGeneration + try { + const response = await http.post, ApiEnvelope>( + '/auth/login', + { username: account, password }, + ) + const result = unwrapApiResponse(response) + await applySession(result, expectedGeneration) + } catch (error) { + if (expectedGeneration === sessionGeneration) { + const issuedAccessToken = token.value + const issuedRefreshToken = refreshToken.value + clearSession() + if (issuedAccessToken && issuedRefreshToken) { + void revokeTokenPair(issuedAccessToken, issuedRefreshToken).catch(() => undefined) + } + } + throw error + } } async function refreshSession() { if (!refreshToken.value) throw new Error('刷新令牌不存在,请重新登录') + const expectedGeneration = sessionGeneration try { const response = await http.post, ApiEnvelope>( '/auth/refresh', { refreshToken: refreshToken.value }, ) const result = unwrapApiResponse(response) - await applySession(result) + await applySession(result, expectedGeneration) } catch (error) { - clearSession() + if (expectedGeneration === sessionGeneration) clearSession() throw error } } + async function restoreSession() { + if (isAuthenticated.value) return true + if (!refreshToken.value) return false + try { + await refreshSession() + return true + } catch { + return false + } + } + registerAuthRefreshHandler(async () => { await refreshSession() if (!token.value) throw new Error('登录状态刷新失败') return token.value }) - function logout() { + async function logout() { const accessToken = token.value const tokenToRevoke = refreshToken.value clearSession() if (accessToken && tokenToRevoke) { - void http.post('/auth/logout', { refreshToken: tokenToRevoke }, { - headers: { Authorization: `Bearer ${accessToken}` }, - }).catch(() => undefined) + await revokeTokenPair(accessToken, tokenToRevoke) } } @@ -145,6 +197,7 @@ export const useAuthStore = defineStore('admin-auth', () => { isAuthenticated, login, refreshSession, + restoreSession, logout, } }) diff --git a/src/views/AccessControlledActions.test.ts b/src/views/AccessControlledActions.test.ts index b09738be..7822f08c 100644 --- a/src/views/AccessControlledActions.test.ts +++ b/src/views/AccessControlledActions.test.ts @@ -13,6 +13,15 @@ import RolesView from './RolesView.vue' import WarehousesView from './WarehousesView.vue' import { useAdminDataStore } from '@/stores/adminData' import { ElMessageBox } from 'element-plus' +import { getPlatformWarehouse, listPlatformWarehouses } from '@/api/platformWarehouses' + +vi.mock('@/api/platformWarehouses', () => ({ + listPlatformWarehouses: vi.fn(), + getPlatformWarehouse: vi.fn(), + createPlatformWarehouse: vi.fn(), + updatePlatformWarehouse: vi.fn(), + changePlatformWarehouseStatus: vi.fn(), +})) class ResizeObserverStub implements ResizeObserver { observe() {} @@ -74,8 +83,28 @@ afterAll(() => vi.unstubAllGlobals()) describe('business action permissions', () => { beforeEach(() => { vi.stubGlobal('localStorage', createMemoryStorage()) + vi.stubGlobal('sessionStorage', createMemoryStorage()) + sessionStorage.setItem('cc-admin-warehouse-tenant-id', '10001') vi.stubGlobal('ResizeObserver', ResizeObserverStub) document.body.innerHTML = '' + const warehouse = { + id: '19001', tenantId: 10001, warehouseCode: 'CN-TEST-01', warehouseName: '测试收货仓', + warehouseType: 'DOMESTIC', countryCode: 'CN', timezone: 'Asia/Shanghai', contactName: '仓库客服', + contactPhone: '0579-12345678', contactEmail: null, status: 'ACTIVE' as const, remark: null, + } + vi.mocked(listPlatformWarehouses).mockResolvedValue({ records: [warehouse], page: 1, size: 200, total: 1, pages: 1 }) + vi.mocked(getPlatformWarehouse).mockResolvedValue({ + warehouse, + addresses: [{ + id: '19002', tenantId: 10001, warehouseId: '19001', addressType: 'OPERATING', countryCode: 'CN', + stateProvince: '浙江省', city: '义乌市', district: null, postalCode: null, addressLine1: '测试地址', + addressLine2: null, latitude: null, longitude: null, + }], + capabilities: [{ + id: '19003', tenantId: 10001, warehouseId: '19001', capabilityCode: 'RECEIVING', capabilityName: '收货服务', + enabled: true, cutoffTime: null, description: '周一至周六 08:30-18:00', + }], + }) }) it('keeps review actions out of customer records now that reviews use a dedicated route', async () => { diff --git a/src/views/FirstLegOrdersView.vue b/src/views/FirstLegOrdersView.vue index cffd0e22..e80ab02b 100644 --- a/src/views/FirstLegOrdersView.vue +++ b/src/views/FirstLegOrdersView.vue @@ -4,6 +4,7 @@ import { ElMessage } from 'element-plus' import { ClipboardCheck, Download, Eye, PackageCheck, Pencil, Plus, RotateCcw, Search, Ship, UserRoundCheck } from '@lucide/vue' import { FIRST_LEG_ORDER_STATUSES } from '@chongchong/shared' import MetricStrip from '@/components/MetricStrip.vue' +import PlatformFirstLegOrdersPanel from '@/components/PlatformFirstLegOrdersPanel.vue' import StatusBadge from '@/components/StatusBadge.vue' import { useUnsavedWorkRegistration } from '@/composables/useUnsavedWorkRegistration' import { useAccessPreviewStore } from '@/stores/accessPreview' @@ -172,7 +173,8 @@ function exportOrders() { ElMessage.success(`已生成 ${filteredRows.value.leng