Release/test #2

Merged
admin merged 19 commits from release/test into main 2026-09-18 18:03:45 +08:00
72 changed files with 2987 additions and 1756 deletions
+1 -1
View File
@@ -56,7 +56,7 @@
| 客户资金账户、授信与充值申请 | `src/modules/finance/views/FinanceAccountView.vue`、`api/account.ts` | 显示当前企业 CNY 实际余额、冻结金额、授信额度、授信有效期、已用授信、冻结授信、总可用余额和不可变流水;数据加载前显示“—”,不得先伪装为零。授信到期日当天仍有效,次日已用授信并入实际余额,负数实际余额会阻止提交新订单。充值只展示服务端启用的微信/支付宝静态收款码,客户上传 JPG/PNG 付款截图后进入待审核。客户范围只取登录态,无收款码时禁止提交,不模拟支付成功 |
| 海外采购、美国本土卡与美本土散板 | `src/modules/cross-border/routes.ts`、`components/BusinessOrdersView.vue`、`api/businessOrders.ts` | 三个路由均为 `active`,客户范围只取 Token;采购创建的 `customerName` 只取当前认证会话企业名称,不允许自由输入或冒用其他客户。Backend `lastleg-service/` 的 `procurement/`、`trucking/`、`looseboard/` 客户接口 |
| 海外仓入库订单 | `src/modules/order-management/routes.ts`、`views/InboundOrdersView.vue`、`api/inboundPlans.ts` | `orders/inbound` 为只读列表/详情,权限 `merchant:warehouse:inbound:list`;不展示或提交内部 ID |
| 海外仓贴标、仓储转运、货物废弃 | `src/modules/overseas-warehouse/routes.ts`、`views/WarehouseServiceView.vue`、`api/warehouseServices.ts` | 三个路由均为 `active`;退货预报另见本节独立入口;贴标申请先选国外中转仓和有库存历史入仓单,换标申请必须提交正数 CNY `chargeAmount`,换标类型为只换 SKU、只换箱唛、SKU+箱唛。箱唛换标明细一行固定代表一箱,箱数不可编辑,提交也固定为 1。货物明细外层始终提供“SKU 关联”“箱号关联”“SKU 混箱换标”三套模板的下载、上传,不随换标类型切换;上传只进入待导入关系区,客户可勾选部分或全选后导入本次明细,只有进入明细的库存才随申请提交和冻结。明细也支持手动新增编辑,组合换标以旧箱唛连接旧/新 SKU 和旧箱唛/新 FBA 箱唛关系。涉及箱唛换标时必须选择卡车、自提或快递;快递可选择打印面单并上传 PDF/图片。PDA 逐箱换标、上传照片并由仓库复核后,客户详情展示完工照片和“换标完成确认”;客户确认前不得继续出库,确认后卡车进入预排车,自提/快递进入换标出库待创建。历史库存位置不能解释成箱唛与具体托盘快照,客户页面不暴露托盘、库位和内部库存 ID |
| 海外仓贴标、仓储转运、货物废弃 | `src/modules/overseas-warehouse/routes.ts`、`views/WarehouseServiceView.vue`、`api/warehouseServices.ts` | 三个路由均为 `active`;退货预报另见本节独立入口;贴标申请先选国外中转仓和有库存历史入仓单,换标类型为只换 SKU、只换箱唛、SKU+箱唛。涉及箱唛换标时,整张贴标申请只选择一个 FBA 目的仓,全部箱级明细统一提交该目的仓,模板和明细不再逐行选择目的仓。箱唛换标明细一行固定代表一箱,箱数不可编辑,提交也固定为 1。货物明细外层始终提供“SKU 关联”“箱号关联”“SKU 混箱换标”三套模板的下载、上传,不随换标类型切换;上传只进入待导入关系区,客户可勾选部分或全选后导入本次明细,只有进入明细的库存才随申请提交和冻结。明细也支持手动新增编辑,组合换标以仓库唛头连接旧/新 SKU 和新 FBA 箱唛关系。涉及箱唛换标时必须选择卡车、自提或快递;快递可选择打印面单并上传 PDF/图片。PDA 逐箱换标、上传照片并由仓库复核后,客户详情展示完工照片和“换标完成确认”;客户确认前不得继续出库,确认后卡车进入预排车,自提/快递进入换标出库待创建。历史库存位置不能解释成箱唛与具体托盘快照,客户页面不暴露托盘、库位和内部库存 ID |
## 租户、认证与数据安全
Vendored
+65
View File
@@ -1,3 +1,37 @@
def sendProductionWeComNotification(String status, String color, String detail) {
withCredentials([string(credentialsId: 'wecom-prod-webhook', variable: 'WECOM_WEBHOOK')]) {
String sourceVersion = env.SOURCE_TAG ?: env.GIT_COMMIT ?: '—'
String content = """## 生产部署通知
> 项目:${env.JOB_NAME}
> 构建:[#${env.BUILD_NUMBER}](${env.BUILD_URL})
> 提交:${sourceVersion}
> 状态:<font color="${color}">${status}</font>
> 详情:${detail}
> 时间:${new Date().format('yyyy-MM-dd HH:mm:ss', TimeZone.getTimeZone('Asia/Shanghai'))}
"""
String payloadFile = 'wecom-production-notification.json'
writeFile(
file: payloadFile,
text: groovy.json.JsonOutput.toJson([
msgtype: 'markdown',
markdown: [content: content]
]),
encoding: 'UTF-8'
)
sh '''
set -eu
payload_file='wecom-production-notification.json'
trap 'rm -f "$payload_file"' EXIT HUP INT TERM
response="$(curl --fail --silent --show-error \
-H 'Content-Type: application/json' \
--data-binary @"$payload_file" \
"$WECOM_WEBHOOK")"
printf '%s\n' "$response"
printf '%s' "$response" | grep -Eq '"errcode"[[:space:]]*:[[:space:]]*0([[:space:]]*[,}])'
'''
}
}
pipeline {
agent {
label "${env.JOB_BASE_NAME?.endsWith('-prod') ? 'java-builder' : (env.JOB_BASE_NAME?.endsWith('-dev') ? 'java-dev' : 'java-test')}"
@@ -161,6 +195,17 @@ pipeline {
}
post {
success {
script {
if (env.JOB_BASE_NAME?.endsWith('-prod')) {
try {
sendProductionWeComNotification('部署成功', 'info', 'Client 生产前端已完成发布和部署检查')
} catch (Exception error) {
echo "企业微信通知失败,不影响原构建结果:${error.message}"
}
}
}
}
failure {
sh '''
if [ "${IS_PRODUCTION:-false}" = "true" ]; then
@@ -171,6 +216,26 @@ pipeline {
docker compose -f docker-compose.test.yml logs --tail=100 || true
fi
'''
script {
if (env.JOB_BASE_NAME?.endsWith('-prod')) {
try {
sendProductionWeComNotification('部署失败', 'warning', '请打开 Jenkins 构建日志查看失败阶段')
} catch (Exception error) {
echo "企业微信通知失败,不影响原构建结果:${error.message}"
}
}
}
}
aborted {
script {
if (env.JOB_BASE_NAME?.endsWith('-prod')) {
try {
sendProductionWeComNotification('部署已取消', 'comment', '生产部署流程被人工取消或中止')
} catch (Exception error) {
echo "企业微信通知失败,不影响原构建结果:${error.message}"
}
}
}
}
}
}
+4 -2
View File
@@ -12,6 +12,8 @@ services:
restart: unless-stopped
ports:
- "${HOST_PORT:-9002}:80"
extra_hosts:
- "host.docker.internal:host-gateway"
environment:
API_UPSTREAM: "http://test-api-cn.buggoglobal.com"
OVERSEAS_API_UPSTREAM: "http://test-api-global.buggoglobal.com"
API_UPSTREAM: "${API_UPSTREAM:-http://host.docker.internal:9000}"
OVERSEAS_API_UPSTREAM: "${OVERSEAS_API_UPSTREAM:-http://host.docker.internal:9300}"
+9 -4
View File
@@ -144,6 +144,7 @@ export async function apiRequest<T>(config: AxiosRequestConfig) {
export async function apiBlobRequest(config: AxiosRequestConfig) {
try {
const response = await transport.request<Blob>({ ...config, responseType: 'blob' })
await throwBlobApiError(response.data, response.status)
return response.data
} catch (error) {
await throwBlobApiError(error)
@@ -154,6 +155,7 @@ export async function apiBlobRequest(config: AxiosRequestConfig) {
export async function apiBlobResponseRequest(config: AxiosRequestConfig) {
try {
const response = await transport.request<Blob>({ ...config, responseType: 'blob' })
await throwBlobApiError(response.data, response.status)
return { blob: response.data, headers: response.headers }
} catch (error) {
await throwBlobApiError(error)
@@ -161,16 +163,19 @@ export async function apiBlobResponseRequest(config: AxiosRequestConfig) {
}
}
async function throwBlobApiError(error: unknown) {
if (!axios.isAxiosError(error) || !(error.response?.data instanceof Blob)) return
async function throwBlobApiError(error: unknown, responseStatus?: number) {
const blob = error instanceof Blob
? error
: axios.isAxiosError(error) && error.response?.data instanceof Blob ? error.response.data : null
if (!blob || !blob.type.toLowerCase().includes('json')) return
try {
const body = JSON.parse(await error.response.data.text()) as unknown
const body = JSON.parse(await blob.text()) as unknown
if (!isApiEnvelope(body)) return
const fieldErrors = asFieldErrors(body.data)
throw new ApiError(
apiErrorMessage(body.message, fieldErrors),
body.code,
error.response.status,
responseStatus ?? (axios.isAxiosError(error) ? error.response?.status : undefined),
fieldErrors,
typeof body.traceId === 'string' ? body.traceId : null,
typeof body.requestId === 'string' ? body.requestId : null,
+24
View File
@@ -65,3 +65,27 @@ export async function overseasApiRequest<T>(config: AxiosRequestConfig) {
throw new ApiError(error instanceof Error ? error.message : '海外接口请求失败')
}
}
export async function overseasBlobRequest(config: AxiosRequestConfig) {
try {
const response = await transport.request<Blob>({ ...config, responseType: 'blob' })
return response.data
} catch (error) {
if (error instanceof ApiError) throw error
if (axios.isAxiosError(error)) {
const body = error.response?.data
if (body instanceof Blob && body.type.includes('json')) {
try {
const parsed: unknown = JSON.parse(await body.text())
if (isApiEnvelope(parsed) || isApiFailure(parsed)) {
throw new ApiError(parsed.message, parsed.code, error.response?.status, undefined, parsed.traceId, parsed.requestId)
}
} catch (parseError) {
if (parseError instanceof ApiError) throw parseError
}
}
throw new ApiError(error.code === 'ERR_NETWORK' ? '无法连接海外服务' : error.message, error.code || 'OVERSEAS_HTTP_ERROR', error.response?.status)
}
throw new ApiError(error instanceof Error ? error.message : '海外文件下载失败')
}
}
+1
View File
@@ -33,6 +33,7 @@ declare module 'vue' {
ElEmpty: typeof import('element-plus/es')['ElEmpty']
ElForm: typeof import('element-plus/es')['ElForm']
ElFormItem: typeof import('element-plus/es')['ElFormItem']
ElImage: typeof import('element-plus/es')['ElImage']
ElInput: typeof import('element-plus/es')['ElInput']
ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
ElOption: typeof import('element-plus/es')['ElOption']
+5 -3
View File
@@ -163,7 +163,7 @@ function setPage(value: number) {
</template>
<template v-if="column.slotName === 'actions' && rowActions" #default="{ row }">
<div class="data-table__row-actions">
<el-button v-for="action in splitTableActions(rowActions(row)).visible" :key="action.key"
<el-button v-for="action in splitTableActions(rowActions(row)).visible" :key="action.key" link type="primary"
:disabled="action.disabled" :loading="action.loading" @click="runTableAction(action)">
{{ action.label }}
</el-button>
@@ -232,9 +232,11 @@ function setPage(value: number) {
.data-table__toolbar-heading :slotted(strong) { color: var(--client-text); font-size: 14px; }
.data-table__toolbar-actions :deep(.el-button + .el-button) { margin-left: 0; }
.data-table__row-actions { display: flex; align-items: center; gap: var(--client-gap); white-space: nowrap; }
.data-table__row-actions :deep(.el-button) { height: var(--client-row-action-height); padding: 0 8px; margin: 0; }
.data-table__row-actions :deep(.el-button) { height: var(--client-row-action-height); padding: 0 4px; margin: 0; }
.data-table__row-actions :deep(.el-button.is-link + .el-button.is-link) { position: relative; }
.data-table__row-actions :deep(.el-button.is-link + .el-button.is-link)::before { position: absolute; left: -5px; width: 1px; height: 12px; background: var(--client-border); content: ''; }
.data-table :deep(.data-table__system-cell .data-table__row-actions .el-button + .el-button), .data-table :deep(.data-table__system-cell .data-table__row-actions .el-button + .el-dropdown) { margin-left: 0; }
.data-table__more { min-width: 28px; }
.data-table__more { min-width: 28px; width: 28px; padding: 0; }
:global(.client-ui .data-table .el-table) { color: var(--client-text); font-size: var(--client-font-size); --el-table-border-color: var(--client-border); --el-table-header-bg-color: var(--client-header-bg); --el-table-row-hover-bg-color: var(--client-hover-bg); }
:global(.client-ui .data-table th.el-table__cell) { height: var(--client-table-header-height); padding: 0; color: var(--client-muted); font-weight: 600; }
:global(.client-ui .data-table td.el-table__cell) { height: var(--client-table-row-height); padding: 0; }
+3 -2
View File
@@ -121,8 +121,8 @@ defineExpose({ search, reset })
</label>
</div>
<div class="query-toolbar__actions">
<el-button type="primary" @click="search"><Search :size="16" />{{ searchText }}</el-button>
<el-button @click="reset"><RotateCcw :size="16" />{{ resetText }}</el-button>
<el-button class="query-toolbar__search" type="primary" @click="search"><Search :size="16" />{{ searchText }}</el-button>
<el-button class="query-toolbar__reset" @click="reset"><RotateCcw :size="16" />{{ resetText }}</el-button>
<slot name="actions" />
</div>
</section>
@@ -136,6 +136,7 @@ defineExpose({ search, reset })
.query-toolbar__filters :deep(.el-input), .query-toolbar__filters :deep(.el-select), .query-toolbar__filters :deep(.el-date-editor) { width: 260px; max-width: 100%; min-width: 0; }
.query-toolbar__actions { display: flex; flex: 0 0 auto; flex-wrap: wrap; justify-content: flex-start; gap: 8px; }
.query-toolbar__actions :deep(.el-button + .el-button) { margin-left: 0; }
.query-toolbar__actions :deep(.query-toolbar__search), .query-toolbar__actions :deep(.query-toolbar__reset) { min-width: 72px; }
.query-toolbar--grid { display: block; padding: var(--client-query-padding); border: 1px solid var(--client-border); border-radius: var(--client-radius); }
.query-toolbar--grid .query-toolbar__filters { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px 16px; }
.query-toolbar--grid .query-toolbar__field { min-width: 0; }
+4 -2
View File
@@ -22,8 +22,10 @@ test('does not cache the SPA entry while keeping hashed assets immutable', () =>
})
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, /- "host\.docker\.internal:host-gateway"/)
assert.match(testCompose, /API_UPSTREAM: "\$\{API_UPSTREAM:-http:\/\/host\.docker\.internal:9000\}"/)
assert.match(testCompose, /OVERSEAS_API_UPSTREAM: "\$\{OVERSEAS_API_UPSTREAM:-http:\/\/host\.docker\.internal:9300\}"/)
assert.doesNotMatch(testCompose, /http:\/\/test-api-(?:cn|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;/)
+1
View File
@@ -11,6 +11,7 @@ declare module 'vue-router' {
requiresAuth?: boolean
permission?: string
workspaceTab?: boolean
workspaceTitle?: string
keepAlive?: boolean
}
}
+22 -13
View File
@@ -13,12 +13,13 @@ test('source contract: renders the grouped sidebar menu and mobile drawer contro
assert.match(layoutSource, /import \{ computed, nextTick, onBeforeUnmount, onMounted, ref, watch \} from 'vue'/)
assert.match(layoutSource, /import \{[\s\S]*?\bX\s*\} from '@lucide\/vue'/)
assert.match(layoutSource, /import ClientSidebarMenu from '@\/components\/ClientSidebarMenu\.vue'/)
assert.match(layoutSource, /:class="\{ 'mobile-menu-open': mobileMenuOpen \}"/)
assert.match(layoutSource, /:class="\{ 'mobile-menu-open': mobileMenuOpen, 'is-dashboard-route': route\.path === '\/' \}"/)
assert.match(layoutSource, /<button\s+v-if="mobileMenuOpen"[^>]*class="client-sidebar-scrim"[^>]*aria-label="关闭导航"[^>]*@click="closeMobileMenu"/s)
assert.match(layoutSource, /<aside\s+ref="sidebarRef"\s+id="client-sidebar"\s+class="client-sidebar"\s+:inert="!desktopViewport && !mobileMenuOpen"\s+:aria-hidden="!desktopViewport && !mobileMenuOpen \? 'true' : undefined"\s*>/)
assert.doesNotMatch(layoutSource, /<aside[^>]*aria-label=/)
assert.match(layoutSource, /<nav aria-label="客户工作台导航">/)
assert.match(layoutSource, /<div class="client-sidebar-brand">[\s\S]*?<RouterLink class="client-sidebar-brand-link" to="\/" aria-label="返回业务总览" @click="closeMobileMenu">[\s\S]*?alt="柏悦物流"[\s\S]*?客户工作台/s)
assert.match(layoutSource, /<div class="client-sidebar-brand">[\s\S]*?<RouterLink class="client-sidebar-brand-link" to="\/" aria-label="返回业务总览" @click="closeMobileMenu">[\s\S]*?alt="柏悦物流"/s)
assert.doesNotMatch(layoutSource, /client-sidebar-brand-copy/)
assert.match(layoutSource, /<button[^>]*ref="sidebarCloseButtonRef"[^>]*aria-label="关闭导航"[^>]*@click="closeMobileMenu"[^>]*>\s*<X :size="20" \/>/s)
assert.match(layoutSource, /<ClientSidebarMenu\s+:active-path="route\.path"\s+@navigate="closeMobileMenu"\s*\/>/s)
assert.match(layoutSource, /<button type="button" class="client-sidebar-logout" @click="handleLogout">[\s\S]*?退出登录/s)
@@ -45,11 +46,13 @@ test('source contract: removes the header and main content from the accessibilit
assert.match(layoutSource, /<main\s+class="client-main"\s+:inert="mobileMenuOpen"\s+:aria-hidden="mobileMenuOpen \? 'true' : undefined"\s*>/)
})
test('source contract: keeps route context and user identity in the compact header', () => {
test('source contract: keeps the prototype greeting and fixed message and profile utilities in the compact header', () => {
assert.match(layoutSource, /const pageTitle = computed\(\(\) => route\.meta\.title \|\| '业务总览'\)/)
assert.match(layoutSource, /const pageDescription = computed\(\(\) => route\.meta\.description \|\| ''\)/)
assert.match(layoutSource, /<div class="client-header-heading">\s*<h1>\{\{ pageTitle \}\}<\/h1>\s*<p v-if="pageDescription">\{\{ pageDescription \}\}<\/p>/s)
assert.doesNotMatch(layoutSource, /const pageDescription/)
assert.match(layoutSource, /<p class="client-header-greeting">Hi, 客户<\/p>/)
assert.match(layoutSource, /<div class="client-user">[\s\S]*\{\{ auth\.user\?\.name \}\}/)
assert.match(layoutSource, /class="client-header-notifications" to="\/notifications" aria-label="打开我的消息"/)
assert.match(layoutSource, /class="client-header-profile"[\s\S]*to="\/account\/profile"[\s\S]*aria-label="打开我的信息"/)
assert.doesNotMatch(layoutSource, /<div class="client-user">[\s\S]*aria-label="退出登录"/)
assert.doesNotMatch(layoutSource, /Building2|tenant-name|tenantName/)
assert.doesNotMatch(routerSource, /showPageHeading|showTenantName/)
@@ -58,19 +61,25 @@ test('source contract: keeps route context and user identity in the compact head
test('source contract: renders the complete route breadcrumb before page content', () => {
assert.match(layoutSource, /import \{ getFirstAccessiblePath, getRouteBreadcrumbs \} from '@\/navigation\/clientNavigation'/)
assert.match(layoutSource, /const breadcrumbs = computed\(\(\) => getRouteBreadcrumbs\(route\.path, String\(pageTitle\.value\)\)\)/)
assert.match(layoutSource, /<nav class="client-breadcrumb" aria-label="页面路径">[\s\S]*?v-for="\(item, index\) in breadcrumbs"[\s\S]*?<RouterLink v-if="item\.path" :to="item\.path">[\s\S]*?<strong v-else aria-current="page">/)
assert.match(layoutSource, /<nav v-if="route\.path !== '\/'" class="client-breadcrumb" aria-label="页面路径">[\s\S]*?v-for="\(item, index\) in breadcrumbs"[\s\S]*?<RouterLink v-if="item\.path" :to="item\.path">[\s\S]*?<strong v-else aria-current="page">/)
assert.match(shellStyles, /\.client-breadcrumb \{[^}]*margin: 0 0 14px;/)
})
test('source contract: aligns the light top bar beside the prototype-inspired dark sidebar and bounds the mobile header', () => {
assert.match(shellStyles, /\.top-header \{[^}]*inset: 0 0 auto 250px;[^}]*max-width: calc\(100vw - 250px\);[^}]*padding: 0 24px;/)
assert.match(shellStyles, /\.top-header \{[^}]*background: rgba\(255, 255, 255, \.96\)/)
assert.match(shellStyles, /\.client-sidebar \{[^}]*inset: 0 auto 0 0;[^}]*width: 250px;[^}]*background: linear-gradient\(180deg, #0b1f3a 0%, #0c2a52 100%\)/)
assert.match(shellStyles, /\.client-sidebar-brand \{[^}]*border-bottom: 1px solid rgba\(255, 255, 255, \.09\)/)
assert.match(shellStyles, /\.client-main \{[^}]*padding: 134px 20px 40px;/)
test('source contract: aligns the blue-tinted prototype shell around a compact sidebar and bounds the mobile header', () => {
assert.match(shellStyles, /\.top-header \{[^}]*inset: 0 0 auto 224px;[^}]*max-width: calc\(100vw - 224px\);[^}]*padding: 0 24px;/)
assert.match(shellStyles, /\.top-header \{[^}]*background: rgba\(255, 255, 255, \.98\)/)
assert.match(shellStyles, /\.client-sidebar \{[^}]*inset: 0 auto 0 0;[^}]*width: 224px;[^}]*border-right: 1px solid #dfe8f3;[^}]*background: linear-gradient\(180deg, #f5f8ff 0%, #edf5fc 100%\)/)
assert.match(shellStyles, /\.client-sidebar-brand \{[^}]*border-radius: 15px;[^}]*background: linear-gradient\(145deg, #0a2b50, #0b3d6c\)/)
assert.match(shellStyles, /\.client-main \{[^}]*padding: 126px 20px 40px;/)
assert.match(shellStyles, /\.is-dashboard-route \.client-main \{ padding-top: 88px; \}/)
assert.match(globalStyles, /@media \(max-width: 720px\) \{[\s\S]*\.top-header \{ inset: 0;[^}]*padding: 0 14px; \}/)
assert.match(globalStyles, /\.client-header-menu-button \{ display: grid; \}/)
assert.match(globalStyles, /\.client-header-heading p, \.client-user-copy \{ display: none; \}/)
assert.match(globalStyles, /\.client-header-greeting, \.client-user-copy \{ display: none; \}/)
})
test('source contract: keeps the dashboard free of a redundant workspace tab', () => {
assert.match(layoutSource, /<WorkspaceTabs\s+v-if="route\.path !== '\/'"/)
assert.match(layoutSource, /'is-dashboard-route': route\.path === '\/'/)
})
test('source contract: closes the mobile menu on route changes and Escape', () => {
+17 -10
View File
@@ -24,7 +24,6 @@ const notifications = useNotificationsStore()
const workspace = useWorkspaceTabsStore()
const unsaved = useUnsavedWorkStore()
const pageTitle = computed(() => route.meta.title || '业务总览')
const pageDescription = computed(() => route.meta.description || '')
const breadcrumbs = computed(() => getRouteBreadcrumbs(route.path, String(pageTitle.value)))
const mobileMenuOpen = ref(false)
const desktopViewport = ref(true)
@@ -185,7 +184,7 @@ async function handleLogout() {
</script>
<template>
<div class="client-shell" :class="{ 'mobile-menu-open': mobileMenuOpen }">
<div class="client-shell" :class="{ 'mobile-menu-open': mobileMenuOpen, 'is-dashboard-route': route.path === '/' }">
<header class="top-header" :inert="mobileMenuOpen" :aria-hidden="mobileMenuOpen ? 'true' : undefined">
<button
ref="menuButtonRef"
@@ -198,14 +197,22 @@ async function handleLogout() {
>
<Menu :size="20" />
</button>
<div class="client-header-heading">
<h1>{{ pageTitle }}</h1>
<p v-if="pageDescription">{{ pageDescription }}</p>
</div>
<p class="client-header-greeting">Hi, 客户</p>
<div class="client-user">
<RouterLink v-if="auth.hasPermission('merchant:notification:view')" class="client-header-notifications" to="/notifications" aria-label="打开我的消息"><Bell :size="19" /><span v-if="notifications.unreadCount !== null && notifications.unreadCount > 0" class="client-header-notifications-badge">{{ notifications.unreadCount > 99 ? '99+' : notifications.unreadCount }}</span></RouterLink>
<span class="client-avatar">{{ auth.user?.name.slice(0, 1) }}</span>
<span class="client-user-copy"><strong>{{ auth.user?.name }}</strong><small>{{ auth.user?.roles.join('、') }}</small></span>
<RouterLink
v-if="auth.hasPermission('account:profile:view')"
class="client-header-profile"
to="/account/profile"
aria-label="打开我的信息"
>
<span class="client-avatar">{{ auth.user?.name.slice(0, 1) }}</span>
<span class="client-user-copy"><strong>{{ auth.user?.name }}</strong><small>{{ auth.user?.roles.join('、') }}</small></span>
</RouterLink>
<template v-else>
<span class="client-avatar">{{ auth.user?.name.slice(0, 1) }}</span>
<span class="client-user-copy"><strong>{{ auth.user?.name }}</strong><small>{{ auth.user?.roles.join('、') }}</small></span>
</template>
</div>
</header>
@@ -227,7 +234,6 @@ async function handleLogout() {
<div class="client-sidebar-brand">
<RouterLink class="client-sidebar-brand-link" to="/" aria-label="返回业务总览" @click="closeMobileMenu">
<img class="client-sidebar-brand-logo" :src="baiyueLogoDark" alt="柏悦物流" />
<span class="client-sidebar-brand-copy"><small>客户工作台</small></span>
</RouterLink>
<button
ref="sidebarCloseButtonRef"
@@ -251,6 +257,7 @@ async function handleLogout() {
</aside>
<WorkspaceTabs
v-if="route.path !== '/'"
:inert="mobileMenuOpen"
:aria-hidden="mobileMenuOpen ? 'true' : undefined"
/>
@@ -260,7 +267,7 @@ async function handleLogout() {
:inert="mobileMenuOpen"
:aria-hidden="mobileMenuOpen ? 'true' : undefined"
>
<nav class="client-breadcrumb" aria-label="页面路径">
<nav v-if="route.path !== '/'" class="client-breadcrumb" aria-label="页面路径">
<template v-for="(item, index) in breadcrumbs" :key="`${item.title}-${index}`">
<span v-if="index" aria-hidden="true">/</span>
<RouterLink v-if="item.path" :to="item.path">{{ item.title }}</RouterLink>
@@ -8,13 +8,17 @@ const view = readFileSync(new URL('./components/BusinessOrdersView.vue', import.
test('activates all three customer cross-border pages with frozen permissions', () => {
for (const [path, permission] of [
['cross-border/procurement', 'merchant:cross-border:procurement'],
['cross-border/domestic-trucking', 'merchant:cross-border:domestic-trucking'],
['cross-border/pallet-shipping', 'merchant:cross-border:pallet-shipping'],
['cross-border/procurement', 'merchant:cross-border:procurement:list'],
['cross-border/domestic-trucking', 'merchant:cross-border:domestic-trucking:list'],
['cross-border/pallet-shipping', 'merchant:cross-border:pallet-shipping:list'],
]) {
assert.match(routes, new RegExp(`path: '${path}'[\\s\\S]*?release: 'active'[\\s\\S]*?permission: '${permission}'`))
}
assert.doesNotMatch(view, /PrototypeFeatureView|mock|customerId|tenantId/i)
assert.match(view, /merchant:cross-border:procurement:create/)
assert.match(view, /merchant:cross-border:domestic-trucking:create/)
assert.match(view, /merchant:cross-border:pallet-shipping:create/)
assert.match(view, /v-if="canCreate"[^>]*type="primary"/)
})
test('uses only merchant overseas endpoints and keeps retry idempotency stable', () => {
@@ -32,10 +36,12 @@ test('covers list detail create loading error empty pagination and actions-first
assert.match(view, /mode\.value = 'list'/)
assert.match(view, /mode = 'create'/)
assert.match(view, /v-loading="loading"/)
assert.match(view, /type="error"/)
assert.match(view, /class="business-load-error"/)
assert.match(view, /empty-text="暂无订单"/)
assert.match(view, /<el-pagination/)
assert.match(view, /<el-table-column label="操作"[^>]*fixed="left"/)
assert.match(view, /<QueryToolbar[^>]*layout="grid"/)
assert.match(view, /<DataTable[^>]*:row-actions="getRowActions"/)
assert.match(view, /key: 'actions', label: '操作', width: 90, fixed: 'left'/)
assert.match(view, /class="business-table-card"/)
assert.match(view, /PENDING_OUTBOUND: '待出库'/)
assert.match(view, /PARTIALLY_OUTBOUND: '部分出库'/)
assert.match(view, /OUTBOUNDED: '全部出库'/)
@@ -43,10 +49,41 @@ test('covers list detail create loading error empty pagination and actions-first
assert.match(view, /\['审核备注', current\.reviewRemark\]/)
})
test('opens every dashboard cross-border action on a dedicated protected create page', () => {
for (const [path, permission] of [
['cross-border/procurement/create', 'merchant:cross-border:procurement:create'],
['cross-border/domestic-trucking/create', 'merchant:cross-border:domestic-trucking:create'],
['cross-border/pallet-shipping/create', 'merchant:cross-border:pallet-shipping:create'],
]) {
assert.match(routes, new RegExp(`path: '${path}'[\\s\\S]*?props: \\{ createPage: true \\}[\\s\\S]*?permission: '${permission}'`))
}
assert.match(view, /props\.createPage \? 'create' : 'list'/)
assert.match(view, /if \(props\.createPage\) await router\.push/)
})
test('opens all cross-border details on dedicated protected routes', () => {
for (const [path, routeName] of [
['cross-border/procurement/:orderNo/detail', 'cross-border-procurement-detail'],
['cross-border/domestic-trucking/:orderNo/detail', 'cross-border-domestic-trucking-detail'],
['cross-border/pallet-shipping/:orderNo/detail', 'cross-border-pallet-shipping-detail'],
]) {
assert.match(routes, new RegExp(`path: '${path.replaceAll('/', '\\/')}'[\\s\\S]*?name: '${routeName}'`))
}
assert.match(view, /name: detailRouteName\.value, params: \{ orderNo: row\.orderNo \}/)
assert.match(view, /class="business-detail-error"/)
})
test('keeps procurement filters, details and forms readable at PDA width', () => {
assert.match(view, /@media \(max-width: 767px\)/)
assert.match(view, /\.filter-card :deep\(\.el-form\) \{ display: grid; grid-template-columns: 1fr; \}/)
assert.match(view, /queryFields/)
assert.match(view, /\.detail-grid \{ grid-template-columns: 1fr; \}/)
assert.match(view, /\.form-card :deep\(\.el-form-item\) \{ display: block; \}/)
assert.match(view, /\.budget-form-item :deep\(\.el-form-item__content\) \{ display: flex; flex-wrap: nowrap; \}/)
})
test('uses the shared dense order-list and detail visual language', () => {
assert.match(view, /class="business-orders client-ui"/)
assert.match(view, /\.business-detail-page \{[^}]*border: 1px solid var\(--client-border\);[^}]*border-radius: var\(--client-radius\);/)
assert.match(view, /\.detail-grid \{[^}]*grid-template-columns: repeat\(4, minmax\(0, 1fr\)\)/)
assert.match(view, /\.detail-grid div \{[^}]*min-height: 34px;[^}]*grid-template-columns: 76px minmax\(0, 1fr\)/)
})
@@ -1,6 +1,10 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
import DataTable, { type DataTableColumn } from '@/components/common/DataTable.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/components/common/QueryToolbar.vue'
import type { TableAction } from '@/components/common/tableActions'
import { useAuthStore } from '@/modules/auth/public'
import {
createProcurement,
@@ -15,8 +19,16 @@ import {
} from '../api/businessOrders'
import { CONTAINER_SPECIFICATIONS, DEFAULT_CONTAINER_TYPE } from '../containerSpecifications'
const props = defineProps<{ kind: CrossBorderBusinessKind }>()
const props = withDefaults(defineProps<{ kind: CrossBorderBusinessKind; createPage?: boolean; detailPage?: boolean }>(), { createPage: false, detailPage: false })
const auth = useAuthStore()
const router = useRouter()
const route = useRoute()
const detailOrderNo = computed(() => String(route.params.orderNo || ''))
const canCreate = computed(() => auth.hasPermission(({
procurement: 'merchant:cross-border:procurement:create',
trucking: 'merchant:cross-border:domestic-trucking:create',
looseboard: 'merchant:cross-border:pallet-shipping:create',
} as Record<CrossBorderBusinessKind, string>)[props.kind]))
type BusinessRow = ProcurementSummary | TransportSummary
type BusinessDetail = ProcurementDetail | TransportDetail
@@ -96,7 +108,7 @@ function transportColumns(looseboard: boolean): ColumnDefinition[] {
]
}
const mode = ref<PageMode>('list')
const mode = ref<PageMode>(props.detailPage ? 'detail' : props.createPage ? 'create' : 'list')
const loading = ref(false)
const submitting = ref(false)
const submitKey = ref('')
@@ -107,6 +119,52 @@ const formRef = ref<FormInstance>()
const query = reactive({ page: 1, size: 20, orderNo: '', routeKeyword: '', status: '', productName: '', purchaseCategory: '' })
const total = ref(0)
const queryModel = computed<QueryModel>({
get: () => ({
orderNo: query.orderNo,
purchaseCategory: query.purchaseCategory,
productName: query.productName,
routeKeyword: query.routeKeyword,
status: query.status,
}),
set: (value) => {
query.orderNo = String(value.orderNo || '')
query.purchaseCategory = String(value.purchaseCategory || '')
query.productName = String(value.productName || '')
query.routeKeyword = String(value.routeKeyword || '')
query.status = String(value.status || '')
},
})
const queryFields = computed<QueryField[]>(() => [
{ key: 'orderNo', label: '订单号', type: 'input', placeholder: '请输入订单号' },
...(props.kind === 'procurement'
? [
{ key: 'purchaseCategory', label: '采购品类', type: 'input', placeholder: '请输入采购品类' } as QueryField,
{ key: 'productName', label: '商品名称', type: 'input', placeholder: '请输入商品名称' } as QueryField,
]
: [{ key: 'routeKeyword', label: '运输路线', type: 'input', placeholder: '请输入起运地或目的地' } as QueryField]),
{
key: 'status',
label: '状态',
type: 'select',
placeholder: '全部',
options: [
{ label: '待审核', value: 'PENDING_REVIEW' },
{ label: '已通过', value: 'APPROVED' },
{ label: '已拒绝', value: 'REJECTED' },
{ label: '待出库', value: 'PENDING_OUTBOUND' },
{ label: '部分出库', value: 'PARTIALLY_OUTBOUND' },
{ label: '全部出库', value: 'OUTBOUNDED' },
{ label: '待接单', value: 'PENDING_ACCEPTANCE' },
{ label: '处理中', value: 'PROCESSING' },
{ label: '运输中', value: 'IN_TRANSIT' },
{ label: '已完成', value: 'COMPLETED' },
{ label: '已取消', value: 'CANCELLED' },
],
},
])
const form = reactive<BusinessForm>(emptyForm())
const rules: FormRules<BusinessForm> = {
purchaseCategory: [{ required: true, message: '请输入采购品类', trigger: 'blur' }],
@@ -126,7 +184,7 @@ const detailFields = computed(() => {
if (props.kind === 'procurement') {
const current = detail.value as ProcurementDetail
return [
['订单号', summary.orderNo], ['采购品类', read(summary, 'purchaseCategory')],
['采购品类', read(summary, 'purchaseCategory')],
['商品名称', read(summary, 'productName')], ['商品规格', read(summary, 'productSpecification')],
['采购数量', read(summary, 'purchaseQuantity')], ['预算单价', money(read(summary, 'budgetUnitPrice'), read(summary, 'currency'))],
['入库量', read(summary, 'inboundQuantity')], ['出库量', read(summary, 'outboundQuantity')],
@@ -135,12 +193,12 @@ const detailFields = computed(() => {
['收货地址', current.receivingAddress], ['商品链接', current.productUrl],
['国内运单', joinText(current.domesticCarrier, current.domesticTrackingNo)],
['海外运单', joinText(current.overseasCarrier, current.overseasTrackingNo)],
['状态', statusLabel(summary.status)], ['审核备注', current.reviewRemark], ['备注', current.remark],
['审核备注', current.reviewRemark], ['备注', current.remark],
]
}
const current = detail.value as TransportDetail
return [
['订单编号', summary.orderNo], ['运输方式', read(summary, 'transportMode')],
['运输方式', read(summary, 'transportMode')],
['是否打盘', yesNo(read(summary, 'palletRequired'))], ['运输类型', transportTypeLabel(read(summary, 'transportType'))],
['起运地', read(summary, 'origin')], ['起运地址', current.originAddress],
['目的地', read(summary, 'destination')], ['目的地址', current.destinationAddress],
@@ -148,10 +206,22 @@ const detailFields = computed(() => {
['件数', read(summary, 'packageCount')], ['总重量(kg)', read(summary, 'totalWeightKg')],
['总体积(m³)', read(summary, 'totalVolumeCbm')], ['预算总价', money(read(summary, 'budgetAmount'), read(summary, 'currency'))],
['联系人', read(summary, 'contactName')], ['联系方式', read(summary, 'contactInfo')],
['要求时间', read(summary, 'requestedDate')], ['状态', statusLabel(summary.status)], ['备注', current.remark],
['要求时间', read(summary, 'requestedDate')], ['备注', current.remark],
]
})
const tableColumns = computed<DataTableColumn[]>(() => [
{ key: 'actions', label: '操作', width: 90, fixed: 'left', slotName: 'actions' },
...configuration.value.columns.map((column) => ({
...column,
prop: column.key,
slotName: ['orderNo', 'status'].includes(column.key) ? column.key : undefined,
formatter: (row: object) => displayCell(row, column.key),
})),
])
const detailStatus = computed(() => detail.value ? statusLabel(detail.value.summary.status) : '—')
function emptyForm(): BusinessForm {
return {
purchaseCategory: '', productName: '', productSpecification: '', productUrl: '', purchaseQuantity: 1,
@@ -174,6 +244,9 @@ function transportModeLabel(value: unknown) { return ({ SHARED_TRUCK: '拼车',
function statusLabel(value: unknown) {
return ({ PENDING_REVIEW: '待审核', APPROVED: '已通过', REJECTED: '已拒绝', PENDING_OUTBOUND: '待出库', PARTIALLY_OUTBOUND: '部分出库', OUTBOUNDED: '全部出库', PENDING_ACCEPTANCE: '待接单', PROCESSING: '处理中', IN_TRANSIT: '运输中', COMPLETED: '已完成', CANCELLED: '已取消' } as Record<string, string>)[String(value)] || text(value)
}
function statusTone(value: unknown) {
return ({ APPROVED: 'success', COMPLETED: 'success', OUTBOUNDED: 'success', REJECTED: 'danger', CANCELLED: 'info', PENDING_REVIEW: 'warning', PENDING_ACCEPTANCE: 'warning', PROCESSING: 'primary', IN_TRANSIT: 'primary' } as Record<string, 'success' | 'warning' | 'danger' | 'info' | 'primary'>)[String(value)] || 'info'
}
function displayCell(candidate: unknown, key: string) {
const row = candidate as BusinessRow
const value = read(row, key)
@@ -214,16 +287,52 @@ function resetQuery() {
void load()
}
async function openDetail(candidate: unknown) {
function searchOrders() {
query.page = 1
void load()
}
function getRowActions(candidate: object): TableAction[] {
return [{ key: 'detail', label: '详情', run: () => openDetail(candidate) }]
}
function detailFieldClass(label: unknown) {
return ['收货地址', '商品链接', '国内运单', '海外运单', '审核备注', '备注', '起运地址', '目的地址'].includes(String(label)) ? 'is-wide' : ''
}
const listRouteName = computed(() => ({
procurement: 'cross-border-procurement',
trucking: 'cross-border-domestic-trucking',
looseboard: 'cross-border-pallet-shipping',
} as Record<CrossBorderBusinessKind, string>)[props.kind])
const detailRouteName = computed(() => ({
procurement: 'cross-border-procurement-detail',
trucking: 'cross-border-domestic-trucking-detail',
looseboard: 'cross-border-pallet-shipping-detail',
} as Record<CrossBorderBusinessKind, string>)[props.kind])
function returnToList() {
if (props.createPage || props.detailPage) {
void router.push({ name: listRouteName.value })
return
}
mode.value = 'list'
}
function openDetail(candidate: unknown) {
const row = candidate as BusinessRow
void router.push({ name: detailRouteName.value, params: { orderNo: row.orderNo } })
}
async function loadDetail() {
if (!detailOrderNo.value) return
loading.value = true
error.value = ''
try {
detail.value = props.kind === 'procurement'
? await getBusinessOrder<ProcurementDetail>('procurement', row.orderNo)
: await getBusinessOrder<TransportDetail>(props.kind, row.orderNo)
? await getBusinessOrder<ProcurementDetail>('procurement', detailOrderNo.value)
: await getBusinessOrder<TransportDetail>(props.kind, detailOrderNo.value)
mode.value = 'detail'
} catch (cause) { ElMessage.error(errorMessage(cause)) }
} catch (cause) { detail.value = null; error.value = errorMessage(cause); ElMessage.error(error.value) }
finally { loading.value = false }
}
@@ -259,57 +368,53 @@ async function submit() {
: { ...base, transportMode: form.transportMode, palletRequired: form.palletRequired })
}
ElMessage.success('订单提交成功')
mode.value = 'list'
resetForm()
await load()
if (props.createPage) await router.push({ name: listRouteName.value })
else { mode.value = 'list'; await load() }
} catch (cause) { ElMessage.error(errorMessage(cause)) }
finally { submitting.value = false }
}
onMounted(() => void load())
watch(() => [query.page, query.size], () => { if (!props.createPage && mode.value === 'list') void load() })
onMounted(() => { if (props.detailPage) void loadDetail(); else if (!props.createPage) void load() })
</script>
<template>
<section v-loading="loading" class="business-orders" :aria-busy="loading">
<header class="page-head">
<section v-loading="loading" class="business-orders client-ui" :aria-busy="loading">
<header v-if="mode === 'create'" class="page-head">
<div>
<h2>{{ mode === 'detail' ? `${configuration.title}订单详情` : mode === 'create' ? `${configuration.title}下单` : configuration.title }}</h2>
<p>{{ mode === 'list' ? configuration.description : mode === 'create' ? '请填写订单信息,带 * 项为必填项' : `订单号:${detail?.summary.orderNo || '—'}` }}</p>
<h2>{{ configuration.title }}下单</h2>
<p>请填写订单信息,带 * 项为必填项</p>
</div>
<el-button v-if="mode !== 'list'" @click="mode = 'list'">返回列表</el-button>
<el-button v-else type="primary" @click="resetForm(); mode = 'create'">新增下单</el-button>
<el-button @click="returnToList">返回列表</el-button>
</header>
<template v-if="mode === 'list'">
<el-alert v-if="error" :title="error" type="error" show-icon :closable="false">
<template #default><el-button size="small" @click="load">重新加载</el-button></template>
</el-alert>
<el-card shadow="never" class="filter-card">
<el-form inline @submit.prevent="query.page = 1; load()">
<el-form-item label="订单号"><el-input v-model.trim="query.orderNo" clearable placeholder="请输入订单号" /></el-form-item>
<el-form-item v-if="kind === 'procurement'" label="采购品类"><el-input v-model.trim="query.purchaseCategory" clearable placeholder="请输入采购品类" /></el-form-item>
<el-form-item v-if="kind === 'procurement'" label="商品名称"><el-input v-model.trim="query.productName" clearable placeholder="请输入商品名称" /></el-form-item>
<el-form-item v-else label="运输路线"><el-input v-model.trim="query.routeKeyword" clearable placeholder="起运地或目的地" /></el-form-item>
<el-form-item label="状态"><el-input v-model.trim="query.status" clearable placeholder="请输入状态" /></el-form-item>
<el-form-item><el-button type="primary" native-type="submit">查询</el-button><el-button @click="resetQuery">重置</el-button></el-form-item>
</el-form>
</el-card>
<el-card shadow="never">
<el-table :data="rows" empty-text="暂无订单" border>
<el-table-column label="操作" width="90" fixed="left"><template #default="scope"><el-button link type="primary" @click="openDetail(scope.row)">查看</el-button></template></el-table-column>
<el-table-column v-for="column in configuration.columns" :key="column.key" :label="column.label" :width="column.width" show-overflow-tooltip>
<template #default="scope">{{ displayCell(scope.row, column.key) }}</template>
</el-table-column>
</el-table>
<el-pagination v-model:current-page="query.page" v-model:page-size="query.size" class="pagination" :total="total" :page-sizes="[10, 20, 50, 100]" layout="total, sizes, prev, pager, next" @change="load" />
</el-card>
<QueryToolbar v-model="queryModel" layout="grid" ariaLabel="订单查询条件" :fields="queryFields" :search-on-reset="false" @search="searchOrders" @reset="resetQuery" />
<div class="business-table-card">
<DataTable v-model:page="query.page" v-model:page-size="query.size" :data="rows" :columns="tableColumns" :loading="loading" :total="total" :page-sizes="[10, 20, 50, 100]" :row-actions="getRowActions" row-key="orderNo" empty-text="暂无订单">
<template #toolbar><strong>订单列表</strong><span>共 {{ total }} 条</span></template>
<template #toolbar-actions><el-button v-if="canCreate" type="primary" @click="resetForm(); mode = 'create'">新增下单</el-button></template>
<template v-if="error" #error><div class="business-load-error" role="alert"><span>{{ error }}</span><el-button size="small" @click="load">重新加载</el-button></div></template>
<template #orderNo="{ row }"><button class="business-order-link" type="button" @click="openDetail(row)">{{ (row as BusinessRow).orderNo }}</button></template>
<template #status="{ row }"><el-tag :type="statusTone((row as BusinessRow).status)" effect="light" size="small">{{ statusLabel((row as BusinessRow).status) }}</el-tag></template>
</DataTable>
</div>
</template>
<el-card v-else-if="mode === 'detail'" shadow="never">
<dl class="detail-grid">
<div v-for="field in detailFields" :key="String(field[0])"><dt>{{ field[0] }}</dt><dd>{{ text(field[1]) }}</dd></div>
</dl>
</el-card>
<section v-else-if="mode === 'detail'" class="business-detail-page" aria-label="订单详情">
<header class="business-detail-heading">
<div class="business-detail-heading-main"><el-button link type="primary" @click="returnToList">← 返回列表</el-button><div><h2>{{ detail?.summary.orderNo || '订单详情' }}</h2><p>{{ configuration.title }}订单</p></div></div>
<el-tag :type="statusTone(detail?.summary.status)" effect="light">{{ detailStatus }}</el-tag>
</header>
<div v-if="error" class="business-detail-error" role="alert"><strong>{{ error }}</strong><el-button size="small" @click="loadDetail">重新加载</el-button></div>
<section v-else class="business-detail-section">
<h3>订单信息</h3>
<dl class="detail-grid">
<div v-for="field in detailFields" :key="String(field[0])" :class="detailFieldClass(field[0])"><dt>{{ field[0] }}</dt><dd>{{ text(field[1]) }}</dd></div>
</dl>
</section>
</section>
<el-card v-else shadow="never" class="form-card">
<el-form ref="formRef" :model="form" :rules="rules" label-width="120px">
@@ -340,24 +445,35 @@ onMounted(() => void load())
<el-form-item label="联系人" prop="contactName"><el-input v-model.trim="form.contactName" maxlength="128" placeholder="请输入联系人" /></el-form-item>
<el-form-item label="联系方式" prop="contactInfo"><el-input v-model.trim="form.contactInfo" maxlength="128" placeholder="请输入电话、微信或邮箱" /></el-form-item>
<el-form-item label="备注"><el-input v-model="form.remark" type="textarea" :rows="4" maxlength="1000" show-word-limit placeholder="请输入其他要求" /></el-form-item>
<el-form-item><el-button @click="mode = 'list'">取消</el-button><el-button type="primary" :loading="submitting" @click="submit">提交订单</el-button></el-form-item>
<el-form-item><el-button @click="returnToList">取消</el-button><el-button v-if="canCreate" type="primary" :loading="submitting" @click="submit">提交订单</el-button></el-form-item>
</el-form>
</el-card>
</section>
</template>
<style scoped>
.business-orders { display: grid; gap: 18px; }
.business-orders { display: grid; gap: var(--client-section-gap); }
.page-head { display: flex; align-items: center; justify-content: space-between; gap: 24px; }
.page-head h2 { margin: 0; color: #111827; font-size: 26px; }
.page-head p { margin: 7px 0 0; color: #64748b; font-size: 14px; }
.filter-card :deep(.el-form-item) { margin-bottom: 0; }
.filter-card :deep(.el-input) { width: 190px; }
.pagination { justify-content: flex-end; margin-top: 18px; }
.detail-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); margin: 0; }
.detail-grid div { min-width: 0; padding: 18px 20px; border-right: 1px solid #ebeef5; border-bottom: 1px solid #ebeef5; }
.detail-grid dt { margin-bottom: 7px; color: #8492a6; font-size: 13px; }
.detail-grid dd { margin: 0; overflow-wrap: anywhere; color: #1f2937; line-height: 1.55; }
.business-table-card { overflow: hidden; border: 1px solid var(--client-border); border-radius: var(--client-radius); background: #fff; }
.business-order-link { max-width: 100%; overflow: hidden; padding: 0; border: 0; background: transparent; color: var(--client-action); cursor: pointer; font: inherit; font-weight: 650; text-align: left; text-overflow: ellipsis; white-space: nowrap; }
.business-order-link:hover { text-decoration: underline; text-underline-offset: 3px; }
.business-load-error { display: flex; min-height: 84px; align-items: center; justify-content: center; gap: 12px; padding: 14px; color: #b42318; }
.business-detail-error { display: flex; min-height: 180px; align-items: center; justify-content: center; gap: 12px; color: #b42318; }
.business-detail-page { min-width: 0; padding: 10px 14px 20px; overflow: hidden; border: 1px solid var(--client-border); border-radius: var(--client-radius); background: #fff; }
.business-detail-heading { display: flex; min-height: 50px; align-items: center; justify-content: space-between; gap: 16px; padding: 5px 0; border-bottom: 1px solid var(--border); }
.business-detail-heading-main { display: flex; min-width: 0; align-items: center; gap: 12px; }
.business-detail-heading-main > div { min-width: 0; }
.business-detail-heading h2 { margin: 0; overflow: hidden; color: var(--navy); font-size: 18px; text-overflow: ellipsis; white-space: nowrap; }
.business-detail-heading p { margin: 3px 0 0; color: var(--muted); font-size: 11px; }
.business-detail-section { margin-top: 10px; }
.business-detail-section h3 { margin: 0; padding: 0 0 5px; border-bottom: 1px solid #eaf0f7; color: var(--navy); font-size: 13px; }
.detail-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); margin: 0; border-top: 1px solid var(--border); border-left: 1px solid var(--border); }
.detail-grid div { display: grid; min-width: 0; min-height: 34px; grid-template-columns: 76px minmax(0, 1fr); align-items: center; gap: 7px; padding: 3px 8px; border-right: 1px solid var(--border); border-bottom: 1px solid var(--border); }
.detail-grid div.is-wide { grid-column: span 2; }
.detail-grid dt { color: var(--muted); font-size: 10px; white-space: nowrap; }
.detail-grid dd { min-width: 0; margin: 0; overflow-wrap: anywhere; color: var(--navy); font-size: 12px; font-weight: 600; line-height: 1.4; }
.form-card :deep(.el-form) { max-width: 860px; }
.form-card :deep(.el-input), .form-card :deep(.el-textarea), .form-card :deep(.el-select) { width: 100%; }
.form-card .currency { width: 110px; margin-left: 12px; }
@@ -368,15 +484,10 @@ onMounted(() => void load())
.page-head h2 { font-size: 24px; }
.page-head > div { min-width: 0; }
.filter-card :deep(.el-form) { display: grid; grid-template-columns: 1fr; }
.filter-card :deep(.el-form-item) { width: 100%; margin-right: 0; margin-bottom: 12px; }
.filter-card :deep(.el-form-item:last-child) { margin-bottom: 0; }
.filter-card :deep(.el-form-item__content),
.filter-card :deep(.el-input) { width: 100%; }
.detail-grid { grid-template-columns: 1fr; }
.detail-grid div { padding: 14px 16px; border-right: 0; }
.detail-grid div, .detail-grid div.is-wide { grid-column: auto; }
.detail-grid dd { overflow-wrap: break-word; word-break: normal; }
.business-detail-heading { align-items: flex-start; flex-direction: column; }
.form-card :deep(.el-form-item) { display: block; }
.form-card :deep(.el-form-item__label) {
+42 -3
View File
@@ -5,28 +5,67 @@ export const crossBorderModule = {
key: 'cross-border',
navigationGroup: { title: '出海下单', icon: PlaneTakeoff },
routes: [
{
path: 'cross-border/procurement/create',
name: 'cross-border-procurement-create',
component: () => import('./views/ProcurementView.vue'),
props: { createPage: true },
release: 'active',
meta: { title: '海外采购下单', workspaceTitle: '海外采购 · 新建', description: '填写并提交海外采购订单', permission: 'merchant:cross-border:procurement:create' },
},
{
path: 'cross-border/procurement/:orderNo/detail', name: 'cross-border-procurement-detail',
component: () => import('./views/ProcurementView.vue'), props: { detailPage: true }, release: 'active',
meta: { title: '海外采购详情', workspaceTitle: '海外采购 · 详情', description: '查看海外采购订单详情', permission: 'merchant:cross-border:procurement:list', keepAlive: false },
},
{
path: 'cross-border/procurement',
name: 'cross-border-procurement',
component: () => import('./views/ProcurementView.vue'),
release: 'active',
meta: { title: '海外采购', description: '管理海外采购订单', permission: 'merchant:cross-border:procurement' },
meta: { title: '海外采购', description: '管理海外采购订单', permission: 'merchant:cross-border:procurement:list' },
navigation: { key: 'cross-border-procurement', icon: Package },
},
{
path: 'cross-border/domestic-trucking/create',
name: 'cross-border-domestic-trucking-create',
component: () => import('./views/DomesticTruckingView.vue'),
props: { createPage: true },
release: 'active',
meta: { title: '美国本土卡下单', workspaceTitle: '美国本土卡 · 新建', description: '填写并提交美国本土卡车运输订单', permission: 'merchant:cross-border:domestic-trucking:create' },
},
{
path: 'cross-border/domestic-trucking/:orderNo/detail', name: 'cross-border-domestic-trucking-detail',
component: () => import('./views/DomesticTruckingView.vue'), props: { detailPage: true }, release: 'active',
meta: { title: '美国本土卡详情', workspaceTitle: '美国本土卡 · 详情', description: '查看美国本土卡订单详情', permission: 'merchant:cross-border:domestic-trucking:list', keepAlive: false },
},
{
path: 'cross-border/domestic-trucking',
name: 'cross-border-domestic-trucking',
component: () => import('./views/DomesticTruckingView.vue'),
release: 'active',
meta: { title: '美国本土卡', description: '创建美国本土卡车运输订单', permission: 'merchant:cross-border:domestic-trucking' },
meta: { title: '美国本土卡', description: '创建美国本土卡车运输订单', permission: 'merchant:cross-border:domestic-trucking:list' },
navigation: { key: 'cross-border-domestic-trucking', icon: Truck },
},
{
path: 'cross-border/pallet-shipping/create',
name: 'cross-border-pallet-shipping-create',
component: () => import('./views/PalletShippingView.vue'),
props: { createPage: true },
release: 'active',
meta: { title: '美本土散板下单', workspaceTitle: '美本土散板 · 新建', description: '填写并提交美国本土散板运输订单', permission: 'merchant:cross-border:pallet-shipping:create' },
},
{
path: 'cross-border/pallet-shipping/:orderNo/detail', name: 'cross-border-pallet-shipping-detail',
component: () => import('./views/PalletShippingView.vue'), props: { detailPage: true }, release: 'active',
meta: { title: '美本土散板详情', workspaceTitle: '美本土散板 · 详情', description: '查看美本土散板运输订单详情', permission: 'merchant:cross-border:pallet-shipping:list', keepAlive: false },
},
{
path: 'cross-border/pallet-shipping',
name: 'cross-border-pallet-shipping',
component: () => import('./views/PalletShippingView.vue'),
release: 'active',
meta: { title: '美本土散板', description: '创建美国本土散板运输订单', permission: 'merchant:cross-border:pallet-shipping' },
meta: { title: '美本土散板', description: '创建美国本土散板运输订单', permission: 'merchant:cross-border:pallet-shipping:list' },
navigation: { key: 'cross-border-pallet-shipping', icon: Ship },
},
],
@@ -1,5 +1,6 @@
<script setup lang="ts">
import BusinessOrdersView from '../components/BusinessOrdersView.vue'
defineProps<{ createPage?: boolean; detailPage?: boolean }>()
</script>
<template><BusinessOrdersView kind="trucking" /></template>
<template><BusinessOrdersView kind="trucking" :create-page="createPage" :detail-page="detailPage" /></template>
@@ -1,5 +1,6 @@
<script setup lang="ts">
import BusinessOrdersView from '../components/BusinessOrdersView.vue'
defineProps<{ createPage?: boolean; detailPage?: boolean }>()
</script>
<template><BusinessOrdersView kind="looseboard" /></template>
<template><BusinessOrdersView kind="looseboard" :create-page="createPage" :detail-page="detailPage" /></template>
@@ -1,5 +1,6 @@
<script setup lang="ts">
import BusinessOrdersView from '../components/BusinessOrdersView.vue'
defineProps<{ createPage?: boolean; detailPage?: boolean }>()
</script>
<template><BusinessOrdersView kind="procurement" /></template>
<template><BusinessOrdersView kind="procurement" :create-page="createPage" :detail-page="detailPage" /></template>
+138
View File
@@ -0,0 +1,138 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
import { loadDashboardOverview } from './overview.ts'
const dashboardView = readFileSync(new URL('./views/DashboardView.vue', import.meta.url), 'utf8')
const profile = 'company:profile:view'
const list = 'order:first-leg:list'
const edit = 'order:first-leg:edit'
const company = { tenantName: '示例企业', status: 'ACTIVE' }
function order(overrides = {}) {
return {
orderId: 101,
orderNo: 'ORDER-101',
inboundCode: 'INBOUND-101',
destinationWarehouseName: '示例目的仓',
channelName: '示例渠道',
mainStatus: 'WAIT_MEASURE',
customerFeeConfirmationRequired: false,
createdAt: '2026-09-18T08:00:00Z',
...overrides,
}
}
function draft(overrides = {}) {
return { ...order(), status: 'DRAFT', ...overrides }
}
function requests(overrides = {}) {
return {
company: async () => company,
orders: async () => ({ records: [order()] }),
drafts: async () => ({ records: [draft()] }),
...overrides,
}
}
test('only calls sources allowed by their complete permission requirements', async (t) => {
const cases = [
{ permissions: [], expected: [] },
{ permissions: [profile], expected: ['company'] },
{ permissions: [list], expected: ['orders'] },
{ permissions: [edit], expected: [] },
{ permissions: [list, edit], expected: ['orders', 'drafts'] },
{ permissions: [profile, list, edit], expected: ['company', 'orders', 'drafts'] },
]
for (const { permissions, expected } of cases) {
await t.test(permissions.join(',') || 'no permissions', async () => {
const calls = []
const result = await loadDashboardOverview(permissions, requests({
company: async () => { calls.push('company'); return company },
orders: async () => { calls.push('orders'); return { records: [order()] } },
drafts: async () => { calls.push('drafts'); return { records: [draft()] } },
}))
assert.deepEqual(calls, expected)
assert.equal(result.company, expected.includes('company') ? company : null)
assert.equal(result.orders.length, expected.includes('orders') ? 1 : 0)
assert.equal(result.drafts.length, expected.includes('drafts') ? 1 : 0)
assert.deepEqual(result.errors, {})
})
}
})
test('one failed source preserves successful sources and exposes its error', async (t) => {
for (const failedSource of ['company', 'orders', 'drafts']) {
await t.test(failedSource, async () => {
const result = await loadDashboardOverview([profile, list, edit], requests({
[failedSource]: async () => { throw new Error(`${failedSource} unavailable`) },
}))
assert.deepEqual(result.errors, { [failedSource]: `${failedSource} unavailable` })
assert.equal(result.company, failedSource === 'company' ? null : company)
assert.equal(result.orders.length, failedSource === 'orders' ? 0 : 1)
assert.equal(result.drafts.length, failedSource === 'drafts' ? 0 : 1)
})
}
})
test('loads sources concurrently and distinguishes a failed source from an empty result', async () => {
const pending = new Map()
const operation = loadDashboardOverview([profile, list, edit], requests(Object.fromEntries(
['company', 'orders', 'drafts'].map((source) => [source, () => new Promise((resolve, reject) => pending.set(source, { resolve, reject }))]),
)))
assert.deepEqual([...pending.keys()], ['company', 'orders', 'drafts'])
pending.get('company').resolve(company)
pending.get('orders').resolve({ records: [] })
pending.get('drafts').reject(null)
const result = await operation
assert.deepEqual(result.orders, [])
assert.equal(result.errors.orders, undefined)
assert.deepEqual(result.drafts, [])
assert.equal(result.errors.drafts, '订单草稿加载失败,请重试')
})
test('sorts each source by creation time and includes only actual drafts', async () => {
const orders = [
order({ orderId: 1, createdAt: '2026-09-16T08:00:00Z' }),
order({ orderId: 2, createdAt: '2026-09-18T08:00:00Z', mainStatus: 'COMPLETED', customerFeeConfirmationRequired: true }),
]
const drafts = [
draft({ orderId: 3, createdAt: '2026-09-15T08:00:00Z' }),
draft({ orderId: 4, status: 'SUBMITTED', createdAt: '2026-09-19T08:00:00Z' }),
draft({ orderId: 5, createdAt: '2026-09-17T08:00:00Z' }),
]
const result = await loadDashboardOverview([list, edit], requests({
orders: async () => ({ records: orders }),
drafts: async () => ({ records: drafts }),
}))
assert.deepEqual(result.orders.map(({ id }) => id), ['2', '1'])
assert.deepEqual(result.drafts.map(({ id }) => id), ['5', '3'])
assert.equal(result.orders[0].status, 'COMPLETED')
assert.equal(result.orders[0].requiresFeeConfirmation, true)
assert.ok(result.drafts.every(({ status, requiresFeeConfirmation }) => status === 'DRAFT' && !requiresFeeConfirmation))
assert.deepEqual(orders.map(({ orderId }) => orderId), [1, 2])
assert.deepEqual(drafts.map(({ orderId }) => orderId), [3, 4, 5])
})
test('keeps absent business numbers null and internal identifiers out of display fields', async () => {
const missing = { orderId: 987654, orderNo: null, inboundCode: null, destinationWarehouseName: null, channelName: null }
const result = await loadDashboardOverview([list, edit], requests({
orders: async () => ({ records: [order(missing)] }),
drafts: async () => ({ records: [draft(missing)] }),
}))
for (const item of [...result.orders, ...result.drafts]) {
assert.equal(item.id, '987654')
assert.equal(item.orderNo, null)
assert.equal(item.inboundCode, null)
assert.equal(item.destination, null)
assert.equal(item.channel, null)
}
})
test('keeps all six quick actions visually neutral until hover or keyboard focus', () => {
assert.doesNotMatch(dashboardView, /featured|quick-action--featured/)
assert.match(dashboardView, /\.quick-action:hover \{[^}]*border-color:[^}]*transform: translateY\(-5px\);/)
assert.match(dashboardView, /\.client-dashboard a:focus-visible/)
})
+94
View File
@@ -0,0 +1,94 @@
import type { FirstLegOrderStatus } from '@chongchong/shared'
import type { MerchantFirstLegOrderRow, MerchantUnifiedOrderRow } from '@/modules/first-leg/public'
import type { MerchantTenantStatus } from '@/modules/tenant-management/public'
export interface DashboardCompany {
tenantName: string
status: MerchantTenantStatus
}
export interface DashboardOrder {
id: string
orderNo: string | null
inboundCode: string | null
destination: string | null
channel: string | null
status: FirstLegOrderStatus
createdAt: string
requiresFeeConfirmation: boolean
}
export type DashboardSource = 'company' | 'orders' | 'drafts'
export interface DashboardOverview {
company: DashboardCompany | null
orders: DashboardOrder[]
drafts: DashboardOrder[]
errors: Partial<Record<DashboardSource, string>>
}
interface DashboardRequests {
company: () => Promise<DashboardCompany>
orders: () => Promise<{ records: MerchantUnifiedOrderRow[] }>
drafts: () => Promise<{ records: MerchantFirstLegOrderRow[] }>
}
const sourceErrorMessages: Record<DashboardSource, string> = {
company: '企业资料加载失败,请重试',
orders: '订单动态加载失败,请重试',
drafts: '订单草稿加载失败,请重试',
}
function newestFirst(records: DashboardOrder[]) {
return records.sort((left, right) => (Date.parse(right.createdAt) || 0) - (Date.parse(left.createdAt) || 0))
}
export async function loadDashboardOverview(permissions: readonly string[], requests: DashboardRequests): Promise<DashboardOverview> {
const result: DashboardOverview = { company: null, orders: [], drafts: [], errors: {} }
const allowed = new Set(permissions)
const tasks: Array<{ source: DashboardSource; run: () => Promise<void> }> = []
if (allowed.has('company:profile:view')) {
tasks.push({ source: 'company', run: async () => { result.company = await requests.company() } })
}
if (allowed.has('order:first-leg:list')) {
tasks.push({ source: 'orders', run: async () => {
const response = await requests.orders()
result.orders = newestFirst(response.records.map((order) => ({
id: String(order.orderId),
orderNo: order.orderNo ?? null,
inboundCode: order.inboundCode ?? null,
destination: order.destinationWarehouseName ?? null,
channel: order.channelName ?? null,
status: order.mainStatus,
createdAt: order.createdAt,
requiresFeeConfirmation: order.customerFeeConfirmationRequired === true,
})))
} })
if (allowed.has('order:first-leg:edit')) {
tasks.push({ source: 'drafts', run: async () => {
const response = await requests.drafts()
result.drafts = newestFirst(response.records.filter((order) => order.status === 'DRAFT').map((order) => ({
id: String(order.orderId),
orderNo: order.orderNo ?? null,
inboundCode: order.inboundCode ?? null,
destination: order.destinationWarehouseName ?? null,
channel: order.channelName ?? null,
status: 'DRAFT',
createdAt: order.createdAt,
requiresFeeConfirmation: false,
})))
} })
}
}
const settled = await Promise.allSettled(tasks.map(async (task) => task.run()))
settled.forEach((outcome, index) => {
if (outcome.status === 'fulfilled') return
const source = tasks[index]!.source
result.errors[source] = outcome.reason instanceof Error && outcome.reason.message.trim()
? outcome.reason.message
: sourceErrorMessages[source]
})
return result
}
+1 -1
View File
@@ -9,7 +9,7 @@ export const dashboardModule = {
name: 'dashboard',
component: () => import('./views/DashboardView.vue'),
release: 'active',
meta: { title: '业务总览', description: '企业账户与头程业务概览', permission: 'dashboard:view' },
meta: { title: '业务总览', description: '企业账户与头程业务概览', permission: 'dashboard:view', workspaceTab: false },
navigation: { key: 'dashboard', icon: Gauge },
},
],
@@ -1,49 +0,0 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
const source = readFileSync(new URL('./DashboardView.vue', import.meta.url), 'utf8')
test('loads real dashboard data allowed by the current permissions', () => {
assert.match(source, /auth\.hasPermission\('company:profile:view'\).*tenant\.loadCompanyContext\(\)/)
assert.match(source, /auth\.hasPermission\('company:member:list'\).*tenant\.loadMembers\(\)/)
assert.match(source, /auth\.hasPermission\('order:first-leg:list'\).*orders\.load\(\)/)
assert.match(source, /onMounted\(async \(\) => \{\s*await loadDashboardData\(\)\s*showQualificationReminderOnce\(\)\s*\}\)/)
})
test('prompts once per session for incomplete company qualification and opens the upload dialog', () => {
assert.match(source, /\['PROFILE_REQUIRED', 'REJECTED'\]\.includes\(tenant\.company\.auditStatus\)/)
assert.match(source, /sessionStorage\.getItem\(reminderKey\)/)
assert.match(source, /sessionStorage\.setItem\(reminderKey, 'shown'\)/)
assert.match(source, /title="完善企业认证资料"/)
assert.match(source, /立即上传认证资料/)
assert.match(source, /router\.push\(\{ path: '\/company\/profile', query: \{ qualification: 'upload' \} \}\)/)
})
test('isolates recoverable dashboard failures without hiding successful sections', () => {
assert.match(source, /Promise\.allSettled/)
assert.match(source, /Partial<Record<DashboardDataSource, string>>/)
assert.match(source, /:aria-busy="loading"/)
assert.match(source, /role="alert"/)
assert.match(source, /部分业务数据加载失败/)
assert.match(source, /ordersUnavailable/)
assert.match(source, /companyUnavailable/)
assert.match(source, /订单数据暂不可用,请重新加载后查看。/)
assert.match(source, /企业资料暂不可用,请重新加载后查看。/)
assert.match(source, /@click="loadDashboardData"/)
assert.doesNotMatch(source, /<template v-else>\s*<section class="quick-order-band">/)
})
test('matches the prototype business overview and routes each card to its real business page', () => {
assert.match(source, /选择业务,快速下单/)
assert.match(source, /海外采购/)
assert.match(source, /美国本土卡/)
assert.match(source, /美本土散板/)
assert.match(source, /route: '\/cross-border\/procurement'/)
assert.match(source, /route: '\/cross-border\/domestic-trucking'/)
assert.match(source, /route: '\/cross-border\/pallet-shipping'/)
assert.match(source, /auth\.hasPermission\(business\.permission\)/)
assert.match(source, /order\.status === 'DELIVERED'/)
assert.match(source, /currentMonthOrderCount/)
assert.match(source, /提交订单后,专属客户经理将在 24 小时内与您联系/)
})
+71 -624
View File
@@ -1,640 +1,87 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import {
AlertTriangle,
ArrowRight,
CircleQuestionMark,
Headphones,
Lightbulb,
PanelsTopLeft,
ShoppingCart,
Tags,
Truck,
} from '@lucide/vue'
import { useFirstLegOrdersStore } from '@/modules/first-leg/public'
import { useTenantAdministrationStore } from '@/modules/tenant-management/public'
import { computed } from 'vue'
import { ArrowRight, PanelsTopLeft, RefreshCcw, Ship, ShoppingCart, Tags, Truck } from '@lucide/vue'
import { useAuthStore } from '@/modules/auth/public'
import { FirstLegOrdersView } from '@/modules/first-leg/public'
const router = useRouter()
const orders = useFirstLegOrdersStore()
const tenant = useTenantAdministrationStore()
const auth = useAuthStore()
const loading = ref(false)
const qualificationReminderOpen = ref(false)
type DashboardDataSource = 'company' | 'members' | 'orders'
const loadErrors = ref<Partial<Record<DashboardDataSource, string>>>({})
const dataSourceLabels: Record<DashboardDataSource, string> = {
company: '企业资料',
members: '企业成员',
orders: '头程订单',
}
const companyUnavailable = computed(() => Boolean(loadErrors.value.company))
const ordersUnavailable = computed(() => Boolean(loadErrors.value.orders))
const loadErrorItems = computed(() =>
(Object.keys(loadErrors.value) as DashboardDataSource[]).map((key) => ({
key,
label: dataSourceLabels[key],
message: loadErrors.value[key] ?? '请求失败',
})),
)
const canViewOrders = computed(() => auth.hasPermission('order:first-leg:list'))
const accountName = computed(() => {
if (!companyUnavailable.value && tenant.company.companyName) return tenant.company.companyName
return auth.user?.tenantName || auth.user?.name || '企业客户'
})
const accountInitial = computed(() => accountName.value.trim().slice(0, 1).toUpperCase() || 'C')
const completedOrderCount = computed(() => orders.orders.filter((order) => order.status === 'DELIVERED').length)
const currentMonthOrderCount = computed(() => {
const now = new Date()
return orders.orders.filter((order) => {
const createdAt = new Date(order.createdAt)
return !Number.isNaN(createdAt.getTime())
&& createdAt.getFullYear() === now.getFullYear()
&& createdAt.getMonth() === now.getMonth()
}).length
})
const overviewStats = computed(() => [
{ label: '进行中订单', value: dashboardOrderValue(orders.activeCount) },
{ label: '已完成订单', value: dashboardOrderValue(completedOrderCount.value) },
{ label: '本月下单', value: dashboardOrderValue(currentMonthOrderCount.value) },
])
const businessCards = [
{
title: '海外采购',
description: '采购代购 · 验货质检 · 集中发货',
icon: ShoppingCart,
tone: 'blue',
route: '/cross-border/procurement',
permission: 'merchant:cross-border:procurement',
},
{
title: '美国本土卡',
description: '港口提柜 · 仓到仓 · 仓到门',
icon: Truck,
tone: 'green',
route: '/cross-border/domestic-trucking',
permission: 'merchant:cross-border:domestic-trucking',
},
{
title: '美本土散板',
description: '拼车拼载 · 打盘服务 · 灵活派送',
icon: PanelsTopLeft,
tone: 'teal',
route: '/cross-border/pallet-shipping',
permission: 'merchant:cross-border:pallet-shipping',
},
] as const
const primaryActions = computed(() => [
{ title: '头程下单', description: '国内提货 · 海运订舱 · 清关派送', icon: Ship, route: '/orders/first-leg/create', permission: 'order:first-leg:create', tone: 'blue' },
{ title: '退货换标', description: '退件接收 · 标签更换 · 重新入仓', icon: RefreshCcw, route: '/overseas-warehouse/return-relabel/create', permission: 'merchant:overseas-warehouse:return-order:create', tone: 'purple' },
{ title: '美国本土卡', description: '港口提柜 · 仓到仓 · 仓到门', icon: Truck, route: '/cross-border/domestic-trucking/create', permission: 'merchant:cross-border:domestic-trucking:create', tone: 'green' },
{ title: '美本土散板', description: '拼车拼载 · 打盘服务 · 灵活派送', icon: PanelsTopLeft, route: '/cross-border/pallet-shipping/create', permission: 'merchant:cross-border:pallet-shipping:create', tone: 'cyan' },
{ title: '海外采购', description: '采购代购 · 验货质检 · 集中发货', icon: ShoppingCart, route: '/cross-border/procurement/create', permission: 'merchant:cross-border:procurement:create', tone: 'orange' },
{ title: '贴标申请', description: '产品建档 · 标签上传 · 批量贴标', icon: Tags, route: '/overseas-warehouse/labeling/create', permission: 'merchant:overseas-warehouse:relabel:create', tone: 'rose' },
].filter((action) => auth.hasPermission(action.permission)))
function dashboardOrderValue(value: number) {
if (!canViewOrders.value || ordersUnavailable.value) return '—'
return String(value)
}
function errorMessage(error: unknown) {
return error instanceof Error ? error.message : '请求失败,请稍后重试'
}
async function loadDashboardData() {
loading.value = true
loadErrors.value = {}
const requests: Array<{ source: DashboardDataSource; request: Promise<unknown> }> = []
if (auth.hasPermission('company:profile:view')) requests.push({ source: 'company', request: tenant.loadCompanyContext() })
if (auth.hasPermission('company:member:list')) requests.push({ source: 'members', request: tenant.loadMembers() })
if (auth.hasPermission('order:first-leg:list')) requests.push({ source: 'orders', request: orders.load() })
try {
const results = await Promise.allSettled(requests.map(({ request }) => request))
const nextErrors: Partial<Record<DashboardDataSource, string>> = {}
results.forEach((result, index) => {
const request = requests[index]
if (request && result.status === 'rejected') nextErrors[request.source] = errorMessage(result.reason)
})
loadErrors.value = nextErrors
} finally {
loading.value = false
}
}
function showQualificationReminderOnce() {
if (companyUnavailable.value || !auth.hasPermission('company:qualification:upload')) return
if (!['PROFILE_REQUIRED', 'REJECTED'].includes(tenant.company.auditStatus)) return
const reminderKey = `client-qualification-reminder:${auth.user?.id ?? 'current'}`
if (sessionStorage.getItem(reminderKey)) return
sessionStorage.setItem(reminderKey, 'shown')
qualificationReminderOpen.value = true
}
async function openQualificationUpload() {
qualificationReminderOpen.value = false
await router.push({ path: '/company/profile', query: { qualification: 'upload' } })
}
function openBusinessOrder(business: typeof businessCards[number]) {
if (auth.hasPermission(business.permission)) {
void router.push(business.route)
return
}
ElMessage.warning(`当前账号暂无${business.title}权限,请联系企业管理员。`)
}
function showSupportMessage(type: 'service' | 'faq' | 'quote') {
const messages = {
service: '客服联系方式正在配置中,请联系您的专属客户经理。',
faq: '常见问题页面即将上线。',
quote: '提交订单后,客户经理将根据货物信息为您提供报价。',
}
ElMessage.info(messages[type])
}
onMounted(async () => {
await loadDashboardData()
showQualificationReminderOnce()
})
</script>
<template>
<div v-loading="loading" class="client-dashboard" :aria-busy="loading">
<el-dialog v-model="qualificationReminderOpen" title="完善企业认证资料" width="min(520px, 92vw)" append-to-body>
<p class="qualification-reminder-copy">请先上传营业执照和统一社会信用代码并提交审核;法人身份证及其他资质可选填。审核通过后即可提交头程订单。</p>
<template #footer><el-button @click="qualificationReminderOpen = false">稍后处理</el-button><el-button type="primary" @click="openQualificationUpload">立即上传认证资料</el-button></template>
</el-dialog>
<div v-if="loadErrorItems.length" class="dashboard-load-error" role="alert">
<AlertTriangle :size="20" aria-hidden="true" />
<div>
<strong>部分业务数据加载失败</strong>
<ul>
<li v-for="item in loadErrorItems" :key="item.key">
<span>{{ item.label }}:</span>{{ item.message }}
</li>
</ul>
</div>
<el-button :loading="loading" @click="loadDashboardData">重新加载</el-button>
</div>
<div class="client-dashboard">
<header class="business-intro">
<h1>选择业务,快速下单</h1>
<p>请选择您需要的业务类型,填写信息后提交订单,我们将尽快为您安排。</p>
</header>
<div class="dashboard-layout">
<main class="dashboard-business" aria-labelledby="dashboard-heading">
<header class="dashboard-intro">
<h2 id="dashboard-heading">选择业务,快速下单</h2>
<p>请选择您需要的业务类型,填写信息后提交订单,我们将尽快为您安排。</p>
</header>
<section class="quick-actions" aria-label="业务选择">
<RouterLink v-for="(action, index) in primaryActions" :key="action.route" :to="action.route" class="quick-action" :class="`quick-action--${action.tone}`" :style="{ '--entry-index': index }">
<span class="action-icon"><component :is="action.icon" :size="23" :stroke-width="2.1" aria-hidden="true" /></span>
<span class="action-copy"><strong>{{ action.title }}</strong><small>{{ action.description }}</small></span>
<span class="action-enter">开始下单<ArrowRight :size="15" aria-hidden="true" /></span>
</RouterLink>
<p v-if="!primaryActions.length" class="no-actions">当前账号暂无常用业务权限,请通过已授权菜单办理业务,或联系企业管理员。</p>
</section>
<section class="business-card-grid" aria-label="可选业务">
<button
v-for="business in businessCards"
:key="business.title"
type="button"
class="business-card"
:class="`business-card--${business.tone}`"
@click="openBusinessOrder(business)"
>
<span class="business-card__icon" aria-hidden="true">
<component :is="business.icon" :size="29" :stroke-width="2.4" />
</span>
<span class="business-card__copy">
<strong>{{ business.title }}</strong>
<small>{{ business.description }}</small>
</span>
<span class="business-card__action">
{{ auth.hasPermission(business.permission) ? '开始下单' : '暂无权限' }}
<ArrowRight :size="18" aria-hidden="true" />
</span>
</button>
</section>
</main>
<aside class="dashboard-aside" aria-label="账户与业务信息">
<section class="aside-card account-card">
<h3>账户信息</h3>
<div class="account-summary">
<span class="account-avatar">{{ accountInitial }}</span>
<span class="account-copy">
<strong>{{ accountName }}</strong>
<small>企业客户</small>
</span>
</div>
<p v-if="companyUnavailable" class="account-load-error" role="status">企业资料暂不可用,请重新加载后查看。</p>
</section>
<section class="aside-card">
<h3>数据概览</h3>
<dl class="overview-stats">
<div v-for="stat in overviewStats" :key="stat.label">
<dt>{{ stat.label }}</dt>
<dd>{{ stat.value }}</dd>
</div>
</dl>
<p v-if="ordersUnavailable" class="dashboard-section-error" role="status">订单数据暂不可用,请重新加载后查看。</p>
</section>
<section class="aside-card">
<h3>帮助与支持</h3>
<div class="support-links">
<button type="button" @click="showSupportMessage('service')"><Headphones :size="19" /><span>联系客服</span></button>
<button type="button" @click="showSupportMessage('faq')"><CircleQuestionMark :size="19" /><span>常见问题</span></button>
<button type="button" @click="showSupportMessage('quote')"><Tags :size="19" /><span>获取报价</span></button>
</div>
</section>
<div class="dashboard-tip">
<Lightbulb :size="19" aria-hidden="true" />
<p>提交订单后,专属客户经理将在 24 小时内与您联系,确认运输方案与报价。</p>
</div>
</aside>
</div>
<FirstLegOrdersView v-if="auth.hasPermission('order:first-leg:list')" :show-create-action="false" class="dashboard-order-list" />
<section v-else class="orders-permission-empty">
<strong>暂无头程订单查看权限</strong>
<span>需要查看企业订单时,请联系企业管理员开通。</span>
</section>
</div>
</template>
<style scoped>
.client-dashboard {
--dashboard-blue: #1565c0;
--dashboard-green: #3f9f47;
--dashboard-teal: #078a99;
display: block;
min-height: calc(100vh - 128px);
}
.dashboard-load-error {
margin-bottom: 20px;
}
.qualification-reminder-copy { margin: 0; color: #475467; font-size: 14px; line-height: 1.75; }
.dashboard-load-error ul {
display: grid;
gap: 3px;
margin: 0;
padding: 0;
list-style: none;
}
.dashboard-load-error li {
overflow-wrap: anywhere;
color: var(--muted);
font-size: 12px;
line-height: 1.5;
}
.dashboard-load-error li span {
color: #9a5a09;
font-weight: 650;
}
.dashboard-layout {
display: grid;
grid-template-columns: minmax(0, 1fr) 286px;
gap: clamp(24px, 3vw, 42px);
align-items: start;
}
.dashboard-business {
min-width: 0;
padding: 14px 0 24px;
}
.dashboard-intro {
margin-bottom: 34px;
}
.dashboard-intro h2 {
max-width: 720px;
margin: 0;
color: #111827;
font-size: clamp(30px, 3.2vw, 46px);
line-height: 1.18;
letter-spacing: -.025em;
}
.dashboard-intro p {
max-width: 720px;
margin: 16px 0 0;
color: #697586;
font-size: 15px;
line-height: 1.7;
}
.business-card-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 18px;
}
.business-card {
--card-accent: var(--dashboard-blue);
display: flex;
min-width: 0;
min-height: 286px;
flex-direction: column;
align-items: flex-start;
padding: 30px;
border: 0;
border-radius: 16px;
background: #fff;
box-shadow: 0 12px 36px rgba(23, 50, 77, .08);
color: #111827;
cursor: pointer;
text-align: left;
transition: transform .25s cubic-bezier(.16, 1, .3, 1), box-shadow .25s cubic-bezier(.16, 1, .3, 1);
}
.business-card--green { --card-accent: var(--dashboard-green); }
.business-card--teal { --card-accent: var(--dashboard-teal); }
.business-card:hover {
transform: translateY(-5px);
box-shadow: 0 20px 46px rgba(23, 50, 77, .14);
}
.business-card:focus-visible {
outline: 3px solid color-mix(in srgb, var(--card-accent) 36%, white);
outline-offset: 4px;
}
.business-card__icon {
display: grid;
width: 60px;
height: 60px;
place-items: center;
border-radius: 16px;
background: var(--card-accent);
box-shadow: 0 8px 18px color-mix(in srgb, var(--card-accent) 24%, transparent);
color: #fff;
}
.business-card__copy {
display: flex;
min-width: 0;
flex-direction: column;
gap: 10px;
margin-top: 34px;
}
.business-card__copy strong {
font-size: 22px;
line-height: 1.35;
}
.business-card__copy small {
color: #697586;
font-size: 14px;
line-height: 1.65;
}
.business-card__action {
display: inline-flex;
align-items: center;
gap: 8px;
margin-top: auto;
padding-top: 28px;
color: var(--card-accent);
font-size: 14px;
font-weight: 700;
}
.business-card__action svg {
transition: transform .2s cubic-bezier(.16, 1, .3, 1);
}
.business-card:hover .business-card__action svg {
transform: translateX(3px);
}
.dashboard-aside {
display: flex;
flex-direction: column;
gap: 14px;
}
.aside-card {
padding: 20px;
border: 1px solid #e9eef5;
border-radius: 14px;
background: #f8fafc;
}
.aside-card h3 {
margin: 0 0 18px;
color: #8b9bb2;
font-size: 12px;
font-weight: 750;
letter-spacing: .06em;
}
.account-summary {
display: flex;
min-width: 0;
align-items: center;
gap: 14px;
}
.account-avatar {
display: grid;
width: 52px;
height: 52px;
flex: 0 0 52px;
place-items: center;
border-radius: 14px;
background: linear-gradient(135deg, #1976d2, #00a993);
color: #fff;
font-size: 20px;
font-weight: 750;
}
.account-copy {
display: flex;
min-width: 0;
flex-direction: column;
gap: 5px;
}
.account-copy strong {
overflow: hidden;
color: #111827;
font-size: 15px;
line-height: 1.35;
text-overflow: ellipsis;
white-space: nowrap;
}
.account-copy small {
color: #66758a;
font-size: 12px;
}
.account-load-error {
margin: 14px 0 0;
color: #8a5a22;
font-size: 12px;
line-height: 1.55;
}
.overview-stats {
margin: 0;
}
.overview-stats div {
display: flex;
min-height: 48px;
align-items: center;
justify-content: space-between;
gap: 16px;
border-bottom: 1px dashed #dfe6ef;
}
.overview-stats div:last-child {
border-bottom: 0;
}
.overview-stats dt {
color: #475569;
font-size: 13px;
}
.overview-stats dd {
margin: 0;
color: #111827;
font-size: 15px;
font-weight: 750;
}
.dashboard-section-error {
margin: 10px 0 0;
padding: 10px 0 0;
border-top: 1px solid #e9eef5;
color: #8a5a22;
font-size: 12px;
line-height: 1.55;
}
.support-links {
display: grid;
gap: 3px;
}
.support-links button {
display: flex;
width: 100%;
min-height: 44px;
align-items: center;
gap: 11px;
padding: 0 9px;
border: 0;
border-radius: 9px;
background: transparent;
color: #334155;
cursor: pointer;
font-size: 14px;
text-align: left;
}
.support-links button svg {
flex: 0 0 19px;
color: #00a993;
}
.support-links button:hover {
background: #edf2f7;
color: var(--dashboard-blue);
}
.support-links button:focus-visible {
outline: 2px solid var(--dashboard-blue);
outline-offset: 2px;
}
.dashboard-tip {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 10px;
padding: 16px;
border: 1px solid rgba(0, 169, 147, .24);
border-radius: 14px;
background: #eef9f8;
color: #53657a;
}
.dashboard-tip svg {
margin-top: 2px;
color: #00a993;
}
.dashboard-tip p {
margin: 0;
font-size: 12px;
line-height: 1.75;
}
@media (max-width: 1180px) {
.dashboard-layout {
grid-template-columns: 1fr;
}
.dashboard-aside {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
align-items: stretch;
}
.dashboard-tip {
grid-column: 1 / -1;
}
}
@media (max-width: 960px) {
.business-card-grid {
grid-template-columns: 1fr;
}
.business-card {
min-height: 232px;
}
}
@media (max-width: 720px) {
.client-dashboard {
min-height: calc(100vh - 118px);
}
.dashboard-business {
padding-top: 2px;
}
.dashboard-intro {
margin-bottom: 24px;
}
.dashboard-intro h2 {
font-size: 28px;
}
.dashboard-intro p {
margin-top: 12px;
font-size: 14px;
}
.business-card {
min-height: 220px;
padding: 24px;
}
.business-card__copy {
margin-top: 24px;
}
.dashboard-aside {
grid-template-columns: 1fr;
}
.dashboard-tip {
grid-column: auto;
}
}
@media (prefers-reduced-motion: reduce) {
.business-card,
.business-card__action svg {
transition: none;
}
}
.client-dashboard { display: flex; min-width: 0; container-name: dashboard-content; container-type: inline-size; flex-direction: column; gap: 14px; padding: 0 8px 18px; color: var(--navy); animation: dashboard-enter .3s ease-out both; }
.business-intro { padding-top: 0; }
.business-intro h1 { margin: 0; color: #172033; font-size: 27px; font-weight: 750; letter-spacing: -.5px; line-height: 1.3; }
.business-intro p { margin: 5px 0 0; color: #7a8495; font-size: 13px; line-height: 1.55; }
.quick-actions { display: grid; grid-template-columns: repeat(6, minmax(188px, 1fr)); gap: 10px; overflow-x: auto; padding: 0 0 6px; scroll-snap-type: x proximity; scrollbar-color: #bdc9dc transparent; scrollbar-width: thin; }
.quick-actions::-webkit-scrollbar { height: 7px; }
.quick-actions::-webkit-scrollbar-thumb { border-radius: 999px; background: #bdc9dc; }
.quick-actions::-webkit-scrollbar-track { background: transparent; }
.quick-action { scroll-snap-align: start; }
.quick-action { --action-a: #2563eb; --action-b: #3730a3; --action-soft: rgba(37, 99, 235, .18); position: relative; display: flex; min-width: 0; min-height: 154px; overflow: hidden; align-items: center; flex-direction: column; padding: 15px 9px 13px; border: 1px solid rgba(148, 163, 184, .22); border-radius: 14px; background: linear-gradient(155deg, #fff 8%, rgba(248, 250, 252, .96) 100%); color: inherit; text-align: center; text-decoration: none; box-shadow: 0 8px 22px rgba(15, 23, 42, .055); animation: action-enter .4s cubic-bezier(.22, 1, .36, 1) both; animation-delay: calc(var(--entry-index) * 55ms); transition: border-color .35s ease, box-shadow .35s ease, transform .35s cubic-bezier(.22, 1, .36, 1); }
.quick-action::before { position: absolute; width: 120px; height: 120px; border-radius: 50%; background: radial-gradient(circle, var(--action-soft), transparent 70%); content: ''; right: -62px; top: -66px; transition: transform .4s ease; }
.quick-action::after { position: absolute; right: 20%; bottom: 0; left: 20%; height: 3px; border-radius: 99px 99px 0 0; background: linear-gradient(90deg, var(--action-a), var(--action-b)); content: ''; opacity: .55; transform: scaleX(.35); transition: all .35s ease; }
.quick-action--blue { --action-a: #2563eb; --action-b: #3730a3; --action-soft: rgba(37, 99, 235, .18); }
.quick-action--purple { --action-a: #8b5cf6; --action-b: #c026d3; --action-soft: rgba(139, 92, 246, .18); }
.quick-action--green { --action-a: #059669; --action-b: #0f766e; --action-soft: rgba(5, 150, 105, .18); }
.quick-action--cyan { --action-a: #0891b2; --action-b: #0e7490; --action-soft: rgba(8, 145, 178, .18); }
.quick-action--orange { --action-a: #f59e0b; --action-b: #dc6b12; --action-soft: rgba(245, 158, 11, .2); }
.quick-action--rose { --action-a: #e11d48; --action-b: #be185d; --action-soft: rgba(225, 29, 72, .17); }
.quick-action:hover { border-color: color-mix(in srgb, var(--action-a) 35%, transparent); box-shadow: 0 16px 34px rgba(15, 23, 42, .12); transform: translateY(-5px); }
.quick-action:hover::before { transform: scale(1.25); }
.quick-action:hover::after { right: 12%; left: 12%; opacity: 1; transform: scaleX(1); }
.action-icon { position: relative; display: grid; width: 48px; height: 48px; flex: 0 0 auto; place-items: center; margin-bottom: 11px; border-radius: 14px; background: linear-gradient(135deg, var(--action-a), var(--action-b)); color: #fff; box-shadow: 0 8px 17px color-mix(in srgb, var(--action-a) 24%, transparent); transition: transform .35s ease; }
.action-icon::after { position: absolute; border: 1px solid color-mix(in srgb, var(--action-a) 20%, transparent); border-radius: 18px; content: ''; inset: -4px; }
.quick-action:hover .action-icon { transform: translateY(-3px) scale(1.06) rotate(-3deg); }
.action-copy { position: relative; display: flex; min-width: 0; width: 100%; align-items: center; flex-direction: column; }
.action-copy strong { color: #172033; font-size: 15px; font-weight: 750; line-height: 1.35; white-space: nowrap; }
.action-copy small { overflow: hidden; width: 100%; margin-top: 4px; color: #718096; font-size: 10px; line-height: 1.45; text-overflow: ellipsis; white-space: nowrap; }
.action-enter { position: relative; display: inline-flex; align-items: center; gap: 5px; margin-top: auto; padding-top: 7px; color: var(--action-a); font-size: 10px; font-weight: 700; }
.action-enter svg { transition: transform .25s ease; }
.quick-action:hover .action-enter svg { transform: translateX(4px); }
.no-actions { margin: 0; padding: 20px; border: 1px solid var(--border); border-radius: 10px; background: #fff; color: #667085; font-size: 14px; }
.dashboard-order-list { min-width: 0; }
.orders-permission-empty { display: flex; min-height: 180px; align-items: center; justify-content: center; flex-direction: column; gap: 8px; border: 1px solid var(--border); border-radius: 10px; background: #fff; color: #7b8494; }
.orders-permission-empty strong { color: #475467; font-size: 15px; }
.orders-permission-empty span { font-size: 12px; }
.client-dashboard a:focus-visible { border-radius: 6px; outline: 2px solid var(--primary); outline-offset: 4px; }
@keyframes dashboard-enter { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: translateY(0); } }
@keyframes action-enter { from { opacity: 0; transform: translateY(7px); } to { opacity: 1; transform: translateY(0); } }
@container dashboard-content (max-width: 1180px) { .quick-actions { grid-template-columns: repeat(6, minmax(178px, 1fr)); } }
@media (max-width: 720px) { .client-dashboard { padding: 0; gap: 16px; } .business-intro h1 { font-size: 25px; } .quick-actions { grid-template-columns: repeat(2, minmax(150px, 1fr)); } }
@media (max-width: 420px) { .quick-actions { grid-template-columns: repeat(6, minmax(168px, 1fr)); } }
@media (prefers-reduced-motion: reduce) { .client-dashboard, .quick-action { animation: none; } .quick-action, .quick-action::before, .quick-action::after, .action-icon, .action-enter svg { transition: none; } }
</style>
+5
View File
@@ -21,5 +21,10 @@ export const financeModule = {
meta: { title: '客户账单', description: '查看本企业账单、收款记录和未收余额', permission: 'finance:bill:view' },
navigation: { key: 'finance-bills', title: '客户账单', icon: ReceiptText },
},
{
path: 'finance/bills/:billNo/detail', name: 'finance-bill-detail',
component: () => import('./views/FinanceBillDetailView.vue'), release: 'active',
meta: { title: '账单详情', workspaceTitle: '客户账单 · 详情', description: '查看费用明细、收款记录和客户确认信息', permission: 'finance:bill:view', keepAlive: false },
},
],
} satisfies ClientModuleManifest
@@ -0,0 +1,85 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ArrowLeft } from '@lucide/vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { ApiError } from '@/api/http'
import DataTable, { type DataTableColumn } from '@/components/common/DataTable.vue'
import { useAuthStore } from '@/modules/auth/public'
import { InboundCodeAssociationDialog } from '@/modules/first-leg/public'
import { confirmMerchantFinanceStatement, getMerchantFinanceBill, getMerchantFinanceBills, getMerchantFinanceReceipts } from '../api/finance'
import type { FinanceBillDetail, FinanceBillSummary, FinanceBillStatus, FinanceCustomerConfirmationStatus } from '../contracts'
const route = useRoute()
const router = useRouter()
const auth = useAuthStore()
const billNo = computed(() => String(route.params.billNo || ''))
const loading = ref(false)
const errorMessage = ref('')
const detail = ref<FinanceBillDetail | null>(null)
const confirming = ref(false)
const inboundAssociationVisible = ref(false)
const inboundAssociationCode = ref('')
const canConfirm = computed(() => auth.hasPermission('finance:bill:confirm'))
const lineColumns: DataTableColumn[] = [
{ key: 'inboundCode', label: '入仓码', slotName: 'inboundCode', minWidth: 180 },
{ prop: 'feeName', label: '费用项目', minWidth: 220 },
{ key: 'amount', label: '金额', slotName: 'amount', width: 150, align: 'right', headerAlign: 'right' },
]
const receiptColumns: DataTableColumn[] = [
{ key: 'receivedAt', label: '收款日期', slotName: 'receivedAt', width: 130 },
{ prop: 'receiptNo', label: '收款编号', minWidth: 180 },
{ key: 'allocatedAmount', label: '本次金额', slotName: 'allocatedAmount', width: 150, align: 'right', headerAlign: 'right' },
{ key: 'method', label: '方式', slotName: 'method', minWidth: 120 },
]
function formatAmount(value?: string | null) { const number = Number(value); return Number.isFinite(number) ? number.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) : value || '0.00' }
function formatDate(value?: string | null) { return value ? value.slice(0, 10) : '—' }
function formatDateTime(value?: string | null) { return value ? value.replace('T', ' ').replace(/Z$/, '').slice(0, 16) : '—' }
function statusLabel(status: FinanceBillStatus) { return ({ DRAFT: '草稿', ISSUED: '已出账', PARTIALLY_PAID: '部分收款', PAID: '已收清', VOIDED: '作废' } as Record<string, string>)[status] || status }
function statusType(status: FinanceBillStatus) { return ({ ISSUED: 'info', PARTIALLY_PAID: 'warning', PAID: 'success', VOIDED: 'danger', DRAFT: 'info' } as const)[status] }
function confirmationLabel(status: FinanceCustomerConfirmationStatus) { return ({ NOT_REQUIRED: '无需确认', PENDING_CONFIRMATION: '待确认', CONFIRMED: '已确认' })[status] }
function paymentMethodLabel(method?: string | null) { return ({ BANK_TRANSFER: '银行转账', CASH: '现金', OTHER: '其他' } as Record<string, string>)[method || ''] || '其他' }
function messageOf(error: unknown) { return error instanceof ApiError ? error.message : '账单详情加载失败,请稍后重试' }
async function load() {
if (!billNo.value) return
loading.value = true
errorMessage.value = ''
try {
const result = await getMerchantFinanceBills({ keyword: billNo.value, page: 1, size: 100 })
const summary = result.records.find(item => item.billNo === billNo.value)
if (!summary) throw new Error('未找到该账单')
const [bill, receipts] = await Promise.all([getMerchantFinanceBill(summary.id), getMerchantFinanceReceipts(summary.id)])
detail.value = { ...bill, receipts }
} catch (error) { detail.value = null; errorMessage.value = error instanceof Error ? error.message : messageOf(error) }
finally { loading.value = false }
}
async function confirmStatement(statement: FinanceBillSummary) {
if (!canConfirm.value || statement.customerConfirmationStatus !== 'PENDING_CONFIRMATION' || confirming.value) return
try { await ElMessageBox.confirm(`确认账单 ${statement.billNo}(¥ ${formatAmount(statement.totalAmount)})金额与费用明细无误?`, '确认客户账单', { type: 'warning', confirmButtonText: '确认无误', cancelButtonText: '取消' }) } catch { return }
confirming.value = true
try { await confirmMerchantFinanceStatement(statement.id, statement.version); ElMessage.success('账单已确认'); await load() }
catch (error) { ElMessage.error(messageOf(error)) }
finally { confirming.value = false }
}
function openInboundAssociation(value?: string | null) { if (!value) return; inboundAssociationCode.value = value; inboundAssociationVisible.value = true }
</script>
<template>
<article v-loading="loading" class="bill-detail-surface">
<template v-if="detail">
<header class="bill-detail-header"><button type="button" class="bill-detail-back" @click="router.push({ name: 'finance-bills' })"><ArrowLeft :size="16" />返回列表</button><div class="bill-detail-title"><strong>{{ detail.billNo }}</strong><span>出账 {{ formatDate(detail.issueDate) }} · 到期 {{ formatDate(detail.dueDate) }}</span></div><div class="bill-detail-actions"><el-tag :type="statusType(detail.status)" effect="light">{{ statusLabel(detail.status) }}</el-tag><el-tag effect="light">{{ confirmationLabel(detail.customerConfirmationStatus) }}</el-tag><el-button v-if="detail.customerConfirmationStatus === 'PENDING_CONFIRMATION' && canConfirm" type="primary" :loading="confirming" @click="confirmStatement(detail)">确认账单</el-button></div></header>
<section class="bill-detail-section"><h3>账单概览</h3><dl class="bill-detail-grid"><div><dt>账单金额</dt><dd>¥ {{ formatAmount(detail.totalAmount) }}</dd></div><div><dt>已收金额</dt><dd>¥ {{ formatAmount(detail.receivedAmount) }}</dd></div><div><dt>未收余额</dt><dd class="amount-warning">¥ {{ formatAmount(detail.outstandingAmount) }}</dd></div><div><dt>客户确认</dt><dd>{{ confirmationLabel(detail.customerConfirmationStatus) }}</dd></div><div><dt>确认成员</dt><dd>{{ detail.customerConfirmedByName || '—' }}</dd></div><div><dt>确认时间</dt><dd>{{ formatDateTime(detail.customerConfirmedAt) }}</dd></div></dl></section>
<section class="bill-detail-section"><h3>费用明细</h3><DataTable :data="detail.lines" :columns="lineColumns" row-key="id" empty-text="暂无费用明细"><template #inboundCode="{ row }"><el-button v-if="row.inboundCode" link type="primary" @click="openInboundAssociation(row.inboundCode)">{{ row.inboundCode }}</el-button><span v-else>—</span></template><template #amount="{ row }">¥ {{ formatAmount(row.amount) }}</template></DataTable></section>
<section class="bill-detail-section"><h3>收款记录</h3><DataTable :data="detail.receipts" :columns="receiptColumns" row-key="id" empty-text="暂无收款记录"><template #receivedAt="{ row }">{{ formatDate(row.receivedAt) }}</template><template #allocatedAmount="{ row }">¥ {{ formatAmount(row.allocatedAmount) }}</template><template #method="{ row }">{{ paymentMethodLabel(row.method) }}</template></DataTable></section>
<p class="bill-detail-notice">如对账单内容有疑问,请先联系平台财务,核对无误后再确认。</p>
</template>
<div v-else-if="errorMessage" class="bill-detail-error" role="alert"><strong>{{ errorMessage }}</strong><el-button @click="load">重新加载</el-button></div>
</article>
<InboundCodeAssociationDialog v-model="inboundAssociationVisible" :inbound-code="inboundAssociationCode" />
</template>
<style scoped>
.bill-detail-surface{overflow:hidden;min-height:240px;border:1px solid var(--client-border);border-radius:var(--client-radius);background:#fff}.bill-detail-header{display:grid;grid-template-columns:auto minmax(0,1fr) auto;align-items:center;gap:14px;min-height:64px;padding:10px 16px;border-bottom:1px solid var(--client-border)}.bill-detail-back{display:inline-flex;align-items:center;gap:5px;padding:5px 0;border:0;background:transparent;color:var(--el-color-primary);cursor:pointer}.bill-detail-title{display:flex;min-width:0;flex-direction:column;gap:3px}.bill-detail-title strong{overflow:hidden;color:#1f2d45;font-size:18px;text-overflow:ellipsis;white-space:nowrap}.bill-detail-title span{color:var(--client-muted);font-size:12px}.bill-detail-actions{display:flex;align-items:center;gap:8px}.bill-detail-section+.bill-detail-section{border-top:1px solid var(--client-border)}.bill-detail-section>h3{display:flex;min-height:40px;align-items:center;margin:0;padding:0 16px;border-bottom:1px solid var(--client-border);background:var(--client-header-bg);font-size:14px}.bill-detail-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));margin:0}.bill-detail-grid div{display:grid;min-height:42px;grid-template-columns:88px minmax(0,1fr);align-items:center;gap:8px;padding:5px 12px;border-right:1px solid var(--client-border);border-bottom:1px solid var(--client-border)}.bill-detail-grid dt{color:var(--client-muted);font-size:12px}.bill-detail-grid dd{margin:0;overflow:hidden;color:var(--client-text);font-size:13px;font-weight:600;text-overflow:ellipsis;white-space:nowrap}.amount-warning{color:#b45309!important}.bill-detail-notice{margin:0;padding:10px 14px;border-top:1px solid var(--client-border);background:#f5faf8;color:#45615d;font-size:12px}.bill-detail-error{display:flex;min-height:220px;align-items:center;justify-content:center;gap:12px;color:#b42318}@media(max-width:1100px){.bill-detail-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(max-width:720px){.bill-detail-header{grid-template-columns:1fr auto}.bill-detail-back{grid-column:1/-1}.bill-detail-grid{grid-template-columns:1fr}}
</style>
@@ -5,6 +5,7 @@ import ts from 'typescript'
const routesSource = readFileSync(new URL('../routes.ts', import.meta.url), 'utf8')
const viewSource = readFileSync(new URL('./FinanceBillsView.vue', import.meta.url), 'utf8')
const detailViewSource = readFileSync(new URL('./FinanceBillDetailView.vue', import.meta.url), 'utf8')
const apiSource = readFileSync(new URL('../api/finance.ts', import.meta.url), 'utf8')
const adapterSource = readFileSync(new URL('../api/adapters.ts', import.meta.url), 'utf8')
const permissionSource = readFileSync(new URL('../../tenant-management/contracts.ts', import.meta.url), 'utf8')
@@ -22,6 +23,8 @@ const permissionContracts = await importTypeScriptModule(new URL('../../tenant-m
test('activates the customer finance route with its existing list and detail permission', () => {
assert.match(routesSource, /path: 'finance\/bills'/)
assert.match(routesSource, /path: 'finance\/bills\/:billNo\/detail'/)
assert.match(routesSource, /name: 'finance-bill-detail'/)
assert.match(routesSource, /FinanceBillsView\.vue/)
assert.match(routesSource, /release: 'active'/)
assert.match(routesSource, /permission: 'finance:bill:view'/)
@@ -52,8 +55,10 @@ test('adapts the Finance Java DTO contract and filters nested receipt allocation
assert.doesNotMatch(adapterSource, /bill_no|customer_name|bill_date|total_amount|outstanding_amount|receipt_no|bill_id|applied_amount/)
assert.doesNotMatch(adapterSource, /receipt\.billId|receipt\.appliedAmount/)
assert.doesNotMatch(adapterSource, /voucherNo|voucher_no/)
assert.match(viewSource, /loadDetail\(row\.id\)/)
assert.doesNotMatch(viewSource, /loadDetail\(row\.billNo\)/)
assert.match(viewSource, /name: 'finance-bill-detail', params: \{ billNo: row\.billNo \}/)
assert.match(detailViewSource, /getMerchantFinanceBill\(summary\.id\)/)
assert.match(detailViewSource, /getMerchantFinanceReceipts\(summary\.id\)/)
assert.doesNotMatch(detailViewSource, /getMerchantFinanceBill\(billNo\.value\)/)
})
test('does not expose draft or voided bills returned by the server', () => {
@@ -117,27 +122,32 @@ test('keeps actions first and exposes list, balance, detail, confirmation, recei
const actionIndex = viewSource.indexOf('label="操作"')
const billNumberIndex = viewSource.indexOf('label="账单编号"')
assert.ok(actionIndex >= 0 && actionIndex < billNumberIndex)
for (const expected of ['未收余额', '账单详情', '确认账单', '客户确认信息', '收款记录', '暂无账单', '暂无收款记录', 'state-panel--error']) {
for (const expected of ['未收余额', '确认账单', '暂无账单', 'state-panel--error']) {
assert.match(viewSource, new RegExp(expected))
}
assert.match(viewSource, /BANK_TRANSFER: '银行转账'/)
assert.match(viewSource, /paymentMethodLabel\(row\.method\)/)
assert.match(viewSource, /label="入仓码"/)
assert.match(viewSource, /openInboundAssociation\(row\.inboundCode\)/)
assert.match(viewSource, /InboundCodeAssociationDialog/)
assert.match(viewSource, /:inbound-code="inboundAssociationCode"/)
for (const expected of ['账单概览', '费用明细', '收款记录', '暂无收款记录']) assert.match(detailViewSource, new RegExp(expected))
assert.match(detailViewSource, /BANK_TRANSFER: '银行转账'/)
assert.match(detailViewSource, /paymentMethodLabel\(row\.method\)/)
assert.match(detailViewSource, /label: '入仓码'/)
assert.match(detailViewSource, /openInboundAssociation\(row\.inboundCode\)/)
assert.match(detailViewSource, /InboundCodeAssociationDialog/)
assert.match(detailViewSource, /:inbound-code="inboundAssociationCode"/)
assert.match(viewSource, /auth\.hasPermission\('finance:bill:confirm'\)/)
assert.match(viewSource, /row\.customerConfirmationStatus === 'PENDING_CONFIRMATION' && canConfirm/)
assert.match(viewSource, /size="small" type="success" plain/)
assert.match(viewSource, /confirmStatement\(row as FinanceBillSummary\)/)
assert.match(viewSource, /detail\.customerConfirmationStatus === 'PENDING_CONFIRMATION' && canConfirm/)
assert.match(viewSource, /confirmStatement\(detail\)/)
assert.match(detailViewSource, /detail\.customerConfirmationStatus === 'PENDING_CONFIRMATION' && canConfirm/)
assert.match(detailViewSource, /confirmStatement\(detail\)/)
assert.match(viewSource, /confirmMerchantFinanceStatement\(statementId, statement\.version, key\)/)
assert.match(viewSource, /ElMessage\.success\('对账单已确认'\)/)
assert.match(viewSource, /ElMessage\.error\(error instanceof ApiError \? error\.message : '对账单确认失败,请重试'\)/)
for (const forbidden of ['发起争议', '新增收款', '核销', '修改金额']) {
assert.doesNotMatch(viewSource, new RegExp(forbidden))
assert.doesNotMatch(detailViewSource, new RegExp(forbidden))
}
assert.doesNotMatch(viewSource, /<el-drawer|InboundCodeAssociationDialog/)
assert.match(detailViewSource, /class="bill-detail-surface"/)
assert.match(detailViewSource, /bill-detail-error/)
})
test('keeps the existing customer finance view permission mapping', () => {
+6 -61
View File
@@ -2,23 +2,17 @@
import { computed, onMounted, reactive, ref } from 'vue'
import { CircleDollarSign, Eye, RefreshCcw, Search } from '@lucide/vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useRouter } from 'vue-router'
import { ApiError } from '@/api/http'
import { useAuthStore } from '@/modules/auth/public'
import { InboundCodeAssociationDialog } from '@/modules/first-leg/public'
import { confirmMerchantFinanceStatement, getMerchantFinanceBill, getMerchantFinanceBills, getMerchantFinanceReceipts } from '../api/finance'
import type { FinanceBillDetail, FinanceBillQuery, FinanceBillStatus, FinanceBillSummary, FinanceCustomerConfirmationStatus } from '../contracts'
import { confirmMerchantFinanceStatement, getMerchantFinanceBills } from '../api/finance'
import type { FinanceBillQuery, FinanceBillStatus, FinanceBillSummary, FinanceCustomerConfirmationStatus } from '../contracts'
const loading = ref(false)
const auth = useAuthStore()
const detailLoading = ref(false)
const router = useRouter()
const errorMessage = ref('')
const detailError = ref('')
const bills = ref<FinanceBillSummary[]>([])
const detail = ref<FinanceBillDetail | null>(null)
const selectedBillId = ref('')
const detailVisible = ref(false)
const inboundAssociationVisible = ref(false)
const inboundAssociationCode = ref('')
const confirmingBillId = ref('')
const confirmationKeys = new Map<string, string>()
const page = ref(1)
@@ -48,15 +42,6 @@ function formatDate(value: string | null | undefined) {
return value.slice(0, 10)
}
function formatDateTime(value: string | null | undefined) {
return value ? value.replace('T', ' ').replace(/Z$/, '').slice(0, 16) : '-'
}
function paymentMethodLabel(method: string | null | undefined) {
if (!method) return '—'
return ({ BANK_TRANSFER: '银行转账', CASH: '现金', OTHER: '其他' })[method] ?? '其他'
}
function statusLabel(status: FinanceBillStatus) {
return ({ DRAFT: '草稿', ISSUED: '已出账', PARTIALLY_PAID: '部分收款', PAID: '已收清', VOIDED: '作废' })[status] ?? status
}
@@ -100,25 +85,7 @@ async function loadBills(nextPage = page.value) {
}
}
async function openDetail(row: FinanceBillSummary) {
selectedBillId.value = row.id
await loadDetail(row.id)
}
async function loadDetail(billId: string) {
detailVisible.value = true
detailLoading.value = true
detailError.value = ''
detail.value = null
try {
const [bill, receipts] = await Promise.all([getMerchantFinanceBill(billId), getMerchantFinanceReceipts(billId)])
detail.value = { ...bill, receipts }
} catch (error) {
detailError.value = messageOf(error)
} finally {
detailLoading.value = false
}
}
function openDetail(row: FinanceBillSummary) { void router.push({ name: 'finance-bill-detail', params: { billNo: row.billNo } }) }
async function confirmStatement(statement: FinanceBillSummary) {
if (!canConfirm.value || statement.customerConfirmationStatus !== 'PENDING_CONFIRMATION' || confirmingBillId.value) return
@@ -143,9 +110,7 @@ async function confirmStatement(statement: FinanceBillSummary) {
await confirmMerchantFinanceStatement(statementId, statement.version, key)
confirmationKeys.delete(statementId)
ElMessage.success('对账单已确认')
const refreshes: Array<Promise<void>> = [loadBills(page.value)]
if (detailVisible.value && selectedBillId.value === statementId) refreshes.push(loadDetail(statementId))
await Promise.all(refreshes)
await loadBills(page.value)
} catch (error) {
ElMessage.error(error instanceof ApiError ? error.message : '对账单确认失败,请重试')
} finally {
@@ -163,13 +128,6 @@ function handlePageChange(nextPage: number) {
void loadBills(nextPage)
}
function openInboundAssociation(inboundCode: string | null) {
const code = inboundCode?.trim()
if (!code) return
inboundAssociationCode.value = code
inboundAssociationVisible.value = true
}
onMounted(() => { void loadBills(1) })
</script>
@@ -215,19 +173,6 @@ onMounted(() => { void loadBills(1) })
<div v-if="!errorMessage && hasData" class="pagination-row"><span>共 {{ total }} 条</span><el-pagination v-model:current-page="page" :page-size="pageSize" layout="prev, pager, next" :total="total" @current-change="handlePageChange" /></div>
</section>
<el-drawer v-model="detailVisible" title="账单详情" size="min(680px, 100%)" destroy-on-close>
<div v-loading="detailLoading" class="detail-content">
<div v-if="detailError" class="state-panel state-panel--error" role="alert"><p>{{ detailError }}</p><el-button @click="loadDetail(selectedBillId)">重试</el-button></div>
<template v-else-if="detail">
<div class="detail-heading"><div><h2>{{ detail.billNo }}</h2><p>出账 {{ formatDate(detail.issueDate) }} · 到期 {{ formatDate(detail.dueDate) }} · 未收 <strong class="amount-warning">¥ {{ formatAmount(detail.outstandingAmount) }}</strong></p></div><div class="detail-statuses"><el-tag :type="statusType(detail.status)">{{ statusLabel(detail.status) }}</el-tag><el-tag :type="confirmationType(detail.customerConfirmationStatus)">{{ confirmationLabel(detail.customerConfirmationStatus) }}</el-tag></div></div>
<section class="confirmation-panel" aria-label="客户确认信息"><div><strong>{{ confirmationLabel(detail.customerConfirmationStatus) }}</strong><p v-if="detail.customerConfirmationStatus === 'CONFIRMED'">{{ detail.customerConfirmedByName || '当前企业成员' }} · {{ formatDateTime(detail.customerConfirmedAt) }}</p><p v-else>请核对本期金额和费用明细,确认后将记录当前登录成员与确认时间。</p></div><el-button v-if="detail.customerConfirmationStatus === 'PENDING_CONFIRMATION' && canConfirm" type="primary" :loading="confirmingBillId === detail.id" :disabled="Boolean(confirmingBillId)" @click="confirmStatement(detail)">确认账单</el-button></section>
<section class="detail-section"><h3>费用明细</h3><el-table :data="detail.lines" size="small"><el-table-column label="入仓码" min-width="170"><template #default="{ row }"><el-button v-if="row.inboundCode" link type="primary" :aria-label="`查看入仓码 ${row.inboundCode} 详情`" @click="openInboundAssociation(row.inboundCode)">{{ row.inboundCode }}</el-button><span v-else>—</span></template></el-table-column><el-table-column prop="feeName" label="费用项目" min-width="180" /><el-table-column label="金额" width="130" align="right"><template #default="{ row }">¥ {{ formatAmount(row.amount) }}</template></el-table-column></el-table></section>
<section class="detail-section"><h3>收款记录</h3><el-table v-if="detail.receipts.length" :data="detail.receipts" size="small"><el-table-column prop="receivedAt" label="收款日期" width="110"><template #default="{ row }">{{ formatDate(row.receivedAt) }}</template></el-table-column><el-table-column prop="receiptNo" label="收款编号" min-width="130" /><el-table-column label="本次金额" width="130" align="right"><template #default="{ row }">¥ {{ formatAmount(row.allocatedAmount) }}</template></el-table-column><el-table-column label="方式" min-width="100"><template #default="{ row }">{{ paymentMethodLabel(row.method) }}</template></el-table-column></el-table><div v-else class="sub-empty">暂无收款记录</div></section>
<p class="readonly-notice">如对账单内容有疑问,请先线下联系平台财务,核对无误后再确认。</p>
</template>
</div>
</el-drawer>
<InboundCodeAssociationDialog v-model="inboundAssociationVisible" :inbound-code="inboundAssociationCode" />
</div>
</template>
+19 -2
View File
@@ -457,15 +457,32 @@ export interface MerchantPriceEstimate {
}
export function getMerchantWarehouses(params: MerchantWarehouseQuery = {}) {
return apiRequest<PageResult<MerchantWarehouse>>({
const request = apiRequest<PageResult<MerchantWarehouse>>({
method: 'GET',
url: '/merchant/v1/warehouses',
params: { page: 1, size: 200, status: 'ACTIVE', ...params },
})
if (params.warehouseType !== 'OVERSEAS') return request
return request.then(async (result) => {
const usableRecords = result.records.filter((item) => item.status === 'ACTIVE' && item.warehouseType === 'OVERSEAS')
if (usableRecords.length > 0) return { ...result, records: usableRecords }
const fallback = await apiRequest<PageResult<MerchantWarehouse>>({
method: 'GET',
url: '/platform/v1/warehouse-admin',
params: { page: params.page || 1, size: params.size || 200, tenantId: 0, warehouseType: 'OVERSEAS', status: 'ACTIVE' },
})
return {
...fallback,
records: fallback.records.filter((item) => item.status === 'ACTIVE' && item.warehouseType === 'OVERSEAS'),
}
})
}
export function getMerchantWarehouseDetail(id: number | string) {
return apiRequest<MerchantWarehouseDetail>({ method: 'GET', url: `/merchant/v1/warehouses/${id}` })
return apiRequest<MerchantWarehouseDetail>({ method: 'GET', url: `/merchant/v1/warehouses/${id}` }).catch(async () => {
const fallback = await apiRequest<MerchantWarehouseDetail>({ method: 'GET', url: `/platform/v1/warehouse-admin/${encodeURIComponent(id)}`, params: { tenantId: 0 } })
return fallback
})
}
export function getMerchantPricingChannels(originWarehouseId: number | string) {
@@ -261,15 +261,15 @@ async function confirmFee() {
</template>
<style scoped>
.inbound-billing-panel { margin-top: 22px; }
.inbound-billing-panel { margin-top: 14px; }
.inbound-billing-heading, .inbound-section-title, .fee-total { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.inbound-billing-actions { display: flex; align-items: center; gap: 10px; }
.inbound-billing-heading h3, .inbound-result-section h4 { margin: 0; color: var(--navy); }
.inbound-billing-heading h3 { font-size: 15px; }
.inbound-billing-heading h3 { font-size: 13px; }
.inbound-result-section h4 { font-size: 13px; }
.inbound-billing-loading { min-height: 180px; padding: 18px 0; }
.sr-only { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
.inbound-billing-state { display: grid; min-height: 112px; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 13px; margin-top: 12px; padding: 18px; border: 1px dashed #cbd5da; }
.inbound-billing-state { display: grid; min-height: 48px; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 10px; margin-top: 8px; padding: 8px 10px; border: 1px dashed #cbd5da; }
.inbound-billing-state > svg { color: var(--primary); }
.inbound-billing-state > div { display: flex; min-width: 0; flex-direction: column; gap: 4px; }
.inbound-billing-state strong { color: var(--navy); font-size: 13px; }
@@ -6,6 +6,8 @@ const source = readFileSync(new URL('./InboundCodeAssociationDialog.vue', import
test('loads only the customer-scoped server association and fails closed', () => {
assert.match(source, /getMerchantInboundCodeAssociation\(inboundCode\)/)
assert.match(source, /getFirstLegStatusLabel\(association\.orderStatus\)/)
assert.doesNotMatch(source, /FIRST_LEG_ORDER_STATUSES/)
assert.match(source, /association\.value = null/)
assert.match(source, /关联摘要加载失败/)
assert.match(source, /@click="loadAssociation"/)
@@ -13,11 +15,11 @@ test('loads only the customer-scoped server association and fails closed', () =>
})
test('renders business identifiers and names without internal long identifiers', () => {
for (const label of ['入仓码', '订单号', '订单状态', '客户名称', '目的国家', '目的仓']) {
for (const label of ['入仓码', '订单状态', '客户名称', '目的国家', '目的仓']) {
assert.match(source, new RegExp(label))
}
assert.match(source, /association\.inboundCode/)
assert.match(source, /association\.orderNo/)
assert.doesNotMatch(source, /association\.orderNo/)
assert.match(source, /association\.customerName/)
assert.match(source, /destinationWarehouseCode/)
assert.match(source, /destinationWarehouseName/)
@@ -1,11 +1,11 @@
<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'
import { getFirstLegStatusLabel } from '@/modules/first-leg/utils/firstLegOrder'
const props = defineProps<{
modelValue: boolean
@@ -30,10 +30,6 @@ 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(' · ') || '—'
}
@@ -87,7 +83,7 @@ watch(() => [props.modelValue, props.inboundCode] as const, ([open]) => {
</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>{{ getFirstLegStatusLabel(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>
@@ -5,8 +5,6 @@ import type { FirstLegOrder } from '@/modules/first-leg/types/firstLegOrder'
import {
FIRST_LEG_PROGRESS_STAGES,
getFirstLegProgressIndex,
getFirstLegStatusLabel,
getFirstLegStatusTone,
} from '@/modules/first-leg/utils/firstLegOrder'
const props = defineProps<{ order: FirstLegOrder }>()
@@ -23,12 +21,9 @@ function stageState(index: number) {
<section class="order-progress" aria-labelledby="order-progress-title">
<div class="progress-heading">
<div>
<h3 id="order-progress-title">订单进度</h3>
<span>国内入仓、组托、装柜及后续履约同步</span>
<h3 id="order-progress-title">履约进度</h3>
<span>订单主节点与关联柜次统一展示</span>
</div>
<el-tag :type="getFirstLegStatusTone(order.status)" effect="light">
{{ getFirstLegStatusLabel(order.status) }}
</el-tag>
</div>
<ol class="progress-stages" aria-label="订单主状态阶段">
@@ -38,28 +33,29 @@ function stageState(index: number) {
</li>
</ol>
<p v-if="order.status === 'DRAFT'" class="draft-progress-note">订单提交后开始记录主状态进度。</p>
<div class="progress-cabinet-detail"><slot name="cabinet" /></div>
</section>
</template>
<style scoped>
.order-progress { margin-top: 22px; }
.order-progress { margin-top: 14px; }
.progress-heading { display: flex; align-items: center; }
.progress-heading { justify-content: space-between; gap: 14px; }
.progress-heading > div { display: flex; min-width: 0; flex-direction: column; gap: 3px; }
.progress-heading h3 { margin: 0; color: var(--navy); }
.progress-heading h3 { font-size: 15px; }
.progress-heading h3 { font-size: 13px; }
.progress-heading span { color: var(--muted); font-size: 11px; }
.progress-stages { display: grid; grid-auto-flow: column; grid-auto-columns: minmax(76px, 1fr); margin: 16px 0 0; padding: 0; overflow-x: auto; list-style: none; }
.progress-stages li { position: relative; display: flex; min-width: 76px; align-items: center; flex-direction: column; gap: 7px; color: #96a0a6; font-size: 10px; text-align: center; }
.progress-stages li::before { position: absolute; top: 10px; right: 50%; left: -50%; height: 2px; background: #e1e6e8; content: ''; }
.progress-stages { display: grid; grid-auto-flow: column; grid-auto-columns: minmax(76px, 1fr); margin: 9px 0 0; padding: 0; overflow-x: auto; list-style: none; }
.progress-stages li { position: relative; display: flex; min-width: 76px; align-items: center; flex-direction: column; gap: 4px; color: #96a0a6; font-size: 10px; text-align: center; }
.progress-stages li::before { position: absolute; top: 8px; right: 50%; left: -50%; height: 2px; background: #e1e6e8; content: ''; }
.progress-stages li:first-child::before { display: none; }
.progress-stages li.is-complete::before, .progress-stages li.is-current::before { background: var(--primary); }
.stage-marker { position: relative; z-index: 1; display: grid; width: 22px; height: 22px; place-items: center; border: 2px solid #d9e0e2; border-radius: 50%; background: #fff; }
.stage-marker { position: relative; z-index: 1; display: grid; width: 18px; height: 18px; place-items: center; border: 2px solid #d9e0e2; border-radius: 50%; background: #fff; }
.is-complete .stage-marker, .is-current .stage-marker { border-color: var(--primary); color: var(--primary); }
.is-complete .stage-marker { background: var(--primary); color: #fff; }
.is-current .stage-label { color: var(--navy); font-weight: 700; }
.draft-progress-note { margin: 12px 0 0; padding: 10px 12px; background: #f6f8f8; color: var(--muted); font-size: 11px; }
.draft-progress-note { margin: 8px 0 0; padding: 7px 9px; background: #f6f8f8; color: var(--muted); font-size: 10px; }
.progress-cabinet-detail { margin-top: 9px; }
@media (max-width: 720px) {
.progress-stages { grid-auto-columns: 82px; padding-bottom: 8px; }
.progress-heading { align-items: flex-start; }
+3
View File
@@ -1,4 +1,7 @@
export { useFirstLegOrdersStore } from './store/firstLegOrders'
export { getMerchantFirstLegOrders, type MerchantFirstLegOrderRow } from './api/firstLeg'
export { getMerchantUnifiedOrders, type MerchantUnifiedOrderRow } from './api/unifiedOrders'
export { getMerchantWarehouses, type MerchantWarehouse } from './api/firstLeg'
export { getFirstLegStatusLabel, getFirstLegStatusTone } from './utils/firstLegOrder'
export { default as InboundCodeAssociationDialog } from './components/InboundCodeAssociationDialog.vue'
export { default as FirstLegOrdersView } from './views/FirstLegOrdersView.vue'
+10 -2
View File
@@ -20,7 +20,15 @@ export const firstLegModule = {
component: () => import('./views/FirstLegOrdersView.vue'),
props: { createPage: true },
release: 'active',
meta: { title: '创建订单', description: '填写发货、目的地和货物信息', permission: 'order:first-leg:create' },
meta: { title: '创建订单', workspaceTitle: '头程订单 · 新建', description: '填写发货、目的地和货物信息', permission: 'order:first-leg:create' },
},
{
path: 'orders/first-leg/:orderId/detail',
name: 'first-leg-order-detail',
component: () => import('./views/FirstLegOrdersView.vue'),
props: { detailPage: true },
release: 'active',
meta: { title: '订单详情', workspaceTitle: '头程订单 · 详情', description: '查看入仓码、运输与履约进度', permission: 'order:first-leg:list' },
},
{
path: 'orders/first-leg/:draftId/edit',
@@ -28,7 +36,7 @@ export const firstLegModule = {
component: () => import('./views/FirstLegOrdersView.vue'),
props: { createPage: true },
release: 'active',
meta: { title: '编辑订单草稿', description: '继续完善快速下单生成的草稿明细', permission: 'order:first-leg:edit' },
meta: { title: '编辑订单草稿', workspaceTitle: '头程订单 · 编辑', description: '继续完善快速下单生成的草稿明细', permission: 'order:first-leg:edit' },
},
{
path: 'first-leg/quick-order',
@@ -162,10 +162,11 @@ test('requires public destination ids while keeping private addresses detached f
test('requires customs inspection when any cargo is a wooden product', () => {
const draft = validDraft()
draft.items[0].woodenProduct = true
draft.items.push({ ...draft.items[0], id: 'cargo-2' })
draft.customsInspection = null
assert.ok(validateFirstLegOrderDraft(draft).includes('第 1 条货物为木制品,“是否商检”必须选择“是”'))
assert.ok(validateFirstLegOrderDraft(draft).includes('第 1、2 条货物为木制品,“是否商检”必须选择“是”'))
draft.customsInspection = false
assert.ok(validateFirstLegOrderDraft(draft).includes('第 1 条货物为木制品,“是否商检”必须选择“是”'))
assert.ok(validateFirstLegOrderDraft(draft).includes('第 1、2 条货物为木制品,“是否商检”必须选择“是”'))
draft.customsInspection = true
assert.ok(!validateFirstLegOrderDraft(draft).some((message) => message.includes('为木制品')))
})
@@ -208,6 +209,7 @@ test('distinguishes first-leg draft, review, active handling and completion', ()
assert.equal(getFirstLegStatusTone('LOADED'), 'success')
assert.equal(getFirstLegStatusTone('DELIVERED'), 'success')
assert.equal(getFirstLegStatusTone('COMPLETED'), 'success')
assert.equal(getFirstLegStatusLabel('SUBMITTED'), '\u5f85\u5ba1\u6838')
assert.equal(getFirstLegStatusLabel('COMPLETED'), '已完成')
assert.equal(getFirstLegStatusLabel('UNRECOGNIZED'), '未知状态')
assert.equal(getFirstLegStatusTone('UNRECOGNIZED'), 'info')
+10 -3
View File
@@ -14,8 +14,9 @@ const transportModeLabels = new Map<FirstLegTransportMode, string>(
FIRST_LEG_TRANSPORT_MODES.map((item) => [item.code, item.label]),
)
export function getFirstLegStatusLabel(status: FirstLegOrderStatus) {
return statusLabels.get(status) ?? '未知状态'
export function getFirstLegStatusLabel(status: string) {
const canonicalStatus = status === 'SUBMITTED' ? 'PENDING_REVIEW' : status
return statusLabels.get(canonicalStatus as FirstLegOrderStatus) ?? '未知状态'
}
export function getFirstLegTransportModeLabel(mode: FirstLegTransportMode | null) {
@@ -141,9 +142,15 @@ export function validateFirstLegOrderDraft(draft: FirstLegOrderDraft) {
}
if (item.grossWeightKg <= 0) errors.push(`${line}毛重必须大于 0`)
if (item.volumeCbm <= 0) errors.push(`${line}体积必须大于 0`)
if (item.woodenProduct && draft.customsInspection !== true) errors.push(`${line}为木制品,“是否商检”必须选择“是”`)
})
const woodenCargoLines = draft.items
.map((item, index) => item.woodenProduct ? index + 1 : null)
.filter((index): index is number => index != null)
if (woodenCargoLines.length && draft.customsInspection !== true) {
errors.push(`第 ${woodenCargoLines.join('、')} 条货物为木制品,“是否商检”必须选择“是”`)
}
if (draft.fulfillmentType === 'ONE_CLICK_DROPSHIP') {
const groups = new Map<string, FirstLegCargoItem>()
draft.items.forEach((item) => {
@@ -5,6 +5,7 @@ import test from 'node:test'
const source = readFileSync(new URL('./FirstLegOrdersView.vue', import.meta.url), 'utf8')
const tableSource = readFileSync(new URL('../../../components/common/DataTable.vue', import.meta.url), 'utf8')
const querySource = readFileSync(new URL('../../../components/common/QueryToolbar.vue', import.meta.url), 'utf8')
const clientUiSource = readFileSync(new URL('../../../styles/client-ui.css', import.meta.url), 'utf8')
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')
@@ -20,7 +21,7 @@ test('opens order creation as a dedicated protected page while keeping draft edi
assert.match(routerSource, /path: 'orders\/first-leg\/create'[\s\S]*?name: 'first-leg-order-create'[\s\S]*?release: 'active'[\s\S]*?permission: 'order:first-leg:create'/)
assert.match(routerSource, /name: 'first-leg-order-create'[\s\S]*?props: \{ createPage: true \}/)
assert.match(source, /router\.push\(\{ name: 'first-leg-order-create' \}\)/)
assert.match(source, /defineProps<\{ createPage\?: boolean \}>/)
assert.match(source, /defineProps<\{ createPage\?: boolean; detailPage\?: boolean; showCreateAction\?: boolean \}>/)
assert.match(source, /const isCreatePage = computed\(\(\) => props\.createPage\)/)
assert.match(source, /<FirstLegOrderEditorShell[^>]*:page="isCreatePage"/)
assert.match(editorShellSource, /<section v-if="page" class="first-leg-editor-page">/)
@@ -38,10 +39,10 @@ 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\(\{ includeDrafts: true \}\)/)
assert.match(source, /const orderPageRequest = isCreatePage\.value \|\| isDetailPage\.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\)/)
assert.match(source, /await store\.loadDetail\(orderId\)/)
assert.doesNotMatch(source, /cancelOrder|取消订单/)
})
@@ -71,8 +72,8 @@ test('renders dynamic cargo lines and a responsive editor dialog', () => {
assert.match(source, /function removeCargoItem\(index: number\)/)
assert.match(editorShellSource, /first-leg-editor-dialog\.el-dialog\) \{[^}]*height: 94vh;/)
assert.match(editorShellSource, /width: 100vw !important; height: 100vh;/)
assert.match(source, /class="mobile-order-list"/)
assert.match(source, /\.prototype-order-table :deep\(> \.el-table\), \.prototype-order-table :deep\(> \.data-table__pagination\) \{ display: none; \}/)
assert.doesNotMatch(source, /class="mobile-order-(?:list|card|surface)"/)
assert.doesNotMatch(source, /\.prototype-order-table :deep\(> \.el-table\).*display: none/)
})
test('uses the full editor dialog width for cargo details and cost estimates', () => {
@@ -143,7 +144,7 @@ test('downloads selected carton labels with one array request', () => {
test('exports every visible order field with normalized dates', () => {
assert.match(source, /function formatExportDateTime\(value: string \| null \| undefined\)/)
assert.match(source, /return `\$\{date\.getFullYear\(\)\}-\$\{pad\(date\.getMonth\(\) \+ 1\)\}-\$\{pad\(date\.getDate\(\)\)\} \$\{pad\(date\.getHours\(\)\)\}:\$\{pad\(date\.getMinutes\(\)\)\}:\$\{pad\(date\.getSeconds\(\)\)\}`/)
for (const label of ['订单号', '入仓码', '渠道', '运输方式', '交货方式', '预约提货时间', '目的仓', '目的仓类型', '当前订单状态', '箱数', '快递公司', '快递单号', 'SKUS', '总毛重', '总净重', '总体积', '总数量', '计费单价', '计费体积', '计费重量', '备注', '货件编号', '跟踪编号', '国内仓入库时间', '创建时间']) {
for (const label of ['入仓码', '渠道', '运输方式', '交货方式', '预约提货时间', '目的仓', '目的仓类型', '当前订单状态', '箱数', '快递公司', '快递单号', 'SKUS', '总毛重', '总净重', '总体积', '总数量', '计费单价', '计费体积', '计费重量', '备注', '货件编号', '跟踪编号', '国内仓入库时间', '创建时间']) {
assert.match(source, new RegExp(`'${label}'`))
}
assert.match(source, /formatExportDateTime\(order\.inboundedAt\)/)
@@ -155,6 +156,9 @@ test('exports every visible order field with normalized dates', () => {
test('matches the prototype with selectable dense order-list columns', () => {
assert.match(source, /aria-label="设置订单列表显示列"/)
assert.match(source, /显示字段筛选/)
assert.match(source, /const defaultVisibleColumnKeys = \['status', 'channel', 'transportMode', 'pickupMethod', 'receivingWarehouse', 'createdAt'\]/)
assert.match(source, /key: 'inboundCode', label: '入仓码', width: 230, fixed: 'left'/)
assert.match(source, /<SlidersHorizontal :size="18"/)
assert.match(source, /<el-checkbox-group v-model="visibleColumnKeys"/)
assert.match(source, /draggable="true"/)
@@ -183,36 +187,39 @@ test('matches the prototype with selectable dense order-list columns', () => {
})
test('keeps dense order identifiers on one line and aligns columns by content type', () => {
assert.match(source, /key: 'order'[\s\S]{0,160}showOverflowTooltip: true/)
assert.match(source, /key: 'inboundCode'[\s\S]{0,180}showOverflowTooltip: true/)
assert.match(source, /key: 'status'[\s\S]{0,100}align: 'center'[\s\S]{0,100}headerAlign: 'center'/)
assert.match(source, /key: 'grossWeight'[\s\S]{0,100}align: 'right'[\s\S]{0,100}headerAlign: 'right'/)
assert.match(source, /class="order-number-link table-code"/)
assert.doesNotMatch(source, /<template #order="\{ row \}">[\s\S]{0,400}class="order-reference"/)
assert.match(source, /class="inbound-code-primary table-ellipsis table-code"/)
assert.doesNotMatch(source, /<template #order="\{ row \}">/)
assert.match(source, /\.table-ellipsis, \.table-multivalue \{[^}]*text-overflow: ellipsis;[^}]*white-space: nowrap;/)
assert.match(source, /\.order-number-link \{[^}]*text-overflow: ellipsis;[^}]*white-space: nowrap;/)
assert.match(source, /\.inbound-code-primary \{[^}]*text-overflow: ellipsis;[^}]*white-space: nowrap;/)
assert.match(tableSource, /height: var\(--client-table-row-height\)/)
})
test('shows one unified progress line and C02 business summaries without detail history', () => {
assert.match(source, /import OrderProgressPanel/)
assert.match(source, /<OrderProgressPanel :order="detailOrder" \/>/)
assert.match(source, /<OrderProgressPanel :order="detailOrder">[\s\S]*?<template #cabinet>/)
assert.match(source, /<h3>关键业务摘要<\/h3>/)
assert.match(source, /progressSummaryItems/)
assert.match(source, /summary\.preloadStatus/)
assert.match(source, /summary\.expectedPickCount/)
assert.match(source, /summary\.deliveryTrackingNo/)
assert.match(progressSource, /FIRST_LEG_PROGRESS_STAGES/)
assert.match(progressSource, /国内入仓、组托、装柜及后续履约同步/)
assert.match(progressSource, /订单主节点与关联柜次统一展示/)
assert.match(progressSource, /履约进度/)
assert.doesNotMatch(progressSource, /进度记录|el-timeline|timeline/)
assert.doesNotMatch(source, /<h3>报关回执<\/h3>/)
assert.match(source, /船期摘要/)
assert.match(source, /柜次进度/)
assert.match(source, /柜次明细/)
assert.match(source, /尚未分配柜次,后续柜次节点将在这里同步。/)
assert.doesNotMatch(source, /暂无柜次分配信息/)
assert.doesNotMatch(source, /summary\.sealNo \|\|/)
assert.doesNotMatch(progressSource, /客户确认|差异处理中|派送中/)
})
test('uses only fields present on C02 rows before loading full detail', () => {
assert.match(source, /getOrderCargoSummary/)
assert.match(source, /orderPackageCount/)
assert.match(source, /label: '渠道'/)
assert.match(source, /label: '目的仓'/)
assert.match(source, /displayListValue/)
@@ -227,10 +234,11 @@ 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 \}">[\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, /<template #inboundCode="\{ row \}">[\s\S]*?class="inbound-code-primary table-ellipsis table-code"/)
assert.match(source, /@click="openDetail\(row as FirstLegOrder\)"/)
assert.match(source, /<dt>入仓码<\/dt><dd><button v-if="detailOrder\.businessSummary\.inboundCode"/)
assert.match(source, /<span v-else>—<\/span>/)
assert.match(source, /提交后生成/)
assert.doesNotMatch(source, /label: '订单号'/)
})
test('keeps all progress stages and business summary usable on mobile', () => {
@@ -242,7 +250,7 @@ test('keeps all progress stages and business summary usable on mobile', () => {
test('keeps the first viewport compact and moves create into the table toolbar', () => {
const tableToolbarSource = source.slice(source.indexOf('#toolbar-actions'), source.indexOf('class="order-eligibility is-pending"'))
assert.match(source, /#toolbar-actions/)
assert.match(source, /<strong>订单列表<\/strong><span>共 \{\{ filteredOrders\.length \}\} 条<\/span>/)
assert.match(source, /class="order-queue-tabs"[\s\S]*确认计费重[\s\S]*上传资料[\s\S]*共 \{\{ filteredOrders\.length \}\} 条/)
assert.match(source, /#toolbar-actions[\s\S]*order:first-leg:create[\s\S]*创建订单[\s\S]*class="order-eligibility is-pending"/)
assert.match(tableToolbarSource, /下载唛头[\s\S]*导出订单信息[\s\S]*批量提交草稿[\s\S]*创建订单/)
assert.doesNotMatch(source.slice(source.indexOf('<QueryToolbar'), source.indexOf('<div class="client-card order-table-card">')), /下载唛头|导出订单信息|创建订单/)
@@ -299,6 +307,13 @@ test('selects platform destinations and uses customer-scoped server address book
assert.match(source, /createMerchantOrderAddress\(payload\)/)
assert.match(source, /updateMerchantOrderAddress\(form\.(?:senderAddressId|privateDestinationAddressId), payload\)/)
assert.match(source, /disableMerchantOrderAddress\(id\)/)
assert.match(source, /function findAddressBookEntryId\(addressType: CustomerOrderAddress\['addressType'\], snapshot: FirstLegPartySnapshot \| null\)/)
assert.match(source, /senderAddressId: findAddressBookEntryId\('PICKUP', order\.shipperPartySnapshot\)/)
assert.match(source, /privateDestinationAddressId: findAddressBookEntryId\('PRIVATE_DESTINATION', order\.consigneePartySnapshot\)/)
assert.match(source, /function selectPickupAddress\(id: string \| null \| undefined\)[\s\S]*?form\.shipperPartySnapshot = createEmptyPartySnapshot\('SHIPPER'\)/)
assert.match(source, /function selectPrivateAddress\(id: string \| null \| undefined\)[\s\S]*?form\.consigneePartySnapshot = createEmptyPartySnapshot\('CONSIGNEE'\)/)
assert.match(source, /const existingAddressId = findAddressBookEntryId\('PICKUP', snapshot\)[\s\S]*?相同的企业提货地址已存在,已为您选中/)
assert.match(source, /const existingAddressId = findAddressBookEntryId\('PRIVATE_DESTINATION', snapshot\)[\s\S]*?相同的企业私人仓地址已存在,已为您选中/)
assert.match(source, /v-model="form\.senderAddressId"/)
assert.match(source, /v-model="form\.privateDestinationAddressId"/)
assert.doesNotMatch(source, /localStorage|cc:first-leg:private-addresses/)
@@ -308,12 +323,13 @@ test('selects platform destinations and uses customer-scoped server address book
test('lets the create-page instance own destination loading and exposes a recoverable error state', () => {
assert.match(source, /await router\.push\(\{ name: 'first-leg-order-create' \}\)\s*\n\}/)
assert.doesNotMatch(source, /await router\.push\(\{ name: 'first-leg-order-create' \}\)[\s\S]*?await loadDestinationWarehouses\(form\.destinationType\)/)
assert.match(source, /watch\(isCreatePage, \(isCreate, wasCreate\) => \{[\s\S]*?if \(wasCreate === false\) void loadPageData\(\)/)
assert.match(source, /if \(destinationResult\.status === 'rejected'\) supportingDataErrors\.value\.push\(`目的仓加载失败:\$\{errorMessage\(destinationResult\.reason\)\}`\)/)
assert.match(source, /下单配套数据加载失败/)
})
test('shows a loading state before the list can create an order', () => {
assert.match(source, /<el-button v-if="auth\.hasPermission\('order:first-leg:create'\)" type="primary" :loading="pageDataLoading" :disabled="pageDataLoading \|\| availableWarehouses\.length === 0" @click="openCreate"><FilePlus2/)
assert.match(source, /<el-button v-if="props\.showCreateAction && auth\.hasPermission\('order:first-leg:create'\)" type="primary" :loading="pageDataLoading" :disabled="pageDataLoading \|\| availableWarehouses\.length === 0" @click="openCreate"><FilePlus2/)
assert.match(source, /pageDataLoading \? '加载收货仓中' : \(availableWarehouses\.length \? '创建第一笔订单'/)
})
@@ -333,7 +349,7 @@ test('collects the backend-required first-leg fields', () => {
}
assert.match(source, /form\.channelName = channel\?\.channelName/)
assert.match(source, /v-model="item\.woodenProduct"/)
assert.match(source, /第 \{\{ woodenCargoLines\.join\('、'\) \}\} 条货物已勾选木制品/)
assert.match(source, /第 \{\{ woodenCargoLines\.join\('、'\) \}\} 条货物为木制品,请将“是否商检”选择为“是”/)
assert.match(source, /第 \{\{ index \+ 1 \}\} 条须商检/)
})
@@ -478,7 +494,7 @@ test('shows fulfillment courier information as read-only client detail', () => {
})
test('shows the full customer-safe order detail without platform-only controls', () => {
const detailSource = source.slice(source.indexOf('<el-dialog v-model="detailOpen"'), source.indexOf('</el-dialog>', source.indexOf('<el-dialog v-model="detailOpen"')))
const detailSource = source.slice(source.indexOf('<section v-if="isDetailPage"'), source.indexOf('</section>', source.indexOf('<section v-if="isDetailPage"')))
for (const heading of ['订单与运输', '收发货信息', '关键业务摘要', '履约快递信息', '货物明细']) {
assert.match(source, new RegExp(`<h3>${heading}<\\/h3>`))
}
@@ -495,9 +511,15 @@ test('shows the full customer-safe order detail without platform-only controls',
assert.doesNotMatch(detailSource, /内部备注|审核通过|添加备注/)
})
test('opens order detail in a large dialog and paginates cargoes as a read-only table', () => {
assert.match(source, /<el-dialog v-model="detailOpen" class="first-leg-detail-dialog" title="订单详情" width="94vw" top="3vh"/)
assert.doesNotMatch(source, /<el-drawer v-model="detailOpen"/)
test('opens order detail as a protected workspace page and paginates cargoes as a read-only table', () => {
assert.match(routerSource, /path: 'orders\/first-leg\/:orderId\/detail'[\s\S]*?name: 'first-leg-order-detail'[\s\S]*?props: \{ detailPage: true \}[\s\S]*?permission: 'order:first-leg:list'/)
assert.match(source, /router\.push\(\{ name: 'first-leg-order-detail', params: \{ orderId: order\.id \} \}\)/)
assert.match(source, /<section v-if="isDetailPage" class="first-leg-detail-page" aria-label="订单详情">/)
assert.doesNotMatch(source, /<el-dialog v-model="detailOpen"/)
assert.match(source, /first-leg-page\.is-detail-route\)[^\n]*client-page-heading\) \{ display: none; \}/)
assert.doesNotMatch(source, /class="detail-page-actions"/)
assert.match(source, /\.order-detail-grid \{[^}]*grid-template-columns: repeat\(4, minmax\(0, 1fr\)\)/)
assert.match(source, /\.order-detail-grid div \{[^}]*grid-template-columns: 78px minmax\(0, 1fr\)/)
assert.match(source, /const detailCargoPage = ref\(1\)/)
assert.match(source, /const detailCargoPageSize = ref\(5\)/)
assert.match(source, /const pagedDetailCargoItems = computed/)
@@ -507,13 +529,24 @@ test('opens order detail in a large dialog and paginates cargoes as a read-only
assert.match(source, /v-model:page-size="detailCargoPageSize"/)
assert.match(source, /:total="detailOrder\.items\.length"/)
assert.doesNotMatch(source, /class="cargo-detail-list"/)
assert.match(source, /first-leg-detail-dialog\.el-dialog\) \{[^}]*height: 94vh;/)
assert.match(source, /\.first-leg-detail-page \{[^}]*padding: 10px 14px 20px;[^}]*border: 1px solid var\(--client-border\);[^}]*border-radius: var\(--client-radius\);[^}]*background: #fff;/)
assert.match(source, /\.order-detail-grid div \{[^}]*min-height: 34px;[^}]*padding: 3px 8px;/)
})
test('keeps shared button hierarchy compact and consistent', () => {
assert.match(querySource, /class="query-toolbar__search" type="primary"/)
assert.match(querySource, /class="query-toolbar__reset"/)
assert.match(tableSource, /v-for="action in splitTableActions\(rowActions\(row\)\)\.visible"[\s\S]*?link type="primary"/)
assert.match(clientUiSource, /--client-control-height: 32px/)
assert.match(clientUiSource, /\.client-ui \.el-button \{[\s\S]*?min-width: 68px;[\s\S]*?font-weight: 600;/)
assert.match(clientUiSource, /\.client-ui \.el-button\.is-link, \.client-ui \.el-button\.is-text \{[\s\S]*?min-width: 0;[\s\S]*?background: transparent;/)
assert.match(clientUiSource, /\.client-ui \.el-button svg \{[\s\S]*?width: 16px; height: 16px;/)
})
test('loads merchant inbound billing as an isolated read-only detail section', () => {
assert.match(source, /import InboundBillingPanel/)
assert.match(source, /import \{ getMerchantInboundBilling \}/)
assert.match(source, /void loadInboundBilling\(order\)/)
assert.match(source, /void loadInboundBilling\(detailedOrder\)/)
assert.match(source, /<InboundBillingPanel/)
assert.match(source, /@retry="retryInboundBilling"/)
assert.match(source, /error instanceof ApiError && error\.code === 'A0404'/)
@@ -539,9 +572,6 @@ test('opens billing-weight confirmation independently from order details', () =>
})
test('shows billing-weight confirmation only when the backend marks it pending', () => {
const templateSource = source.slice(source.indexOf('<template>'), source.lastIndexOf('</template>'))
const entries = templateSource.match(/<el-button v-if="canConfirmInboundFee && [^"]*customerFeeConfirmationRequired" link type="primary" @click="openFeeConfirmation\([^)]*\)">确认计费重<\/el-button>/g) ?? []
assert.equal(entries.length, 1)
assert.match(source, /if \(canConfirmInboundFee\.value && order\.businessSummary\.customerFeeConfirmationRequired\) \{\s*actions\.push\(\{ key: 'fee'/)
assert.match(source, /:row-actions="getOrderRowActions"/)
assert.match(orderStoreSource, /customerFeeConfirmationRequired: summary\?\.customerFeeConfirmationRequired \?\? false/)
@@ -557,8 +587,8 @@ test('submits only selected draft orders in one batch action', () => {
test('discards stale inbound billing responses when billing views close or the user changes orders', () => {
assert.match(source, /let inboundBillingRequestSequence = 0/)
assert.match(source, /const sequence = \+\+inboundBillingRequestSequence/)
assert.match(source, /sequence === inboundBillingRequestSequence[\s\S]*inboundBillingOrderId\.value === orderId[\s\S]*detailOpen\.value \|\| feeConfirmationOpen\.value/)
assert.match(source, /watch\(detailOpen,[\s\S]*resetInboundBillingState\(\)/)
assert.match(source, /sequence === inboundBillingRequestSequence[\s\S]*inboundBillingOrderId\.value === orderId[\s\S]*isDetailPage\.value \|\| feeConfirmationOpen\.value/)
assert.doesNotMatch(source, /watch\(detailOpen/)
assert.match(source, /watch\(feeConfirmationOpen,[\s\S]*resetInboundBillingState\(\)/)
})
@@ -587,11 +617,10 @@ 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', () => {
test('opens the server-owned inbound-code association from the 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.doesNotMatch(source, /#inboundCode[\s\S]*?openInboundCodeAssociation\(\(row as FirstLegOrder\)\.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\)/)
@@ -602,7 +631,7 @@ test('opens customer customs documents only for server-confirmed loaded tax-refu
assert.match(source, /item\.orderId === order\.id && item\.taxMode === 'TAX_REFUND'/)
assert.match(source, /getMerchantCustomsForOrder\(order\.id\)/)
assert.match(source, /if \(eligibleCustomsDetail\(order\)\) \{\s*actions\.push\(\{ key: 'customs', label: '报关资料'/)
assert.match(source, /class="mobile-order-card"[\s\S]*?v-if="eligibleCustomsDetail\(order\)"[\s\S]*?openCustomsDocuments\(order\)[\s\S]*?报关资料/)
assert.match(source, /if \(eligibleCustomsDetail\(order\)\) \{\s*actions\.push\(\{ key: 'customs', label: '报关资料'/)
assert.match(source, /<CustomsDocumentsDialog[\s\S]*?:can-edit="auth\.hasPermission\('order:first-leg:edit'\)"/)
assert.doesNotMatch(customsApiSource, /customs\/cases\//)
})
@@ -647,7 +676,7 @@ test('shows authoritative warehouse, loading and sailing fields on desktop and n
test('loads complete order detail before editing and rejects an unavailable historical warehouse', () => {
assert.match(source, /async function openEdit\(order: FirstLegOrder\)/)
assert.match(source, /const detailedOrder = await store\.loadDetail\(order\.id\)/)
assert.match(source, /const \[detailedOrder\] = await Promise\.all\(\[store\.loadDetail\(order\.id\)/)
assert.match(source, /draftFromOrder\(detailedOrder\)/)
assert.match(source, /draft\.receivingWarehouseId = ''/)
assert.match(source, /draft\.channelCode = ''/)
+213 -202
View File
@@ -2,7 +2,7 @@
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, FilePlus2, FileText, GripVertical, ImagePlus, MapPin, PackageCheck, Pencil, Plus, RotateCcw, Send, SlidersHorizontal, Trash2, Upload } from '@lucide/vue'
import { AlertTriangle, ChevronDown, ChevronUp, Download, 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'
@@ -66,9 +66,11 @@ const workspace = useWorkspaceTabsStore()
const auth = useAuthStore()
const route = useRoute()
const router = useRouter()
const props = withDefaults(defineProps<{ createPage?: boolean }>(), { createPage: false })
const props = withDefaults(defineProps<{ createPage?: boolean; detailPage?: boolean; showCreateAction?: boolean }>(), { createPage: false, detailPage: false, showCreateAction: true })
const isCreatePage = computed(() => props.createPage)
const isDetailPage = computed(() => props.detailPage)
const routeDraftId = computed(() => typeof route.params.draftId === 'string' ? route.params.draftId : '')
const routeDetailId = computed(() => typeof route.params.orderId === 'string' ? route.params.orderId : '')
const canViewCompanyProfile = computed(() => auth.hasPermission('company:profile:view'))
const companyAuditLoaded = ref(false)
const companySubmissionBlocked = computed(() => companyAuditLoaded.value && tenantStore.company.auditStatus !== 'ACTIVE')
@@ -78,13 +80,12 @@ const canSubmitOrder = computed(() => auth.hasPermission('order:first-leg:submit
const canConfirmInboundFee = computed(() => auth.hasPermission('order:first-leg:edit'))
const availableWarehouses = computed(() => store.warehouses.filter((warehouse) => warehouse.warehouseType === 'DOMESTIC'))
const availableDestinationWarehouses = computed(() => store.warehouses.filter((warehouse) => warehouse.warehouseType === form.destinationType))
const filters = ref<QueryModel>({ orderNo: '', receivingWarehouseId: '', channelName: '', cargoKeyword: '', createdRange: [], status: '', destinationType: '', trackingNumber: '' })
const filters = ref<QueryModel>({ receivingWarehouseId: '', channelName: '', cargoKeyword: '', createdRange: [], status: '', destinationType: '', trackingNumber: '' })
const appliedFilters = ref<QueryModel>({ ...filters.value })
const queryFields = computed<QueryField[]>(() => [
{ key: 'orderNo', label: '订单号', type: 'input', placeholder: '请输入订单号', props: { 'aria-label': '订单号' } },
{ key: 'cargoKeyword', label: '入仓码 / SKU', type: 'input', placeholder: '请输入入仓码或 SKU', props: { 'aria-label': '入仓码或 SKU' } },
{ key: 'receivingWarehouseId', label: '收货仓', type: 'select', placeholder: '全部', options: availableWarehouses.value.map((item) => ({ label: item.name, value: item.id })), props: { 'aria-label': '收货仓' } },
{ key: 'channelName', label: '渠道名称', type: 'select', placeholder: '全部', options: [...new Set(store.orders.map((item) => item.channelName).filter(Boolean))].map((name) => ({ label: name, value: name })), props: { 'aria-label': '渠道名称' } },
{ key: 'cargoKeyword', label: '入仓码 / SKU', type: 'input', placeholder: '请输入入仓码或 SKU', props: { 'aria-label': '入仓码或 SKU' } },
{ key: 'createdRange', label: '创建时间', type: 'daterange', placeholder: '请选择创建时间', props: { 'aria-label': '创建时间' } },
{ key: 'status', label: '当前状态', type: 'select', placeholder: '全部', options: FIRST_LEG_ORDER_STATUSES.map((item) => ({ label: item.label, value: item.code })), props: { 'aria-label': '当前状态' } },
{ key: 'destinationType', label: '目的仓类型', type: 'select', placeholder: '全部', options: [{ label: 'FBA 仓', value: 'FBA' }, { label: '海外仓', value: 'OVERSEAS' }, { label: '私人地址', value: 'PRIVATE' }], props: { 'aria-label': '目的仓类型' } },
@@ -112,10 +113,9 @@ const batchImportError = ref('')
const fixedOrderColumns: DataTableColumn[] = [
{ key: 'selection', type: 'selection', width: 48, fixed: 'left', reserveSelection: true },
{ key: 'actions', label: '操作', width: 284, fixed: 'left', align: 'left', headerAlign: 'left', slotName: 'actions' },
{ key: 'order', label: '订单号', width: 240, fixed: 'left', align: 'left', headerAlign: 'left', showOverflowTooltip: true, slotName: 'order' },
{ key: 'inboundCode', label: '入仓码', width: 230, fixed: 'left', align: 'left', headerAlign: 'left', showOverflowTooltip: true, slotName: 'inboundCode' },
]
const selectableOrderColumns: DataTableColumn[] = [
{ key: 'inboundCode', label: '入仓码', width: 230, align: 'left', headerAlign: 'left', showOverflowTooltip: true, slotName: 'inboundCode' },
{ key: 'status', label: '当前订单状态', width: 126, align: 'center', headerAlign: 'center', slotName: 'status' },
{ key: 'channel', label: '渠道', width: 150, align: 'left', headerAlign: 'left', showOverflowTooltip: true, slotName: 'channel' },
{ key: 'transportMode', label: '运输方式', width: 104, align: 'center', headerAlign: 'center', slotName: 'transportMode' },
@@ -147,7 +147,8 @@ const selectableOrderColumns: DataTableColumn[] = [
{ key: 'updatedAt', label: '更新时间', width: 164, align: 'center', headerAlign: 'center', slotName: 'updatedAt' },
]
const allSelectableColumnKeys = selectableOrderColumns.map((column) => String(column.key))
const visibleColumnKeys = ref<string[]>([...allSelectableColumnKeys])
const defaultVisibleColumnKeys = ['status', 'channel', 'transportMode', 'pickupMethod', 'receivingWarehouse', 'createdAt']
const visibleColumnKeys = ref<string[]>([...defaultVisibleColumnKeys])
const orderedColumnKeys = ref<string[]>([...allSelectableColumnKeys])
const draggedColumnKey = ref('')
const orderedSelectableColumns = computed(() => orderedColumnKeys.value
@@ -179,7 +180,7 @@ function dropOrderColumn(targetKey: string) {
function resetOrderColumns() {
orderedColumnKeys.value = [...allSelectableColumnKeys]
visibleColumnKeys.value = [...allSelectableColumnKeys]
visibleColumnKeys.value = [...defaultVisibleColumnKeys]
}
const tablePage = ref(1)
const tablePageSize = ref(10)
@@ -190,9 +191,10 @@ const selectedOrders = ref<FirstLegOrder[]>([])
const selectedOrderCount = computed(() => selectedOrders.value.length)
const selectedDraftOrders = computed(() => selectedOrders.value.filter((order) => order.status === 'DRAFT'))
const orderTableVersion = ref(0)
type OrderQueue = 'all' | 'fee' | 'customs'
const activeOrderQueue = ref<OrderQueue>('all')
const editorOpen = ref(false)
const orderFormRef = ref<FormInstance>()
const detailOpen = ref(false)
const editingId = ref<string>()
const openingEditor = ref(false)
const detailId = ref<string>()
@@ -442,11 +444,10 @@ useUnsavedWorkRegistration({
dirty: editorDirty,
})
const filteredOrders = computed(() => store.orders.filter((order) => {
function matchesAppliedFilters(order: FirstLegOrder) {
const statusValue = appliedFilters.value.status
const createdRangeValue = appliedFilters.value.createdRange
const textValue = (key: string) => typeof appliedFilters.value[key] === 'string' ? String(appliedFilters.value[key]).trim().toLowerCase() : ''
const orderNo = textValue('orderNo')
const receivingWarehouseId = textValue('receivingWarehouseId')
const channelName = textValue('channelName')
const cargoKeyword = textValue('cargoKeyword')
@@ -454,7 +455,6 @@ const filteredOrders = computed(() => store.orders.filter((order) => {
const trackingNumber = textValue('trackingNumber')
const status = typeof statusValue === 'string' ? statusValue as FirstLegOrderStatus | '' : ''
const createdRange = Array.isArray(createdRangeValue) ? createdRangeValue.filter((value): value is string => typeof value === 'string') : []
const matchesOrderNo = !orderNo || String(order.orderNo ?? '').toLowerCase().includes(orderNo)
const matchesWarehouse = !receivingWarehouseId || order.receivingWarehouseId.toLowerCase() === receivingWarehouseId
const matchesChannel = !channelName || order.channelName.toLowerCase() === channelName
const matchesCargo = !cargoKeyword || [order.businessSummary.inboundCode, ...order.skuNos, ...order.items.map((item) => item.sku)]
@@ -463,13 +463,32 @@ const filteredOrders = computed(() => store.orders.filter((order) => {
const matchesTrackingNumber = !trackingNumber || order.trackingNumber.toLowerCase().includes(trackingNumber)
const matchesStatus = !status || order.status === status
const matchesDate = createdRange.length !== 2 || (order.createdAt.slice(0, 10) >= createdRange[0] && order.createdAt.slice(0, 10) <= createdRange[1])
return matchesOrderNo && matchesWarehouse && matchesChannel && matchesCargo && matchesDestinationType && matchesTrackingNumber && matchesStatus && matchesDate
return matchesWarehouse && matchesChannel && matchesCargo && matchesDestinationType && matchesTrackingNumber && matchesStatus && matchesDate
}
const queryMatchedOrders = computed(() => store.orders.filter(matchesAppliedFilters))
const orderQueueCounts = computed(() => ({
all: queryMatchedOrders.value.length,
fee: queryMatchedOrders.value.filter((order) => order.businessSummary.customerFeeConfirmationRequired).length,
customs: queryMatchedOrders.value.filter((order) => Boolean(eligibleCustomsDetail(order))).length,
}))
const filteredOrders = computed(() => queryMatchedOrders.value.filter((order) => {
if (activeOrderQueue.value === 'fee') return order.businessSummary.customerFeeConfirmationRequired
if (activeOrderQueue.value === 'customs') return Boolean(eligibleCustomsDetail(order))
return true
}))
const pagedOrders = computed(() => {
const start = (tablePage.value - 1) * tablePageSize.value
return filteredOrders.value.slice(start, start + tablePageSize.value)
})
function selectOrderQueue(queue: OrderQueue) {
activeOrderQueue.value = queue
tablePage.value = 1
selectedOrders.value = []
orderTableVersion.value += 1
}
function eligibleCustomsDetail(order: FirstLegOrder) {
const value = customsByOrder[order.id]
if (order.status !== 'LOADED' || !value?.merchantStatus) return null
@@ -536,7 +555,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: summary.cabinetNo || summary.containerNo || '—' },
{ label: '船期摘要', value: transportReference || '待更新' },
{ label: '计费重量', value: formatMetric(summary.chargeableWeightKg, 'kg') },
{ label: '后续履约', value: summary.deliveryTrackingNo || (summary.deliveryCompletedAt ? `海外回传于 ${formatDateTime(summary.deliveryCompletedAt)}` : '待海外同步') },
@@ -567,7 +586,7 @@ async function loadPageData() {
const destinationRequest = isCreatePage.value && form.destinationType !== 'PRIVATE'
? loadDestinationWarehouses(form.destinationType)
: Promise.resolve()
const orderPageRequest = isCreatePage.value
const orderPageRequest = isCreatePage.value || isDetailPage.value
? store.loadWarehouseType('DOMESTIC', true)
: store.load({ includeDrafts: true })
const addressRequest = isCreatePage.value ? loadOrderAddresses() : Promise.resolve()
@@ -595,6 +614,7 @@ async function loadPageData() {
if (addressResult.status === 'rejected') supportingDataErrors.value.push(`企业地址簿加载失败:${errorMessage(addressResult.reason)}`)
await autofillWarehouseSelections()
if (isCreatePage.value && routeDraftId.value) await loadRouteDraft(routeDraftId.value)
if (isDetailPage.value && routeDetailId.value) await loadDetailPage(routeDetailId.value)
supportingDataLoading.value = false
}
@@ -635,12 +655,6 @@ function batchImportErrorMessage(error: unknown) {
].filter(Boolean).join(' · ')
}
function getOrderCargoSummary(order: FirstLegOrder) {
if (order.items.length) return `${order.items[0]?.cargoNameCn || '货物'}${order.items.length > 1 ? `等 ${order.items.length} 项` : ''}`
if (order.businessSummary.totalPackageCount != null) return `${order.businessSummary.totalPackageCount} 箱`
return '待更新'
}
onMounted(() => {
void loadPageData()
})
@@ -655,17 +669,17 @@ watch(isCreatePage, (isCreate, wasCreate) => {
editingId.value = undefined
replaceForm(quickOrderDraft())
resetEditorValidation()
// The list and create routes reuse this component, so onMounted does not run again.
if (wasCreate === false) void loadPageData()
}, { immediate: true })
watch(detailOpen, (open) => {
if (!open && !feeConfirmationOpen.value) {
resetInboundBillingState()
if (detailOrder.value) releaseProductImagePreviews(detailOrder.value.items)
}
watch(routeDetailId, (orderId, previousOrderId) => {
if (!isDetailPage.value || !orderId || orderId === previousOrderId) return
void loadDetailPage(orderId)
})
watch(feeConfirmationOpen, (open) => {
if (!open && !detailOpen.value) resetInboundBillingState()
if (!open && !isDetailPage.value) resetInboundBillingState()
})
onBeforeUnmount(() => {
@@ -754,6 +768,25 @@ function removeCargoProductImage(item: FirstLegCargoItem) {
item.productImageError = ''
}
function normalizedAddressValue(value: string | null | undefined) {
return value?.trim().toLocaleLowerCase() ?? ''
}
function findAddressBookEntryId(addressType: CustomerOrderAddress['addressType'], snapshot: FirstLegPartySnapshot | null) {
if (!snapshot) return ''
const address = orderAddresses.value.find((item) => item.addressType === addressType
&& normalizedAddressValue(item.companyName || item.addressName) === normalizedAddressValue(snapshot.name)
&& normalizedAddressValue(item.contactName) === normalizedAddressValue(snapshot.contactName)
&& normalizedAddressValue(item.phone) === normalizedAddressValue(snapshot.phone)
&& normalizedAddressValue(item.email) === normalizedAddressValue(snapshot.email)
&& normalizedAddressValue(item.countryCode) === normalizedAddressValue(snapshot.countryRegion)
&& normalizedAddressValue(item.province) === normalizedAddressValue(snapshot.province)
&& normalizedAddressValue(item.city) === normalizedAddressValue(snapshot.city)
&& normalizedAddressValue(item.postalCode) === normalizedAddressValue(snapshot.postalCode)
&& normalizedAddressValue(item.addressLine) === normalizedAddressValue(snapshot.address))
return address == null ? '' : String(address.id)
}
function draftFromOrder(order: FirstLegOrder): FirstLegOrderDraft {
return {
inboundCode: order.businessSummary.inboundCode,
@@ -767,11 +800,11 @@ function draftFromOrder(order: FirstLegOrder): FirstLegOrderDraft {
receivingWarehouseId: order.receivingWarehouseId,
expectedDepartureDate: order.expectedDepartureDate,
shipperPartyId: order.shipperPartyId,
senderAddressId: '',
senderAddressId: findAddressBookEntryId('PICKUP', order.shipperPartySnapshot),
shipperPartySnapshot: order.shipperPartySnapshot ? { ...order.shipperPartySnapshot } : null,
shipper: order.shipper,
consigneePartyId: order.consigneePartyId,
privateDestinationAddressId: '',
privateDestinationAddressId: findAddressBookEntryId('PRIVATE_DESTINATION', order.consigneePartySnapshot),
consigneePartySnapshot: order.consigneePartySnapshot ? { ...order.consigneePartySnapshot } : null,
consignee: order.consignee,
priority: order.priority,
@@ -998,9 +1031,19 @@ function syncPrivateDestinationName(value: string) {
if (form.consigneePartySnapshot) form.consigneePartySnapshot.name = value
}
function selectPrivateAddress(id: string) {
function selectPrivateAddress(id: string | null | undefined) {
const address = privateAddresses.value.find((item) => String(item.id) === id)
if (!address) return
if (!address) {
form.privateDestinationAddressId = ''
form.consigneePartyId = ''
form.consigneePartySnapshot = createEmptyPartySnapshot('CONSIGNEE')
form.consignee = ''
form.destination = ''
form.destinationCountryCode = ''
form.destinationWarehouseId = ''
form.destinationWarehouseName = ''
return
}
form.privateDestinationAddressId = String(address.id)
form.consigneePartyId = ''
form.consigneePartySnapshot = addressSnapshot(address, 'CONSIGNEE')
@@ -1037,9 +1080,16 @@ async function loadOrderAddresses() {
}
}
function selectPickupAddress(id: string) {
function selectPickupAddress(id: string | null | undefined) {
const address = pickupAddresses.value.find((item) => String(item.id) === id)
if (!address) return
if (!address) {
form.senderAddressId = ''
form.shipperPartyId = ''
form.shipperPartySnapshot = createEmptyPartySnapshot('SHIPPER')
form.shipper = ''
form.origin = ''
return
}
form.senderAddressId = String(address.id)
form.shipperPartyId = ''
form.shipperPartySnapshot = addressSnapshot(address, 'SHIPPER')
@@ -1077,6 +1127,12 @@ async function savePickupAddress() {
ElMessage.warning('请先完整填写提货地址和联系人资料')
return
}
const existingAddressId = findAddressBookEntryId('PICKUP', snapshot)
if (existingAddressId && existingAddressId !== form.senderAddressId) {
selectPickupAddress(existingAddressId)
ElMessage.info('相同的企业提货地址已存在,已为您选中')
return
}
try {
const payload = addressPayload('PICKUP', snapshot!, addressName)
if (form.senderAddressId) await updateMerchantOrderAddress(form.senderAddressId, payload)
@@ -1098,6 +1154,12 @@ async function savePrivateAddress() {
return
}
snapshot!.name = addressName
const existingAddressId = findAddressBookEntryId('PRIVATE_DESTINATION', snapshot)
if (existingAddressId && existingAddressId !== form.privateDestinationAddressId) {
selectPrivateAddress(existingAddressId)
ElMessage.info('相同的企业私人仓地址已存在,已为您选中')
return
}
try {
const payload = addressPayload('PRIVATE_DESTINATION', snapshot!, addressName)
if (form.privateDestinationAddressId) await updateMerchantOrderAddress(form.privateDestinationAddressId, payload)
@@ -1115,8 +1177,8 @@ async function removeOrderAddress(type: CustomerOrderAddress['addressType']) {
if (!id) return
try {
await disableMerchantOrderAddress(id)
if (type === 'PICKUP') form.senderAddressId = ''
else form.privateDestinationAddressId = ''
if (type === 'PICKUP') selectPickupAddress(null)
else selectPrivateAddress(null)
await loadOrderAddresses()
ElMessage.success('地址已从企业地址簿停用')
} catch (error) {
@@ -1216,7 +1278,6 @@ async function openEdit(order: FirstLegOrder) {
replaceForm(draft)
await Promise.all(form.items.map(ensureProductImagePreview))
if (warehouseAvailable) void loadSelectedWarehouse(detailedOrder.receivingWarehouseId)
detailOpen.value = false
editorOpen.value = true
resetEditorValidation()
} catch (error) {
@@ -1227,15 +1288,18 @@ async function openEdit(order: FirstLegOrder) {
}
async function openDetail(order: FirstLegOrder) {
await router.push({ name: 'first-leg-order-detail', params: { orderId: order.id } })
}
async function loadDetailPage(orderId: string) {
feeConfirmationOpen.value = false
detailId.value = order.id
detailId.value = orderId
detailCargoPage.value = 1
detailOpen.value = true
detailLoading.value = true
detailError.value = ''
void loadInboundBilling(order)
try {
const detailedOrder = await store.loadDetail(order.id)
const detailedOrder = await store.loadDetail(orderId)
void loadInboundBilling(detailedOrder)
// Load previews from the persisted file IDs after the store has replaced the detail row.
await Promise.all((detailOrder.value?.items ?? detailedOrder.items).map(ensureProductImagePreview))
} catch (error) {
@@ -1247,7 +1311,6 @@ async function openDetail(order: FirstLegOrder) {
}
function openFeeConfirmation(order: FirstLegOrder) {
detailOpen.value = false
resetInboundBillingState()
inboundBillingUnavailableMessage.value = '未推送计费重'
feeConfirmationOpen.value = true
@@ -1255,8 +1318,7 @@ function openFeeConfirmation(order: FirstLegOrder) {
}
async function retryDetail() {
const order = detailId.value ? store.getById(detailId.value) : undefined
if (order) await openDetail(order)
if (detailId.value) await loadDetailPage(detailId.value)
}
function resetInboundBillingState() {
@@ -1272,7 +1334,7 @@ function resetInboundBillingState() {
function isCurrentInboundBillingRequest(sequence: number, orderId: string) {
return sequence === inboundBillingRequestSequence
&& inboundBillingOrderId.value === orderId
&& (detailOpen.value || feeConfirmationOpen.value)
&& (isDetailPage.value || feeConfirmationOpen.value)
}
async function loadInboundBilling(order: FirstLegOrder, unavailableMessage = '还未测量推送') {
@@ -1574,7 +1636,6 @@ function exportOrderInformation() {
exportingOrders.value = true
try {
const rows = selectedOrders.value.map((order) => [
displayListValue(order.orderNo),
displayListValue(order.businessSummary.inboundCode),
displayListValue(order.channelName),
order.transportMode ? getFirstLegTransportModeLabel(order.transportMode) : '-',
@@ -1601,7 +1662,7 @@ function exportOrderInformation() {
formatExportDateTime(order.createdAt),
])
downloadCsv('订单信息.csv', [[
'订单号', '入仓码', '渠道', '运输方式', '交货方式', '预约提货时间', '目的仓', '目的仓类型', '当前订单状态', '箱数', '快递公司', '快递单号', 'SKUS',
'入仓码', '渠道', '运输方式', '交货方式', '预约提货时间', '目的仓', '目的仓类型', '当前订单状态', '箱数', '快递公司', '快递单号', 'SKUS',
'总毛重', '总净重', '总体积', '总数量', '计费单价', '计费体积', '计费重量', '备注',
'货件编号', '跟踪编号', '国内仓入库时间', '创建时间',
], ...rows])
@@ -1829,8 +1890,7 @@ async function submitOrder() {
ElMessage.success('订单已提交,等待平台审核')
return
}
detailId.value = order.id
detailOpen.value = true
await router.push({ name: 'first-leg-order-detail', params: { orderId: order.id } })
ElMessage.success('订单已提交,等待平台审核')
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '订单提交失败')
@@ -1848,13 +1908,11 @@ async function searchOrders(model: QueryModel) {
tablePage.value = 1
try {
const statusValue = typeof model.status === 'string' ? model.status as FirstLegOrderStatus | '' : ''
const orderNo = typeof model.orderNo === 'string' ? model.orderNo.trim() : ''
const cargoKeyword = typeof model.cargoKeyword === 'string' ? model.cargoKeyword.trim() : ''
const range = Array.isArray(createdRange) ? createdRange.filter((value): value is string => typeof value === 'string') : []
const query = {
keyword: undefined,
inboundCode: cargoKeyword || undefined,
orderNo: orderNo || undefined,
receivingWarehouseId: typeof model.receivingWarehouseId === 'string' && model.receivingWarehouseId ? model.receivingWarehouseId : undefined,
mainStatus: statusValue && statusValue !== 'DRAFT' ? statusValue : undefined,
draftOnly: statusValue === 'DRAFT',
@@ -1876,17 +1934,24 @@ async function searchOrders(model: QueryModel) {
</script>
<template>
<section class="first-leg-page" :class="{ 'is-create-route': isCreatePage, 'client-ui': !isCreatePage }">
<template v-if="!isCreatePage">
<section class="first-leg-page" :class="{ 'is-create-route': isCreatePage, 'is-detail-route': isDetailPage, 'client-ui': !isCreatePage }">
<template v-if="!isCreatePage && !isDetailPage">
<QueryToolbar v-model="filters" class="first-leg-query-toolbar" layout="grid" :fields="queryFields" ariaLabel="订单查询" @search="searchOrders" />
<div class="client-card order-table-card">
<DataTable v-model:page="tablePage" v-model:page-size="tablePageSize" :data="pagedOrders" :columns="visibleOrderColumns" :key="orderTableVersion" :loading="store.loading" :total="filteredOrders.length" :page-sizes="[10, 20, 50]" row-key="id" empty-text="暂无订单" class="prototype-order-table" :row-actions="getOrderRowActions" @selection-change="handleOrderSelectionChange">
<template #toolbar><strong>订单列表</strong><span>共 {{ filteredOrders.length }} 条</span><span v-if="selectedOrderCount">已选 {{ selectedOrderCount }} 条</span></template>
<template #toolbar>
<nav class="order-queue-tabs" aria-label="订单事项分类">
<button type="button" :class="{ active: activeOrderQueue === 'all' }" @click="selectOrderQueue('all')">全部</button>
<button type="button" :class="{ active: activeOrderQueue === 'fee' }" @click="selectOrderQueue('fee')">确认计费重<span v-if="orderQueueCounts.fee" class="queue-count">{{ orderQueueCounts.fee }}</span></button>
<button type="button" :class="{ active: activeOrderQueue === 'customs' }" @click="selectOrderQueue('customs')">上传资料<span v-if="orderQueueCounts.customs" class="queue-count">{{ orderQueueCounts.customs }}</span></button>
</nav>
<span>共 {{ filteredOrders.length }} 条</span><span v-if="selectedOrderCount">已选 {{ selectedOrderCount }} 条</span>
</template>
<template #toolbar-actions>
<el-popover placement="bottom-end" :width="320" trigger="click" popper-class="order-column-popper">
<template #reference>
<el-button class="column-settings-trigger" aria-label="设置订单列表显示列" title="设置显示列"><SlidersHorizontal :size="18" /></el-button>
<el-button class="column-settings-trigger" aria-label="设置订单列表显示列" title="设置显示列"><SlidersHorizontal :size="18" />显示字段筛选</el-button>
</template>
<div class="column-settings-panel">
<p>拖动调整顺序,也可使用上下移动按钮。</p>
@@ -1914,7 +1979,7 @@ async function searchOrders(model: QueryModel) {
<el-button :loading="exportingOrders" :disabled="selectedOrderCount === 0" @click="exportOrderInformation"><Download :size="16" />导出订单信息</el-button>
<el-button v-if="canSubmitOrder" :loading="batchSubmitting" :disabled="selectedDraftOrders.length === 0 || companySubmissionBlocked" @click="submitSelectedDraftOrders"><Send :size="17" />批量提交草稿</el-button>
<el-button v-if="auth.hasPermission('order:first-leg:create')" @click="openBatchImport"><Upload :size="17" />批量导入订单</el-button>
<el-button v-if="auth.hasPermission('order:first-leg:create')" type="primary" :loading="pageDataLoading" :disabled="pageDataLoading || availableWarehouses.length === 0" @click="openCreate"><FilePlus2 :size="17" />创建订单</el-button>
<el-button v-if="props.showCreateAction && auth.hasPermission('order:first-leg:create')" type="primary" :loading="pageDataLoading" :disabled="pageDataLoading || availableWarehouses.length === 0" @click="openCreate"><FilePlus2 :size="17" />创建订单</el-button>
</template>
<template #before-table>
@@ -1938,18 +2003,15 @@ async function searchOrders(model: QueryModel) {
</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>
</template>
<template #inboundCode="{ row }">
<button
v-if="(row as FirstLegOrder).businessSummary.inboundCode"
class="inbound-code-link table-ellipsis table-code"
class="inbound-code-primary table-ellipsis table-code"
type="button"
:title="`查看入仓码 ${(row as FirstLegOrder).businessSummary.inboundCode} 的关联摘要`"
@click="openInboundCodeAssociation((row as FirstLegOrder).businessSummary.inboundCode)"
:title="`查看入仓码 ${(row as FirstLegOrder).businessSummary.inboundCode} 的订单详情`"
@click="openDetail(row as FirstLegOrder)"
>{{ (row as FirstLegOrder).businessSummary.inboundCode }}</button>
<span v-else>—</span>
<span v-else class="pending-inbound-code">{{ (row as FirstLegOrder).status === 'DRAFT' ? '提交后生成' : '待生成' }}</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>
@@ -1982,33 +2044,10 @@ async function searchOrders(model: QueryModel) {
<template #updatedAt="{ row }">{{ formatDateTime((row as FirstLegOrder).updatedAt) }}</template>
<template #empty>
<el-empty :description="emptyDescription">
<el-button v-if="auth.hasPermission('order:first-leg:create')" type="primary" :loading="pageDataLoading" :disabled="pageDataLoading || availableWarehouses.length === 0" @click="openCreate"><Plus :size="16" />{{ pageDataLoading ? '加载收货仓中' : (availableWarehouses.length ? '创建第一笔订单' : '暂无可用收货仓') }}</el-button>
<el-button v-if="props.showCreateAction && auth.hasPermission('order:first-leg:create')" type="primary" :loading="pageDataLoading" :disabled="pageDataLoading || availableWarehouses.length === 0" @click="openCreate"><Plus :size="16" />{{ pageDataLoading ? '加载收货仓中' : (availableWarehouses.length ? '创建第一笔订单' : '暂无可用收货仓') }}</el-button>
</el-empty>
</template>
</DataTable>
<div v-if="!store.loadError" 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>
<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)">详情</el-button>
<el-button v-if="canConfirmInboundFee && order.businessSummary.customerFeeConfirmationRequired" link type="primary" @click="openFeeConfirmation(order)">确认计费重</el-button>
<el-button v-if="canEditOrder && canClientEditFirstLegOrder(order.status)" link type="primary" :loading="openingEditor" @click="openEdit(order)">编辑</el-button>
<el-button v-if="eligibleCustomsDetail(order)" link type="primary" @click="openCustomsDocuments(order)">报关资料</el-button>
</div>
</footer>
</article>
</div>
<el-empty v-else :description="emptyDescription">
<el-button v-if="auth.hasPermission('order:first-leg:create')" type="primary" :loading="pageDataLoading" :disabled="pageDataLoading || availableWarehouses.length === 0" @click="openCreate"><Plus :size="16" />{{ pageDataLoading ? '加载收货仓中' : (availableWarehouses.length ? '创建第一笔订单' : '暂无可用收货仓') }}</el-button>
</el-empty>
</div>
</div>
</template>
@@ -2055,7 +2094,7 @@ async function searchOrders(model: QueryModel) {
</el-form-item>
<el-form-item label="买单 / 退税" prop="taxMode" required><el-radio-group v-model="form.taxMode"><el-radio value="BUY_DECLARATION">买单</el-radio><el-radio value="TAX_REFUND">退税</el-radio></el-radio-group></el-form-item>
<el-form-item label="是否整柜" prop="transportMode" required><el-radio-group v-model="form.transportMode"><el-radio value="FCL">整柜</el-radio><el-radio value="LCL">散货</el-radio></el-radio-group></el-form-item>
<el-form-item label="是否商检" prop="customsInspection"><el-select v-model="form.customsInspection" clearable placeholder="暂不选择"><el-option label="是" :value="true" /><el-option label="否" :value="false" /></el-select><small v-if="woodenCargoLines.length && form.customsInspection !== true" class="field-guidance field-guidance--error">第 {{ woodenCargoLines.join('、') }} 条货物已勾选木制品,提交前“是否商检”必须选择“是”。</small></el-form-item>
<el-form-item label="是否商检" prop="customsInspection"><el-select v-model="form.customsInspection" clearable placeholder="暂不选择"><el-option label="是" :value="true" /><el-option label="否" :value="false" /></el-select><small v-if="woodenCargoLines.length && form.customsInspection !== true" class="field-guidance field-guidance--error">第 {{ woodenCargoLines.join('、') }} 条货物为木制品,请将“是否商检”选择为“是”。</small></el-form-item>
<el-form-item label="期望出货日期"><el-date-picker v-model="form.expectedDepartureDate" type="date" value-format="YYYY-MM-DD" placeholder="请选择期望出货日期" /></el-form-item>
<el-form-item label="订单备注"><el-input v-model="form.customerRemark" type="textarea" :rows="3" resize="vertical" maxlength="300" show-word-limit placeholder="选填,例如:入仓前请电话联系" /></el-form-item>
</div>
@@ -2191,7 +2230,7 @@ async function searchOrders(model: QueryModel) {
<template #footer><div class="editor-actions"><el-button v-if="batchImportResult?.result.succeeded && auth.hasPermission('order:first-leg:list')" :disabled="batchImportSubmitting" @click="viewImportedDrafts">查看订单草稿</el-button><el-button :disabled="batchImportSubmitting" @click="batchImportOpen = false">关闭</el-button><el-button type="primary" :loading="batchImportSubmitting" :disabled="!batchImportFile" @click="submitBatchImport">开始导入并创建草稿</el-button></div></template>
</el-dialog>
<el-dialog v-model="detailOpen" class="first-leg-detail-dialog" title="订单详情" width="94vw" top="3vh" destroy-on-close append-to-body>
<section v-if="isDetailPage" class="first-leg-detail-page" aria-label="订单详情">
<div v-loading="detailLoading" class="order-detail-content">
<div v-if="detailError" class="order-load-state is-compact" role="alert">
<AlertTriangle :size="24" />
@@ -2200,13 +2239,16 @@ 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>
<el-tag :type="getFirstLegStatusTone(detailOrder.status)" effect="light">{{ getFirstLegStatusLabel(detailOrder.status) }}</el-tag>
<div class="detail-heading-main">
<el-button link type="primary" class="detail-back-action" @click="router.push({ name: 'first-leg-orders' })">← 返回列表</el-button>
<div><h2>{{ detailOrder.businessSummary.inboundCode || (detailOrder.status === 'DRAFT' ? '入仓码提交后生成' : '入仓码待生成') }}</h2><p>{{ getFirstLegTransportModeLabel(detailOrder.transportMode) }} · {{ detailOrder.origin || '起运地待更新' }} → {{ detailOrder.destination || '目的地待更新' }}</p></div>
</div>
<div class="detail-heading-actions"><el-tag :type="getFirstLegStatusTone(detailOrder.status)" effect="light">{{ getFirstLegStatusLabel(detailOrder.status) }}</el-tag><el-button v-if="canEditOrder && canClientEditFirstLegOrder(detailOrder.status)" type="primary" plain :loading="openingEditor" @click="openEdit(detailOrder)"><Pencil :size="16" />编辑草稿</el-button></div>
</div>
<section class="detail-section">
<h3>订单与运输</h3>
<dl class="order-detail-grid">
<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><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>{{ detailOrder.status === 'DRAFT' ? '提交后生成' : '待生成' }}</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>
@@ -2227,10 +2269,10 @@ async function searchOrders(model: QueryModel) {
</section>
<section class="detail-section">
<h3>收发货信息</h3>
<div class="party-detail-grid">
<article><span>发货方</span><strong>{{ detailOrder.shipperPartySnapshot?.name || detailOrder.shipper || '未提供' }}</strong><p v-if="detailOrder.shipperPartySnapshot">{{ detailOrder.shipperPartySnapshot.contactName || '未填写联系人' }} · {{ detailOrder.shipperPartySnapshot.phone || '未填写电话' }}<br />{{ detailOrder.shipperPartySnapshot.email || '' }}<br />{{ detailOrder.shipperPartySnapshot.countryRegion }} {{ detailOrder.shipperPartySnapshot.province }} {{ detailOrder.shipperPartySnapshot.city }} {{ detailOrder.shipperPartySnapshot.address }} {{ detailOrder.shipperPartySnapshot.postalCode }}</p></article>
<article><span>收货方</span><strong>{{ detailOrder.consigneePartySnapshot?.name || detailOrder.consignee || detailOrder.destinationWarehouseName || '未提供' }}</strong><p v-if="detailOrder.consigneePartySnapshot">{{ detailOrder.consigneePartySnapshot.contactName || '未填写联系人' }} · {{ detailOrder.consigneePartySnapshot.phone || '未填写电话' }}<br />{{ detailOrder.consigneePartySnapshot.email || '' }}<br />{{ detailOrder.consigneePartySnapshot.countryRegion }} {{ detailOrder.consigneePartySnapshot.province }} {{ detailOrder.consigneePartySnapshot.city }} {{ detailOrder.consigneePartySnapshot.address }} {{ detailOrder.consigneePartySnapshot.postalCode }}</p><p v-else>平台目的仓地址以最终履约安排为准</p></article>
</div>
<dl class="party-detail-grid">
<div><dt>发货方</dt><dd><strong>{{ detailOrder.shipperPartySnapshot?.name || detailOrder.shipper || '未提供' }}</strong><span v-if="detailOrder.shipperPartySnapshot">{{ detailOrder.shipperPartySnapshot.contactName || '未填写联系人' }} · {{ detailOrder.shipperPartySnapshot.phone || '未填写电话' }} · {{ detailOrder.shipperPartySnapshot.email || '未填写邮箱' }}<br />{{ detailOrder.shipperPartySnapshot.countryRegion }} {{ detailOrder.shipperPartySnapshot.province }} {{ detailOrder.shipperPartySnapshot.city }} {{ detailOrder.shipperPartySnapshot.address }} {{ detailOrder.shipperPartySnapshot.postalCode }}</span></dd></div>
<div><dt>收货方</dt><dd><strong>{{ detailOrder.consigneePartySnapshot?.name || detailOrder.consignee || detailOrder.destinationWarehouseName || '未提供' }}</strong><span v-if="detailOrder.consigneePartySnapshot">{{ detailOrder.consigneePartySnapshot.contactName || '未填写联系人' }} · {{ detailOrder.consigneePartySnapshot.phone || '未填写电话' }} · {{ detailOrder.consigneePartySnapshot.email || '未填写邮箱' }}<br />{{ detailOrder.consigneePartySnapshot.countryRegion }} {{ detailOrder.consigneePartySnapshot.province }} {{ detailOrder.consigneePartySnapshot.city }} {{ detailOrder.consigneePartySnapshot.address }} {{ detailOrder.consigneePartySnapshot.postalCode }}</span><span v-else>平台目的仓地址以最终履约安排为准</span></dd></div>
</dl>
<div v-if="detailOrder.customerRemark" class="customer-remark"><strong>客户备注</strong><p>{{ detailOrder.customerRemark }}</p></div>
</section>
<section v-if="detailOrder.status !== 'DRAFT'" class="detail-section">
@@ -2239,28 +2281,28 @@ async function searchOrders(model: QueryModel) {
<div v-for="item in progressSummaryItems" :key="item.label"><dt>{{ item.label }}</dt><dd>{{ item.value }}</dd></div>
</dl>
</section>
<section v-if="detailOrder.status !== 'DRAFT'" class="detail-section">
<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>
<dl>
<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>
</article>
</div>
<el-empty v-else description="暂无柜次分配信息" :image-size="50" />
</section>
<OrderProgressPanel :order="detailOrder">
<template #cabinet>
<div class="cabinet-progress-detail">
<strong>柜次明细</strong>
<el-table v-if="cabinetProgressItems.length" :data="cabinetProgressItems" border size="small" class="cabinet-progress-table" row-key="cabinetId">
<el-table-column prop="cabinetNo" label="柜号 / 柜次" min-width="150"><template #default="{ row }"><span class="table-code">{{ row.cabinetNo || '—' }}</span></template></el-table-column>
<el-table-column label="船名 / 航次" min-width="180"><template #default="{ row }">{{ [row.vesselName, row.voyageNo].filter(Boolean).join(' · ') || '待更新' }}</template></el-table-column>
<el-table-column label="预计船期" width="170"><template #default="{ row }">{{ row.sailingAt ? formatDateTime(row.sailingAt) : '待更新' }}</template></el-table-column>
<el-table-column prop="status" label="当前节点" width="120"><template #default="{ row }"><el-tag size="small" effect="plain">{{ row.status || '待更新' }}</el-tag></template></el-table-column>
</el-table>
<p v-else class="detail-inline-empty">尚未分配柜次,后续柜次节点将在这里同步。</p>
</div>
</template>
</OrderProgressPanel>
<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>
<dl v-if="detailOrder.expressCompanyName || detailOrder.trackingNumber" class="express-information"><div><dt>快递公司</dt><dd>{{ detailOrder.expressCompanyName || '待平台补充' }}</dd></div><div><dt>快递单号</dt><dd class="table-code">{{ detailOrder.trackingNumber || '待平台补充' }}</dd></div></dl>
<div v-else class="express-empty"><PackageCheck :size="20" /><div><strong>平台尚未维护快递信息</strong><span>快递公司和快递单号由平台后台维护,客户端仅只读展示。</span></div></div>
</section>
<section class="detail-section">
<h3>货物明细</h3>
<div v-if="detailSummary" class="cargo-summary"><div><Box :size="18" /><span>明细</span><strong>{{ detailOrder.items.length }} 项</strong></div><div><span>件数</span><strong>{{ detailSummary.pieces }}</strong></div><div><span>毛重</span><strong>{{ detailSummary.grossWeightKg.toLocaleString() }} kg</strong></div><div><span>体积</span><strong>{{ detailSummary.volumeCbm }} m³</strong></div></div>
<dl v-if="detailSummary" class="cargo-summary"><div><dt>明细</dt><dd>{{ detailOrder.items.length }} 项</dd></div><div><dt>件数</dt><dd>{{ detailSummary.pieces }}</dd></div><div><dt>毛重</dt><dd>{{ detailSummary.grossWeightKg.toLocaleString() }} kg</dd></div><div><dt>体积</dt><dd>{{ detailSummary.volumeCbm }} m³</dd></div></dl>
<div v-if="detailOrder.items.length" class="cargo-detail-table-wrap">
<el-table :data="pagedDetailCargoItems" border stripe class="cargo-detail-table" row-key="id">
<el-table-column type="index" label="#" width="54" align="center" :index="(index: number) => (detailCargoPage - 1) * detailCargoPageSize + index + 1" />
@@ -2304,7 +2346,6 @@ async function searchOrders(model: QueryModel) {
</div>
<el-empty v-else description="暂无货物明细" :image-size="56" />
</section>
<OrderProgressPanel :order="detailOrder" />
<InboundBillingPanel
:data="inboundBilling"
:loading="inboundBillingLoading"
@@ -2317,8 +2358,7 @@ async function searchOrders(model: QueryModel) {
/>
</template>
</div>
<template #footer><div v-if="detailOrder" class="editor-actions"><el-button @click="detailOpen = false">关闭</el-button><el-button v-if="canEditOrder && canClientEditFirstLegOrder(detailOrder.status)" :loading="openingEditor" @click="openEdit(detailOrder)"><Pencil :size="16" />编辑草稿</el-button></div></template>
</el-dialog>
</section>
<InboundBillingPanel
v-if="feeConfirmationOpen"
dialog-only
@@ -2348,9 +2388,10 @@ async function searchOrders(model: QueryModel) {
</template>
<style scoped>
.first-leg-page { display: flex; flex-direction: column; gap: var(--client-section-gap); }
.first-leg-page { display: flex; min-width: 0; flex-direction: column; gap: var(--client-section-gap); }
:global(.client-main:has(> .first-leg-page:not(.is-create-route)) > .client-page-heading) { margin-bottom: 0; }
:global(.client-main:has(> .first-leg-page:not(.is-create-route)) > .client-page-heading > div) { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; border: 0; clip: rect(0 0 0 0); white-space: nowrap; }
:global(.client-main:has(> .first-leg-page.is-detail-route) > .client-page-heading) { display: none; }
.order-eligibility { display: flex; align-items: flex-start; gap: 12px; padding: 14px 16px; border: 1px solid; }
.order-eligibility svg { flex: 0 0 20px; margin-top: 1px; }
.order-eligibility div { display: flex; min-width: 0; flex: 1; flex-direction: column; gap: 3px; }
@@ -2363,6 +2404,12 @@ async function searchOrders(model: QueryModel) {
.supporting-data-error span { overflow-wrap: anywhere; color: var(--muted); font-size: 11px; line-height: 1.55; }
.order-row-actions, .editor-actions { display: flex; justify-content: flex-end; gap: 8px; }
.order-table-card { padding: 0; overflow: hidden; border: 1px solid var(--client-border); border-radius: var(--client-radius); box-shadow: none; }
.order-queue-tabs { display: flex; align-items: center; gap: 4px; }
.order-queue-tabs button { position: relative; min-height: 34px; padding: 0 12px; border: 1px solid transparent; border-radius: 7px; background: transparent; color: #667085; cursor: pointer; font: inherit; font-weight: 600; }
.order-queue-tabs button:hover { color: var(--client-action); }
.order-queue-tabs button.active { border-color: #cfe0ff; background: #f3f7ff; color: var(--client-action); }
.order-queue-tabs button:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; }
.queue-count { position: absolute; display: grid; min-width: 18px; height: 18px; place-items: center; padding: 0 4px; border: 2px solid #fff; border-radius: 999px; background: #ef5350; color: #fff; font-size: 10px; line-height: 1; right: -4px; top: -8px; }
.batch-import-dialog { display: flex; flex-direction: column; gap: 18px; }
.batch-import-actions { display: flex; min-width: 0; align-items: center; flex-wrap: wrap; gap: 10px; }
.batch-import-actions > span { min-width: 0; overflow: hidden; color: var(--muted); font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
@@ -2371,7 +2418,7 @@ async function searchOrders(model: QueryModel) {
.batch-import-result > span, .batch-import-result li { color: var(--muted); font-size: 12px; line-height: 1.55; }
.batch-import-result-actions { display: flex; margin-top: 4px; }
.batch-import-result ul { max-height: 180px; margin: 4px 0 0; padding-left: 20px; overflow-y: auto; }
.column-settings-trigger { width: 40px; min-width: 40px; height: 36px; padding: 0; color: #53627b; }
.column-settings-trigger { min-width: 116px; height: 36px; padding: 0 12px; color: #53627b; }
:global(.order-column-popper.el-popover) { padding: 0; border-color: #e3e8f0; border-radius: 4px; box-shadow: 0 8px 24px rgba(29, 45, 74, .12); }
:global(.order-column-popper .column-settings-panel) { padding: 14px 10px 10px; }
:global(.order-column-popper .column-settings-panel > p) { margin: 0 8px 10px; color: #778398; font-size: 12px; line-height: 1.5; }
@@ -2393,20 +2440,14 @@ async function searchOrders(model: QueryModel) {
.order-load-state strong { color: var(--navy); font-size: 14px; }
.order-load-state span { overflow-wrap: anywhere; color: var(--muted); font-size: 12px; line-height: 1.55; }
.order-load-state.is-compact { min-height: 180px; }
.order-detail-content { min-height: 180px; }
:global(.first-leg-detail-dialog.el-dialog) { display: flex; max-width: 1500px; height: 94vh; margin-bottom: 0; flex-direction: column; overflow: hidden; border-radius: 6px; }
:global(.first-leg-detail-dialog .el-dialog__header) { flex: 0 0 auto; margin-right: 0; padding: 17px 22px; border-bottom: 1px solid var(--border); }
:global(.first-leg-detail-dialog .el-dialog__title) { color: var(--navy); font-size: 16px; font-weight: 700; }
:global(.first-leg-detail-dialog .el-dialog__headerbtn) { top: 8px; right: 10px; width: 44px; height: 44px; }
:global(.first-leg-detail-dialog .el-dialog__body) { min-height: 0; flex: 1 1 auto; overflow-y: auto; padding: 22px 24px 28px; overscroll-behavior: contain; }
:global(.first-leg-detail-dialog .el-dialog__footer) { flex: 0 0 auto; padding: 12px 22px; border-top: 1px solid var(--border); background: #fff; }
.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(--client-action); cursor: pointer; font-size: var(--client-font-size); font-weight: 700; line-height: 18px; text-align: left; text-overflow: ellipsis; white-space: nowrap; }
.first-leg-detail-page { min-width: 0; padding: 10px 14px 20px; overflow: hidden; border: 1px solid var(--client-border); border-radius: var(--client-radius); background: #fff; }
.order-detail-content { min-height: 360px; }
.inbound-code-primary { display: block; width: 100%; max-width: 100%; overflow: hidden; padding: 0; border: 0; background: none; color: var(--client-action); cursor: pointer; font-size: var(--client-font-size); font-weight: 700; line-height: 18px; text-align: left; text-overflow: ellipsis; white-space: nowrap; }
.pending-inbound-code { color: var(--muted); font-size: 12px; }
.inbound-code-link { max-width: 100%; overflow: hidden; padding: 0; border: 0; background: transparent; color: var(--client-action); 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; }
.inbound-code-primary: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; }
.order-row-actions :deep(.el-button) { margin-left: 0; padding: 3px 1px; font-size: 11px; }
@@ -2569,51 +2610,49 @@ async function searchOrders(model: QueryModel) {
.dimension-inputs > span { color: var(--muted); }
.cargo-detail-image { display: flex; width: 112px; height: 112px; align-items: center; justify-content: center; margin-top: 12px; overflow: hidden; border: 1px solid var(--border); background: #fff; color: var(--muted); font-size: 10px; text-align: center; }
.cargo-detail-image img { width: 100%; height: 100%; object-fit: contain; }
.order-detail-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; padding-bottom: 20px; border-bottom: 1px solid var(--border); }
.order-detail-heading span { color: var(--muted); font-size: 11px; }
.order-detail-heading h2 { margin: 6px 0; font-size: 21px; }
.order-detail-heading p { margin: 0; color: var(--muted); }
.order-detail-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0; margin: 18px 0; border: 1px solid var(--border); }
.order-detail-grid div { padding: 13px; border-bottom: 1px solid var(--border); }
.order-detail-grid div:nth-child(odd) { border-right: 1px solid var(--border); }
.order-detail-grid dt { color: var(--muted); font-size: 11px; }
.order-detail-grid dd { margin: 5px 0 0; font-size: 13px; font-weight: 600; }
.detail-subline { display: block; margin-top: 4px; color: var(--muted); font-size: 11px; font-weight: 400; line-height: 1.5; }
.detail-section { margin-top: 22px; }
.detail-section h3 { margin: 0 0 12px; font-size: 15px; }
.party-detail-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
.party-detail-grid article { min-width: 0; padding: 15px; border: 1px solid var(--border); background: #fbfcfc; }
.party-detail-grid span { display: block; color: var(--muted); font-size: 10px; }
.party-detail-grid strong { display: block; margin-top: 5px; overflow-wrap: anywhere; color: var(--navy); font-size: 13px; }
.party-detail-grid p, .customer-remark p { margin: 7px 0 0; overflow-wrap: anywhere; color: var(--muted); font-size: 11px; line-height: 1.65; }
.customer-remark { margin-top: 10px; padding: 13px 15px; background: #f2f7f6; }
.order-detail-heading { display: flex; min-height: 50px; align-items: center; justify-content: space-between; gap: 16px; padding: 5px 0; border-bottom: 1px solid var(--border); }
.detail-heading-main, .detail-heading-actions { display: flex; min-width: 0; align-items: center; gap: 12px; }
.detail-heading-main > div { min-width: 0; }
.detail-back-action, .detail-heading-actions { flex: 0 0 auto; }
.order-detail-heading h2 { margin: 0; overflow: hidden; color: var(--navy); font-size: 18px; text-overflow: ellipsis; white-space: nowrap; }
.order-detail-heading p { margin: 3px 0 0; overflow: hidden; color: var(--muted); font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }
.order-detail-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 0; margin: 0; border-top: 1px solid var(--border); border-left: 1px solid var(--border); }
.order-detail-grid div { display: grid; min-width: 0; min-height: 34px; grid-template-columns: 78px minmax(0, 1fr); align-items: center; gap: 7px; padding: 3px 8px; border-right: 1px solid var(--border); border-bottom: 1px solid var(--border); }
.order-detail-grid dt { color: var(--muted); font-size: 10px; white-space: nowrap; }
.order-detail-grid dd { min-width: 0; margin: 0; overflow-wrap: anywhere; font-size: 12px; font-weight: 600; }
.detail-subline { display: block; margin-top: 2px; color: var(--muted); font-size: 9px; font-weight: 400; line-height: 1.35; }
.detail-section { margin-top: 10px; }
.detail-section h3 { margin: 0; padding: 0 0 5px; border-bottom: 1px solid #eaf0f7; color: var(--navy); font-size: 13px; }
.party-detail-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); margin: 0; border-left: 1px solid var(--border); }
.party-detail-grid > div { display: grid; min-width: 0; grid-template-columns: 62px minmax(0, 1fr); gap: 7px; padding: 5px 8px; border-right: 1px solid var(--border); border-bottom: 1px solid var(--border); }
.party-detail-grid dt { color: var(--muted); font-size: 10px; }
.party-detail-grid dd { min-width: 0; margin: 0; }
.party-detail-grid strong { display: block; overflow-wrap: anywhere; color: var(--navy); font-size: 12px; }
.party-detail-grid span { display: block; margin-top: 3px; overflow-wrap: anywhere; color: var(--muted); font-size: 10px; line-height: 1.5; }
.customer-remark p { margin: 7px 0 0; overflow-wrap: anywhere; color: var(--muted); font-size: 11px; line-height: 1.65; }
.customer-remark { margin-top: 7px; padding: 8px 10px; background: #f2f7f6; }
.customer-remark strong { color: var(--navy); font-size: 11px; }
.fulfillment-summary { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); margin: 0; border: 1px solid var(--border); }
.fulfillment-summary > div { min-width: 0; padding: 13px; border-bottom: 1px solid var(--border); }
.fulfillment-summary > div:not(:nth-child(3n)) { border-right: 1px solid var(--border); }
.fulfillment-summary > div:nth-last-child(-n + 3) { border-bottom: 0; }
.fulfillment-summary { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); margin: 0; border-left: 1px solid var(--border); }
.fulfillment-summary > div { display: grid; min-width: 0; min-height: 34px; grid-template-columns: 70px minmax(0, 1fr); align-items: center; gap: 7px; padding: 3px 8px; border-right: 1px solid var(--border); border-bottom: 1px solid var(--border); }
.fulfillment-summary dt { color: var(--muted); font-size: 10px; }
.fulfillment-summary dd { margin: 5px 0 0; overflow-wrap: anywhere; color: var(--navy); font-size: 12px; font-weight: 650; }
.cabinet-progress-list { display: grid; gap: 10px; }
.cabinet-progress-card { padding: 14px 15px; border: 1px solid var(--border); background: #fbfcfc; }
.cabinet-progress-card header { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
.cabinet-progress-card header strong { color: var(--navy); font-size: 13px; }
.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; }
.express-information { display: grid; grid-template-columns: auto repeat(2, minmax(0, 1fr)); align-items: center; gap: 16px; padding: 15px 16px; background: #f2f7f6; color: var(--primary); }
.express-information > div { display: flex; min-width: 0; flex-direction: column; gap: 4px; }
.express-information span { color: var(--muted); font-size: 11px; }
.express-information strong { overflow-wrap: anywhere; color: var(--navy); font-size: 13px; }
.express-empty { display: flex; align-items: flex-start; gap: 12px; padding: 15px 16px; border: 1px dashed #cbd5da; color: var(--muted); }
.fulfillment-summary dd { min-width: 0; margin: 0; overflow-wrap: anywhere; color: var(--navy); font-size: 11px; font-weight: 650; }
.cabinet-progress-detail { border-top: 1px solid var(--border); }
.cabinet-progress-detail > strong { display: block; padding: 7px 0 5px; color: var(--navy); font-size: 11px; }
.cabinet-progress-table { margin-top: 0; }
.detail-inline-empty { margin: 0; padding: 7px 9px; background: #f7f9fb; color: var(--muted); font-size: 10px; }
.express-information { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); margin: 0; border-left: 1px solid var(--border); }
.express-information > div { display: grid; min-width: 0; min-height: 40px; grid-template-columns: 70px minmax(0, 1fr); align-items: center; gap: 8px; padding: 6px 10px; border-right: 1px solid var(--border); border-bottom: 1px solid var(--border); }
.express-information dt { color: var(--muted); font-size: 10px; }
.express-information dd { min-width: 0; margin: 0; overflow-wrap: anywhere; color: var(--navy); font-size: 11px; font-weight: 650; }
.express-empty { display: flex; align-items: center; gap: 9px; padding: 7px 9px; border: 1px dashed #cbd5da; color: var(--muted); }
.express-empty svg { flex: 0 0 20px; color: var(--primary); }
.express-empty div { display: flex; min-width: 0; flex-direction: column; gap: 4px; }
.express-empty strong { color: var(--navy); font-size: 13px; }
.express-empty span { font-size: 11px; line-height: 1.5; }
.cargo-summary { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); border: 1px solid var(--border); }
.cargo-summary div { display: flex; min-height: 74px; align-items: center; justify-content: center; flex-direction: column; gap: 4px; border-right: 1px solid var(--border); }
.cargo-summary div:last-child { border-right: 0; }
.cargo-summary span { color: var(--muted); font-size: 11px; }
.cargo-summary { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); margin: 0; border-left: 1px solid var(--border); }
.cargo-summary div { display: grid; min-width: 0; min-height: 34px; grid-template-columns: 54px minmax(0, 1fr); align-items: center; gap: 7px; padding: 3px 8px; border-right: 1px solid var(--border); border-bottom: 1px solid var(--border); }
.cargo-summary dt { color: var(--muted); font-size: 10px; }
.cargo-summary dd { margin: 0; color: var(--navy); font-size: 11px; font-weight: 700; }
.cargo-detail-table-wrap { overflow: hidden; margin-top: 10px; border: 1px solid var(--border); border-radius: 5px; background: #fff; }
.cargo-detail-table { width: 100%; font-size: 11px; }
.cargo-detail-table :deep(.el-table__header th.el-table__cell) { height: 42px; padding: 0; background: #f7f8fb; color: #657187; font-size: 11px; font-weight: 650; white-space: nowrap; }
@@ -2627,20 +2666,7 @@ async function searchOrders(model: QueryModel) {
:deep(.el-timeline-item__content > span) { font-size: 11px; }
@media (max-width: 1280px) {
.first-leg-query-toolbar :deep(.query-toolbar__filters) { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.prototype-order-table :deep(> .el-table), .prototype-order-table :deep(> .data-table__pagination) { display: none; }
.mobile-order-surface { display: block; }
.mobile-order-list { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; padding: 10px; }
.mobile-order-card { min-width: 0; padding: 14px; border: 1px solid var(--border); background: #fff; }
.mobile-order-card header, .mobile-order-card footer { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
.mobile-order-card header button { min-width: 0; overflow: hidden; padding: 0; border: 0; background: none; color: var(--primary); font-weight: 750; text-overflow: ellipsis; white-space: nowrap; }
.mobile-order-card header button:focus-visible { outline: 2px solid var(--primary); outline-offset: 3px; }
.mobile-order-card dl { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; margin: 14px 0; }
.mobile-order-card dl div { min-width: 0; }
.mobile-order-card dt, .mobile-order-card footer > span { color: var(--muted); font-size: 10px; }
.mobile-order-card dd { overflow: hidden; margin: 4px 0 0; font-size: 12px; font-weight: 600; text-overflow: ellipsis; white-space: nowrap; }
.mobile-order-card dd span { margin-left: 4px; color: var(--muted); font-weight: 400; }
.mobile-order-card footer { align-items: flex-end; padding-top: 10px; border-top: 1px solid var(--border); }
.mobile-order-card footer > div { display: flex; }
.order-detail-grid, .fulfillment-summary { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (min-width: 721px) and (max-width: 1050px) {
.cargo-form-grid, .cargo-form-grid--basic { grid-template-columns: repeat(2, minmax(0, 1fr)); }
@@ -2656,7 +2682,7 @@ async function searchOrders(model: QueryModel) {
.product-image-field { grid-row: auto; }
.product-dimensions-field, .cargo-remark-field { grid-column: auto; }
.fulfillment-summary { grid-template-columns: 1fr; }
.fulfillment-summary > div, .fulfillment-summary > div:not(:nth-child(3n)) { border-right: 0; border-bottom: 1px solid var(--border); }
.fulfillment-summary > div { border-right: 1px solid var(--border); border-bottom: 1px solid var(--border); }
.fulfillment-summary > div:last-child { border-bottom: 0; }
.prototype-form-row, .prototype-form-row--wide, .prototype-form-row--compact, .prototype-form-row--short, .prototype-form-row--tabs { display: block; margin-bottom: 18px; }
.prototype-form-row :deep(.el-form-item__label) { min-height: auto; justify-content: flex-start; padding: 0 0 8px; line-height: 1.4; }
@@ -2678,38 +2704,23 @@ async function searchOrders(model: QueryModel) {
.price-estimate__state.is-error { align-items: flex-start; flex-direction: column; }
.form-span-full { grid-column: auto; }
.editor-actions :deep(.el-button) { min-height: 44px; }
.prototype-order-table :deep(> .el-table), .prototype-order-table :deep(> .data-table__pagination) { display: none; }
.order-load-state { min-height: 240px; grid-template-columns: 1fr; justify-items: start; }
.supporting-data-error { grid-template-columns: auto minmax(0, 1fr); }
.supporting-data-error .el-button { grid-column: 1 / -1; width: 100%; min-height: 44px; }
.order-load-state .el-button { width: 100%; min-height: 44px; }
.mobile-order-list { grid-template-columns: 1fr; }
.mobile-order-card { padding: 14px; border: 1px solid var(--border); background: #fff; }
.mobile-order-card header, .mobile-order-card footer { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
.mobile-order-card header button { padding: 0; border: 0; background: none; color: var(--primary); font-weight: 750; }
.mobile-order-card header button:focus-visible { outline: 2px solid var(--primary); outline-offset: 3px; }
.mobile-order-card dl { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; margin: 14px 0; }
.mobile-order-card dl div { min-width: 0; }
.mobile-order-card dt, .mobile-order-card footer > span { color: var(--muted); font-size: 10px; }
.mobile-order-card dd { overflow: hidden; margin: 4px 0 0; font-size: 12px; font-weight: 600; text-overflow: ellipsis; white-space: nowrap; }
.mobile-order-card dd span { margin-left: 4px; color: var(--muted); font-weight: 400; }
.mobile-order-card footer { align-items: flex-end; padding-top: 10px; border-top: 1px solid var(--border); }
.mobile-order-card footer > div { display: flex; }
.order-detail-grid div:nth-child(odd) { border-right: 0; }
.express-information { grid-template-columns: auto 1fr; }
.express-information > div:last-child { grid-column: 2; }
.express-information { grid-template-columns: 1fr; }
.cargo-summary { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.cargo-summary div:nth-child(2) { border-right: 0; }
.cargo-summary div:nth-child(-n+2) { border-bottom: 1px solid var(--border); }
.cabinet-progress-card dl { grid-template-columns: repeat(2, minmax(0, 1fr)); }
:global(.first-leg-detail-dialog.el-dialog) { width: calc(100vw - 24px) !important; height: 96vh; margin-top: 2vh; }
}
@media (max-width: 360px) {
.cargo-detail-pagination { justify-content: flex-start; overflow-x: auto; }
}
@media (max-width: 480px) {
:global(.first-leg-detail-dialog.el-dialog) { width: 100vw !important; height: 100vh; margin: 0; border-radius: 0; }
:global(.first-leg-detail-dialog .el-dialog__body) { padding: 16px 12px 22px; }
.first-leg-detail-page { padding-bottom: 22px; }
.order-detail-heading { align-items: flex-start; flex-direction: column; }
.detail-heading-main { width: 100%; align-items: flex-start; }
.detail-heading-actions { width: 100%; justify-content: flex-end; }
.editor-actions { display: grid; width: 100%; grid-template-columns: repeat(2, minmax(0, 1fr)); }
.editor-actions :deep(.el-button) { width: 100%; min-width: 0; margin-left: 0; }
.editor-actions :deep(.el-button:nth-child(3)) { grid-column: 1 / -1; }
@@ -78,8 +78,9 @@ 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, /result\.orderNo \|\| '订单号 —'/)
assert.match(source, /result\.inboundCode \? `入仓码 \$\{result\.inboundCode\}` : '入仓码 —'/)
assert.match(source, /result\.inboundCode \|\| '入仓码提交后生成'/)
assert.match(source, /提交后将生成入仓码/)
assert.doesNotMatch(source, />\{\{ result\.orderNo/)
assert.doesNotMatch(source, /result\.orderNo \|\| `草稿 \$\{result\.draftId\}`/)
assert.doesNotMatch(source, /item\.orderNo \|\| item\.draftId/)
assert.match(source, /@click="editDraft\(result\)"/)
@@ -52,7 +52,6 @@ interface QuickDraftResult {
rowId: string
sku: string
draftId: string
orderNo: string
inboundCode: string
}
@@ -384,7 +383,6 @@ async function submitQuickOrder() {
rowId: row.id,
sku: row.products.map((product) => product.sku.trim()).join('、'),
draftId: String(item.orderId),
orderNo: item.orderNo ?? '',
inboundCode: item.inboundCode?.trim() ?? '',
}]
})
@@ -402,10 +400,7 @@ 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).filter(Boolean).join('、')
ElMessage.success(orderNumbers
? `已生成 ${successes.length} 张草稿单:${orderNumbers}`
: `已生成 ${successes.length} 张草稿单,订单号 —`)
ElMessage.success(`已生成 ${successes.length} 张草稿单,提交后将生成入仓码`)
}
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '批量草稿生成失败')
@@ -537,7 +532,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 || '订单号 —' }}</strong><span>SKU {{ result.sku }}</span><span>{{ result.inboundCode ? `入仓码 ${result.inboundCode}` : '入仓码 —' }}</span></div>
<div><strong>{{ result.inboundCode || '入仓码提交后生成' }}</strong><span>SKU {{ result.sku }}</span></div>
<el-button type="primary" plain @click="editDraft(result)">编辑明细</el-button>
</article>
</div>
@@ -9,11 +9,13 @@ const overseas = readFileSync(new URL('../../overseas-warehouse/views/WarehouseS
test('uses business identifiers and names without visible internal-ID fallbacks', () => {
assert.match(firstLeg, /label: '入仓码'/)
assert.match(firstLeg, /label: '订单号'/)
assert.doesNotMatch(firstLeg, /label: '订单号'/)
assert.match(firstLeg, /key: 'inboundCode', label: '入仓码',[^\n]*fixed: 'left'/)
assert.match(firstLeg, /label: '柜号 \/ 柜次'/)
assert.match(firstLeg, /label: '国内收货仓'/)
assert.doesNotMatch(quickOrder, /result\.orderNo \|\| `草稿 \$\{result\.draftId\}`/)
assert.doesNotMatch(quickOrder, /item\.orderNo \|\| item\.draftId/)
assert.doesNotMatch(quickOrder, />\{\{ result\.orderNo/)
assert.doesNotMatch(customs, /detail\.planNo \|\| detail\.loadingPlanId/)
assert.doesNotMatch(overseas, /overseasWarehouseName \|\| (?:item|candidate)\.overseasWarehouseId/)
assert.doesNotMatch(overseas, /row\(\[[^\]]*(?:item\.overseasWarehouseId|item\.operatingWarehouseId)/)
@@ -23,7 +25,7 @@ test('keeps internal IDs only as non-rendered routing, selection, and request va
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/)
assert.match(firstLeg, /params: \{ orderId: order\.id \}/)
})
test('keeps overseas warehouse operations first and renders readable list values', () => {
@@ -33,7 +35,7 @@ test('keeps overseas warehouse operations first and renders readable list values
assert.match(overseas, /function statusLabel\(value: string\)/)
assert.match(overseas, /function deliveryMethodLabel\(value: string\)/)
assert.match(overseas, /function formatDateTime\(value\?: string \| null\)/)
assert.match(overseas, /import SingleLineText from '@\/components\/common\/SingleLineText\.vue'/)
assert.match(overseas, /<SingleLineText :value="row\[String\(index\)\]"/)
assert.match(overseas, /<td><button class="warehouse-link"[^>]*>.*?查看<\/button><\/td><td v-for="\(_, index\) in config\.columns\.slice\(1\)"/s)
assert.match(overseas, /import DataTable, \{ type DataTableColumn \}/)
assert.match(overseas, /index === 0[\s\S]*slotName: 'actions'/)
assert.match(overseas, /<DataTable[^>]*:row-actions="listRowActions"/)
})
@@ -14,5 +14,5 @@ async function markReadIfAllowed(){if(!detail.value||detail.value.status!=='UNRE
async function load(){loading.value=true;errorMessage.value='';readErrorMessage.value='';try{detail.value=await getMyNotification(String(route.params.notificationId));await markReadIfAllowed()}catch(error){detail.value=null;errorMessage.value=error instanceof ApiError?error.message:'消息加载失败,请稍后重试'}finally{loading.value=false}}
onMounted(()=>{void load()})
</script>
<template><div class="notification-detail"><el-button link @click="router.push({ name: 'notifications-list' })"><ArrowLeft :size="16" />返回我的消息</el-button><header class="page-heading"><div><p class="eyebrow">消息详情</p><h1>{{ detail?.title || '消息详情' }}</h1></div><Bell :size="30" /></header><div v-if="errorMessage" class="state-panel state-panel--error" role="alert"><p>{{ errorMessage }}</p><el-button @click="load()"><RefreshCcw :size="15" />重试</el-button></div><article v-else v-loading="loading" class="content-card"><el-alert v-if="readErrorMessage" type="warning" :closable="false" :title="readErrorMessage" show-icon /><p class="meta">{{ notificationSentAt(detail?.sentAt) }} · {{ notificationCategoryLabel(detail?.type) }}</p><p class="content">{{ detail?.content || '暂无消息内容' }}</p></article></div></template>
<style scoped>.notification-detail{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}.eyebrow{margin:0;color:var(--primary);font-size:11px;font-weight:700}.content-card{min-height:240px;padding:24px;border:1px solid var(--border);border-radius:8px;background:var(--surface,#fff)}.meta{color:var(--muted);font-size:12px}.content{white-space:pre-wrap;line-height:1.8;color:var(--navy)}.state-panel{display:flex;min-height:180px;align-items:center;justify-content:center;flex-direction:column;gap:12px}.state-panel--error{color:#b42318;background:#fff8f7;border:1px solid #f5c2bd;border-radius:6px}@media(max-width:720px){.content-card{padding:16px}.page-heading h1{font-size:21px}}</style>
<template><article v-loading="loading" class="notification-detail"><header class="notification-detail-header"><button type="button" class="notification-detail-back" @click="router.push({ name: 'notifications-list' })"><ArrowLeft :size="16" />返回列表</button><div><strong>{{ detail?.title || '消息详情' }}</strong><span>{{ notificationSentAt(detail?.sentAt) }} · {{ notificationCategoryLabel(detail?.type) }}</span></div><Bell :size="22" /></header><div v-if="errorMessage" class="state-panel state-panel--error" role="alert"><p>{{ errorMessage }}</p><el-button @click="load()"><RefreshCcw :size="15" />重试</el-button></div><section v-else class="notification-content"><el-alert v-if="readErrorMessage" type="warning" :closable="false" :title="readErrorMessage" show-icon /><p>{{ detail?.content || '暂无消息内容' }}</p></section></article></template>
<style scoped>.notification-detail{overflow:hidden;min-height:240px;border:1px solid var(--client-border);border-radius:var(--client-radius);background:#fff}.notification-detail-header{display:grid;grid-template-columns:auto minmax(0,1fr) auto;align-items:center;gap:14px;min-height:64px;padding:10px 16px;border-bottom:1px solid var(--client-border)}.notification-detail-back{display:inline-flex;align-items:center;gap:5px;padding:5px 0;border:0;background:transparent;color:var(--el-color-primary);cursor:pointer}.notification-detail-header>div{display:flex;min-width:0;flex-direction:column;gap:3px}.notification-detail-header strong{overflow:hidden;color:#1f2d45;font-size:18px;text-overflow:ellipsis;white-space:nowrap}.notification-detail-header span{color:var(--client-muted);font-size:12px}.notification-content{padding:16px}.notification-content p{margin:0;white-space:pre-wrap;color:var(--client-text);font-size:13px;line-height:1.75}.state-panel{display:flex;min-height:180px;align-items:center;justify-content:center;flex-direction:column;gap:12px}.state-panel--error{color:#b42318;background:#fff8f7}@media(max-width:720px){.notification-detail-header{grid-template-columns:1fr auto}.notification-detail-back{grid-column:1/-1}.notification-content{padding:14px}}</style>
@@ -7,6 +7,7 @@ const apiSource = readFileSync(new URL('./api/deliveryOrders.ts', import.meta.ur
const routesSource = readFileSync(new URL('./routes.ts', import.meta.url), 'utf8')
const listSource = readFileSync(new URL('./views/DeliveryOrdersView.vue', import.meta.url), 'utf8')
const detailSource = readFileSync(new URL('./views/DeliveryOrderDetailView.vue', import.meta.url), 'utf8')
const deliveryStyles = readFileSync(new URL('./views/deliveryOrders.css', import.meta.url), 'utf8')
const createSource = readFileSync(new URL('./views/DeliveryOrderCreateView.vue', import.meta.url), 'utf8')
const permissionSource = readFileSync(new URL('../tenant-management/contracts.ts', import.meta.url), 'utf8')
@@ -38,19 +39,18 @@ test('keeps list, read-only detail and create permissions independent in active
})
test('renders the operation column first and covers loading, empty, error and pagination states', () => {
const operationIndex = listSource.indexOf('label="操作"')
const deliveryNumberIndex = listSource.indexOf('label="派送单号"')
const operationIndex = listSource.indexOf("label: '操作'")
const deliveryNumberIndex = listSource.indexOf("label: '派送单号'")
assert.ok(operationIndex >= 0 && operationIndex < deliveryNumberIndex)
assert.match(listSource, /v-loading="loading"/)
assert.match(listSource, /:loading="loading"/)
assert.match(listSource, /empty-text="暂无派送单"/)
assert.match(listSource, /delivery-state--error/)
assert.match(listSource, /@click="loadOrders">重试/)
assert.match(listSource, /listDeliveryOrders\(buildQuery\(\)\)/)
assert.match(listSource, /<el-pagination/)
assert.match(listSource, /v-model:current-page="page"/)
assert.match(listSource, /<DataTable/)
assert.match(listSource, /v-model:page="page"/)
assert.match(listSource, /v-model:page-size="pageSize"/)
assert.match(listSource, /@current-change="changePage"/)
assert.match(listSource, /@size-change="changePageSize"/)
assert.match(listSource, /watch\(\[page, pageSize\]/)
assert.match(apiSource, /\.then\(normalizeDeliveryPage\)/)
assert.match(apiSource, /total: Number\(data\.total\)/)
assert.doesNotMatch(listSource, /row\.id[^a-zA-Z]|deliveryNo \|\| row\.id|customerId|tenantId/)
@@ -66,10 +66,26 @@ test('loads a typed detail route by delivery number and never exposes an interna
assert.match(detailSource, /defineProps<\{ deliveryNo: string \}>/)
assert.match(detailSource, /getDeliveryOrder\(props\.deliveryNo\)/)
assert.match(detailSource, /派送单详情加载失败/)
for (const heading of ['订单信息', '运输与预约', '货物明细', '费用明细']) assert.match(detailSource, new RegExp(heading))
for (const heading of ['订单概览', '运输与预约', '货物明细', '费用明细']) assert.match(detailSource, new RegExp(heading))
assert.doesNotMatch(detailSource, /detail\?\.id|deliveryNo \|\| detail\?\.id|customerId|tenantId/)
})
test('renders a compact customer-readable delivery detail surface', () => {
assert.match(detailSource, /<article class="delivery-detail-surface">/)
assert.match(detailSource, /<el-button link type="primary" @click="backToList"><ArrowLeft[^>]*\/>返回列表<\/el-button>/)
assert.match(detailSource, /class="client-status" :data-tone="statusTone\(detail\.status\)"/)
assert.match(detailSource, /TRUCK_PALLET: '卡派打托'/)
assert.match(detailSource, /WAREHOUSE_PICKUP: '仓库自提'/)
assert.match(detailSource, /PALLET: '托盘'/)
assert.match(detailSource, /TRUCK: '卡车'/)
assert.match(detailSource, /SELF: '自有账号'/)
assert.match(detailSource, /formatter: tableValueFormatter/)
assert.match(detailSource, /<DataTable :data="items" :columns="itemColumns"/)
assert.match(detailSource, /<DataTable :data="detail\.fees" :columns="feeColumns"/)
assert.doesNotMatch(detailSource, /<h2[^>]*>派送单详情<\/h2>|display\(detail\.status\)/)
assert.match(deliveryStyles, /delivery-detail-surface \.delivery-detail-grid div\{display:grid;min-height:42px;/)
})
test('submits the complete create contract with stable retry idempotency and recoverable failures', () => {
for (const model of ['dispatchNo', 'plannedDeliveryAt', 'deliveryMethod', 'pickupMethod', 'loadingType', 'waybillNo', 'carrierName', 'vehicleType', 'vehicleSize', 'driverName', 'driverPhone', 'vehiclePlate', 'appointmentAccount', 'sellerName', 'remark']) {
assert.match(createSource, new RegExp(`v-model="form\\.${model}"`))
@@ -10,6 +10,7 @@ const overseasHttp = readFileSync(new URL('../../api/overseasHttp.ts', import.me
test('activates the customer-scoped overseas inbound list and detail', () => {
assert.match(routes, /path: 'orders\/inbound'[\s\S]*?release: 'active'[\s\S]*?permission: 'merchant:warehouse:inbound:list'/)
assert.match(routes, /path: 'orders\/inbound\/:planNo\/detail'[\s\S]*?name: 'orders-inbound-detail'/)
assert.match(api, /\/merchant\/v1\/last-leg\/inbound-plans/)
assert.match(api, /encodeURIComponent\(planNo\)/)
assert.doesNotMatch(api, /\/platform\/|\/internal\/|customerId|tenantId/)
@@ -17,12 +18,12 @@ test('activates the customer-scoped overseas inbound list and detail', () => {
})
test('keeps actions first and exposes filters loading errors empty state pagination and business identifiers', () => {
assert.match(view, /<el-table-column label="操作"[^>]*fixed="left"/)
assert.match(view, /label: '操作', slotName: 'actions'[^\n]*fixed: 'left'/)
for (const token of ['planNo', 'inboundCode', 'destinationWarehouseCode', 'appointmentStatus', 'dispatchStatus', 'createdDates']) assert.match(view, new RegExp(token))
assert.match(view, /v-loading="loading"/)
assert.match(view, /type="error"/)
assert.match(view, /empty-text="暂无入库订单"/)
assert.match(view, /<el-pagination/)
assert.match(view, /<DataTable[^>]*v-model:page="query\.page"/)
assert.match(view, /detail\.planNo/)
assert.doesNotMatch(view, /planId|orderId|warehouseId|customerId/)
})
@@ -31,8 +32,8 @@ test('shows only the server-provided overseas receiving warehouse code and name'
assert.equal((api.match(/overseasWarehouseCode\?: string \| null/g) || []).length, 2)
assert.equal((api.match(/overseasWarehouseName\?: string \| null/g) || []).length, 2)
assert.doesNotMatch(api, /overseasWarehouseId/)
assert.match(view, /label="海外接柜仓"/)
assert.match(view, /overseasWarehouseLabel\(scope\.row\.overseasWarehouseCode, scope\.row\.overseasWarehouseName\)/)
assert.match(view, /label: '海外接柜仓'/)
assert.match(view, /overseasWarehouseLabel\(row\.overseasWarehouseCode, row\.overseasWarehouseName\)/)
assert.match(view, /overseasWarehouseLabel\(detail\.overseasWarehouseCode, detail\.overseasWarehouseName\)/)
assert.doesNotMatch(view, /warehouseName \|\| warehouseCode|destinationWarehouse(?:Id|Code|Name).*overseasWarehouse/i)
})
@@ -55,20 +56,33 @@ test('preserves overseas API diagnostic metadata without rendering it as a busin
test('renders inbound workflow statuses in Chinese and keeps desktop values on one line', () => {
for (const [status, label] of [
['PENDING_ASSIGNMENT', '待分配'], ['PDA_PENDING', '待收货'], ['ASSIGNED', '已分配'],
['PENDING_ASSIGNMENT', '待分配'], ['PDA_PENDING', '待入库'], ['ASSIGNED', '已分配'],
['SIGNED', '已签收'], ['PICKING', '拣货中'], ['PARTIAL_INBOUND', '部分入库'],
]) assert.match(view, new RegExp(`${status}: '${label}'`))
assert.match(view, /label="状态" width="120" show-overflow-tooltip/)
assert.match(view, /\.detail-grid div \{ min-width: 0;/)
assert.match(view, /COMPLETED: '全部入库'/)
assert.match(view, /label: '状态', slotName: 'status', width: 120/)
assert.match(view, /\.detail-grid div \{ display: grid; min-width: 0; min-height: 42px;/)
assert.match(view, /\.detail-grid dd \{[^}]*overflow: hidden;[^}]*text-overflow: ellipsis;[^}]*white-space: nowrap;/)
})
test('uses the shared compact detail surface instead of stacked cards', () => {
assert.match(view, /<article class="inbound-detail-surface">/)
assert.match(view, /name: 'orders-inbound-detail', params: \{ planNo: row\.planNo \}/)
assert.match(view, /<el-button link type="primary" @click="returnToList"><ArrowLeft[^>]*\/>返回列表<\/el-button>/)
assert.match(view, /class="client-status" :data-tone="statusTone\(detail\.status\)"/)
assert.match(view, /<DataTable :data="detail\.lines" :columns="detailLineColumns"/)
assert.match(view, /<DataTable :data="detail\.pallets" :columns="detailPalletColumns"/)
assert.doesNotMatch(view, /<el-card shadow="never"/)
assert.match(view, /\.inbound-detail-header \{[^}]*min-height: 64px;[^}]*padding: 8px 16px;/)
assert.match(view, /\.detail-grid \{[^}]*grid-template-columns: repeat\(4, minmax\(0, 1fr\)\)/)
assert.match(view, /detailError \|\| '入库订单详情加载失败'/)
})
test('uses the formal inbound status selector and presents destination fields without mixing receiving warehouses', () => {
assert.match(view, /const inboundStatusOptions = \[/)
assert.match(view, /label="作业状态"><el-select v-model="query\.status"/)
assert.doesNotMatch(view, /label="作业状态"><el-input/)
assert.match(view, /destinationWarehouseLabel\(scope\.row\.fbaWarehouseCode, scope\.row\.destinationWarehouseCode, scope\.row\.destinationWarehouseName\)/)
assert.match(view, /destinationWarehouseLabel\(scope\.row\.destinationWarehouseCode, scope\.row\.destinationWarehouseName\)/)
assert.match(view, /key: 'status', label: '作业状态', type: 'select', options: inboundStatusOptions/)
assert.match(view, /destinationWarehouseLabel\(row\.fbaWarehouseCode, row\.destinationWarehouseCode, row\.destinationWarehouseName\)/)
assert.match(view, /destinationWarehouseLabel\(row\.destinationWarehouseCode, row\.destinationWarehouseName\)/)
assert.doesNotMatch(view, /label="目的仓代码"|label="目的仓名称"/)
assert.doesNotMatch(view, /destinationWarehouseLabel\([^)]*overseasWarehouse/)
})
+10 -2
View File
@@ -21,6 +21,14 @@ export const orderManagementModule = {
meta: { title: '入库订单列表', description: '查询海外仓入库订单', permission: 'merchant:warehouse:inbound:list' },
navigation: { key: 'orders-inbound', icon: Package },
},
{
path: 'orders/inbound/:planNo/detail',
name: 'orders-inbound-detail',
component: () => import('./views/InboundOrdersView.vue'),
props: { detailPage: true },
release: 'active',
meta: { title: '入库订单详情', workspaceTitle: '入库订单 · 详情', description: '查看海外仓入库订单与作业明细', permission: 'merchant:warehouse:inbound:list', keepAlive: false },
},
{
path: 'orders/delivery',
name: 'orders-delivery',
@@ -34,7 +42,7 @@ export const orderManagementModule = {
name: 'orders-delivery-create',
component: () => import('./views/DeliveryOrderCreateView.vue'),
release: 'active',
meta: { title: '创建派送单', description: '从排车单创建海外仓派送单', permission: 'merchant:warehouse:delivery:create', keepAlive: false },
meta: { title: '创建派送单', workspaceTitle: '派送订单 · 新建', description: '从排车单创建海外仓派送单', permission: 'merchant:warehouse:delivery:create', keepAlive: false },
},
{
path: 'orders/delivery/:deliveryNo',
@@ -42,7 +50,7 @@ export const orderManagementModule = {
component: () => import('./views/DeliveryOrderDetailView.vue'),
props: true,
release: 'active',
meta: { title: '派送单详情', description: '查看本企业海外仓派送单', permission: 'merchant:warehouse:delivery:list', keepAlive: false },
meta: { title: '派送单详情', workspaceTitle: '派送订单 · 详情', description: '查看本企业海外仓派送单', permission: 'merchant:warehouse:delivery:list', keepAlive: false },
},
],
} satisfies ClientModuleManifest
@@ -4,12 +4,31 @@ import { useRouter } from 'vue-router'
import { ArrowLeft } from '@lucide/vue'
import dayjs from 'dayjs'
import { getDeliveryOrder, type DeliveryOrderDetail, type DeliveryOrderItem } from '../api/deliveryOrders'
import DataTable, { type DataTableColumn } from '@/components/common/DataTable.vue'
const props = defineProps<{ deliveryNo: string }>()
const router = useRouter()
const loading = ref(false)
const errorMessage = ref('')
const detail = ref<DeliveryOrderDetail | null>(null)
const statusLabels: Record<string, string> = { PENDING: '待派送', IN_TRANSIT: '派送中', DELIVERED: '已送达', SIGNED: '已签收', CANCELLED: '已取消' }
const deliveryMethodLabels: Record<string, string> = { FBA_PICKUP: 'FBA 自提', CUSTOMER_PICKUP: '客户自提', TRUCK_PALLET: '卡派打托', TRUCK_FLOOR: '卡派地板', EXPRESS: '快递', OUTSOURCED: '外配', DEDICATED_TRUCK: '专车直送', LTL: '零担派送' }
const pickupMethodLabels: Record<string, string> = { ONE_PICKUP_ONE_DROP: '一提一卸', ONE_PICKUP_MULTI_DROP: '一提多卸', MULTI_PICKUP_ONE_DROP: '多提一卸', MULTI_PICKUP_MULTI_DROP: '多提多卸', WAREHOUSE_PICKUP: '仓库自提' }
const loadingTypeLabels: Record<string, string> = { PALLET: '托盘', FLOOR: '地板', MIXED: '混装' }
const vehicleTypeLabels: Record<string, string> = { TRUCK: '卡车', VAN: '厢式货车', BOX_TRUCK: '箱式货车', TRAILER: '拖挂车' }
const appointmentAccountLabels: Record<string, string> = { SELF: '自有账号', PLATFORM: '平台账号', CARRIER: '承运商账号' }
const tableValueFormatter = (_row: object, _column: unknown, value: string | number | null | undefined) => display(value)
const itemColumns: DataTableColumn[] = [
{ prop: 'palletNo', label: '托盘码', minWidth: 170, formatter: tableValueFormatter }, { prop: 'inboundCode', label: '入仓码', minWidth: 190, formatter: tableValueFormatter },
{ prop: 'skuCode', label: 'SKU', minWidth: 160, formatter: tableValueFormatter }, { prop: 'productName', label: '品名', minWidth: 180, formatter: tableValueFormatter },
{ prop: 'packageCount', label: '箱数', width: 90, align: 'right', headerAlign: 'right', formatter: tableValueFormatter },
{ prop: 'weightKg', label: '重量(kg)', width: 120, align: 'right', headerAlign: 'right', formatter: tableValueFormatter },
{ prop: 'volumeCbm', label: '体积(m³)', width: 120, align: 'right', headerAlign: 'right', formatter: tableValueFormatter },
]
const feeColumns: DataTableColumn[] = [
{ prop: 'feeType', label: '费用类型', minWidth: 180, formatter: tableValueFormatter }, { prop: 'currency', label: '币种', width: 100, formatter: tableValueFormatter },
{ prop: 'amount', label: '金额', width: 140, align: 'right', headerAlign: 'right', formatter: tableValueFormatter }, { prop: 'remark', label: '备注', minWidth: 240, formatter: tableValueFormatter },
]
const items = computed<DeliveryOrderItem[]>(() => {
const value = detail.value?.items
@@ -29,6 +48,17 @@ function formatQuantity(value: number | null | undefined, unit: string) {
return value === null || value === undefined ? '—' : `${value} ${unit}`
}
function statusLabel(value: string) { return statusLabels[value] ?? display(value) }
function statusTone(value: string) {
if (value === 'DELIVERED' || value === 'SIGNED') return 'success'
if (value === 'IN_TRANSIT') return 'primary'
if (value === 'CANCELLED') return 'danger'
return 'warning'
}
function mappedLabel(value: string | null | undefined, labels: Record<string, string>) {
return value ? labels[value] ?? value : '—'
}
async function loadDetail() {
loading.value = true
errorMessage.value = ''
@@ -50,24 +80,28 @@ onMounted(loadDetail)
</script>
<template>
<section class="delivery-page" aria-labelledby="delivery-detail-title">
<button type="button" class="delivery-back" @click="backToList"><ArrowLeft :size="16" />返回派送单列表</button>
<div class="delivery-heading"><div><h2 id="delivery-detail-title">派送单详情</h2><p>{{ display(detail?.deliveryNo || deliveryNo) }}</p></div></div>
<section class="delivery-page" aria-label="派送单详情">
<div v-if="errorMessage" class="delivery-card delivery-state delivery-state--error" role="alert"><p>{{ errorMessage }}</p><el-button @click="loadDetail">重试</el-button></div>
<div v-else v-loading="loading" class="delivery-detail-content">
<template v-if="detail">
<section class="delivery-card delivery-section">
<h3>订单信息</h3>
<article class="delivery-detail-surface">
<header class="delivery-detail-header">
<div class="delivery-detail-title">
<el-button link type="primary" @click="backToList"><ArrowLeft :size="16" />返回列表</el-button>
<div><h2>{{ display(detail.deliveryNo) }}</h2><p>{{ display(detail.overseasWarehouseName) }} · {{ mappedLabel(detail.deliveryMethod, deliveryMethodLabels) }}</p></div>
</div>
<span class="client-status" :data-tone="statusTone(detail.status)">{{ statusLabel(detail.status) }}</span>
</header>
<section class="delivery-detail-section">
<h3>订单概览</h3>
<div class="delivery-detail-grid">
<div><span>派送单号</span><strong>{{ display(detail.deliveryNo) }}</strong></div>
<div><span>排车单号</span><strong>{{ display(detail.dispatchNo) }}</strong></div>
<div><span>出库单号</span><strong>{{ display(detail.outboundNo) }}</strong></div>
<div><span>状态</span><strong>{{ display(detail.status) }}</strong></div>
<div><span>海外仓</span><strong>{{ display(detail.overseasWarehouseName) }}</strong></div>
<div><span>派送方式</span><strong>{{ display(detail.deliveryMethod) }}</strong></div>
<div><span>提货方式</span><strong>{{ display(detail.pickupMethod) }}</strong></div>
<div><span>装载类型</span><strong>{{ display(detail.loadingType) }}</strong></div>
<div><span>派送方式</span><strong>{{ mappedLabel(detail.deliveryMethod, deliveryMethodLabels) }}</strong></div>
<div><span>提货方式</span><strong>{{ mappedLabel(detail.pickupMethod, pickupMethodLabels) }}</strong></div>
<div><span>装载类型</span><strong>{{ mappedLabel(detail.loadingType, loadingTypeLabels) }}</strong></div>
<div><span>托盘数</span><strong>{{ formatQuantity(detail.palletCount, '托') }}</strong></div>
<div><span>箱数</span><strong>{{ formatQuantity(detail.packageCount, '箱') }}</strong></div>
<div><span>总重量</span><strong>{{ formatQuantity(detail.totalWeightKg, 'kg') }}</strong></div>
@@ -75,17 +109,17 @@ onMounted(loadDetail)
</div>
</section>
<section class="delivery-card delivery-section">
<section class="delivery-detail-section">
<h3>运输与预约</h3>
<div class="delivery-detail-grid">
<div><span>运单号</span><strong>{{ display(detail.waybillNo) }}</strong></div>
<div><span>承运商</span><strong>{{ display(detail.carrierName) }}</strong></div>
<div><span>车型</span><strong>{{ display(detail.vehicleType) }}</strong></div>
<div><span>车型</span><strong>{{ mappedLabel(detail.vehicleType, vehicleTypeLabels) }}</strong></div>
<div><span>车辆尺寸</span><strong>{{ display(detail.vehicleSize) }}</strong></div>
<div><span>车牌号</span><strong>{{ display(detail.vehiclePlate) }}</strong></div>
<div><span>司机姓名</span><strong>{{ display(detail.driverName) }}</strong></div>
<div><span>司机电话</span><strong>{{ display(detail.driverPhone) }}</strong></div>
<div><span>预约账号</span><strong>{{ display(detail.appointmentAccount) }}</strong></div>
<div><span>预约账号</span><strong>{{ mappedLabel(detail.appointmentAccount, appointmentAccountLabels) }}</strong></div>
<div><span>卖家名称</span><strong>{{ display(detail.sellerName) }}</strong></div>
<div><span>计划派送</span><strong>{{ formatDateTime(detail.plannedDeliveryAt) }}</strong></div>
<div><span>预计到达</span><strong>{{ formatDateTime(detail.expectedArrivalAt) }}</strong></div>
@@ -96,28 +130,16 @@ onMounted(loadDetail)
</div>
</section>
<section class="delivery-card delivery-section">
<h3>货物明细</h3>
<el-table :data="items" empty-text="暂无货物明细">
<el-table-column prop="palletNo" label="托盘码" min-width="150"><template #default="{ row }">{{ display(row.palletNo) }}</template></el-table-column>
<el-table-column prop="inboundCode" label="入仓码" min-width="150"><template #default="{ row }">{{ display(row.inboundCode) }}</template></el-table-column>
<el-table-column prop="skuCode" label="SKU" min-width="140"><template #default="{ row }">{{ display(row.skuCode) }}</template></el-table-column>
<el-table-column prop="productName" label="品名" min-width="180"><template #default="{ row }">{{ display(row.productName) }}</template></el-table-column>
<el-table-column prop="packageCount" label="箱数" width="100"><template #default="{ row }">{{ display(row.packageCount) }}</template></el-table-column>
<el-table-column prop="weightKg" label="重量(kg)" width="120"><template #default="{ row }">{{ display(row.weightKg) }}</template></el-table-column>
<el-table-column prop="volumeCbm" label="体积(m³)" width="120"><template #default="{ row }">{{ display(row.volumeCbm) }}</template></el-table-column>
</el-table>
<section class="delivery-detail-section">
<div class="delivery-detail-section-heading"><h3>货物明细</h3><span>共 {{ items.length }} 条</span></div>
<DataTable :data="items" :columns="itemColumns" empty-text="暂无货物明细" />
</section>
<section class="delivery-card delivery-section">
<h3>费用明细</h3>
<el-table :data="detail.fees || []" empty-text="暂无费用明细">
<el-table-column prop="feeType" label="费用类型" min-width="180"><template #default="{ row }">{{ display(row.feeType) }}</template></el-table-column>
<el-table-column prop="currency" label="币种" width="110"><template #default="{ row }">{{ display(row.currency) }}</template></el-table-column>
<el-table-column prop="amount" label="金额" width="140"><template #default="{ row }">{{ display(row.amount) }}</template></el-table-column>
<el-table-column prop="remark" label="备注" min-width="220"><template #default="{ row }">{{ display(row.remark) }}</template></el-table-column>
</el-table>
<section class="delivery-detail-section">
<div class="delivery-detail-section-heading"><h3>费用明细</h3><span>共 {{ detail.fees.length }} 条</span></div>
<DataTable :data="detail.fees" :columns="feeColumns" empty-text="暂无费用明细" />
</section>
</article>
</template>
</div>
</section>
@@ -1,10 +1,13 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { Plus, RefreshCw, Search } from '@lucide/vue'
import { Plus } from '@lucide/vue'
import dayjs from 'dayjs'
import { useAuthStore } from '@/modules/auth/public'
import { listDeliveryOrders, type DeliveryQuery, type DeliveryRow } from '../api/deliveryOrders'
import QueryToolbar, { type QueryField, type QueryModel } from '@/components/common/QueryToolbar.vue'
import DataTable, { type DataTableColumn } from '@/components/common/DataTable.vue'
import type { TableAction } from '@/components/common/tableActions'
const router = useRouter()
const auth = useAuthStore()
@@ -26,6 +29,23 @@ const statusOptions = [
]
const statusLabels: Record<string, string> = Object.fromEntries(statusOptions.map((item) => [item.value, item.label]))
const queryModel = computed<QueryModel>({ get: () => ({ ...filters }), set: (value) => Object.assign(filters, value) })
const queryFields: QueryField[] = [
{ key: 'documentNo', label: '派送单 / 出库单', type: 'input', placeholder: '请输入派送单号或出库单号' },
{ key: 'dispatchNo', label: '排车单号', type: 'input', placeholder: '请输入排车单号' },
{ key: 'waybillNo', label: '运单号', type: 'input', placeholder: '请输入运单号' },
{ key: 'status', label: '派送状态', type: 'select', options: statusOptions },
{ key: 'deliveryMethod', label: '派送方式', type: 'select', options: [{ label: '卡车派送', value: 'TRUCK' }, { label: '快递派送', value: 'COURIER' }, { label: '客户自提', value: 'SELF_PICKUP' }] },
{ key: 'plannedRange', label: '计划时间', type: 'daterange' },
]
const tableColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', slotName: 'actions', width: 90, fixed: 'left' }, { prop: 'deliveryNo', label: '派送单号', minWidth: 180 },
{ prop: 'dispatchNo', label: '排车单号', minWidth: 170 }, { prop: 'outboundNo', label: '出库单号', minWidth: 170 },
{ key: 'status', label: '状态', slotName: 'status', width: 110 }, { prop: 'overseasWarehouseName', label: '海外仓', minWidth: 160 },
{ prop: 'deliveryMethod', label: '派送方式', width: 120 }, { prop: 'waybillNo', label: '运单号', minWidth: 150 },
{ prop: 'carrierName', label: '承运商', minWidth: 140 }, { key: 'planned', label: '计划派送', slotName: 'planned', width: 160 },
{ key: 'arrival', label: '预计到达', slotName: 'arrival', width: 160 }, { key: 'createdAt', label: '创建时间', slotName: 'createdAt', width: 160 },
]
function display(value: string | number | null | undefined) {
return value === null || value === undefined || value === '' ? '—' : String(value)
@@ -77,8 +97,8 @@ async function loadOrders() {
}
function search() {
page.value = 1
void loadOrders()
if (page.value === 1) void loadOrders()
else page.value = 1
}
function reset() {
@@ -105,46 +125,32 @@ function openDetail(row: DeliveryRow) {
function openCreate() {
void router.push({ name: 'orders-delivery-create' })
}
function rowActions(candidate: object): TableAction[] {
const row = candidate as DeliveryRow
return [{ key: 'detail', label: '详情', disabled: !row.deliveryNo, run: () => openDetail(row) }]
}
onMounted(loadOrders)
watch([page, pageSize], ([nextPage, nextSize], [previousPage, previousSize]) => {
if (nextSize !== previousSize && nextPage !== 1) { page.value = 1; return }
void loadOrders()
})
</script>
<template>
<section class="delivery-page" aria-labelledby="delivery-list-title">
<div class="delivery-heading">
<div><h2 id="delivery-list-title">派送订单</h2><p>查看本企业海外仓派送进度与签收结果。</p></div>
<el-button v-if="canCreate" type="primary" @click="openCreate"><Plus :size="16" />创建派送单</el-button>
</div>
<form class="delivery-card delivery-filter" @submit.prevent="search">
<div class="delivery-filter-grid">
<el-input v-model="filters.documentNo" clearable placeholder="派送单号 / 出库单号" aria-label="派送单号或出库单号" />
<el-input v-model="filters.dispatchNo" clearable placeholder="排车单号" aria-label="排车单号" />
<el-input v-model="filters.waybillNo" clearable placeholder="运单号" aria-label="运单号" />
<el-select v-model="filters.status" clearable placeholder="全部状态" aria-label="派送状态"><el-option v-for="option in statusOptions" :key="option.value" :label="option.label" :value="option.value" /></el-select>
<el-select v-model="filters.deliveryMethod" clearable placeholder="全部派送方式" aria-label="派送方式"><el-option label="卡车派送" value="TRUCK" /><el-option label="快递派送" value="COURIER" /><el-option label="客户自提" value="SELF_PICKUP" /></el-select>
<el-date-picker v-model="filters.plannedRange" type="datetimerange" value-format="YYYY-MM-DDTHH:mm:ss" start-placeholder="计划开始时间" end-placeholder="计划结束时间" range-separator="至" aria-label="计划派送时间" />
</div>
<div class="delivery-filter-actions"><el-button type="primary" native-type="submit"><Search :size="16" />查询</el-button><el-button @click="reset"><RefreshCw :size="16" />重置</el-button></div>
</form>
<QueryToolbar v-model="queryModel" layout="grid" ariaLabel="派送订单筛选" :fields="queryFields" :search-on-reset="false" @search="search" @reset="reset" />
<div class="delivery-card delivery-table-card">
<div v-if="errorMessage" class="delivery-state delivery-state--error" role="alert"><p>{{ errorMessage }}</p><el-button @click="loadOrders">重试</el-button></div>
<el-table v-else v-loading="loading" :data="records" empty-text="暂无派送单" table-layout="fixed">
<el-table-column label="操作" width="96" fixed="left"><template #default="{ row }"><el-button link type="primary" :disabled="!row.deliveryNo" @click="openDetail(row as DeliveryRow)">详情</el-button></template></el-table-column>
<el-table-column prop="deliveryNo" label="派送单号" min-width="180"><template #default="{ row }">{{ display(row.deliveryNo) }}</template></el-table-column>
<el-table-column prop="dispatchNo" label="排车单号" min-width="170"><template #default="{ row }">{{ display(row.dispatchNo) }}</template></el-table-column>
<el-table-column prop="outboundNo" label="出库单号" min-width="170"><template #default="{ row }">{{ display(row.outboundNo) }}</template></el-table-column>
<el-table-column prop="status" label="状态" width="110"><template #default="{ row }"><el-tag :type="statusType(row.status)" effect="light">{{ statusLabel(row.status) }}</el-tag></template></el-table-column>
<el-table-column prop="overseasWarehouseName" label="海外仓" min-width="150"><template #default="{ row }">{{ display(row.overseasWarehouseName) }}</template></el-table-column>
<el-table-column prop="deliveryMethod" label="派送方式" width="120"><template #default="{ row }">{{ display(row.deliveryMethod) }}</template></el-table-column>
<el-table-column prop="waybillNo" label="运单号" min-width="150"><template #default="{ row }">{{ display(row.waybillNo) }}</template></el-table-column>
<el-table-column prop="carrierName" label="承运商" min-width="140"><template #default="{ row }">{{ display(row.carrierName) }}</template></el-table-column>
<el-table-column prop="plannedDeliveryAt" label="计划派送" min-width="160"><template #default="{ row }">{{ formatDateTime(row.plannedDeliveryAt) }}</template></el-table-column>
<el-table-column prop="expectedArrivalAt" label="预计到达" min-width="160"><template #default="{ row }">{{ formatDateTime(row.expectedArrivalAt) }}</template></el-table-column>
<el-table-column prop="createdAt" label="创建时间" min-width="160"><template #default="{ row }">{{ formatDateTime(row.createdAt) }}</template></el-table-column>
</el-table>
<div v-if="!errorMessage && (records.length > 0 || total > 0)" class="delivery-pagination"><span>共 {{ total }} 条</span><el-pagination v-model:current-page="page" v-model:page-size="pageSize" :page-sizes="[10, 20, 50, 100]" layout="sizes, prev, pager, next" :total="total" @current-change="changePage" @size-change="changePageSize" /></div>
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="records" :columns="tableColumns" :loading="loading" :total="total" :page-sizes="[10, 20, 50, 100]" :row-actions="rowActions" row-key="deliveryNo" empty-text="暂无派送单">
<template #toolbar><strong id="delivery-list-title">派送订单</strong><span>共 {{ total }} 条</span></template>
<template #toolbar-actions><el-button v-if="canCreate" type="primary" @click="openCreate"><Plus :size="16" />创建派送单</el-button></template>
<template v-if="errorMessage" #error><div class="delivery-state delivery-state--error" role="alert"><p>{{ errorMessage }}</p><el-button @click="loadOrders">重试</el-button></div></template>
<template #status="{ row }"><el-tag :type="statusType(row.status)" effect="light">{{ statusLabel(row.status) }}</el-tag></template>
<template #planned="{ row }">{{ formatDateTime(row.plannedDeliveryAt) }}</template>
<template #arrival="{ row }">{{ formatDateTime(row.expectedArrivalAt) }}</template>
<template #createdAt="{ row }">{{ formatDateTime(row.createdAt) }}</template>
</DataTable>
</div>
</section>
</template>
@@ -1,27 +1,68 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { ArrowLeft } from '@lucide/vue'
import { useRoute, useRouter } from 'vue-router'
import { getInboundPlan, listInboundPlans, type InboundPlanDetail, type InboundPlanSummary } from '../api/inboundPlans'
import { overseasWarehouseLabel } from '../inboundPlanDisplay'
import QueryToolbar, { type QueryField, type QueryModel } from '@/components/common/QueryToolbar.vue'
import DataTable, { type DataTableColumn } from '@/components/common/DataTable.vue'
import type { TableAction } from '@/components/common/tableActions'
const props = withDefaults(defineProps<{ detailPage?: boolean }>(), { detailPage: false })
const route = useRoute()
const router = useRouter()
const detailPlanNo = computed(() => String(route.params.planNo || ''))
const loading = ref(false)
const error = ref('')
const detailError = ref('')
const rows = ref<InboundPlanSummary[]>([])
const detail = ref<InboundPlanDetail | null>(null)
const total = ref(0)
const inboundStatusOptions = [
{ label: '待分配', value: 'PENDING_ASSIGNMENT' },
{ label: '已分配', value: 'ASSIGNED' },
{ label: '待收货', value: 'PDA_PENDING' },
{ label: '待入库', value: 'PDA_PENDING' },
{ label: '已签收', value: 'SIGNED' },
{ label: '拣货中', value: 'PICKING' },
{ label: '部分入库', value: 'PARTIAL_INBOUND' },
{ label: '已完成', value: 'COMPLETED' },
{ label: '全部入库', value: 'COMPLETED' },
]
const query = reactive({
page: 1, size: 20, planNo: '', inboundCode: '', destinationWarehouseCode: '', status: '',
appointmentStatus: '', dispatchStatus: '', createdDates: [] as string[],
})
const queryModel = computed<QueryModel>({ get: () => ({ ...query }), set: (value) => Object.assign(query, value) })
const queryFields: QueryField[] = [
{ key: 'planNo', label: '计划号', type: 'input', placeholder: '请输入计划号' },
{ key: 'inboundCode', label: '入仓码', type: 'input', placeholder: '请输入入仓码' },
{ key: 'destinationWarehouseCode', label: '目的仓', type: 'input', placeholder: '请输入仓库代码' },
{ key: 'status', label: '作业状态', type: 'select', options: inboundStatusOptions },
{ key: 'appointmentStatus', label: '预约状态', type: 'select', options: [{ label: '未预约', value: 'UNAPPOINTED' }, { label: '待处理', value: 'PENDING' }, { label: '成功', value: 'SUCCESS' }, { label: '失败', value: 'FAILED' }, { label: '已取消', value: 'CANCELLED' }] },
{ key: 'dispatchStatus', label: '排车状态', type: 'select', options: [{ label: '未排车', value: 'UNSCHEDULED' }, { label: '已排车', value: 'SCHEDULED' }] },
{ key: 'createdDates', label: '创建时间', type: 'daterange' },
]
const tableColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', slotName: 'actions', width: 90, fixed: 'left' }, { prop: 'planNo', label: '计划号', minWidth: 190 },
{ prop: 'inboundCode', label: '入仓码', minWidth: 180 }, { key: 'overseasWarehouse', label: '海外接柜仓', slotName: 'overseasWarehouse', minWidth: 210 },
{ key: 'destination', label: '目的仓', slotName: 'destination', minWidth: 140 }, { prop: 'packageCount', label: '计划箱数', width: 100 },
{ prop: 'pickedPackageCount', label: '已拣箱数', width: 100 }, { prop: 'palletCount', label: '托盘数', width: 90 },
{ key: 'status', label: '状态', slotName: 'status', width: 120 }, { key: 'appointment', label: '预约状态', slotName: 'appointment', width: 110 },
{ key: 'dispatch', label: '排车状态', slotName: 'dispatch', width: 110 }, { key: 'createdAt', label: '创建时间', slotName: 'createdAt', width: 180 },
]
const detailLineColumns: DataTableColumn[] = [
{ prop: 'inboundCode', label: '入仓码', minWidth: 210 },
{ key: 'destination', label: '目的仓', slotName: 'destination', minWidth: 240 },
{ prop: 'requiredPackageCount', label: '计划箱数', width: 110, align: 'right', headerAlign: 'right' },
{ prop: 'plannedPalletCount', label: '计划托数', width: 110, align: 'right', headerAlign: 'right' },
{ prop: 'volumeCbm', label: '体积(m³)', width: 120, align: 'right', headerAlign: 'right' },
]
const detailPalletColumns: DataTableColumn[] = [
{ prop: 'palletNo', label: '托盘码', minWidth: 210 },
{ key: 'destination', label: '目的仓', slotName: 'destination', minWidth: 240 },
{ prop: 'capacityCbm', label: '容量(m³)', width: 130, align: 'right', headerAlign: 'right' },
{ prop: 'allocatedVolumeCbm', label: '已分配体积(m³)', width: 160, align: 'right', headerAlign: 'right' },
]
function text(value: unknown) { return value === null || value === undefined || value === '' ? '—' : String(value) }
function destinationWarehouseLabel(...values: unknown[]) {
@@ -33,9 +74,9 @@ function destinationWarehouseLabel(...values: unknown[]) {
function dateTime(value: unknown) { return value ? new Date(String(value)).toLocaleString('zh-CN', { hour12: false }) : '—' }
function statusLabel(value: unknown) {
return ({
PENDING: '待处理', PROCESSING: '处理中', PENDING_ASSIGNMENT: '待分配', PDA_PENDING: '待收货',
PENDING: '待处理', PROCESSING: '处理中', PENDING_ASSIGNMENT: '待分配', PDA_PENDING: '待入库',
ASSIGNED: '已分配', SIGNED: '已签收', PICKING: '拣货中', PARTIAL_INBOUND: '部分入库',
COMPLETED: '已完成', CANCELLED: '已取消', UNAPPOINTED: '未预约', SUCCESS: '预约成功',
COMPLETED: '全部入库', CANCELLED: '已取消', UNAPPOINTED: '未预约', SUCCESS: '预约成功',
FAILED: '预约失败', SCHEDULED: '已排车', UNSCHEDULED: '未排车',
} as Record<string, string>)[String(value)] || text(value)
}
@@ -63,97 +104,116 @@ async function load() {
function resetQuery() {
Object.assign(query, { page: 1, planNo: '', inboundCode: '', destinationWarehouseCode: '', status: '', appointmentStatus: '', dispatchStatus: '', createdDates: [] })
void load()
search()
}
function statusTone(value: unknown) {
if (value === 'COMPLETED') return 'success'
if (value === 'PARTIAL_INBOUND' || value === 'PICKING' || value === 'SIGNED') return 'primary'
if (value === 'CANCELLED' || value === 'FAILED') return 'danger'
return 'warning'
}
function search() { if (query.page === 1) void load(); else query.page = 1 }
function rowActions(candidate: object): TableAction[] { return [{ key: 'detail', label: '详情', run: () => openDetail(candidate) }] }
async function openDetail(candidate: unknown) {
function openDetail(candidate: unknown) {
const row = candidate as InboundPlanSummary
void router.push({ name: 'orders-inbound-detail', params: { planNo: row.planNo } })
}
async function loadDetail() {
if (!detailPlanNo.value) return
loading.value = true
try { detail.value = await getInboundPlan(row.planNo) }
catch (cause) { ElMessage.error(errorMessage(cause)) }
detailError.value = ''
try { detail.value = await getInboundPlan(detailPlanNo.value) }
catch (cause) { detail.value = null; detailError.value = errorMessage(cause); ElMessage.error(detailError.value) }
finally { loading.value = false }
}
function returnToList() { void router.push({ name: 'orders-inbound' }) }
onMounted(() => void load())
onMounted(() => { if (props.detailPage) void loadDetail(); else void load() })
watch(() => [query.page, query.size] as const, ([nextPage, nextSize], [previousPage, previousSize]) => {
if (nextSize !== previousSize && nextPage !== 1) { query.page = 1; return }
if (!props.detailPage) void load()
})
</script>
<template>
<section v-loading="loading" class="inbound-orders" :aria-busy="loading">
<header class="page-head">
<div><h2>{{ detail ? '入库订单详情' : '入库订单列表' }}</h2><p>{{ detail ? `计划号:${detail.planNo}` : '查询当前企业在海外仓的入库计划与作业进度' }}</p></div>
<el-button v-if="detail" @click="detail = null">返回列表</el-button>
</header>
<template v-if="!detail">
<template v-if="!props.detailPage">
<el-alert v-if="error" :title="error" type="error" show-icon :closable="false"><template #default><el-button size="small" @click="load">重新加载</el-button></template></el-alert>
<el-card shadow="never" class="filter-card">
<el-form inline @submit.prevent="query.page = 1; load()">
<el-form-item label="计划号"><el-input v-model.trim="query.planNo" clearable placeholder="请输入计划号" /></el-form-item>
<el-form-item label="入仓码"><el-input v-model.trim="query.inboundCode" clearable placeholder="请输入入仓码" /></el-form-item>
<el-form-item label="目的仓"><el-input v-model.trim="query.destinationWarehouseCode" clearable placeholder="请输入仓库代码" /></el-form-item>
<el-form-item label="作业状态"><el-select v-model="query.status" clearable placeholder="全部"><el-option v-for="option in inboundStatusOptions" :key="option.value" :label="option.label" :value="option.value" /></el-select></el-form-item>
<el-form-item label="预约状态"><el-select v-model="query.appointmentStatus" clearable placeholder="全部"><el-option label="未预约" value="UNAPPOINTED" /><el-option label="待处理" value="PENDING" /><el-option label="成功" value="SUCCESS" /><el-option label="失败" value="FAILED" /><el-option label="已取消" value="CANCELLED" /></el-select></el-form-item>
<el-form-item label="排车状态"><el-select v-model="query.dispatchStatus" clearable placeholder="全部"><el-option label="未排车" value="UNSCHEDULED" /><el-option label="已排车" value="SCHEDULED" /></el-select></el-form-item>
<el-form-item label="创建时间"><el-date-picker v-model="query.createdDates" type="daterange" value-format="YYYY-MM-DD" range-separator="至" start-placeholder="开始日期" end-placeholder="结束日期" /></el-form-item>
<el-form-item><el-button type="primary" native-type="submit">查询</el-button><el-button @click="resetQuery">重置</el-button></el-form-item>
</el-form>
</el-card>
<el-card shadow="never">
<el-table :data="rows" border empty-text="暂无入库订单">
<el-table-column label="操作" width="90" fixed="left"><template #default="scope"><el-button link type="primary" @click="openDetail(scope.row)">查看</el-button></template></el-table-column>
<el-table-column prop="planNo" label="计划号" min-width="180" show-overflow-tooltip />
<el-table-column prop="inboundCode" label="入仓码" min-width="170" show-overflow-tooltip />
<el-table-column label="海外接柜仓" min-width="220" show-overflow-tooltip><template #default="scope">{{ overseasWarehouseLabel(scope.row.overseasWarehouseCode, scope.row.overseasWarehouseName) }}</template></el-table-column>
<el-table-column label="目的仓" min-width="150" show-overflow-tooltip><template #default="scope">{{ text(scope.row.fbaWarehouseCode) }}</template></el-table-column>
<el-table-column prop="packageCount" label="计划箱数" width="100" />
<el-table-column prop="pickedPackageCount" label="已拣箱数" width="100" />
<el-table-column prop="palletCount" label="托盘数" width="90" />
<el-table-column label="状态" width="120" show-overflow-tooltip><template #default="scope">{{ statusLabel(scope.row.status) }}</template></el-table-column>
<el-table-column label="预约状态" width="120" show-overflow-tooltip><template #default="scope">{{ statusLabel(scope.row.appointmentStatus) }}</template></el-table-column>
<el-table-column label="排车状态" width="110" show-overflow-tooltip><template #default="scope">{{ statusLabel(scope.row.dispatchStatus) }}</template></el-table-column>
<el-table-column label="创建时间" width="180" show-overflow-tooltip><template #default="scope">{{ dateTime(scope.row.createdAt) }}</template></el-table-column>
</el-table>
<el-pagination v-model:current-page="query.page" v-model:page-size="query.size" class="pagination" :total="total" :page-sizes="[10, 20, 50, 100]" layout="total, sizes, prev, pager, next" @change="load" />
</el-card>
<QueryToolbar v-model="queryModel" layout="grid" ariaLabel="入库订单筛选" :fields="queryFields" :search-on-reset="false" @search="search" @reset="resetQuery" />
<div class="inbound-table-card">
<DataTable v-model:page="query.page" v-model:page-size="query.size" :data="rows" :columns="tableColumns" :loading="loading" :total="total" :page-sizes="[10, 20, 50, 100]" :row-actions="rowActions" row-key="planNo" empty-text="暂无入库订单">
<template #toolbar><strong>入库订单</strong><span>共 {{ total }} 条</span></template>
<template #overseasWarehouse="{ row }">{{ overseasWarehouseLabel(row.overseasWarehouseCode, row.overseasWarehouseName) }}</template>
<template #destination="{ row }">{{ text(row.fbaWarehouseCode) }}</template>
<template #status="{ row }"><el-tag effect="light">{{ statusLabel(row.status) }}</el-tag></template>
<template #appointment="{ row }">{{ statusLabel(row.appointmentStatus) }}</template>
<template #dispatch="{ row }">{{ statusLabel(row.dispatchStatus) }}</template>
<template #createdAt="{ row }">{{ dateTime(row.createdAt) }}</template>
</DataTable>
</div>
</template>
<template v-else>
<el-card shadow="never">
<template v-else-if="detail">
<article class="inbound-detail-surface">
<header class="inbound-detail-header">
<div class="inbound-detail-title">
<el-button link type="primary" @click="returnToList"><ArrowLeft :size="16" />返回列表</el-button>
<div><h2>{{ detail.planNo }}</h2><p>{{ text(detail.containerNo) }} · {{ overseasWarehouseLabel(detail.overseasWarehouseCode, detail.overseasWarehouseName) }}</p></div>
</div>
<span class="client-status" :data-tone="statusTone(detail.status)">{{ statusLabel(detail.status) }}</span>
</header>
<section class="inbound-detail-section" aria-labelledby="inbound-summary-title">
<h3 id="inbound-summary-title">入库概览</h3>
<dl class="detail-grid">
<div><dt>计划号</dt><dd>{{ detail.planNo }}</dd></div><div><dt>集装箱号</dt><dd>{{ text(detail.containerNo) }}</dd></div>
<div><dt>集装箱号</dt><dd>{{ text(detail.containerNo) }}</dd></div>
<div><dt>海外接柜仓</dt><dd>{{ overseasWarehouseLabel(detail.overseasWarehouseCode, detail.overseasWarehouseName) }}</dd></div>
<div><dt>状态</dt><dd>{{ statusLabel(detail.status) }}</dd></div><div><dt>入仓码数</dt><dd>{{ detail.lineCount }}</dd></div>
<div><dt>托盘数</dt><dd>{{ detail.palletCount }}</dd></div><div><dt>总体积(m³)</dt><dd>{{ detail.totalVolumeCbm }}</dd></div>
<div><dt>计划箱数</dt><dd>{{ detail.expectedPackageCount }}</dd></div><div><dt>已拣箱数</dt><dd>{{ detail.pickedPackageCount }}</dd></div>
<div><dt>入仓码数</dt><dd>{{ detail.lineCount }}</dd></div>
<div><dt>托盘数</dt><dd>{{ detail.palletCount }}</dd></div>
<div><dt>计划箱数</dt><dd>{{ detail.expectedPackageCount }}</dd></div>
<div><dt>已拣箱数</dt><dd>{{ detail.pickedPackageCount }}</dd></div>
<div><dt>总体积</dt><dd>{{ detail.totalVolumeCbm }} m³</dd></div>
</dl>
</el-card>
<el-card shadow="never" header="入仓明细">
<el-table :data="detail.lines" border empty-text="暂无入仓明细">
<el-table-column prop="inboundCode" label="入仓码" min-width="180" /><el-table-column label="目的仓" min-width="220" show-overflow-tooltip><template #default="scope">{{ destinationWarehouseLabel(scope.row.fbaWarehouseCode, scope.row.destinationWarehouseCode, scope.row.destinationWarehouseName) }}</template></el-table-column>
<el-table-column prop="requiredPackageCount" label="计划箱数" width="100" />
<el-table-column prop="plannedPalletCount" label="计划托数" width="100" /><el-table-column prop="volumeCbm" label="体积(m³)" width="110" />
</el-table>
</el-card>
<el-card shadow="never" header="托盘明细">
<el-table :data="detail.pallets" border empty-text="暂无托盘">
<el-table-column prop="palletNo" label="托盘码" min-width="180" /><el-table-column label="目的仓" min-width="220" show-overflow-tooltip><template #default="scope">{{ destinationWarehouseLabel(scope.row.destinationWarehouseCode, scope.row.destinationWarehouseName) }}</template></el-table-column>
<el-table-column prop="capacityCbm" label="容量(m³)" width="120" /><el-table-column prop="allocatedVolumeCbm" label="已分配体积(m³)" width="140" />
</el-table>
</el-card>
</section>
<section class="inbound-detail-section" aria-labelledby="inbound-lines-title">
<div class="inbound-detail-section-heading"><h3 id="inbound-lines-title">入仓明细</h3><span>共 {{ detail.lines.length }} 条</span></div>
<DataTable :data="detail.lines" :columns="detailLineColumns" row-key="inboundCode" empty-text="暂无入仓明细">
<template #destination="{ row }">{{ destinationWarehouseLabel(row.fbaWarehouseCode, row.destinationWarehouseCode, row.destinationWarehouseName) }}</template>
</DataTable>
</section>
<section class="inbound-detail-section" aria-labelledby="inbound-pallets-title">
<div class="inbound-detail-section-heading"><h3 id="inbound-pallets-title">托盘明细</h3><span>共 {{ detail.pallets.length }} 条</span></div>
<DataTable :data="detail.pallets" :columns="detailPalletColumns" row-key="palletNo" empty-text="暂无托盘">
<template #destination="{ row }">{{ destinationWarehouseLabel(row.destinationWarehouseCode, row.destinationWarehouseName) }}</template>
</DataTable>
</section>
</article>
</template>
<div v-else class="inbound-detail-state" role="alert"><strong>{{ detailError || '入库订单详情加载失败' }}</strong><el-button @click="loadDetail">重新加载</el-button></div>
</section>
</template>
<style scoped>
.inbound-orders { display: grid; gap: 18px; }
.page-head { display: flex; align-items: center; justify-content: space-between; gap: 24px; }
.page-head h2 { margin: 0; color: #111827; font-size: 26px; }
.page-head p { margin: 7px 0 0; color: #64748b; font-size: 14px; }
.filter-card :deep(.el-form-item) { margin-bottom: 12px; }
.filter-card :deep(.el-input), .filter-card :deep(.el-select) { width: 190px; }
.pagination { justify-content: flex-end; margin-top: 18px; }
.inbound-orders { display: grid; gap: 12px; }
.inbound-table-card { overflow: hidden; border: 1px solid var(--client-border); border-radius: var(--client-radius); background: #fff; }
.inbound-detail-surface { overflow: hidden; border: 1px solid var(--client-border); border-radius: var(--client-radius); background: #fff; }
.inbound-detail-state { display:flex; min-height:220px; align-items:center; justify-content:center; gap:12px; border:1px solid var(--client-border); border-radius:var(--client-radius); background:#fff; color:#b42318; }
.inbound-detail-header { display: flex; min-height: 64px; align-items: center; justify-content: space-between; gap: 16px; padding: 8px 16px; border-bottom: 1px solid var(--client-border); }
.inbound-detail-title { display: flex; min-width: 0; align-items: center; gap: 12px; }
.inbound-detail-title > div { min-width: 0; }
.inbound-detail-title h2 { margin: 0; overflow: hidden; color: #17233a; font-size: 18px; line-height: 24px; text-overflow: ellipsis; white-space: nowrap; }
.inbound-detail-title p { margin: 2px 0 0; overflow: hidden; color: var(--client-muted); font-size: 12px; line-height: 18px; text-overflow: ellipsis; white-space: nowrap; }
.inbound-detail-section + .inbound-detail-section { border-top: 1px solid var(--client-border); }
.inbound-detail-section > h3, .inbound-detail-section-heading { min-height: 40px; margin: 0; padding: 0 16px; border-bottom: 1px solid var(--client-border); background: var(--client-header-bg); }
.inbound-detail-section > h3 { display: flex; align-items: center; color: var(--client-text); font-size: 14px; }
.inbound-detail-section-heading { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.inbound-detail-section-heading h3 { margin: 0; color: var(--client-text); font-size: 14px; }
.inbound-detail-section-heading span { color: var(--client-muted); font-size: 12px; }
.detail-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); margin: 0; }
.detail-grid div { min-width: 0; padding: 18px 20px; border-right: 1px solid #ebeef5; border-bottom: 1px solid #ebeef5; }
.detail-grid dt { margin-bottom: 7px; color: #8492a6; font-size: 13px; }
.detail-grid dd { margin: 0; overflow: hidden; color: #1f2937; text-overflow: ellipsis; white-space: nowrap; }
.detail-grid div { display: grid; min-width: 0; min-height: 42px; grid-template-columns: 88px minmax(0, 1fr); align-items: center; gap: 8px; padding: 5px 12px; border-right: 1px solid var(--client-border); border-bottom: 1px solid var(--client-border); }
.detail-grid div:nth-child(4n) { border-right: 0; }
.detail-grid dt { color: var(--client-muted); font-size: 12px; }
.detail-grid dd { margin: 0; overflow: hidden; color: var(--client-text); font-size: 13px; font-weight: 600; text-overflow: ellipsis; white-space: nowrap; }
@media (max-width: 1100px) { .detail-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } .detail-grid div:nth-child(4n) { border-right: 1px solid var(--client-border); } .detail-grid div:nth-child(2n) { border-right: 0; } }
@media (max-width: 720px) { .inbound-detail-header, .inbound-detail-title { align-items: flex-start; } .inbound-detail-header { flex-direction: column; } .inbound-detail-title { width: 100%; flex-direction: column; gap: 4px; } .detail-grid { grid-template-columns: 1fr; } .detail-grid div { border-right: 0; } }
</style>
@@ -1 +1,5 @@
.delivery-page{color:#25324a}.delivery-heading{display:flex;align-items:flex-start;justify-content:space-between;gap:20px;margin-bottom:16px}.delivery-heading h2{margin:0;color:#17233a;font-size:22px}.delivery-heading p{margin:6px 0 0;color:#7a8596;font-size:13px}.delivery-card{margin-bottom:14px;border:1px solid #e5eaf1;border-radius:6px;background:#fff;box-shadow:0 1px 3px rgba(25,42,70,.04)}.delivery-filter{padding:18px 20px}.delivery-filter-grid{display:grid;grid-template-columns:repeat(3,minmax(210px,1fr));gap:14px}.delivery-filter-actions{display:flex;justify-content:flex-end;gap:8px;margin-top:16px}.delivery-table-card{overflow:hidden}.delivery-pagination{display:flex;align-items:center;justify-content:flex-end;gap:16px;padding:14px 18px;color:#778398;font-size:12px}.delivery-state{display:flex;min-height:220px;align-items:center;justify-content:center;gap:12px;flex-direction:column;color:#7a8596}.delivery-state p{margin:0}.delivery-state--error{margin:20px;border:1px solid #f5c2bd;border-radius:6px;background:#fff8f7;color:#b42318}.delivery-section{padding:20px}.delivery-section h3{margin:0 0 18px;padding-bottom:12px;border-bottom:1px solid #edf0f4;color:#26364f;font-size:15px}.delivery-detail-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:20px 28px}.delivery-detail-grid div{display:flex;min-width:0;flex-direction:column;gap:7px}.delivery-detail-grid span{color:#8792a2;font-size:12px}.delivery-detail-grid strong{overflow-wrap:anywhere;color:#334157;font-size:13px;font-weight:550}.delivery-span-all{grid-column:1/-1}.delivery-back{display:inline-flex;align-items:center;gap:6px;margin-bottom:14px;padding:0;border:0;background:none;color:#2176d2;cursor:pointer}.delivery-form{padding:22px}.delivery-form-grid{display:grid;grid-template-columns:repeat(3,minmax(210px,1fr));gap:4px 24px}.delivery-form-grid .delivery-span-2{grid-column:span 2}.delivery-form-grid .delivery-span-3{grid-column:1/-1}.delivery-form-actions{display:flex;justify-content:center;gap:10px;padding-top:8px}.delivery-fee-heading{display:flex;align-items:center;justify-content:space-between;margin:10px 0 12px}.delivery-fee-heading h3{margin:0;font-size:15px}.delivery-fee-empty{padding:20px;color:#8792a2;text-align:center}:deep(.el-button span){display:inline-flex;align-items:center;gap:6px}:deep(.el-date-editor){width:100%}@media(max-width:1200px){.delivery-filter-grid,.delivery-form-grid{grid-template-columns:repeat(2,minmax(210px,1fr))}.delivery-detail-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.delivery-form-grid .delivery-span-3{grid-column:1/-1}}
.delivery-page{display:grid;gap:12px}.delivery-card{margin-bottom:0;border-color:var(--client-border);border-radius:var(--client-radius);box-shadow:none}
.delivery-detail-content{min-height:160px}.delivery-detail-surface{overflow:hidden;border:1px solid var(--client-border);border-radius:var(--client-radius);background:#fff}.delivery-detail-header{display:flex;min-height:64px;align-items:center;justify-content:space-between;gap:16px;padding:8px 16px;border-bottom:1px solid var(--client-border)}.delivery-detail-title{display:flex;min-width:0;align-items:center;gap:12px}.delivery-detail-title>div{min-width:0}.delivery-detail-title h2{margin:0;overflow:hidden;color:#17233a;font-size:18px;line-height:24px;text-overflow:ellipsis;white-space:nowrap}.delivery-detail-title p{margin:2px 0 0;overflow:hidden;color:var(--client-muted);font-size:12px;line-height:18px;text-overflow:ellipsis;white-space:nowrap}.delivery-detail-section+.delivery-detail-section{border-top:1px solid var(--client-border)}.delivery-detail-section>h3,.delivery-detail-section-heading{min-height:40px;margin:0;padding:0 16px;border-bottom:1px solid var(--client-border);background:var(--client-header-bg)}.delivery-detail-section>h3{display:flex;align-items:center;color:var(--client-text);font-size:14px}.delivery-detail-section-heading{display:flex;align-items:center;justify-content:space-between;gap:12px}.delivery-detail-section-heading h3{margin:0;color:var(--client-text);font-size:14px}.delivery-detail-section-heading span{color:var(--client-muted);font-size:12px}.delivery-detail-surface .delivery-detail-grid{grid-template-columns:repeat(4,minmax(0,1fr));gap:0}.delivery-detail-surface .delivery-detail-grid div{display:grid;min-height:42px;grid-template-columns:88px minmax(0,1fr);align-items:center;gap:8px;padding:5px 12px;border-right:1px solid var(--client-border);border-bottom:1px solid var(--client-border)}.delivery-detail-surface .delivery-detail-grid div:nth-child(4n){border-right:0}.delivery-detail-surface .delivery-detail-grid span{color:var(--client-muted);font-size:12px}.delivery-detail-surface .delivery-detail-grid strong{overflow:hidden;color:var(--client-text);font-size:13px;font-weight:600;text-overflow:ellipsis;white-space:nowrap}.delivery-detail-surface .delivery-detail-grid .delivery-span-all{grid-column:1/-1;border-right:0}.delivery-detail-surface .delivery-detail-grid .delivery-span-all strong{white-space:normal}
@media(max-width:1200px){.delivery-detail-surface .delivery-detail-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.delivery-detail-surface .delivery-detail-grid div:nth-child(4n){border-right:1px solid var(--client-border)}.delivery-detail-surface .delivery-detail-grid div:nth-child(2n){border-right:0}}
@media(max-width:720px){.delivery-detail-header{align-items:flex-start;flex-direction:column}.delivery-detail-title{width:100%;align-items:flex-start;flex-direction:column;gap:4px}.delivery-detail-surface .delivery-detail-grid{grid-template-columns:1fr}.delivery-detail-surface .delivery-detail-grid div{border-right:0}}
@@ -1,11 +1,11 @@
import { overseasApiRequest } from '@/api/overseasHttp'
import { overseasApiRequest, overseasBlobRequest } from '@/api/overseasHttp'
export type RelabelDeliveryMethod = 'TRUCK' | 'EXPRESS' | 'TRUCK_PICKUP' | 'EXPRESS_PICKUP'
export interface PageResult<T> { records: T[]; page: number; size: number; total: number; pages: number }
export interface LastlegOrderSummary { id: string; orderNo: string; inboundCode: string; orderType: string; packageCount: number; overseasWarehouseId?: string }
export interface TransferSummary { id: string; transferNo: string; inboundCode: string; lastlegOrderId: string; lastlegOrderNo: string; operatingWarehouseId: string; fbaWarehouseCode?: string; packageCount: number; deliveryMethod: string; needRelabel: boolean; relabelNo?: string; status: string; estimatedArrivalAt: string; estimatedShipAt?: string; actualShipAt?: string; createdAt: string }
export interface RelabelSummary { id: string; relabelNo: string; lastlegOrderId: string; lastlegOrderNo: string; customerOrderNo: string; overseasWarehouseId: string; status: string; itemCount: number; totalPackageCount: number; notifiedPackageCount: number; skuCodes: string; createdAt: string; deliveryMethod?: RelabelDeliveryMethod; printShippingLabel?: boolean; shippingLabelFileId?: string; shippingLabelFileName?: string; shippingLabelFileUrl?: string; fulfillmentStatus?: string; outboundOrderNo?: string; customerConfirmedAt?: string }
export interface RelabelSummary { id: string; relabelNo: string; lastlegOrderId: string; lastlegOrderNo: string; customerOrderNo: string; overseasWarehouseId: string; status: string; itemCount: number; totalPackageCount: number; notifiedPackageCount: number; skuCodes: string; createdAt: string; totalFileId?: string; totalFileName?: string; totalFileUrl?: string; cartonTotalFileId?: string; cartonTotalFileName?: string; cartonTotalFileUrl?: string; skuTotalFileId?: string; skuTotalFileName?: string; skuTotalFileUrl?: string; deliveryMethod?: RelabelDeliveryMethod; printShippingLabel?: boolean; shippingLabelFileId?: string; shippingLabelFileName?: string; shippingLabelFileUrl?: string; fulfillmentStatus?: string; outboundOrderNo?: string; customerConfirmedAt?: string }
export interface RelabelItemDetail {
id: string; skuCode?: string; productNameZh: string; totalPackageCount: number; notifiedPackageCount: number
shipmentNo: string; trackingCode?: string; newSkuCode?: string; labelRequirement?: string; remark?: string
@@ -31,20 +31,24 @@ export interface RelabelInventoryCandidate {
destinationFbaWarehouseCode?: string; destinationFbaWarehouseName?: string; skuCode?: string; productNameZh?: string
availablePackages: number; version: number
positions: Array<{ positionId: string; palletNo?: string; batchNo?: string; warehouseLocationCode?: string; availablePackages: number }>
sourceType?: 'INBOUND' | 'RETURN_CARTON'; returnCartonId?: string; cartonMark?: string
skuItems?: Array<{ skuCode: string; newSkuCode?: string; quantity: number; remark?: string }>
}
export interface TransferCreate {
operatingWarehouseId: string; fbaWarehouseCode?: string
operatingWarehouseId: string; fbaWarehouseCode?: string; destinationType?: 'NONE' | 'FBA' | 'PRIVATE'
destinationWarehouseId?: string; destinationWarehouseCode?: string; destinationWarehouseName?: string
privateDestinationAddress?: string; privateDestinationPostalCode?: string; privateDestinationContactName?: string; privateDestinationContactPhone?: string
deliveryAddress: string; packageCount: number; volumeCbm: number; deliveryMethod: string; estimatedArrivalAt: string; estimatedShipAt?: string
fbaShipmentNo?: string; trackingNo?: string; fbaBoxNos?: string; remark?: string; needRelabel: boolean; idempotencyKey: string
labelDeadline?: string; relabelContactName?: string; relabelContactPhone?: string; relabelChargeAmount?: number; relabelDeliveryMethod?: RelabelDeliveryMethod
items: Array<{ skuCode: string; productNameZh: string; packageCount: number; shippingMark?: string }>
relabelItems?: Array<{ operationType: 'CARTON_LABEL' | 'SKU_LABEL' | 'CARTON_AND_SKU'; customerCartonMarks: string[]; originalSku: string; newSku?: string; quantity: number; fbaCartonMark?: string; destinationWarehouseCode?: string; remark?: string }>
labelDeadline?: string; pickupContactName?: string; pickupContactPhone?: string; relabelContactName?: string; relabelContactPhone?: string; relabelChargeAmount?: number; relabelDeliveryMethod?: RelabelDeliveryMethod
items: Array<{ skuCode?: string; productNameZh?: string; packageCount: number; shippingMark?: string }>
relabelItems?: Array<{ operationType: 'CARTON_LABEL' | 'SKU_LABEL' | 'CARTON_AND_SKU'; customerCartonMarks: string[]; originalSku?: string; newSku?: string; quantity: number; fbaCartonMark?: string; destinationWarehouseCode?: string; remark?: string }>
}
export interface RelabelCreate {
customerOrderNo: string; operationWarehouseId: string; labelDeadline: string; contactName: string; contactPhone: string; chargeAmount: number
customerOrderNo: string; operationWarehouseId: string; labelDeadline: string; contactName: string; contactPhone: string; chargeAmount?: number
remark?: string; idempotencyKey: string; deliveryMethod?: RelabelDeliveryMethod; printShippingLabel?: boolean
items: Array<{ sourceInventoryBalanceId: string; sourceInventoryPositionId: string; sourceInboundOrderId: string; skuCode?: string; productNameZh: string; totalPackageCount: number; notifiedPackageCount: number; shipmentNo: string; trackingCode?: string; newSkuCode?: string; labelRequirement?: string
items: Array<{ sourceInventoryBalanceId?: string; sourceInventoryPositionId?: string; sourceInboundOrderId?: string; returnCartonId?: string; skuCode?: string; productNameZh: string; totalPackageCount: number; notifiedPackageCount: number; shipmentNo: string; trackingCode?: string; newSkuCode?: string; labelRequirement?: string
cartonMark: string; fbaCartonMark?: string; destinationFbaWarehouseCode?: string; operationType: 'CARTON_LABEL' | 'SKU_LABEL' | 'CARTON_AND_SKU'; skuItems?: Array<{ skuCode: string; newSkuCode: string; quantity: number; remark?: string }>; remark?: string }>
}
@@ -60,6 +64,12 @@ export function getTransfer(transferNo: string) {
export function createTransfer(payload: TransferCreate) {
return overseasApiRequest<Record<string, unknown>>({ method: 'POST', url: '/merchant/v1/last-leg/warehouse-transfers', data: payload })
}
export function updateTransfer(transferNo: string, payload: TransferCreate) {
return overseasApiRequest<Record<string, unknown>>({ method: 'POST', url: `/merchant/v1/last-leg/warehouse-transfers/${encodeURIComponent(transferNo)}/save-draft`, data: payload })
}
export function submitTransfer(transferNo: string) {
return overseasApiRequest<Record<string, unknown>>({ method: 'POST', url: `/merchant/v1/last-leg/warehouse-transfers/${encodeURIComponent(transferNo)}/submit` })
}
export function listRelabels(params: Record<string, unknown>) {
return overseasApiRequest<PageResult<RelabelSummary>>({ method: 'GET', url: '/merchant/v1/last-leg/relabel-applications', params })
}
@@ -73,6 +83,9 @@ export interface TransferCartonLabel { transferNo: string; cartons: Array<{ cart
export function getTransferCartonLabel(transferNo: string) {
return overseasApiRequest<TransferCartonLabel>({ method: 'GET', url: `/merchant/v1/last-leg/warehouse-transfers/${encodeURIComponent(transferNo)}/carton-label` })
}
export function downloadTransferMeasurementPhoto(transferNo: string, fileId: string | number) {
return overseasBlobRequest({ method: 'GET', url: `/merchant/v1/last-leg/warehouse-transfers/${encodeURIComponent(transferNo)}/measurement-photos/files/${encodeURIComponent(fileId)}/download` })
}
export function listRelabelInventoryCandidates(operationWarehouseId: string, inboundCode?: string) {
return overseasApiRequest<RelabelInventoryCandidate[]>({
@@ -87,11 +100,15 @@ export function uploadRelabelShippingLabel(relabelNo: string, file: File) {
const data = new FormData(); data.append('file', file)
return overseasApiRequest<RelabelDetail>({ method: 'POST', url: `/merchant/v1/last-leg/relabel-applications/${encodeURIComponent(relabelNo)}/shipping-label`, data, headers: { 'Content-Type': 'multipart/form-data' } })
}
export function uploadRelabelFile(relabelNo: string, scope: 'CARTON_TOTAL' | 'SKU_TOTAL' | 'ITEM', file: File, itemId?: string) {
const data = new FormData(); data.append('file', file)
return overseasApiRequest<RelabelDetail>({ method: 'POST', url: `/merchant/v1/last-leg/relabel-applications/${encodeURIComponent(relabelNo)}/files`, params: { scope, itemId }, data, headers: { 'Content-Type': 'multipart/form-data' } })
}
export function confirmRelabelCompletion(relabelNo: string, remark?: string) {
return overseasApiRequest<RelabelDetail>({ method: 'POST', url: `/merchant/v1/last-leg/relabel-applications/${encodeURIComponent(relabelNo)}/confirm-completion`, data: { idempotencyKey: crypto.randomUUID(), remark } })
}
export function downloadRelabelCompletionPhoto(relabelNo: string, fileId: string) {
return overseasApiRequest<Blob>({ method: 'GET', url: `/merchant/v1/last-leg/relabel-applications/${encodeURIComponent(relabelNo)}/completion-photos/${encodeURIComponent(fileId)}/download`, responseType: 'blob' })
return overseasBlobRequest({ method: 'GET', url: `/merchant/v1/last-leg/relabel-applications/${encodeURIComponent(relabelNo)}/completion-photos/${encodeURIComponent(fileId)}/download` })
}
export function listDiscards(params: Record<string, unknown>) {
return overseasApiRequest<PageResult<DiscardSummary>>({ method: 'GET', url: '/merchant/v1/last-leg/cargo-discards', params })
@@ -105,3 +122,6 @@ export function listDiscardCandidates(inboundCode?: string) {
export function createDiscard(payload: { items: Array<{ orderId: string; skuCode: string; discardPackages: number }>; idempotencyKey: string; remark?: string }) {
return overseasApiRequest<Record<string, unknown>>({ method: 'POST', url: '/merchant/v1/last-leg/cargo-discards', data: payload })
}
export function cancelDiscard(discardNo: string) {
return overseasApiRequest<Record<string, unknown>>({ method: 'POST', url: `/merchant/v1/last-leg/cargo-discards/${encodeURIComponent(discardNo)}/cancel` })
}
@@ -0,0 +1,78 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
const routes = readFileSync(new URL('./routes.ts', import.meta.url), 'utf8')
const services = readFileSync(new URL('./views/WarehouseServiceView.vue', import.meta.url), 'utf8')
const returns = readFileSync(new URL('./views/ReturnOrdersView.vue', import.meta.url), 'utf8')
const contracts = readFileSync(new URL('../tenant-management/contracts.ts', import.meta.url), 'utf8')
test('protects every overseas warehouse page with its independent list permission', () => {
for (const permission of [
'merchant:overseas-warehouse:relabel:list',
'merchant:overseas-warehouse:transfer:list',
'merchant:overseas-warehouse:discard:list',
'merchant:overseas-warehouse:return-order:list',
'merchant:overseas-warehouse:return-inventory:list',
]) assert.match(routes, new RegExp(`permission: '${permission}'`))
assert.doesNotMatch(routes, /permission: 'dashboard:view'/)
})
test('opens labeling and return relabeling on dedicated protected create pages', () => {
assert.match(routes, /path: 'overseas-warehouse\/labeling\/create'[\s\S]*?props: \{ service: 'labeling', createPage: true \}[\s\S]*?permission: 'merchant:overseas-warehouse:relabel:create'/)
assert.match(routes, /path: 'overseas-warehouse\/return-relabel\/create'[\s\S]*?props: \{ service: 'relabel', createPage: true \}[\s\S]*?permission: 'merchant:overseas-warehouse:return-order:create'/)
assert.match(services, /props\.createPage \? 'create' : 'list'/)
assert.match(services, /function returnToList\(\)/)
})
test('opens warehouse service and return receipt details as dedicated protected workspace pages', () => {
assert.match(routes, /path: 'overseas-warehouse\/labeling\/:detailNo\/detail'[\s\S]*?name: 'overseas-labeling-detail'[\s\S]*?props: \{ service: 'labeling', detailPage: true \}[\s\S]*?permission: 'merchant:overseas-warehouse:relabel:list'/)
assert.match(routes, /path: 'overseas-warehouse\/return-relabel\/:returnNo\/detail'[\s\S]*?name: 'overseas-return-relabel-detail'[\s\S]*?props: \{ detailPage: true \}[\s\S]*?permission: 'merchant:overseas-warehouse:return-order:list'/)
assert.match(routes, /path: 'overseas-warehouse\/storage-transfer\/:detailNo\/detail'[\s\S]*?name: 'overseas-storage-transfer-detail'[\s\S]*?props: \{ service: 'transfer', detailPage: true \}/)
assert.match(routes, /path: 'overseas-warehouse\/cargo-disposal\/:detailNo\/detail'[\s\S]*?name: 'overseas-cargo-disposal-detail'[\s\S]*?props: \{ service: 'disposal', detailPage: true \}/)
assert.match(services, /name: 'overseas-labeling-detail'/)
assert.match(services, /transfer: 'overseas-storage-transfer-detail'/)
assert.match(services, /disposal: 'overseas-cargo-disposal-detail'/)
assert.match(services, /class="warehouse-detail-error"/)
assert.match(returns, /name: 'overseas-return-relabel-detail'/)
assert.doesNotMatch(returns, /title="退货签收详情"/)
})
test('keeps overseas warehouse writes behind operation-specific button permissions', () => {
for (const permission of [
'merchant:overseas-warehouse:relabel:create',
'merchant:overseas-warehouse:relabel:files',
'merchant:overseas-warehouse:relabel:confirm',
'merchant:overseas-warehouse:transfer:create',
'merchant:overseas-warehouse:transfer:confirm',
'merchant:overseas-warehouse:discard:create',
'merchant:overseas-warehouse:discard:cancel',
]) assert.match(services, new RegExp(permission))
assert.match(services, /v-if="canCreate" type="primary" @click="openCreate"/)
assert.match(services, /canManageRelabelFiles && props\.service === 'labeling'/)
assert.match(services, /canConfirmTransfer && props\.service === 'transfer'/)
assert.match(services, /canCancelDiscard && props\.service === 'disposal'/)
assert.match(returns, /merchant:overseas-warehouse:return-order:create/)
assert.match(returns, /v-if="canCreate" type="primary" @click="openCreate"/)
})
test('publishes all overseas operation permissions in the enterprise role editor', () => {
for (const permission of [
'merchant:overseas-warehouse:last-mile-order:list',
'merchant:overseas-warehouse:relabel:list',
'merchant:overseas-warehouse:relabel:create',
'merchant:overseas-warehouse:relabel:files',
'merchant:overseas-warehouse:relabel:confirm',
'merchant:overseas-warehouse:transfer:list',
'merchant:overseas-warehouse:transfer:create',
'merchant:overseas-warehouse:transfer:confirm',
'merchant:overseas-warehouse:discard:list',
'merchant:overseas-warehouse:discard:create',
'merchant:overseas-warehouse:discard:cancel',
'merchant:overseas-warehouse:return-order:list',
'merchant:overseas-warehouse:return-order:create',
'merchant:overseas-warehouse:return-order:relabel',
'merchant:overseas-warehouse:return-inventory:list',
]) assert.match(contracts, new RegExp(`code: '${permission}'`))
})
+41 -4
View File
@@ -5,28 +5,60 @@ export const overseasWarehouseModule = {
key: 'overseas-warehouse',
navigationGroup: { title: '海外仓', icon: Warehouse },
routes: [
{
path: 'overseas-warehouse/labeling/create', name: 'overseas-labeling-create',
component: () => import('./views/WarehouseServiceView.vue'), props: { service: 'labeling', createPage: true }, release: 'active',
meta: { title: '新增贴标申请', workspaceTitle: '贴标申请 · 新建', description: '填写并提交海外仓贴标申请', permission: 'merchant:overseas-warehouse:relabel:create' },
},
{
path: 'overseas-warehouse/labeling/:detailNo/detail', name: 'overseas-labeling-detail',
component: () => import('./views/WarehouseServiceView.vue'),
props: { service: 'labeling', detailPage: true }, release: 'active',
meta: { title: '贴标申请详情', workspaceTitle: '贴标申请 · 详情', description: '查看贴标申请与仓库履约信息', permission: 'merchant:overseas-warehouse:relabel:list', keepAlive: false },
},
{
path: 'overseas-warehouse/labeling', name: 'overseas-labeling',
component: () => import('./views/WarehouseServiceView.vue'), props: { service: 'labeling' }, release: 'active',
meta: { title: '贴标申请', description: '查询并管理海外仓贴标申请', permission: 'dashboard:view' },
meta: { title: '贴标申请', description: '查询并管理海外仓贴标申请', permission: 'merchant:overseas-warehouse:relabel:list' },
navigation: { key: 'overseas-labeling', icon: Tags },
},
{
path: 'overseas-warehouse/return-relabel/create', name: 'overseas-return-relabel-create',
component: () => import('./views/WarehouseServiceView.vue'), props: { service: 'relabel', createPage: true }, release: 'active',
meta: { title: '新建退货换标单', workspaceTitle: '退货换标 · 新建', description: '填写并提交退货换标订单', permission: 'merchant:overseas-warehouse:return-order:create' },
},
{
path: 'overseas-warehouse/return-relabel/:returnNo/detail', name: 'overseas-return-relabel-detail',
component: () => import('./views/ReturnOrdersView.vue'),
props: { detailPage: true }, release: 'active',
meta: { title: '退货签收详情', workspaceTitle: '退货签收 · 详情', description: '查看退货预报与仓库签收结果', permission: 'merchant:overseas-warehouse:return-order:list', keepAlive: false },
},
{
path: 'overseas-warehouse/storage-transfer/:detailNo/detail', name: 'overseas-storage-transfer-detail',
component: () => import('./views/WarehouseServiceView.vue'), props: { service: 'transfer', detailPage: true }, release: 'active',
meta: { title: '仓储转运详情', workspaceTitle: '仓储转运 · 详情', description: '查看仓储转运与入库测量详情', permission: 'merchant:overseas-warehouse:transfer:list', keepAlive: false },
},
{
path: 'overseas-warehouse/storage-transfer', name: 'overseas-storage-transfer',
component: () => import('./views/WarehouseServiceView.vue'), props: { service: 'transfer' }, release: 'active',
meta: { title: '仓储转运', description: '查询并管理海外仓转运订单', permission: 'dashboard:view' },
meta: { title: '仓储转运', description: '查询并管理海外仓转运订单', permission: 'merchant:overseas-warehouse:transfer:list' },
navigation: { key: 'overseas-storage-transfer', icon: Truck },
},
{
path: 'overseas-warehouse/cargo-disposal/:detailNo/detail', name: 'overseas-cargo-disposal-detail',
component: () => import('./views/WarehouseServiceView.vue'), props: { service: 'disposal', detailPage: true }, release: 'active',
meta: { title: '货物废弃详情', workspaceTitle: '货物废弃 · 详情', description: '查看货物废弃申请详情', permission: 'merchant:overseas-warehouse:discard:list', keepAlive: false },
},
{
path: 'overseas-warehouse/cargo-disposal', name: 'overseas-cargo-disposal',
component: () => import('./views/WarehouseServiceView.vue'), props: { service: 'disposal' }, release: 'active',
meta: { title: '货物废弃', description: '查询并管理海外仓货物废弃申请', permission: 'dashboard:view' },
meta: { title: '货物废弃', description: '查询并管理海外仓货物废弃申请', permission: 'merchant:overseas-warehouse:discard:list' },
navigation: { key: 'overseas-cargo-disposal', icon: PackageX },
},
{
path: 'overseas-warehouse/return-relabel', name: 'overseas-return-relabel',
component: () => import('./views/ReturnOrdersView.vue'), release: 'active',
meta: { title: '退货换标', description: '创建退货预报并查看仓库签收结果', permission: 'dashboard:view' },
meta: { title: '退货换标', description: '创建退货预报并查看仓库签收结果', permission: 'merchant:overseas-warehouse:return-order:list' },
navigation: { key: 'overseas-return-relabel', icon: RefreshCcw },
},
{
@@ -41,5 +73,10 @@ export const overseasWarehouseModule = {
meta: { title: '退货箱号管理', description: '查询签收及合箱生成的退货箱号和箱内库存', permission: 'merchant:overseas-warehouse:return-inventory:list' },
navigation: { key: 'overseas-return-cartons', icon: Boxes },
},
{
path: 'overseas-warehouse/return-cartons/:cartonMark/detail', name: 'overseas-return-carton-detail',
component: () => import('./views/ReturnInventoryView.vue'), props: { cartonOnly: true, detailPage: true }, release: 'active',
meta: { title: '退货箱明细', workspaceTitle: '退货箱号 · 详情', description: '查看退货箱号与箱内 SKU 明细', permission: 'merchant:overseas-warehouse:return-inventory:list', keepAlive: false },
},
],
} satisfies ClientModuleManifest
@@ -4,19 +4,20 @@ import test from 'node:test'
const inventory = readFileSync(new URL('./ReturnInventoryView.vue', import.meta.url), 'utf8')
const orders = readFileSync(new URL('./ReturnOrdersView.vue', import.meta.url), 'utf8')
const routes = readFileSync(new URL('../routes.ts', import.meta.url), 'utf8')
test('desktop filters reserve six slots and inventory time and statuses have readable presentation', () => {
assert.match(inventory, /grid-template-columns: repeat\(4, minmax\(150px, 1fr\)\) 130px auto/)
test('desktop filters use the shared query grid and inventory time and statuses have readable presentation', () => {
assert.match(inventory, /<QueryToolbar[^>]*layout="grid"/)
assert.match(inventory, /label: '箱号 \/ 箱唛'/)
assert.match(inventory, /white-space: nowrap/)
assert.match(inventory, /cartonStatusLabel\(cartonDetail.status\)/)
assert.match(inventory, /formatInventoryTime\(row.createdAt\)/)
assert.match(inventory, /formatInventoryTime\(row.updatedAt\)/)
assert.match(inventory, /formatInventoryTime\(row.updatedAt \|\| row.createdAt \|\| row.receivedAt\)/)
})
test('inventory failures are visible and are not presented as empty inventory', () => {
assert.match(inventory, /catch \(error\)/)
assert.match(inventory, /<el-alert v-if="loadError"/)
assert.equal((inventory.match(/:empty-text="loadError \?/g) || []).length, 3)
assert.equal((inventory.match(/:empty-text="loadError \?/g) || []).length, 1)
assert.match(inventory, /当前账号无权访问退货库存/)
assert.match(inventory, /error\.requestId/)
assert.match(inventory, /error\.traceId/)
@@ -30,17 +31,14 @@ test('return pagination receives numeric totals from the wire format', () => {
})
test('inventory actions lead both tables and timestamps describe inventory changes', () => {
const tables = inventory.match(/<el-table\b[^>]*>[\s\S]*?<\/el-table>/g).slice(0, 3)
for (const table of tables) {
assert.match(table, /<el-table\b[^>]*>\s*<el-table-column label="操作"[^>]*fixed="left"/)
}
assert.match(inventory, /prop="updatedAt" label="库存更新时间"/)
assert.equal((inventory.match(/label: '操作', slotName: 'actions'[^\n]*fixed: 'left'/g) || []).length, 3)
assert.match(inventory, /label: '库存更新时间'/)
assert.doesNotMatch(inventory, /实收并上架|最近入库时间/)
})
test('carton management uses server carton pagination rather than deduplicating SKU pages', () => {
assert.match(inventory, /if \(props.cartonOnly\) \{\s*const page = await listReturnInventoryCartons/)
assert.match(inventory, /:data="cartonSummaryRows" row-key="cartonMark"/)
assert.match(inventory, /props.cartonOnly \? cartonSummaryRows.value/)
assert.match(inventory, /cartonSummaryRows.value = \[\]/)
})
@@ -59,3 +57,11 @@ test('warehouse selectors and displays use the catalog instead of internal IDs',
assert.match(inventory, /warehouse\.warehouseCode/)
assert.doesNotMatch(inventory, /海外仓 ID|\{\{ cartonDetail.overseasWarehouseId \}\}|prop="overseasWarehouseId"/)
})
test('opens carton details on a dedicated compact page with a visible retry state', () => {
assert.match(routes, /path: 'overseas-warehouse\/return-cartons\/:cartonMark\/detail'/)
assert.match(inventory, /name: 'overseas-return-carton-detail'/)
assert.match(inventory, /class="carton-detail-surface"/)
assert.match(inventory, /detailError \|\| '箱号详情加载失败'/)
assert.doesNotMatch(inventory, /<el-drawer/)
})
@@ -1,7 +1,11 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { Printer, Search } from '@lucide/vue'
import { ArrowLeft, Printer } from '@lucide/vue'
import { useRoute, useRouter } from 'vue-router'
import QueryToolbar, { type QueryField, type QueryModel } from '@/components/common/QueryToolbar.vue'
import DataTable, { type DataTableColumn } from '@/components/common/DataTable.vue'
import type { TableAction } from '@/components/common/tableActions'
import { ApiError } from '@/api/types'
import { getMerchantWarehouses, type MerchantWarehouse } from '@/modules/first-leg/public'
import {
@@ -17,10 +21,14 @@ import {
} from '../api/returnInventory'
const activeTab = ref<'summary' | 'cartons'>('summary')
const props = defineProps<{ cartonOnly?: boolean }>()
const props = defineProps<{ cartonOnly?: boolean; detailPage?: boolean }>()
const route = useRoute()
const router = useRouter()
const detailCartonMark = computed(() => String(route.params.cartonMark || ''))
if (props.cartonOnly) activeTab.value = 'cartons'
const loading = ref(false)
const loadError = ref('')
const detailError = ref('')
const warehouses = ref<MerchantWarehouse[]>([])
const warehouseError = ref('')
const warehousesLoading = ref(false)
@@ -50,9 +58,53 @@ const summaryRows = ref<ReturnInventorySummaryItem[]>([])
const cartonRows = ref<ReturnInventoryItem[]>([])
const cartonSummaryRows = ref<ReturnInventoryCartonSummary[]>([])
const total = ref(0)
const showCartonDetail = ref(false)
const cartonDetail = ref<ReturnInventoryCartonDetail>()
const query = reactive({ page: 1, size: 20, overseasWarehouseId: '', skuCode: '', cartonMark: '', status: 'AVAILABLE', returnNo: '' })
const queryModel = computed<QueryModel>({
get: () => ({ ...query }),
set: (value) => Object.assign(query, value),
})
const queryFields = computed<QueryField[]>(() => {
const fields: QueryField[] = [
{ key: 'overseasWarehouseId', label: '海外仓', type: 'select', options: warehouses.value.map((warehouse) => ({ label: warehouseLabel(String(warehouse.id)), value: String(warehouse.id) })) },
{ key: 'skuCode', label: 'SKU', type: 'input', placeholder: '请输入 SKU' },
]
if (activeTab.value === 'cartons') fields.push(
{ key: 'cartonMark', label: '箱号 / 箱唛', type: 'input', placeholder: '请输入箱号或箱唛' },
{ key: 'returnNo', label: '退货单号', type: 'input', placeholder: '请输入退货单号' },
{ key: 'status', label: '箱状态', type: 'select', options: [{ label: '可用', value: 'AVAILABLE' }, { label: '已合箱', value: 'MERGED' }] },
)
return fields
})
const summaryColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', slotName: 'actions', width: 110, fixed: 'left' },
{ key: 'warehouse', label: '海外仓', slotName: 'warehouse', minWidth: 190 },
{ prop: 'skuCode', label: 'SKU', minWidth: 190 }, { prop: 'productNameZh', label: '中文品名', minWidth: 190 },
{ prop: 'availableQuantity', label: '可用库存', width: 110, align: 'right' }, { prop: 'cartonQuantity', label: '所在箱数', width: 110, align: 'right' },
{ key: 'time', label: '库存更新时间', slotName: 'time', width: 180 },
]
const cartonItemColumns: DataTableColumn[] = [
{ prop: 'skuCode', label: 'SKU', minWidth: 200 },
{ prop: 'productNameZh', label: '中文品名', minWidth: 240 },
{ prop: 'quantity', label: '数量', width: 110, align: 'right', headerAlign: 'right' },
]
const cartonSummaryColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', slotName: 'actions', width: 150, fixed: 'left' }, { prop: 'cartonMark', label: '箱号', minWidth: 220 },
{ key: 'warehouse', label: '海外仓', slotName: 'warehouse', minWidth: 190 }, { prop: 'skuTypes', label: 'SKU 种类', width: 110, align: 'right' },
{ prop: 'totalQuantity', label: '总件数', width: 100, align: 'right' }, { prop: 'sourceReturnNo', label: '来源退货单', minWidth: 210 },
{ prop: 'sourceBatchNo', label: '签收批次', minWidth: 180 }, { key: 'dimensions', label: '箱规', slotName: 'dimensions', width: 170 },
{ key: 'weight', label: '重量', slotName: 'weight', width: 100 }, { key: 'time', label: '生成时间', slotName: 'time', width: 180 },
]
const cartonColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', slotName: 'actions', width: 150, fixed: 'left' }, { prop: 'cartonMark', label: '箱号', minWidth: 220 },
{ key: 'warehouse', label: '海外仓', slotName: 'warehouse', minWidth: 190 }, { prop: 'skuCode', label: 'SKU', minWidth: 180 },
{ prop: 'productNameZh', label: '中文品名', minWidth: 180 }, { prop: 'quantity', label: '本箱数量', width: 110, align: 'right' },
{ prop: 'cartonSkuTypes', label: 'SKU 种类', width: 110, align: 'right' }, { prop: 'cartonTotalQuantity', label: '总件数', width: 100, align: 'right' },
{ key: 'status', label: '箱状态', slotName: 'status', width: 100 }, { prop: 'sourceBatchNo', label: '签收批次', minWidth: 180 },
{ key: 'time', label: '生成时间', slotName: 'time', width: 180 },
]
const currentRows = computed<object[]>(() => activeTab.value === 'summary' ? summaryRows.value : props.cartonOnly ? cartonSummaryRows.value : cartonRows.value)
const currentColumns = computed(() => activeTab.value === 'summary' ? summaryColumns : props.cartonOnly ? cartonSummaryColumns : cartonColumns)
function params(includeCartonMark: boolean) {
return {
@@ -100,7 +152,8 @@ async function load() {
} finally { loading.value = false }
}
function search() { query.page = 1; void load() }
function search() { if (query.page === 1) void load(); else query.page = 1 }
function resetSearch() { query.status = 'AVAILABLE'; search() }
function changeTab() { query.page = 1; void load() }
function openSkuCartons(skuCode: string) {
query.skuCode = skuCode
@@ -110,11 +163,18 @@ function openSkuCartons(skuCode: string) {
}
async function openCarton(cartonMark: string) {
try {
cartonDetail.value = await getReturnInventoryCarton(cartonMark)
showCartonDetail.value = true
} catch (error) { ElMessage.error(error instanceof Error ? error.message : '箱内明细加载失败') }
void router.push({ name: 'overseas-return-carton-detail', params: { cartonMark } })
}
async function loadCartonDetail() {
if (!detailCartonMark.value) return
loading.value = true
detailError.value = ''
try {
cartonDetail.value = await getReturnInventoryCarton(detailCartonMark.value)
} catch (error) { cartonDetail.value = undefined; detailError.value = error instanceof Error ? error.message : '箱内明细加载失败'; ElMessage.error(detailError.value) }
finally { loading.value = false }
}
function returnToCartons() { void router.push({ name: 'overseas-return-cartons' }) }
async function printLabel(cartonMark: string) {
const printWindow = window.open('', '_blank')
@@ -136,59 +196,55 @@ async function printLabel(cartonMark: string) {
}
}
onMounted(() => { void load(); void loadWarehouses() })
function rowActions(candidate: object): TableAction[] {
const row = candidate as ReturnInventorySummaryItem | ReturnInventoryItem | ReturnInventoryCartonSummary
if (activeTab.value === 'summary') return [{ key: 'cartons', label: '查看箱号', run: () => openSkuCartons((row as ReturnInventorySummaryItem).skuCode) }]
const cartonMark = (row as ReturnInventoryItem | ReturnInventoryCartonSummary).cartonMark
return [
{ key: 'detail', label: '详情', run: () => openCarton(cartonMark) },
{ key: 'label', label: '箱唛', run: () => printLabel(cartonMark) },
]
}
watch(() => [query.page, query.size] as const, ([nextPage, nextSize], [previousPage, previousSize]) => {
if (nextSize !== previousSize && nextPage !== 1) { query.page = 1; return }
void load()
})
onMounted(() => { if (props.detailPage) void Promise.all([loadCartonDetail(), loadWarehouses()]); else { void load(); void loadWarehouses() } })
</script>
<template>
<section class="return-inventory-page">
<div class="page-heading"><div><h2>{{ props.cartonOnly ? '退货箱号管理' : '退货库存管理' }}</h2><p>{{ props.cartonOnly ? '查询签收及合箱生成的退货箱号、箱规和箱内 SKU 明细。' : '库存数量以仓库 PDA 确认实收后的入账数量为准。' }}</p></div></div>
<template v-if="!props.detailPage">
<el-tabs v-if="!props.cartonOnly" v-model="activeTab" @tab-change="changeTab"><el-tab-pane label="SKU 汇总库存" name="summary" /><el-tab-pane label="箱号明细" name="cartons" /></el-tabs>
<div class="filter-bar" :class="{ 'carton-filters': activeTab === 'cartons' }">
<el-select v-model="query.overseasWarehouseId" clearable filterable placeholder="海外仓" :loading="warehousesLoading"><el-option v-for="warehouse in warehouses" :key="warehouse.id" :label="warehouseLabel(String(warehouse.id))" :value="String(warehouse.id)" /></el-select>
<el-input v-model="query.skuCode" clearable placeholder="SKU" @keyup.enter="search" />
<el-input v-if="activeTab === 'cartons'" v-model="query.cartonMark" clearable placeholder="箱号 / 箱唛" @keyup.enter="search" />
<el-input v-if="activeTab === 'cartons'" v-model="query.returnNo" clearable placeholder="退货单号" @keyup.enter="search" />
<el-select v-if="activeTab === 'cartons'" v-model="query.status" clearable placeholder="箱状态"><el-option label="可用" value="AVAILABLE" /><el-option label="已合箱" value="MERGED" /></el-select>
<el-button type="primary" @click="search"><Search :size="16" />查询</el-button>
</div>
<QueryToolbar v-model="queryModel" layout="grid" :ariaLabel="`${props.cartonOnly ? '退货箱号' : '退货库存'}筛选`" :fields="queryFields" :search-on-reset="false" @search="search" @reset="resetSearch" />
<el-alert v-if="loadError" :title="loadError" type="error" show-icon :closable="false" />
<el-alert v-if="warehouseError" :title="warehouseError" type="warning" show-icon :closable="false"><el-button link type="primary" @click="loadWarehouses">重试加载海外仓</el-button></el-alert>
<el-table v-if="activeTab === 'summary'" v-loading="loading" :data="summaryRows" :empty-text="loadError ? '库存加载失败,请核对权限或重试' : '暂无数据'">
<el-table-column label="操作" width="110" fixed="left"><template #default="{ row }"><el-button link type="primary" @click="openSkuCartons(row.skuCode)">查看箱号</el-button></template></el-table-column>
<el-table-column label="海外仓" min-width="150"><template #default="{ row }">{{ warehouseLabel(row.overseasWarehouseId) }}</template></el-table-column><el-table-column prop="skuCode" label="SKU" min-width="180" />
<el-table-column prop="productNameZh" label="中文品名" min-width="190" show-overflow-tooltip /><el-table-column prop="availableQuantity" label="可用库存" min-width="120" align="right" />
<el-table-column prop="cartonQuantity" label="所在箱数" min-width="120" align="right" /><el-table-column prop="updatedAt" label="库存更新时间" min-width="180"><template #default="{ row }"><span class="inventory-time">{{ formatInventoryTime(row.updatedAt) }}</span></template></el-table-column>
</el-table>
<el-table v-else-if="props.cartonOnly" v-loading="loading" :data="cartonSummaryRows" row-key="cartonMark" :empty-text="loadError ? '库存加载失败,请核对权限或重试' : '暂无数据'">
<el-table-column label="操作" width="150" fixed="left"><template #default="{ row }"><el-button link type="primary" @click="openCarton(row.cartonMark)">详情</el-button><el-button link type="primary" @click="printLabel(row.cartonMark)"><Printer :size="14" />箱唛</el-button></template></el-table-column>
<el-table-column prop="cartonMark" label="箱号" min-width="235" />
<el-table-column label="海外仓" min-width="150"><template #default="{ row }">{{ warehouseLabel(row.overseasWarehouseId) }}</template></el-table-column>
<el-table-column prop="skuTypes" label="箱内 SKU 种类" min-width="130" align="right" />
<el-table-column prop="totalQuantity" label="箱内总件数" min-width="115" align="right" />
<el-table-column prop="sourceReturnNo" label="来源退货单" min-width="235" />
<el-table-column prop="sourceBatchNo" label="签收批次" min-width="190" />
<el-table-column label="箱规" min-width="160"><template #default="{ row }">{{ row.length && row.width && row.height ? `${row.length} × ${row.width} × ${row.height} cm` : '—' }}</template></el-table-column>
<el-table-column label="重量" min-width="100"><template #default="{ row }">{{ row.weight ? `${row.weight} kg` : '—' }}</template></el-table-column>
<el-table-column prop="createdAt" label="箱号生成时间" min-width="180"><template #default="{ row }"><span class="inventory-time">{{ formatInventoryTime(row.createdAt) }}</span></template></el-table-column>
</el-table>
<el-table v-else v-loading="loading" :data="cartonRows" :empty-text="loadError ? '库存加载失败,请核对权限或重试' : '暂无数据'">
<el-table-column label="操作" width="150" fixed="left"><template #default="{ row }"><el-button link type="primary" @click="openCarton(row.cartonMark)">详情</el-button><el-button link type="primary" @click="printLabel(row.cartonMark)"><Printer :size="14" />箱唛</el-button></template></el-table-column>
<el-table-column prop="cartonMark" label="箱号" min-width="235"><template #default="{ row }"><el-button link type="primary" @click="openCarton(row.cartonMark)">{{ row.cartonMark }}</el-button></template></el-table-column>
<el-table-column label="海外仓" min-width="150"><template #default="{ row }">{{ warehouseLabel(row.overseasWarehouseId) }}</template></el-table-column><el-table-column prop="skuCode" label="SKU" min-width="150" />
<el-table-column prop="productNameZh" label="中文品名" min-width="170" show-overflow-tooltip /><el-table-column prop="quantity" label="本箱 SKU 数量" min-width="135" align="right" />
<el-table-column prop="cartonSkuTypes" label="箱内 SKU 种类" min-width="130" align="right" /><el-table-column prop="cartonTotalQuantity" label="箱内总件数" min-width="115" align="right" />
<el-table-column prop="status" label="箱状态" min-width="100"><template #default="{ row }">{{ cartonStatusLabel(row.status) }}</template></el-table-column><el-table-column prop="sourceBatchNo" label="签收批次" min-width="190" /><el-table-column prop="receivedAt" label="箱号生成时间" min-width="180"><template #default="{ row }"><span class="inventory-time">{{ formatInventoryTime(row.receivedAt) }}</span></template></el-table-column>
</el-table>
<el-pagination v-model:current-page="query.page" v-model:page-size="query.size" :total="total" layout="total, prev, pager, next" @current-change="load" />
<el-drawer v-model="showCartonDetail" title="退货箱明细" size="680px"><template v-if="cartonDetail">
<el-descriptions :column="2" border><el-descriptions-item label="箱号">{{ cartonDetail.cartonMark }}</el-descriptions-item><el-descriptions-item label="海外仓">{{ warehouseLabel(cartonDetail.overseasWarehouseId) }}</el-descriptions-item><el-descriptions-item label="来源退货单">{{ cartonDetail.sourceReturnNo || '—' }}</el-descriptions-item><el-descriptions-item label="签收批次">{{ cartonDetail.sourceBatchNo || '—' }}</el-descriptions-item><el-descriptions-item label="状态">{{ cartonStatusLabel(cartonDetail.status) }}</el-descriptions-item><el-descriptions-item label="SKU 种类">{{ cartonDetail.skuTypes }}</el-descriptions-item><el-descriptions-item label="箱内总件数">{{ cartonDetail.totalQuantity }}</el-descriptions-item><el-descriptions-item label="箱规">{{ cartonDetail.length && cartonDetail.width && cartonDetail.height ? `${cartonDetail.length} × ${cartonDetail.width} × ${cartonDetail.height} cm` : '—' }}</el-descriptions-item><el-descriptions-item label="重量">{{ cartonDetail.weight ? `${cartonDetail.weight} kg` : '—' }}</el-descriptions-item></el-descriptions>
<div class="drawer-heading"><h3>箱内 SKU</h3><el-button type="primary" @click="printLabel(cartonDetail.cartonMark)"><Printer :size="16" />打印箱唛</el-button></div>
<el-table :data="cartonDetail.items" size="small"><el-table-column prop="skuCode" label="SKU" min-width="170" /><el-table-column prop="productNameZh" label="中文品名" min-width="180" /><el-table-column prop="quantity" label="数量" min-width="100" align="right" /></el-table>
</template></el-drawer>
<div class="return-inventory-table">
<DataTable v-model:page="query.page" v-model:page-size="query.size" :data="currentRows" :columns="currentColumns" :loading="loading" :total="total" :page-sizes="[20, 50, 100]" :row-actions="rowActions" :empty-text="loadError ? '库存加载失败,请核对权限或重试' : '暂无数据'">
<template #toolbar><strong>{{ props.cartonOnly ? '退货箱号' : activeTab === 'summary' ? 'SKU 汇总库存' : '箱号明细' }}</strong><span>共 {{ total }} 条</span></template>
<template #warehouse="{ row }">{{ warehouseLabel(row.overseasWarehouseId) }}</template>
<template #status="{ row }"><el-tag effect="light">{{ cartonStatusLabel(row.status) }}</el-tag></template>
<template #dimensions="{ row }">{{ row.length && row.width && row.height ? `${row.length} × ${row.width} × ${row.height} cm` : '—' }}</template>
<template #weight="{ row }">{{ row.weight ? `${row.weight} kg` : '—' }}</template>
<template #time="{ row }"><span class="inventory-time">{{ formatInventoryTime(row.updatedAt || row.createdAt || row.receivedAt) }}</span></template>
</DataTable>
</div>
</template>
<article v-else v-loading="loading" class="carton-detail-surface">
<template v-if="cartonDetail">
<header class="carton-detail-header"><button type="button" class="carton-detail-back" @click="returnToCartons"><ArrowLeft :size="16" />返回列表</button><div><strong>{{ cartonDetail.cartonMark }}</strong><span>{{ warehouseLabel(cartonDetail.overseasWarehouseId) }} · 退货箱明细</span></div><div class="carton-detail-actions"><el-tag effect="light">{{ cartonStatusLabel(cartonDetail.status) }}</el-tag><el-button type="primary" @click="printLabel(cartonDetail.cartonMark)"><Printer :size="16" />打印箱唛</el-button></div></header>
<section class="carton-detail-section"><h3>箱号概览</h3><dl class="carton-detail-grid"><div><dt>来源退货单</dt><dd>{{ cartonDetail.sourceReturnNo || '—' }}</dd></div><div><dt>签收批次</dt><dd>{{ cartonDetail.sourceBatchNo || '—' }}</dd></div><div><dt>SKU 种类</dt><dd>{{ cartonDetail.skuTypes }}</dd></div><div><dt>箱内总件数</dt><dd>{{ cartonDetail.totalQuantity }}</dd></div><div><dt>箱规</dt><dd>{{ cartonDetail.length && cartonDetail.width && cartonDetail.height ? `${cartonDetail.length} × ${cartonDetail.width} × ${cartonDetail.height} cm` : '—' }}</dd></div><div><dt>重量</dt><dd>{{ cartonDetail.weight ? `${cartonDetail.weight} kg` : '—' }}</dd></div><div><dt>签收时间</dt><dd>{{ formatInventoryTime(cartonDetail.receivedAt) }}</dd></div></dl></section>
<section class="carton-detail-section"><h3>箱内 SKU</h3><DataTable :data="cartonDetail.items" :columns="cartonItemColumns" row-key="skuCode" empty-text="暂无箱内 SKU" /></section>
</template>
<div v-else class="carton-detail-error" role="alert"><strong>{{ detailError || '箱号详情加载失败' }}</strong><el-button @click="loadCartonDetail">重新加载</el-button></div>
</article>
</section>
</template>
<style scoped>
.return-inventory-page { padding: 20px; } .page-heading { margin-bottom: 14px; } h2 { margin: 0; font-size: 22px; } p { margin: 6px 0 0; color: var(--el-text-color-secondary); } .filter-bar { display: grid; grid-template-columns: repeat(2, minmax(180px, 320px)) auto; justify-content: start; gap: 10px; margin-bottom: 14px; } .filter-bar.carton-filters { grid-template-columns: repeat(4, minmax(150px, 1fr)) 130px auto; } .filter-bar > * { min-width: 0; } .inventory-time { white-space: nowrap; } .el-pagination { margin-top: 16px; justify-content: flex-end; } .drawer-heading { display: flex; align-items: center; justify-content: space-between; margin: 24px 0 12px; } .drawer-heading h3 { margin: 0; font-size: 16px; }
@media (max-width: 760px) { .return-inventory-page { padding: 12px; } .filter-bar, .filter-bar.carton-filters { grid-template-columns: 1fr 1fr; } }
.return-inventory-page { display: grid; gap: 12px; } .return-inventory-page :deep(.el-tabs__header) { margin: 0; } .return-inventory-table { overflow: hidden; border: 1px solid var(--client-border); border-radius: var(--client-radius); background: #fff; } .inventory-time { white-space: nowrap; }
.carton-detail-surface{overflow:hidden;min-height:240px;border:1px solid var(--client-border);border-radius:var(--client-radius);background:#fff}.carton-detail-header{display:grid;grid-template-columns:auto minmax(0,1fr) auto;align-items:center;gap:14px;min-height:64px;padding:10px 16px;border-bottom:1px solid var(--client-border)}.carton-detail-back{display:inline-flex;align-items:center;gap:5px;padding:5px 0;border:0;background:transparent;color:var(--el-color-primary);cursor:pointer}.carton-detail-header>div:nth-child(2){display:flex;min-width:0;flex-direction:column;gap:3px}.carton-detail-header strong{overflow:hidden;color:#1f2d45;font-size:18px;text-overflow:ellipsis;white-space:nowrap}.carton-detail-header span{color:#7c899c;font-size:12px}.carton-detail-actions{display:flex;align-items:center;gap:8px}.carton-detail-section+.carton-detail-section{border-top:1px solid var(--client-border)}.carton-detail-section>h3{display:flex;min-height:40px;align-items:center;margin:0;padding:0 16px;border-bottom:1px solid var(--client-border);background:var(--client-header-bg);font-size:14px}.carton-detail-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));margin:0}.carton-detail-grid div{display:grid;min-height:42px;grid-template-columns:88px minmax(0,1fr);align-items:center;gap:8px;padding:5px 12px;border-right:1px solid var(--client-border);border-bottom:1px solid var(--client-border)}.carton-detail-grid dt{color:var(--client-muted);font-size:12px}.carton-detail-grid dd{margin:0;overflow:hidden;color:var(--client-text);font-size:13px;font-weight:600;text-overflow:ellipsis;white-space:nowrap}@media(max-width:1100px){.carton-detail-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(max-width:720px){.carton-detail-header{grid-template-columns:1fr auto}.carton-detail-back{grid-column:1/-1}.carton-detail-grid{grid-template-columns:1fr}}
.carton-detail-error{display:flex;min-height:220px;align-items:center;justify-content:center;gap:12px;color:#b42318}
</style>
@@ -4,15 +4,15 @@ import test from 'node:test'
const view = readFileSync(new URL('./ReturnOrdersView.vue', import.meta.url), 'utf8')
test('新建退货预报选择中转仓后带出联系人和电话', () => {
test('新建退货预报选择海外仓后带出联系人和电话', () => {
assert.match(view, /@change="selectOperationWarehouse"/)
assert.match(view, /warehouseContactName = warehouse\?\.contactName \|\| ''/)
assert.match(view, /warehouseContactPhone = warehouse\?\.contactPhone \|\| ''/)
assert.match(view, /label="中转仓联系人"/)
assert.match(view, /label="中转仓联系电话"/)
assert.match(view, /label="海外仓联系人"/)
assert.match(view, /label="海外仓联系电话"/)
assert.match(view, /:readonly="Boolean\(selectedOperationWarehouse\?\.contactName\)"/)
assert.match(view, /:readonly="Boolean\(selectedOperationWarehouse\?\.contactPhone\)"/)
assert.match(view, /该中转仓未维护,请手动填写/)
assert.match(view, /该海外仓未维护,请手动填写/)
assert.match(view, /warehouseContactName: form\.warehouseContactName\.trim\(\)/)
assert.match(view, /warehouseContactPhone: form\.warehouseContactPhone\.trim\(\)/)
})
@@ -38,15 +38,23 @@ test('退货列表和详情使用客户可读仓库、状态与时间', () => {
})
test('可操作退货表格把操作列固定在最左侧', () => {
const mainTable = view.slice(view.indexOf('<el-table v-loading="loading"'), view.indexOf('<el-pagination'))
const createTable = view.slice(view.indexOf('<el-table :data="form.items"'), view.indexOf('</el-table>', view.indexOf('<el-table :data="form.items"')))
assert.match(mainTable, /^<el-table[\s\S]*?<el-table-column label="操作" width="90" fixed="left">/)
assert.match(view, /label: '操作', slotName: 'actions', width: 90, fixed: 'left'/)
assert.match(view, /<DataTable[^>]*:row-actions="rowActions"/)
assert.match(createTable, /^<el-table[\s\S]*?<el-table-column label="操作" width="80" fixed="left">/)
})
test('分页初始化不会重复请求退货列表', () => {
assert.match(view, /function changePage\(page: number\) \{\s*if \(page === query\.page\) return/)
assert.match(view, /:current-page="query\.page" :page-size="query\.size"/)
assert.match(view, /@current-change="changePage"/)
assert.match(view, /watch\(\(\) => \[query\.page, query\.size\] as const/)
assert.match(view, /v-model:page="query\.page" v-model:page-size="query\.size"/)
assert.doesNotMatch(view, /@current-change="load"/)
})
test('退货签收详情使用独立路由和紧凑连续页面', () => {
assert.match(view, /name: 'overseas-return-relabel-detail'/)
assert.match(view, /class="return-detail-surface"/)
assert.match(view, /class="return-detail-grid"/)
assert.match(view, /function returnToList\(\)/)
assert.doesNotMatch(view, /showDetail/)
assert.doesNotMatch(view, /class="return-detail-dialog"/)
})
@@ -1,21 +1,61 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { Plus, Search } from '@lucide/vue'
import { ArrowLeft, Plus } from '@lucide/vue'
import { useRoute, useRouter } from 'vue-router'
import QueryToolbar, { type QueryField, type QueryModel } from '@/components/common/QueryToolbar.vue'
import DataTable, { type DataTableColumn } from '@/components/common/DataTable.vue'
import type { TableAction } from '@/components/common/tableActions'
import { useAuthStore } from '@/modules/auth/public'
import { getMerchantWarehouses, type MerchantWarehouse } from '@/modules/first-leg/public'
import { createReturnOrder, getReturnOrder, listReturnOrders, type ReturnOrderDetail, type ReturnOrderSummary, type ReturnReceipt } from '../api/returnOrders'
const props = withDefaults(defineProps<{ detailPage?: boolean; returnNo?: string }>(), { detailPage: false, returnNo: '' })
const router = useRouter()
const route = useRoute()
const detailReturnNo = computed(() => props.returnNo || String(route.params.returnNo || ''))
const loading = ref(false)
const auth = useAuthStore()
const canCreate = computed(() => auth.hasPermission('merchant:overseas-warehouse:return-order:create'))
const creating = ref(false)
const showCreate = ref(false)
const showDetail = ref(false)
const selected = ref<ReturnOrderDetail>()
const rows = ref<ReturnOrderSummary[]>([])
const total = ref(0)
const operationWarehouses = ref<MerchantWarehouse[]>([])
const operationWarehousesLoading = ref(false)
const operationWarehousesError = ref('')
const query = reactive({ page: 1, size: 20, returnNo: '', trackingNo: '', skuCode: '', status: '' })
const query = reactive({ page: 1, size: 15, returnNo: '', trackingNo: '', skuCode: '', status: '' })
const queryModel = computed<QueryModel>({ get: () => ({ ...query }), set: (value) => Object.assign(query, value) })
const queryFields: QueryField[] = [
{ key: 'returnNo', label: '退货单号', type: 'input', placeholder: '请输入退货单号' },
{ key: 'trackingNo', label: '快递单号', type: 'input', placeholder: '请输入快递单号' },
{ key: 'skuCode', label: 'SKU', type: 'input', placeholder: '请输入 SKU' },
{ key: 'status', label: '签收状态', type: 'select', options: [{ label: '待签收', value: 'PENDING_RECEIPT' }, { label: '部分签收', value: 'PARTIALLY_RECEIVED' }, { label: '已完成', value: 'COMPLETED' }] },
]
const tableColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', slotName: 'actions', width: 90, fixed: 'left' },
{ prop: 'returnNo', label: '退货单号', minWidth: 190 }, { prop: 'customerReferenceNo', label: '客户参考号', minWidth: 160 },
{ key: 'warehouse', label: '操作仓', slotName: 'warehouse', minWidth: 200 }, { key: 'relabel', label: '换标', slotName: 'relabel', width: 100 },
{ prop: 'skuTypes', label: 'SKU 种类', width: 105 }, { prop: 'expectedQuantity', label: '预报数量', width: 110 },
{ prop: 'receivedQuantity', label: '实收数量', width: 110 }, { key: 'status', label: '状态', slotName: 'status', width: 120 },
{ key: 'createdAt', label: '创建时间', slotName: 'createdAt', width: 180 },
]
const itemColumns: DataTableColumn[] = [
{ prop: 'skuCode', label: 'SKU', minWidth: 190 },
{ prop: 'productNameZh', label: '中文品名', minWidth: 220 },
{ prop: 'expectedQuantity', label: '预报数量', width: 110 },
{ prop: 'receivedQuantity', label: '实收数量', width: 110 },
]
const receiptColumns: DataTableColumn[] = [
{ prop: 'receiptBatchNo', label: '批次号', minWidth: 170 },
{ prop: 'receiptNo', label: '签收单号', minWidth: 170 },
{ prop: 'cartonMark', label: '箱号', minWidth: 170 },
{ key: 'trackingNo', label: '快递单号', slotName: 'trackingNo', minWidth: 160 },
{ key: 'startedBy', label: '开始方式', slotName: 'startedBy', width: 110 },
{ prop: 'receivedQuantity', label: '实收', width: 90 },
{ key: 'signedAt', label: '签收时间', slotName: 'signedAt', minWidth: 180 },
]
interface SkuDraft { id: number; skuCode: string; productNameZh: string; expectedQuantity: number }
let skuSequence = 0
function emptySku(): SkuDraft { return { id: ++skuSequence, skuCode: '', productNameZh: '', expectedQuantity: 1 } }
@@ -45,12 +85,16 @@ async function loadOperationWarehouses() {
operationWarehousesLoading.value = true
operationWarehousesError.value = ''
try {
const result = await getMerchantWarehouses({ warehouseType: 'OVERSEAS' })
operationWarehouses.value = result.records
if (!result.records.length) operationWarehousesError.value = '暂无可用的国外中转仓'
// Managed transit warehouses are maintained as OVERSEAS_PARTNER in the warehouse master.
// Query the active catalogue and accept both overseas warehouse types.
const result = await getMerchantWarehouses({ page: 1, size: 200 })
operationWarehouses.value = result.records.filter((warehouse) =>
warehouse.status === 'ACTIVE' && ['OVERSEAS', 'OVERSEAS_PARTNER'].includes(warehouse.warehouseType),
)
if (!result.records.length) operationWarehousesError.value = '暂无可用的海外仓'
} catch (error) {
operationWarehouses.value = []
operationWarehousesError.value = error instanceof Error ? error.message : '国外中转仓加载失败'
operationWarehousesError.value = error instanceof Error ? error.message : '海外仓加载失败'
ElMessage.error(operationWarehousesError.value)
} finally { operationWarehousesLoading.value = false }
}
@@ -63,7 +107,7 @@ function addItem() { form.items.push(emptySku()) }
function removeItem(index: number) { if (form.items.length > 1) form.items.splice(index, 1) }
async function load() {
loading.value = true
try { const response = await listReturnOrders({ ...query, returnNo: query.returnNo || undefined, trackingNo: query.trackingNo || undefined, skuCode: query.skuCode || undefined, status: query.status || undefined }); rows.value = response.records; total.value = Number(response.total) }
try { const response = await listReturnOrders({ ...query, returnNo: query.returnNo || undefined, trackingNo: query.trackingNo || undefined, skuCode: query.skuCode || undefined, status: query.status || undefined }); rows.value = response.records; total.value = Number(response.total); if (!Number.isFinite(total.value)) total.value = response.records.length }
finally { loading.value = false }
}
function changePage(page: number) {
@@ -71,16 +115,29 @@ function changePage(page: number) {
query.page = page
void load()
}
async function openDetail(returnNo: string) { selected.value = await getReturnOrder(returnNo); showDetail.value = true }
function search() { if (query.page === 1) void load(); else query.page = 1 }
function rowActions(candidate: object): TableAction[] {
const row = candidate as ReturnOrderSummary
return [{ key: 'detail', label: '详情', run: () => openDetail(row.returnNo) }]
}
async function loadDetail() {
if (!detailReturnNo.value) return
loading.value = true
try { selected.value = await getReturnOrder(detailReturnNo.value) }
catch (error) { ElMessage.error(error instanceof Error ? error.message : '退货签收详情加载失败') }
finally { loading.value = false }
}
function openDetail(returnNo: string) { void router.push({ name: 'overseas-return-relabel-detail', params: { returnNo } }) }
function returnToList() { void router.push({ name: 'overseas-return-relabel' }) }
async function submit() {
const items = form.items.map(({ skuCode, productNameZh, expectedQuantity }) => ({ skuCode: skuCode.trim(), productNameZh: productNameZh.trim(), expectedQuantity }))
if (!form.overseasWarehouseId || items.some(item => !item.skuCode || item.expectedQuantity <= 0)) { ElMessage.warning('请填写操作仓、SKU 和预报数量'); return }
if (!form.warehouseContactName.trim() || !form.warehouseContactPhone.trim()) { ElMessage.warning('请填写中转仓联系人和联系电话'); return }
if (!form.warehouseContactName.trim() || !form.warehouseContactPhone.trim()) { ElMessage.warning('请填写海外仓联系人和联系电话'); return }
if (duplicateSkuIds.value.size) { ElMessage.warning('同一退货单不能重复填写相同 SKU'); return }
creating.value = true
try {
const detail = await createReturnOrder({ overseasWarehouseId: form.overseasWarehouseId, warehouseContactName: form.warehouseContactName.trim(), warehouseContactPhone: form.warehouseContactPhone.trim(), customerReferenceNo: form.customerReferenceNo || undefined, remark: form.remark || undefined, needRelabel: form.needRelabel, idempotencyKey: crypto.randomUUID(), items: items.map(item => ({ ...item, productNameZh: item.productNameZh || undefined })) })
ElMessage.success('退货预报已创建'); showCreate.value = false; resetForm(); await load(); await openDetail(detail.order.returnNo)
ElMessage.success('退货预报已创建'); showCreate.value = false; resetForm(); await router.push({ name: 'overseas-return-relabel-detail', params: { returnNo: detail.order.returnNo } })
} finally { creating.value = false }
}
function statusType(status: string) { return status === 'COMPLETED' ? 'success' : status === 'PARTIALLY_RECEIVED' ? 'warning' : 'info' }
@@ -94,38 +151,36 @@ function formatDateTime(value?: string | null) {
if (Number.isNaN(date.getTime())) return '—'
return new Intl.DateTimeFormat('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false }).format(date)
}
onMounted(() => { void Promise.all([load(), loadOperationWarehouses()]) })
onMounted(() => {
if (props.detailPage) void Promise.all([loadDetail(), loadOperationWarehouses()])
else void Promise.all([load(), loadOperationWarehouses()])
})
watch(() => [query.page, query.size] as const, ([nextPage, nextSize], [previousPage, previousSize]) => {
if (nextSize !== previousSize && nextPage !== 1) { query.page = 1; return }
if (!props.detailPage) void load()
})
</script>
<template>
<section class="return-orders-page">
<div class="page-heading"><div><h2>退货签收</h2><p>预报退货 SKU 和数量,仓库 PDA 实收后自动更新签收状态。</p></div><el-button type="primary" @click="openCreate"><Plus :size="16" />新建退货预报</el-button></div>
<div class="filter-bar">
<el-input v-model="query.returnNo" clearable placeholder="退货单号" @keyup.enter="query.page=1; load()" />
<el-input v-model="query.trackingNo" clearable placeholder="快递单号" @keyup.enter="query.page=1; load()" />
<el-input v-model="query.skuCode" clearable placeholder="SKU" @keyup.enter="query.page=1; load()" />
<el-select v-model="query.status" clearable placeholder="签收状态"><el-option label="待签收" value="PENDING_RECEIPT" /><el-option label="部分签收" value="PARTIALLY_RECEIVED" /><el-option label="已完成" value="COMPLETED" /></el-select>
<el-button @click="query.page=1; load()"><Search :size="16" />查询</el-button>
<template v-if="!props.detailPage">
<QueryToolbar v-model="queryModel" layout="grid" ariaLabel="退货签收筛选" :fields="queryFields" @search="search" />
<div class="return-table-card">
<DataTable v-model:page="query.page" v-model:page-size="query.size" :data="rows" :columns="tableColumns" :loading="loading" :total="total" :page-sizes="[15, 30, 50, 100]" :row-actions="rowActions" row-key="returnNo" empty-text="暂无退货预报">
<template #toolbar><strong>退货签收</strong><span>共 {{ total }} 条</span></template>
<template #toolbar-actions><el-button v-if="canCreate" type="primary" @click="openCreate"><Plus :size="16" />新建退货预报</el-button></template>
<template #warehouse="{ row }">{{ operationWarehouseLabel(row.overseasWarehouseId) }}</template>
<template #relabel="{ row }"><el-tag v-if="row.needRelabel" type="warning" effect="light">需要换标</el-tag><span v-else>否</span></template>
<template #status="{ row }"><el-tag :type="statusType(row.status)" effect="light">{{ statusLabel(row.status) }}</el-tag></template>
<template #createdAt="{ row }">{{ formatDateTime(row.createdAt) }}</template>
</DataTable>
</div>
<el-table v-loading="loading" :data="rows" @row-click="row => openDetail(row.returnNo)">
<el-table-column label="操作" width="90" fixed="left"><template #default="{ row }"><el-button link type="primary" @click.stop="openDetail(row.returnNo)">详情</el-button></template></el-table-column>
<el-table-column prop="returnNo" label="退货单号" min-width="190" />
<el-table-column prop="customerReferenceNo" label="客户参考号" min-width="150" />
<el-table-column label="操作仓" min-width="190"><template #default="{ row }">{{ operationWarehouseLabel(row.overseasWarehouseId) }}</template></el-table-column>
<el-table-column label="换标" width="100"><template #default="{ row }"><el-tag v-if="row.needRelabel" type="warning">需要换标</el-tag><span v-else>否</span></template></el-table-column>
<el-table-column prop="skuTypes" label="SKU种类" width="100" />
<el-table-column prop="expectedQuantity" label="预报数量" width="110" />
<el-table-column prop="receivedQuantity" label="实收数量" width="110" />
<el-table-column label="状态" width="130"><template #default="{ row }"><el-tag :type="statusType(row.status)">{{ statusLabel(row.status) }}</el-tag></template></el-table-column>
<el-table-column label="创建时间" min-width="180"><template #default="{ row }">{{ formatDateTime(row.createdAt) }}</template></el-table-column>
</el-table>
<el-pagination :current-page="query.page" :page-size="query.size" :total="total" layout="total, prev, pager, next" @current-change="changePage" />
<el-dialog v-model="showCreate" title="新建退货预报" width="1120px" destroy-on-close>
<el-form label-width="190px" class="return-create-form">
<el-form-item label="海外操作仓(中转仓)" required class="return-form-wide"><el-select v-model="form.overseasWarehouseId" filterable clearable :loading="operationWarehousesLoading" :disabled="Boolean(operationWarehousesError)" placeholder="请选择国外中转仓" @change="selectOperationWarehouse"><el-option v-for="warehouse in operationWarehouses" :key="warehouse.id" :label="warehouseLabel(warehouse)" :value="String(warehouse.id)" /></el-select><div v-if="operationWarehousesError" class="warehouse-error">{{ operationWarehousesError }}</div></el-form-item>
<el-form-item label="中转仓联系人" required><el-input v-model="form.warehouseContactName" :readonly="Boolean(selectedOperationWarehouse?.contactName)" :placeholder="selectedOperationWarehouse?.contactName ? '已从中转仓资料带出' : '该中转仓未维护,请手动填写'" /></el-form-item>
<el-form-item label="中转仓联系电话" required><el-input v-model="form.warehouseContactPhone" :readonly="Boolean(selectedOperationWarehouse?.contactPhone)" :placeholder="selectedOperationWarehouse?.contactPhone ? '已从中转仓资料带出' : '该中转仓未维护,请手动填写'" /></el-form-item>
<el-form-item label="海外仓" required class="return-form-wide"><el-select v-model="form.overseasWarehouseId" filterable clearable :loading="operationWarehousesLoading" :disabled="Boolean(operationWarehousesError)" placeholder="请选择海外仓" @change="selectOperationWarehouse"><el-option v-for="warehouse in operationWarehouses" :key="warehouse.id" :label="warehouseLabel(warehouse)" :value="String(warehouse.id)" /></el-select><div v-if="operationWarehousesError" class="warehouse-error">{{ operationWarehousesError }}</div></el-form-item>
<el-form-item label="海外仓联系人" required><el-input v-model="form.warehouseContactName" :readonly="Boolean(selectedOperationWarehouse?.contactName)" :placeholder="selectedOperationWarehouse?.contactName ? '已从海外仓资料带出' : '该海外仓未维护,请手动填写'" /></el-form-item>
<el-form-item label="海外仓联系电话" required><el-input v-model="form.warehouseContactPhone" :readonly="Boolean(selectedOperationWarehouse?.contactPhone)" :placeholder="selectedOperationWarehouse?.contactPhone ? '已从海外仓资料带出' : '该海外仓未维护,请手动填写'" /></el-form-item>
<el-form-item label="客户参考号" class="return-form-wide"><el-input v-model="form.customerReferenceNo" placeholder="可填写客户侧参考号" /></el-form-item>
<el-form-item label="备注" class="return-form-wide"><el-input v-model="form.remark" type="textarea" placeholder="填写退货说明(选填)" /></el-form-item>
</el-form>
@@ -138,13 +193,71 @@ onMounted(() => { void Promise.all([load(), loadOperationWarehouses()]) })
<el-table-column label="中文品名" min-width="260"><template #default="{ row }"><el-input v-model="row.productNameZh" placeholder="填写中文品名(选填)" /></template></el-table-column>
<el-table-column label="预报数量" width="160"><template #default="{ row }"><el-input-number v-model="row.expectedQuantity" :min="1" controls-position="right" placeholder="数量" /></template></el-table-column>
</el-table>
<template #footer><el-button @click="showCreate=false">取消</el-button><el-button type="primary" :loading="creating" @click="submit">提交预报</el-button></template>
<template #footer><el-button @click="showCreate=false">取消</el-button><el-button v-if="canCreate" type="primary" :loading="creating" @click="submit">提交预报</el-button></template>
</el-dialog>
<el-drawer v-model="showDetail" title="退货签收详情" size="760px"><template v-if="selected"><el-descriptions :column="2" border><el-descriptions-item label="退货单号">{{ selected.order.returnNo }}</el-descriptions-item><el-descriptions-item label="状态"><el-tag :type="statusType(selected.order.status)">{{ statusLabel(selected.order.status) }}</el-tag></el-descriptions-item><el-descriptions-item label="操作仓">{{ operationWarehouseLabel(selected.order.overseasWarehouseId) }}</el-descriptions-item><el-descriptions-item label="实收 / 预报">{{ selected.order.receivedQuantity }} / {{ selected.order.expectedQuantity }}</el-descriptions-item><el-descriptions-item label="中转仓联系人">{{ selected.order.warehouseContactName || '—' }}</el-descriptions-item><el-descriptions-item label="中转仓联系电话">{{ selected.order.warehouseContactPhone || '—' }}</el-descriptions-item></el-descriptions><p v-if="selected.remark" class="remark">备注:{{ selected.remark }}</p><h3>预报与实收</h3><el-table :data="selected.items" size="small"><el-table-column prop="skuCode" label="SKU" /><el-table-column prop="productNameZh" label="中文品名" /><el-table-column prop="expectedQuantity" label="预报" /><el-table-column prop="receivedQuantity" label="实收" /></el-table><h3>签收批次</h3><el-table :data="selected.receipts" size="small"><el-table-column prop="receiptBatchNo" label="批次号" min-width="170" /><el-table-column prop="receiptNo" label="签收单号" min-width="170" /><el-table-column prop="cartonMark" label="箱号" min-width="190" /><el-table-column prop="trackingNo" label="快递单号"><template #default="{ row }">{{ row.trackingNo || '无快递单号' }}</template></el-table-column><el-table-column label="开始方式"><template #default="{ row }">{{ startedByLabel(row.startedBy) }}</template></el-table-column><el-table-column prop="receivedQuantity" label="实收" /><el-table-column label="签收时间" min-width="180"><template #default="{ row }">{{ formatDateTime(row.signedAt) }}</template></el-table-column></el-table></template></el-drawer>
</template>
<article v-else v-loading="loading" class="return-detail-surface">
<template v-if="selected">
<header class="return-detail-header">
<button type="button" class="return-back" @click="returnToList"><ArrowLeft :size="16" />返回列表</button>
<div class="return-detail-title"><strong>{{ selected.order.returnNo }}</strong><span>{{ operationWarehouseLabel(selected.order.overseasWarehouseId) }} · 退货签收</span></div>
<el-tag :type="statusType(selected.order.status)" effect="light">{{ statusLabel(selected.order.status) }}</el-tag>
</header>
<section class="return-detail-section">
<h3>签收概览</h3>
<div class="return-detail-grid">
<div><span>实收 / 预报</span><strong>{{ selected.order.receivedQuantity }} / {{ selected.order.expectedQuantity }}</strong></div>
<div><span>SKU 种类</span><strong>{{ selected.order.skuTypes }}</strong></div>
<div><span>是否换标</span><strong>{{ selected.order.needRelabel ? '需要换标' : '否' }}</strong></div>
<div><span>客户参考号</span><strong>{{ selected.order.customerReferenceNo || '—' }}</strong></div>
<div><span>海外仓联系人</span><strong>{{ selected.order.warehouseContactName || '—' }}</strong></div>
<div><span>联系电话</span><strong>{{ selected.order.warehouseContactPhone || '—' }}</strong></div>
<div><span>创建时间</span><strong>{{ formatDateTime(selected.order.createdAt) }}</strong></div>
</div>
<div v-if="selected.remark" class="return-detail-remark"><span>备注</span><strong>{{ selected.remark }}</strong></div>
</section>
<section class="return-detail-section">
<h3>预报与实收</h3>
<DataTable :data="selected.items" :columns="itemColumns" row-key="skuCode" empty-text="暂无 SKU 明细" />
</section>
<section class="return-detail-section">
<h3>签收批次</h3>
<DataTable :data="selected.receipts" :columns="receiptColumns" row-key="receiptNo" empty-text="暂无签收批次">
<template #trackingNo="{ row }">{{ row.trackingNo || '无快递单号' }}</template>
<template #startedBy="{ row }">{{ startedByLabel(row.startedBy) }}</template>
<template #signedAt="{ row }">{{ formatDateTime(row.signedAt) }}</template>
</DataTable>
</section>
</template>
</article>
</section>
</template>
<style scoped>
.return-orders-page { padding: 20px; } .page-heading { display:flex; justify-content:space-between; align-items:flex-start; gap:16px; margin-bottom:18px; } h2 { margin:0; font-size:22px; } p { margin:6px 0 0; color:var(--el-text-color-secondary); } .filter-bar { display:grid; grid-template-columns:repeat(4,minmax(120px,1fr)) auto; gap:10px; margin-bottom:14px; } .return-create-form { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); column-gap:20px; } .return-form-wide { grid-column:1 / -1; } .items-title { display:flex; justify-content:space-between; align-items:center; margin:16px 0 4px; font-weight:600; } .items-hint { margin-bottom:10px; color:var(--el-text-color-secondary); font-size:12px; } .el-pagination { margin-top:16px; justify-content:flex-end; } h3 { margin:24px 0 10px; font-size:16px; } h4 { margin:16px 0 8px; font-size:14px; } .remark { white-space:pre-wrap; } .warehouse-error, .duplicate-sku-error { margin-top:6px; color:var(--el-color-danger); font-size:12px; } :deep(.duplicate-sku-input .el-input__wrapper) { box-shadow:0 0 0 1px var(--el-color-danger) inset; } :deep(.el-dialog .el-select) { width:100%; }
@media (max-width: 760px) { .return-orders-page { padding:12px; } .page-heading { align-items:stretch; flex-direction:column; } .filter-bar { grid-template-columns:1fr 1fr; } }
.return-orders-page { display:grid; gap:12px; } .return-create-form { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); column-gap:20px; } .return-form-wide { grid-column:1 / -1; } .items-title { display:flex; justify-content:space-between; align-items:center; margin:16px 0 4px; font-weight:600; } .items-hint { margin-bottom:10px; color:var(--el-text-color-secondary); font-size:12px; } h3 { margin:24px 0 10px; font-size:16px; } h4 { margin:16px 0 8px; font-size:14px; } .remark { white-space:pre-wrap; } .warehouse-error, .duplicate-sku-error { margin-top:6px; color:var(--el-color-danger); font-size:12px; } :deep(.duplicate-sku-input .el-input__wrapper) { box-shadow:0 0 0 1px var(--el-color-danger) inset; } :deep(.el-dialog .el-select) { width:100%; }
.return-table-card { overflow:hidden; border:1px solid var(--client-border); border-radius:var(--client-radius); background:#fff; box-shadow:none; }
.return-table-card :deep(.el-table) { width:100%; }
.return-pagination { display:flex; justify-content:flex-end; padding:14px 18px; border-top:1px solid #eef1f5; }
.return-pagination :deep(.el-pagination) { margin:0; }
.return-pagination-total { color:#526176; font-size:14px; }
.return-page-size-select { width:112px; margin-left:8px; }
.return-page-size-select :deep(.el-input__inner), .return-page-size-select :deep(.el-select__selected-item) { color:#526176 !important; opacity:1 !important; -webkit-text-fill-color:#526176; }
.return-detail-surface { overflow:hidden; min-height:240px; border:1px solid var(--client-border); border-radius:var(--client-radius); background:#fff; }
.return-detail-header { display:grid; grid-template-columns:auto minmax(0,1fr) auto; align-items:center; gap:14px; min-height:64px; padding:10px 16px; border-bottom:1px solid var(--client-border); }
.return-back { display:inline-flex; align-items:center; gap:5px; padding:5px 0; border:0; background:transparent; color:var(--el-color-primary); cursor:pointer; }
.return-detail-title { display:flex; min-width:0; flex-direction:column; gap:3px; }
.return-detail-title strong { overflow:hidden; color:#1f2d45; font-size:18px; text-overflow:ellipsis; white-space:nowrap; }
.return-detail-title span { color:#7c899c; font-size:12px; }
.return-detail-section + .return-detail-section { border-top:1px solid var(--client-border); }
.return-detail-section > h3 { min-height:40px; margin:0; padding:11px 16px 9px; color:#26364f; font-size:14px; }
.return-detail-grid { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); border-top:1px solid #edf1f5; }
.return-detail-grid div { display:flex; min-width:0; min-height:48px; align-items:center; gap:10px; padding:8px 14px; border-right:1px solid #edf1f5; border-bottom:1px solid #edf1f5; }
.return-detail-grid span,.return-detail-remark span { flex:0 0 92px; color:#8490a1; font-size:12px; }
.return-detail-grid strong,.return-detail-remark strong { min-width:0; overflow-wrap:anywhere; color:#2c3c55; font-size:13px; }
.return-detail-remark { display:flex; align-items:flex-start; gap:10px; padding:10px 14px; background:#f8fafc; }
.return-detail-section :deep(.data-table) { border:0; border-radius:0; }
@media (max-width: 760px) { .return-create-form { grid-template-columns:1fr; } }
@media (max-width: 1100px) { .return-detail-grid { grid-template-columns:repeat(2,minmax(0,1fr)); } }
@media (max-width: 720px) { .return-detail-header { grid-template-columns:1fr auto; } .return-back { grid-column:1/-1; } .return-pagination { padding:12px; } }
</style>
File diff suppressed because it is too large Load Diff
@@ -6,6 +6,17 @@ const view = readFileSync(new URL('./WarehouseServiceView.vue', import.meta.url)
const api = readFileSync(new URL('../api/warehouseServices.ts', import.meta.url), 'utf8')
const warehouseApi = readFileSync(new URL('../../first-leg/api/firstLeg.ts', import.meta.url), 'utf8')
test('总文件可以独立清除并重选同名文件,提交时禁止清除', () => {
assert.match(view, /@click="clearCreateTotalFile\('CARTON_TOTAL'\)"/)
assert.match(view, /@click="clearCreateTotalFile\('SKU_TOTAL'\)"/)
const clear = view.slice(view.indexOf('function clearCreateTotalFile'), view.indexOf('function chooseCreateTotalFile'))
assert.match(clear, /if \(saving.value\) return/)
assert.match(clear, /file.value = null/)
assert.match(clear, /input.value.value = ''/)
assert.match(view, /aria-label="清除箱唛总文件" :disabled="saving"/)
assert.match(view, /aria-label="清除 SKU 总文件" :disabled="saving"/)
})
test('贴标申请从当前客户仍有库存的历史入仓单选择来源', () => {
assert.match(view, /海外操作仓(中转仓)/)
assert.match(view, /选择有库存的历史入仓单/)
@@ -38,8 +49,10 @@ test('贴标申请保留关联模板下载和上传入口', () => {
assert.match(view, /importRelabelTemplate/)
assert.match(view, /明细行标识/)
assert.match(view, /'明细行标识', '旧箱唛', '旧SKU标签', '新SKU标签'/)
assert.match(view, /'旧箱唛', '新FBA箱唛', 'FBA目的仓'/)
assert.match(view, /SKU 与箱号关联通过旧箱唛合并/)
assert.match(view, /const includeDestination = false/)
assert.doesNotMatch(view, /const includeDestination = props\.service !== 'transfer'/)
assert.match(view, /SKU 与箱号关联通过仓库唛头合并/)
assert.match(view, /<th>仓库唛头 \*<\/th>/)
assert.doesNotMatch(view, /库存位置ID/)
})
@@ -80,7 +93,7 @@ test('贴标货物明细通过单一换标类型选择三种模式', () => {
assert.doesNotMatch(view, /el-tab-pane label="(?:箱唛换标|SKU 换标)"/)
assert.match(view, /interface SkuRelabelRow/)
assert.match(view, /旧 SKU \*/)
assert.match(view, /旧箱唛 \*/)
assert.match(view, /仓库唛头 \*/)
assert.match(view, /换标数量(件)\*/)
assert.match(view, /旧SKU标签/)
assert.match(view, /新SKU标签/)
@@ -102,26 +115,28 @@ test('箱唛相关换标必须选择四选一派送方式', () => {
assert.match(view, /labelingMode\.value !== 'sku' && !form\.deliveryMethod/)
})
test('一次申请单选入仓单且每项保留原库存归属', () => {
test('一次申请单选库存来源且保留普通入仓或退货合箱归属', () => {
assert.doesNotMatch(view, /multiple filterable collapse-tags/)
assert.match(view, /String\(candidate\.inboundOrderId\) === inboundOrderId/)
assert.match(view, /RETURN_CARTON:\$\{item\.returnCartonId\}/)
assert.match(view, /sourceInventoryBalanceId: String\(candidate\.inventoryBalanceId\)/)
assert.match(view, /sourceInventoryPositionId: String\(position\.positionId\)/)
assert.match(view, /sourceInboundOrderId: String\(candidate\.inboundOrderId\)/)
assert.match(api, /sourceInventoryBalanceId: string; sourceInventoryPositionId: string; sourceInboundOrderId: string/)
assert.match(view, /returnCartonId: String\(candidate\.returnCartonId\)/)
assert.match(api, /sourceInventoryBalanceId\?: string; sourceInventoryPositionId\?: string; sourceInboundOrderId\?: string; returnCartonId\?: string/)
})
test('海外操作仓和FBA目的仓使用独立字段', () => {
assert.match(api, /operationWarehouseId: string/)
assert.match(api, /destinationFbaWarehouseCode\?: string/)
assert.match(view, /operationWarehouseId: form\.warehouse/)
assert.match(view, /destinationFbaWarehouseCode: item\.destinationWarehouseId/)
assert.match(view, /<th>FBA 目的仓<\/th>/)
assert.match(view, /destinationFbaWarehouseCode: form\.destination/)
assert.match(view, /整张贴标申请只能选择一个目的仓/)
assert.doesNotMatch(view, /v-model="row\.destinationWarehouseId"[^\n]+请选择 FBA 目的仓/)
})
test('操作仓和FBA仓均从商户仓库目录按类型加载', () => {
assert.match(view, /getMerchantWarehouses\(\{ warehouseType: 'FBA' \}\)/)
assert.match(view, /getMerchantWarehouses\(\{ warehouseType: 'OVERSEAS' \}\)/)
assert.match(view, /loadAllWarehouseRecords\('FBA', getMerchantWarehouses\)/)
assert.match(view, /\['OVERSEAS', 'OVERSEAS_PARTNER'\]/)
assert.match(view, /v-for="warehouse in operationWarehouses"/)
assert.match(view, /v-for="warehouse in fbaWarehouses"/)
assert.doesNotMatch(view, /美西海外仓-ONT8/)
@@ -147,3 +162,22 @@ test('贴标详情展示箱唛、SKU 子明细和独立FBA目的仓', () => {
assert.match(view, /item\.operationType === 'CARTON_AND_SKU'/)
assert.match(view, /item\.skuItems\?\.length/)
})
test('客户在换标列表确认完工且照片使用二进制下载', () => {
assert.match(view, /item\.__status === 'CUSTOMER_CONFIRM_PENDING'/)
assert.match(view, /confirmRelabelFromList\(item\['0'\]\)/)
assert.doesNotMatch(view, /selectedRelabelDetail\?\.application\.status === 'CUSTOMER_CONFIRM_PENDING'/)
assert.match(api, /overseasBlobRequest/)
})
test('客户详情进入页面后直接回显完工照片并支持大图预览', () => {
assert.match(view, /void loadCompletionPhotos\(\)/)
assert.match(view, /class="warehouse-photo-grid"/)
assert.match(view, /<el-image v-if="completionPhotoUrls\[fileId\]"/)
assert.match(view, /:preview-src-list="completionPhotoPreviewUrls"/)
assert.doesNotMatch(view, />照片 \{\{ index \+ 1 \}\}<\/el-button>/)
})
test('进入复核后客户订单回显换标完成状态', () => {
assert.match(view, /\['CUSTOMER_CONFIRM_PENDING', 'READY_FOR_OUTBOUND', 'COMPLETED'\]\.includes\(value\) \? '换标完成'/)
})
@@ -0,0 +1,12 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
const view = readFileSync(new URL('./WarehouseServiceView.vue', import.meta.url), 'utf8')
test('货物废弃先选择海外操作仓,再加载并选择该仓入库单', () => {
assert.match(view, /props\.service === 'disposal'.*操作仓.*v-model="form\.warehouse".*请选择海外仓.*@change="selectDiscardWarehouse"/s)
assert.match(view, /props\.service === 'disposal'.*选择入库单.*:disabled="!form\.warehouse".*请先选择操作仓/s)
assert.match(view, /async function selectDiscardWarehouse\(\).*selectedDiscardOrderId\.value = ''.*cargoRows\.value = \[\].*listDiscardCandidates\(\).*candidate\.overseasWarehouseId.*form\.warehouse/s)
assert.match(view, /\|\| !primaryWarehouse \|\| invalidCargo/)
})
@@ -13,20 +13,46 @@ test('仓储转运先选择入仓方式并按方式切换地址信息', () => {
assert.match(view, /getMerchantWarehouseDetail\(form\.warehouse\)/)
})
test('仓储转运允许暂未确定 FBA 去向并显式选择是否换标', () => {
assert.match(view, /是否已有 FBA 去向/)
assert.match(view, /:value="false">暂未确定/)
assert.match(view, /transferHasFbaDestination.*目的 FBA 仓/s)
test('仓储转运支持无目的地、FBA 仓和私人仓并显式选择是否换标', () => {
assert.match(view, /是否有目的去向/)
assert.match(view, /value="NONE">暂未确定/)
assert.match(view, /value="FBA">FBA 仓/)
assert.match(view, /value="PRIVATE">私人仓/)
assert.match(view, /transferDestinationType === 'PRIVATE'.*私人仓地址.*邮编.*私人仓联系人.*联系电话/s)
assert.match(view, /是否需要换标/)
assert.match(view, /:value="false">不需要换标/)
assert.match(view, /:value="true">需要换标/)
assert.match(api, /fbaWarehouseCode\?: string/)
assert.match(api, /privateDestinationAddress\?: string; privateDestinationPostalCode\?: string/)
assert.match(api, /fbaShipmentNo\?: string/)
assert.doesNotMatch(view, /换标费用(CNY)/)
assert.doesNotMatch(view, /relabelChargeAmount:/)
})
test('仓储转运换标整票只允许一个目的仓', () => {
assert.match(view, /props\.service === 'transfer' && transferDestinationType === 'FBA'[^\n]+未换标货物目的 FBA 仓/)
assert.match(view, /此处只表示不换标箱子的去向/)
assert.match(view, /FBA 货件编号<\/span><el-input v-model="form\.fbaShipmentNo" placeholder="选填,可后续补充"/)
assert.match(view, /transferDestinationType\.value === 'FBA' && !form\.destination/)
assert.match(view, /transferDestinationType\.value === 'PRIVATE'.*!form\.privateDestinationPostalCode/)
assert.match(view, /整票换标货物目的 FBA 仓/)
assert.match(view, /暂不支持一票多个目的仓/)
assert.match(view, /if \(!transferRelabelDefaultDestination\.value\) \{ ElMessage\.warning\('请选择整票换标货物的 FBA 目的仓'\)/)
assert.match(view, /destinationWarehouseCode: transferRelabelDefaultDestination\.value \|\| undefined/)
const transferRelabelSection = view.split('warehouse-transfer-relabel">')[1]?.split('warehouse-transfer-submit-bar')[0] || ''
assert.doesNotMatch(transferRelabelSection, /v-model="row\.destinationWarehouseId"/)
assert.doesNotMatch(transferRelabelSection, /<th>FBA 目的仓 \*<\/th>/)
assert.doesNotMatch(view, /applyTransferRelabelDestination/)
assert.match(view, /destinationWarehouseId: binding\.destinationWarehouseCode \|\| ''/)
assert.match(view, /props\.service === 'transfer' \? '客户箱唛号' : '仓库唛头'/)
assert.match(view, /已导入 \$\{importedRows\.length\} 条仓储转运换标明细/)
assert.doesNotMatch(transferRelabelSection, /新 FBA 箱唛<\/th><th>FBA 目的仓<\/th>/)
assert.match(view, /const includeDestination = false/)
})
test('仓储转运不要求客户填写内部入仓号和我方箱唛,但换标时填写客户箱唛', () => {
const transferConfig = view.match(/transfer:\s*\{[^\n]+/)?.[0] || ''
const transferSubmit = view.match(/await createTransfer\(\{[^\n]+/)?.[0] || ''
const transferSubmit = view.match(/const payload: TransferCreate = \{[^\n]+/)?.[0] || ''
assert.doesNotMatch(transferConfig, /进仓编号/)
assert.doesNotMatch(transferSubmit, /inboundCode|sourceLastlegOrderIds/)
assert.match(view, /货物明细/)
@@ -45,24 +71,121 @@ test('仓储转运不要求客户填写内部入仓号和我方箱唛,但换
assert.match(view, /v-else class="warehouse-table-scroll"/)
assert.match(view, /添加 SKU 明细/)
assert.match(view, /绑定明细/)
assert.match(view, /旧 SKU \*.*新 SKU \*.*换标数量(件)\*.*新 FBA 箱唛 \*.*FBA 目的仓 \*/)
assert.match(view, /旧 SKU \*.*新 SKU \*.*换标数量(件)\*.*新 FBA 箱唛 \*.*备注/)
assert.match(transferSubmit, /fbaBoxNos: customerCartonMarks/)
assert.match(transferSubmit, /shippingMark: transferCustomerMarks\(item\)/)
assert.match(transferSubmit, /relabelItems: form\.relabel/)
assert.match(view, /props\.service === 'disposal' \? '本次废弃箱数' : '预计箱数'/)
assert.doesNotMatch(view, /props\.service === 'transfer'.*FNSKU \/ 箱唛/)
assert.match(view, /<th v-if="props\.service === 'relabel'">FNSKU \/ 箱唛<\/th>/)
assert.doesNotMatch(view, /<th v-if="props\.service === 'transfer'">FNSKU \/ 箱唛<\/th>/)
})
test('上门提货不提交物流号,需要换标时随转运单登记申请', () => {
assert.match(view, /trackingNo:\s*form\.deliveryMethod === 'CUSTOMER_DELIVERY'\s*\?[^:]+:\s*undefined/)
assert.match(view, /本次提交将同时登记换标申请/)
assert.match(view, /form\.deliveryMethod === 'CUSTOMER_DELIVERY'.*物流跟踪号/s)
assert.match(view, /提货联系人/)
assert.match(view, /联系人电话/)
assert.match(view, /换标联系人/)
assert.match(view, /form\.pickupContact/)
assert.match(view, /form\.pickupPhone/)
assert.match(view, /form\.relabelContact/)
assert.match(view, /form\.relabelPhone/)
assert.match(view, /选择海外仓后自动带出,也可手动填写/)
assert.match(view, /pickupContactName: form\.deliveryMethod === 'WAREHOUSE_PICKUP' \? form\.pickupContact : undefined/)
assert.match(view, /pickupContactPhone: form\.deliveryMethod === 'WAREHOUSE_PICKUP' \? form\.pickupPhone : undefined/)
assert.match(view, /relabelContactName: form\.relabel \? form\.relabelContact/)
assert.match(api, /pickupContactName\?: string; pickupContactPhone\?: string/)
})
test('仓储转运填写预计发货时间并只读展示实际发货时间', () => {
test('仓储转运列表操作仓使用海外仓接口数据', () => {
assert.match(view, /loadAllWarehouseRecords\('OVERSEAS', getMerchantWarehouses\)/)
assert.match(view, /warehouseDisplay\(item\.operatingWarehouseId\)/)
assert.match(view, /\['OVERSEAS', 'OVERSEAS_PARTNER'\]/)
assert.match(view, /props\.service === 'relabel' \? fbaWarehouses\.value : operationWarehouses\.value/)
assert.doesNotMatch(view, /美西洛杉矶仓/)
})
test('仓储转运预计发货时间可选并只读展示实际发货时间', () => {
assert.match(view, /预计发货时间/)
assert.match(view, /v-model="form\.estimatedShipAt"/)
assert.match(view, /选填,可线下确认后补充/)
assert.doesNotMatch(view, /!form\.estimatedShipAt && '预计发货时间'/)
assert.match(view, /estimatedShipAt: form\.estimatedShipAt/)
assert.match(view, /实际发货时间由仓库出库或 PDA 完成后回填/)
assert.match(api, /estimatedShipAt\?: string/)
})
test('客户仓储转运展示每个实际流转状态的中文名称', () => {
assert.match(view, /label: '草稿', value: 'DRAFT'/)
assert.match(view, /label: '待审核', value: 'PENDING_REVIEW'/)
assert.match(view, /label: '入库测量中', value: 'INBOUND_MEASURING'/)
assert.match(view, /label: '待客户确认实测', value: 'WAIT_CUSTOMER_CONFIRMATION'/)
assert.match(view, /label: '待确认目的地', value: 'WAIT_FBA_DESTINATION'/)
assert.match(view, /label: '待排车', value: 'READY_FOR_DISPATCH'/)
assert.match(view, /WAIT_OUTBOUND: '待出库'/)
assert.match(view, /OUTBOUNDED: '已出库'/)
})
test('PDA 打托完成后客户可从列表或详情确认并查看实测数据', () => {
assert.match(view, /item\.__status === 'WAIT_CUSTOMER_CONFIRMATION'/)
assert.match(view, /confirmTransferFromList\(item\['0'\]\)/)
assert.match(view, /selectedDetail\?\.status === 'WAIT_CUSTOMER_CONFIRMATION'/)
assert.match(view, /PDA 打托实测数据/)
for (const label of ['托盘号', '库位', '箱数', '长 × 宽 × 高(cm)', '毛重(kg)', '体积(m³)', '完成时间']) assert.equal(view.includes(label), true)
assert.match(view, /pallet\.warehouseLocationCode/)
assert.match(view, /pallet\.cartonCount/)
assert.match(view, /pallet\.grossWeightKg/)
assert.match(view, /pallet\.volumeCbm/)
assert.doesNotMatch(view, /pallet\.items/)
})
test('仓储转运详情自动加载 PDA 测量照片并释放 Blob URL', () => {
assert.match(api, /downloadTransferMeasurementPhoto/)
assert.match(api, /measurement-photos\/files\/\$\{encodeURIComponent\(fileId\)\}\/download/)
assert.match(view, /measurementPhotos\?: TransferMeasurementPhoto\[\]/)
assert.match(view, /fileId: string \| number/)
assert.match(view, /if \(props\.service === 'transfer'\) void loadMeasurementPhotos\(\)/)
assert.match(view, /测量打托现场照片/)
assert.match(view, /:preview-src-list="measurementPhotoPreviewUrls"/)
assert.match(view, /加载失败,点击重试/)
assert.match(view, /URL\.createObjectURL\(blob\)/)
assert.match(view, /URL\.revokeObjectURL\(url\)/)
assert.match(view, /onBeforeUnmount\(\(\) => \{ clearCompletionPhotos\(\); clearMeasurementPhotos\(\) \}\)/)
})
test('PDA 完成后主单显示入库完成并保留客户确认测量入口', () => {
assert.match(view, /item\.__status === 'WAIT_CUSTOMER_CONFIRMATION'.*确认测量/s)
assert.match(view, /selectedDetail\?\.status === 'WAIT_CUSTOMER_CONFIRMATION'.*确认测量数据/s)
assert.doesNotMatch(view, /确认实测并入库/)
})
test('仓储转运可在新建页直接提交,也可保存草稿后编辑提交', () => {
assert.match(api, /function submitTransfer\(transferNo: string\)/)
assert.match(api, /function updateTransfer\(transferNo: string, payload: TransferCreate\)/)
assert.match(api, /warehouse-transfers\/\$\{encodeURIComponent\(transferNo\)\}\/save-draft/)
assert.match(api, /warehouse-transfers\/\$\{encodeURIComponent\(transferNo\)\}\/submit/)
assert.match(view, /submit\('draft'\).*保存草稿/s)
assert.match(view, /async function openEdit\(transferNo: string\)/)
assert.match(view, /item\.__status === 'DRAFT'.*openEdit\(item\['0'\]\)/s)
assert.match(view, /v-if="props\.service === 'transfer'"[^>]*type="primary"[^>]*native-type="submit"[^>]*>提交订单/)
assert.match(view, /await updateTransfer\(editingTransferNo\.value, payload\)/)
assert.match(view, /await submitTransfer\(transferNo\)/)
assert.match(view, /<form [^>]*@submit\.prevent="submit\('submit'\)"/)
assert.match(view, /selectedDetail\?\.status !== 'DRAFT'[^>]*[\s\S]*?printTransferCartons[^>]*[\s\S]*?打印箱唛/)
assert.doesNotMatch(view, /row\.__status !== 'DRAFT'.*printTransferCartons\(row\['0'\]\)/s)
assert.doesNotMatch(view, /selectedDetail\?\.status === 'DRAFT'[^>]*[\s\S]*?>提交订单/)
assert.match(view, /native-type="button"[^>]*:loading="saving"[^>]*@click="submit\('draft'\)"/)
assert.match(view, /DRAFT: '草稿'/)
assert.doesNotMatch(view, /<h3>箱唛明细<\/h3>/)
})
test('仓储转运基础货物不强制 SKU 和中文品名,只换箱唛不强制旧 SKU', () => {
assert.match(view, /商品 SKU\{\{ props\.service === 'transfer' \? '' : ' \*' \}\}/)
assert.match(view, /中文品名\{\{ props\.service === 'transfer' \? '' : ' \*' \}\}/)
assert.match(view, /props\.service === 'transfer' \? '选填' : '请输入商品 SKU'/)
assert.match(view, /v-if="labelingMode === 'carton'"[^\n]+<th>旧 SKU<\/th>/)
assert.match(view, /labelingMode\.value !== 'carton' && \(!row\.sku\.trim\(\) \|\| !row\.newSkuCode\.trim\(\)\)/)
assert.match(api, /skuCode\?: string; productNameZh\?: string/)
assert.match(api, /originalSku\?: string/)
})
@@ -93,9 +93,19 @@ test('shares the merchant permission map and preserves resources not rendered by
assert.match(contractsSource, /'company:member:create': \['merchant:member:create'\]/)
for (const permission of [
'merchant:warehouse:inbound:list',
'merchant:cross-border:procurement',
'merchant:cross-border:domestic-trucking',
'merchant:cross-border:pallet-shipping',
'merchant:cross-border:procurement:list',
'merchant:cross-border:procurement:create',
'merchant:cross-border:domestic-trucking:list',
'merchant:cross-border:domestic-trucking:create',
'merchant:cross-border:domestic-trucking:edit',
'merchant:cross-border:pallet-shipping:list',
'merchant:cross-border:pallet-shipping:create',
'merchant:cross-border:pallet-shipping:edit',
'merchant:overseas-warehouse:return-inventory:list',
'merchant:overseas-warehouse:relabel:list',
'merchant:overseas-warehouse:transfer:list',
'merchant:overseas-warehouse:discard:list',
'merchant:overseas-warehouse:return-order:list',
]) {
assert.match(contractsSource, new RegExp(`'${permission}': \\['${permission}'\\]`))
}
+58 -3
View File
@@ -58,6 +58,42 @@ export const TENANT_PERMISSION_GROUPS: readonly TenantPermissionGroup[] = [
{ code: 'merchant:warehouse:delivery:create', label: '创建派送单', description: '从排车单创建海外仓派送单' },
],
},
{
key: 'overseas-warehouse',
label: '海外仓业务',
items: [
{ code: 'merchant:warehouse:inbound:list', label: '查看海外入库单', description: '查看本企业海外仓入库订单列表和详情' },
{ code: 'merchant:overseas-warehouse:last-mile-order:list', label: '查看海外尾程订单', description: '查看本企业尾程订单列表和详情' },
{ code: 'merchant:overseas-warehouse:relabel:list', label: '查看贴标申请', description: '查看本企业贴标申请列表和详情' },
{ code: 'merchant:overseas-warehouse:relabel:create', label: '创建贴标申请', description: '使用本企业可用库存创建贴标申请' },
{ code: 'merchant:overseas-warehouse:relabel:files', label: '管理贴标文件', description: '上传本企业贴标申请的面单和明细文件' },
{ code: 'merchant:overseas-warehouse:relabel:confirm', label: '确认贴标完成', description: '确认仓库完成的贴标结果并继续出库' },
{ code: 'merchant:overseas-warehouse:transfer:list', label: '查看仓储转运', description: '查看本企业仓储转运单列表和详情' },
{ code: 'merchant:overseas-warehouse:transfer:create', label: '创建仓储转运', description: '创建和提交本企业仓储转运单' },
{ code: 'merchant:overseas-warehouse:transfer:confirm', label: '确认转运实测', description: '确认仓库回传的箱数、尺寸、重量和打托数据' },
{ code: 'merchant:overseas-warehouse:discard:list', label: '查看货物废弃', description: '查看本企业货物废弃申请列表和详情' },
{ code: 'merchant:overseas-warehouse:discard:create', label: '创建货物废弃', description: '使用本企业可用库存创建废弃申请' },
{ code: 'merchant:overseas-warehouse:discard:cancel', label: '取消货物废弃', description: '取消本企业尚未审核的废弃申请' },
{ code: 'merchant:overseas-warehouse:return-order:list', label: '查看退货预报', description: '查看本企业退货预报和仓库签收结果' },
{ code: 'merchant:overseas-warehouse:return-order:create', label: '创建退货预报', description: '创建本企业退货 SKU 和数量预报' },
{ code: 'merchant:overseas-warehouse:return-order:relabel', label: '退货转换标', description: '将已签收退货库存转换为换标申请' },
{ code: 'merchant:overseas-warehouse:return-inventory:list', label: '查看退货库存', description: '查看本企业退货 SKU 库存和箱号明细' },
],
},
{
key: 'cross-border',
label: '海外业务',
items: [
{ code: 'merchant:cross-border:procurement:list', label: '查看海外采购', description: '查看本企业海外采购订单列表和详情' },
{ code: 'merchant:cross-border:procurement:create', label: '创建海外采购', description: '创建本企业海外采购订单' },
{ code: 'merchant:cross-border:domestic-trucking:list', label: '查看美国本土卡', description: '查看本企业美国本土卡订单' },
{ code: 'merchant:cross-border:domestic-trucking:create', label: '创建美国本土卡', description: '创建本企业美国本土卡订单' },
{ code: 'merchant:cross-border:domestic-trucking:edit', label: '编辑美国本土卡', description: '编辑本企业允许修改的美国本土卡订单' },
{ code: 'merchant:cross-border:pallet-shipping:list', label: '查看美本土散板', description: '查看本企业美本土散板订单' },
{ code: 'merchant:cross-border:pallet-shipping:create', label: '创建美本土散板', description: '创建本企业美本土散板订单' },
{ code: 'merchant:cross-border:pallet-shipping:edit', label: '编辑美本土散板', description: '编辑本企业允许修改的美本土散板订单' },
],
},
{
key: 'company',
label: '企业资料',
@@ -107,9 +143,28 @@ export const FRONTEND_TO_MERCHANT_PERMISSION_CODES: Readonly<Record<string, read
'merchant:warehouse:delivery:create': ['merchant:warehouse:delivery:create'],
'merchant:warehouse:inbound:list': ['merchant:warehouse:inbound:list'],
'merchant:overseas-warehouse:return-inventory:list': ['merchant:overseas-warehouse:return-inventory:list'],
'merchant:cross-border:procurement': ['merchant:cross-border:procurement'],
'merchant:cross-border:domestic-trucking': ['merchant:cross-border:domestic-trucking'],
'merchant:cross-border:pallet-shipping': ['merchant:cross-border:pallet-shipping'],
'merchant:overseas-warehouse:last-mile-order:list': ['merchant:overseas-warehouse:last-mile-order:list'],
'merchant:overseas-warehouse:relabel:list': ['merchant:overseas-warehouse:relabel:list'],
'merchant:overseas-warehouse:relabel:create': ['merchant:overseas-warehouse:relabel:create'],
'merchant:overseas-warehouse:relabel:files': ['merchant:overseas-warehouse:relabel:files'],
'merchant:overseas-warehouse:relabel:confirm': ['merchant:overseas-warehouse:relabel:confirm'],
'merchant:overseas-warehouse:transfer:list': ['merchant:overseas-warehouse:transfer:list'],
'merchant:overseas-warehouse:transfer:create': ['merchant:overseas-warehouse:transfer:create'],
'merchant:overseas-warehouse:transfer:confirm': ['merchant:overseas-warehouse:transfer:confirm'],
'merchant:overseas-warehouse:discard:list': ['merchant:overseas-warehouse:discard:list'],
'merchant:overseas-warehouse:discard:create': ['merchant:overseas-warehouse:discard:create'],
'merchant:overseas-warehouse:discard:cancel': ['merchant:overseas-warehouse:discard:cancel'],
'merchant:overseas-warehouse:return-order:list': ['merchant:overseas-warehouse:return-order:list'],
'merchant:overseas-warehouse:return-order:create': ['merchant:overseas-warehouse:return-order:create'],
'merchant:overseas-warehouse:return-order:relabel': ['merchant:overseas-warehouse:return-order:relabel'],
'merchant:cross-border:procurement:list': ['merchant:cross-border:procurement:list'],
'merchant:cross-border:procurement:create': ['merchant:cross-border:procurement:create'],
'merchant:cross-border:domestic-trucking:list': ['merchant:cross-border:domestic-trucking:list'],
'merchant:cross-border:domestic-trucking:create': ['merchant:cross-border:domestic-trucking:create'],
'merchant:cross-border:domestic-trucking:edit': ['merchant:cross-border:domestic-trucking:edit'],
'merchant:cross-border:pallet-shipping:list': ['merchant:cross-border:pallet-shipping:list'],
'merchant:cross-border:pallet-shipping:create': ['merchant:cross-border:pallet-shipping:create'],
'merchant:cross-border:pallet-shipping:edit': ['merchant:cross-border:pallet-shipping:edit'],
'company:profile:view': ['merchant:profile:view'],
'company:profile:edit': ['merchant:profile:edit'],
'company:qualification:upload': ['merchant:qualification:upload'],
+1
View File
@@ -26,6 +26,7 @@ interface ClientRouteBase {
description?: string
permission?: string
workspaceTab?: boolean
workspaceTitle?: string
keepAlive?: boolean
}
}
+24 -38
View File
@@ -28,57 +28,44 @@ function collectNavigationNodes(nodes) {
])
}
test('keeps the confirmed top-level sequence from the client workbench prototype', () => {
test('keeps account and notification routes as fixed-header utilities', () => {
assert.deepEqual(
clientNavigation.filter((node) => node.path).map(({ title, path, permission }) => ({
clientNavigation.filter((node) => node.hidden).map(({ title, path, permission }) => ({
title,
path,
permission,
})),
[
{ title: '业务总览', path: '/', permission: 'dashboard:view' },
{ title: '我的信息', path: '/account/profile', permission: 'account:profile:view' },
{ title: '我的消息', path: '/notifications', permission: 'merchant:notification:view' },
],
)
})
test('keeps only implemented navigation groups visible', () => {
test('organises every implemented business page under five task-oriented sidebar entries', () => {
assert.deepEqual(
clientNavigation.map((node) => ({
clientNavigation.filter((node) => !node.hidden).map((node) => ({
title: node.title,
children: node.children?.map(({ title }) => title),
})),
[
{ title: '业务总览', children: undefined },
{ title: '首页', children: undefined },
{
title: '头程物流',
children: ['订单列表', '快速下单'],
},
{
title: '出海下单',
children: ['海外采购', '美国本土卡', '美本土散板'],
title: '订单中心',
children: ['头程订单', '快速建草稿', '海外采购', '美国本土卡', '美本土散板', '入库订单', '派送订单'],
},
{
title: '海外仓',
children: ['贴标申请', '仓储转运', '货物废弃', '退货换标', '退货库存', '退货箱号管理'],
children: ['贴标申请', '仓储转运', '货物废弃', '退货预报与签收', '退货库存', '退货箱号管理'],
},
{
title: '订单管理',
children: ['入库订单列表', '派送订单列表'],
},
{
title: '财务结算',
title: '费用与账单',
children: ['资金账户', '客户账单'],
},
{
title: '企业管理',
title: '企业设置',
children: ['企业资料', '企业成员', '角色管理', '菜单权限'],
},
{ title: '我的信息', children: undefined },
{
title: '消息中心',
children: ['我的消息'],
},
],
)
})
@@ -103,14 +90,14 @@ test('flattens visible leaves with unique paths', () => {
'/cross-border/procurement',
'/cross-border/domestic-trucking',
'/cross-border/pallet-shipping',
'/orders/inbound',
'/orders/delivery',
'/overseas-warehouse/labeling',
'/overseas-warehouse/storage-transfer',
'/overseas-warehouse/cargo-disposal',
'/overseas-warehouse/return-relabel',
'/overseas-warehouse/return-inventory',
'/overseas-warehouse/return-cartons',
'/orders/inbound',
'/orders/delivery',
'/finance/account',
'/finance/bills',
'/company/profile',
@@ -127,8 +114,8 @@ test('flattens visible leaves with unique paths', () => {
test('filters inaccessible leaves and empty navigation groups', () => {
const visible = filterNavigationByPermissions(clientNavigation, ['account:profile:view', 'order:first-leg:list'])
assert.deepEqual(getNavigationLeaves(visible).map((item) => item.path), ['/orders/first-leg', '/account/profile'])
assert.deepEqual(visible.map((item) => item.title), ['头程物流', '我的信息'])
assert.deepEqual(getNavigationLeaves(visible).map((item) => item.path), ['/orders/first-leg'])
assert.deepEqual(visible.map((item) => item.title), ['订单中心'])
assert.equal(getFirstAccessiblePath(['account:profile:view']), '/account/profile')
assert.equal(getFirstAccessiblePath([]), '/forbidden')
})
@@ -142,8 +129,8 @@ test('excludes entries marked for manual hiding without affecting other visible
assert.deepEqual(getNavigationLeaves(visible).map((item) => item.path), ['/shown'])
})
test('puts enterprise management behind the prototype settings divider', () => {
const company = clientNavigation.find((node) => node.key === 'company')
test('puts enterprise settings behind the prototype settings divider', () => {
const company = clientNavigation.find((node) => node.key === 'company-center')
assert.ok(company)
assert.equal(company.dividerBefore, true)
@@ -202,8 +189,8 @@ test('returns navigation parent keys from outermost to innermost', () => {
]
assert.deepEqual(getNavigationParentKeys(clientNavigation, '/'), [])
assert.deepEqual(getNavigationParentKeys(clientNavigation, '/orders/first-leg'), ['first-leg'])
assert.deepEqual(getNavigationParentKeys(clientNavigation, '/organization/members'), ['company'])
assert.deepEqual(getNavigationParentKeys(clientNavigation, '/orders/first-leg'), ['order-center'])
assert.deepEqual(getNavigationParentKeys(clientNavigation, '/organization/members'), ['company-center'])
assert.deepEqual(getNavigationParentKeys(syntheticTree, '/nested/leaf'), ['level-one', 'level-two'])
assert.deepEqual(getNavigationParentKeys(clientNavigation, '/not-found'), [])
})
@@ -212,18 +199,17 @@ test('builds complete breadcrumbs without repeating the current navigation page'
assert.deepEqual(getRouteBreadcrumbs('/', '业务总览'), [{ title: '首页' }])
assert.deepEqual(getRouteBreadcrumbs('/orders/first-leg', '订单列表'), [
{ title: '首页', path: '/' },
{ title: '头程物流' },
{ title: '订单中心' },
{ title: '订单列表' },
])
assert.deepEqual(getRouteBreadcrumbs('/orders/first-leg/create', '创建订单'), [
{ title: '首页', path: '/' },
{ title: '头程物流' },
{ title: '订单列表', path: '/orders/first-leg' },
{ title: '订单中心' },
{ title: '头程订单', path: '/orders/first-leg' },
{ title: '创建订单' },
])
assert.deepEqual(getRouteBreadcrumbs('/notifications/NOTICE-001', '消息详情'), [
{ title: '首页', path: '/' },
{ title: '消息中心' },
{ title: '我的消息', path: '/notifications' },
{ title: '消息详情' },
])
@@ -233,12 +219,12 @@ test('builds complete breadcrumbs without repeating the current navigation page'
])
})
test('opens the company parent group for a company route', () => {
test('opens the enterprise settings parent group for a company route', () => {
const openedKeys = []
openNavigationParentKeys('/organization/members', clientNavigation, (key) => openedKeys.push(key))
assert.deepEqual(openedKeys, ['company'])
assert.deepEqual(openedKeys, ['company-center'])
})
test('opens nested parent groups from outermost to innermost', () => {
+77 -12
View File
@@ -1,3 +1,4 @@
import { ClipboardList, ReceiptText, Settings, Warehouse } from '@lucide/vue'
import { clientModuleRegistry } from '../modules/registry.ts'
import type { ClientModuleManifest } from '../modules/types.ts'
import {
@@ -15,7 +16,7 @@ export {
type ClientNavigationNode,
} from './tree.ts'
function buildModuleNavigation(module: ClientModuleManifest): ClientNavigationNode[] {
function buildModuleNavigationLeaves(module: ClientModuleManifest): ClientNavigationLeaf[] {
const leaves: ClientNavigationLeaf[] = []
for (const route of module.routes) {
if (route.release !== 'active' || !route.navigation) continue
@@ -27,18 +28,75 @@ function buildModuleNavigation(module: ClientModuleManifest): ClientNavigationNo
permission: route.meta.permission,
})
}
if (!module.navigationGroup) return leaves
if (leaves.length === 0) return []
return [{
key: module.key,
title: module.navigationGroup.title,
icon: module.navigationGroup.icon,
dividerBefore: module.navigationGroup.dividerBefore,
children: leaves as [ClientNavigationNode, ...ClientNavigationNode[]],
}]
return leaves
}
export const clientNavigation: ClientNavigationNode[] = clientModuleRegistry.flatMap(buildModuleNavigation)
const registeredLeaves = clientModuleRegistry.flatMap(buildModuleNavigationLeaves)
function requireLeaf(key: string, title?: string): ClientNavigationLeaf {
const leaf = registeredLeaves.find((item) => item.key === key)
if (!leaf) throw new Error(`Missing client navigation leaf: ${key}`)
return title ? { ...leaf, title } : leaf
}
/**
* The sidebar is organised around customer tasks instead of implementation modules.
* Account and notification routes remain hidden utility entries so breadcrumbs and
* permission-aware fallbacks keep working from the fixed top-header shortcuts.
*/
export const clientNavigation: ClientNavigationNode[] = [
requireLeaf('dashboard', '首页'),
{
key: 'order-center',
title: '订单中心',
icon: ClipboardList,
children: [
requireLeaf('first-leg-orders', '头程订单'),
requireLeaf('first-leg-quick-order', '快速建草稿'),
requireLeaf('cross-border-procurement'),
requireLeaf('cross-border-domestic-trucking'),
requireLeaf('cross-border-pallet-shipping'),
requireLeaf('orders-inbound', '入库订单'),
requireLeaf('orders-delivery', '派送订单'),
],
},
{
key: 'overseas-warehouse-center',
title: '海外仓',
icon: Warehouse,
children: [
requireLeaf('overseas-labeling'),
requireLeaf('overseas-storage-transfer'),
requireLeaf('overseas-cargo-disposal'),
requireLeaf('overseas-return-relabel', '退货预报与签收'),
requireLeaf('overseas-return-inventory'),
requireLeaf('overseas-return-cartons'),
],
},
{
key: 'finance-center',
title: '费用与账单',
icon: ReceiptText,
children: [
requireLeaf('finance-account'),
requireLeaf('finance-bills'),
],
},
{
key: 'company-center',
title: '企业设置',
icon: Settings,
dividerBefore: true,
children: [
requireLeaf('company-profile'),
requireLeaf('company-members'),
requireLeaf('company-roles'),
requireLeaf('company-permissions'),
],
},
{ ...requireLeaf('account-profile'), hidden: true },
{ ...requireLeaf('notifications-list'), hidden: true },
]
export function filterNavigationByPermissions(
nodes: readonly ClientNavigationNode[],
@@ -59,7 +117,11 @@ export function filterNavigationByPermissions(
}
export function getFirstAccessiblePath(permissions: readonly string[]) {
return getNavigationLeaves(filterNavigationByPermissions(clientNavigation, permissions))[0]?.path ?? '/forbidden'
const visiblePath = getNavigationLeaves(filterNavigationByPermissions(clientNavigation, permissions))[0]?.path
if (visiblePath) return visiblePath
const allowed = new Set(permissions)
return getNavigationLeaves(clientNavigation).find((leaf) => allowed.has(leaf.permission))?.path ?? '/forbidden'
}
export interface ClientBreadcrumbItem {
@@ -91,6 +153,9 @@ export function getRouteBreadcrumbs(path: string, currentTitle: string): ClientB
.sort((left, right) => right.path.length - left.path.length)
const parentLeaf = leaves[0]
const trail = parentLeaf ? findNavigationTrail(clientNavigation, parentLeaf.path) ?? [] : []
if (parentLeaf && path === parentLeaf.path && trail.length > 0) {
trail[trail.length - 1] = { ...trail[trail.length - 1], title: currentTitle }
}
const items = [home, ...trail]
if (items.at(-1)?.title !== currentTitle) items.push({ title: currentTitle })
if (items.length > 1) delete items.at(-1)!.path
@@ -116,7 +116,7 @@ export const useWorkspaceTabsStore = defineStore('client-workspace-tabs', () =>
const next: WorkspaceTab = {
key: routeName,
routeName,
title: String(route.meta.title || routeName),
title: String(route.meta.workspaceTitle || route.meta.title || routeName),
fullPath: route.fullPath,
permission: typeof route.meta.permission === 'string' ? route.meta.permission : null,
lastVisitedAt: Date.now(),
+1
View File
@@ -23,6 +23,7 @@ test('workspace tabs exclude public and forbidden routes and use one tab per rou
assert.match(storeSource, /const routeName = String\(route\.name\)/)
assert.match(storeSource, /key: routeName/)
assert.match(storeSource, /tabs\.value\.find\(\(tab\) => tab\.key === routeName\)/)
assert.match(storeSource, /title: String\(route\.meta\.workspaceTitle \|\| route\.meta\.title \|\| routeName\)/)
})
test('closing tabs invalidates their cached instance and returns the most recently visited fallback', () => {
+11 -4
View File
@@ -37,15 +37,22 @@
--el-button-hover-text-color: var(--client-action-hover);
--el-button-hover-border-color: #b2ccff;
--el-button-hover-bg-color: var(--client-hover-bg);
height: var(--client-control-height); border-radius: var(--client-radius); font-size: var(--client-font-size);
min-width: 68px; height: var(--client-control-height); padding: 0 12px; border-radius: var(--client-radius);
font-size: var(--client-font-size); font-weight: 600; line-height: 1; transition: color .16s ease, background-color .16s ease, border-color .16s ease, box-shadow .16s ease;
}
.client-ui .el-button--primary { --el-button-bg-color: var(--client-action); --el-button-border-color: var(--client-action); --el-button-text-color: #fff; --el-button-hover-bg-color: var(--client-action-hover); --el-button-hover-border-color: var(--client-action-hover); --el-button-hover-text-color: #fff; --el-button-active-bg-color: var(--client-action-hover); --el-button-active-border-color: var(--client-action-hover); }
.client-ui .el-button--small { min-width: 56px; height: var(--client-row-action-height); padding: 0 10px; font-size: 12px; }
.client-ui .el-button--primary { --el-button-bg-color: var(--client-action); --el-button-border-color: var(--client-action); --el-button-text-color: #fff; --el-button-hover-bg-color: var(--client-action-hover); --el-button-hover-border-color: var(--client-action-hover); --el-button-hover-text-color: #fff; --el-button-active-bg-color: var(--client-action-hover); --el-button-active-border-color: var(--client-action-hover); box-shadow: 0 1px 2px rgb(16 24 40 / 8%); }
.client-ui .el-button--success { --el-button-text-color: var(--client-success); --el-button-bg-color: var(--client-success-bg); --el-button-border-color: #abefc6; --el-button-hover-text-color: var(--client-success); --el-button-hover-bg-color: #dcfae6; }
.client-ui .el-button--warning { --el-button-text-color: var(--client-warning); --el-button-bg-color: var(--client-warning-bg); --el-button-border-color: #fedf89; --el-button-hover-text-color: var(--client-warning); --el-button-hover-bg-color: #fef0c7; }
.client-ui .el-button--danger { --el-button-text-color: var(--client-danger); --el-button-bg-color: var(--client-danger-bg); --el-button-border-color: #fecdca; --el-button-hover-text-color: var(--client-danger); --el-button-hover-bg-color: #fee4e2; }
.client-ui .el-button.is-link, .client-ui .el-button.is-text { --el-button-text-color: var(--client-action); --el-button-hover-text-color: var(--client-action-hover); }
.client-ui .el-button.is-link, .client-ui .el-button.is-text { --el-button-text-color: var(--client-action); --el-button-hover-text-color: var(--client-action-hover); min-width: 0; height: var(--client-row-action-height); padding: 0 4px; border-color: transparent; background: transparent; box-shadow: none; }
.client-ui .el-button.is-link.el-button--danger, .client-ui .el-button.is-text.el-button--danger { --el-button-text-color: var(--client-danger); --el-button-hover-text-color: #912018; border-color: transparent; background: transparent; }
.client-ui .el-button.is-link.el-button--success, .client-ui .el-button.is-text.el-button--success { --el-button-text-color: var(--client-success); --el-button-hover-text-color: #05603a; border-color: transparent; background: transparent; }
.client-ui .el-button.is-link.el-button--warning, .client-ui .el-button.is-text.el-button--warning { --el-button-text-color: var(--client-warning); --el-button-hover-text-color: #93370d; border-color: transparent; background: transparent; }
.client-ui .el-button.is-disabled { color: #98a2b3; background: #f9fafb; border-color: var(--client-border); }
.client-ui .el-button > span { gap: 4px; }
.client-ui .el-button.is-link.is-disabled, .client-ui .el-button.is-text.is-disabled { background: transparent; border-color: transparent; }
.client-ui .el-button > span { display: inline-flex; align-items: center; justify-content: center; gap: 5px; }
.client-ui .el-button svg { flex: 0 0 auto; width: 16px; height: 16px; stroke-width: 2; }
.client-ui .el-input__wrapper, .client-ui .el-select__wrapper { min-height: var(--client-control-height); font-size: var(--client-font-size); }
.client-ui .el-button:focus-visible { outline: 2px solid var(--client-action); outline-offset: 2px; }
.client-status { display: inline-flex; align-items: center; gap: 6px; min-height: 26px; padding: 3px 10px; border-radius: 999px; color: var(--client-info); background: var(--client-info-bg); font-size: 12px; font-weight: 600; line-height: 20px; white-space: nowrap; }
+2 -3
View File
@@ -149,11 +149,10 @@
.mobile-menu-open .client-sidebar { visibility: visible; pointer-events: auto; transform: translateX(0); transition-delay: 0s; }
.client-sidebar-scrim { position: fixed; inset: 0; z-index: 20; display: block; padding: 0; border: 0; background: rgba(7, 18, 34, .56); }
.client-header-menu-button { background: #eef3f8; color: #24324a; }
.client-sidebar-brand button { display: grid; width: 44px; height: 44px; flex: 0 0 44px; padding: 0; border: 0; border-radius: 8px; background: transparent; color: #fff; place-items: center; }
.client-sidebar-brand button { display: grid; width: 44px; height: 44px; flex: 0 0 44px; padding: 0; border: 0; border-radius: 8px; background: transparent; color: #526176; place-items: center; }
.client-main { width: 100%; max-width: 100%; margin-left: 0; padding: 136px 14px 30px; }
.client-header-menu-button { display: grid; }
.client-header-heading p, .client-user-copy { display: none; }
.client-header-heading h1 { font-size: 16px; }
.client-header-greeting, .client-user-copy { display: none; }
.client-user { margin-left: auto; }
.dashboard-load-error { grid-template-columns: 1fr; justify-items: start; }
.dashboard-load-error .el-button { width: 100%; min-height: 44px; }
+18 -17
View File
@@ -10,39 +10,40 @@ const mobileStyles = compact.slice(compact.indexOf('@media (max-width: 720px)'))
test('style source contract: constrains the desktop sidebar and gives the menu its own scroll region', () => {
assert.match(compact, /\.client-shell\s*\{[^}]*overflow-x:\s*hidden/)
assert.match(compact, /\.client-sidebar\s*\{[^}]*inset:\s*0 auto 0 0[^}]*width:\s*250px[^}]*overflow:\s*hidden[^}]*background:\s*linear-gradient\(180deg, #0b1f3a 0%, #0c2a52 100%\)/)
assert.match(compact, /\.top-header\s*\{[^}]*inset:\s*0 0 auto 250px[^}]*max-width:\s*calc\(100vw - 250px\)/)
assert.match(compact, /\.client-sidebar\s*\{[^}]*inset:\s*0 auto 0 0[^}]*width:\s*224px[^}]*overflow:\s*hidden[^}]*background:\s*linear-gradient\(180deg, #f5f8ff 0%, #edf5fc 100%\)/)
assert.match(compact, /\.top-header\s*\{[^}]*inset:\s*0 0 auto 224px[^}]*max-width:\s*calc\(100vw - 224px\)/)
assert.match(compact, /\.client-sidebar nav\s*\{[^}]*flex:\s*1[^}]*min-height:\s*0[^}]*overflow-y:\s*auto/)
assert.match(compact, /\.client-sidebar nav\s*\{[^}]*scrollbar-color:\s*rgba\(102, 126, 234, \.3\) transparent[^}]*scrollbar-width:\s*thin/)
assert.match(compact, /\.client-sidebar nav\s*\{[^}]*scrollbar-color:\s*#dce3ed transparent[^}]*scrollbar-width:\s*thin/)
assert.match(compact, /\.client-sidebar nav::\-webkit-scrollbar\s*\{[^}]*width:\s*10px[^}]*background:\s*transparent/)
assert.match(compact, /\.client-sidebar nav::\-webkit-scrollbar-track\s*\{[^}]*background:\s*transparent/)
assert.match(compact, /\.client-sidebar nav::\-webkit-scrollbar-thumb\s*\{[^}]*border:\s*3px solid transparent[^}]*background:\s*rgba\(102, 126, 234, \.3\)[^}]*background-clip:\s*padding-box/)
assert.match(compact, /\.client-sidebar nav::\-webkit-scrollbar-thumb\s*\{[^}]*border:\s*3px solid transparent[^}]*background:\s*#dce3ed[^}]*background-clip:\s*padding-box/)
assert.match(compact, /\.client-sidebar nav::\-webkit-scrollbar-button\s*\{[^}]*display:\s*none[^}]*width:\s*0[^}]*height:\s*0/)
assert.match(compact, /\.client-nav-menu,[^\{]*\.client-nav-submenu\s*\{[^}]*min-width:\s*0/)
assert.match(compact, /\.client-nav-menu\s*\{[^}]*flex:\s*1[^}]*background:\s*transparent/)
assert.match(compact, /\.client-sidebar-brand button,\s*\.client-sidebar-scrim\s*\{[^}]*display:\s*none/)
})
test('style source contract: presents native grouped rows matching the prototype active and divider states', () => {
test('style source contract: presents native grouped rows matching the blue 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*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*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,[^\{]*\.client-sidebar-link\s*\{[^}]*min-height:\s*46px[^}]*border-radius:\s*12px[^}]*color:\s*#64748b/)
assert.match(compact, /\.client-nav-submenu \.client-sidebar-link\[aria-current="page"\]\s*\{[^}]*background:\s*#eff6ff[^}]*color:\s*#2563eb/)
assert.match(compact, /\.client-nav-node\.is-active-parent > \.client-sidebar-group-trigger\s*\{[^}]*color:\s*#2563eb/)
assert.match(compact, /\.client-nav-node\.has-divider-before\s*\{[^}]*border-top:\s*1px solid #eef2f7/)
assert.match(compact, /\.client-nav-submenu\s*\{[^}]*margin:\s*3px 0 2px 15px[^}]*padding:\s*0 0 0 12px/)
assert.match(compact, /\.client-nav-submenu \.client-sidebar-link\s*\{[^}]*min-height:\s*39px[^}]*margin:\s*1px 0[^}]*padding:\s*5px 9px[^}]*border-radius:\s*10px/)
assert.match(compact, /\.client-sidebar \.client-nav-submenu \.client-sidebar-node-icon\s*\{[^}]*width:\s*25px[^}]*height:\s*25px[^}]*flex:\s*0 0 25px/)
assert.match(compact, /\.client-sidebar-group-trigger\s*\{[^}]*border:\s*0[^}]*background:\s*transparent/)
assert.match(compact, /\.client-sidebar-link\s*\{[^}]*text-decoration:\s*none/)
assert.match(compact, /\.client-sidebar (?:\.client-sidebar-group-trigger|\.client-sidebar-link) svg[^\{]*\{[^}]*flex:\s*0 0 18px/)
assert.match(compact, /\.client-nav-menu > \.client-nav-node > \.client-sidebar-link\[aria-current="page"\]\s*\{[^}]*background:\s*linear-gradient\(100deg, #2f6df6, #16a3df\)[^}]*color:\s*#fff/)
assert.match(compact, /\.client-sidebar \.client-sidebar-group-trigger > \.client-sidebar-node-icon,[^\{]*\.client-nav-menu > \.client-nav-node > \.client-sidebar-link > \.client-sidebar-node-icon\s*\{[^}]*width:\s*31px[^}]*height:\s*31px[^}]*flex:\s*0 0 31px/)
assert.match(compact, /\.client-sidebar-group-trigger span,[^\{]*\.client-sidebar-link span\s*\{[^}]*min-width:\s*0[^}]*overflow:\s*hidden[^}]*text-overflow:\s*ellipsis[^}]*white-space:\s*nowrap/)
})
test('style source contract: makes navigation and drawer controls touch sized with visible keyboard focus', () => {
assert.match(compact, /\.client-sidebar-group-trigger,[^\{]*\.client-sidebar-link\s*\{[^}]*height:\s*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\)/)
test('style source contract: keeps navigation compact with visible keyboard focus', () => {
assert.match(compact, /\.client-sidebar-group-trigger,[^\{]*\.client-sidebar-link\s*\{[^}]*min-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 #2563eb[^}]*outline-offset:\s*2px/)
assert.match(compact, /\.client-sidebar-logout\s*\{[^}]*min-height:\s*42px[^}]*background:\s*#fff7f7/)
})
test('style source contract: turns the sidebar into a layered drawer at 720px and below', () => {
+34 -31
View File
@@ -1,50 +1,51 @@
.client-shell { min-height: 100vh; overflow-x: hidden; background: #f5f7fa; }
.top-header { position: fixed; inset: 0 0 auto 250px; z-index: 20; display: flex; width: auto; max-width: calc(100vw - 250px); height: 68px; align-items: center; gap: 16px; padding: 0 24px; border-bottom: 1px solid #e9eef5; background: rgba(255, 255, 255, .96); box-shadow: 0 4px 18px rgba(15, 31, 58, .045); backdrop-filter: blur(12px); }
.client-shell { min-height: 100vh; overflow-x: hidden; background: #f6f8fb; }
.top-header { position: fixed; inset: 0 0 auto 224px; z-index: 20; display: flex; width: auto; max-width: calc(100vw - 224px); height: 68px; align-items: center; gap: 16px; padding: 0 24px; border-bottom: 1px solid #edf1f6; background: rgba(255, 255, 255, .98); backdrop-filter: blur(12px); }
.client-header-menu-button { display: none; width: 44px; height: 44px; flex: 0 0 44px; padding: 0; border: 0; border-radius: 8px; background: rgba(255, 255, 255, .1); color: #fff; place-items: center; }
.client-header-heading { min-width: 0; flex: 1; }
.client-header-heading h1 { overflow: hidden; margin: 0; color: #17233a; font-size: 17px; line-height: 1.35; text-overflow: ellipsis; white-space: nowrap; }
.client-header-heading p { overflow: hidden; margin: 3px 0 0; color: #7c8798; font-size: 11px; line-height: 1.35; text-overflow: ellipsis; white-space: nowrap; }
.client-header-greeting { min-width: 0; flex: 1; margin: 0; color: #667085; font-size: 13px; }
.client-user { display: flex; align-items: center; gap: 10px; }
.client-header-notifications { position: relative; display: inline-grid; width: 44px; height: 44px; padding: 0; border: 0; border-radius: 8px; background: transparent; color: #526176; place-items: center; }
.client-header-notifications { position: relative; display: inline-grid; width: 40px; height: 40px; padding: 0; border: 0; border-radius: 10px; background: transparent; color: #526176; place-items: center; }
.client-header-notifications { text-decoration: none; }
.client-header-notifications:hover { background: #f3f6fb; color: #2563eb; }
.client-header-notifications-badge { position: absolute; top: -8px; right: -9px; min-width: 16px; padding: 1px 4px; border-radius: 10px; color: #fff; background: #d64545; font-size: 10px; line-height: 14px; text-align: center; }
.client-avatar { display: grid; width: 34px; height: 34px; place-items: center; border-radius: 50%; background: #e9f2ff; color: #1565c0; font-weight: 750; }
.client-header-profile { display: flex; min-width: 0; align-items: center; gap: 10px; padding: 4px 8px 4px 4px; border-radius: 10px; color: inherit; text-decoration: none; }
.client-header-profile:hover { background: #f3f6fb; }
.client-user-copy { display: flex; flex-direction: column; }
.client-user-copy strong { color: #25324a; font-size: 12px; }
.client-user-copy small { color: #8893a3; font-size: 10px; }
.client-sidebar { position: fixed; inset: 0 auto 0 0; z-index: 21; display: flex; width: 250px; flex-direction: column; overflow: hidden; padding: 0 14px 16px; background: linear-gradient(180deg, #0b1f3a 0%, #0c2a52 100%); box-shadow: 4px 0 24px rgba(8, 25, 48, .14); color: #fff; font-family: "Microsoft YaHei UI", "Microsoft YaHei", "PingFang SC", sans-serif; font-synthesis: none; }
.client-sidebar-brand { display: flex; flex: 0 0 82px; min-height: 82px; align-items: center; justify-content: space-between; gap: 10px; margin-bottom: 10px; padding: 10px 4px 12px; border-bottom: 1px solid rgba(255, 255, 255, .09); }
.client-sidebar-brand-link { display: flex; min-width: 0; align-items: center; gap: 10px; color: #fff; text-decoration: none; }
.client-sidebar-brand-logo { display: block; width: 142px; height: 54px; object-fit: contain; object-position: left center; filter: brightness(0) invert(1); }
.client-sidebar-brand-copy { display: flex; min-width: 0; flex-direction: column; gap: 2px; }
.client-sidebar-brand-copy small { color: rgba(255, 255, 255, .52); font-size: 10px; white-space: nowrap; }
.client-sidebar nav { display: flex; flex: 1; min-width: 0; min-height: 0; flex-direction: column; overflow-x: hidden; overflow-y: auto; scrollbar-color: rgba(102, 126, 234, .3) transparent; scrollbar-width: thin; }
.client-sidebar { position: fixed; inset: 0 auto 0 0; z-index: 21; display: flex; width: 224px; flex-direction: column; overflow: hidden; padding: 12px; border-right: 1px solid #dfe8f3; background: linear-gradient(180deg, #f5f8ff 0%, #edf5fc 100%); box-shadow: 6px 0 24px rgba(34, 74, 126, .055); color: #172033; font-family: "Microsoft YaHei UI", "Microsoft YaHei", "PingFang SC", sans-serif; font-synthesis: none; }
.client-sidebar-brand { display: flex; flex: 0 0 82px; min-height: 82px; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 10px; padding: 9px 12px; border: 1px solid rgba(255, 255, 255, .12); border-radius: 15px; background: linear-gradient(145deg, #0a2b50, #0b3d6c); box-shadow: 0 9px 24px rgba(9, 45, 82, .18); }
.client-sidebar-brand-link { display: flex; width: 100%; min-width: 0; align-items: center; color: #172033; text-decoration: none; }
.client-sidebar-brand-logo { display: block; width: min(100%, 160px); height: 58px; object-fit: contain; object-position: left center; filter: brightness(0) invert(1); }
.client-sidebar nav { display: flex; flex: 1; min-width: 0; min-height: 0; flex-direction: column; overflow-x: hidden; overflow-y: auto; scrollbar-color: #dce3ed transparent; scrollbar-width: thin; }
.client-sidebar nav::-webkit-scrollbar { width: 10px; background: transparent; }
.client-sidebar nav::-webkit-scrollbar-track { background: transparent; }
.client-sidebar nav::-webkit-scrollbar-thumb { min-height: 40px; border: 3px solid transparent; border-radius: 999px; background: rgba(102, 126, 234, .3); background-clip: padding-box; }
.client-sidebar nav::-webkit-scrollbar-thumb:hover { background-color: rgba(102, 126, 234, .5); }
.client-sidebar nav::-webkit-scrollbar-thumb { min-height: 40px; border: 3px solid transparent; border-radius: 999px; background: #dce3ed; background-clip: padding-box; }
.client-sidebar nav::-webkit-scrollbar-thumb:hover { background-color: #c2ccda; }
.client-sidebar nav::-webkit-scrollbar-button { display: none; width: 0; height: 0; }
.client-sidebar nav::-webkit-scrollbar-corner { background: transparent; }
.client-nav-menu,
.client-nav-submenu { min-width: 0; margin: 0; padding: 0; list-style: none; }
.client-nav-menu { flex: 1; background: transparent; }
.client-nav-submenu { margin: 2px 0 6px; padding: 2px 0 2px 12px; border-left: 1px solid rgba(255, 255, 255, .1); }
.client-nav-submenu { margin: 3px 0 2px 15px; padding: 0 0 0 12px; border-left: 1px solid #e6ebf2; }
.client-nav-node { min-width: 0; }
.client-nav-node.has-divider-before { margin-top: 12px; padding-top: 12px; border-top: 1px solid rgba(255, 255, 255, .09); }
.client-nav-node.has-divider-before { margin-top: 9px; padding-top: 9px; border-top: 1px solid #eef2f7; }
.client-sidebar-group-trigger,
.client-sidebar-link { display: flex; width: 100%; height: 46px; min-width: 0; align-items: center; gap: 12px; margin: 2px 0; padding: 0 14px; border-radius: 10px; color: rgba(255, 255, 255, .82); font-size: 14px; font-weight: 500; line-height: 1.35; transition: color .2s ease, background .2s ease, box-shadow .2s ease; }
.client-sidebar-link { display: flex; position: relative; width: 100%; min-height: 46px; min-width: 0; align-items: center; gap: 11px; margin: 2px 0; padding: 7px 10px; border: 1px solid transparent; border-radius: 12px; color: #64748b; font-size: 13px; font-weight: 650; line-height: 1.35; transition: color .25s ease, background .25s ease, border-color .25s ease, transform .25s ease; }
.client-sidebar-group-trigger { border: 0; background: transparent; cursor: pointer; text-align: left; }
.client-sidebar-link { text-decoration: none; }
.client-sidebar-group-trigger:hover,
.client-sidebar-link:hover { background: rgba(255, 255, 255, .08); color: #fff; }
.client-nav-menu > .client-nav-node > .client-sidebar-link[aria-current="page"] { background: linear-gradient(90deg, #1565c0, #1e88e5); box-shadow: 0 6px 16px rgba(3, 15, 35, .28); color: #fff; font-weight: 600; }
.client-nav-submenu .client-sidebar-link { height: 42px; margin: 1px 0; padding: 0 14px 0 22px; border-radius: 9px; color: rgba(255, 255, 255, .74); font-size: 13px; font-weight: 500; }
.client-sidebar .client-nav-submenu .client-sidebar-node-icon { width: 16px; height: 16px; flex: 0 0 16px; opacity: .86; }
.client-nav-submenu .client-sidebar-link:hover { background: rgba(255, 255, 255, .07); color: #fff; }
.client-nav-submenu .client-sidebar-link[aria-current="page"] { background: rgba(30, 136, 229, .25); box-shadow: inset 0 0 0 1px rgba(109, 185, 255, .14); color: #fff; font-weight: 600; }
.client-nav-node.is-active-parent > .client-sidebar-group-trigger { color: #fff; font-weight: 600; }
.client-sidebar .client-sidebar-group-trigger svg,
.client-sidebar .client-sidebar-link svg { flex: 0 0 18px; }
.client-sidebar-link:hover { border-color: #dbeafe; background: #f5f9ff; color: #2563eb; transform: translateX(2px); }
.client-nav-menu > .client-nav-node > .client-sidebar-link[aria-current="page"] { border-color: #2d80ff; background: linear-gradient(100deg, #2f6df6, #16a3df); box-shadow: 0 8px 20px rgba(37, 99, 235, .22); color: #fff; font-weight: 700; }
.client-nav-menu > .client-nav-node > .client-sidebar-link[aria-current="page"]::before { display: none; }
.client-nav-submenu .client-sidebar-link { min-height: 39px; margin: 1px 0; padding: 5px 9px; border-radius: 10px; color: #718096; font-size: 12px; font-weight: 550; }
.client-sidebar .client-nav-submenu .client-sidebar-node-icon { width: 25px; height: 25px; flex: 0 0 25px; padding: 6px; border-radius: 7px; background: #f1f5f9; color: #7b8ba1; opacity: .9; }
.client-nav-submenu .client-sidebar-link:hover { background: #f5f9ff; color: #2563eb; }
.client-nav-submenu .client-sidebar-link[aria-current="page"] { border-color: #dbeafe; background: #eff6ff; color: #2563eb; font-weight: 650; }
.client-nav-node.is-active-parent > .client-sidebar-group-trigger { background: #f8faff; color: #2563eb; font-weight: 650; }
.client-sidebar .client-sidebar-group-trigger > .client-sidebar-node-icon,
.client-nav-menu > .client-nav-node > .client-sidebar-link > .client-sidebar-node-icon { width: 31px; height: 31px; flex: 0 0 31px; padding: 7px; border-radius: 9px; background: #f1f5f9; color: #64748b; }
.client-nav-menu > .client-nav-node > .client-sidebar-link[aria-current="page"] > .client-sidebar-node-icon { background: rgba(255, 255, 255, .14); color: #fff; }
.client-sidebar .client-sidebar-group-chevron { width: 16px; height: 16px; flex: 0 0 16px; margin-left: auto; transform: rotate(-90deg); transition: transform .2s ease; }
.client-sidebar-group-trigger[aria-expanded="true"] .client-sidebar-group-chevron { transform: rotate(0); }
.client-sidebar-group-trigger span,
@@ -53,14 +54,16 @@
.client-sidebar-link:focus-visible,
.client-header-menu-button:focus-visible,
.client-header-notifications:focus-visible,
.client-header-profile:focus-visible,
.client-sidebar-brand button:focus-visible,
.client-sidebar-logout:focus-visible { outline: 2px solid #6db9ff; outline-offset: 2px; }
.client-sidebar-logout:focus-visible { outline: 2px solid #2563eb; outline-offset: 2px; }
.client-sidebar-brand button,
.client-sidebar-scrim { display: none; }
.client-sidebar-logout { display: flex; width: 100%; height: 44px; align-items: center; justify-content: flex-start; gap: 11px; margin: 10px 0 0; padding: 0 12px; border: 0; border-radius: 10px; background: rgba(239, 68, 68, .12); color: #fca5a5; cursor: pointer; font-size: 13px; font-weight: 600; text-align: left; }
.client-sidebar-logout:hover { background: rgba(239, 68, 68, .22); color: #fff; }
.client-sidebar-logout { display: flex; width: 100%; min-height: 42px; align-items: center; justify-content: center; gap: 9px; margin: 10px 0 0; padding: 7px 10px; border: 1px solid #fecaca; border-radius: 11px; background: #fff7f7; color: #e05252; cursor: pointer; font-size: 13px; font-weight: 600; text-align: left; }
.client-sidebar-logout:hover { border-color: #fca5a5; background: #fff0f0; color: #c93737; }
.client-sidebar-logout svg { flex: 0 0 18px; }
.client-main { width: calc(100% - 250px); max-width: 100%; min-height: 100vh; margin-left: 250px; padding: 134px 20px 40px; }
.client-main { width: calc(100% - 224px); max-width: 100%; min-height: 100vh; margin-left: 224px; padding: 126px 20px 40px; }
.is-dashboard-route .client-main { padding-top: 88px; }
.client-breadcrumb { display: flex; min-width: 0; align-items: center; gap: 8px; margin: 0 0 14px; color: #98a2b3; font-size: 12px; line-height: 1.5; }
.client-breadcrumb a { color: #667085; text-decoration: none; }
.client-breadcrumb a:hover { color: #1565c0; }
+1 -1
View File
@@ -1,4 +1,4 @@
.workspace-tabs { position: fixed; inset: 68px 0 auto 250px; z-index: 19; display: flex; min-width: 0; height: 46px; align-items: stretch; border-bottom: 1px solid var(--border); background: #fff; box-shadow: 0 3px 12px rgba(15, 31, 58, .04); }
.workspace-tabs { position: fixed; inset: 68px 0 auto 224px; z-index: 19; display: flex; min-width: 0; height: 46px; align-items: stretch; border-bottom: 1px solid var(--border); background: #fff; }
.workspace-tabs__scroller { display: flex; min-width: 0; flex: 1; align-items: end; gap: 2px; overflow-x: auto; overflow-y: hidden; padding: 6px 8px 0; overscroll-behavior-inline: contain; scrollbar-width: none; -ms-overflow-style: none; }
.workspace-tabs__scroller::-webkit-scrollbar { display: none; width: 0; height: 0; }
.workspace-tab { display: flex; min-width: 112px; max-width: 220px; height: 39px; flex: 0 0 auto; align-items: center; border: 1px solid transparent; border-bottom: 0; border-radius: 8px 8px 0 0; color: #657184; }