Merge branch 'release/test' of http://git.buggoglobal.com/html/client-web into dev

This commit is contained in:
2026-09-01 10:02:48 +08:00
44 changed files with 1208 additions and 142 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
VITE_API_BASE_URL=/api
VITE_OVERSEAS_API_BASE_URL=/overseas-api
OVERSEAS_API_PROXY_TARGET=http://127.0.0.1:9300
API_PROXY_TARGET=http://8.163.30.128:9000
API_PROXY_TARGET=http://127.0.0.1:9000
VITE_ADMIN_WEB_ORIGIN=http://localhost:5173
+8
View File
@@ -0,0 +1,8 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml
+9
View File
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="JAVA_MODULE" version="4">
<component name="NewModuleRootManager" inherit-compiler-output="true">
<exclude-output />
<content url="file://$MODULE_DIR$" />
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectRootManager" version="2" languageLevel="JDK_17" default="true" project-jdk-name="17" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/out" />
</component>
</project>
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/client-web.iml" filepath="$PROJECT_DIR$/.idea/client-web.iml" />
</modules>
</component>
</project>
Generated
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>
+2 -1
View File
@@ -47,7 +47,8 @@
| 企业资料、成员与角色 | `src/modules/tenant-management/`、`src/modules/account/` | Backend `iam/`、`file/` |
| 工作台与系统入口 | `src/modules/dashboard/`、`src/modules/system/` | `src/navigation/clientNavigation.ts`、`src/router/index.ts` |
| 企业成员消息收件箱 | `src/modules/notifications/` | Backend `iam/src/main/java/com/baiyue/iam/notification/`;列表、详情、已读与未读数契约见 `iam/src/main/resources/openapi/notification-v1.yaml` |
| 头程下单、订单、入仓码/SKU 查询、箱唛与履约进度 | `src/modules/first-leg/`;列表与查询从 `views/FirstLegOrdersView.vue`、`store/firstLegOrders.ts`、`api/unifiedOrders.ts` 进入,箱唛下载从 `api/firstLeg.ts` 进入 | Backend `order/`、`firstleg/`、`warehouse/`;稳定箱码与打印契约先读 Backend `firstleg/AGENTS.md` |
| 头程下单、订单、入仓码业务关系、箱唛与履约进度 | `src/modules/first-leg/`;列表从 `views/FirstLegOrdersView.vue` 进入,点击入仓码由 `components/InboundCodeAssociationDialog.vue`、`api/inboundCodeAssociations.ts` 查询订单、客户和目的仓关联摘要;箱唛下载从 `api/firstLeg.ts` 进入 | Backend `order/` 的客户隔离 `InboundCodeAssociationService`、`order-v1.yaml`,以及 `firstleg/`、`warehouse/`;不得用内部 ID 补齐缺失业务编号 |
| 装柜完成后的退税报关资料 | `src/modules/first-leg/views/FirstLegOrdersView.vue` 的“报关资料”、`components/CustomsDocumentsDialog.vue`、`api/customs.ts` | 只显示登录客户在同柜的退税入仓码;报关开始前可上传、替换、调整同一资料覆盖的一个或多个入仓码及删除误传,全部写入携带幂等键和版本;开始后只读冻结资料,整柜完成后才能下载平台回传的一对一完成文件。Backend `firstleg/customs/container/`、`customs-v1.yaml`,二进制归 `file/` |
| 统一订单展示 | `src/modules/order-management/` | `src/modules/first-leg/` 与对应后端订单契约 |
| 客户财务账单、收款、未收余额与客户确认 | `src/modules/finance/`;确认入口 `views/FinanceBillsView.vue`、`api/finance.ts` | Backend `finance/`;只能查看登录态客户的已出账账单。出账或已出账调整后为 `PENDING_CONFIRMATION`,客户确认以登录成员、`expectedVersion` 和幂等键提交,成功后记录确认人姓名快照与时间 |
| 跨境候选能力 | `src/modules/cross-border/` | 先核对 `routes.ts` 的发布状态与阶段范围,不自动扩展实现 |
+2 -2
View File
@@ -35,7 +35,7 @@ pnpm test
依赖和 `pnpm-lock.yaml` 均保存在当前前端项目中,不需要在仓库根目录安装 Node 依赖。
一期路由:登录、注册、工作台、我的信息、企业资料、企业成员、角色权限和头程订单。登录、注册、退出、令牌刷新、个人资料、修改密码、企业资料、企业资质、权限资源、角色列表/新增/授权和成员完整维护已经接入一期后端接口;开发代理默认转发到测试环境 `http://8.163.30.128:9000`,可通过 `API_PROXY_TARGET` 覆盖。登录使用后端约定的用户名和 8–72 位密码,注册成功后可立即登录,企业资料审核只影响订单提交资格。
一期路由:登录、注册、工作台、我的信息、企业资料、企业成员、角色权限和头程订单。登录、注册、退出、令牌刷新、个人资料、修改密码、企业资料、企业资质、权限资源、角色列表/新增/授权和成员完整维护已经接入一期后端接口;开发代理通过 `API_PROXY_TARGET` 与 `OVERSEAS_API_PROXY_TARGET` 分别配置国内、海外 Gateway。登录使用后端约定的用户名和 8–72 位密码,注册成功后可立即登录,企业资料审核只影响订单提交资格。
认证会话保存 Access Token 和轮换后的 Refresh Token,并对并发 401 请求执行单次刷新。刷新或会话上下文加载遇到临时网络故障、超时等非认证错误时保留当前有效 Token,只有服务端明确返回 401/会话失效才清理登录状态。客户端只接受 `app_code` 为 `MERCHANT` 的 JWT;页面权限通过当前用户授权接口实时加载,不从 JWT 携带或解析角色权限。前端权限仅用于导航和交互控制,服务端仍须按用户、客户和权限编码完成最终授权。
@@ -47,4 +47,4 @@ pnpm test
头程订单列表、详情、仓库查询、创建/编辑草稿和提交已经接入真实 API,并显式映射后端 `transportMode`、`containerMode` 和订单状态。创建与提交请求携带幂等键,`merchant:order:create`、`merchant:order:edit`、`merchant:order:submit` 分别映射到独立前端操作权限。页面补齐渠道、提货方式、报关模式、目的仓类型、包装类型等后端必填字段;API 模式先清空本地记录,请求失败不会回退模拟成功。FBA 和海外仓按公共仓类型筛选,选择后由仓库主数据生成目的仓字段,不再要求客户重复选择收货单位;私人仓才填写并按当前用户缓存收件资料。只有“我司提货”需要填写提货联系人和地址,其他送货方式不发送发货方快照,订单也不再依赖往来单位 `customerId`。快递信息仍由平台维护、客户只读。订单取消不属于本轮本地一期验收,客户端不暴露取消写操作。订单产品状态仍按已确认的七状态执行,不设置“已驳回”;若兼容旧接口数据收到 `REJECTED`,只按待审核展示且不开放编辑。
联调远端 Gateway 时可在启动命令前设置 `API_PROXY_TARGET=http://8.163.30.128:9000`;进程环境变量优先于 `.env` 文件。浏览器仍统一请求同源 `/api`,源码中不写死业务服务地址。联调时可只读检查同工作区的 `baiyue-firstleg/` Controller、DTO、Service、Repository 和回归脚本,用于发现后端拉取后的契约变化,但不修改后端仓库。2026-07-31 测试 Gateway 登录和真实角色列表读取正常;Warehouse 服务已注册,但仓库和往来单位业务查询仍返回 `S0500`,头程草稿创建和相关浏览器流程需待后端修复后继续验收。
联调远端 Gateway 时,进程环境变量优先于 `.env` 文件。浏览器国内业务统一请求同源 `/api`,海外业务请求同源 `/overseas-api`,由代理分别转发并确保最终路径只有一个 `/api` 前缀。联调时可只读检查同工作区的 `baiyue-firstleg/` Controller、DTO、Service、Repository 和回归脚本,用于发现后端拉取后的契约变化,但不修改后端仓库。
+3 -5
View File
@@ -4,7 +4,7 @@ services:
context: .
dockerfile: Dockerfile
args:
VITE_ADMIN_WEB_ORIGIN: "${VITE_ADMIN_WEB_ORIGIN:-http://8.163.30.128:9001}"
VITE_ADMIN_WEB_ORIGIN: "${VITE_ADMIN_WEB_ORIGIN:-https://test-admin.buggoglobal.com}"
NODE_IMAGE: "${NODE_BASE_IMAGE:-node:22-alpine}"
NGINX_IMAGE: "${NGINX_BASE_IMAGE:-nginx:1.27-alpine}"
image: client-web-test:local
@@ -12,8 +12,6 @@ services:
restart: unless-stopped
ports:
- "${HOST_PORT:-9002}:80"
extra_hosts:
- "host.docker.internal:host-gateway"
environment:
# Gateway 与前端容器在同一台测试服务器,通过宿主机内部地址访问。
API_UPSTREAM: "${API_UPSTREAM:-http://host.docker.internal:9000}"
API_UPSTREAM: "http://test-api-cn.buggoglobal.com"
OVERSEAS_API_UPSTREAM: "http://test-api-global.buggoglobal.com"
+14 -2
View File
@@ -13,10 +13,22 @@ server {
# 前端统一请求 /api,保持原始路径转发给 Gateway。
location /api/ {
client_max_body_size 12m;
client_max_body_size 55m;
proxy_pass ${API_UPSTREAM};
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header Host $proxy_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# 海外业务使用独立 Gateway;浏览器前缀只在此处改写一次为 /api。
location /overseas-api/ {
client_max_body_size 55m;
rewrite ^/overseas-api/(.*)$ /api/$1 break;
proxy_pass ${OVERSEAS_API_UPSTREAM};
proxy_http_version 1.1;
proxy_set_header Host $proxy_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
+32 -3
View File
@@ -1,6 +1,6 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { coordinateCrossTabRefresh } from './crossTabRefresh.ts'
import { coordinateCrossTabRefresh, CrossTabRefreshTimeoutError } from './crossTabRefresh.ts'
test('reuses tokens rotated by another browser tab while waiting for the refresh lock', async () => {
let currentRefreshToken = 'refresh-old'
@@ -10,7 +10,7 @@ test('reuses tokens rotated by another browser tab while waiting for the refresh
configurable: true,
value: {
locks: {
async request(_name, callback) {
async request(_name, _options, callback) {
currentRefreshToken = 'refresh-new'
return callback()
},
@@ -41,7 +41,7 @@ test('refreshes normally when this tab owns the current refresh token', async ()
const originalNavigator = globalThis.navigator
Object.defineProperty(globalThis, 'navigator', {
configurable: true,
value: { locks: { request: async (_name, callback) => callback() } },
value: { locks: { request: async (_name, _options, callback) => callback() } },
})
try {
@@ -76,3 +76,32 @@ test('does not wait for rotation after the shared session was removed', async ()
Object.defineProperty(globalThis, 'navigator', { configurable: true, value: originalNavigator })
}
})
test('aborts a refresh lock that cannot be acquired', async () => {
const originalNavigator = globalThis.navigator
Object.defineProperty(globalThis, 'navigator', {
configurable: true,
value: {
locks: {
request(_name, options) {
return new Promise((_resolve, reject) => {
options.signal.addEventListener('abort', () => reject(options.signal.reason), { once: true })
})
},
},
},
})
try {
await assert.rejects(coordinateCrossTabRefresh({
lockName: 'client-refresh',
lockTimeoutMs: 5,
observedRefreshToken: 'refresh-old',
currentRefreshToken: () => 'refresh-old',
reuseCurrentSession: async () => 'unexpected',
refresh: async () => 'unexpected',
}), CrossTabRefreshTimeoutError)
} finally {
Object.defineProperty(globalThis, 'navigator', { configurable: true, value: originalNavigator })
}
})
+20 -1
View File
@@ -1,11 +1,21 @@
export interface CrossTabRefreshOptions<T> {
lockName: string
lockTimeoutMs?: number
observedRefreshToken: string
currentRefreshToken: () => string
reuseCurrentSession: () => Promise<T>
refresh: (refreshToken: string) => Promise<T>
}
export class CrossTabRefreshTimeoutError extends Error {
constructor() {
super('登录状态刷新等待超时,请重新登录')
this.name = 'CrossTabRefreshTimeoutError'
}
}
const DEFAULT_LOCK_TIMEOUT_MS = 20_000
export async function coordinateCrossTabRefresh<T>(options: CrossTabRefreshOptions<T>): Promise<T> {
const run = async () => {
const currentRefreshToken = options.currentRefreshToken()
@@ -33,5 +43,14 @@ export async function coordinateCrossTabRefresh<T>(options: CrossTabRefreshOptio
throw error
}
}
return navigator.locks.request(options.lockName, run)
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), options.lockTimeoutMs ?? DEFAULT_LOCK_TIMEOUT_MS)
try {
return await navigator.locks.request(options.lockName, { signal: controller.signal }, run)
} catch (error) {
if (controller.signal.aborted) throw new CrossTabRefreshTimeoutError()
throw error
} finally {
clearTimeout(timeout)
}
}
+34 -2
View File
@@ -3,7 +3,7 @@ import { parseApiJsonResponse } from '@chongchong/shared'
import { clearSessionTokens, getAccessToken, getRefreshToken, persistTokenPair, type TokenPair } from '@/api/session'
import { apiErrorMessage, asFieldErrors } from '@/api/errorDetails'
import { ApiError, isApiEnvelope, type ApiEnvelope } from '@/api/types'
import { coordinateCrossTabRefresh } from '@/api/crossTabRefresh'
import { coordinateCrossTabRefresh, CrossTabRefreshTimeoutError } from '@/api/crossTabRefresh'
const transport = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL || '/api',
@@ -18,7 +18,7 @@ interface RetryableRequest extends InternalAxiosRequestConfig {
function unwrap<T>(envelope: ApiEnvelope<T>) {
if (envelope.code !== '200') {
const fieldErrors = asFieldErrors(envelope.data)
throw new ApiError(apiErrorMessage(envelope.message, fieldErrors), envelope.code, undefined, fieldErrors, envelope.traceId)
throw new ApiError(apiErrorMessage(envelope.message, fieldErrors), envelope.code, undefined, fieldErrors, envelope.traceId, envelope.requestId)
}
return envelope.data
}
@@ -36,6 +36,7 @@ function mapError(error: unknown) {
error.response?.status,
fieldErrors,
typeof body.traceId === 'string' ? body.traceId : null,
typeof body.requestId === 'string' ? body.requestId : null,
)
}
if (error.code === AxiosError.ERR_NETWORK) return new ApiError('无法连接服务器,请确认后端服务已启动', 'NETWORK_ERROR')
@@ -78,6 +79,16 @@ export async function refreshAuthenticatedSession() {
return tokens.accessToken
},
})
.catch((error) => {
if (error instanceof CrossTabRefreshTimeoutError) {
const expiredError = new ApiError(error.message, 'A0401', 401)
clearSessionTokens()
window.dispatchEvent(new CustomEvent('cc-client-session-expired'))
redirectToLoginAfterSessionExpiry()
throw expiredError
}
throw error
})
.finally(() => {
refreshPromise = null
})
@@ -132,6 +143,7 @@ export async function apiBlobRequest(config: AxiosRequestConfig) {
const response = await transport.request<Blob>({ ...config, responseType: 'blob' })
return response.data
} catch (error) {
await throwBlobApiError(error)
throw mapError(error)
}
}
@@ -141,8 +153,28 @@ export async function apiBlobResponseRequest(config: AxiosRequestConfig) {
const response = await transport.request<Blob>({ ...config, responseType: 'blob' })
return { blob: response.data, headers: response.headers }
} catch (error) {
await throwBlobApiError(error)
throw mapError(error)
}
}
async function throwBlobApiError(error: unknown) {
if (!axios.isAxiosError(error) || !(error.response?.data instanceof Blob)) return
try {
const body = JSON.parse(await error.response.data.text()) as unknown
if (!isApiEnvelope(body)) return
const fieldErrors = asFieldErrors(body.data)
throw new ApiError(
apiErrorMessage(body.message, fieldErrors),
body.code,
error.response.status,
fieldErrors,
typeof body.traceId === 'string' ? body.traceId : null,
typeof body.requestId === 'string' ? body.requestId : null,
)
} catch (parsedError) {
if (parsedError instanceof ApiError) throw parsedError
}
}
export { ApiError }
+1
View File
@@ -18,6 +18,7 @@ export class ApiError extends Error {
readonly status?: number,
readonly fieldErrors?: ApiFieldErrors,
readonly traceId?: string | null,
readonly requestId?: string | null,
) {
super(message)
this.name = 'ApiError'
+14 -2
View File
@@ -3,11 +3,23 @@ import { readFileSync } from 'node:fs'
import test from 'node:test'
const config = readFileSync(new URL('../../nginx/default.conf.template', import.meta.url), 'utf8')
const testCompose = readFileSync(new URL('../../docker-compose.test.yml', import.meta.url), 'utf8')
const jenkinsfile = readFileSync(new URL('../../Jenkinsfile', import.meta.url), 'utf8')
const dockerfile = readFileSync(new URL('../../Dockerfile', import.meta.url), 'utf8')
test('allows qualification uploads with multipart overhead through the API proxy', () => {
assert.match(config, /location \/api\/ \{[\s\S]*?client_max_body_size 12m;/)
test('allows 50 MiB uploads with multipart overhead through both API proxies', () => {
assert.match(config, /location \/api\/ \{[\s\S]*?client_max_body_size 55m;/)
assert.match(config, /location \/overseas-api\/ \{[\s\S]*?client_max_body_size 55m;/)
assert.equal(config.match(/client_max_body_size 55m;/g)?.length, 2)
})
test('routes domestic and overseas browser APIs to their test gateways without duplicate api prefixes', () => {
assert.match(testCompose, /API_UPSTREAM: "http:\/\/test-api-cn\.buggoglobal\.com"/)
assert.match(testCompose, /OVERSEAS_API_UPSTREAM: "http:\/\/test-api-global\.buggoglobal\.com"/)
assert.match(testCompose, /VITE_ADMIN_WEB_ORIGIN: "\$\{VITE_ADMIN_WEB_ORIGIN:-https:\/\/test-admin\.buggoglobal\.com\}"/)
assert.match(config, /location \/overseas-api\/ \{[\s\S]*?rewrite \^\/overseas-api\/\(\.\*\)\$ \/api\/\$1 break;[\s\S]*?proxy_pass \$\{OVERSEAS_API_UPSTREAM\};/)
assert.match(config, /proxy_set_header Host \$proxy_host;/)
assert.doesNotMatch(config, /\/api\/api/)
})
test('runs each deployment on its target node without controller-local SSH files', () => {
@@ -87,6 +87,9 @@ test('uses real auth endpoints and single-flight refresh handling', () => {
assert.match(httpSource, /return await transport\.request\(request\)/)
assert.match(httpSource, /envelope\.code !== '200'/)
assert.match(httpSource, /redirectToLoginAfterSessionExpiry/)
assert.match(httpSource, /error instanceof CrossTabRefreshTimeoutError/)
assert.match(httpSource, /const expiredError = new ApiError\(error\.message, 'A0401', 401\)/)
assert.match(httpSource, /error instanceof CrossTabRefreshTimeoutError[\s\S]*window\.dispatchEvent\(new CustomEvent\('cc-client-session-expired'\)\)[\s\S]*redirectToLoginAfterSessionExpiry\(\)/)
assert.match(httpSource, /window\.location\.replace\(`\/login\?redirect=\$\{encodeURIComponent\(redirect\)\}`\)/)
assert.match(authStoreSource, /decodeMerchantToken/)
assert.doesNotMatch(authStoreSource, /mock-client:/)
@@ -0,0 +1,36 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
const source = readFileSync(new URL('./customs.ts', import.meta.url), 'utf8')
const httpTypes = readFileSync(new URL('../../../api/types.ts', import.meta.url), 'utf8')
const httpSource = readFileSync(new URL('../../../api/http.ts', import.meta.url), 'utf8')
test('maps only the frozen merchant container-customs contract', () => {
assert.match(source, /type MerchantCustomsStatus = 'WAITING_UPLOAD' \| 'DOCUMENT_UPLOADED' \| 'DECLARING' \| 'CUSTOMS_COMPLETED'/)
assert.match(source, /inboundId: string/)
assert.match(source, /orderId: string/)
assert.match(source, /documentId: string/)
assert.match(source, /loadingPlanId: string/)
assert.match(source, /getMerchantCustomsForOrder\(orderId: string\)/)
assert.doesNotMatch(source, /number \| string/)
assert.match(source, /url: `\/merchant\/v1\/customs\/orders\/\$\{encodedId\(orderId\)\}`/)
assert.doesNotMatch(source, /customs\/cases|MerchantCustomsCaseDetail|getMerchantCustomsByOrder/)
})
test('sends multi-inbound scope, optimistic versions and idempotency for every write', () => {
assert.match(source, /params\.append\('inboundIds', id\)/)
assert.match(source, /expectedVersion: document\.source\.version/)
assert.match(source, /params: \{ expectedVersion: document\.source\.version \}/)
assert.equal(source.match(/'Idempotency-Key'/g)?.length, 4)
assert.match(source, /data\.append\('file', file\)/)
assert.match(source, /file\.size > MAX_CUSTOMS_FILE_SIZE/)
})
test('preserves request and trace identifiers on API errors', () => {
assert.match(httpTypes, /readonly requestId\?: string \| null/)
assert.match(httpSource, /envelope\.traceId, envelope\.requestId/)
assert.match(source, /interface ApiErrorMeta/)
assert.match(source, /requestId: error\.requestId \?\? null/)
assert.match(source, /traceId: error\.traceId \?\? null/)
})
+198 -19
View File
@@ -1,31 +1,210 @@
import { ApiError, apiRequest } from '@/api/http'
import { ApiError, apiBlobResponseRequest, apiRequest } from '@/api/http'
import { createIdempotencyKey } from '@/modules/first-leg/api/firstLeg'
export interface MerchantCustomsReceipt {
id: number | string
receiptType: 'RECEIVED' | 'DECLARED' | 'REJECTED' | 'INSPECTION' | 'RELEASED' | 'OTHER' | string
receiptVersion: number | null
fileName: string | null
uploadedAt: string | null
export type MerchantCustomsStatus = 'WAITING_UPLOAD' | 'DOCUMENT_UPLOADED' | 'DECLARING' | 'CUSTOMS_COMPLETED'
export type ContainerCustomsStatus = 'WAITING_CUSTOMER_DOCUMENTS' | 'READY_TO_START' | 'DECLARING' | 'COMPLETED'
export interface MerchantCustomsFile {
fileId: string
fileName: string
fileHash: string
contentType: string | null
sizeBytes: number
version: number
uploadedAt: string
}
export interface MerchantCustomsCaseDetail {
customsCase: {
id: number | string
customsNo: string | null
orderId: number | string
status: string | null
updatedAt: string | null
}
receipts: MerchantCustomsReceipt[]
export interface MerchantCustomsInbound {
inboundId: string
orderId: string
orderNo: string
inboundCode: string
tenantId: string
customerName: string
taxMode: 'TAX_REFUND' | 'BUY_DECLARATION'
covered: boolean
}
export function getMerchantCustomsByOrder(orderId: number | string) {
return apiRequest<MerchantCustomsCaseDetail>({
export interface MerchantCustomsDocument {
documentId: string
tenantId: string
source: MerchantCustomsFile
inboundIds: string[]
inboundCodes: string[]
frozen: boolean
completion: MerchantCustomsFile | null
}
export interface MerchantCustomsContainer {
aggregateId: string
loadingPlanId: string
containerBookingId: string
planNo?: string
cabinetNo?: string
containerNo: string | null
status: ContainerCustomsStatus
merchantStatus?: MerchantCustomsStatus | null
version: number
taxRefundCount: number
coveredTaxRefundCount: number
inbounds: MerchantCustomsInbound[]
documents: MerchantCustomsDocument[]
startedAt?: string | null
completedAt?: string | null
}
export interface ApiErrorMeta {
code: string
requestId: string | null
traceId: string | null
}
export interface MerchantCustomsDownload {
blob: Blob
fileName: string
}
export const MERCHANT_CUSTOMS_STATUS_LABELS: Record<MerchantCustomsStatus, string> = {
WAITING_UPLOAD: '待上传资料',
DOCUMENT_UPLOADED: '资料已上传',
DECLARING: '报关中',
CUSTOMS_COMPLETED: '报关完成',
}
const MAX_CUSTOMS_FILE_SIZE = 50 * 1024 * 1024
function encodedId(value: string) {
return encodeURIComponent(value)
}
function inboundQuery(inboundIds: string[]) {
const params = new URLSearchParams()
inboundIds.forEach((id) => params.append('inboundIds', id))
return params.toString()
}
function fileBody(file: File) {
const data = new FormData()
data.append('file', file)
return data
}
export function validateMerchantCustomsFile(file: File) {
if (file.size > MAX_CUSTOMS_FILE_SIZE) throw new Error('单个文件不能超过 50MB')
}
export function getMerchantCustomsForOrder(orderId: string) {
return apiRequest<MerchantCustomsContainer>({
method: 'GET',
url: `/merchant/v1/customs/orders/${orderId}`,
url: `/merchant/v1/customs/orders/${encodedId(orderId)}`,
})
}
export function uploadMerchantCustomsDocument(planId: string, inboundIds: string[], file: File) {
validateMerchantCustomsFile(file)
return apiRequest<MerchantCustomsDocument>({
method: 'POST',
url: `/merchant/v1/customs/loading-plans/${encodedId(planId)}/documents/upload?${inboundQuery(inboundIds)}`,
headers: { 'Idempotency-Key': createIdempotencyKey(`merchant-customs-upload-${planId}`) },
data: fileBody(file),
timeout: 120_000,
})
}
export function replaceMerchantCustomsDocument(document: MerchantCustomsDocument, inboundIds: string[], file: File) {
validateMerchantCustomsFile(file)
const params = new URLSearchParams(inboundQuery(inboundIds))
params.set('expectedVersion', String(document.source.version))
return apiRequest<MerchantCustomsDocument>({
method: 'POST',
url: `/merchant/v1/customs/documents/${encodedId(document.documentId)}/upload?${params.toString()}`,
headers: { 'Idempotency-Key': createIdempotencyKey(`merchant-customs-replace-${document.documentId}`) },
data: fileBody(file),
timeout: 120_000,
})
}
export function updateMerchantCustomsDocumentInbounds(document: MerchantCustomsDocument, inboundIds: string[]) {
return apiRequest<MerchantCustomsDocument>({
method: 'PUT',
url: `/merchant/v1/customs/documents/${encodedId(document.documentId)}/inbounds`,
headers: { 'Idempotency-Key': createIdempotencyKey(`merchant-customs-scope-${document.documentId}`) },
data: { inboundIds, expectedVersion: document.source.version },
})
}
export function deleteMerchantCustomsDocument(document: MerchantCustomsDocument) {
return apiRequest<MerchantCustomsContainer>({
method: 'DELETE',
url: `/merchant/v1/customs/documents/${encodedId(document.documentId)}`,
headers: { 'Idempotency-Key': createIdempotencyKey(`merchant-customs-delete-${document.documentId}`) },
params: { expectedVersion: document.source.version },
})
}
function contentDispositionFileName(value: unknown, fallback: string) {
if (typeof value !== 'string') return fallback
const encoded = value.match(/filename\*=UTF-8''([^;]+)/i)?.[1]
if (encoded) {
try { return decodeURIComponent(encoded) } catch { return fallback }
}
return value.match(/filename="?([^";]+)"?/i)?.[1] || fallback
}
async function requireBinaryDownload(blob: Blob) {
if (!blob.type.toLowerCase().includes('json')) return
try {
const body = JSON.parse(await blob.text()) as { code?: unknown; message?: unknown; requestId?: unknown; traceId?: unknown }
if (typeof body.message === 'string') {
throw new ApiError(
body.message,
typeof body.code === 'string' ? body.code : 'DOWNLOAD_FAILED',
undefined,
undefined,
typeof body.traceId === 'string' ? body.traceId : null,
typeof body.requestId === 'string' ? body.requestId : null,
)
}
} catch (error) {
if (error instanceof ApiError) throw error
}
}
async function download(url: string, fallbackName: string): Promise<MerchantCustomsDownload> {
const response = await apiBlobResponseRequest({ method: 'GET', url })
await requireBinaryDownload(response.blob)
return {
blob: response.blob,
fileName: contentDispositionFileName(response.headers['content-disposition'], fallbackName),
}
}
export function downloadMerchantCustomsSource(document: MerchantCustomsDocument) {
return download(`/merchant/v1/customs/documents/${encodedId(document.documentId)}/download`, document.source.fileName)
}
export function downloadMerchantCustomsCompletion(document: MerchantCustomsDocument) {
return download(
`/merchant/v1/customs/documents/${encodedId(document.documentId)}/completion/download`,
document.completion?.fileName || `customs-completion-${document.documentId}`,
)
}
export function merchantCustomsErrorMeta(error: unknown): ApiErrorMeta | null {
if (!(error instanceof ApiError)) return null
return { code: error.code, requestId: error.requestId ?? null, traceId: error.traceId ?? null }
}
export function merchantCustomsErrorMessage(error: unknown, fallback: string) {
if (!(error instanceof ApiError)) return error instanceof Error ? error.message : fallback
const identifiers = [
error.code ? `错误码 ${error.code}` : '',
error.requestId ? `请求号 ${error.requestId}` : '',
error.traceId ? `追踪号 ${error.traceId}` : '',
].filter(Boolean)
return `${error.message || fallback}${identifiers.length ? `(${identifiers.join(',')})` : ''}`
}
export function isMerchantCustomsUnavailable(error: unknown) {
return error instanceof ApiError && (error.status === 404 || error.code === 'A0404')
}
+1
View File
@@ -238,6 +238,7 @@ export interface MerchantFirstLegOrderRow {
inboundCode: string | null
channelName: string | null
destinationType: ApiDestinationType | null
pickupMethod: ApiPickupMethod | null
customerName: string | null
receivingCompanyName: string | null
specificationModel: string | null
@@ -0,0 +1,19 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
const source = readFileSync(new URL('./inboundCodeAssociations.ts', import.meta.url), 'utf8')
test('queries the customer-scoped inbound-code association without browser-selected customer scope', () => {
assert.match(source, /method: 'GET'/)
assert.match(source, /`\/merchant\/v1\/orders\/first-leg\/inbound-code-associations\/\$\{encodeURIComponent\(inboundCode\.trim\(\)\)\}`/)
assert.doesNotMatch(source, /params:[\s\S]*customerId/)
})
test('keeps internal association identifiers as strings without making them display keys', () => {
for (const field of ['orderId', 'customerId']) assert.match(source, new RegExp(`${field}: string`))
assert.match(source, /destinationWarehouseId: string \| null/)
for (const field of ['inboundCode', 'orderNo', 'customerName', 'destinationWarehouseCode', 'destinationWarehouseName']) {
assert.match(source, new RegExp(`${field}: string`))
}
})
@@ -0,0 +1,22 @@
import { apiRequest } from '@/api/http'
export interface MerchantInboundCodeAssociation {
inboundCode: string
orderNo: string
orderStatus: string
orderId: string
customerId: string
customerName: string
destinationType: string
destinationCountryCode: string | null
destinationWarehouseId: string | null
destinationWarehouseCode: string | null
destinationWarehouseName: string | null
}
export function getMerchantInboundCodeAssociation(inboundCode: string) {
return apiRequest<MerchantInboundCodeAssociation>({
method: 'GET',
url: `/merchant/v1/orders/first-leg/inbound-code-associations/${encodeURIComponent(inboundCode.trim())}`,
})
}
@@ -0,0 +1,341 @@
<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Download, FileText, LockKeyhole, Pencil, Trash2, Upload } from '@lucide/vue'
import {
MERCHANT_CUSTOMS_STATUS_LABELS,
deleteMerchantCustomsDocument,
downloadMerchantCustomsCompletion,
downloadMerchantCustomsSource,
getMerchantCustomsForOrder,
merchantCustomsErrorMessage,
replaceMerchantCustomsDocument,
updateMerchantCustomsDocumentInbounds,
uploadMerchantCustomsDocument,
validateMerchantCustomsFile,
type MerchantCustomsContainer,
type MerchantCustomsDocument,
} from '@/modules/first-leg/api/customs'
const props = defineProps<{
modelValue: boolean
orderId: string
canEdit: boolean
initialDetail?: MerchantCustomsContainer | null
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
updated: [value: MerchantCustomsContainer]
}>()
const detail = ref<MerchantCustomsContainer | null>(null)
const loading = ref(false)
const errorMessage = ref('')
const selectedInboundIds = ref<string[]>([])
const editingDocument = ref<MerchantCustomsDocument | null>(null)
const editingInboundIds = ref<string[]>([])
const replacingDocument = ref<MerchantCustomsDocument | null>(null)
const uploadInput = ref<HTMLInputElement>()
const replaceInput = ref<HTMLInputElement>()
const mutating = ref(false)
const downloadingKey = ref('')
const taxRefundInbounds = computed(() => detail.value?.inbounds.filter((item) => item.taxMode === 'TAX_REFUND') ?? [])
const locked = computed(() => Boolean(detail.value?.startedAt) || detail.value?.status === 'DECLARING' || detail.value?.status === 'COMPLETED')
const merchantStatusLabel = computed(() => detail.value?.merchantStatus
? MERCHANT_CUSTOMS_STATUS_LABELS[detail.value.merchantStatus]
: '待上传资料')
const statusTagType = computed(() => ({
WAITING_UPLOAD: 'warning',
DOCUMENT_UPLOADED: 'success',
DECLARING: 'primary',
CUSTOMS_COMPLETED: 'success',
} as const)[detail.value?.merchantStatus ?? 'WAITING_UPLOAD'])
function initializeSelection(value: MerchantCustomsContainer) {
const currentOrderIds = value.inbounds
.filter((item) => item.taxMode === 'TAX_REFUND' && item.orderId === props.orderId)
.map((item) => item.inboundId)
selectedInboundIds.value = currentOrderIds
}
async function loadDetail() {
if (!props.orderId) return
loading.value = true
errorMessage.value = ''
try {
const result = await getMerchantCustomsForOrder(props.orderId)
detail.value = result
initializeSelection(result)
emit('updated', result)
} catch (error) {
detail.value = null
errorMessage.value = merchantCustomsErrorMessage(error, '退税报关资料加载失败')
} finally {
loading.value = false
}
}
watch(() => props.modelValue, (open) => {
if (!open) return
detail.value = props.initialDetail ?? null
if (detail.value) initializeSelection(detail.value)
void loadDetail()
})
function close() {
emit('update:modelValue', false)
}
function chooseUpload() {
if (!selectedInboundIds.value.length) {
ElMessage.warning('请先选择至少一个退税入仓码')
return
}
uploadInput.value?.click()
}
async function handleUploadFile(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
input.value = ''
if (!file || !detail.value) return
try {
validateMerchantCustomsFile(file)
mutating.value = true
await uploadMerchantCustomsDocument(detail.value.loadingPlanId, selectedInboundIds.value, file)
ElMessage.success('资料上传成功')
await loadDetail()
} catch (error) {
ElMessage.error(merchantCustomsErrorMessage(error, '资料上传失败'))
} finally {
mutating.value = false
}
}
function beginEditInbounds(document: MerchantCustomsDocument) {
editingDocument.value = document
editingInboundIds.value = [...document.inboundIds]
}
async function saveInbounds() {
if (!editingDocument.value || !editingInboundIds.value.length) {
ElMessage.warning('请至少保留一个关联入仓码')
return
}
try {
mutating.value = true
await updateMerchantCustomsDocumentInbounds(editingDocument.value, editingInboundIds.value)
editingDocument.value = null
ElMessage.success('关联入仓码已更新')
await loadDetail()
} catch (error) {
ElMessage.error(merchantCustomsErrorMessage(error, '关联入仓码更新失败'))
} finally {
mutating.value = false
}
}
async function chooseReplacement(document: MerchantCustomsDocument) {
replacingDocument.value = document
editingInboundIds.value = [...document.inboundIds]
await nextTick()
replaceInput.value?.click()
}
async function handleReplacementFile(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
input.value = ''
const document = replacingDocument.value
replacingDocument.value = null
if (!file || !document) return
try {
validateMerchantCustomsFile(file)
mutating.value = true
await replaceMerchantCustomsDocument(document, editingInboundIds.value, file)
ElMessage.success('资料已覆盖并生成新版本')
await loadDetail()
} catch (error) {
ElMessage.error(merchantCustomsErrorMessage(error, '资料覆盖失败'))
} finally {
mutating.value = false
}
}
async function removeDocument(document: MerchantCustomsDocument) {
try {
await ElMessageBox.confirm(`确认删除误传资料“${document.source.fileName}”吗?历史版本仍会由系统保留。`, '删除资料', {
type: 'warning',
confirmButtonText: '确认删除',
cancelButtonText: '取消',
})
mutating.value = true
await deleteMerchantCustomsDocument(document)
ElMessage.success('资料已删除')
await loadDetail()
} catch (error) {
if (error === 'cancel' || error === 'close') return
ElMessage.error(merchantCustomsErrorMessage(error, '资料删除失败'))
} finally {
mutating.value = false
}
}
function saveDownload(blob: Blob, fileName: string) {
const url = URL.createObjectURL(blob)
const anchor = document.createElement('a')
anchor.href = url
anchor.download = fileName
anchor.click()
URL.revokeObjectURL(url)
}
async function downloadFile(document: MerchantCustomsDocument, kind: 'source' | 'completion') {
const key = `${document.documentId}:${kind}`
downloadingKey.value = key
try {
const result = kind === 'source'
? await downloadMerchantCustomsSource(document)
: await downloadMerchantCustomsCompletion(document)
saveDownload(result.blob, result.fileName)
} catch (error) {
ElMessage.error(merchantCustomsErrorMessage(error, '文件下载失败'))
} finally {
if (downloadingKey.value === key) downloadingKey.value = ''
}
}
function formatDateTime(value: string | null | undefined) {
if (!value) return '-'
const date = new Date(value)
return Number.isNaN(date.getTime()) ? value : date.toLocaleString('zh-CN', { hour12: false })
}
function formatSize(bytes: number) {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / 1024 / 1024).toFixed(1)} MB`
}
</script>
<template>
<el-dialog
:model-value="modelValue"
class="customs-documents-dialog"
title="退税报关资料"
width="920px"
append-to-body
destroy-on-close
@close="close"
>
<el-skeleton v-if="loading && !detail" :rows="6" animated />
<div v-else-if="errorMessage" class="customs-error" role="alert">
<strong>退税报关资料加载失败</strong>
<span>{{ errorMessage }}</span>
<el-button :loading="loading" @click="loadDetail">重新加载</el-button>
</div>
<template v-else-if="detail">
<header class="customs-summary">
<div>
<span>柜次</span>
<strong>{{ detail.containerNo || detail.cabinetNo || detail.planNo || '—' }}</strong>
</div>
<div>
<span>资料覆盖</span>
<strong>{{ detail.coveredTaxRefundCount }} / {{ detail.taxRefundCount }} 票</strong>
</div>
<el-tag :type="statusTagType" effect="light">{{ merchantStatusLabel }}</el-tag>
</header>
<div v-if="locked" class="customs-locked" role="status">
<LockKeyhole :size="18" />
<div><strong>报关资料已锁定</strong><span>报关开始后仅可查看和下载,不提供退回或解锁。</span></div>
</div>
<section class="customs-section">
<div class="customs-section-title">
<div><h3>上传资料</h3><p>支持任意格式,单个文件不超过 50MB;可关联同柜内当前企业的一票或多票退税入仓码。</p></div>
<el-button v-if="canEdit && !locked" type="primary" :loading="mutating" @click="chooseUpload"><Upload :size="16" />选择文件上传</el-button>
</div>
<el-checkbox-group v-model="selectedInboundIds" class="customs-inbound-options" :disabled="!canEdit || locked" aria-label="选择资料关联入仓码">
<el-checkbox v-for="item in taxRefundInbounds" :key="item.inboundId" :value="item.inboundId">
<span class="customs-inbound-code">{{ item.inboundCode }}</span>
<!-- <small>{{ item.orderNo }}</small> -->
</el-checkbox>
</el-checkbox-group>
<input ref="uploadInput" class="customs-hidden-input" type="file" @change="handleUploadFile">
<input ref="replaceInput" class="customs-hidden-input" type="file" @change="handleReplacementFile">
</section>
<section class="customs-section">
<div class="customs-section-title">
<div><h3>资料版本历史</h3><p>每份当前资料展示服务端版本、关联票和上传时间;覆盖及删除操作由服务端保留不可变历史。</p></div>
</div>
<el-empty v-if="!detail.documents.length" description="尚未上传退税报关资料" :image-size="54" />
<div v-else class="customs-document-list">
<article v-for="item in detail.documents" :key="item.documentId" class="customs-document-card">
<div class="customs-document-main">
<FileText :size="20" />
<div>
<strong>{{ item.source.fileName }}</strong>
<span>版本 V{{ item.source.version }} · {{ formatSize(item.source.sizeBytes) }} · {{ formatDateTime(item.source.uploadedAt) }}</span>
<small>关联入仓码:{{ item.inboundCodes.join('、') || '—' }}</small>
</div>
</div>
<div class="customs-document-actions">
<el-button link type="primary" :loading="downloadingKey === `${item.documentId}:source`" @click="downloadFile(item, 'source')"><Download :size="15" />源文件</el-button>
<el-button v-if="detail.status === 'COMPLETED' && item.completion" link type="success" :loading="downloadingKey === `${item.documentId}:completion`" @click="downloadFile(item, 'completion')"><Download :size="15" />完成文件</el-button>
<template v-if="canEdit && !locked && !item.frozen">
<el-button link type="primary" :disabled="mutating" @click="beginEditInbounds(item)"><Pencil :size="15" />改关联</el-button>
<el-button link type="primary" :disabled="mutating" @click="chooseReplacement(item)"><Upload :size="15" />覆盖</el-button>
<el-button link type="danger" :disabled="mutating" @click="removeDocument(item)"><Trash2 :size="15" />删除</el-button>
</template>
</div>
<div v-if="editingDocument?.documentId === item.documentId" class="customs-scope-editor">
<el-checkbox-group v-model="editingInboundIds" aria-label="修改资料关联入仓码">
<el-checkbox v-for="inbound in taxRefundInbounds" :key="inbound.inboundId" :value="inbound.inboundId">{{ inbound.inboundCode }}</el-checkbox>
</el-checkbox-group>
<div><el-button @click="editingDocument = null">取消</el-button><el-button type="primary" :loading="mutating" @click="saveInbounds">保存关联</el-button></div>
</div>
</article>
</div>
</section>
</template>
<template #footer><el-button @click="close">关闭</el-button></template>
</el-dialog>
</template>
<style scoped>
.customs-error { display: grid; gap: 10px; justify-items: start; padding: 22px; border: 1px solid #f1b8b8; background: #fff7f7; color: #9f2d2d; }
.customs-error span { overflow-wrap: anywhere; font-size: 12px; }
.customs-summary { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)) auto; align-items: center; gap: 18px; padding: 16px 18px; border: 1px solid var(--border); background: #f8faf9; }
.customs-summary div { min-width: 0; }
.customs-summary span { display: block; color: var(--muted); font-size: 11px; }
.customs-summary strong { display: block; margin-top: 5px; overflow-wrap: anywhere; color: var(--navy); font-size: 14px; }
.customs-locked { display: flex; align-items: flex-start; gap: 10px; margin-top: 14px; padding: 13px 15px; border: 1px solid #d5dde1; background: #f5f7f8; color: var(--muted); }
.customs-locked strong, .customs-locked span { display: block; }
.customs-locked strong { color: var(--navy); font-size: 12px; }
.customs-locked span { margin-top: 3px; font-size: 11px; }
.customs-section { margin-top: 18px; }
.customs-section-title { display: flex; align-items: flex-start; justify-content: space-between; gap: 18px; margin-bottom: 12px; }
.customs-section-title h3 { margin: 0; color: var(--navy); font-size: 14px; }
.customs-section-title p { margin: 5px 0 0; color: var(--muted); font-size: 11px; line-height: 1.55; }
.customs-inbound-options { display: flex; flex-wrap: wrap; gap: 8px 18px; padding: 13px 15px; border: 1px solid var(--border); }
.customs-inbound-options :deep(.el-checkbox) { margin-right: 0; }
.customs-inbound-code { font-family: ui-monospace, SFMono-Regular, Consolas, monospace; }
.customs-inbound-options small { margin-left: 7px; color: var(--muted); }
.customs-hidden-input { display: none; }
.customs-document-list { display: grid; gap: 10px; }
.customs-document-card { padding: 14px 16px; border: 1px solid var(--border); }
.customs-document-main { display: flex; align-items: flex-start; gap: 11px; min-width: 0; }
.customs-document-main > svg { flex: 0 0 20px; color: var(--primary); }
.customs-document-main > div { min-width: 0; }
.customs-document-main strong, .customs-document-main span, .customs-document-main small { display: block; overflow-wrap: anywhere; }
.customs-document-main strong { color: var(--navy); font-size: 13px; }
.customs-document-main span, .customs-document-main small { margin-top: 4px; color: var(--muted); font-size: 11px; }
.customs-document-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 2px; margin-top: 8px; }
.customs-scope-editor { display: flex; align-items: flex-end; justify-content: space-between; gap: 16px; margin-top: 12px; padding-top: 12px; border-top: 1px dashed var(--border); }
.customs-scope-editor :deep(.el-checkbox) { margin-right: 16px; }
</style>
@@ -19,7 +19,7 @@ test('shows the confirmed merchant inbound and fee fields without write actions'
assert.match(source, new RegExp(label))
}
assert.match(source, /formatInboundMoney\(props\.data\.latestFee\.totalAmount/)
assert.match(source, /getRoundingModeLabel\(props\.data\.latestFee\.roundingMode\)/)
assert.match(source, /formatConversionPrecision\(/)
assert.doesNotMatch(source, /确认费用|调整费用|发送费用|下载照片/)
assert.doesNotMatch(source, /internalNotes|statusHistory|files/)
assert.match(source, /latestFee\.ruleCode/)
@@ -2,12 +2,12 @@
import { Clock3, PackageCheck, RefreshCw, TriangleAlert } from '@lucide/vue'
import type { MerchantInboundBilling } from '@/modules/first-leg/types/inboundBilling'
import {
formatConversionPrecision,
formatInboundDateTime,
formatInboundMeasure,
formatInboundMoney,
formatInboundNumber,
getChargeModeLabel,
getRoundingModeLabel,
getTaxModeLabel,
} from '@/modules/first-leg/utils/inboundBilling'
@@ -109,10 +109,11 @@ defineEmits<{
<div><dt>计费方式</dt><dd>{{ getChargeModeLabel(props.data.latestFee.chargeMode) }} · {{ getTaxModeLabel(props.data.latestFee.taxMode) }}</dd></div>
<div>
<dt>换算与精度</dt>
<dd>
系数 {{ formatInboundNumber(props.data.latestFee.conversionFactor) }} ·
保留 {{ props.data.latestFee.roundingScale }} 位 {{ getRoundingModeLabel(props.data.latestFee.roundingMode) }}
</dd>
<dd>{{ formatConversionPrecision(
props.data.latestFee.conversionFactor,
props.data.latestFee.roundingScale,
props.data.latestFee.roundingMode,
) }}</dd>
</div>
<div><dt>实际重量</dt><dd>{{ formatInboundMeasure(props.data.latestFee.actualWeightKg, 'kg') }}</dd></div>
<div><dt>实际体积</dt><dd>{{ formatInboundMeasure(props.data.latestFee.actualVolumeCbm, 'm³') }}</dd></div>
@@ -0,0 +1,32 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
const source = readFileSync(new URL('./InboundCodeAssociationDialog.vue', import.meta.url), 'utf8')
test('loads only the customer-scoped server association and fails closed', () => {
assert.match(source, /getMerchantInboundCodeAssociation\(inboundCode\)/)
assert.match(source, /association\.value = null/)
assert.match(source, /关联摘要加载失败/)
assert.match(source, /@click="loadAssociation"/)
assert.doesNotMatch(source, /orderId\)|customerId\)|destinationWarehouseId\)/)
})
test('renders business identifiers and names without internal long identifiers', () => {
for (const label of ['入仓码', '订单号', '订单状态', '客户名称', '目的国家', '目的仓']) {
assert.match(source, new RegExp(label))
}
assert.match(source, /association\.inboundCode/)
assert.match(source, /association\.orderNo/)
assert.match(source, /association\.customerName/)
assert.match(source, /destinationWarehouseCode/)
assert.match(source, /destinationWarehouseName/)
assert.doesNotMatch(source, /association\.(?:orderId|customerId|destinationWarehouseId)/)
assert.match(source, /\|\| '—'/)
})
test('uses a bounded centered summary dialog suitable for the desktop detail flow', () => {
assert.match(source, /<el-dialog[^>]*width="620px"/)
assert.match(source, /grid-template-columns: repeat\(2, minmax\(0, 1fr\)\)/)
assert.match(source, /min-height: 190px/)
})
@@ -0,0 +1,115 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { AlertTriangle, Link2, RefreshCw } from '@lucide/vue'
import { FIRST_LEG_ORDER_STATUSES } from '@chongchong/shared'
import {
getMerchantInboundCodeAssociation,
type MerchantInboundCodeAssociation,
} from '@/modules/first-leg/api/inboundCodeAssociations'
const props = defineProps<{
modelValue: boolean
inboundCode: string
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
}>()
const loading = ref(false)
const error = ref('')
const association = ref<MerchantInboundCodeAssociation | null>(null)
let requestSequence = 0
const visible = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value),
})
function displayValue(value: string | null | undefined) {
return value?.trim() || '—'
}
function statusLabel(value: string) {
return FIRST_LEG_ORDER_STATUSES.find((item) => item.code === value)?.label ?? displayValue(value)
}
function destinationLabel(value: MerchantInboundCodeAssociation) {
return [value.destinationWarehouseCode, value.destinationWarehouseName].filter(Boolean).join(' · ') || '—'
}
async function loadAssociation() {
const inboundCode = props.inboundCode.trim()
const sequence = ++requestSequence
association.value = null
error.value = ''
loading.value = false
if (!inboundCode) {
error.value = '当前订单暂无可查询的入仓码'
return
}
loading.value = true
try {
const result = await getMerchantInboundCodeAssociation(inboundCode)
if (sequence === requestSequence && props.modelValue) association.value = result
} catch (reason) {
if (sequence === requestSequence && props.modelValue) {
error.value = reason instanceof Error ? reason.message : '入仓码关联查询失败'
}
} finally {
if (sequence === requestSequence) loading.value = false
}
}
watch(() => [props.modelValue, props.inboundCode] as const, ([open]) => {
if (open) void loadAssociation()
else {
requestSequence += 1
loading.value = false
error.value = ''
association.value = null
}
}, { immediate: true })
</script>
<template>
<el-dialog v-model="visible" class="inbound-association-dialog" title="入仓码关联摘要" width="620px" append-to-body destroy-on-close>
<div v-loading="loading" class="inbound-association-content">
<div v-if="error" class="inbound-association-error" role="alert">
<AlertTriangle :size="22" />
<div><strong>关联摘要加载失败</strong><span>{{ error }}</span></div>
<el-button :loading="loading" @click="loadAssociation"><RefreshCw :size="15" />重新加载</el-button>
</div>
<template v-else-if="association">
<header class="inbound-association-heading">
<Link2 :size="21" />
<div><span>入仓码</span><strong>{{ displayValue(association.inboundCode) }}</strong></div>
</header>
<dl class="inbound-association-grid">
<div><dt>订单号</dt><dd>{{ displayValue(association.orderNo) }}</dd></div>
<div><dt>订单状态</dt><dd>{{ statusLabel(association.orderStatus) }}</dd></div>
<div><dt>客户名称</dt><dd>{{ displayValue(association.customerName) }}</dd></div>
<div><dt>目的国家</dt><dd>{{ displayValue(association.destinationCountryCode) }}</dd></div>
<div class="is-wide"><dt>目的仓</dt><dd>{{ destinationLabel(association) }}</dd></div>
</dl>
</template>
</div>
<template #footer><el-button @click="visible = false">关闭</el-button></template>
</el-dialog>
</template>
<style scoped>
.inbound-association-content { min-height: 190px; }
.inbound-association-error { display: grid; min-height: 170px; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 13px; padding: 20px; border: 1px solid #efc1bc; background: #fff8f7; color: #a33b32; }
.inbound-association-error > div { display: flex; min-width: 0; flex-direction: column; gap: 4px; }
.inbound-association-error span { overflow-wrap: anywhere; font-size: 12px; }
.inbound-association-heading { display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: center; gap: 12px; padding: 15px 16px; background: #f2f7f6; color: var(--primary); }
.inbound-association-heading > div { display: flex; min-width: 0; flex-direction: column; gap: 3px; }
.inbound-association-heading span { color: var(--muted); font-size: 11px; }
.inbound-association-heading strong { overflow-wrap: anywhere; color: var(--navy); font-size: 16px; }
.inbound-association-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); margin: 14px 0 0; border-top: 1px solid var(--border); border-left: 1px solid var(--border); }
.inbound-association-grid > div { min-width: 0; padding: 13px 14px; border-right: 1px solid var(--border); border-bottom: 1px solid var(--border); }
.inbound-association-grid .is-wide { grid-column: 1 / -1; }
.inbound-association-grid dt { color: var(--muted); font-size: 11px; }
.inbound-association-grid dd { margin: 5px 0 0; overflow-wrap: anywhere; color: var(--navy); font-size: 13px; font-weight: 650; }
</style>
@@ -12,13 +12,14 @@ test('maps the first-leg aggregate list fields without loading detail rows', ()
assert.match(apiSource, /cargoes\?: Array<\{ skuNo\?: string \| null \}>/)
assert.match(apiSource, /apiRequest<PageResult<MerchantFirstLegOrderRow>>/)
assert.match(storeSource, /function mapFirstLegOrderRow/)
for (const field of ['inboundId', 'inboundCode', 'channelName', 'destinationWarehouseName', 'packageCount', 'expressCompany', 'trackingNo', 'totalGrossWeightKg', 'totalNetWeightKg', 'totalVolumeCbm', 'totalQuantity', 'remark', 'shipmentId', 'referenceId', 'createdAt']) {
for (const field of ['inboundId', 'inboundCode', 'channelName', 'destinationWarehouseName', 'pickupMethod', 'packageCount', 'expressCompany', 'trackingNo', 'totalGrossWeightKg', 'totalNetWeightKg', 'totalVolumeCbm', 'totalQuantity', 'remark', 'shipmentId', 'referenceId', 'createdAt']) {
assert.match(storeSource, new RegExp(`row\\.${field}`))
}
assert.match(storeSource, /row\.skuNos/)
assert.match(storeSource, /row\.cargoes/)
assert.match(storeSource, /chargeableWeightKg: null/)
assert.match(storeSource, /chargeableVolumeCbm: null/)
assert.match(apiSource, /pickupMethod: ApiPickupMethod \\| null/)
assert.match(storeSource, /orderResult\.value\.records\.map\(mapFirstLegOrderRow\)/)
assert.match(storeSource, /orderResult\.value\.records\.map\(\(item\) => mapUnifiedOrderRow\(item, warehouses\.value\)\)/)
})
@@ -398,7 +398,7 @@ function mapFirstLegOrderRow(row: MerchantFirstLegOrderRow): FirstLegOrder {
inboundId: row.inboundId == null ? '' : String(row.inboundId), inboundCode: row.inboundCode ?? '',
},
createdAt: row.createdAt, updatedAt: row.createdAt, tradeTerm: '', destinationCountryCode: '', destinationWarehouseId: '',
destinationWarehouseName: row.destinationWarehouseName ?? '', pickupMethod: 'SELF_EXPRESS', channelCode: '',
destinationWarehouseName: row.destinationWarehouseName ?? '', pickupMethod: row.pickupMethod ?? 'SELF_EXPRESS', channelCode: '',
channelName: row.channelName ?? '', pricingTaxMode: 'TAX_INCLUDED', carrierId: '', carrierName: '', taxMode: 'BUY_DECLARATION',
customsInspection: false, destinationType: row.destinationType ?? 'PRIVATE', amazonWarehouseCode: '',
shipmentId: row.shipmentId ?? row.internationalOrderNo ?? '', requestedPickupAt: '', receivingTime: '', submittedAt: '', completedAt: '',
@@ -79,9 +79,9 @@ export interface MerchantInboundFeeSnapshot {
destinationCode: string
taxMode: string
chargeMode: 'KG' | 'CBM'
conversionFactor: number
roundingScale: number
roundingMode: string
conversionFactor: number | null
roundingScale: number | null
roundingMode: string | null
actualWeightKg: number
actualVolumeCbm: number
convertedQuantity: number
@@ -108,7 +108,7 @@ test('only requires sender for pickup and receiver for a private destination', (
assert.ok(validateFirstLegOrderDraft(privateDraft).includes('请填写收件人姓名'))
})
test('requires FBA warehouse and cargo-level shipment, reference, SKU and UPC fields', () => {
test('requires FBA warehouse and cargo-level shipment, reference and SKU while keeping UPC optional', () => {
const draft = validDraft()
draft.referenceNo = ''
draft.amazonWarehouseCode = ''
@@ -121,7 +121,7 @@ test('requires FBA warehouse and cargo-level shipment, reference, SKU and UPC fi
const errors = validateFirstLegOrderDraft(draft)
assert.ok(errors.includes('FBA 订单请填写 Amazon 仓库编码'))
assert.ok(errors.includes('第 1 条货物缺少客户 SKU'))
assert.ok(errors.includes('第 1 条货物缺少 UPC'))
assert.ok(!errors.includes('第 1 条货物缺少 UPC'))
assert.ok(errors.includes('第 1 条货物缺少货件编号'))
assert.ok(errors.includes('第 1 条货物缺少跟踪编号'))
})
@@ -122,7 +122,6 @@ export function validateFirstLegOrderDraft(draft: FirstLegOrderDraft) {
if (!item.cargoNameCn.trim()) errors.push(`${line}缺少中文品名`)
if (!item.packageType.trim()) errors.push(`${line}缺少包装类型`)
if (draft.destinationType === 'FBA' && !item.sku.trim()) errors.push(`${line}缺少客户 SKU`)
if (draft.destinationType === 'FBA' && !item.upc.trim()) errors.push(`${line}缺少 UPC`)
if (draft.destinationType === 'FBA' && !item.shipmentId.trim()) errors.push(`${line}缺少货件编号`)
if (draft.destinationType === 'FBA' && !item.referenceId.trim()) errors.push(`${line}缺少跟踪编号`)
if (!Number.isInteger(item.pieces) || item.pieces <= 0) errors.push(`${line}件数必须为正整数`)
@@ -16,5 +16,12 @@ test('translates backend rounding modes into customer-facing calculation labels'
assert.match(source, /export function getRoundingModeLabel/)
assert.match(source, /CEILING: '向上取整'/)
assert.match(source, /HALF_UP: '四舍五入'/)
assert.match(source, /return labels\[value\] \|\| value \|\| '-'/)
assert.match(source, /return value \? labels\[value\] \|\| value : '-'/)
})
test('renders conversion metadata as one complete label or a single empty marker', () => {
assert.match(source, /export function formatConversionPrecision/)
assert.match(source, /roundingScale === null \|\| roundingScale === undefined/)
assert.match(source, /\|\| !roundingMode\) return '-'/)
assert.match(source, /`系数 \$\{formatInboundNumber\(conversionFactor\)\} · 保留 \$\{roundingScale\} 位 \$\{getRoundingModeLabel\(roundingMode\)\}`/)
})
+13 -2
View File
@@ -46,7 +46,7 @@ export function getTaxModeLabel(value: string) {
return value || '-'
}
export function getRoundingModeLabel(value: string) {
export function getRoundingModeLabel(value: string | null | undefined) {
const labels: Record<string, string> = {
UP: '向上舍入',
DOWN: '向下舍入',
@@ -56,5 +56,16 @@ export function getRoundingModeLabel(value: string) {
HALF_DOWN: '五舍六入',
HALF_EVEN: '银行家舍入',
}
return labels[value] || value || '-'
return value ? labels[value] || value : '-'
}
export function formatConversionPrecision(
conversionFactor: number | null | undefined,
roundingScale: number | null | undefined,
roundingMode: string | null | undefined,
) {
if (conversionFactor === null || conversionFactor === undefined
|| roundingScale === null || roundingScale === undefined
|| !roundingMode) return '-'
return `系数 ${formatInboundNumber(conversionFactor)} · 保留 ${roundingScale} 位 ${getRoundingModeLabel(roundingMode)}`
}
@@ -6,6 +6,8 @@ const source = readFileSync(new URL('./FirstLegOrdersView.vue', import.meta.url)
const routerSource = readFileSync(new URL('../routes.ts', import.meta.url), 'utf8')
const editorShellSource = readFileSync(new URL('../components/FirstLegOrderEditorShell.vue', import.meta.url), 'utf8')
const progressSource = readFileSync(new URL('../components/OrderProgressPanel.vue', import.meta.url), 'utf8')
const customsDialogSource = readFileSync(new URL('../components/CustomsDocumentsDialog.vue', import.meta.url), 'utf8')
const customsApiSource = readFileSync(new URL('../api/customs.ts', import.meta.url), 'utf8')
test('routes the confirmed first-leg path to the implemented order view', () => {
assert.match(routerSource, /path: 'orders\/first-leg'[\s\S]*?component: \(\) => import\('\.\/views\/FirstLegOrdersView\.vue'\)[\s\S]*?release: 'active'/)
@@ -32,7 +34,7 @@ test('exposes real list, draft, submit, and detail workflow entries without canc
assert.match(source, /function submitOrder\(\)/)
assert.match(source, /async function openDetail\(order: FirstLegOrder\)/)
assert.match(source, /Promise\.allSettled\(\[/)
assert.match(source, /const orderPageRequest = isCreatePage\.value\s*\? store\.loadWarehouseType\('DOMESTIC', true\)\s*:\s*store\.load\(\)/)
assert.match(source, /const orderPageRequest = isCreatePage\.value\s*\? store\.loadWarehouseType\('DOMESTIC', true\)\s*:\s*store\.load\(\{ includeDrafts: true \}\)/)
assert.match(source, /Promise\.allSettled\(\[\s*orderPageRequest,\s*companyRequest/)
assert.match(source, /tenantStore\.loadCompanyContext\(\)/)
assert.match(source, /await store\.loadDetail\(order\.id\)/)
@@ -56,7 +58,7 @@ test('defers approval checks to the server when the member cannot read the compa
assert.match(source, /const canViewCompanyProfile = computed/)
assert.match(source, /const companySubmissionBlocked = computed\(\(\) => companyAuditLoaded\.value/)
assert.match(source, /const knownCompanyAuditStatus = companyAuditLoaded\.value \? tenantStore\.company\.auditStatus : undefined/)
assert.match(source, /store\.submit\([^\n]*knownCompanyAuditStatus\)/)
assert.match(source, /store\.submitSaved\(savedOrder\.id, knownCompanyAuditStatus\)/)
})
test('renders dynamic cargo lines and a responsive editor dialog', () => {
@@ -89,7 +91,7 @@ test('uses the shared query toolbar and data table without metric cards', () =>
assert.match(source, /async function searchOrders\(model: QueryModel\)/)
assert.match(source, /await store\.load\(\{/)
assert.match(source, /<DataTable v-model:page="tablePage" v-model:page-size="tablePageSize" :data="pagedOrders" :columns="visibleOrderColumns"/)
assert.match(source, /\{ key: 'actions', label: '操作', width: 128, fixed: 'left', align: 'center', headerAlign: 'center', slotName: 'actions' \}/)
assert.match(source, /\{ key: 'actions', label: '操作', width: 216, fixed: 'left', align: 'center', headerAlign: 'center', slotName: 'actions' \}/)
assert.doesNotMatch(source, /order-metrics|订单概览/)
})
@@ -198,7 +200,7 @@ test('shows one unified progress line and C02 business summaries without detail
assert.match(progressSource, /FIRST_LEG_PROGRESS_STAGES/)
assert.match(progressSource, /国内装柜进度及后续履约同步/)
assert.doesNotMatch(progressSource, /进度记录|el-timeline|timeline/)
assert.match(source, /报关回执/)
assert.doesNotMatch(source, /<h3>报关回执<\/h3>/)
assert.match(source, /船期摘要/)
assert.match(source, /柜次进度/)
assert.doesNotMatch(source, /summary\.sealNo \|\|/)
@@ -221,9 +223,10 @@ test('uses only fields present on C02 rows before loading full detail', () => {
test('shows and searches the inbound code after a draft is submitted', () => {
assert.match(source, /label: '入仓码 \/ SKU'/)
assert.match(source, /order\.businessSummary\.inboundCode/)
assert.match(source, /<template #inboundCode="\{ row \}"><span class="table-ellipsis table-code"[^>]*>\{\{ displayListValue\(\(row as FirstLegOrder\)\.businessSummary\.inboundCode\) \}\}<\/span><\/template>/)
assert.match(source, /v-if="order\.businessSummary\.inboundCode"[^>]*>入仓码/)
assert.match(source, /v-if="detailOrder\.businessSummary\.inboundCode"><dt>入仓码<\/dt>/)
assert.match(source, /<template #inboundCode="\{ row \}">[\s\S]*?class="inbound-code-link table-ellipsis table-code"/)
assert.match(source, /v-if="order\.businessSummary\.inboundCode" class="order-reference inbound-code-link"/)
assert.match(source, /<dt>入仓码<\/dt><dd><button v-if="detailOrder\.businessSummary\.inboundCode"/)
assert.match(source, /<span v-else>—<\/span>/)
})
test('keeps the seven-stage progress and business summary usable on mobile', () => {
@@ -233,10 +236,12 @@ test('keeps the seven-stage progress and business summary usable on mobile', ()
})
test('keeps the first viewport compact and moves create into the table toolbar', () => {
const queryToolbarSource = source.slice(source.indexOf('<QueryToolbar'), source.indexOf('</QueryToolbar>') + '</QueryToolbar>'.length)
assert.match(source, /class="order-table-toolbar"/)
assert.match(source, /<strong>订单列表<\/strong><span>共 \{\{ filteredOrders\.length \}\} 条<\/span>/)
assert.match(source, /class="order-table-toolbar"[\s\S]*order:first-leg:create[\s\S]*创建订单[\s\S]*class="order-eligibility is-pending"/)
assert.doesNotMatch(source, /<QueryToolbar[\s\S]*?<template #actions>/)
assert.match(queryToolbarSource, /<template #actions>[\s\S]*下载唛头[\s\S]*导出订单信息/)
assert.doesNotMatch(queryToolbarSource, /order:first-leg:create|创建订单/)
assert.match(source, /client-main:has\(> \.first-leg-page:not\(\.is-create-route\)\) > \.client-page-heading > div\) \{ position: absolute; width: 1px; height: 1px;/)
})
@@ -316,6 +321,7 @@ test('collects the backend-required first-leg fields', () => {
})
test('distills order creation into the confirmed prototype sections with server pricing', () => {
const cargoTableSource = source.slice(source.indexOf('<table class="cargo-edit-table">'), source.indexOf('</table>', source.indexOf('<table class="cargo-edit-table">')))
assert.match(source, /<h3>目的地信息<\/h3>/)
assert.match(source, /<h3>客户下单<\/h3>/)
assert.match(source, /<h3>货物明细<\/h3>/)
@@ -323,8 +329,10 @@ test('distills order creation into the confirmed prototype sections with server
assert.ok(source.indexOf('<h3>目的地信息</h3>') < source.indexOf('<h3>货物明细</h3>'))
assert.match(source, /class="segmented-options"/)
assert.match(source, /class="prototype-form-row prototype-form-row--tabs"/)
for (const heading of ['商品信息', '箱规、数量与重量', '报关与产品资料', '货件与备注']) {
assert.match(source, new RegExp(`<h4>${heading}</h4>`))
assert.match(source, /<table class="cargo-edit-table">/)
assert.match(source, /上传 Excel[\s\S]*下载模板[\s\S]*添加行[\s\S]*删除所选行/)
for (const heading of ['SKU NO.', '中文品名', '箱规长 cm', '总毛重', '报关 HS CODE', '货件编号', '跟踪编号', 'UPC NO.']) {
assert.ok(cargoTableSource.includes(heading))
}
assert.match(source, /参考运费/)
assert.match(source, /仅供参考,最终以实际结算为准/)
@@ -386,8 +394,11 @@ test('shows field-level validation and concise guidance for required order input
assert.match(source, /prop="consigneePartySnapshot\.address" required/)
assert.match(source, /:prop="`items\.\$\{index\}\.cargoNameCn`" required/)
assert.match(source, /:prop="`items\.\$\{index\}\.grossWeightKg`" required/)
assert.match(source, /label="UPC NO\."[^>]*:required="form\.destinationType === 'FBA'"[^>]*>\s*<el-input v-model="item\.upc"/)
assert.match(source, /placeholder="例如:蓝牙音箱"/)
assert.match(source, /:prop="`items\.\$\{index\}\.upc`">\s*<el-input v-model="item\.upc"[^>]*placeholder="UPC NO\.(选填)"/)
assert.doesNotMatch(source, /:prop="`items\.\$\{index\}\.upc`"[^>]*(?:required|:rules)/)
assert.match(source, /UPC NO\. 选填/)
assert.doesNotMatch(source, /UPC必填/)
assert.match(source, /v-model="item\.cargoNameCn"[^>]*placeholder="中文品名"/)
assert.match(source, /\.order-form :deep\(\.el-form-item__error\).*position: static/)
})
@@ -418,8 +429,8 @@ test('shows the full customer-safe order detail without platform-only controls',
for (const field of [
'贸易条款', '发货方式', '报关方式', '运输渠道', '收货要求', '目的仓类型', '海关查验',
'提交时间', '完成时间', 'SKU NO.', 'UPC NO.', '报关 HS CODE', '清关 HS CODE', '每箱数量',
'总净重', '清关申报单价', '中英文材质', '中英文用途', '产品规格', 'Shipment ID',
'Reference ID', '危险品',
'总净重', '清关申报单价', '中英文材质', '中英文用途', '产品规格', '货件编号',
'跟踪编号', '危险品',
]) {
assert.match(source, new RegExp(field))
}
@@ -450,6 +461,7 @@ test('loads merchant inbound billing as an isolated read-only detail section', (
assert.match(source, /<InboundBillingPanel/)
assert.match(source, /@retry="retryInboundBilling"/)
assert.match(source, /error instanceof ApiError && error\.code === 'A0404'/)
assert.match(source, /inboundBillingUnavailableMessage\.value = error\.message \|\| '入库及费用结果尚未发布'/)
assert.match(source, /order\.status === 'DRAFT'/)
assert.match(source, /订单提交后可查看入库结果/)
assert.doesNotMatch(source, /confirmMerchantInbound|adjustMerchantInbound|sendMerchantInbound/)
@@ -492,6 +504,45 @@ test('copies reactive order drafts into plain request data', () => {
assert.doesNotMatch(source, /structuredClone\((?:form|order\.)/)
})
test('opens the server-owned inbound-code association from desktop, mobile, and order detail', () => {
assert.match(source, /import InboundCodeAssociationDialog/)
assert.match(source, /function openInboundCodeAssociation\(inboundCode: string\)/)
assert.match(source, /#inboundCode[\s\S]*?openInboundCodeAssociation\(\(row as FirstLegOrder\)\.businessSummary\.inboundCode\)/)
assert.match(source, /class="order-reference inbound-code-link"[\s\S]*?openInboundCodeAssociation\(order\.businessSummary\.inboundCode\)/)
assert.match(source, /<dt>入仓码<\/dt><dd><button[\s\S]*?openInboundCodeAssociation\(detailOrder\.businessSummary\.inboundCode\)/)
assert.match(source, /<InboundCodeAssociationDialog[\s\S]*?:inbound-code="inboundAssociationCode"/)
assert.doesNotMatch(source, /openInboundCodeAssociation\([^)]*\.id\)/)
})
test('opens customer customs documents only for server-confirmed loaded tax-refund orders', () => {
assert.match(source, /order\.status !== 'LOADED'/)
assert.match(source, /item\.orderId === order\.id && item\.taxMode === 'TAX_REFUND'/)
assert.match(source, /getMerchantCustomsForOrder\(order\.id\)/)
assert.match(source, /v-if="eligibleCustomsDetail\(row as FirstLegOrder\)"[\s\S]*?报关资料/)
assert.match(source, /class="mobile-order-card"[\s\S]*?v-if="eligibleCustomsDetail\(order\)"[\s\S]*?openCustomsDocuments\(order\)[\s\S]*?报关资料/)
assert.match(source, /<CustomsDocumentsDialog[\s\S]*?:can-edit="auth\.hasPermission\('order:first-leg:edit'\)"/)
assert.doesNotMatch(customsApiSource, /customs\/cases\//)
})
test('supports the frozen customs document workflow and customer-safe downloads', () => {
for (const path of [
'/merchant/v1/customs/orders/',
'/merchant/v1/customs/loading-plans/',
'/merchant/v1/customs/documents/',
'/inbounds',
'/completion/download',
]) assert.match(customsApiSource, new RegExp(path.replaceAll('/', '\\/')))
assert.match(customsApiSource, /MAX_CUSTOMS_FILE_SIZE = 50 \* 1024 \* 1024/)
assert.match(customsApiSource, /data: \{ inboundIds, expectedVersion: document\.source\.version \}/)
assert.match(customsApiSource, /requestId: error\.requestId/)
assert.match(customsDialogSource, /报关开始后仅可查看和下载,不提供退回或解锁/)
assert.match(customsDialogSource, /detail\.status === 'COMPLETED' && item\.completion/)
assert.match(customsDialogSource, /资料版本历史/)
assert.match(customsDialogSource, /尚未上传退税报关资料/)
assert.match(customsDialogSource, /退税报关资料加载失败/)
assert.doesNotMatch(customsDialogSource, /accept=/)
})
test('uses the global inbound-code policy to require old-system codes during parallel running', () => {
assert.match(source, /getMerchantInboundCodePolicy\(\)/)
assert.match(source, /async function validateEditorForm\(\)[\s\S]*?const policy = await getMerchantInboundCodePolicy\(\)/)
@@ -2,16 +2,18 @@
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox, type FormInstance, type FormItemRule, type FormRules, type UploadRequestOptions } from 'element-plus'
import { AlertTriangle, Box, ChevronDown, ChevronUp, Download, Eye, FilePlus2, GripVertical, ImagePlus, MapPin, PackageCheck, Pencil, Plus, RotateCcw, Send, SlidersHorizontal, Trash2, Upload } from '@lucide/vue'
import { AlertTriangle, Box, ChevronDown, ChevronUp, Download, Eye, FilePlus2, FileText, GripVertical, ImagePlus, MapPin, PackageCheck, Pencil, Plus, RotateCcw, Send, SlidersHorizontal, Trash2, Upload } from '@lucide/vue'
import { FIRST_LEG_ORDER_STATUSES, type FirstLegOrderStatus } from '@chongchong/shared'
import QueryToolbar, { type QueryField, type QueryModel } from '@/components/common/QueryToolbar.vue'
import DataTable, { type DataTableColumn } from '@/components/common/DataTable.vue'
import { ApiError } from '@/api/http'
import FirstLegOrderEditorShell from '@/modules/first-leg/components/FirstLegOrderEditorShell.vue'
import InboundBillingPanel from '@/modules/first-leg/components/InboundBillingPanel.vue'
import InboundCodeAssociationDialog from '@/modules/first-leg/components/InboundCodeAssociationDialog.vue'
import OrderProgressPanel from '@/modules/first-leg/components/OrderProgressPanel.vue'
import CustomsDocumentsDialog from '@/modules/first-leg/components/CustomsDocumentsDialog.vue'
import { getMerchantInboundBilling } from '@/modules/first-leg/api/inboundBilling'
import { getMerchantCustomsByOrder, isMerchantCustomsUnavailable, type MerchantCustomsCaseDetail } from '@/modules/first-leg/api/customs'
import { getMerchantCustomsForOrder, isMerchantCustomsUnavailable, type MerchantCustomsContainer } from '@/modules/first-leg/api/customs'
import {
estimateMerchantFirstLegPrice,
downloadMerchantOrderCartonLabelsBatch,
@@ -95,7 +97,7 @@ const cargoTemplateColumns = [
const cargoNumberKeys = new Set<string>(['lengthCm', 'widthCm', 'heightCm', 'unitsPerPackage', 'packageCount', 'grossWeightKg', 'volumeCbm', 'pieces', 'netWeightKg', 'unitDeclaredValue', 'declaredValue', 'productLengthCm', 'productWidthCm', 'productHeightCm'])
const fixedOrderColumns: DataTableColumn[] = [
{ key: 'selection', type: 'selection', width: 48, fixed: 'left', reserveSelection: true },
{ key: 'actions', label: '操作', width: 128, fixed: 'left', align: 'center', headerAlign: 'center', slotName: 'actions' },
{ key: 'actions', label: '操作', width: 216, fixed: 'left', align: 'center', headerAlign: 'center', slotName: 'actions' },
{ key: 'order', label: '订单号', width: 190, fixed: 'left', align: 'left', headerAlign: 'left', showOverflowTooltip: true, slotName: 'order' },
]
const selectableOrderColumns: DataTableColumn[] = [
@@ -188,9 +190,12 @@ const inboundBillingLoading = ref(false)
const inboundBillingUnavailable = ref(false)
const inboundBillingUnavailableMessage = ref('入库及费用结果尚未发布')
const inboundBillingError = ref('')
const customsDetail = ref<MerchantCustomsCaseDetail | null>(null)
const customsLoading = ref(false)
const customsUnavailable = ref(false)
const customsByOrder = reactive<Record<string, MerchantCustomsContainer | null | undefined>>({})
const customsEligibilityPending = new Set<string>()
const customsDialogOpen = ref(false)
const customsDialogOrderId = ref('')
const inboundAssociationOpen = ref(false)
const inboundAssociationCode = ref('')
const supportingDataLoading = ref(false)
const supportingDataErrors = ref<string[]>([])
const destinationWarehousesLoading = ref(false)
@@ -333,7 +338,7 @@ const packageTypeLabels: Readonly<Record<string, string>> = {
}
function displayValue(value: string | null | undefined) {
return value?.trim() || '-'
return value?.trim() || '—'
}
function emptyDraft(): FirstLegOrderDraft {
@@ -461,6 +466,48 @@ const pagedOrders = computed(() => {
return filteredOrders.value.slice(start, start + tablePageSize.value)
})
function eligibleCustomsDetail(order: FirstLegOrder) {
const value = customsByOrder[order.id]
if (order.status !== 'LOADED' || !value?.merchantStatus) return null
return value.inbounds.some((item) => item.orderId === order.id && item.taxMode === 'TAX_REFUND') ? value : null
}
async function loadCustomsEligibility(order: FirstLegOrder) {
if (order.status !== 'LOADED' || customsByOrder[order.id] !== undefined || customsEligibilityPending.has(order.id)) return
customsEligibilityPending.add(order.id)
try {
customsByOrder[order.id] = await getMerchantCustomsForOrder(order.id)
} catch (error) {
customsByOrder[order.id] = null
if (!isMerchantCustomsUnavailable(error)) {
// Keep the operation hidden when eligibility cannot be proven by the customer-scoped server response.
}
} finally {
customsEligibilityPending.delete(order.id)
}
}
watch(() => pagedOrders.value.map((order) => `${order.id}:${order.status}`).join('|'), () => {
pagedOrders.value.forEach((order) => void loadCustomsEligibility(order))
}, { immediate: true })
function openCustomsDocuments(order: FirstLegOrder) {
if (!eligibleCustomsDetail(order)) return
customsDialogOrderId.value = order.id
customsDialogOpen.value = true
}
function openInboundCodeAssociation(inboundCode: string) {
const code = inboundCode.trim()
if (!code) return
inboundAssociationCode.value = code
inboundAssociationOpen.value = true
}
function updateCustomsDetail(value: MerchantCustomsContainer) {
if (customsDialogOrderId.value) customsByOrder[customsDialogOrderId.value] = value
}
const detailOrder = computed(() => detailId.value ? store.getById(detailId.value) : undefined)
const detailSummary = computed(() => detailOrder.value ? summarizeFirstLegCargo(detailOrder.value.items) : null)
const pagedDetailCargoItems = computed(() => {
@@ -485,8 +532,7 @@ const progressSummaryItems = computed(() => {
{ label: '实测体积', value: formatMetric(summary.actualVolumeCbm ?? inbound?.actualVolumeCbm, 'm³') },
{ label: '预装清单', value: preloadStatus ? `${preloadStatus}${summary.preloadItemCount == null ? '' : ` · ${summary.preloadItemCount} 项`}` : '待更新' },
{ label: '拣货进度', value: pickingProgress },
{ label: '柜次进度', value: summary.cabinetNo || summary.containerNo || '待更新' },
{ label: '报关回执', value: customsDetail.value?.receipts.length ? `${customsDetail.value.receipts.length} 份回执` : customsUnavailable.value ? '暂无回执' : '查询中' },
{ label: '柜次进度', value: summary.cabinetNo || summary.containerNo || '—' },
{ label: '船期摘要', value: transportReference || '待更新' },
{ label: '计费重量', value: formatMetric(summary.chargeableWeightKg, 'kg') },
{ label: '后续履约', value: summary.deliveryTrackingNo || (summary.deliveryCompletedAt ? `海外回传于 ${formatDateTime(summary.deliveryCompletedAt)}` : '待海外同步') },
@@ -598,9 +644,6 @@ watch(isCreatePage, (isCreate, wasCreate) => {
watch(detailOpen, (open) => {
if (!open) {
resetInboundBillingState()
customsDetail.value = null
customsLoading.value = false
customsUnavailable.value = false
if (detailOrder.value) releaseProductImagePreviews(detailOrder.value.items)
}
})
@@ -1073,7 +1116,6 @@ async function openDetail(order: FirstLegOrder) {
detailLoading.value = true
detailError.value = ''
void loadInboundBilling(order)
void loadCustoms(order)
try {
const detailedOrder = await store.loadDetail(order.id)
// Load previews from the persisted file IDs after the store has replaced the detail row.
@@ -1129,7 +1171,7 @@ async function loadInboundBilling(order: FirstLegOrder) {
if (!isCurrentInboundBillingRequest(sequence, order.id)) return
if (error instanceof ApiError && error.code === 'A0404') {
inboundBillingUnavailable.value = true
inboundBillingUnavailableMessage.value = '入库及费用结果尚未发布'
inboundBillingUnavailableMessage.value = error.message || '入库及费用结果尚未发布'
} else {
inboundBillingError.value = error instanceof Error ? error.message : '入库与费用加载失败'
}
@@ -1138,21 +1180,6 @@ async function loadInboundBilling(order: FirstLegOrder) {
}
}
async function loadCustoms(order: FirstLegOrder) {
customsDetail.value = null
customsUnavailable.value = false
if (order.status === 'DRAFT') return
customsLoading.value = true
try {
customsDetail.value = await getMerchantCustomsByOrder(order.id)
} catch (error) {
if (isMerchantCustomsUnavailable(error)) customsUnavailable.value = true
else ElMessage.warning(error instanceof Error ? error.message : '报关回执加载失败')
} finally {
customsLoading.value = false
}
}
async function retryInboundBilling() {
const order = detailId.value ? store.getById(detailId.value) : undefined
if (order) await loadInboundBilling(order)
@@ -1203,7 +1230,7 @@ function removeCargoItem(index: number) {
}
function displayListValue(value: string | number | null | undefined) {
return value === '' || value == null ? '-' : String(value)
return value === '' || value == null ? '—' : String(value)
}
function orderMatchesCargoKeyword(order: FirstLegOrder, keyword: string) {
@@ -1632,12 +1659,22 @@ async function searchOrders(model: QueryModel) {
<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>
<el-button v-if="eligibleCustomsDetail(row as FirstLegOrder)" link type="primary" @click="openCustomsDocuments(row as FirstLegOrder)"><FileText :size="15" />报关资料</el-button>
</div>
</template>
<template #order="{ row }">
<button class="order-number-link table-code" type="button" :title="(row as FirstLegOrder).orderNo || '未提交草稿'" @click="openDetail(row as FirstLegOrder)">{{ (row as FirstLegOrder).orderNo || '未提交草稿' }}</button>
<button class="order-number-link table-code" type="button" :title="(row as FirstLegOrder).orderNo || '—'" @click="openDetail(row as FirstLegOrder)">{{ (row as FirstLegOrder).orderNo || '—' }}</button>
</template>
<template #inboundCode="{ row }">
<button
v-if="(row as FirstLegOrder).businessSummary.inboundCode"
class="inbound-code-link table-ellipsis table-code"
type="button"
:title="`查看入仓码 ${(row as FirstLegOrder).businessSummary.inboundCode} 的关联摘要`"
@click="openInboundCodeAssociation((row as FirstLegOrder).businessSummary.inboundCode)"
>{{ (row as FirstLegOrder).businessSummary.inboundCode }}</button>
<span v-else>—</span>
</template>
<template #inboundCode="{ row }"><span class="table-ellipsis table-code" :title="displayListValue((row as FirstLegOrder).businessSummary.inboundCode)">{{ displayListValue((row as FirstLegOrder).businessSummary.inboundCode) }}</span></template>
<template #channel="{ row }"><span class="table-ellipsis" :title="displayListValue((row as FirstLegOrder).channelName)">{{ displayListValue((row as FirstLegOrder).channelName) }}</span></template>
<template #transportMode="{ row }">{{ (row as FirstLegOrder).transportMode ? getFirstLegTransportModeLabel((row as FirstLegOrder).transportMode) : '-' }}</template>
<template #pickupMethod="{ row }">{{ pickupMethodLabels[(row as FirstLegOrder).pickupMethod] }}</template>
@@ -1677,11 +1714,19 @@ async function searchOrders(model: QueryModel) {
<div v-loading="store.loading" class="mobile-order-surface">
<div v-if="filteredOrders.length" class="mobile-order-list">
<article v-for="order in filteredOrders" :key="order.id" class="mobile-order-card">
<header><button type="button" @click="openDetail(order)">{{ order.orderNo || '未提交草稿' }}</button><el-tag :type="getFirstLegStatusTone(order.status)" effect="light">{{ getFirstLegStatusLabel(order.status) }}</el-tag></header>
<span v-if="order.businessSummary.inboundCode" class="order-reference">入仓码 {{ order.businessSummary.inboundCode }}</span>
<span class="order-reference">{{ order.referenceNo || '无 Reference ID' }}</span>
<dl><div><dt>目的地</dt><dd>{{ order.destination || '待更新' }}</dd></div><div><dt>目的仓类型</dt><dd>{{ destinationTypeLabels[order.destinationType] }}</dd></div><div><dt>收货仓</dt><dd>{{ order.receivingWarehouseName || '待更新' }}</dd></div><div><dt>货物摘要</dt><dd>{{ getOrderCargoSummary(order) }}</dd></div><div><dt>运输方式</dt><dd>{{ order.transportMode ? getFirstLegTransportModeLabel(order.transportMode) : '待更新' }}</dd></div><div><dt>交货方式</dt><dd>{{ pickupMethodLabels[order.pickupMethod] }}</dd></div><div><dt>预约提货时间</dt><dd>{{ order.pickupMethod === 'PICKUP' && order.requestedPickupAt ? formatDateTime(order.requestedPickupAt) : '-' }}</dd></div><div v-if="order.businessSummary.cabinetNo || order.businessSummary.containerNo"><dt>柜号 / 柜次</dt><dd>{{ order.businessSummary.cabinetNo || order.businessSummary.containerNo }}</dd></div><div v-if="order.businessSummary.vesselName || order.businessSummary.voyageNo"><dt>船名 / 航次</dt><dd>{{ [order.businessSummary.vesselName, order.businessSummary.voyageNo].filter(Boolean).join(' · ') }}</dd></div><div v-if="order.businessSummary.sailingAt"><dt>预计船期</dt><dd>{{ formatDateTime(order.businessSummary.sailingAt) }}</dd></div></dl>
<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>
<header><button type="button" @click="openDetail(order)">{{ order.orderNo || '—' }}</button><el-tag :type="getFirstLegStatusTone(order.status)" effect="light">{{ getFirstLegStatusLabel(order.status) }}</el-tag></header>
<button v-if="order.businessSummary.inboundCode" class="order-reference inbound-code-link" type="button" @click="openInboundCodeAssociation(order.businessSummary.inboundCode)">入仓码 {{ order.businessSummary.inboundCode }}</button>
<span v-else class="order-reference">入仓码 —</span>
<span class="order-reference">{{ order.referenceNo || '跟踪编号 —' }}</span>
<dl><div><dt>目的地</dt><dd>{{ order.destination || '待更新' }}</dd></div><div><dt>目的仓类型</dt><dd>{{ destinationTypeLabels[order.destinationType] }}</dd></div><div><dt>收货仓</dt><dd>{{ order.receivingWarehouseName || '—' }}</dd></div><div><dt>货物摘要</dt><dd>{{ getOrderCargoSummary(order) }}</dd></div><div><dt>运输方式</dt><dd>{{ order.transportMode ? getFirstLegTransportModeLabel(order.transportMode) : '待更新' }}</dd></div><div><dt>交货方式</dt><dd>{{ pickupMethodLabels[order.pickupMethod] }}</dd></div><div><dt>预约提货时间</dt><dd>{{ order.pickupMethod === 'PICKUP' && order.requestedPickupAt ? formatDateTime(order.requestedPickupAt) : '—' }}</dd></div><div><dt>柜号 / 柜次</dt><dd>{{ order.businessSummary.cabinetNo || order.businessSummary.containerNo || '—' }}</dd></div><div v-if="order.businessSummary.vesselName || order.businessSummary.voyageNo"><dt>船名 / 航次</dt><dd>{{ [order.businessSummary.vesselName, order.businessSummary.voyageNo].filter(Boolean).join(' · ') }}</dd></div><div v-if="order.businessSummary.sailingAt"><dt>预计船期</dt><dd>{{ formatDateTime(order.businessSummary.sailingAt) }}</dd></div></dl>
<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>
<el-button v-if="eligibleCustomsDetail(order)" link type="primary" @click="openCustomsDocuments(order)"><FileText :size="15" />报关资料</el-button>
</div>
</footer>
</article>
</div>
<el-empty v-else :description="emptyDescription">
@@ -1772,7 +1817,7 @@ async function searchOrders(model: QueryModel) {
<th class="cargo-check-cell"><el-checkbox :model-value="allCargoSelected" :indeterminate="someCargoSelected" aria-label="全选货物" @change="toggleAllCargoItems" /></th><th class="cargo-index-cell">#</th>
<th>SKU NO.<small>SKU必填</small></th><th>中文品名</th><th class="cargo-image-column">图片</th><th>英文品名</th><th>报关 HS CODE</th><th>清关 HS CODE</th><th>包装类型</th>
<th class="cargo-number-column">箱规长 cm</th><th class="cargo-number-column">箱规宽 cm</th><th class="cargo-number-column">箱规高 cm</th><th class="cargo-number-column">每箱数量</th><th class="cargo-number-column">箱数</th><th class="cargo-number-column">总毛重</th><th class="cargo-number-column">总体积</th><th class="cargo-number-column">总数量</th><th class="cargo-number-column">总净重</th>
<th>清关申报单价 USD</th><th>清关申报金额 USD</th><th>材质</th><th>用途</th><th>型号</th><th>品牌</th><th class="cargo-dimensions-column">产品规格 长×宽×高</th><th>危险品</th><th>货件编号<small>货件编号必填</small></th><th>跟踪编号<small>跟踪编号必填</small></th><th>UPC NO.<small>UPC必填</small></th><th>备注</th><th class="cargo-operation-column">操作</th>
<th>清关申报单价 USD</th><th>清关申报金额 USD</th><th>材质</th><th>用途</th><th>型号</th><th>品牌</th><th class="cargo-dimensions-column">产品规格 长×宽×高</th><th>危险品</th><th>货件编号<small>货件编号必填</small></th><th>跟踪编号<small>跟踪编号必填</small></th><th>UPC NO.</th><th>备注</th><th class="cargo-operation-column">操作</th>
</tr></thead>
<tbody><tr v-for="(item, index) in form.items" :key="item.id">
<td class="cargo-check-cell"><el-checkbox :model-value="selectedCargoIds.includes(item.id)" :aria-label="`选择货物 ${index + 1}`" @change="toggleCargoItem(item.id, $event)" /></td><td class="cargo-index-cell">{{ index + 1 }}</td>
@@ -1785,11 +1830,11 @@ async function searchOrders(model: QueryModel) {
<td><el-form-item :prop="`items.${index}.unitsPerPackage`" required :rules="positiveNumberRules('每箱数量必须大于 0', true)"><el-input-number v-model="item.unitsPerPackage" :min="1" :controls="false" /></el-form-item></td><td><el-form-item :prop="`items.${index}.packageCount`" required :rules="positiveNumberRules('箱数必须大于 0', true)"><el-input-number v-model="item.packageCount" :min="1" :controls="false" /></el-form-item></td><td><el-form-item :prop="`items.${index}.grossWeightKg`" required :rules="positiveNumberRules('总毛重必须大于 0')"><el-input-number v-model="item.grossWeightKg" :min="0" :precision="3" :controls="false" /></el-form-item></td><td><el-form-item :prop="`items.${index}.volumeCbm`" required :rules="positiveNumberRules('总体积必须大于 0')"><el-input-number v-model="item.volumeCbm" :min="0" :precision="4" :controls="false" /></el-form-item></td><td><el-form-item :prop="`items.${index}.pieces`" required :rules="positiveNumberRules('总数量必须大于 0', true)"><el-input-number v-model="item.pieces" :min="1" :controls="false" /></el-form-item></td><td><el-input-number v-model="item.netWeightKg" :min="0" :precision="3" :controls="false" /></td>
<td><el-input-number v-model="item.unitDeclaredValue" :min="0" :precision="2" :controls="false" /></td><td><el-input-number v-model="item.declaredValue" :min="0" :precision="2" :controls="false" /></td><td><el-input v-model="item.material" maxlength="128" placeholder="材质" /></td><td><el-input v-model="item.usage" maxlength="128" placeholder="用途" /></td><td><el-input v-model="item.model" maxlength="64" placeholder="型号" /></td><td><el-input v-model="item.brand" maxlength="64" placeholder="品牌" /></td>
<td class="cargo-dimensions-column"><div class="cargo-table-dimensions"><el-input-number v-model="item.productLengthCm" :min="0" :precision="2" :controls="false" /><span>×</span><el-input-number v-model="item.productWidthCm" :min="0" :precision="2" :controls="false" /><span>×</span><el-input-number v-model="item.productHeightCm" :min="0" :precision="2" :controls="false" /></div></td><td class="cargo-danger-cell"><el-switch v-model="item.dangerousGoods" /></td>
<td><el-form-item :prop="`items.${index}.shipmentId`" :required="form.destinationType === 'FBA'" :rules="form.destinationType === 'FBA' ? requiredTextRules('FBA 货物请输入货件编号') : undefined"><el-input v-model="item.shipmentId" maxlength="64" placeholder="货件编号" /></el-form-item></td><td><el-form-item :prop="`items.${index}.referenceId`" :required="form.destinationType === 'FBA'" :rules="form.destinationType === 'FBA' ? requiredTextRules('FBA 货物请输入跟踪编号') : undefined"><el-input v-model="item.referenceId" maxlength="64" placeholder="跟踪编号" /></el-form-item></td><td><el-form-item :prop="`items.${index}.upc`" :required="form.destinationType === 'FBA'" :rules="form.destinationType === 'FBA' ? requiredTextRules('FBA 货物请输入 UPC NO.') : undefined"><el-input v-model="item.upc" maxlength="64" placeholder="UPC NO." /></el-form-item></td><td><el-input v-model="item.remark" maxlength="500" placeholder="备注" /></td><td class="cargo-operation-column"><el-button link type="danger" :aria-label="`删除货物 ${index + 1}`" @click="removeCargoItem(index)"><Trash2 :size="15" />删除</el-button></td>
<td><el-form-item :prop="`items.${index}.shipmentId`" :required="form.destinationType === 'FBA'" :rules="form.destinationType === 'FBA' ? requiredTextRules('FBA 货物请输入货件编号') : undefined"><el-input v-model="item.shipmentId" maxlength="64" placeholder="货件编号" /></el-form-item></td><td><el-form-item :prop="`items.${index}.referenceId`" :required="form.destinationType === 'FBA'" :rules="form.destinationType === 'FBA' ? requiredTextRules('FBA 货物请输入跟踪编号') : undefined"><el-input v-model="item.referenceId" maxlength="64" placeholder="跟踪编号" /></el-form-item></td><td><el-form-item :prop="`items.${index}.upc`"><el-input v-model="item.upc" maxlength="64" placeholder="UPC NO.(选填)" /></el-form-item></td><td><el-input v-model="item.remark" maxlength="500" placeholder="备注" /></td><td class="cargo-operation-column"><el-button link type="danger" :aria-label="`删除货物 ${index + 1}`" @click="removeCargoItem(index)"><Trash2 :size="15" />删除</el-button></td>
</tr></tbody>
</table>
</div>
<div class="cargo-table-hint"><span>SKU NO.、货件编号、跟踪编号、UPC NO. 均为必填项</span></div>
<div class="cargo-table-hint"><span>SKU NO.、货件编号、跟踪编号为 FBA 货物必填项,UPC NO. 选填</span></div>
</section>
<section class="order-form-section prototype-section cost-estimate-section" aria-label="费用预估">
@@ -1839,13 +1884,13 @@ async function searchOrders(model: QueryModel) {
</div>
<template v-else-if="detailOrder">
<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>
</div>
<section class="detail-section">
<h3>订单与运输</h3>
<dl class="order-detail-grid">
<div v-if="detailOrder.businessSummary.inboundCode"><dt>入仓码</dt><dd>{{ detailOrder.businessSummary.inboundCode }}</dd></div>
<div><dt>入仓码</dt><dd><button v-if="detailOrder.businessSummary.inboundCode" class="inbound-code-link table-code" type="button" @click="openInboundCodeAssociation(detailOrder.businessSummary.inboundCode)">{{ detailOrder.businessSummary.inboundCode }}</button><span v-else>—</span></dd></div>
<div><dt>创建时间</dt><dd>{{ formatDateTime(detailOrder.createdAt) }}</dd></div>
<div><dt>运输方式</dt><dd>{{ getFirstLegTransportModeLabel(detailOrder.transportMode) }}</dd></div>
<div><dt>贸易条款</dt><dd>{{ displayValue(detailOrder.tradeTerm) }}</dd></div>
@@ -1853,7 +1898,7 @@ async function searchOrders(model: QueryModel) {
<div><dt>预约提货时间</dt><dd>{{ detailOrder.pickupMethod === 'PICKUP' && detailOrder.requestedPickupAt ? formatDateTime(detailOrder.requestedPickupAt) : '-' }}</dd></div>
<div><dt>报关方式</dt><dd>{{ taxModeLabels[detailOrder.taxMode] }}</dd></div>
<div><dt>运输渠道</dt><dd>{{ detailOrder.channelName || detailOrder.channelCode || '-' }}<span v-if="detailOrder.channelName && detailOrder.channelCode" class="detail-subline">{{ detailOrder.channelCode }}</span></dd></div>
<div><dt>国内收货仓</dt><dd>{{ detailOrder.receivingWarehouseSnapshot?.name || detailOrder.receivingWarehouseName || '-' }}<span v-if="detailOrder.receivingWarehouseSnapshot" class="detail-subline">{{ detailOrder.receivingWarehouseSnapshot.code || '仓库编码待更新' }} · {{ detailOrder.receivingWarehouseSnapshot.province }}{{ detailOrder.receivingWarehouseSnapshot.city }}{{ detailOrder.receivingWarehouseSnapshot.address }}</span></dd></div>
<div><dt>国内收货仓</dt><dd>{{ detailOrder.receivingWarehouseSnapshot?.name || detailOrder.receivingWarehouseName || '—' }}<span v-if="detailOrder.receivingWarehouseSnapshot" class="detail-subline">{{ detailOrder.receivingWarehouseSnapshot.code || '—' }} · {{ detailOrder.receivingWarehouseSnapshot.province }}{{ detailOrder.receivingWarehouseSnapshot.city }}{{ detailOrder.receivingWarehouseSnapshot.address }}</span></dd></div>
<div><dt>收货要求</dt><dd>{{ displayValue(detailOrder.receivingTime) }}</dd></div>
<div><dt>目的仓类型</dt><dd>{{ destinationTypeLabels[detailOrder.destinationType] }}</dd></div>
<div><dt>目的国家 / 仓库</dt><dd>{{ detailOrder.destinationCountryCode || '-' }} · {{ detailOrder.destinationWarehouseName || '-' }}</dd></div>
@@ -1882,9 +1927,9 @@ async function searchOrders(model: QueryModel) {
<h3>柜次进度</h3>
<div v-if="cabinetProgressItems.length" class="cabinet-progress-list">
<article v-for="cabinet in cabinetProgressItems" :key="cabinet.cabinetId" class="cabinet-progress-card">
<header><strong>{{ cabinet.cabinetNo || '柜次待分配' }}</strong><el-tag size="small" effect="plain">{{ cabinet.status || '待更新' }}</el-tag></header>
<header><strong>{{ cabinet.cabinetNo || '—' }}</strong><el-tag size="small" effect="plain">{{ cabinet.status || '待更新' }}</el-tag></header>
<dl>
<div><dt>柜号</dt><dd>{{ cabinet.cabinetNo || '待分配' }}</dd></div>
<div><dt>柜号</dt><dd>{{ cabinet.cabinetNo || '—' }}</dd></div>
<div><dt>船名 / 航次</dt><dd>{{ [cabinet.vesselName, cabinet.voyageNo].filter(Boolean).join(' · ') || '待更新' }}</dd></div>
<div><dt>预计船期</dt><dd>{{ cabinet.sailingAt ? formatDateTime(cabinet.sailingAt) : '待更新' }}</dd></div>
</dl>
@@ -1892,16 +1937,6 @@ async function searchOrders(model: QueryModel) {
</div>
<el-empty v-else description="暂无柜次分配信息" :image-size="50" />
</section>
<section v-if="detailOrder.status !== 'DRAFT'" class="detail-section">
<h3>报关回执</h3>
<el-skeleton v-if="customsLoading" :rows="2" animated />
<el-empty v-else-if="customsUnavailable || !customsDetail?.receipts.length" description="暂无客户可见报关回执" :image-size="50" />
<div v-else class="customs-receipt-list">
<article v-for="receipt in customsDetail.receipts" :key="receipt.id">
<strong>{{ receipt.receiptType }}</strong><span>版本 {{ receipt.receiptVersion ?? '-' }}</span><span>{{ receipt.uploadedAt ? formatDateTime(receipt.uploadedAt) : '时间待更新' }}</span>
</article>
</div>
</section>
<section class="detail-section">
<h3>履约快递信息</h3>
<div v-if="detailOrder.expressCompanyName || detailOrder.trackingNumber" class="express-information"><PackageCheck :size="20" /><div><span>快递公司</span><strong>{{ detailOrder.expressCompanyName || '待平台补充' }}</strong></div><div><span>快递单号</span><strong>{{ detailOrder.trackingNumber || '待平台补充' }}</strong></div></div>
@@ -1964,6 +1999,17 @@ async function searchOrders(model: QueryModel) {
</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-dialog>
<CustomsDocumentsDialog
v-model="customsDialogOpen"
:order-id="customsDialogOrderId"
:can-edit="auth.hasPermission('order:first-leg:edit')"
:initial-detail="customsByOrder[customsDialogOrderId]"
@updated="updateCustomsDetail"
/>
<InboundCodeAssociationDialog
v-model="inboundAssociationOpen"
:inbound-code="inboundAssociationCode"
/>
</section>
</template>
@@ -2040,6 +2086,9 @@ async function searchOrders(model: QueryModel) {
.mobile-order-surface { display: none; min-height: 220px; }
.mobile-order-list { display: none; }
.order-number-link { display: block; width: 100%; max-width: 100%; overflow: hidden; padding: 0; border: 0; background: none; color: var(--primary); cursor: pointer; font-size: 11px; font-weight: 700; line-height: 18px; text-align: left; text-overflow: ellipsis; white-space: nowrap; }
.inbound-code-link { max-width: 100%; overflow: hidden; padding: 0; border: 0; background: transparent; color: var(--primary); cursor: pointer; text-align: left; text-decoration: underline; text-decoration-color: transparent; text-underline-offset: 3px; text-overflow: ellipsis; white-space: nowrap; }
.inbound-code-link:hover, .inbound-code-link:focus-visible { text-decoration-color: currentColor; }
.inbound-code-link:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; }
.order-number-link:focus-visible { outline: 2px solid var(--primary); outline-offset: 3px; }
.order-reference { display: block; margin-top: 4px; color: var(--muted); font-size: 11px; font-weight: 400; }
.order-row-actions { align-items: center; justify-content: center; gap: 6px; white-space: nowrap; }
@@ -2226,9 +2275,6 @@ async function searchOrders(model: QueryModel) {
.cabinet-progress-card dl { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; margin: 13px 0 0; }
.cabinet-progress-card dt { color: var(--muted); font-size: 10px; }
.cabinet-progress-card dd { margin: 4px 0 0; overflow-wrap: anywhere; color: var(--navy); font-size: 11px; font-weight: 600; }
.customs-receipt-list { display: grid; gap: 8px; }
.customs-receipt-list article { display: flex; flex-wrap: wrap; gap: 10px; padding: 11px 13px; border: 1px solid var(--border); color: var(--muted); font-size: 11px; }
.customs-receipt-list strong { color: var(--navy); }
.express-information { display: grid; grid-template-columns: auto repeat(2, minmax(0, 1fr)); align-items: center; gap: 16px; padding: 15px 16px; background: #f2f7f6; color: var(--primary); }
.express-information > div { display: flex; min-width: 0; flex-direction: column; gap: 4px; }
.express-information span { color: var(--muted); font-size: 11px; }
@@ -78,7 +78,10 @@ test('requires the old inbound code globally only while legacy manual mode is ac
test('shows every created draft and keeps failed rows available for retry', () => {
assert.match(source, /v-for="result in draftResults"/)
assert.match(source, /入仓码将在提交订单时生成/)
assert.match(source, /result\.orderNo \|\| '订单号 —'/)
assert.match(source, /result\.inboundCode \? `入仓码 \$\{result\.inboundCode\}` : '入仓码 —'/)
assert.doesNotMatch(source, /result\.orderNo \|\| `草稿 \$\{result\.draftId\}`/)
assert.doesNotMatch(source, /item\.orderNo \|\| item\.draftId/)
assert.match(source, /@click="editDraft\(result\)"/)
assert.match(source, /if \(rows\.length === 0\) rows\.push\(createRow\(\)\)/)
assert.match(source, /resultDialogOpen\.value = successes\.length > 0/)
@@ -394,8 +394,10 @@ async function submitQuickOrder() {
await Promise.all(successes.map((item) => store.loadDraftDetail(item.draftId)))
workspace.close(['first-leg-quick-order'])
await router.push({ name: 'first-leg-orders' })
const orderNumbers = successes.map((item) => item.orderNo || item.draftId).join('、')
ElMessage.success(`已生成 ${successes.length} 张草稿单:${orderNumbers}`)
const orderNumbers = successes.map((item) => item.orderNo).filter(Boolean).join('、')
ElMessage.success(orderNumbers
? `已生成 ${successes.length} 张草稿单:${orderNumbers}`
: `已生成 ${successes.length} 张草稿单,订单号 —`)
}
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '批量草稿生成失败')
@@ -529,7 +531,7 @@ onMounted(loadChannels)
<el-dialog v-model="resultDialogOpen" class="quick-result-dialog" title="草稿单生成结果" width="min(700px, 94vw)">
<div class="quick-draft-results">
<article v-for="result in draftResults" :key="result.rowId">
<div><strong>{{ result.orderNo || `草稿 ${result.draftId}` }}</strong><span>SKU {{ result.sku }}</span><span>{{ result.inboundCode ? `入仓码 ${result.inboundCode}` : '入仓码将在提交订单时生成' }}</span></div>
<div><strong>{{ result.orderNo || '订单号 —' }}</strong><span>SKU {{ result.sku }}</span><span>{{ result.inboundCode ? `入仓码 ${result.inboundCode}` : '入仓码 —' }}</span></div>
<el-button type="primary" plain @click="editDraft(result)">编辑明细</el-button>
</article>
</div>
@@ -0,0 +1,27 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
const firstLeg = readFileSync(new URL('./FirstLegOrdersView.vue', import.meta.url), 'utf8')
const quickOrder = readFileSync(new URL('./QuickOrderView.vue', import.meta.url), 'utf8')
const customs = readFileSync(new URL('../components/CustomsDocumentsDialog.vue', import.meta.url), 'utf8')
const overseas = readFileSync(new URL('../../overseas-warehouse/views/WarehouseServiceView.vue', import.meta.url), 'utf8')
test('uses business identifiers and names without visible internal-ID fallbacks', () => {
assert.match(firstLeg, /label: '入仓码'/)
assert.match(firstLeg, /label: '订单号'/)
assert.match(firstLeg, /label: '柜号 \/ 柜次'/)
assert.match(firstLeg, /label: '国内收货仓'/)
assert.doesNotMatch(quickOrder, /result\.orderNo \|\| `草稿 \$\{result\.draftId\}`/)
assert.doesNotMatch(quickOrder, /item\.orderNo \|\| item\.draftId/)
assert.doesNotMatch(customs, /detail\.planNo \|\| detail\.loadingPlanId/)
assert.doesNotMatch(overseas, /overseasWarehouseName \|\| (?:item|candidate)\.overseasWarehouseId/)
assert.doesNotMatch(overseas, /row\(\[[^\]]*(?:item\.overseasWarehouseId|item\.operatingWarehouseId)/)
})
test('keeps internal IDs only as non-rendered routing, selection, and request values', () => {
assert.match(quickOrder, /params: \{ draftId: result\.draftId \}/)
assert.match(customs, /uploadMerchantCustomsDocument\(detail\.value\.loadingPlanId/)
assert.match(overseas, /:value="candidate\.orderId"/)
assert.match(firstLeg, /detailId\.value = order\.id/)
})
@@ -44,4 +44,15 @@ onMounted(() => { void Promise.all([load(), notifications.refreshUnreadCount()])
<style scoped>
.notification-page{display:flex;flex-direction:column;gap:16px}.page-heading{display:flex;justify-content:space-between;color:var(--navy)}.page-heading h1{margin:2px 0 5px;font-size:24px}.page-heading p:not(.eyebrow){margin:0;color:var(--muted);font-size:13px}.eyebrow{margin:0;color:var(--primary);font-size:11px;font-weight:700;letter-spacing:.08em;text-transform:uppercase}.content-card{padding:16px;border:1px solid var(--border);border-radius:8px;background:var(--surface,#fff)}.filter-bar{display:flex;flex-wrap:wrap;gap:8px;margin-bottom:14px}.mark-all{margin-left:auto}.state-panel{display:flex;min-height:180px;align-items:center;justify-content:center;flex-direction:column;gap:12px;text-align:center}.state-panel--error{color:#b42318;background:#fff8f7;border:1px solid #f5c2bd;border-radius:6px}.state-panel p{margin:0}.table-empty{display:flex;align-items:center;flex-direction:column;gap:8px;padding:42px 12px;color:var(--muted)}.table-empty strong{color:var(--navy)}.table-empty span{font-size:12px}.pagination-row{display:flex;align-items:center;justify-content:space-between;gap:12px;padding-top:14px;color:var(--muted);font-size:12px}@media(max-width:720px){.content-card{padding:12px}.mark-all{margin-left:0;width:100%}.filter-bar .el-select,.filter-bar>.el-button{flex:1}.pagination-row{align-items:flex-start;flex-direction:column}.page-heading h1{font-size:21px}}
/* Keep the notification filters compact and aligned with the other client lists. */
.filter-bar { align-items: flex-end; }
.filter-bar > .el-select { width: 190px; }
.filter-bar > .el-input { width: 260px; }
.filter-bar > .el-button { flex: 0 0 auto; min-height: 32px; }
.filter-bar > .el-button + .el-button { margin-left: 0; }
@media (max-width: 720px) {
.filter-bar { align-items: stretch; flex-direction: column; }
.filter-bar > .el-select, .filter-bar > .el-input { width: 100%; }
.filter-bar > .el-button { width: 100%; }
}
</style>
@@ -69,21 +69,21 @@ function selectDiscardInbound(orderId: string) {
}))
}
function dateTime(value: string, end = false) { return value ? `${value}T${end ? '23:59:59' : '00:00:00'}` : undefined }
function row(values: Array<string | number | undefined | null>) { return Object.fromEntries(values.map((value, index) => [String(index), value == null || value === '' ? '-' : String(value)])) }
function row(values: Array<string | number | undefined | null>) { return Object.fromEntries(values.map((value, index) => [String(index), value == null || value === '' ? '—' : String(value)])) }
async function load() {
loading.value = true
try {
if (props.service === 'labeling') {
const result = await listRelabels({ page: 1, size: 20, relabelNo: filters.number || undefined, overseasWarehouseId: filters.warehouse || undefined, status: filters.status || undefined, createdFrom: dateTime(filters.start), createdTo: dateTime(filters.end, true) })
submittedRows.value = result.records.map((item) => row([item.relabelNo, item.customerOrderNo, item.overseasWarehouseId, '-', item.itemCount, item.totalPackageCount, item.status, item.createdAt]))
submittedRows.value = result.records.map((item) => row([item.relabelNo, item.customerOrderNo, '—', '—', item.itemCount, item.totalPackageCount, item.status, item.createdAt]))
} else if (props.service === 'disposal') {
const result = await listDiscards({ page: 1, size: 20, discardNo: filters.number || undefined, inboundCode: filters.inboundNo || undefined, overseasWarehouseId: filters.warehouse || undefined, status: filters.status || undefined, createdFrom: dateTime(filters.start), createdTo: dateTime(filters.end, true) })
submittedRows.value = result.records.map((item) => row([item.discardNo, item.inboundCodes, item.overseasWarehouseName || item.overseasWarehouseId, '-', item.totalPackages, `${item.currency} ${item.totalFee}`, item.status, item.createdAt]))
submittedRows.value = result.records.map((item) => row([item.discardNo, item.inboundCodes, item.overseasWarehouseName || '—', '—', item.totalPackages, `${item.currency} ${item.totalFee}`, item.status, item.createdAt]))
} else {
const result = await listTransfers({ page: 1, size: 20, transferNo: filters.number || undefined, inboundCode: filters.inboundNo || undefined, operatingWarehouseId: filters.warehouse || undefined, fbaWarehouseCode: filters.destination || undefined, status: filters.status || undefined, needRelabel: props.service === 'relabel', createdFrom: dateTime(filters.start), createdTo: dateTime(filters.end, true) })
submittedRows.value = result.records.map((item) => props.service === 'transfer'
? row([item.transferNo, item.inboundCode, item.packageCount, item.operatingWarehouseId, item.fbaWarehouseCode, item.deliveryMethod, item.needRelabel ? '是' : '否', item.status, item.createdAt])
: row([item.transferNo, item.operatingWarehouseId, item.fbaWarehouseCode, '-', item.packageCount, '是', item.relabelNo ? '已生成' : '待生成', item.status, item.createdAt]))
? row([item.transferNo, item.inboundCode, item.packageCount, '—', item.fbaWarehouseCode, item.deliveryMethod, item.needRelabel ? '是' : '否', item.status, item.createdAt])
: row([item.transferNo, '—', item.fbaWarehouseCode, '—', item.packageCount, '是', item.relabelNo ? '已生成' : '待生成', item.status, item.createdAt]))
}
} catch (error) { ElMessage.error(error instanceof Error ? error.message : '列表加载失败') }
finally { loading.value = false }
@@ -169,7 +169,7 @@ async function openDetail(value: Record<string, string>) {
<div class="warehouse-card warehouse-form-section"><h3>{{ props.service === 'relabel' ? '退货信息' : props.service === 'labeling' ? '订单信息' : '基础与派送信息' }}</h3>
<div class="warehouse-form-grid">
<label v-if="props.service === 'labeling'"><span><i>*</i>客户单号</span><el-input v-model="form.customerNo" placeholder="请输入客户单号" /></label>
<label v-if="props.service === 'disposal'" class="warehouse-span-2"><span><i>*</i>选择入库单</span><el-select v-model="selectedDiscardOrderId" filterable :loading="discardCandidatesLoading" placeholder="请选择有可用库存的入库单" @change="selectDiscardInbound"><el-option v-for="candidate in discardCandidates" :key="candidate.orderId" :label="`${candidate.inboundOrderNo || candidate.inboundCode}|${candidate.inboundCode}|${candidate.overseasWarehouseName || candidate.overseasWarehouseId}|可用 ${candidate.availablePackages} 箱`" :value="candidate.orderId" /></el-select><small class="warehouse-hint">仅显示一件代发中仍有可用箱数的入库单。</small></label>
<label v-if="props.service === 'disposal'" class="warehouse-span-2"><span><i>*</i>选择入库单</span><el-select v-model="selectedDiscardOrderId" filterable :loading="discardCandidatesLoading" placeholder="请选择有可用库存的入库单" @change="selectDiscardInbound"><el-option v-for="candidate in discardCandidates" :key="candidate.orderId" :label="`${candidate.inboundOrderNo || candidate.inboundCode || '—'}|${candidate.inboundCode || '—'}|${candidate.overseasWarehouseName || '—'}|可用 ${candidate.availablePackages} 箱`" :value="candidate.orderId" /></el-select><small class="warehouse-hint">仅显示一件代发中仍有可用箱数的入库单。</small></label>
<label v-if="props.service === 'transfer' || props.service === 'relabel'"><span><i>*</i>进仓编号</span><el-input v-model="form.inboundNo" placeholder="请输入已有入仓编号" /></label>
<label v-if="props.service === 'relabel'"><span><i>*</i>来源 FBA 仓库</span><el-select v-model="form.sourceWarehouse" placeholder="请选择"><el-option label="ONT8" value="ONT8" /><el-option label="ABE8" value="ABE8" /><el-option label="CLT2" value="CLT2" /></el-select></label>
<label v-else-if="props.service === 'disposal'"><span>进仓编号</span><el-input v-model="form.inboundNo" readonly /></label>
@@ -198,7 +198,7 @@ async function openDetail(value: Record<string, string>) {
</form>
</template>
<template v-else><div class="warehouse-breadcrumb"><button type="button" @click="mode = 'list'">{{ config.title }}</button><b>/</b><strong>订单详情</strong></div><div class="warehouse-page-heading"><h2>{{ config.title }}详情</h2><el-button @click="mode = 'list'"><ArrowLeft :size="16" />返回列表</el-button></div><div class="warehouse-card warehouse-form-section"><h3>订单概览</h3><div class="warehouse-detail-grid"><div v-for="(column, index) in config.columns.slice(0, -1)" :key="column"><span>{{ column }}</span><strong>{{ selectedDetail?.[String(index)] || '-' }}</strong></div></div></div></template>
<template v-else><div class="warehouse-breadcrumb"><button type="button" @click="mode = 'list'">{{ config.title }}</button><b>/</b><strong>订单详情</strong></div><div class="warehouse-page-heading"><h2>{{ config.title }}详情</h2><el-button @click="mode = 'list'"><ArrowLeft :size="16" />返回列表</el-button></div><div class="warehouse-card warehouse-form-section"><h3>订单概览</h3><div class="warehouse-detail-grid"><div v-for="(column, index) in config.columns.slice(0, -1)" :key="column"><span>{{ column }}</span><strong>{{ selectedDetail?.[String(index)] || '—' }}</strong></div></div></div></template>
</section>
</template>
@@ -107,3 +107,12 @@ test('keeps client baseline permissions selected when server resources do not de
assert.match(permissionsViewSource, /:disabled="isRequiredPermission\(item\.code\)"/)
assert.doesNotMatch(permissionsViewSource, /selectedCodes\.value\.unshift\('account:profile:view'\)/)
})
test('exposes all message-center permissions in the enterprise role editor', () => {
for (const permission of ['merchant:notification:view', 'merchant:notification:read', 'merchant:notification:read-all']) {
assert.match(contractsSource, new RegExp(`code: '${permission}'`))
assert.match(contractsSource, new RegExp(`'${permission}': \\['${permission}'\\]`))
}
assert.match(contractsSource, /key: 'notifications'/)
assert.match(storeSource, /const mappedMerchantPermissionCodes = new Set\(Object\.values\(FRONTEND_TO_MERCHANT_PERMISSION_CODES\)\.flat\(\)\)/)
})
@@ -25,6 +25,15 @@ export const TENANT_PERMISSION_GROUPS: readonly TenantPermissionGroup[] = [
label: '业务总览',
items: [{ code: 'dashboard:view', label: '查看总览', description: '所有企业成员默认可用,可查看企业统计和最近订单' }],
},
{
key: 'notifications',
label: '消息中心',
items: [
{ code: 'merchant:notification:view', label: '查看我的消息', description: '查看本企业和当前成员可见的站内消息' },
{ code: 'merchant:notification:read', label: '标记消息已读', description: '将已查看的站内消息标记为已读' },
{ code: 'merchant:notification:read-all', label: '全部标记已读', description: '将当前成员的全部站内消息标记为已读' },
],
},
{
key: 'finance',
label: '财务账单',
@@ -200,7 +200,7 @@ async function resetPassword(member: TenantMember) {
<el-form-item label="登录账号" prop="account" required><el-input v-model="form.account" :disabled="Boolean(editingId)" maxlength="64" placeholder="4–64 位字母、数字或 _ . @ -" /></el-form-item>
<el-form-item v-if="!editingId" label="初始密码" prop="password" required><el-input v-model="form.password" type="password" show-password autocomplete="new-password" maxlength="64" placeholder="8–64 位,同时包含字母和数字" /></el-form-item>
<div class="member-form-grid"><el-form-item label="联系电话" prop="phone"><el-input v-model="form.phone" maxlength="32" placeholder="例如 13800138000(选填)" /></el-form-item><el-form-item label="邮箱" prop="email"><el-input v-model="form.email" maxlength="128" placeholder="例如 name@example.com(选填)" /></el-form-item></div>
<el-form-item label="成员角色" prop="roleIds" required><el-select v-model="form.roleIds" multiple :disabled="Boolean(editingId) && !canGrantMemberRole" placeholder="请选择角色"><el-option v-for="role in store.roles.filter((item) => item.status === 'ACTIVE')" :key="role.id" :label="role.name" :value="role.id" /></el-select></el-form-item>
<el-form-item label="成员角色" prop="roleIds" required><el-select v-model="form.roleIds" multiple :disabled="Boolean(editingId) && !canGrantMemberRole" placeholder="请选择角色"><el-option v-for="role in store.roles.filter((item) => item.status === 'ACTIVE' && (!item.builtin || form.roleIds.includes(item.id)))" :key="role.id" :label="role.name" :value="role.id" :disabled="role.builtin" /></el-select></el-form-item>
<div v-if="!editingId" class="member-form-tip"><KeyRound :size="17" /><span>请通过安全渠道将初始密码交给成员;前端不会保存或再次展示该密码。</span></div>
</el-form>
<template #footer><div class="drawer-actions"><el-button :disabled="saving" @click="editorOpen = false">取消</el-button><el-button type="primary" :loading="saving" @click="saveMember">保存</el-button></div></template>
+3 -3
View File
@@ -26,12 +26,12 @@ test('style source contract: constrains the desktop sidebar and gives the menu i
test('style source contract: presents native grouped rows matching the prototype active and divider states', () => {
assert.doesNotMatch(compact, /\.client-sidebar \.el-menu|\.el-menu-item|\.el-sub-menu/)
assert.match(compact, /\.client-nav-menu,[^\{]*\.client-nav-submenu\s*\{[^}]*margin:\s*0[^}]*padding:\s*0[^}]*list-style:\s*none/)
assert.match(compact, /\.client-sidebar-group-trigger,[^\{]*\.client-sidebar-link\s*\{[^}]*height:\s*44px[^}]*border-radius:\s*10px[^}]*color:\s*rgba\(255, 255, 255, \.72\)/)
assert.match(compact, /\.client-sidebar-group-trigger,[^\{]*\.client-sidebar-link\s*\{[^}]*height:\s*46px[^}]*border-radius:\s*10px[^}]*color:\s*rgba\(255, 255, 255, \.82\)/)
assert.match(compact, /\.client-nav-submenu \.client-sidebar-link\[aria-current="page"\]\s*\{[^}]*background:\s*rgba\(30, 136, 229, \.25\)[^}]*color:\s*#fff/)
assert.match(compact, /\.client-nav-node\.is-active-parent > \.client-sidebar-group-trigger\s*\{[^}]*color:\s*#fff/)
assert.match(compact, /\.client-nav-node\.has-divider-before\s*\{[^}]*border-top:\s*1px solid rgba\(255, 255, 255, \.09\)/)
assert.match(compact, /\.client-nav-submenu\s*\{[^}]*padding:\s*2px 0 2px 12px/)
assert.match(compact, /\.client-nav-submenu \.client-sidebar-link\s*\{[^}]*height:\s*40px[^}]*margin:\s*1px 0[^}]*padding:\s*0 12px[^}]*border-radius:\s*9px/)
assert.match(compact, /\.client-nav-submenu \.client-sidebar-link\s*\{[^}]*height:\s*42px[^}]*margin:\s*1px 0[^}]*padding:\s*0 14px 0 22px[^}]*border-radius:\s*9px/)
assert.match(compact, /\.client-sidebar \.client-nav-submenu \.client-sidebar-node-icon\s*\{[^}]*width:\s*16px[^}]*height:\s*16px[^}]*flex:\s*0 0 16px/)
assert.match(compact, /\.client-sidebar-group-trigger\s*\{[^}]*border:\s*0[^}]*background:\s*transparent/)
assert.match(compact, /\.client-sidebar-link\s*\{[^}]*text-decoration:\s*none/)
@@ -40,7 +40,7 @@ test('style source contract: presents native grouped rows matching the prototype
})
test('style source contract: makes navigation and drawer controls touch sized with visible keyboard focus', () => {
assert.match(compact, /\.client-sidebar-group-trigger,[^\{]*\.client-sidebar-link\s*\{[^}]*height:\s*44px/)
assert.match(compact, /\.client-sidebar-group-trigger,[^\{]*\.client-sidebar-link\s*\{[^}]*height:\s*46px/)
assert.match(compact, /\.client-sidebar-group-trigger:focus-visible,\s*\.client-sidebar-link:focus-visible,[\s\S]*?\.client-sidebar-logout:focus-visible\s*\{[^}]*outline:\s*2px solid #6db9ff[^}]*outline-offset:\s*2px/)
assert.match(compact, /\.client-sidebar-logout\s*\{[^}]*height:\s*44px[^}]*background:\s*rgba\(239, 68, 68, \.12\)/)
})