Release/test #10

Merged
admin merged 40 commits from release/test into main 2026-09-28 15:59:06 +08:00
81 changed files with 3418 additions and 1960 deletions
Showing only changes of commit 0fa80874bc - Show all commits
+1 -1
View File
@@ -53,7 +53,7 @@
| 装柜完成后的退税报关资料 | `src/modules/first-leg/views/FirstLegOrdersView.vue` 的“报关资料”、`components/CustomsDocumentsDialog.vue`、`api/customs.ts` | 只显示登录客户在同柜的退税入仓码;报关开始前可上传、替换、调整同一资料覆盖的一个或多个入仓码及删除误传,全部写入携带幂等键和版本;开始后只读冻结资料,整柜完成后才能下载平台回传的一对一完成文件。Backend `firstleg/customs/container/`、`customs-v1.yaml`,二进制归 `file/` |
| 统一订单展示 | `src/modules/order-management/` | `src/modules/first-leg/` 与对应后端订单契约 |
| 客户财务账单、入仓码联查、收款、未收余额与客户确认 | `src/modules/finance/`;确认与明细入口 `views/FinanceBillsView.vue`、`api/finance.ts`,入仓码通过 `src/modules/first-leg/public.ts` 复用 `InboundCodeAssociationDialog` | Backend `finance/`;账单行只显示服务端 `inboundCode` 并按登录客户查询业务关系,缺失显示“—”,不得回退内部 ID。收款方式由服务端枚举映射中文。出账或已出账调整后为 `PENDING_CONFIRMATION`,客户确认提交登录成员、`expectedVersion` 和幂等键 |
| 客户资金账户、授信与充值申请 | `src/modules/finance/views/FinanceAccountView.vue`、`api/account.ts` | 显示当前企业 CNY 实际余额、冻结金额、授信额度、授信有效期、已用授信、冻结授信、总可用余额和不可变流水;数据加载前显示“—”,不得先伪装为零。授信到期日当天仍有效,次日已用授信并入实际余额,负数实际余额会阻止提交新订单。充值只展示服务端启用的微信/支付宝静态收款码,客户上传 JPG/PNG 付款截图后进入待审核。客户范围只取登录态,无收款码时禁止提交,不模拟支付成功 |
| 客户资金账户、授信与充值申请 | `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`;退货预报另见本节独立入口;贴标申请先选国外中转仓和有库存历史入仓单,换标类型为只换 SKU、只换箱唛、SKU+箱唛。涉及箱唛换标时,整张贴标申请只选择一个 FBA 目的仓,全部箱级明细统一提交该目的仓,模板和明细不再逐行选择目的仓。箱唛换标明细一行固定代表一箱,箱数不可编辑,提交也固定为 1。货物明细外层始终提供“SKU 关联”“箱号关联”“SKU 混箱换标”三套模板的下载、上传,不随换标类型切换;上传只进入待导入关系区,客户可勾选部分或全选后导入本次明细,只有进入明细的库存才随申请提交和冻结。明细也支持手动新增编辑,组合换标以仓库唛头连接旧/新 SKU 和新 FBA 箱唛关系。涉及箱唛换标时必须选择卡车、自提或快递;快递可选择打印面单并上传 PDF/图片。PDA 逐箱换标、上传照片并由仓库复核后,客户详情展示完工照片和“换标完成确认”;客户确认前不得继续出库,确认后卡车进入预排车,自提/快递进入换标出库待创建。历史库存位置不能解释成箱唛与具体托盘快照,客户页面不暴露托盘、库位和内部库存 ID |
Vendored
+83 -7
View File
@@ -1,3 +1,40 @@
def sendProductionWeComNotification(String title, String status, String color, String detail) {
withCredentials([string(credentialsId: 'wecom-prod-webhook', variable: 'WECOM_WEBHOOK')]) {
String sourceVersion = env.SOURCE_TAG ?: env.GIT_COMMIT ?: '未解析'
String duration = currentBuild.durationString?.replace(' and counting', '') ?: '—'
String content = """## ${title}
> 应用:客户工作台
> Job:${env.JOB_NAME}
> 构建:[#${env.BUILD_NUMBER}](${env.BUILD_URL})
> 提交:${sourceVersion}
> 状态:<font color="${color}">${status}</font>
${detail}
> 耗时:${duration}
> 时间:${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')}"
@@ -89,13 +126,6 @@ pipeline {
}
}
stage('确认部署生产前端') {
when { expression { env.IS_PRODUCTION == 'true' } }
steps {
input message: "确认将 main 提交 ${env.SOURCE_TAG} 部署到 app.buggoglobal.com?", ok: '确认部署'
}
}
stage('部署生产前端') {
when { expression { env.IS_PRODUCTION == 'true' } }
steps {
@@ -161,6 +191,22 @@ 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 +217,36 @@ 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}"
}
}
}
}
}
}
+6 -2
View File
@@ -8,12 +8,16 @@ services:
ports:
- "127.0.0.1:${HOST_PORT:-9002}:80"
environment:
API_UPSTREAM: "http://gateway:9000"
OVERSEAS_API_UPSTREAM: "http://gateway-overseas:9300"
API_UPSTREAM: "http://gateway-active:9000"
OVERSEAS_API_UPSTREAM: "http://gateway-overseas-active:9300"
networks:
- infra-network
- routing-network
networks:
infra-network:
external: true
name: firstleg-prod-infra-network
routing-network:
external: true
name: firstleg-production-routing
Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

+85 -28
View File
@@ -59,35 +59,92 @@ compose() {
docker compose --project-name "$project_name" -f "$compose_file" "$@"
}
container_id="$(compose ps --all --quiet "$service" | head -n 1)"
previous_image=""
if [ -n "$container_id" ]; then
previous_image="$(docker inspect --format '{{.Config.Image}}' "$container_id")"
fi
if compose up -d --no-deps --pull never "$service" && \
compose exec -T "$service" wget -qO- http://127.0.0.1/healthz >/dev/null; then
echo "生产前端部署成功: $image_repository:$image_tag"
compose ps "$service"
exit 0
fi
echo "生产前端健康检查失败,准备回滚" >&2
compose logs --tail=100 "$service" >&2 || true
case "$previous_image" in
"$image_repository:"*) ;;
'') echo "首次部署无旧镜像可回滚" >&2; exit 1 ;;
*) echo "旧镜像不属于当前应用,拒绝回滚: $previous_image" >&2; exit 1 ;;
case "$service" in
admin-web)
base_port=9001
nginx_config=/www/server/panel/vhost/nginx/00-blue-green-admin-web.conf
legacy_container=firstleg-prod-admin-web
;;
client-web)
base_port=9002
nginx_config=/www/server/panel/vhost/nginx/00-blue-green-client-web.conf
legacy_container=firstleg-prod-client-web
;;
*) echo "不支持蓝绿部署的前端服务: $service" >&2; exit 2 ;;
esac
previous_tag="${previous_image#"$image_repository:"}"
if [ "$previous_tag" = "$image_tag" ]; then
echo "旧镜像与新镜像一致,无法回滚" >&2
state_root=/data/firstleg/deploy/blue-green-state
state_file="$state_root/frontend-$service.active"
routing_network=firstleg-production-routing
mkdir -p "$state_root"
chmod 750 "$state_root"
docker network inspect "$routing_network" >/dev/null 2>&1 || docker network create "$routing_network" >/dev/null
for pair in gateway-active:firstleg-production-gateway gateway-overseas-active:firstleg-production-gateway-overseas; do
alias_name="${pair%%:*}"
container="${pair#*:}"
if docker inspect "$container" >/dev/null 2>&1; then
docker network inspect "$routing_network" --format '{{json .Containers}}' | grep -q "\"$container\"" \
|| docker network connect --alias "$alias_name" "$routing_network" "$container"
fi
done
active_slot=""
active_container=""
active_port=""
if [ -s "$state_file" ]; then
IFS='|' read -r active_slot active_container active_port active_image < "$state_file"
elif docker inspect "$legacy_container" >/dev/null 2>&1; then
active_slot=legacy
active_container="$legacy_container"
active_port="$base_port"
fi
if [ "$active_slot" = blue ]; then target_slot=green; else target_slot=blue; fi
[ "$active_slot" = legacy ] && target_slot=green
if [ "$target_slot" = blue ]; then target_port=$((base_port + 20000)); else target_port=$((base_port + 10000)); fi
candidate="${legacy_container}-${target_slot}"
docker rm -f "$candidate" >/dev/null 2>&1 || true
if ! compose run -d --no-deps --name "$candidate" \
-p "127.0.0.1:$target_port:80" "$service" >/dev/null; then
docker rm -f "$candidate" >/dev/null 2>&1 || true
exit 1
fi
docker pull "$previous_image"
export IMAGE_TAG="$previous_tag"
compose up -d --no-deps --pull never "$service"
compose exec -T "$service" wget -qO- http://127.0.0.1/healthz >/dev/null || true
echo "已回滚至: $previous_image" >&2
exit 1
healthy=false
elapsed=0
while [ "$elapsed" -lt 120 ]; do
if docker exec "$candidate" wget -qO- http://127.0.0.1/healthz >/dev/null 2>&1; then
healthy=true
break
fi
sleep 2
elapsed=$((elapsed + 2))
done
if [ "$healthy" != true ]; then
docker logs --tail 100 "$candidate" >&2 || true
docker rm -f "$candidate" >/dev/null 2>&1 || true
exit 1
fi
backup="$nginx_config.blue-green-backup"
cp -f "$nginx_config" "$backup"
if ! sed -i "s#127\.0\.0\.1:${active_port:-$base_port}#127.0.0.1:$target_port#g" "$nginx_config" \
|| cmp -s "$nginx_config" "$backup" || ! nginx -t || ! nginx -s reload; then
cp -f "$backup" "$nginx_config"
nginx -t && nginx -s reload || true
docker rm -f "$candidate" >/dev/null 2>&1 || true
exit 1
fi
rm -f "$backup"
if [ -n "$active_container" ] && docker inspect "$active_container" >/dev/null 2>&1; then
docker stop --time 30 "$active_container" >/dev/null
docker rm "$active_container" >/dev/null
fi
printf '%s|%s|%s|%s\n' "$target_slot" "$candidate" "$target_port" "$image_repository:$image_tag" > "$state_file.tmp"
chmod 640 "$state_file.tmp"
mv -f "$state_file.tmp" "$state_file"
echo "生产前端蓝绿部署成功: service=$service slot=$target_slot port=$target_port image=$image_repository:$image_tag"
REMOTE_DEPLOY
+2 -2
View File
@@ -22,9 +22,9 @@ declare module 'vue' {
ElCard: typeof import('element-plus/es')['ElCard']
ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
ElCheckboxGroup: typeof import('element-plus/es')['ElCheckboxGroup']
ElCollapse: typeof import('element-plus/es')['ElCollapse']
ElCollapseItem: typeof import('element-plus/es')['ElCollapseItem']
ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
ElDescriptions: typeof import('element-plus/es')['ElDescriptions']
ElDescriptionsItem: typeof import('element-plus/es')['ElDescriptionsItem']
ElDialog: typeof import('element-plus/es')['ElDialog']
ElDrawer: typeof import('element-plus/es')['ElDrawer']
ElDropdown: typeof import('element-plus/es')['ElDropdown']
-2
View File
@@ -22,8 +22,6 @@ const activeParentKeys = computed(() => new Set(getNavigationParentKeys(visibleN
const openedKeys = ref<ReadonlySet<string>>(new Set(activeParentKeys.value))
function handleToggle(key: string) {
if (activeParentKeys.value.has(key)) return
const next = new Set(openedKeys.value)
if (next.has(key)) next.delete(key)
else next.add(key)
+11
View File
@@ -0,0 +1,11 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
const source = readFileSync(new URL('./DataTable.vue', import.meta.url), 'utf8')
test('keeps action-only table toolbars on the left', () => {
assert.match(source, /'data-table__toolbar--actions-only': !\$slots\.toolbar/)
assert.match(source, /v-if="\$slots\.toolbar" class="data-table__toolbar-heading"/)
assert.match(source, /\.data-table__toolbar--actions-only \{ justify-content: flex-start; \}/)
})
+8 -5
View File
@@ -143,8 +143,8 @@ function setPage(value: number) {
<template>
<div class="data-table" :class="$attrs.class">
<header v-if="$slots.toolbar || $slots['toolbar-actions']" class="data-table__toolbar">
<div class="data-table__toolbar-heading"><slot name="toolbar" /></div>
<header v-if="$slots.toolbar || $slots['toolbar-actions']" class="data-table__toolbar" :class="{ 'data-table__toolbar--actions-only': !$slots.toolbar }">
<div v-if="$slots.toolbar" class="data-table__toolbar-heading"><slot name="toolbar" /></div>
<div class="data-table__toolbar-actions"><slot name="toolbar-actions" /></div>
</header>
<slot name="before-table" />
@@ -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>
@@ -227,14 +227,17 @@ function setPage(value: number) {
<style scoped>
.data-table { min-width: 0; }
.data-table__toolbar { display: flex; min-height: 48px; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: var(--client-gap); padding: var(--client-toolbar-padding); border-bottom: 1px solid var(--client-border); }
.data-table__toolbar--actions-only { justify-content: flex-start; }
.data-table__toolbar-heading, .data-table__toolbar-actions { display: flex; align-items: center; flex-wrap: wrap; gap: var(--client-gap); }
.data-table__toolbar-heading { color: var(--client-muted); font-size: 12px; }
.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; }
+24
View File
@@ -0,0 +1,24 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
const recoverySource = readFileSync(new URL('./staticAssetRecovery.ts', import.meta.url), 'utf8')
const mainSource = readFileSync(new URL('../main.ts', import.meta.url), 'utf8')
test('refreshes once when a deployment invalidates lazy chunks or same-origin scripts and styles', () => {
assert.match(mainSource, /installStaticAssetRecovery\(router\)/)
assert.match(recoverySource, /vite:preloadError/)
assert.match(recoverySource, /router\.onError/)
assert.match(recoverySource, /HTMLScriptElement/)
assert.match(recoverySource, /HTMLLinkElement/)
assert.match(recoverySource, /window\.location\.reload\(\)/)
assert.match(recoverySource, /RECOVERY_COOLDOWN_MS/)
assert.match(recoverySource, /previous\?\.href === window\.location\.href/)
})
test('does not turn ordinary application errors or third-party asset failures into refresh loops', () => {
assert.match(recoverySource, /isStaticAssetLoadError\(error\)/)
assert.match(recoverySource, /origin !== window\.location\.origin/)
assert.match(recoverySource, /sessionStorage/)
assert.match(recoverySource, /setTimeout\(clearRecoveryRecord, RECOVERY_STABLE_MS\)/)
})
+91
View File
@@ -0,0 +1,91 @@
import type { Router } from 'vue-router'
const RECOVERY_KEY = 'client-static-asset-recovery'
const RECOVERY_COOLDOWN_MS = 60_000
const RECOVERY_STABLE_MS = 15_000
interface RecoveryRecord {
href: string
refreshedAt: number
}
function errorMessage(error: unknown) {
if (error instanceof Error) return `${error.name}: ${error.message}`
return typeof error === 'string' ? error : ''
}
export function isStaticAssetLoadError(error: unknown) {
return /ChunkLoadError|Loading chunk [\w-]+ failed|Failed to fetch dynamically imported module|Importing a module script failed|error loading dynamically imported module|Unable to preload CSS/i.test(errorMessage(error))
}
function readRecoveryRecord(): RecoveryRecord | null {
try {
const raw = window.sessionStorage.getItem(RECOVERY_KEY)
if (!raw) return null
const value = JSON.parse(raw) as Partial<RecoveryRecord>
return typeof value.href === 'string' && typeof value.refreshedAt === 'number'
? { href: value.href, refreshedAt: value.refreshedAt }
: null
} catch {
return null
}
}
function writeRecoveryRecord(record: RecoveryRecord) {
try {
window.sessionStorage.setItem(RECOVERY_KEY, JSON.stringify(record))
return true
} catch {
// Without a persistent guard, reloading could loop while the deployment is still unavailable.
return false
}
}
function clearRecoveryRecord() {
try {
window.sessionStorage.removeItem(RECOVERY_KEY)
} catch {
// Storage availability must not affect application startup.
}
}
function recoverFromStaticAssetFailure(error: unknown, confirmedAssetFailure = false) {
if (!confirmedAssetFailure && !isStaticAssetLoadError(error)) return false
const now = Date.now()
const previous = readRecoveryRecord()
if (previous?.href === window.location.href && now - previous.refreshedAt < RECOVERY_COOLDOWN_MS) return false
if (!writeRecoveryRecord({ href: window.location.href, refreshedAt: now })) return false
window.location.reload()
return true
}
function failedApplicationAsset(event: Event) {
const target = event.target
if (target instanceof HTMLScriptElement) return target.src
if (target instanceof HTMLLinkElement && ['stylesheet', 'modulepreload', 'preload'].includes(target.rel)) return target.href
return ''
}
export function installStaticAssetRecovery(router: Router) {
window.addEventListener('vite:preloadError', (event) => {
const preloadEvent = event as Event & { payload?: unknown }
if (recoverFromStaticAssetFailure(preloadEvent.payload, true)) preloadEvent.preventDefault()
})
window.addEventListener('error', (event) => {
const assetUrl = failedApplicationAsset(event)
if (!assetUrl) return
try {
if (new URL(assetUrl, window.location.href).origin !== window.location.origin) return
} catch {
return
}
recoverFromStaticAssetFailure(assetUrl, true)
}, true)
router.onError((error) => {
recoverFromStaticAssetFailure(error)
})
window.setTimeout(clearRecoveryRecord, RECOVERY_STABLE_MS)
}
@@ -0,0 +1,28 @@
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
import test from 'node:test'
const script = (await readFile(new URL('../../scripts/deploy-production.sh', import.meta.url), 'utf8'))
.replaceAll('\r\n', '\n')
const compose = (await readFile(new URL('../../docker-compose.production.yml', import.meta.url), 'utf8'))
.replaceAll('\r\n', '\n')
test('client production deployment switches healthy blue-green slots atomically', () => {
const start = script.indexOf('compose run -d --no-deps --name "$candidate"')
const health = script.indexOf('docker exec "$candidate" wget')
const nginx = script.indexOf('nginx -t || ! nginx -s reload')
const stop = script.indexOf('docker stop --time 30 "$active_container"')
assert.ok(start >= 0 && start < health && health < nginx && nginx < stop)
assert.match(script, /target_port=\$\(\(base_port \+ 20000\)\)/)
assert.match(script, /target_port=\$\(\(base_port \+ 10000\)\)/)
assert.match(script, /00-blue-green-admin-web\.conf/)
assert.match(script, /00-blue-green-client-web\.conf/)
assert.doesNotMatch(script, /nginx_config=\/www\/server\/panel\/vhost\/nginx\/(?:admin|app)\.buggoglobal\.com\.conf/)
assert.match(script, /cp -f "\$backup" "\$nginx_config"/)
})
test('client uses stable active gateway aliases on the routing network', () => {
assert.match(compose, /API_UPSTREAM: "http:\/\/gateway-active:9000"/)
assert.match(compose, /OVERSEAS_API_UPSTREAM: "http:\/\/gateway-overseas-active:9300"/)
assert.match(compose, /name: firstleg-production-routing/)
})
+3 -1
View File
@@ -49,7 +49,9 @@ test('keeps local environments on target nodes and uses credential-managed SSH f
assert.match(jenkinsfile, /credentialsId: 'firstleg-production-ssh'/)
assert.match(jenkinsfile, /git rev-parse origin\/main/)
assert.match(productionCompose, /127\.0\.0\.1:\$\{HOST_PORT:-9002\}:80/)
assert.match(productionCompose, /API_UPSTREAM: "http:\/\/gateway:9000"/)
assert.match(productionCompose, /API_UPSTREAM: "http:\/\/gateway-active:9000"/)
assert.match(productionCompose, /OVERSEAS_API_UPSTREAM: "http:\/\/gateway-overseas-active:9300"/)
assert.match(productionCompose, /name: firstleg-production-routing/)
assert.match(productionCompose, /name: firstleg-prod-infra-network/)
})
+1
View File
@@ -11,6 +11,7 @@ declare module 'vue-router' {
requiresAuth?: boolean
permission?: string
workspaceTab?: boolean
workspaceTitle?: string
keepAlive?: boolean
}
}
+24 -14
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', () => {
@@ -110,7 +119,8 @@ test('source contract: uses a native recursive disclosure list without Element m
assert.match(sidebarMenuSource, /const activeParentKeys = computed\(\(\) => new Set\(getNavigationParentKeys\(visibleNavigation\.value, props\.activePath\)\)\)/)
assert.match(sidebarMenuSource, /const openedKeys = ref<ReadonlySet<string>>\(new Set\(activeParentKeys\.value\)\)/)
assert.match(sidebarMenuSource, /openedKeys\.value = new Set\(\[\.\.\.openedKeys\.value, \.\.\.activeParentKeys\.value\]\)/)
assert.match(sidebarMenuSource, /if \(activeParentKeys\.value\.has\(key\)\) return/)
assert.doesNotMatch(sidebarMenuSource, /if \(activeParentKeys\.value\.has\(key\)\) return/)
assert.match(sidebarMenuSource, /if \(next\.has\(key\)\) next\.delete\(key\)/)
assert.match(sidebarMenuSource, /<ul class="client-nav-menu">/)
assert.match(sidebarMenuSource, /:active-path="activePath"/)
assert.match(sidebarMenuSource, /:opened-keys="openedKeys"/)
+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>
+3
View File
@@ -6,9 +6,12 @@ import { ElConfigProvider } from 'element-plus'
import zhCn from 'element-plus/es/locale/lang/zh-cn'
import App from './App.vue'
import router from './router'
import { installStaticAssetRecovery } from './core/staticAssetRecovery'
import 'element-plus/es/components/message/style/css'
import 'element-plus/es/components/message-box/style/css'
import './styles/index.css'
installStaticAssetRecovery(router)
const app = createApp({ render: () => h(ElConfigProvider, { locale: zhCn }, { default: () => h(App) }) })
app.use(createPinia()).use(router).mount('#app')
@@ -18,7 +18,7 @@ test('activates all three customer cross-border pages with frozen permissions',
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-else-if="canCreate"/)
assert.match(view, /v-if="canCreate"[^>]*type="primary"/)
})
test('uses only merchant overseas endpoints and keeps retry idempotency stable', () => {
@@ -36,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: '全部出库'/)
@@ -47,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,11 @@ 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',
@@ -101,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('')
@@ -112,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' }],
@@ -131,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')],
@@ -140,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],
@@ -153,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,
@@ -179,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)
@@ -219,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 }
}
@@ -264,57 +368,52 @@ 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-if="canCreate" 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-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">
@@ -345,24 +444,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 v-if="canCreate" 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; }
@@ -373,15 +483,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) {
+39
View File
@@ -5,6 +5,19 @@ 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',
@@ -13,6 +26,19 @@ export const crossBorderModule = {
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',
@@ -21,6 +47,19 @@ export const crossBorderModule = {
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',
@@ -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>
+156
View File
@@ -0,0 +1,156 @@
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('matches the updated six-action icon and motion treatment', () => {
assert.match(dashboardView, /icon: Forklift/)
assert.doesNotMatch(dashboardView, /PanelsTopLeft/)
assert.match(dashboardView, /featured: true/)
assert.match(dashboardView, /quick-action--featured/)
assert.match(dashboardView, /featured-aura 5s ease-in-out infinite/)
assert.match(dashboardView, /featured-shine 3\.6s ease-in-out infinite/)
assert.match(dashboardView, /\.quick-action:hover \{[^}]*border-color:[^}]*transform: translateY\(-9px\);/)
assert.match(dashboardView, /prefers-reduced-motion: reduce/)
assert.match(dashboardView, /\.client-dashboard a:focus-visible/)
})
test('opens all six dashboard businesses through their dedicated create pages', () => {
for (const route of [
'/orders/first-leg/create',
'/overseas-warehouse/return-relabel/create',
'/cross-border/domestic-trucking/create',
'/cross-border/pallet-shipping/create',
'/cross-border/procurement/create',
'/overseas-warehouse/labeling/create',
]) assert.match(dashboardView, new RegExp(`route: '${route.replaceAll('/', '\\/')}'`))
assert.match(dashboardView, /title: '退货换标'[\s\S]*?permission: 'merchant:overseas-warehouse:return-order:create'/)
})
+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 小时内与您联系/)
})
+84 -624
View File
@@ -1,640 +1,100 @@
<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, Forklift, 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:list',
},
{
title: '美国本土卡',
description: '港口提柜 · 仓到仓 · 仓到门',
icon: Truck,
tone: 'green',
route: '/cross-border/domestic-trucking',
permission: 'merchant:cross-border:domestic-trucking:list',
},
{
title: '美本土散板',
description: '拼车拼载 · 打盘服务 · 灵活派送',
icon: PanelsTopLeft,
tone: 'teal',
route: '/cross-border/pallet-shipping',
permission: 'merchant:cross-border:pallet-shipping:list',
},
] as const
const primaryActions = computed(() => [
{ title: '头程下单', description: '国内提货 · 海运订舱 · 清关派送', icon: Ship, route: '/orders/first-leg/create', permission: 'order:first-leg:create', tone: 'blue', featured: true },
{ title: '退货换标', description: '退件接收 · 标签更换 · 重新入仓', icon: RefreshCcw, route: '/overseas-warehouse/return-relabel/create', permission: 'merchant:overseas-warehouse:return-order:create', tone: 'purple', featured: false },
{ title: '美国本土卡', description: '港口提柜 · 仓到仓 · 仓到门', icon: Truck, route: '/cross-border/domestic-trucking/create', permission: 'merchant:cross-border:domestic-trucking:create', tone: 'green', featured: false },
{ title: '美本土散板', description: '拼车拼载 · 打盘服务 · 灵活派送', icon: Forklift, route: '/cross-border/pallet-shipping/create', permission: 'merchant:cross-border:pallet-shipping:create', tone: 'cyan', featured: false },
{ title: '海外采购', description: '采购代购 · 验货质检 · 集中发货', icon: ShoppingCart, route: '/cross-border/procurement/create', permission: 'merchant:cross-border:procurement:create', tone: 'orange', featured: false },
{ title: '贴标申请', description: '产品建档 · 标签上传 · 批量贴标', icon: Tags, route: '/overseas-warehouse/labeling/create', permission: 'merchant:overseas-warehouse:relabel:create', tone: 'rose', featured: false },
].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}`, { 'quick-action--featured': action.featured }]" :style="{ '--entry-index': index }">
<span class="action-icon"><component :is="action.icon" :size="25" :stroke-width="2.2" aria-hidden="true" /></span>
<span class="action-copy"><strong>{{ action.title }}</strong><small>{{ action.description }}</small></span>
<span class="action-enter"><span>开始下单</span><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; isolation: isolate; display: flex; min-width: 0; min-height: 190px; overflow: hidden; align-items: center; flex-direction: column; padding: 21px 10px 18px; border: 1px solid rgba(148, 163, 184, .2); border-radius: 17px; background: linear-gradient(155deg, #fff 8%, rgba(248, 250, 252, .96) 100%); color: inherit; text-align: center; text-decoration: none; box-shadow: 0 12px 34px rgba(15, 23, 42, .07); backdrop-filter: blur(10px); 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 24px 54px rgba(15, 23, 42, .14), 0 0 0 1px color-mix(in srgb, var(--action-a) 16%, transparent); transform: translateY(-9px); }
.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: 58px; height: 58px; flex: 0 0 auto; place-items: center; margin-bottom: 16px; border-radius: 18px; background: linear-gradient(135deg, var(--action-a), var(--action-b)); color: #fff; box-shadow: 0 11px 22px color-mix(in srgb, var(--action-a) 25%, transparent); transition: transform .35s ease; }
.action-icon::before { position: absolute; inset: 7px; border-radius: 12px; background: linear-gradient(135deg, rgba(255, 255, 255, .32), transparent 42%); content: ''; pointer-events: none; }
.action-icon::after { position: absolute; border: 1px solid color-mix(in srgb, var(--action-a) 20%, transparent); border-radius: 22px; content: ''; inset: -5px; }
.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: 16px; font-weight: 800; line-height: 1.35; white-space: nowrap; }
.action-copy small { overflow: hidden; width: 100%; margin-top: 6px; color: #718096; font-size: 11px; line-height: 1.5; text-overflow: ellipsis; white-space: nowrap; }
.action-enter { position: relative; display: inline-flex; align-items: center; gap: 6px; margin-top: auto; color: var(--action-a); font-size: 11px; font-weight: 750; }
.action-enter svg { transition: transform .25s ease; }
.quick-action:hover .action-enter svg { transform: translateX(4px); }
.quick-action--featured { border-color: rgba(49, 126, 255, .62); background: radial-gradient(circle at 8% 2%, rgba(34, 211, 238, .23), transparent 32%), radial-gradient(circle at 100% 100%, rgba(79, 70, 229, .22), transparent 44%), linear-gradient(145deg, #f2fbff 0%, #f8faff 45%, #eef2ff 100%); box-shadow: 0 20px 48px rgba(37, 99, 235, .2), 0 0 0 1px rgba(255, 255, 255, .88) inset, 0 0 34px rgba(34, 211, 238, .1); }
.quick-action--featured::before { width: 220px; height: 220px; right: -88px; top: -112px; background: radial-gradient(circle, rgba(34, 211, 238, .35) 0%, rgba(59, 130, 246, .2) 34%, transparent 69%); filter: blur(1px); animation: featured-aura 5s ease-in-out infinite; }
.quick-action--featured::after { right: 8%; left: 8%; height: 4px; background: linear-gradient(90deg, #22d3ee 0%, #2563eb 48%, #4f46e5 100%); box-shadow: 0 -4px 16px rgba(37, 99, 235, .44), 0 -1px 6px rgba(34, 211, 238, .55); opacity: 1; transform: scaleX(1); }
.quick-action--featured:hover { border-color: rgba(34, 211, 238, .8); box-shadow: 0 30px 70px rgba(37, 99, 235, .27), 0 0 0 1px rgba(255, 255, 255, .95) inset, 0 0 40px rgba(34, 211, 238, .15); transform: translateY(-11px) scale(1.015); }
.quick-action--featured .action-icon { width: 64px; height: 64px; margin-bottom: 17px; border-radius: 20px; background: linear-gradient(145deg, #22c7df 0%, #2875ef 48%, #4338ca 100%); box-shadow: 0 16px 32px rgba(37, 99, 235, .37), 0 0 0 7px rgba(59, 130, 246, .09), 0 0 26px rgba(34, 211, 238, .24); }
.quick-action--featured .action-icon::after { inset: -6px; border-color: rgba(59, 130, 246, .25); border-radius: 25px; box-shadow: 0 0 0 1px rgba(34, 211, 238, .09); }
.quick-action--featured .action-copy strong { color: #15233e; font-size: 17px; }
.quick-action--featured .action-enter { overflow: hidden; padding: 8px 15px; border-radius: 999px; background: linear-gradient(100deg, #12b8d0 0%, #2563eb 52%, #4338ca 100%); box-shadow: 0 10px 24px rgba(37, 99, 235, .3), 0 0 18px rgba(34, 211, 238, .12); color: #fff; }
.quick-action--featured .action-enter::before { position: absolute; top: -60%; bottom: -60%; left: -55%; width: 38%; transform: rotate(18deg); background: linear-gradient(90deg, transparent, rgba(255, 255, 255, .55), transparent); content: ''; animation: featured-shine 3.6s ease-in-out infinite; }
.quick-action--featured .action-enter > * { position: relative; z-index: 1; }
.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); } }
@keyframes featured-aura { 0%, 100% { opacity: .86; transform: translate3d(0, 0, 0) scale(1); } 50% { opacity: 1; transform: translate3d(-10px, 9px, 0) scale(1.08); } }
@keyframes featured-shine { 0%, 55% { left: -55%; opacity: 0; } 65% { opacity: 1; } 88%, 100% { left: 125%; opacity: 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, .quick-action--featured::before, .quick-action--featured .action-enter::before { animation: none; } .quick-action, .quick-action::before, .quick-action::after, .action-icon, .action-enter svg { transition: none; } }
</style>
+1 -16
View File
@@ -61,14 +61,12 @@ export interface AccountLedgerEntry {
createdAt: string
}
export type RechargePaymentMethod = 'WECHAT' | 'ALIPAY'
export type RechargeStatus = 'PENDING' | 'APPROVED' | 'REJECTED'
export interface RechargeRequest {
requestId: string
requestNo: string
amount: string
paymentMethod: RechargePaymentMethod
voucherFileId: string
voucherFileName: string
status: RechargeStatus
@@ -80,13 +78,6 @@ export interface RechargeRequest {
reviewedAt: string | null
}
export interface RechargeChannel {
paymentMethod: RechargePaymentMethod
displayName: string
enabled: boolean
qrCodeUrl: string | null
}
export function getFinanceAccount() {
return apiRequest<FinanceAccount>({ method: 'GET', url: '/client/finance/v1/account' })
}
@@ -109,15 +100,9 @@ export async function getRechargeRequests(page: number, size: number) {
return normalizePageResult(result)
}
export function getRechargeChannels() {
return apiRequest<RechargeChannel[]>({ method: 'GET', url: '/client/finance/v1/recharge-channels' })
}
export function createRechargeRequest(amount: string, paymentMethod: RechargePaymentMethod, file: File,
idempotencyKey: string) {
export function createRechargeRequest(amount: string, file: File, idempotencyKey: string) {
const formData = new FormData()
formData.append('amount', amount)
formData.append('paymentMethod', paymentMethod)
formData.append('file', file, file.name)
return apiRequest<RechargeRequest>({
method: 'POST',
+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
@@ -8,13 +8,13 @@ const routesSource = readFileSync(new URL('../routes.ts', import.meta.url), 'utf
const permissionsSource = readFileSync(new URL('../../tenant-management/contracts.ts', import.meta.url), 'utf8')
test('uses only authenticated client finance account endpoints without customer scope', () => {
for (const path of ['/client/finance/v1/account', '/client/finance/v1/account/ledger', '/client/finance/v1/recharge-requests', '/client/finance/v1/recharge-channels']) {
for (const path of ['/client/finance/v1/account', '/client/finance/v1/account/ledger', '/client/finance/v1/recharge-requests']) {
assert.match(apiSource, new RegExp(path.replaceAll('/', '\\/')))
}
assert.match(apiSource, /headers: \{ 'Idempotency-Key': idempotencyKey \}/)
assert.match(apiSource, /formData\.append\('amount', amount\)/)
assert.match(apiSource, /formData\.append\('paymentMethod', paymentMethod\)/)
assert.match(apiSource, /formData\.append\('file', file, file\.name\)/)
assert.doesNotMatch(apiSource, /RechargePaymentMethod|paymentMethod|WECHAT|ALIPAY/)
assert.doesNotMatch(apiSource, /customerId|tenantId|mock|fallback/i)
})
@@ -23,12 +23,14 @@ test('activates the account route and maps read and recharge permissions indepen
assert.match(permissionsSource, /'merchant:finance:account:view': \['merchant:finance:account:view'\]/)
assert.match(permissionsSource, /'merchant:finance:recharge:create': \['merchant:finance:recharge:create'\]/)
assert.match(viewSource, /auth\.hasPermission\('merchant:finance:recharge:create'\)/)
assert.match(viewSource, /<el-button v-if="canRecharge"/)
assert.match(viewSource, /class="balance-title"[\s\S]*?<el-button v-if="canRecharge" size="small" type="primary"/)
assert.match(viewSource, /\.balance-summary \{[^}]*grid-template-columns: repeat\(6,/)
assert.doesNotMatch(viewSource, /class="account-actions"|class="balance-grid"/)
assert.doesNotMatch(viewSource, /class="account-heading"/)
})
test('renders server balances, ledger and recharge states without internal identifiers', () => {
for (const field of ['actualBalance', 'frozenBalance', 'availableBalance', 'creditLimit', 'creditUsed', 'availableCredit']) assert.match(viewSource, new RegExp(`account\\?\\.${field}`))
for (const field of ['creditUsedAfter', 'availableCreditAfter']) assert.match(viewSource, new RegExp(`row\\.${field}`))
assert.match(viewSource, /实际可用资金与可用授信合计/)
assert.match(viewSource, /CREDIT_LIMIT: '授信调整'/)
assert.match(viewSource, /row-key="businessNo"/)
@@ -39,9 +41,12 @@ test('renders server balances, ledger and recharge states without internal ident
assert.match(viewSource, /v-loading="pageLoading"/)
assert.match(viewSource, /function formatAccountAmount[\s\S]*value == null \? '—' : formatAmount\(value\)/)
assert.match(viewSource, /formatAccountAmount\(account\?\.actualBalance\)/)
assert.match(viewSource, /:default-current-page="ledgerPage"/)
assert.match(viewSource, /:default-current-page="rechargePage"/)
assert.doesNotMatch(viewSource, /<el-pagination (?:v-model:current-page|:current-page)=/)
assert.match(viewSource, /<DataTable v-model:page="ledgerPage" v-model:page-size="ledgerPageSize"/)
assert.match(viewSource, /<DataTable v-model:page="rechargePage" v-model:page-size="rechargePageSize"/)
assert.match(viewSource, /const ledgerColumns: DataTableColumn\[]/)
assert.match(viewSource, /const rechargeColumns: DataTableColumn\[]/)
assert.doesNotMatch(viewSource, /label: '已用授信'|label: '可用授信'/)
assert.doesNotMatch(viewSource, /<el-table|<el-pagination/)
assert.match(apiSource, /function normalizePageResult<[\s\S]*total: Number\(result\.total\)/)
assert.match(apiSource, /page: Number\(result\.page\)/)
assert.doesNotMatch(viewSource, /requestId|ledgerId|customerId|tenantId/)
@@ -49,13 +54,16 @@ test('renders server balances, ledger and recharge states without internal ident
test('matches the frozen recharge workflow and preserves an idempotency key for failed retries', () => {
assert.match(viewSource, /title="账户充值" width="760px"/)
for (const amount of ['50.00', '100.00', '200.00', '500.00']) assert.match(viewSource, new RegExp(amount.replace('.', '\\.')))
assert.match(viewSource, /\['WECHAT', 'ALIPAY'\]/)
assert.match(viewSource, /selectedChannel\.value\?\.enabled && selectedChannel\.value\.qrCodeUrl/)
for (const amount of ['1000', '5000', '20000', '50000']) assert.match(viewSource, new RegExp(amount))
assert.match(viewSource, /自定义/)
assert.match(viewSource, /src="\/customer-recharge-qr\.png"/)
assert.match(viewSource, /alt="平台收款二维码"/)
assert.match(viewSource, /请扫描平台收款二维码,付款后上传截图。/)
assert.match(viewSource, /accept="\.jpg,\.jpeg,\.png,image\/jpeg,image\/png"/)
assert.match(viewSource, /if \(!pendingIdempotencyKey\)[\s\S]*globalThis\.crypto\?\.randomUUID\?\.\(\)/)
assert.match(viewSource, /pendingIdempotencyKey = ''[\s\S]*rechargeVisible\.value = false/)
assert.match(viewSource, /:disabled="!canSubmit"/)
assert.match(viewSource, /await loadAccountData\(\)/)
assert.doesNotMatch(viewSource, /payment SDK|支付成功|模拟成功/)
assert.doesNotMatch(viewSource, /微信支付|支付宝|payment-methods|paymentMethod/)
})
@@ -3,18 +3,16 @@ import { computed, onMounted, reactive, ref, watch } from 'vue'
import { CircleDollarSign, RefreshCcw, Upload } from '@lucide/vue'
import { ElMessage, type UploadFile, type UploadFiles, type UploadInstance } from 'element-plus'
import { ApiError } from '@/api/http'
import DataTable, { type DataTableColumn } from '@/components/common/DataTable.vue'
import { useAuthStore } from '@/modules/auth/public'
import {
createRechargeRequest,
getAccountLedger,
getFinanceAccount,
getRechargeChannels,
getRechargeRequests,
type AccountChangeType,
type AccountLedgerEntry,
type FinanceAccount,
type RechargeChannel,
type RechargePaymentMethod,
type RechargeRequest,
type RechargeStatus,
} from '../api/account'
@@ -23,15 +21,15 @@ const auth = useAuthStore()
const account = ref<FinanceAccount | null>(null)
const ledger = ref<AccountLedgerEntry[]>([])
const rechargeRequests = ref<RechargeRequest[]>([])
const channels = ref<RechargeChannel[]>([])
const pageLoading = ref(false)
const pageError = ref('')
const channelsError = ref('')
const qrCodeLoadError = ref(false)
const ledgerPage = ref(1)
const ledgerTotal = ref(0)
const ledgerPageSize = ref(10)
const rechargePage = ref(1)
const rechargeTotal = ref(0)
const pageSize = 10
const rechargePageSize = ref(10)
const rechargeVisible = ref(false)
const submitting = ref(false)
const submitError = ref('')
@@ -39,11 +37,11 @@ const voucherFile = ref<File | null>(null)
const voucherUpload = ref<UploadInstance>()
let pendingIdempotencyKey = ''
const form = reactive({ amount: '100.00', paymentMethod: 'WECHAT' as RechargePaymentMethod })
const quickAmounts = ['50.00', '100.00', '200.00', '500.00']
const form = reactive({ amount: '1000' })
const quickAmounts = ['1000', '5000', '20000', '50000']
const amountMode = ref<'preset' | 'custom'>('preset')
const canRecharge = computed(() => auth.hasPermission('merchant:finance:recharge:create'))
const selectedChannel = computed(() => channels.value.find((item) => item.paymentMethod === form.paymentMethod))
const channelReady = computed(() => Boolean(selectedChannel.value?.enabled && selectedChannel.value.qrCodeUrl))
const channelReady = computed(() => !qrCodeLoadError.value)
const amountValid = computed(() => {
const value = Number(form.amount)
return Number.isFinite(value) && value > 0 && /^\d+(?:\.\d{1,2})?$/.test(form.amount.trim())
@@ -54,7 +52,27 @@ const changeLabels: Record<AccountChangeType, string> = {
RECHARGE: '充值', DEDUCTION: '扣款', FREEZE: '冻结', UNFREEZE: '解冻', CONSUMPTION: '消费', CREDIT_LIMIT: '授信调整',
}
const statusLabels: Record<RechargeStatus, string> = { PENDING: '待审核', APPROVED: '已通过', REJECTED: '已驳回' }
const methodLabels: Record<RechargePaymentMethod, string> = { WECHAT: '微信支付', ALIPAY: '支付宝' }
const ledgerColumns: DataTableColumn[] = [
{ label: '业务单号', prop: 'businessNo', minWidth: 180 },
{ label: '变动类型', slotName: 'changeType', width: 100 },
{ label: '变动金额', slotName: 'amount', width: 120, align: 'right' },
{ label: '实际余额', slotName: 'actualBalance', width: 120, align: 'right' },
{ label: '冻结金额', slotName: 'frozenBalance', width: 120, align: 'right' },
{ label: '可用余额', slotName: 'availableBalance', width: 120, align: 'right' },
{ label: '操作人', slotName: 'operatorName', width: 120 },
{ label: '备注', slotName: 'remark', minWidth: 180 },
{ label: '发生时间', slotName: 'createdAt', width: 160 },
]
const rechargeColumns: DataTableColumn[] = [
{ label: '充值申请单号', prop: 'requestNo', minWidth: 180 },
{ label: '金额', slotName: 'amount', width: 120, align: 'right' },
{ label: '付款截图', prop: 'voucherFileName', minWidth: 150 },
{ label: '状态', slotName: 'status', width: 100 },
{ label: '提交人', slotName: 'submittedByName', width: 120 },
{ label: '审核备注', slotName: 'reviewRemark', minWidth: 180 },
{ label: '提交时间', slotName: 'createdAt', width: 160 },
{ label: '审核时间', slotName: 'reviewedAt', width: 160 },
]
function messageOf(error: unknown, fallback: string) {
return error instanceof ApiError ? error.message : fallback
@@ -86,8 +104,8 @@ function amountClass(amount: string) {
async function loadAccountData() {
pageLoading.value = true
pageError.value = ''
channelsError.value = ''
const requests = [getFinanceAccount(), getAccountLedger(ledgerPage.value, pageSize), getRechargeRequests(rechargePage.value, pageSize)] as const
qrCodeLoadError.value = false
const requests = [getFinanceAccount(), getAccountLedger(ledgerPage.value, ledgerPageSize.value), getRechargeRequests(rechargePage.value, rechargePageSize.value)] as const
try {
const [accountResult, ledgerResult, rechargeResult] = await Promise.all(requests)
account.value = accountResult
@@ -105,20 +123,11 @@ async function loadAccountData() {
} finally {
pageLoading.value = false
}
if (canRecharge.value) {
try {
channels.value = await getRechargeChannels()
} catch (error) {
channels.value = []
channelsError.value = messageOf(error, '充值渠道加载失败,请稍后重试')
}
}
}
async function changeLedgerPage(nextPage: number) {
ledgerPage.value = nextPage
try {
const result = await getAccountLedger(nextPage, pageSize)
const result = await getAccountLedger(nextPage, ledgerPageSize.value)
ledger.value = result.records
ledgerTotal.value = result.total
} catch (error) {
@@ -127,9 +136,8 @@ async function changeLedgerPage(nextPage: number) {
}
async function changeRechargePage(nextPage: number) {
rechargePage.value = nextPage
try {
const result = await getRechargeRequests(nextPage, pageSize)
const result = await getRechargeRequests(nextPage, rechargePageSize.value)
rechargeRequests.value = result.records
rechargeTotal.value = result.total
} catch (error) {
@@ -139,13 +147,26 @@ async function changeRechargePage(nextPage: number) {
function openRecharge() {
submitError.value = ''
qrCodeLoadError.value = false
amountMode.value = 'preset'
form.amount = '1000'
rechargeVisible.value = true
}
function handleQrCodeError() {
qrCodeLoadError.value = true
}
function chooseAmount(amount: string) {
amountMode.value = 'preset'
form.amount = amount
}
function chooseCustomAmount() {
amountMode.value = 'custom'
if (quickAmounts.includes(form.amount)) form.amount = ''
}
function handleVoucherChange(uploadFile: UploadFile, _uploadFiles: UploadFiles) {
const file = uploadFile.raw
if (!file) return
@@ -175,7 +196,7 @@ async function submitRecharge() {
return
}
if (!channelReady.value) {
submitError.value = `${methodLabels[form.paymentMethod]}收款二维码尚未配置,暂不能提交充值申请`
submitError.value = '收款二维码尚未配置,暂不能提交充值申请'
return
}
if (!voucherFile.value) {
@@ -187,7 +208,7 @@ async function submitRecharge() {
}
submitting.value = true
try {
await createRechargeRequest(normalizedAmount(), form.paymentMethod, voucherFile.value, pendingIdempotencyKey)
await createRechargeRequest(normalizedAmount(), voucherFile.value, pendingIdempotencyKey)
pendingIdempotencyKey = ''
rechargeVisible.value = false
voucherFile.value = null
@@ -201,28 +222,26 @@ async function submitRecharge() {
}
}
watch([() => form.amount, () => form.paymentMethod, voucherFile], () => {
watch([() => form.amount, voucherFile], () => {
if (!submitting.value) pendingIdempotencyKey = ''
submitError.value = ''
})
watch([ledgerPage, ledgerPageSize], () => { void changeLedgerPage(ledgerPage.value) })
watch([rechargePage, rechargePageSize], () => { void changeRechargePage(rechargePage.value) })
onMounted(() => { void loadAccountData() })
</script>
<template>
<div v-loading="pageLoading" class="finance-account-page" :aria-busy="pageLoading">
<div class="account-heading">
<div><h2>资金账户</h2><p>查看本企业 CNY 资金、授信额度、账户流水和充值申请状态。</p></div>
<el-button v-if="canRecharge" type="primary" @click="openRecharge"><CircleDollarSign :size="17" />账户充值</el-button>
</div>
<div v-if="pageError" class="account-state account-state--error" role="alert">
<p>{{ pageError }}</p><el-button @click="loadAccountData"><RefreshCcw :size="15" />重新加载</el-button>
</div>
<template v-else>
<section class="balance-grid" aria-label="账户余额">
<article><span>实际余额</span><strong>¥ {{ formatAccountAmount(account?.actualBalance) }}</strong><small>当前账户实际资金</small></article>
<section class="balance-summary" aria-label="账户余额">
<article><div class="balance-title"><span>实际余额</span><el-button v-if="canRecharge" size="small" type="primary" @click="openRecharge"><CircleDollarSign :size="15" />账户充值</el-button></div><strong>¥ {{ formatAccountAmount(account?.actualBalance) }}</strong><small>当前账户实际资金</small></article>
<article><span>冻结金额</span><strong>¥ {{ formatAccountAmount(account?.frozenBalance) }}</strong><small>暂不可使用的资金</small></article>
<article class="balance-available"><span>总可用余额</span><strong>¥ {{ formatAccountAmount(account?.availableBalance) }}</strong><small>实际可用资金与可用授信合计</small></article>
<article><span>授信额度</span><strong>¥ {{ formatAccountAmount(account?.creditLimit) }}</strong><small>{{ account?.creditExpiresOn ? `${account.creditExpiresOn} ${account.creditActive ? '到期' : '已到期'}` : '未设置有效期' }}</small></article>
@@ -232,57 +251,46 @@ onMounted(() => { void loadAccountData() })
<section class="account-card">
<div class="section-heading"><div><h3>账户流水</h3><p>每次资金变动均生成唯一业务单号。</p></div></div>
<el-table :data="ledger" row-key="businessNo" empty-text="暂无资金流水">
<el-table-column prop="businessNo" label="业务单号" min-width="190" />
<el-table-column label="变动类型" width="110"><template #default="{ row }">{{ changeLabels[row.changeType as AccountChangeType] }}</template></el-table-column>
<el-table-column label="变动金额" width="145" align="right"><template #default="{ row }"><strong :class="amountClass(row.amount)">{{ Number(row.amount) >= 0 ? '+' : '' }}¥ {{ formatAmount(row.amount) }}</strong></template></el-table-column>
<el-table-column label="实际余额" width="145" align="right"><template #default="{ row }">¥ {{ formatAmount(row.actualBalanceAfter) }}</template></el-table-column>
<el-table-column label="冻结金额" width="145" align="right"><template #default="{ row }">¥ {{ formatAmount(row.frozenBalanceAfter) }}</template></el-table-column>
<el-table-column label="可用余额" width="145" align="right"><template #default="{ row }">¥ {{ formatAmount(row.availableBalanceAfter) }}</template></el-table-column>
<el-table-column label="已用授信" width="145" align="right"><template #default="{ row }">¥ {{ formatAmount(row.creditUsedAfter) }}</template></el-table-column>
<el-table-column label="可用授信" width="145" align="right"><template #default="{ row }">¥ {{ formatAmount(row.availableCreditAfter) }}</template></el-table-column>
<el-table-column prop="operatorName" label="操作人" min-width="130"><template #default="{ row }">{{ row.operatorName || '—' }}</template></el-table-column>
<el-table-column prop="remark" label="备注" min-width="180"><template #default="{ row }">{{ row.remark || '—' }}</template></el-table-column>
<el-table-column label="发生时间" width="170"><template #default="{ row }">{{ formatDateTime(row.createdAt) }}</template></el-table-column>
</el-table>
<div v-if="ledgerTotal" class="pagination-row"><span>共 {{ ledgerTotal }} 条</span><el-pagination :default-current-page="ledgerPage" :page-size="pageSize" layout="prev, pager, next" :total="ledgerTotal" @current-change="changeLedgerPage" /></div>
<DataTable v-model:page="ledgerPage" v-model:page-size="ledgerPageSize" :data="ledger" :columns="ledgerColumns" :total="ledgerTotal" :page-sizes="[10, 20, 50]" row-key="businessNo" empty-text="暂无资金流水">
<template #changeType="{ row }">{{ changeLabels[row.changeType as AccountChangeType] }}</template>
<template #amount="{ row }"><strong :class="amountClass(row.amount)">{{ Number(row.amount) >= 0 ? '+' : '' }}¥ {{ formatAmount(row.amount) }}</strong></template>
<template #actualBalance="{ row }">¥ {{ formatAmount(row.actualBalanceAfter) }}</template>
<template #frozenBalance="{ row }">¥ {{ formatAmount(row.frozenBalanceAfter) }}</template>
<template #availableBalance="{ row }">¥ {{ formatAmount(row.availableBalanceAfter) }}</template>
<template #operatorName="{ row }">{{ row.operatorName || '—' }}</template>
<template #remark="{ row }">{{ row.remark || '—' }}</template>
<template #createdAt="{ row }">{{ formatDateTime(row.createdAt) }}</template>
</DataTable>
</section>
<section class="account-card">
<div class="section-heading"><div><h3>充值申请</h3><p>付款后上传截图,平台审核通过后资金入账。</p></div></div>
<el-table :data="rechargeRequests" row-key="requestNo" empty-text="暂无充值申请">
<el-table-column prop="requestNo" label="充值申请单号" min-width="190" />
<el-table-column label="金额" width="145" align="right"><template #default="{ row }">¥ {{ formatAmount(row.amount) }}</template></el-table-column>
<el-table-column label="付款方式" width="120"><template #default="{ row }">{{ methodLabels[row.paymentMethod as RechargePaymentMethod] }}</template></el-table-column>
<el-table-column prop="voucherFileName" label="付款截图" min-width="170" />
<el-table-column label="状态" width="110"><template #default="{ row }"><el-tag :type="statusType(row.status as RechargeStatus)">{{ statusLabels[row.status as RechargeStatus] }}</el-tag></template></el-table-column>
<el-table-column prop="submittedByName" label="提交人" min-width="120"><template #default="{ row }">{{ row.submittedByName || '—' }}</template></el-table-column>
<el-table-column prop="reviewRemark" label="审核备注" min-width="180"><template #default="{ row }">{{ row.reviewRemark || '—' }}</template></el-table-column>
<el-table-column label="提交时间" width="170"><template #default="{ row }">{{ formatDateTime(row.createdAt) }}</template></el-table-column>
<el-table-column label="审核时间" width="170"><template #default="{ row }">{{ formatDateTime(row.reviewedAt) }}</template></el-table-column>
</el-table>
<div v-if="rechargeTotal" class="pagination-row"><span>共 {{ rechargeTotal }} 条</span><el-pagination :default-current-page="rechargePage" :page-size="pageSize" layout="prev, pager, next" :total="rechargeTotal" @current-change="changeRechargePage" /></div>
<DataTable v-model:page="rechargePage" v-model:page-size="rechargePageSize" :data="rechargeRequests" :columns="rechargeColumns" :total="rechargeTotal" :page-sizes="[10, 20, 50]" row-key="requestNo" empty-text="暂无充值申请">
<template #amount="{ row }">¥ {{ formatAmount(row.amount) }}</template>
<template #status="{ row }"><el-tag :type="statusType(row.status as RechargeStatus)">{{ statusLabels[row.status as RechargeStatus] }}</el-tag></template>
<template #submittedByName="{ row }">{{ row.submittedByName || '—' }}</template>
<template #reviewRemark="{ row }">{{ row.reviewRemark || '—' }}</template>
<template #createdAt="{ row }">{{ formatDateTime(row.createdAt) }}</template>
<template #reviewedAt="{ row }">{{ formatDateTime(row.reviewedAt) }}</template>
</DataTable>
</section>
</template>
<el-dialog v-model="rechargeVisible" title="账户充值" width="760px" :close-on-click-modal="false" :close-on-press-escape="!submitting" :show-close="!submitting" class="recharge-dialog">
<div class="recharge-form">
<label class="field-label" for="recharge-amount">充值金额</label>
<el-input id="recharge-amount" v-model="form.amount" size="large" inputmode="decimal" placeholder="请输入充值金额" aria-label="充值金额"><template #prefix>¥</template></el-input>
<el-input v-if="amountMode === 'custom'" id="recharge-amount" v-model="form.amount" size="large" inputmode="decimal" placeholder="请输入充值金额" aria-label="充值金额"><template #prefix>¥</template></el-input>
<div class="quick-amounts" aria-label="快捷充值金额">
<button v-for="amount in quickAmounts" :key="amount" type="button" :class="{ active: form.amount === amount }" @click="chooseAmount(amount)">¥{{ Number(amount) }}</button>
<button v-for="amount in quickAmounts" :key="amount" type="button" :class="{ active: amountMode === 'preset' && form.amount === amount }" @click="chooseAmount(amount)">¥{{ Number(amount).toLocaleString() }}</button>
<button type="button" :class="{ active: amountMode === 'custom' }" @click="chooseCustomAmount">自定义</button>
</div>
<div class="payment-methods" aria-label="付款方式">
<button v-for="method in (['WECHAT', 'ALIPAY'] as RechargePaymentMethod[])" :key="method" type="button" :class="[`payment-${method.toLowerCase()}`, { active: form.paymentMethod === method }]" @click="form.paymentMethod = method"><span>{{ method === 'WECHAT' ? '微' : '支' }}</span>{{ methodLabels[method] }}</button>
<div v-if="channelReady" class="qr-panel">
<img src="/customer-recharge-qr.png" alt="平台收款二维码" @error="handleQrCodeError">
<div><strong>扫码付款</strong><p>请扫描平台收款二维码,付款后上传截图。</p></div>
</div>
<div v-if="channelsError" class="channel-warning" role="alert">{{ channelsError }}</div>
<div v-else-if="channelReady" class="qr-panel">
<img :src="selectedChannel?.qrCodeUrl || ''" :alt="`${methodLabels[form.paymentMethod]}收款二维码`">
<p>请使用{{ methodLabels[form.paymentMethod] }}扫码完成付款</p>
</div>
<div v-else class="channel-warning" role="status">{{ methodLabels[form.paymentMethod] }}收款二维码尚未配置,暂不能提交充值申请。</div>
<div v-else-if="qrCodeLoadError" class="channel-warning" role="alert">平台收款二维码资源不可用,请联系管理员更新二维码</div>
<div v-else class="channel-warning" role="status">收款二维码尚未配置,暂不能提交充值申请。</div>
<div class="voucher-upload">
<span class="field-label">付款截图</span>
@@ -300,18 +308,21 @@ onMounted(() => { void loadAccountData() })
<style scoped>
.finance-account-page { display: flex; min-height: 420px; flex-direction: column; gap: 16px; }
.account-heading, .section-heading, .pagination-row { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
.account-heading h2, .section-heading h3 { margin: 0; color: var(--navy); }.account-heading h2 { font-size: 22px; }.section-heading h3 { font-size: 16px; }
.account-heading p, .section-heading p { margin: 5px 0 0; color: var(--muted); font-size: 13px; }.account-heading .el-button { display: inline-flex; align-items: center; gap: 6px; }
.balance-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 16px; }
.balance-grid article { display: flex; min-height: 132px; flex-direction: column; padding: 22px 24px; border: 1px solid var(--border); border-radius: 10px; background: #fff; }
.balance-grid span { color: var(--muted); font-size: 13px; }.balance-grid strong { margin-top: 14px; color: var(--navy); font-size: 28px; line-height: 1; }.balance-grid small { margin-top: auto; color: #8a98a8; }.balance-grid .balance-available { border-color: #b9e1db; background: #f3faf8; }.balance-grid .balance-available strong { color: #058b78; }
.account-card { min-width: 0; padding: 18px; border: 1px solid var(--border); border-radius: 8px; background: #fff; }.section-heading { margin-bottom: 14px; }
.pagination-row { padding-top: 14px; color: var(--muted); font-size: 12px; }.amount-positive { color: #078a72; }.amount-negative { color: #b42318; }
.section-heading { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
.section-heading h3 { margin: 0; color: var(--navy); font-size: 16px; }
.section-heading p { margin: 5px 0 0; color: var(--muted); font-size: 13px; }
.balance-summary { display: grid; overflow: hidden; grid-template-columns: repeat(6, minmax(0, 1fr)); border: 1px solid var(--client-border); border-radius: var(--client-radius); background: #fff; }
.balance-summary article { display: flex; min-width: 0; min-height: 104px; flex-direction: column; padding: 14px 16px; border-right: 1px solid var(--client-border); }
.balance-summary article:last-child { border-right: 0; }
.balance-summary span { color: var(--muted); font-size: 12px; }.balance-summary strong { margin-top: 10px; overflow: hidden; color: var(--navy); font-size: 22px; line-height: 1.1; text-overflow: ellipsis; white-space: nowrap; }.balance-summary small { margin-top: auto; overflow: hidden; color: #8a98a8; font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }
.balance-title { display: flex; min-height: 28px; align-items: center; justify-content: space-between; gap: 8px; }.balance-title .el-button { flex: 0 0 auto; height: 28px; padding: 0 9px; }.balance-title .el-button :deep(span) { display: inline-flex; align-items: center; gap: 4px; color: inherit; }
.balance-summary .balance-available { background: #f3faf8; }.balance-summary .balance-available strong { color: #058b78; }
.account-card { min-width: 0; overflow: hidden; border: 1px solid var(--client-border); border-radius: var(--client-radius); background: #fff; }.section-heading { padding: 14px 16px; border-bottom: 1px solid var(--client-border); }
.amount-positive { color: #078a72; }.amount-negative { color: #b42318; }
.account-state { display: flex; min-height: 240px; align-items: center; justify-content: center; flex-direction: column; gap: 12px; border-radius: 8px; }.account-state--error { border: 1px solid #f5c2bd; background: #fff8f7; color: #b42318; }.account-state p { margin: 0; }
.recharge-form { display: flex; flex-direction: column; gap: 16px; }.field-label { color: #4e6075; font-size: 14px; font-weight: 650; }
.quick-amounts, .payment-methods { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; }.quick-amounts button, .payment-methods button { min-height: 54px; border: 1px solid #dbe3ed; border-radius: 10px; background: #f8fafc; color: #506077; cursor: pointer; font: inherit; }.quick-amounts button.active, .payment-methods button.active { border: 2px solid #1671c9; background: #eef8f7; color: #1467b8; font-weight: 700; }
.payment-methods { grid-template-columns: repeat(2, 1fr); }.payment-methods button { display: flex; align-items: center; justify-content: center; gap: 10px; font-size: 17px; font-weight: 650; }.payment-methods span { display: grid; width: 26px; height: 26px; place-items: center; border-radius: 7px; background: #1685ef; color: #fff; font-size: 14px; }.payment-wechat span { background: #09ae42; }
.qr-panel { display: flex; align-items: center; flex-direction: column; gap: 14px; padding: 22px; border: 1px dashed #bed0e4; border-radius: 12px; background: #f8fafc; }.qr-panel img { width: 210px; height: 210px; border: 1px solid #dae3ed; border-radius: 10px; background: #fff; object-fit: contain; }.qr-panel p { margin: 0; color: #62758d; }
.quick-amounts { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 12px; }.quick-amounts button { min-height: 54px; border: 1px solid #dbe3ed; border-radius: 10px; background: #f8fafc; color: #506077; cursor: pointer; font: inherit; }.quick-amounts button.active { border: 2px solid #1671c9; background: #eef8f7; color: #1467b8; font-weight: 700; }
.qr-panel { display: grid; grid-template-columns: 210px minmax(0, 1fr); align-items: center; gap: 24px; padding: 20px; border: 1px solid #dbe3ed; border-radius: 12px; background: #f8fafc; }.qr-panel img { width: 210px; height: 210px; border: 1px solid #dae3ed; border-radius: 10px; background: #fff; object-fit: contain; }.qr-panel strong { color: var(--navy); font-size: 17px; }.qr-panel p { margin: 8px 0 0; color: #62758d; }
.channel-warning { padding: 24px; border: 1px solid #f0d39b; border-radius: 10px; background: #fff9ed; color: #8b5b12; text-align: center; }.voucher-upload { display: grid; gap: 10px; }.voucher-upload :deep(.el-button) { display: inline-flex; align-items: center; gap: 6px; }.submit-error { margin: 0; color: #b42318; font-size: 13px; }.dialog-actions { display: grid; grid-template-columns: repeat(2, 1fr); gap: 16px; }.dialog-actions .el-button { min-height: 46px; margin: 0; }
@media (max-width: 1200px) { .balance-summary { grid-template-columns: repeat(3, minmax(0, 1fr)); }.balance-summary article { border-bottom: 1px solid var(--client-border); }.balance-summary article:nth-child(3n) { border-right: 0; }.balance-summary article:nth-last-child(-n + 3) { border-bottom: 0; } }
</style>
@@ -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', () => {
@@ -114,30 +119,44 @@ test('requires both finance statement authorities for the page permission', () =
})
test('keeps actions first and exposes list, balance, detail, confirmation, receipt, empty and error states', () => {
const actionIndex = viewSource.indexOf('label="操作"')
const billNumberIndex = viewSource.indexOf('label="账单编号"')
const tableDefinition = viewSource.slice(viewSource.indexOf('const tableColumns'))
const actionIndex = tableDefinition.indexOf("label: '操作'")
const billNumberIndex = tableDefinition.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(viewSource, /statement\.customerConfirmationStatus === 'PENDING_CONFIRMATION' && canConfirm\.value/)
assert.match(viewSource, /label: '确认账单'/)
assert.match(viewSource, /run: \(\) => confirmStatement\(statement\)/)
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('uses the shared compact query, table and pagination layout', () => {
assert.match(viewSource, /<QueryToolbar[^>]*layout="grid"/)
assert.match(viewSource, /<DataTable[^>]*:row-actions="rowActions"/)
assert.match(viewSource, /v-model:page="page" v-model:page-size="pageSize"/)
assert.match(viewSource, /:page-sizes="\[20, 50, 100\]"/)
assert.doesNotMatch(viewSource, /class="filter-bar"|class="pagination-row"|class="table-empty"/)
})
test('keeps the existing customer finance view permission mapping', () => {
+63 -104
View File
@@ -1,24 +1,21 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { CircleDollarSign, Eye, RefreshCcw, Search } from '@lucide/vue'
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { RefreshCcw } from '@lucide/vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useRouter } from 'vue-router'
import { ApiError } from '@/api/http'
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 { 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)
@@ -34,7 +31,23 @@ const statusOptions: Array<{ label: string; value: FinanceBillStatus }> = [
{ label: '作废', value: 'VOIDED' },
]
const hasData = computed(() => bills.value.length > 0)
const queryModel = ref<QueryModel>({ keyword: '', status: '' })
const queryFields: QueryField[] = [
{ key: 'keyword', label: '账单编号', type: 'input', placeholder: '请输入账单编号' },
{ key: 'status', label: '账单状态', type: 'select', placeholder: '全部', options: statusOptions },
]
const tableColumns: DataTableColumn[] = [
{ label: '操作', slotName: 'actions', width: 190, fixed: 'left' },
{ label: '账单编号', prop: 'billNo', minWidth: 160 },
{ label: '出账日期', slotName: 'issueDate', width: 120 },
{ label: '到期日', slotName: 'dueDate', width: 120 },
{ label: '账单金额', slotName: 'totalAmount', width: 145, align: 'right' },
{ label: '已收', slotName: 'receivedAmount', width: 145, align: 'right' },
{ label: '未收余额', slotName: 'outstandingAmount', width: 145, align: 'right' },
{ label: '状态', slotName: 'status', width: 110 },
{ label: '客户确认', slotName: 'confirmationStatus', width: 110 },
]
const canConfirm = computed(() => auth.hasPermission('finance:bill:confirm'))
function formatAmount(value: string | null | undefined) {
@@ -48,15 +61,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,24 +104,21 @@ async function loadBills(nextPage = page.value) {
}
}
async function openDetail(row: FinanceBillSummary) {
selectedBillId.value = row.id
await loadDetail(row.id)
}
function openDetail(row: FinanceBillSummary) { void router.push({ name: 'finance-bill-detail', params: { billNo: row.billNo } }) }
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 rowActions(row: object): TableAction[] {
const statement = row as FinanceBillSummary
const actions: TableAction[] = [{ key: 'detail', label: '查看', run: () => openDetail(statement) }]
if (statement.customerConfirmationStatus === 'PENDING_CONFIRMATION' && canConfirm.value) {
actions.push({
key: 'confirm',
label: '确认账单',
loading: confirmingBillId.value === statement.id,
disabled: Boolean(confirmingBillId.value),
run: () => confirmStatement(statement),
})
}
return actions
}
async function confirmStatement(statement: FinanceBillSummary) {
@@ -143,9 +144,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 {
@@ -153,23 +152,19 @@ async function confirmStatement(statement: FinanceBillSummary) {
}
}
function resetFilters() {
filters.keyword = ''
filters.status = undefined
void loadBills(1)
function applyQuery(model: QueryModel) {
filters.keyword = String(model.keyword || '')
filters.status = (model.status || undefined) as FinanceBillStatus | undefined
if (page.value !== 1) page.value = 1
else void loadBills(1)
}
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
function resetFilters(model: QueryModel) {
queryModel.value = model
applyQuery(model)
}
watch([page, pageSize], () => { void loadBills(page.value) })
onMounted(() => { void loadBills(1) })
</script>
@@ -181,65 +176,29 @@ onMounted(() => { void loadBills(1) })
<span class="amount-warning">未收 <strong>¥ {{ formatAmount(totals.outstanding) }}</strong></span>
</section>
<QueryToolbar v-model="queryModel" layout="grid" ariaLabel="客户账单筛选" :fields="queryFields" :search-on-reset="false" @search="applyQuery" @reset="resetFilters" />
<section class="content-card">
<form class="filter-bar" @submit.prevent="loadBills(1)">
<el-input v-model="filters.keyword" clearable placeholder="搜索账单编号" aria-label="搜索账单编号" class="keyword-input">
<template #prefix><Search :size="16" /></template>
</el-input>
<el-select v-model="filters.status" clearable placeholder="全部状态" aria-label="账单状态" class="status-select">
<el-option v-for="option in statusOptions" :key="option.value" :label="option.label" :value="option.value" />
</el-select>
<el-button type="primary" native-type="submit" :loading="loading">查询</el-button>
<el-button :disabled="loading" @click="resetFilters">重置</el-button>
</form>
<div v-if="errorMessage" class="state-panel state-panel--error" role="alert">
<p>{{ errorMessage }}</p>
<el-button @click="loadBills(1)"><RefreshCcw :size="15" />重试</el-button>
</div>
<el-table v-else v-loading="loading" :data="bills" row-key="billNo" class="finance-table" aria-label="客户账单列表">
<el-table-column label="操作" width="190" fixed="left">
<template #default="{ row }"><div class="table-actions"><el-button link type="primary" @click="openDetail(row as FinanceBillSummary)"><Eye :size="15" />查看</el-button><el-button v-if="row.customerConfirmationStatus === 'PENDING_CONFIRMATION' && canConfirm" size="small" type="success" plain :loading="confirmingBillId === row.id" :disabled="Boolean(confirmingBillId)" @click="confirmStatement(row as FinanceBillSummary)">确认账单</el-button></div></template>
</el-table-column>
<el-table-column prop="billNo" label="账单编号" min-width="160" />
<el-table-column prop="issueDate" label="出账日期" width="120"><template #default="{ row }">{{ formatDate(row.issueDate) }}</template></el-table-column>
<el-table-column prop="dueDate" label="到期日" width="120"><template #default="{ row }">{{ formatDate(row.dueDate) }}</template></el-table-column>
<el-table-column label="账单金额" width="145" align="right"><template #default="{ row }">¥ {{ formatAmount(row.totalAmount) }}</template></el-table-column>
<el-table-column label="已收" width="145" align="right"><template #default="{ row }">¥ {{ formatAmount(row.receivedAmount) }}</template></el-table-column>
<el-table-column label="未收余额" width="145" align="right"><template #default="{ row }"><strong :class="{ 'amount-warning': row.outstandingAmount !== '0' }">¥ {{ formatAmount(row.outstandingAmount) }}</strong></template></el-table-column>
<el-table-column label="状态" width="110"><template #default="{ row }"><el-tag :type="statusType(row.status)">{{ statusLabel(row.status) }}</el-tag></template></el-table-column>
<el-table-column label="客户确认" width="110"><template #default="{ row }"><el-tag :type="confirmationType(row.customerConfirmationStatus)">{{ confirmationLabel(row.customerConfirmationStatus) }}</el-tag></template></el-table-column>
<template #empty><div class="table-empty"><CircleDollarSign :size="32" /><strong>暂无账单</strong><span>平台财务出账后,这里会显示本企业账单。</span></div></template>
</el-table>
<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>
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="bills" :columns="tableColumns" :loading="loading" :total="total" :page-sizes="[20, 50, 100]" :row-actions="rowActions" row-key="billNo" empty-text="暂无账单">
<template v-if="errorMessage" #error><div class="state-panel state-panel--error" role="alert"><p>{{ errorMessage }}</p><el-button @click="loadBills(1)"><RefreshCcw :size="15" />重试</el-button></div></template>
<template #issueDate="{ row }">{{ formatDate(row.issueDate) }}</template>
<template #dueDate="{ row }">{{ formatDate(row.dueDate) }}</template>
<template #totalAmount="{ row }">¥ {{ formatAmount(row.totalAmount) }}</template>
<template #receivedAmount="{ row }">¥ {{ formatAmount(row.receivedAmount) }}</template>
<template #outstandingAmount="{ row }"><strong :class="{ 'amount-warning': row.outstandingAmount !== '0' }">¥ {{ formatAmount(row.outstandingAmount) }}</strong></template>
<template #status="{ row }"><el-tag :type="statusType(row.status)">{{ statusLabel(row.status) }}</el-tag></template>
<template #confirmationStatus="{ row }"><el-tag :type="confirmationType(row.customerConfirmationStatus)">{{ confirmationLabel(row.customerConfirmationStatus) }}</el-tag></template>
</DataTable>
</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>
<style scoped>
.finance-bills-page { display: flex; flex-direction: column; gap: 12px; }
.finance-summary { display: flex; flex-wrap: wrap; gap: 8px 26px; color: var(--muted); font-size: 13px; }.finance-summary strong { margin-left: 6px; color: var(--navy); font-size: 18px; }.amount-warning, .amount-warning strong { color: #b45309; }
.content-card { min-width: 0; padding: 16px; border: 1px solid var(--border); border-radius: 8px; background: var(--surface, #fff); }
.filter-bar { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 14px; }.keyword-input { width: 260px; }.status-select { width: 140px; }
.finance-table { width: 100%; }.finance-table :deep(.el-button) { display: inline-flex; align-items: center; gap: 4px; }.table-actions { display: flex; align-items: center; gap: 8px; white-space: nowrap; }
.content-card { min-width: 0; overflow: hidden; border: 1px solid var(--client-border); border-radius: var(--client-radius); background: var(--surface, #fff); }
.state-panel { display: flex; min-height: 180px; align-items: center; justify-content: center; gap: 12px; flex-direction: column; color: var(--muted); text-align: center; }.state-panel p { margin: 0; }.state-panel--error { color: #b42318; background: #fff8f7; border: 1px solid #f5c2bd; border-radius: 6px; }
.table-empty { display: flex; align-items: center; flex-direction: column; gap: 8px; padding: 42px 12px; color: var(--muted); }.table-empty strong { color: var(--navy); }.table-empty span { font-size: 12px; }
.pagination-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding-top: 14px; color: var(--muted); font-size: 12px; }
.detail-content { min-height: 300px; }.detail-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; }.detail-heading h2 { margin: 0 0 5px; color: var(--navy); font-size: 18px; }.detail-heading p { margin: 0; color: var(--muted); font-size: 13px; }.detail-statuses { display: flex; align-items: center; gap: 8px; }.confirmation-panel { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-top: 18px; padding: 14px 16px; border: 1px solid #d7e9e4; border-radius: 8px; background: #f3f9f7; }.confirmation-panel strong { color: var(--navy); }.confirmation-panel p { margin: 4px 0 0; color: var(--muted); font-size: 12px; }.detail-section { margin-top: 22px; }.detail-section h3 { margin: 0 0 10px; color: var(--navy); font-size: 14px; }.sub-empty { padding: 22px; color: var(--muted); font-size: 12px; text-align: center; background: #f7fafb; }.readonly-notice { margin: 22px 0 0; padding: 11px 12px; color: #45615d; background: #edf6f4; font-size: 12px; }
@media (max-width: 720px) { .keyword-input, .status-select { width: 100%; }.filter-bar > .el-button { flex: 1; }.content-card { padding: 12px; }.pagination-row { align-items: flex-start; flex-direction: column; }.finance-summary { align-items: flex-start; flex-direction: column; gap: 6px; } }
@media (max-width: 720px) { .finance-summary { align-items: flex-start; flex-direction: column; gap: 6px; } }
</style>
@@ -42,14 +42,24 @@ const emit = defineEmits<{
</template>
<style scoped>
.first-leg-editor-page { min-width: 0; border: 1px solid #edf0f5; background: #fff; box-shadow: 0 5px 18px rgba(15, 31, 58, .04); }
.editor-page-nav { display: flex; min-height: 52px; align-items: center; justify-content: space-between; gap: 16px; padding: 8px 24px; border-bottom: 1px solid #edf0f5; }
.first-leg-editor-page { min-width: 0; padding-bottom: 72px; border: 1px solid #edf0f5; background: #fff; box-shadow: 0 5px 18px rgba(15, 31, 58, .04); }
.editor-page-nav { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 5px 16px; border-bottom: 1px solid #edf0f5; }
.editor-page-nav span { color: var(--muted); font-size: 12px; }
.editor-page-body { padding: 26px 32px 34px; }
.editor-page-footer { position: sticky; z-index: 3; bottom: 0; display: flex; justify-content: center; padding: 12px 24px; border-top: 1px solid #edf0f5; background: rgba(255, 255, 255, 0.96); box-shadow: 0 -6px 18px rgba(15, 31, 58, .06); backdrop-filter: blur(10px); }
.editor-page-body { padding: 16px 20px 24px; }
.editor-page-footer { position: fixed; z-index: 18; right: 0; bottom: 0; left: 224px; display: flex; min-height: 68px; align-items: center; justify-content: center; padding: 10px 24px; border-top: 1px solid #dfe6ee; background: rgba(255, 255, 255, 0.97); box-shadow: 0 -6px 18px rgba(15, 31, 58, .08); backdrop-filter: blur(10px); }
.editor-page-footer :deep(.editor-actions) { width: min(100%, 900px); justify-content: center; }
.editor-page-footer :deep(.editor-actions > .el-button) { min-width: 132px; min-height: 42px; }
.editor-page-footer :deep(.editor-actions > .el-button:last-child) { min-width: 172px; }
.editor-page-footer :deep(.submit-order-button) { position: relative; isolation: isolate; overflow: hidden; border-color: rgba(255, 255, 255, .24); background: linear-gradient(105deg, #08a9bd 0%, #167da2 28%, #2563eb 62%, #4338ca 100%); background-size: 220% 100%; box-shadow: 0 10px 24px rgba(23, 107, 135, .3), 0 0 0 1px rgba(34, 211, 238, .13), 0 0 26px rgba(37, 99, 235, .12); animation: submit-gradient 5s ease-in-out infinite, submit-pulse 2.8s ease-in-out infinite; }
.editor-page-footer :deep(.submit-order-button::before) { position: absolute; z-index: -1; top: -80%; bottom: -80%; left: -48%; width: 34%; transform: rotate(18deg); background: linear-gradient(90deg, transparent, rgba(255, 255, 255, .68), transparent); content: ''; animation: submit-shine 3.2s ease-in-out infinite; }
.editor-page-footer :deep(.submit-order-button::after) { position: absolute; z-index: -1; inset: 1px; border-radius: 5px; background: linear-gradient(180deg, rgba(255, 255, 255, .13), transparent 48%); content: ''; pointer-events: none; }
.editor-page-footer :deep(.submit-order-button:hover) { transform: translateY(-2px) scale(1.025); border-color: rgba(255, 255, 255, .3); background: linear-gradient(105deg, #08b8cb 0%, #167da2 28%, #2563eb 62%, #4338ca 100%); background-size: 220% 100%; box-shadow: 0 15px 30px rgba(23, 107, 135, .35), 0 0 0 1px rgba(34, 211, 238, .25), 0 0 34px rgba(37, 99, 235, .2); }
.editor-page-footer :deep(.submit-order-button svg) { position: relative; z-index: 1; filter: drop-shadow(0 2px 4px rgba(0, 0, 0, .18)); animation: submit-plane 2.6s ease-in-out infinite; }
@keyframes submit-gradient { 0%, 100% { background-position: 0% 50%; } 50% { background-position: 100% 50%; } }
@keyframes submit-pulse { 0%, 100% { filter: saturate(1); } 50% { filter: saturate(1.12) brightness(1.04); } }
@keyframes submit-shine { 0%, 52% { left: -48%; opacity: 0; } 62% { opacity: 1; } 86%, 100% { left: 122%; opacity: 0; } }
@keyframes submit-plane { 0%, 100% { transform: translate3d(0, 1px, 0) rotate(-3deg); } 50% { transform: translate3d(3px, -2px, 0) rotate(2deg); } }
@media (prefers-reduced-motion: reduce) { .editor-page-footer :deep(.submit-order-button), .editor-page-footer :deep(.submit-order-button::before), .editor-page-footer :deep(.submit-order-button svg) { animation: none; } }
:global(.first-leg-editor-dialog.el-dialog) { display: flex; max-width: 1500px; height: 94vh; margin-bottom: 0; flex-direction: column; overflow: hidden; border-radius: 6px; }
:global(.first-leg-editor-dialog .el-dialog__header) { flex: 0 0 auto; margin-right: 0; padding: 17px 22px; border-bottom: 1px solid var(--border); }
:global(.first-leg-editor-dialog .el-dialog__title) { color: var(--navy); font-size: 16px; font-weight: 700; }
@@ -58,10 +68,11 @@ const emit = defineEmits<{
:global(.first-leg-editor-dialog .el-dialog__footer) { flex: 0 0 auto; padding: 12px 22px; border-top: 1px solid var(--border); background: #fff; }
@media (max-width: 720px) {
.first-leg-editor-page { padding-bottom: 0; }
.editor-page-nav { align-items: flex-start; flex-direction: column; gap: 2px; padding: 10px 16px; }
.editor-page-nav span { padding-left: 1px; line-height: 1.5; }
.editor-page-body { padding: 18px 16px 24px; }
.editor-page-footer { padding: 10px 16px; }
.editor-page-footer { position: sticky; bottom: 0; left: auto; padding: 10px 16px; }
:global(.first-leg-editor-dialog.el-dialog) { width: calc(100vw - 24px) !important; height: 96vh; margin-top: 2vh; }
}
@@ -264,15 +264,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; }
@@ -15,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/)
@@ -82,7 +82,6 @@ watch(() => [props.modelValue, props.inboundCode] as const, ([open]) => {
<div><span>入仓码</span><strong>{{ displayValue(association.inboundCode) }}</strong></div>
</header>
<dl class="inbound-association-grid">
<div><dt>订单号</dt><dd>{{ displayValue(association.orderNo) }}</dd></div>
<div><dt>订单状态</dt><dd>{{ getFirstLegStatusLabel(association.orderStatus) }}</dd></div>
<div><dt>客户名称</dt><dd>{{ displayValue(association.customerName) }}</dd></div>
<div><dt>目的国家</dt><dd>{{ displayValue(association.destinationCountryCode) }}</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'
+12 -6
View File
@@ -1,4 +1,4 @@
import { FileText, Ship, Zap } from '@lucide/vue'
import { FileText, Ship } from '@lucide/vue'
import type { ClientModuleManifest } from '../types.ts'
export const firstLegModule = {
@@ -17,10 +17,17 @@ export const firstLegModule = {
{
path: 'orders/first-leg/create',
name: 'first-leg-order-create',
component: () => import('./views/FirstLegOrdersView.vue'),
props: { createPage: true },
component: () => import('./views/FirstLegOrderCreateHubView.vue'),
release: 'active',
meta: { title: '创建订单', description: '填写发货、目的地和货物信息', permission: 'order:first-leg:create' },
meta: { title: '创建订单', workspaceTitle: '头程订单 · 新建', description: '填写发货、目的地和货物信息', permission: 'order:first-leg:create', keepAlive: false },
},
{
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 +35,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',
@@ -36,7 +43,6 @@ export const firstLegModule = {
component: () => import('./views/QuickOrderView.vue'),
release: 'active',
meta: { title: '快速下单', description: '按渠道、目的仓和产品 SKU 快速生成订单草稿', permission: 'order:first-leg:quick-create' },
navigation: { key: 'first-leg-quick-order', title: '快速下单', icon: Zap },
},
],
} satisfies ClientModuleManifest
@@ -68,9 +68,9 @@ export interface MerchantInboundFeeSnapshot {
chargeRuleId: string
ruleCode: string
chargeRuleVersion: number
carrierRateId: string
carrierRateId: string | null
rateCode: string
carrierRateVersion: number
carrierRateVersion: number | null
customerId: string
customerName: string
originWarehouseId: string
@@ -0,0 +1,41 @@
<script setup lang="ts">
import { computed } from 'vue'
import { FilePenLine, Zap } from '@lucide/vue'
import { useRoute, useRouter } from 'vue-router'
import { useAuthStore } from '@/modules/auth/public'
import FirstLegOrdersView from './FirstLegOrdersView.vue'
import QuickOrderView from './QuickOrderView.vue'
const route = useRoute()
const router = useRouter()
const auth = useAuthStore()
const canQuickOrder = computed(() => auth.hasPermission('order:first-leg:quick-create'))
const activeMode = computed<'standard' | 'quick'>(() => route.query.mode === 'quick' && canQuickOrder.value ? 'quick' : 'standard')
function selectMode(mode: 'standard' | 'quick') {
if (mode === 'quick' && !canQuickOrder.value) return
const query = { ...route.query }
if (mode === 'quick') query.mode = 'quick'
else delete query.mode
void router.replace({ name: 'first-leg-order-create', query })
}
</script>
<template>
<section class="first-leg-create-hub">
<h1 class="create-hub-title">头程下单</h1>
<div class="create-mode-toolbar">
<nav v-if="canQuickOrder" class="order-mode-switch" aria-label="头程下单方式">
<button type="button" :class="{ active: activeMode === 'standard' }" :aria-current="activeMode === 'standard' ? 'page' : undefined" @click="selectMode('standard')"><FilePenLine :size="16" />普通下单</button>
<button type="button" :class="{ active: activeMode === 'quick' }" :aria-current="activeMode === 'quick' ? 'page' : undefined" @click="selectMode('quick')"><Zap :size="16" />快速下单</button>
</nav>
<span v-if="canQuickOrder" class="create-mode-caption">选择下单方式</span>
</div>
<FirstLegOrdersView v-if="activeMode === 'standard'" create-page />
<QuickOrderView v-else />
</section>
</template>
<style scoped>
.first-leg-create-hub{display:grid;min-width:0;gap:10px}.create-hub-title{margin:0;color:var(--navy);font-size:22px;line-height:1.25}.create-mode-toolbar{display:flex;min-width:0;align-items:center;gap:12px}.create-mode-caption{color:#94a0af;font-size:12px;font-weight:500}.order-mode-switch{display:inline-flex;width:max-content;padding:4px;border:1px solid #d5e0e7;border-radius:8px;background:#edf3f6;box-shadow:inset 0 1px 2px rgba(15,23,42,.035)}.order-mode-switch button{display:inline-flex;height:36px;min-width:112px;align-items:center;justify-content:center;gap:7px;padding:0 16px;border:0;border-radius:6px;background:transparent;color:#607087;font:inherit;font-size:12px;font-weight:650;cursor:pointer}.order-mode-switch button.active{background:linear-gradient(105deg,#118fa1,#176b87);color:#fff;box-shadow:0 5px 13px rgba(23,107,135,.24)}.order-mode-switch button:focus-visible{outline:2px solid var(--primary);outline-offset:2px}
</style>
@@ -5,22 +5,25 @@ 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')
const customsDialogSource = readFileSync(new URL('../components/CustomsDocumentsDialog.vue', import.meta.url), 'utf8')
const customsApiSource = readFileSync(new URL('../api/customs.ts', import.meta.url), 'utf8')
const orderStoreSource = readFileSync(new URL('../store/firstLegOrders.ts', import.meta.url), 'utf8')
const createHubSource = readFileSync(new URL('./FirstLegOrderCreateHubView.vue', import.meta.url), 'utf8')
test('routes the confirmed first-leg path to the implemented order view', () => {
assert.match(routerSource, /path: 'orders\/first-leg'[\s\S]*?component: \(\) => import\('\.\/views\/FirstLegOrdersView\.vue'\)[\s\S]*?release: 'active'/)
})
test('opens order creation as a dedicated protected page while keeping draft editing in a large dialog', () => {
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(routerSource, /path: 'orders\/first-leg\/create'[\s\S]*?name: 'first-leg-order-create'[\s\S]*?release: 'active'[\s\S]*?permission: 'order:first-leg:create'[\s\S]*?keepAlive: false/)
assert.match(routerSource, /name: 'first-leg-order-create'[\s\S]*?FirstLegOrderCreateHubView\.vue/)
assert.match(createHubSource, /<FirstLegOrdersView v-if="activeMode === 'standard'" create-page \/>/)
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,13 +41,31 @@ 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|取消订单/)
})
test('returns edited submissions to the refreshed order list instead of opening detail', () => {
const submitSource = source.slice(source.indexOf('async function submitOrder()'), source.indexOf('async function searchOrders'))
assert.match(submitSource, /if \(isCreatePage\.value\)[\s\S]*router\.push\(\{ name: 'first-leg-orders' \}\)[\s\S]*return/)
assert.match(submitSource, /await loadPageData\(\)/)
assert.doesNotMatch(submitSource, /first-leg-order-detail/)
})
test('copies an order into the dedicated create page without reusing order identity', () => {
assert.match(source, /key: 'copy', label: '复制下单'/)
assert.match(source, /name: 'first-leg-order-create'[\s\S]*copyFrom: order\.id/)
assert.match(source, /order\.status === 'DRAFT' \? \{ copyDraft: '1' \}/)
assert.match(source, /sourceIsDraft \? await store\.loadDraftDetail\(id\) : await store\.loadDetail\(id\)/)
assert.match(source, /const draft = draftFromOrder\(order\)[\s\S]*inboundCode: ''/)
assert.match(source, /id: `copied-cargo-\$\{\+\+cargoSequence\}`/)
assert.match(source, /editingId\.value = undefined/)
assert.match(source, /订单数据已复制,请核对后提交/)
})
test('keeps create-page support errors scoped to create-page requests', () => {
assert.match(source, /if \(!isCreatePage\.value && store\.draftLoadError\)/)
})
@@ -71,8 +92,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 +164,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 +176,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 +207,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 +254,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 +270,9 @@ 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]*草稿/)
assert.match(source, /activeOrderQueue\.value === 'draft'[\s\S]*order\.status === 'DRAFT'/)
assert.doesNotMatch(source, /共 \{\{ 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">')), /下载唛头|导出订单信息|创建订单/)
@@ -321,14 +351,15 @@ test('lets the create-page instance own destination loading and exposes a recove
})
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 \? '创建第一笔订单'/)
})
test('auto-fills trusted warehouse and company data instead of asking customers to repeat it', () => {
assert.match(source, /async function selectDestinationWarehouse\(id: string\)/)
assert.match(source, /await store\.loadWarehouseDetail\(warehouse\.id\)/)
assert.match(source, /availableWarehouses\.value\.length === 1/)
assert.match(source, /warehouse\.code\.toUpperCase\(\)\.includes\('YIWU'\)/)
assert.match(source, /warehouse\.name\.includes\('义乌'\)/)
assert.match(source, /availableDestinationWarehouses\.value\.length === 1/)
assert.match(source, /function createPickupPartySnapshot\(\)/)
assert.match(source, /contactName: tenantStore\.company\.contactName/)
@@ -341,8 +372,10 @@ 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, /第 \{\{ index \+ 1 \}\} 条须商检/)
assert.match(source, /form\.customsInspection = form\.items\.some\(\(item\) => item\.woodenProduct\) \? true : null/)
assert.match(source, /木制品<small>勾选后自动商检<\/small>/)
assert.doesNotMatch(source, /label="是否商检"/)
assert.match(source, /<small v-if="item\.woodenProduct">自动商检<\/small>/)
})
test('distills order creation into the confirmed prototype sections with server pricing', () => {
@@ -392,6 +425,36 @@ test('distills order creation into the confirmed prototype sections with server
assert.doesNotMatch(source, /v-model="form\.(?:businessType|origin|destination|priority|tradeTerm)"/)
})
test('matches the prototype two-column create layout and reset action', () => {
assert.match(source, /class="order-form-section prototype-section order-basic-section"/)
assert.match(source, /<span class="order-step-badge">01<\/span><h3>客户下单<\/h3><span class="order-section-context">国内交货与运输信息<\/span>/)
assert.match(source, /<span class="order-step-badge">02<\/span><h3>目的地信息<\/h3><span class="order-section-context">美国目的仓与收件信息<\/span>/)
assert.match(source, /\.is-create-route \.prototype-order-form \{[^}]*grid-template-columns: repeat\(2, minmax\(0, 1fr\)\)/)
assert.match(source, /\.is-create-route \.cargo-section, \.is-create-route \.cost-estimate-section \{ grid-column: 1 \/ -1; \}/)
assert.match(source, /\.is-create-route \.prototype-form-row[^\n]*grid-template-columns: 108px minmax\(0, 1fr\)/)
assert.match(source, /\.is-create-route \.prototype-form-row :deep\(\.el-form-item__label\) \{[^}]*height: 36px[^}]*justify-content: flex-end[^}]*padding: 0 10px 0 0/)
assert.match(source, /\.is-create-route \.compact-transport-grid > :deep\(\.el-form-item__label\) \{[^}]*align-items: center[^}]*text-align: right/)
assert.match(source, /async function resetCreateForm\(\)/)
assert.match(source, /确认清空当前填写的订单内容并恢复默认值/)
assert.match(source, /v-if="isCreatePage"[^>]*@click="resetCreateForm"[^>]*><RotateCcw[^>]*\/>重置/)
assert.match(source, /class="submit-order-button"/)
assert.match(editorShellSource, /\.first-leg-editor-page \{[^}]*padding-bottom: 72px/)
assert.match(editorShellSource, /\.editor-page-footer \{[^}]*position: fixed[^}]*bottom: 0[^}]*left: 224px/)
assert.match(editorShellSource, /submit-gradient 5s ease-in-out infinite/)
assert.match(editorShellSource, /submit-shine 3\.2s ease-in-out infinite/)
assert.match(editorShellSource, /@media \(prefers-reduced-motion: reduce\)/)
assert.match(createHubSource, /<h1 class="create-hub-title">头程下单<\/h1>/)
assert.match(createHubSource, /<div class="create-mode-toolbar">[\s\S]*order-mode-switch[\s\S]*选择下单方式/)
assert.doesNotMatch(createHubSource, /填写货物信息,创建新的头程运输订单/)
assert.match(source, /\.is-create-route \.prototype-order-form \{[^}]*align-items: start/)
assert.doesNotMatch(source, /\.is-create-route \.order-basic-section, \.is-create-route \.destination-section \{ height: 100%; \}/)
assert.doesNotMatch(source, /\.is-create-route \.order-basic-section > \.order-section-heading, \.is-create-route \.destination-section > \.order-section-heading \{[^}]*(?:height|min-height):/)
assert.match(source, /\.is-create-route \.order-basic-section > \.order-section-heading h3, \.is-create-route \.destination-section > \.order-section-heading h3 \{[^}]*font-size: 17px[^}]*line-height: 24px/)
assert.match(source, /\.is-create-route \.order-step-badge \{[^}]*width: 30px[^}]*height: 30px/)
assert.match(source, /\.is-create-route \.order-section-context \{[^}]*margin-left: auto/)
assert.match(source, /\.is-create-route \.warehouse-options :deep\(\.el-radio\.is-checked\) \{[^}]*linear-gradient/)
})
test('shows an optional LX inbound order number for one-click dropship orders', () => {
assert.match(source, /v-if="form\.fulfillmentType === 'ONE_CLICK_DROPSHIP'" label="LX入库单号" prop="lxCode"/)
assert.match(source, /v-model="form\.lxCode"[^>]*placeholder="请输入LX入库单号"/)
@@ -455,6 +518,9 @@ test('shows field-level validation and concise guidance for required order input
assert.match(source, /prop="shipperPartySnapshot\.contactName" required/)
assert.match(source, /prop="consigneePartySnapshot\.address" required/)
assert.match(source, /:prop="`items\.\$\{index\}\.cargoNameCn`" required/)
assert.match(source, /form\.destinationType === 'FBA' \|\| form\.fulfillmentType === 'ONE_CLICK_DROPSHIP' \? '必填' : '选填'/)
assert.match(source, /SKU NO\. 和 UPC NO\. 均为选填项/)
assert.match(source, /一件代发必须填写 SKU/)
assert.match(source, /:prop="`items\.\$\{index\}\.grossWeightKg`" required/)
assert.match(source, /<span class="required-field-mark" aria-hidden="true">\*<\/span>总毛重/)
assert.match(source, /<span class="required-field-mark" aria-hidden="true">\*<\/span>总体积/)
@@ -486,7 +552,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>`))
}
@@ -503,9 +569,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/)
@@ -515,13 +587,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'/)
@@ -547,9 +630,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/)
@@ -565,8 +645,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\(\)/)
})
@@ -595,11 +675,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\)/)
@@ -610,7 +689,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\//)
})
@@ -655,7 +734,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 = ''/)
+303 -211
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,13 @@ 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 routeCopySourceId = computed(() => typeof route.query.copyFrom === 'string' ? route.query.copyFrom : '')
const routeCopySourceIsDraft = computed(() => route.query.copyDraft === '1')
const canViewCompanyProfile = computed(() => auth.hasPermission('company:profile:view'))
const companyAuditLoaded = ref(false)
const companySubmissionBlocked = computed(() => companyAuditLoaded.value && tenantStore.company.auditStatus !== 'ACTIVE')
@@ -78,13 +82,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 +115,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 +149,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 +182,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,11 +193,13 @@ 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' | 'draft'
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 copyingOrderId = ref('')
const detailId = ref<string>()
const saving = ref(false)
const detailLoading = ref(false)
@@ -430,9 +435,6 @@ function quickOrderDraft() {
const form = reactive<FirstLegOrderDraft>(quickOrderDraft())
const serializedDraft = computed(() => JSON.stringify(form))
const editorDirty = computed(() => (editorOpen.value || isCreatePage.value) && serializedDraft.value !== editorBaseline.value)
const woodenCargoLines = computed(() => form.items
.map((item, index) => item.woodenProduct ? index + 1 : null)
.filter((index): index is number => index != null))
const unsavedRouteKey = props.createPage ? 'first-leg-order-create' : 'first-leg-orders'
useUnsavedWorkRegistration({
@@ -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,34 @@ 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,
draft: queryMatchedOrders.value.filter((order) => order.status === 'DRAFT').length,
}))
const filteredOrders = computed(() => queryMatchedOrders.value.filter((order) => {
if (activeOrderQueue.value === 'fee') return order.businessSummary.customerFeeConfirmationRequired
if (activeOrderQueue.value === 'customs') return Boolean(eligibleCustomsDetail(order))
if (activeOrderQueue.value === 'draft') return order.status === 'DRAFT'
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 +557,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 +588,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 +616,8 @@ async function loadPageData() {
if (addressResult.status === 'rejected') supportingDataErrors.value.push(`企业地址簿加载失败:${errorMessage(addressResult.reason)}`)
await autofillWarehouseSelections()
if (isCreatePage.value && routeDraftId.value) await loadRouteDraft(routeDraftId.value)
else if (isCreatePage.value && routeCopySourceId.value) await loadCopiedOrder(routeCopySourceId.value, routeCopySourceIsDraft.value)
if (isDetailPage.value && routeDetailId.value) await loadDetailPage(routeDetailId.value)
supportingDataLoading.value = false
}
@@ -611,6 +634,39 @@ async function loadRouteDraft(id: string) {
markEditorClean()
}
async function loadCopiedOrder(id: string, sourceIsDraft: boolean) {
try {
const detailedOrder = sourceIsDraft ? await store.loadDraftDetail(id) : await store.loadDetail(id)
const draft = copyDraftFromOrder(detailedOrder)
if (draft.destinationType !== 'PRIVATE') await loadDestinationWarehouses(draft.destinationType)
const warehouseAvailable = availableWarehouses.value.some((item) => item.id === draft.receivingWarehouseId)
if (!warehouseAvailable) {
draft.receivingWarehouseId = ''
draft.channelCode = ''
draft.channelName = ''
}
if (draft.destinationType !== 'PRIVATE') {
const destinationAvailable = store.warehouses.some((item) => item.id === draft.destinationWarehouseId && item.warehouseType === draft.destinationType)
if (!destinationAvailable) {
draft.destinationWarehouseId = ''
draft.destinationWarehouseName = ''
draft.amazonWarehouseCode = ''
}
}
editingId.value = undefined
replaceForm(draft)
await Promise.all(form.items.map(ensureProductImagePreview))
if (warehouseAvailable) await loadSelectedWarehouse(draft.receivingWarehouseId)
resetEditorValidation()
markEditorClean()
ElMessage.success('订单数据已复制,请核对后提交')
} catch (error) {
supportingDataErrors.value.push(`复制订单数据失败:${errorMessage(error)}`)
}
}
async function loadDestinationWarehouses(type: 'FBA' | 'OVERSEAS' | 'PRIVATE') {
if (type === 'PRIVATE') return
destinationWarehousesLoading.value = true
@@ -635,12 +691,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()
})
@@ -659,15 +709,13 @@ watch(isCreatePage, (isCreate, wasCreate) => {
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(() => {
@@ -819,6 +867,21 @@ function draftFromOrder(order: FirstLegOrder): FirstLegOrderDraft {
}
}
function copyDraftFromOrder(order: FirstLegOrder): FirstLegOrderDraft {
const draft = draftFromOrder(order)
return {
...draft,
inboundCode: '',
items: draft.items.map((item) => ({
...item,
id: `copied-cargo-${++cargoSequence}`,
productImagePreviewUrl: '',
productImageUploading: false,
productImageError: '',
})),
}
}
function selectChannel(code: string) {
const channel = channelOptions.value.find((item) => item.channelCode === code)
form.channelName = channel?.channelName ?? ''
@@ -1196,8 +1259,12 @@ async function loadSelectedWarehouse(id: string | number | boolean | undefined)
async function autofillWarehouseSelections() {
if (!isCreatePage.value || editingId.value) return
if (!form.receivingWarehouseId && availableWarehouses.value.length === 1) {
form.receivingWarehouseId = availableWarehouses.value[0].id
if (!form.receivingWarehouseId) {
const yiwuWarehouse = availableWarehouses.value.find((warehouse) => warehouse.code.toUpperCase().includes('YIWU'))
?? availableWarehouses.value.find((warehouse) => warehouse.name.includes('义乌'))
if (yiwuWarehouse) form.receivingWarehouseId = yiwuWarehouse.id
}
if (form.receivingWarehouseId) {
await loadSelectedWarehouse(form.receivingWarehouseId)
}
if (form.destinationType !== 'PRIVATE' && !form.destinationWarehouseId && availableDestinationWarehouses.value.length === 1) {
@@ -1211,6 +1278,9 @@ function getOrderRowActions(row: object): TableAction[] {
const actions: TableAction[] = [
{ key: 'detail', label: '详情', run: () => openDetail(order) },
]
if (canCreateOrder.value) {
actions.push({ key: 'copy', label: '复制下单', loading: copyingOrderId.value === order.id, run: () => copyOrder(order) })
}
if (canConfirmInboundFee.value && order.businessSummary.customerFeeConfirmationRequired) {
actions.push({ key: 'fee', label: '确认计费重', run: () => openFeeConfirmation(order) })
}
@@ -1223,6 +1293,22 @@ function getOrderRowActions(row: object): TableAction[] {
return actions
}
async function copyOrder(order: FirstLegOrder) {
if (copyingOrderId.value || !canCreateOrder.value) return
copyingOrderId.value = order.id
try {
await router.push({
name: 'first-leg-order-create',
query: {
copyFrom: order.id,
...(order.status === 'DRAFT' ? { copyDraft: '1' } : {}),
},
})
} finally {
copyingOrderId.value = ''
}
}
async function openCreate() {
if (availableWarehouses.value.length === 0) {
ElMessage.warning(supportingDataLoading.value
@@ -1266,7 +1352,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) {
@@ -1277,15 +1362,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) {
@@ -1297,7 +1385,6 @@ async function openDetail(order: FirstLegOrder) {
}
function openFeeConfirmation(order: FirstLegOrder) {
detailOpen.value = false
resetInboundBillingState()
inboundBillingUnavailableMessage.value = '未推送计费重'
feeConfirmationOpen.value = true
@@ -1305,8 +1392,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() {
@@ -1322,7 +1408,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 = '还未测量推送') {
@@ -1400,6 +1486,27 @@ function closeEditor() {
})
}
async function resetCreateForm() {
if (saving.value) return
if (editorDirty.value) {
try {
await ElMessageBox.confirm('确认清空当前填写的订单内容并恢复默认值?', '重置订单', {
type: 'warning',
confirmButtonText: '确认重置',
cancelButtonText: '继续填写',
})
} catch (error) {
if (error === 'cancel' || error === 'close') return
throw error
}
}
editingId.value = undefined
replaceForm(emptyDraft())
await autofillWarehouseSelections()
resetEditorValidation()
await router.replace({ name: 'first-leg-order-create' })
}
function addCargoItem() {
form.items.push(createEmptyCargoItem(`new-cargo-${++cargoSequence}`))
resetEditorValidation()
@@ -1624,7 +1731,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) : '-',
@@ -1651,7 +1757,7 @@ function exportOrderInformation() {
formatExportDateTime(order.createdAt),
])
downloadCsv('订单信息.csv', [[
'订单号', '入仓码', '渠道', '运输方式', '交货方式', '预约提货时间', '目的仓', '目的仓类型', '当前订单状态', '箱数', '快递公司', '快递单号', 'SKUS',
'入仓码', '渠道', '运输方式', '交货方式', '预约提货时间', '目的仓', '目的仓类型', '当前订单状态', '箱数', '快递公司', '快递单号', 'SKUS',
'总毛重', '总净重', '总体积', '总数量', '计费单价', '计费体积', '计费重量', '备注',
'货件编号', '跟踪编号', '国内仓入库时间', '创建时间',
], ...rows])
@@ -1792,6 +1898,7 @@ function viewImportedDrafts() {
}
async function validateEditorForm() {
form.customsInspection = form.items.some((item) => item.woodenProduct) ? true : null
try {
const policy = await getMerchantInboundCodePolicy()
inboundCodePolicy.value = policy
@@ -1870,7 +1977,7 @@ async function submitOrder() {
const knownCompanyAuditStatus = companyAuditLoaded.value ? tenantStore.company.auditStatus : undefined
const savedOrder = await store.saveDraft(cloneDraft(form), tenantStore.company.companyName, editingId.value)
editingId.value = savedOrder.id
const order = await store.submitSaved(savedOrder.id, knownCompanyAuditStatus)
await store.submitSaved(savedOrder.id, knownCompanyAuditStatus)
markEditorClean()
editorOpen.value = false
if (isCreatePage.value) {
@@ -1879,8 +1986,7 @@ async function submitOrder() {
ElMessage.success('订单已提交,等待平台审核')
return
}
detailId.value = order.id
detailOpen.value = true
await loadPageData()
ElMessage.success('订单已提交,等待平台审核')
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '订单提交失败')
@@ -1898,13 +2004,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',
@@ -1926,17 +2030,25 @@ 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>
<button type="button" :class="{ active: activeOrderQueue === 'draft' }" @click="selectOrderQueue('draft')">草稿<span v-if="orderQueueCounts.draft" class="queue-count">{{ orderQueueCounts.draft }}</span></button>
</nav>
<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>
@@ -1964,7 +2076,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>
@@ -1988,18 +2100,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>
@@ -2032,33 +2141,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>
@@ -2069,10 +2155,10 @@ async function searchOrders(model: QueryModel) {
</div>
<el-alert v-if="isCreatePage && !supportingDataLoading && availableWarehouses.length === 0" title="当前没有可用的平台收货仓" description="请联系平台维护并启用收货仓后再创建订单。" type="warning" :closable="false" show-icon />
<FirstLegOrderEditorShell v-model="editorOpen" :page="isCreatePage" :title="editingId ? '编辑订单草稿' : '创建订单'" :before-close="requestCloseEditor" @back="closeEditor">
<FirstLegOrderEditorShell v-model="editorOpen" :page="isCreatePage" :title="editingId ? '编辑订单草稿' : (routeCopySourceId ? '复制订单' : '创建订单')" :before-close="requestCloseEditor" @back="closeEditor">
<el-form ref="orderFormRef" v-loading="isCreatePage && supportingDataLoading" :model="form" :rules="orderFormRules" label-position="top" class="order-form prototype-order-form" :class="{ 'is-dialog-order-form': !isCreatePage }" scroll-to-error>
<section class="order-form-section prototype-section">
<div class="order-section-heading"><div><h3>客户下单</h3><p>先选择货物如何送到国内收货仓,并填写本次运输安排。</p></div></div>
<section class="order-form-section prototype-section order-basic-section">
<div class="order-section-heading"><span class="order-step-badge">01</span><h3>客户下单</h3><span class="order-section-context">国内交货与运输信息</span></div>
<div class="prototype-form-flow shipping-form-flow">
<el-form-item label="履约类型" prop="fulfillmentType" required class="prototype-form-row prototype-form-row--tabs"><el-radio-group v-model="form.fulfillmentType" class="segmented-options" @change="changeFulfillmentType"><el-radio-button value="STANDARD">普通头程</el-radio-button><el-radio-button value="ONE_CLICK_DROPSHIP">一件代发</el-radio-button></el-radio-group><small v-if="form.fulfillmentType === 'ONE_CLICK_DROPSHIP'" class="field-guidance">测量完成后不办理国内库存入库,将直接推送 PDA 合托。</small></el-form-item>
<el-form-item v-if="form.fulfillmentType === 'ONE_CLICK_DROPSHIP'" label="LX入库单号" prop="lxCode" class="prototype-form-row prototype-form-row--wide"><el-input v-model="form.lxCode" maxlength="128" placeholder="请输入LX入库单号" /><small class="field-guidance">用于绑定领星入库单,提交后可在尾程订单、入库单和出库单中查询。</small></el-form-item>
@@ -2105,14 +2191,13 @@ 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="期望出货日期"><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>
</section>
<section class="order-form-section prototype-section destination-section">
<div class="order-section-heading destination-section-heading"><div><h3>目的地信息</h3></div></div>
<div class="order-section-heading destination-section-heading"><span class="order-step-badge">02</span><h3>目的地信息</h3><span class="order-section-context">美国目的仓与收件信息</span></div>
<div class="prototype-form-flow destination-form-flow">
<el-form-item label="目的仓类型" prop="destinationType" required class="prototype-form-row prototype-form-row--tabs destination-type-row"><el-radio-group v-model="form.destinationType" class="segmented-options destination-type-options" @change="changeDestinationType"><el-radio-button v-if="form.fulfillmentType !== 'ONE_CLICK_DROPSHIP'" value="FBA">FBA 仓</el-radio-button><el-radio-button value="OVERSEAS">海外仓</el-radio-button><el-radio-button v-if="form.fulfillmentType !== 'ONE_CLICK_DROPSHIP'" value="PRIVATE">私人仓</el-radio-button></el-radio-group></el-form-item>
@@ -2150,7 +2235,7 @@ async function searchOrders(model: QueryModel) {
<table class="cargo-edit-table">
<thead><tr>
<th class="cargo-check-cell"><el-checkbox :model-value="allCargoSelected" :indeterminate="someCargoSelected" aria-label="全选货物" @change="toggleAllCargoItems" /></th><th class="cargo-index-cell">#</th>
<th>SKU NO.<small>SKU必填</small></th><th>中文品名</th><th class="cargo-image-column">图片</th><th>英文品名</th><th>报关 HS CODE</th><th>清关 HS CODE</th><th>包装类型</th><th>材质</th><th class="cargo-switch-column">危险品</th><th class="cargo-switch-column">木制品<small>勾选后须商检</small></th>
<th>SKU NO.<small>{{ form.destinationType === 'FBA' || form.fulfillmentType === 'ONE_CLICK_DROPSHIP' ? '必填' : '选填' }}</small></th><th>中文品名</th><th class="cargo-image-column">图片</th><th>英文品名</th><th>报关 HS CODE</th><th>清关 HS CODE</th><th>包装类型</th><th>材质</th><th class="cargo-switch-column">危险品</th><th class="cargo-switch-column">木制品<small>勾选后自动商检</small></th>
<th class="cargo-number-column">箱规长 cm</th><th class="cargo-number-column">箱规宽 cm</th><th class="cargo-number-column">箱规高 cm</th><th class="cargo-number-column">每箱数量</th><th class="cargo-number-column">箱数</th><th class="cargo-number-column"><span class="required-field-mark" aria-hidden="true">*</span>总毛重</th><th class="cargo-number-column"><span class="required-field-mark" aria-hidden="true">*</span>总体积</th><th class="cargo-number-column">总数量</th><th class="cargo-number-column">总净重</th>
<th>清关申报单价 USD</th><th>清关申报金额 USD</th><th>用途</th><th>型号</th><th>品牌</th><th class="cargo-dimensions-column">产品规格 长×宽×高</th><th>货件编号<small>货件编号必填</small></th><th>跟踪编号<small>跟踪编号必填</small></th><th>UPC NO.</th><th>备注</th><th class="cargo-operation-column">操作</th>
</tr></thead>
@@ -2161,7 +2246,7 @@ async function searchOrders(model: QueryModel) {
<td class="cargo-image-column"><div class="cargo-table-image"><img v-if="item.productImagePreviewUrl" :src="item.productImagePreviewUrl" :alt="`${item.cargoNameCn || `货物 ${index + 1}`}产品图片`" /><el-upload v-else :show-file-list="false" accept=".jpg,.jpeg,.png,image/jpeg,image/png" :disabled="item.productImageUploading" :http-request="(options: UploadRequestOptions) => uploadCargoProductImage(item, options)"><el-button text :loading="item.productImageUploading" aria-label="上传图片"><ImagePlus :size="17" /></el-button></el-upload><el-button v-if="item.productImagePreviewUrl" class="cargo-image-remove" text type="danger" aria-label="移除图片" @click="removeCargoProductImage(item)"><Trash2 :size="12" /></el-button></div></td>
<td><el-input v-model="item.cargoNameEn" maxlength="128" placeholder="English Name" /></td><td><el-input v-model="item.hsCode" maxlength="20" placeholder="HS Code" /></td><td><el-input v-model="item.customsHsCode" maxlength="20" placeholder="HS Code" /></td>
<td><span v-if="isAdditionalMixedSku(item, index)" class="shared-carton-value">同箱</span><el-form-item v-else :prop="`items.${index}.packageType`" required :rules="requiredSelectRules('请选择包装类型')"><el-select v-model="item.packageType" @change="syncPackingGroupFields(item)"><el-option label="纸箱" value="CARTON" /><el-option label="托盘" value="PALLET" /><el-option label="木箱" value="WOODEN_CASE" /></el-select></el-form-item></td><td><el-input v-model="item.material" maxlength="128" placeholder="材质" /></td>
<td class="cargo-switch-column"><el-switch v-model="item.dangerousGoods" :aria-label="`第 ${index + 1} 条货物是否危险品`" /></td><td class="cargo-switch-column"><div class="cargo-switch-cell"><el-switch v-model="item.woodenProduct" :aria-label="`第 ${index + 1} 条货物是否木制品`" /><small v-if="item.woodenProduct && form.customsInspection !== true">第 {{ index + 1 }} 条须商检</small></div></td>
<td class="cargo-switch-column"><el-switch v-model="item.dangerousGoods" :aria-label="`第 ${index + 1} 条货物是否危险品`" /></td><td class="cargo-switch-column"><div class="cargo-switch-cell"><el-switch v-model="item.woodenProduct" :aria-label="`第 ${index + 1} 条货物是否木制品`" /><small v-if="item.woodenProduct">自动商检</small></div></td>
<td><span v-if="isAdditionalMixedSku(item, index)" class="shared-carton-value">同上</span><el-input-number v-else v-model="item.lengthCm" :min="0" :precision="2" :controls="false" placeholder="长" @change="syncPackingGroupFields(item)" /></td><td><span v-if="isAdditionalMixedSku(item, index)" class="shared-carton-value">同上</span><el-input-number v-else v-model="item.widthCm" :min="0" :precision="2" :controls="false" placeholder="宽" @change="syncPackingGroupFields(item)" /></td><td><span v-if="isAdditionalMixedSku(item, index)" class="shared-carton-value">同上</span><el-input-number v-else v-model="item.heightCm" :min="0" :precision="2" :controls="false" placeholder="高" @change="syncPackingGroupFields(item)" /></td>
<td><el-form-item :prop="`items.${index}.unitsPerPackage`" required :rules="positiveNumberRules('每箱数量必须大于 0', true)"><el-input-number v-model="item.unitsPerPackage" :min="1" :controls="false" placeholder="每箱数量" @change="syncSkuQuantity(item)" /></el-form-item></td><td><span v-if="isAdditionalMixedSku(item, index)" class="shared-carton-value">同箱</span><el-form-item v-else :prop="`items.${index}.packageCount`" required :rules="positiveNumberRules('箱数必须大于 0', true)"><el-input-number v-model="item.packageCount" :min="1" :controls="false" placeholder="箱数" @change="syncPackingGroupFields(item)" /></el-form-item></td><td><span v-if="isAdditionalMixedSku(item, index)" class="shared-carton-value">同箱</span><el-form-item v-else :prop="`items.${index}.grossWeightKg`" required :rules="positiveNumberRules('总毛重必须大于 0')"><el-input-number v-model="item.grossWeightKg" :min="0" :precision="3" :controls="false" placeholder="总毛重" @change="syncPackingGroupFields(item)" /></el-form-item></td><td><span v-if="isAdditionalMixedSku(item, index)" class="shared-carton-value">同箱</span><el-form-item v-else :prop="`items.${index}.volumeCbm`" required :rules="positiveNumberRules('总体积必须大于 0')"><el-input-number v-model="item.volumeCbm" :min="0" :precision="4" :controls="false" placeholder="总体积" @change="syncPackingGroupFields(item)" /></el-form-item></td><td><span v-if="isAdditionalMixedSku(item, index)" class="derived-sku-value">{{ item.pieces }}</span><el-form-item v-else :prop="`items.${index}.pieces`" required :rules="positiveNumberRules('总数量必须大于 0', true)"><el-input-number v-model="item.pieces" :min="1" :controls="false" placeholder="总数量" /></el-form-item></td><td><span v-if="isAdditionalMixedSku(item, index)" class="shared-carton-value">同箱</span><el-input-number v-else v-model="item.netWeightKg" :min="0" :precision="3" :controls="false" placeholder="净重" @change="syncPackingGroupFields(item)" /></td>
<td><el-input-number v-model="item.unitDeclaredValue" :min="0" :precision="2" :controls="false" placeholder="申报单价" /></td><td><el-input-number v-model="item.declaredValue" :min="0" :precision="2" :controls="false" placeholder="申报总价" /></td><td><el-input v-model="item.usage" maxlength="128" placeholder="用途" /></td><td><el-input v-model="item.model" maxlength="64" placeholder="型号" /></td><td><el-input v-model="item.brand" maxlength="64" placeholder="品牌" /></td>
@@ -2170,7 +2255,7 @@ async function searchOrders(model: QueryModel) {
</tr></tbody>
</table>
</div>
<div class="cargo-table-hint"><span v-if="form.fulfillmentType === 'ONE_CLICK_DROPSHIP'">每个箱组默认装一个 SKU;同一箱内有多个 SKU 时点击“添加 SKU”,箱数、箱规、重量和体积只填写一次。</span><span v-else>SKU NO.、货件编号、跟踪编号为 FBA 货物必填项,UPC NO. 选填</span></div>
<div class="cargo-table-hint"><span v-if="form.fulfillmentType === 'ONE_CLICK_DROPSHIP'">一件代发必须填写 SKU;每个箱组默认装一个 SKU,同一箱内有多个 SKU 时点击“添加 SKU”,箱数、箱规、重量和体积只填写一次。</span><span v-else-if="form.destinationType === 'FBA'">SKU NO.、货件编号、跟踪编号为 FBA 货物必填项,UPC NO. 选填</span><span v-else>SKU NO. 和 UPC NO. 均为选填项</span></div>
</section>
<section class="order-form-section prototype-section cost-estimate-section" aria-label="费用预估">
@@ -2208,7 +2293,7 @@ async function searchOrders(model: QueryModel) {
</div>
</section>
</el-form>
<template #footer><div class="editor-actions"><el-button :disabled="saving" @click="closeEditor">返回</el-button><el-button v-if="editingId ? canEditOrder : canCreateOrder" :loading="saving" @click="saveDraft">保存草稿</el-button><el-button v-if="auth.hasPermission('order:first-leg:submit')" type="primary" :loading="saving" @click="submitOrder"><Send :size="16" />{{ companySubmissionBlocked ? '提交前需完成资料审核' : '提交订单' }}</el-button></div></template>
<template #footer><div class="editor-actions"><el-button v-if="isCreatePage" :disabled="saving" @click="resetCreateForm"><RotateCcw :size="16" />重置</el-button><el-button v-else :disabled="saving" @click="closeEditor">返回</el-button><el-button v-if="editingId ? canEditOrder : canCreateOrder" :loading="saving" @click="saveDraft">保存草稿</el-button><el-button v-if="auth.hasPermission('order:first-leg:submit')" class="submit-order-button" type="primary" :loading="saving" @click="submitOrder"><Send :size="17" />{{ companySubmissionBlocked ? '提交前需完成资料审核' : '提交订单' }}</el-button></div></template>
</FirstLegOrderEditorShell>
<el-dialog v-model="batchImportOpen" title="Excel 批量创建订单草稿" width="680px" destroy-on-close append-to-body>
@@ -2241,7 +2326,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" />
@@ -2250,13 +2335,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>
@@ -2277,10 +2365,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">
@@ -2289,28 +2377,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" />
@@ -2354,7 +2442,6 @@ async function searchOrders(model: QueryModel) {
</div>
<el-empty v-else description="暂无货物明细" :image-size="56" />
</section>
<OrderProgressPanel :order="detailOrder" />
<InboundBillingPanel
:data="inboundBilling"
:loading="inboundBillingLoading"
@@ -2367,8 +2454,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
@@ -2398,9 +2484,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; }
@@ -2413,6 +2500,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; }
@@ -2421,7 +2514,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; }
@@ -2443,33 +2536,61 @@ 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; }
.is-create-route { gap: 0; }
.is-create-route .first-leg-editor-page { width: 100%; }
.is-create-route .supporting-data-error, .is-create-route > .el-alert { margin-bottom: 14px; }
.is-create-route .prototype-order-form { width: 100%; margin: 0; padding: 0 0 16px; }
.is-create-route .prototype-section:not(.cargo-section):not(.cost-estimate-section):not(.destination-section) { width: min(100%, 900px); }
.is-create-route .prototype-order-form { display: grid; width: 100%; grid-template-columns: repeat(2, minmax(0, 1fr)); align-items: start; gap: 10px; margin: 0; padding: 0 0 10px; }
.is-create-route .prototype-section { position: relative; min-width: 0; margin: 0; padding: 0 16px 14px; overflow: hidden; border: 1px solid #d9e4ea; border-radius: 8px; background: #fff; box-shadow: 0 8px 26px rgba(24, 55, 75, .065); transition: border-color .22s ease, box-shadow .22s ease; }
.is-create-route .order-basic-section::before, .is-create-route .destination-section::before { position: absolute; z-index: 2; top: 0; right: 0; left: 0; height: 3px; background: linear-gradient(90deg, #16a394, #176b87 55%, transparent 92%); content: ''; }
.is-create-route .destination-section::before { background: linear-gradient(90deg, #2563eb, #4f46e5 55%, transparent 92%); }
.is-create-route .order-basic-section:focus-within { border-color: rgba(22, 163, 148, .45); box-shadow: 0 12px 34px rgba(22, 113, 113, .1); }
.is-create-route .destination-section:focus-within { border-color: rgba(37, 99, 235, .4); box-shadow: 0 12px 34px rgba(37, 99, 235, .1); }
.is-create-route .prototype-section + .prototype-section { margin-top: 0; }
.is-create-route .cargo-section, .is-create-route .cost-estimate-section { grid-column: 1 / -1; }
.is-create-route .order-basic-section > .order-section-heading, .is-create-route .destination-section > .order-section-heading { box-sizing: border-box; align-items: center; margin: 0 -16px 12px; padding: 10px 16px; border-bottom: 1px solid #e7ecf2; background: linear-gradient(90deg, #f8fbfc, #fff 58%); }
.is-create-route .destination-section > .order-section-heading { background: linear-gradient(90deg, #f7f9ff, #fff 58%); }
.is-create-route .order-basic-section > .order-section-heading h3, .is-create-route .destination-section > .order-section-heading h3 { margin: 0; color: var(--navy); font-size: 17px; font-weight: 700; line-height: 24px; }
.is-create-route .order-step-badge { display: grid; width: 30px; height: 30px; flex: 0 0 30px; color: #fff; border-radius: 8px; background: linear-gradient(135deg, #16a394, #176b87); box-shadow: 0 5px 12px rgba(23, 107, 135, .2); font-size: 11px; font-weight: 800; place-items: center; }
.is-create-route .destination-section .order-step-badge { background: linear-gradient(135deg, #367ae8, #4f46e5); box-shadow: 0 5px 12px rgba(37, 99, 235, .2); }
.is-create-route .order-section-context { margin-left: auto; color: #8a98a8; font-size: 11px; font-weight: 500; }
.is-create-route .cargo-section > .order-section-heading, .is-create-route .cost-estimate-section > .order-section-heading { align-items: center; margin: 0 -16px 10px; padding: 9px 16px; border-bottom: 1px solid #e7ecf2; }
.is-create-route .order-section-heading p { margin: 0; color: #8a98a8; }
.is-create-route .prototype-form-flow, .is-create-route .destination-form-flow { width: 100%; max-width: none; }
.is-create-route .prototype-form-row, .is-create-route .prototype-form-row--wide, .is-create-route .prototype-form-row--compact, .is-create-route .prototype-form-row--short, .is-create-route .prototype-form-row--tabs { grid-template-columns: 108px minmax(0, 1fr); }
.is-create-route .prototype-form-row :deep(.el-form-item__label) { display: flex; height: 36px; min-height: 36px; align-items: center; justify-content: flex-end; padding: 0 10px 0 0; font-size: 13px; line-height: 1.35; text-align: right; }
.is-create-route .segmented-options, .is-create-route .destination-type-options { width: 100%; max-width: 560px; background: #f3f6f9; }
.is-create-route .order-basic-section .segmented-options :deep(.el-radio-button__original-radio:checked + .el-radio-button__inner) { background: linear-gradient(105deg, #1596a6, #176b87); }
.is-create-route .destination-section .segmented-options :deep(.el-radio-button__original-radio:checked + .el-radio-button__inner) { background: linear-gradient(105deg, #367ae8, #4f46e5); box-shadow: 0 4px 10px rgba(37, 99, 235, .2); }
.is-create-route .compact-transport-grid { width: 100%; margin-top: 4px; }
.is-create-route .compact-transport-grid > :deep(.el-form-item), .is-create-route .compact-transport-grid > :deep(.el-form-item:nth-child(1)), .is-create-route .compact-transport-grid > :deep(.el-form-item:nth-child(6)), .is-create-route .compact-transport-grid > :deep(.el-form-item:nth-child(7)) { grid-template-columns: 108px minmax(0, 1fr); }
.is-create-route .compact-transport-grid > :deep(.el-form-item__label) { display: flex; height: 36px; min-height: 36px; align-items: center; justify-content: flex-end; padding: 0 10px 0 0; line-height: 1.35; text-align: right; }
.is-create-route .selected-destination { width: auto; margin-left: 108px; }
.is-create-route .shipping-form-flow .prototype-form-row, .is-create-route .destination-form-flow .prototype-form-row, .is-create-route .compact-transport-grid > :deep(.el-form-item) { margin-bottom: 5px; }
.is-create-route .prototype-form-row :deep(.el-input__wrapper), .is-create-route .prototype-form-row :deep(.el-select__wrapper), .is-create-route .compact-transport-grid :deep(.el-input__wrapper), .is-create-route .compact-transport-grid :deep(.el-select__wrapper) { min-height: 36px; }
.is-create-route .segmented-options :deep(.el-radio-button__inner) { min-height: 36px; padding: 8px 12px; }
.is-create-route .warehouse-options { gap: 8px; padding-top: 1px; }
.is-create-route .warehouse-options :deep(.el-radio) { min-height: 32px; margin: 0; padding: 5px 11px; border: 1px solid #d7e0e7; border-radius: 7px; background: #f8fafb; color: #596779; transition: color .18s ease, border-color .18s ease, background .18s ease, box-shadow .18s ease, transform .18s ease; }
.is-create-route .warehouse-options :deep(.el-radio:hover) { transform: translateY(-1px); border-color: #91b8c3; background: #f3fafb; color: var(--primary); }
.is-create-route .warehouse-options :deep(.el-radio__input) { position: absolute; width: 1px; height: 1px; overflow: hidden; opacity: 0; pointer-events: none; }
.is-create-route .warehouse-options :deep(.el-radio__label) { padding-left: 0; color: inherit; }
.is-create-route .warehouse-options :deep(.el-radio.is-checked) { border-color: var(--primary); background: linear-gradient(105deg, #1596a6, #176b87); box-shadow: 0 4px 10px rgba(23, 107, 135, .2); color: #fff; }
.is-create-route .cargo-table-heading, .is-create-route .cost-estimate-section .order-section-heading { margin-bottom: 10px; }
.order-form { width: min(100%, 1000px); margin: 0; padding: 0 0 12px; }
.order-form.is-dialog-order-form { width: 100%; max-width: none; }
.is-dialog-order-form > .cargo-section, .is-dialog-order-form > .cost-estimate-section { width: 100%; max-width: none; }
.order-form :deep(.el-form-item__error) { position: static; padding-top: 6px; overflow-wrap: anywhere; line-height: 1.45; }
.order-form-section + .order-form-section { margin-top: 30px; padding-top: 28px; border-top: 1px solid #edf0f5; }
.order-form-section + .order-form-section { border-top: 1px solid #edf0f5; }
.order-form-section h3 { margin: 0 0 16px; color: var(--navy); font-size: 16px; }
.order-form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); column-gap: 18px; }
.order-form-grid > *, .cargo-form-grid > * { min-width: 0; }
@@ -2539,6 +2660,7 @@ async function searchOrders(model: QueryModel) {
.compact-transport-grid :deep(.el-form-item__label) { color: #4d5a6c; font-size: 13px; }
.shipping-form-flow { max-width: 900px; }
.prototype-order-form > .prototype-section:first-child { width: 100%; }
.is-create-route .prototype-order-form > .prototype-section:first-child > .order-section-heading { margin-top: 0; }
.shipping-form-flow .prototype-form-row { margin-bottom: 10px; }
.shipping-form-flow .prototype-form-row--tabs { margin-bottom: 12px; }
.compact-transport-grid { display: grid; width: 100%; grid-template-columns: 1fr; gap: 0; margin: 6px 0 0; }
@@ -2619,51 +2741,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; }
@@ -2677,20 +2797,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)); }
@@ -2706,7 +2813,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; }
@@ -2728,38 +2835,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; }
@@ -6,14 +6,23 @@ const source = readFileSync(new URL('./QuickOrderView.vue', import.meta.url), 'u
const routesSource = readFileSync(new URL('../routes.ts', import.meta.url), 'utf8')
const contractsSource = readFileSync(new URL('../../tenant-management/contracts.ts', import.meta.url), 'utf8')
const orderViewSource = readFileSync(new URL('./FirstLegOrdersView.vue', import.meta.url), 'utf8')
const createHubSource = readFileSync(new URL('./FirstLegOrderCreateHubView.vue', import.meta.url), 'utf8')
test('exposes quick order as an active first-leg route with its dedicated permission', () => {
test('keeps quick order active but moves it from the menu into the head-leg create page', () => {
assert.match(routesSource, /path: 'first-leg\/quick-order'[\s\S]*?release: 'active'[\s\S]*?permission: 'order:first-leg:quick-create'/)
assert.match(contractsSource, /code: 'order:first-leg:quick-create'[\s\S]*?label: '快速下单'/)
assert.match(contractsSource, /'order:first-leg:quick-create': \['merchant:order:quick-create', 'merchant:order:create'\]/)
assert.match(contractsSource, /FRONTEND_PERMISSIONS_REQUIRING_ALL_BACKEND_CODES[\s\S]*?'order:first-leg:quick-create'/)
assert.match(routesSource, /path: 'orders\/first-leg\/:draftId\/edit'[\s\S]*?name: 'first-leg-order-edit'[\s\S]*?permission: 'order:first-leg:edit'/)
assert.match(orderViewSource, /store\.loadDraftDetail\(id\)/)
assert.doesNotMatch(routesSource, /navigation: \{ key: 'first-leg-quick-order'/)
assert.match(routesSource, /path: 'orders\/first-leg\/create'[\s\S]*?FirstLegOrderCreateHubView\.vue/)
assert.match(createHubSource, />普通下单<\/button>/)
assert.match(createHubSource, />快速下单<\/button>/)
assert.match(createHubSource, /<h1[^>]*>头程下单<\/h1>/)
assert.match(createHubSource, /auth\.hasPermission\('order:first-leg:quick-create'\)/)
assert.match(createHubSource, /<FirstLegOrdersView v-if="activeMode === 'standard'" create-page \/>/)
assert.match(createHubSource, /<QuickOrderView v-else \/>/)
})
test('keeps the primary quick-order table focused on inbound code policy, channel, destination and cargo', () => {
@@ -78,8 +87,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() ?? '',
}]
})
@@ -400,12 +398,9 @@ async function submitQuickOrder() {
else {
activeBatchKey.value = ''
await Promise.all(successes.map((item) => store.loadDraftDetail(item.draftId)))
workspace.close(['first-leg-quick-order'])
workspace.close(['first-leg-quick-order', 'first-leg-order-create'])
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><div class="warehouse-list-actions"><button class="warehouse-link"[^>]*>.*?查看<\/button>.*?<\/div><\/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"/)
})
@@ -28,23 +28,32 @@ test('does not accept browser-selected customer or user scope in the API boundar
test('keeps customer message actions first and covers the requested local states', async () => {
const source = await readFile(new URL('views/NotificationsView.vue', moduleRoot), 'utf8')
const operationColumn = source.indexOf('label="操作"')
const titleColumn = source.indexOf('label="消息标题"')
const operationColumn = source.indexOf("label: '操作'")
const titleColumn = source.indexOf("label: '消息标题'")
assert.ok(operationColumn >= 0 && operationColumn < titleColumn)
assert.match(source, /全部标为已读/)
assert.match(source, /标为已读/)
assert.match(source, /state-panel--error/)
assert.match(source, /notification-state--error/)
assert.match(source, /暂无消息/)
assert.doesNotMatch(source, /清空已读|删除消息/)
assert.match(source, /Promise\.all\(\[load\(\), notifications\.refreshUnreadCount\(\)\]\)/)
assert.match(source, /auth\.hasPermission\('merchant:notification:read'\)/)
assert.match(source, /auth\.hasPermission\('merchant:notification:read-all'\)/)
assert.match(source, /v-if="canMarkAllRead"/)
assert.match(source, /v-if="canMarkRead && row\.status === 'UNREAD'"/)
assert.match(source, /canMarkRead\.value && row\.status === 'UNREAD'/)
assert.match(source, /notificationCategoryLabel\(row\.type\)/)
assert.match(source, /notificationSentAt\(row\.sentAt\)/)
})
test('uses the shared compact list language without a repeated page hero', async () => {
const source = await readFile(new URL('views/NotificationsView.vue', moduleRoot), 'utf8')
assert.match(source, /<QueryToolbar[^>]*layout="grid"/)
assert.match(source, /<DataTable[^>]*:row-actions="rowActions"/)
assert.match(source, /#toolbar-actions/)
assert.match(source, /empty-text="暂无消息"/)
assert.doesNotMatch(source, /#toolbar>|page-heading|eyebrow|table-empty|共 \{\{ total \}\} 条/)
})
test('uses friendly notification values while keeping unknown server codes visible', async () => {
const source = await readFile(new URL('presentation.ts', moduleRoot), 'utf8')
assert.match(source, /ORDER: '订单消息'/)
@@ -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>
@@ -1,13 +1,16 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { Bell, RefreshCcw } from '@lucide/vue'
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { CheckCheck, RefreshCcw } from '@lucide/vue'
import { useRouter } from 'vue-router'
import { ApiError } from '@/api/http'
import { useNotificationsStore } from '../public'
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 { getMyNotifications, markAllMyNotificationsRead, markMyNotificationRead } from '../api/notifications'
import type { NotificationPage, NotificationStatus, NotificationSummary, NotificationType } from '../contracts'
import type { NotificationStatus, NotificationSummary } from '../contracts'
import { notificationCategoryLabel, notificationSentAt } from '../presentation'
import { useNotificationsStore } from '../public'
const router = useRouter()
const auth = useAuthStore()
@@ -15,44 +18,121 @@ const notifications = useNotificationsStore()
const canMarkRead = computed(() => auth.hasPermission('merchant:notification:read'))
const canMarkAllRead = computed(() => auth.hasPermission('merchant:notification:read-all'))
const loading = ref(false)
const markingAll = ref(false)
const errorMessage = ref('')
const page = ref<NotificationPage>({ records: [], total: 0, page: 1, size: 20, pages: 0 })
const filters = reactive<{ status?: NotificationStatus; type?: NotificationType }>({})
const records = ref<NotificationSummary[]>([])
const total = ref(0)
const currentPage = ref(1)
const pageSize = ref(20)
const filters = reactive({ status: '', type: '' })
const queryModel = computed<QueryModel>({ get: () => ({ ...filters }), set: (value) => Object.assign(filters, value) })
const queryFields: QueryField[] = [
{ key: 'status', label: '消息状态', type: 'select', options: [{ label: '未读', value: 'UNREAD' }, { label: '已读', value: 'READ' }] },
{ key: 'type', label: '消息分类', type: 'input', placeholder: '请输入消息分类' },
]
const tableColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', slotName: 'actions', width: 128, fixed: 'left' },
{ prop: 'title', label: '消息标题', minWidth: 210 },
{ prop: 'summary', label: '内容摘要', minWidth: 320 },
{ key: 'type', label: '分类', slotName: 'type', width: 130 },
{ key: 'sentAt', label: '发送时间', slotName: 'sentAt', width: 170 },
{ key: 'status', label: '状态', slotName: 'status', width: 90, align: 'center' },
]
function messageOf(error: unknown) { return error instanceof ApiError ? error.message : '消息加载失败,请稍后重试' }
async function load(nextPage = page.value.page) {
loading.value = true; errorMessage.value = ''
try { page.value = await getMyNotifications({ ...filters, page: nextPage, size: page.value.size }) } catch (error) { errorMessage.value = messageOf(error); page.value = { records: [], total: 0, page: nextPage, size: 20, pages: 0 } } finally { loading.value = false }
function messageOf(error: unknown) {
return error instanceof ApiError ? error.message : '消息加载失败,请稍后重试'
}
async function markRead(row: NotificationSummary) { if (!canMarkRead.value) return; try { await markMyNotificationRead(row.id); await Promise.all([load(), notifications.refreshUnreadCount()]) } catch (error) { errorMessage.value = messageOf(error) } }
async function markAllRead() { if (!canMarkAllRead.value) return; try { await markAllMyNotificationsRead(); await Promise.all([load(), notifications.refreshUnreadCount()]) } catch (error) { errorMessage.value = messageOf(error) } }
function openDetail(row: NotificationSummary) { void router.push({ name: 'notification-detail', params: { notificationId: row.id } }) }
async function load() {
loading.value = true
errorMessage.value = ''
try {
const result = await getMyNotifications({
status: filters.status ? filters.status as NotificationStatus : undefined,
type: filters.type.trim() || undefined,
page: currentPage.value,
size: pageSize.value,
})
records.value = result.records
total.value = result.total
} catch (error) {
records.value = []
total.value = 0
errorMessage.value = messageOf(error)
} finally {
loading.value = false
}
}
function search() {
if (currentPage.value === 1) void load()
else currentPage.value = 1
}
function reset() {
Object.assign(filters, { status: '', type: '' })
search()
}
async function markRead(row: NotificationSummary) {
if (!canMarkRead.value) return
try {
await markMyNotificationRead(row.id)
await Promise.all([load(), notifications.refreshUnreadCount()])
} catch (error) {
errorMessage.value = messageOf(error)
}
}
async function markAllRead() {
if (!canMarkAllRead.value || markingAll.value) return
markingAll.value = true
try {
await markAllMyNotificationsRead()
await Promise.all([load(), notifications.refreshUnreadCount()])
} catch (error) {
errorMessage.value = messageOf(error)
} finally {
markingAll.value = false
}
}
function openDetail(row: NotificationSummary) {
void router.push({ name: 'notification-detail', params: { notificationId: row.id } })
}
function rowActions(candidate: object): TableAction[] {
const row = candidate as NotificationSummary
const actions: TableAction[] = [{ key: 'detail', label: '详情', run: () => openDetail(row) }]
if (canMarkRead.value && row.status === 'UNREAD') actions.push({ key: 'read', label: '标为已读', run: () => markRead(row) })
return actions
}
onMounted(() => { void Promise.all([load(), notifications.refreshUnreadCount()]) })
watch([currentPage, pageSize], ([nextPage, nextSize], [previousPage, previousSize]) => {
if (nextSize !== previousSize && nextPage !== 1) { currentPage.value = 1; return }
void load()
})
</script>
<template>
<div class="notification-page">
<header class="page-heading"><div><p class="eyebrow">消息中心</p><h1>我的消息</h1><p>仅显示当前登录企业和成员可见的站内消息。</p></div><Bell :size="34" stroke-width="1.5" aria-hidden="true" /></header>
<section class="content-card">
<form class="filter-bar" @submit.prevent="load(1)"><el-select v-model="filters.status" clearable placeholder="全部状态" aria-label="消息状态"><el-option label="未读" value="UNREAD" /><el-option label="已读" value="READ" /></el-select><el-input v-model="filters.type" clearable maxlength="64" placeholder="消息分类" aria-label="消息分类" /><el-button type="primary" native-type="submit" :loading="loading">查询</el-button><el-button :disabled="loading" @click="filters.status = undefined; filters.type = undefined; load(1)">重置</el-button><el-button v-if="canMarkAllRead" class="mark-all" :disabled="loading" @click="markAllRead">全部标为已读</el-button></form>
<div v-if="errorMessage" class="state-panel state-panel--error" role="alert"><p>{{ errorMessage }}</p><el-button @click="load(page.page)"><RefreshCcw :size="15" />重试</el-button></div>
<el-table v-else v-loading="loading" :data="page.records" row-key="id" aria-label="我的消息列表"><el-table-column label="操作" width="150" fixed="left"><template #default="{ row }"><el-button link type="primary" @click="openDetail(row as NotificationSummary)">查看</el-button><el-button v-if="canMarkRead && row.status === 'UNREAD'" link type="primary" @click="markRead(row as NotificationSummary)">标为已读</el-button></template></el-table-column><el-table-column prop="title" label="消息标题" min-width="180" /><el-table-column prop="summary" label="内容摘要" min-width="260" /><el-table-column label="分类" width="140"><template #default="{ row }">{{ notificationCategoryLabel(row.type) }}</template></el-table-column><el-table-column label="发送时间" width="180"><template #default="{ row }">{{ notificationSentAt(row.sentAt) }}</template></el-table-column><el-table-column label="状态" width="90"><template #default="{ row }"><el-tag :type="row.status === 'UNREAD' ? 'danger' : 'info'">{{ row.status === 'UNREAD' ? '未读' : '已读' }}</el-tag></template></el-table-column><template #empty><div class="table-empty"><Bell :size="32" /><strong>暂无消息</strong><span>当前没有可查看的站内消息。</span></div></template></el-table>
<div v-if="!errorMessage && page.total > 0" class="pagination-row"><span>共 {{ page.total }} 条</span><el-pagination :current-page="page.page" :page-size="page.size" layout="prev, pager, next" :total="page.total" @current-change="load" /></div>
</section>
</div>
<section class="notification-page client-ui" aria-label="我的消息">
<QueryToolbar v-model="queryModel" layout="grid" ariaLabel="消息筛选" :fields="queryFields" :search-on-reset="false" @search="search" @reset="reset" />
<div class="notification-table-card">
<DataTable v-model:page="currentPage" v-model:page-size="pageSize" :data="records" :columns="tableColumns" :loading="loading" :total="total" :page-sizes="[10, 20, 50, 100]" :row-actions="rowActions" row-key="id" empty-text="暂无消息">
<template #toolbar-actions>
<el-button v-if="canMarkAllRead" :loading="markingAll" :disabled="loading || total === 0" @click="markAllRead"><CheckCheck :size="16" />全部标为已读</el-button>
</template>
<template v-if="errorMessage" #error><div class="notification-state notification-state--error" role="alert"><p>{{ errorMessage }}</p><el-button @click="load"><RefreshCcw :size="15" />重试</el-button></div></template>
<template #type="{ row }">{{ notificationCategoryLabel(row.type) }}</template>
<template #sentAt="{ row }">{{ notificationSentAt(row.sentAt) }}</template>
<template #status="{ row }"><el-tag :type="row.status === 'UNREAD' ? 'danger' : 'info'" effect="light">{{ row.status === 'UNREAD' ? '未读' : '已读' }}</el-tag></template>
</DataTable>
</div>
</section>
</template>
<style scoped>
.notification-page{display:flex;flex-direction:column;gap:16px}.page-heading{display:flex;justify-content:space-between;color:var(--navy)}.page-heading h1{margin:2px 0 5px;font-size:24px}.page-heading p:not(.eyebrow){margin:0;color:var(--muted);font-size:13px}.eyebrow{margin:0;color:var(--primary);font-size:11px;font-weight:700;letter-spacing:.08em;text-transform:uppercase}.content-card{padding:16px;border:1px solid var(--border);border-radius:8px;background:var(--surface,#fff)}.filter-bar{display:flex;flex-wrap:wrap;gap:8px;margin-bottom:14px}.mark-all{margin-left:auto}.state-panel{display:flex;min-height:180px;align-items:center;justify-content:center;flex-direction:column;gap:12px;text-align:center}.state-panel--error{color:#b42318;background:#fff8f7;border:1px solid #f5c2bd;border-radius:6px}.state-panel p{margin:0}.table-empty{display:flex;align-items:center;flex-direction:column;gap:8px;padding:42px 12px;color:var(--muted)}.table-empty strong{color:var(--navy)}.table-empty span{font-size:12px}.pagination-row{display:flex;align-items:center;justify-content:space-between;gap:12px;padding-top:14px;color:var(--muted);font-size:12px}@media(max-width:720px){.content-card{padding:12px}.mark-all{margin-left:0;width:100%}.filter-bar .el-select,.filter-bar>.el-button{flex:1}.pagination-row{align-items:flex-start;flex-direction:column}.page-heading h1{font-size:21px}}
/* Keep the notification filters compact and aligned with the other client lists. */
.filter-bar { align-items: flex-end; }
.filter-bar > .el-select { width: 190px; }
.filter-bar > .el-input { width: 260px; }
.filter-bar > .el-button { flex: 0 0 auto; min-height: 32px; }
.filter-bar > .el-button + .el-button { margin-left: 0; }
@media (max-width: 720px) {
.filter-bar { align-items: stretch; flex-direction: column; }
.filter-bar > .el-select, .filter-bar > .el-input { width: 100%; }
.filter-bar > .el-button { width: 100%; }
}
.notification-page{display:grid;min-width:0;gap:12px}.notification-table-card{min-width:0;overflow:hidden;border:1px solid var(--client-border);border-radius:var(--client-radius);background:#fff}.notification-state{display:flex;min-height:160px;align-items:center;justify-content:center;gap:10px;flex-direction:column;color:var(--client-muted)}.notification-state p{margin:0}.notification-state--error{margin:16px;border:1px solid #f5c2bd;border-radius:6px;background:#fff8f7;color:#b42318}
</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')
@@ -30,7 +31,9 @@ test('uses only authenticated merchant delivery endpoints without customer scope
})
test('keeps list, read-only detail and create permissions independent in active routes', () => {
assert.match(routesSource, /path: 'orders\/delivery'[\s\S]*permission: 'merchant:warehouse:delivery:list'[\s\S]*navigation:/)
const listRoute = routesSource.match(/path: 'orders\/delivery',[\s\S]*?\n\s*},/)?.[0] ?? ''
assert.match(listRoute, /permission: 'merchant:warehouse:delivery:list'/)
assert.doesNotMatch(listRoute, /navigation:/)
assert.match(routesSource, /path: 'orders\/delivery\/create'[\s\S]*DeliveryOrderCreateView\.vue[\s\S]*permission: 'merchant:warehouse:delivery:create'/)
assert.match(routesSource, /path: 'orders\/delivery\/:deliveryNo'[\s\S]*DeliveryOrderDetailView\.vue[\s\S]*props: true[\s\S]*permission: 'merchant:warehouse:delivery:list'/)
assert.match(routesSource, /orders-delivery-create[\s\S]*keepAlive: false/)
@@ -38,19 +41,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 +68,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)
})
@@ -59,17 +60,29 @@ test('renders inbound workflow statuses in Chinese and keeps desktop values on o
['SIGNED', '已签收'], ['PICKING', '拣货中'], ['PARTIAL_INBOUND', '部分入库'],
]) assert.match(view, new RegExp(`${status}: '${label}'`))
assert.match(view, /COMPLETED: '全部入库'/)
assert.match(view, /label="状态" width="120" show-overflow-tooltip/)
assert.match(view, /\.detail-grid div \{ min-width: 0;/)
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/)
})
+11 -5
View File
@@ -1,4 +1,4 @@
import { ClipboardList, Package, ReceiptText, Truck } from '@lucide/vue'
import { ClipboardList, ReceiptText } from '@lucide/vue'
import type { ClientModuleManifest } from '../types.ts'
export const orderManagementModule = {
@@ -19,7 +19,14 @@ export const orderManagementModule = {
component: () => import('./views/InboundOrdersView.vue'),
release: 'active',
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',
@@ -27,14 +34,13 @@ export const orderManagementModule = {
component: () => import('./views/DeliveryOrdersView.vue'),
release: 'active',
meta: { title: '派送订单列表', description: '查询海外仓派送订单', permission: 'merchant:warehouse:delivery:list' },
navigation: { key: 'orders-delivery', icon: Truck },
},
{
path: 'orders/delivery/create',
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 +48,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,31 @@ 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>
<section class="delivery-page" aria-label="派送订单">
<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-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,11 +1,21 @@
<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)
@@ -22,6 +32,37 @@ 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[]) {
@@ -63,97 +104,115 @@ 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 #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,18 +1,20 @@
import { overseasApiRequest } from '@/api/overseasHttp'
import { overseasApiRequest, overseasBlobRequest } from '@/api/overseasHttp'
import type { PageResult } from './warehouseServices'
export interface ReturnOrderSummary {
returnNo: string; overseasWarehouseId: string; warehouseContactName?: string; warehouseContactPhone?: string; customerReferenceNo?: string; status: string
skuTypes: number; expectedQuantity: number; receivedQuantity: number; needRelabel: boolean; createdAt: string
skuTypes: number; expectedQuantity: number; receivedQuantity: number; needRelabel: boolean; relabelNo?: string; relabelStatus?: string; createdAt: string
}
export interface ReturnOrderItem {
skuCode: string; productNameZh?: string; expectedQuantity: number; receivedQuantity: number
}
export interface ReturnReceipt {
receiptNo: string; receiptBatchNo: string; cartonMark?: string; trackingNo?: string; startedBy: 'RETURN_NO' | 'SKU'
receiptNo: string; receiptBatchNo: string; palletCode: string; trackingNo?: string; startedBy: 'RETURN_NO' | 'SKU'
receivedQuantity: number; operatorName?: string; signedAt: string
}
export interface ReturnOrderDetail { order: ReturnOrderSummary; remark?: string; items: ReturnOrderItem[]; receipts: ReturnReceipt[] }
export interface ReturnPallet { palletCode: string; skuTypes: number; totalQuantity: number; items: Array<{ skuCode: string; productNameZh?: string; quantity: number }> }
export interface ReturnPhoto { fileId: number; fileName: string; contentType: string; sizeBytes: number }
export interface ReturnOrderDetail { order: ReturnOrderSummary; remark?: string; items: ReturnOrderItem[]; receipts: ReturnReceipt[]; pallets: ReturnPallet[]; photos: ReturnPhoto[] }
export interface ReturnOrderCreate {
overseasWarehouseId: string; warehouseContactName: string; warehouseContactPhone: string; customerReferenceNo?: string; remark?: string; needRelabel: boolean; idempotencyKey: string
items: Array<{ skuCode: string; productNameZh?: string; expectedQuantity: number }>
@@ -27,3 +29,21 @@ export function getReturnOrder(returnNo: string) {
export function createReturnOrder(payload: ReturnOrderCreate) {
return overseasApiRequest<ReturnOrderDetail>({ method: 'POST', url: '/merchant/v1/last-leg/return-orders', data: payload })
}
export function updateReturnOrder(returnNo: string, payload: ReturnOrderCreate) {
return overseasApiRequest<ReturnOrderDetail>({ method: 'PUT', url: `/merchant/v1/last-leg/return-orders/${encodeURIComponent(returnNo)}`, data: payload })
}
export interface ReturnRelabelCreate {
fileScope: 'CARTON_TOTAL' | 'SKU_TOTAL'; destinationFbaWarehouseId: string; destinationFbaWarehouseCode: string; cartonRemark?: string; idempotencyKey: string
items: Array<{ skuCode: string; quantity: number }>
}
export interface ReturnRelabelCreated { relabelNo: string; relabelApplicationId: string; returnNo: string; skuCodes: string[]; status: string }
export function createReturnRelabel(returnNo: string, payload: ReturnRelabelCreate) {
return overseasApiRequest<ReturnRelabelCreated>({ method: 'POST', url: `/merchant/v1/last-leg/return-orders/${encodeURIComponent(returnNo)}/relabel`, data: payload })
}
export function uploadReturnRelabelFile(relabelNo: string, scope: 'CARTON_TOTAL' | 'SKU_TOTAL', file: File) {
const data = new FormData(); data.append('file', file)
return overseasApiRequest<unknown>({ method: 'POST', url: `/merchant/v1/last-leg/relabel-applications/${encodeURIComponent(relabelNo)}/files`, params: { scope }, data, headers: { 'Content-Type': 'multipart/form-data' } })
}
export function downloadReturnPhoto(returnNo: string, fileId: string | number) {
return overseasBlobRequest({ method: 'GET', url: `/merchant/v1/last-leg/return-orders/${encodeURIComponent(returnNo)}/photos/files/${encodeURIComponent(String(fileId))}/download` })
}
@@ -29,7 +29,7 @@ export interface RelabelInventoryCandidate {
inventoryBalanceId: string; inboundOrderId: string; inboundOrderNo?: string; inboundCode?: string
overseasWarehouseId: string; overseasWarehouseCode?: string; overseasWarehouseName?: string
destinationFbaWarehouseCode?: string; destinationFbaWarehouseName?: string; skuCode?: string; productNameZh?: string
availablePackages: number; version: number
availablePackages: number | string; 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 }>
@@ -18,6 +18,28 @@ test('protects every overseas warehouse page with its independent list permissio
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]*?ReturnOrdersView\.vue'[\s\S]*?props: \{ createPage: true \}[\s\S]*?title: '新建退货预报'[\s\S]*?permission: 'merchant:overseas-warehouse:return-order:create'/)
assert.match(services, /props\.createPage \? 'create' : 'list'/)
assert.match(services, /function returnToList\(\)/)
assert.match(returns, /props\.createPage/)
assert.match(returns, /class="return-create-surface"/)
})
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',
@@ -37,6 +59,14 @@ test('keeps overseas warehouse writes behind operation-specific button permissio
assert.match(returns, /v-if="canCreate" type="primary" @click="openCreate"/)
})
test('keeps all four overseas warehouse create buttons in the shared left-aligned table toolbar', () => {
for (const service of ['labeling', 'transfer', 'disposal']) {
assert.match(routes, new RegExp(`props: \\{ service: '${service}' \\}`))
}
assert.match(services, /<DataTable[\s\S]*?<template #toolbar-actions><el-button v-if="canCreate"/)
assert.match(returns, /<DataTable[\s\S]*?<template #toolbar-actions><el-button v-if="canCreate"/)
})
test('publishes all overseas operation permissions in the enterprise role editor', () => {
for (const permission of [
'merchant:overseas-warehouse:last-mile-order:list',
+38 -3
View File
@@ -1,22 +1,54 @@
import { Boxes, PackageX, RefreshCcw, Tags, Truck, Warehouse } from '@lucide/vue'
import { PackageX, RefreshCcw, Tags, Truck, Warehouse } from '@lucide/vue'
import type { ClientModuleManifest } from '../types.ts'
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: '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/ReturnOrdersView.vue'), props: { createPage: true }, release: 'active',
meta: { title: '新建退货预报', workspaceTitle: '退货预报 · 新建', description: '填写并提交海外仓退货预报', permission: 'merchant:overseas-warehouse:return-order:create', keepAlive: false },
},
{
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: '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',
@@ -33,13 +65,16 @@ export const overseasWarehouseModule = {
path: 'overseas-warehouse/return-inventory', name: 'overseas-return-inventory',
component: () => import('./views/ReturnInventoryView.vue'), release: 'active',
meta: { title: '退货库存', description: '按箱号和 SKU 查询海外仓退货实收库存', permission: 'merchant:overseas-warehouse:return-inventory:list' },
navigation: { key: 'overseas-return-inventory', icon: Boxes },
},
{
path: 'overseas-warehouse/return-cartons', name: 'overseas-return-cartons',
component: () => import('./views/ReturnInventoryView.vue'), props: { cartonOnly: true }, release: 'active',
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,54 @@ 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 #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>
@@ -3,8 +3,18 @@ import { readFileSync } from 'node:fs'
import test from 'node:test'
const view = readFileSync(new URL('./ReturnOrdersView.vue', import.meta.url), 'utf8')
const api = readFileSync(new URL('../api/returnOrders.ts', import.meta.url), 'utf8')
test('退货签收照片通过客户鉴权下载接口加载并释放对象 URL', () => {
assert.match(api, /\/return-orders\/\$\{encodeURIComponent\(returnNo\)\}\/photos\/files\/\$\{encodeURIComponent\(String\(fileId\)\)\}\/download/)
assert.match(api, /overseasBlobRequest/)
assert.match(view, /URL\.createObjectURL\(await downloadReturnPhoto\(detailReturnNo\.value, photo\.fileId\)\)/)
assert.match(view, /URL\.revokeObjectURL\(url\)/)
assert.match(view, /onBeforeUnmount\(clearPhotoUrls\)/)
})
test('新建退货预报选择海外仓后带出联系人和电话', () => {
assert.match(view, /getMerchantWarehouses\(\{ page: 1, size: 200, warehouseType: 'OVERSEAS' \}\)/)
assert.match(view, /@change="selectOperationWarehouse"/)
assert.match(view, /warehouseContactName = warehouse\?\.contactName \|\| ''/)
assert.match(view, /warehouseContactPhone = warehouse\?\.contactPhone \|\| ''/)
@@ -17,9 +27,13 @@ test('新建退货预报选择海外仓后带出联系人和电话', () => {
assert.match(view, /warehouseContactPhone: form\.warehouseContactPhone\.trim\(\)/)
})
test('新建退货预报使用加宽的双列表单弹窗', () => {
assert.match(view, /title="新建退货预报" width="1120px"/)
assert.match(view, /<el-form label-width="190px" class="return-create-form">/)
test('新建退货预报使用独立路由页面,编辑仍保留加宽弹框', () => {
assert.match(view, /defineProps<\{ createPage\?: boolean; detailPage\?: boolean/)
assert.match(view, /name: 'overseas-return-relabel-create'/)
assert.match(view, /v-if="props\.createPage"/)
assert.match(view, /class="return-create-surface"/)
assert.match(view, /<h2>新建退货预报<\/h2>/)
assert.match(view, /:title="editingReturnNo \? '编辑退货预报' : '新建退货预报'" width="1120px"/)
assert.match(view, /return-create-form \{ display:grid; grid-template-columns:repeat\(2,minmax\(0,1fr\)\)/)
})
@@ -28,25 +42,43 @@ test('退货列表和详情使用客户可读仓库、状态与时间', () => {
assert.match(view, /return warehouse \? warehouseLabel\(warehouse\) : '未关联'/)
assert.match(view, /PENDING_RECEIPT: '待签收'/)
assert.match(view, /PARTIALLY_RECEIVED: '部分签收'/)
assert.match(view, /COMPLETED: '已完成'/)
assert.match(view, /return needRelabel \? '已完成入库\/待换标' : '已完成入库'/)
assert.match(view, /function formatDateTime\(value\?: string \| null\)/)
assert.match(view, /\{\{ operationWarehouseLabel\(row\.overseasWarehouseId\) \}\}/)
assert.match(view, /\{\{ statusLabel\(row\.status\) \}\}/)
assert.match(view, /\{\{ statusLabel\(row\.status, row\.needRelabel\) \}\}/)
assert.match(view, /\{\{ formatDateTime\(row\.createdAt\) \}\}/)
assert.doesNotMatch(view, /<el-table-column prop="overseasWarehouseId"/)
assert.doesNotMatch(view, /\{\{ selected\.order\.overseasWarehouseId \}\}/)
})
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: 220, 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"/)
})
test('待审核退货可编辑,完成入库后从独立新增页发起贴标', () => {
assert.match(view, /row\.status === 'PENDING_REVIEW'.*key: 'edit'/)
assert.match(view, /await updateReturnOrder\(editingReturnNo\.value, payload\)/)
assert.match(view, /order\.status === 'COMPLETED' && order\.needRelabel === true && !order\.relabelNo/)
assert.match(view, /name: 'overseas-labeling-create'/)
assert.match(view, /query: \{ returnNo: order\.returnNo, warehouseId: String\(order\.overseasWarehouseId\) \}/)
assert.match(view, /prop: 'relabelNo', label: '换标单号'/)
assert.match(view, /label: '换标单状态'/)
})
@@ -1,24 +1,72 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { computed, onBeforeUnmount, 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'
import { createReturnOrder, downloadReturnPhoto, getReturnOrder, listReturnOrders, updateReturnOrder, type ReturnOrderDetail, type ReturnOrderSummary, type ReturnReceipt } from '../api/returnOrders'
const props = withDefaults(defineProps<{ createPage?: boolean; detailPage?: boolean; returnNo?: string }>(), { createPage: false, 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 canCreateRelabel = computed(() => auth.hasPermission('merchant:overseas-warehouse:relabel:create'))
const creating = ref(false)
const showCreate = ref(false)
const showDetail = ref(false)
const editingReturnNo = ref('')
const selected = ref<ReturnOrderDetail>()
const photoUrls = reactive<Record<string, string>>({})
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: 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_REVIEW' },
{ label: '待签收', value: 'PENDING_RECEIPT' },
{ label: '部分签收', value: 'PARTIALLY_RECEIVED' },
{ label: '已完成入库', value: 'COMPLETED' },
{ label: '已驳回', value: 'REJECTED' },
{ label: '已取消', value: 'CANCELLED' },
] },
]
const tableColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', slotName: 'actions', width: 220, 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: 150 },
{ prop: 'relabelNo', label: '换标单号', minWidth: 190 }, { key: 'relabelStatus', label: '换标单状态', slotName: 'relabelStatus', width: 130 },
{ 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: 'palletCode', label: '托盘码', minWidth: 190 },
{ 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 } }
@@ -50,7 +98,7 @@ async function loadOperationWarehouses() {
try {
// 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 })
const result = await getMerchantWarehouses({ page: 1, size: 200, warehouseType: 'OVERSEAS' })
operationWarehouses.value = result.records.filter((warehouse) =>
warehouse.status === 'ACTIVE' && ['OVERSEAS', 'OVERSEAS_PARTNER'].includes(warehouse.warehouseType),
)
@@ -62,9 +110,21 @@ async function loadOperationWarehouses() {
} finally { operationWarehousesLoading.value = false }
}
function openCreate() {
resetForm()
void router.push({ name: 'overseas-return-relabel-create' })
}
async function openEdit(returnNo: string) {
const detail = await getReturnOrder(returnNo)
editingReturnNo.value = returnNo
Object.assign(form, {
overseasWarehouseId: String(detail.order.overseasWarehouseId),
warehouseContactName: detail.order.warehouseContactName || '',
warehouseContactPhone: detail.order.warehouseContactPhone || '',
customerReferenceNo: detail.order.customerReferenceNo || '',
remark: detail.remark || '',
needRelabel: Boolean(detail.order.needRelabel),
items: detail.items.map(item => ({ id: ++skuSequence, skuCode: item.skuCode, productNameZh: item.productNameZh || '', expectedQuantity: item.expectedQuantity })),
})
showCreate.value = true
if (!operationWarehouses.value.length && !operationWarehousesLoading.value) void loadOperationWarehouses()
}
function addItem() { form.items.push(emptySku()) }
function removeItem(index: number) { if (form.items.length > 1) form.items.splice(index, 1) }
@@ -78,7 +138,46 @@ 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
const actions: TableAction[] = [{ key: 'detail', label: '详情', run: () => openDetail(row.returnNo) }]
if (canCreate.value && row.status === 'PENDING_REVIEW') actions.push({ key: 'edit', label: '编辑', run: () => openEdit(row.returnNo) })
if (canCreateRelabel.value && canApplyRelabel(row)) actions.push({ key: 'relabel', label: '贴标申请', run: () => openRelabel(row) })
return actions
}
async function loadDetail() {
if (!detailReturnNo.value) return
loading.value = true
clearPhotoUrls()
try {
selected.value = await getReturnOrder(detailReturnNo.value)
await Promise.all((selected.value.photos || []).map(async (photo) => {
try {
photoUrls[String(photo.fileId)] = URL.createObjectURL(await downloadReturnPhoto(detailReturnNo.value, photo.fileId))
} catch {
// 单张照片加载失败不阻断详情展示。
}
}))
}
catch (error) { ElMessage.error(error instanceof Error ? error.message : '退货签收详情加载失败') }
finally { loading.value = false }
}
function clearPhotoUrls() {
Object.values(photoUrls).forEach(url => URL.revokeObjectURL(url))
Object.keys(photoUrls).forEach(key => delete photoUrls[key])
}
function previewPhoto(fileId: string | number) {
const url = photoUrls[String(fileId)]
if (url) window.open(url, '_blank', 'noopener')
}
function openDetail(returnNo: string) { void router.push({ name: 'overseas-return-relabel-detail', params: { returnNo } }) }
function returnToList() { void router.push({ name: 'overseas-return-relabel' }) }
function cancelCreate() { resetForm(); void router.push({ name: 'overseas-return-relabel' }) }
function canApplyRelabel(order: { status?: unknown; needRelabel?: unknown; relabelNo?: unknown }) { return order.status === 'COMPLETED' && order.needRelabel === true && !order.relabelNo }
function openRelabel(order: Pick<ReturnOrderSummary, 'returnNo' | 'overseasWarehouseId'>) {
void router.push({ name: 'overseas-labeling-create', query: { returnNo: order.returnNo, warehouseId: String(order.overseasWarehouseId) } })
}
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 }
@@ -86,13 +185,28 @@ async function submit() {
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)
const payload = { 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 })) }
const detail = editingReturnNo.value ? await updateReturnOrder(editingReturnNo.value, payload) : await createReturnOrder(payload)
ElMessage.success(editingReturnNo.value ? '退货预报已更新' : '退货预报已创建'); showCreate.value = false; editingReturnNo.value = ''; 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' }
function statusLabel(status: string) {
return ({ PENDING_RECEIPT: '待签收', PARTIALLY_RECEIVED: '部分签收', COMPLETED: '已完成' } as Record<string, string>)[status] ?? '未知状态'
function statusType(status: string, needRelabel = false) {
if (status === 'COMPLETED') return needRelabel ? 'warning' : 'success'
return ({ PENDING_REVIEW: 'warning', PENDING_RECEIPT: 'primary', PARTIALLY_RECEIVED: 'warning', REJECTED: 'danger', CANCELLED: 'info' } as Record<string, 'primary' | 'success' | 'warning' | 'danger' | 'info'>)[status] || 'info'
}
function statusClass(status: string, needRelabel = false) {
if (status === 'COMPLETED') return needRelabel ? 'return-status--relabel' : 'return-status--completed'
return `return-status--${status.toLowerCase().replaceAll('_', '-')}`
}
function statusLabel(status: string, needRelabel = false) {
if (status === 'COMPLETED') return needRelabel ? '已完成入库/待换标' : '已完成入库'
return ({ PENDING_REVIEW: '待审核', PENDING_RECEIPT: '待签收', PARTIALLY_RECEIVED: '部分签收', REJECTED: '已驳回', CANCELLED: '已取消' } as Record<string, string>)[status] ?? (status || '未知状态')
}
function relabelStatusType(status?: string) {
return ({ PENDING_REVIEW: 'warning', PENDING_PROCESSING: 'primary', PROCESSING: 'primary', CUSTOMER_CONFIRM_PENDING: 'warning', COMPLETED: 'success', REJECTED: 'danger', CANCELLED: 'info' } as Record<string, 'primary' | 'success' | 'warning' | 'danger' | 'info'>)[status || ''] || 'info'
}
function relabelStatusLabel(status?: string) {
return ({ PENDING_REVIEW: '待审核', PENDING_PROCESSING: '待处理', PROCESSING: '处理中', CUSTOMER_CONFIRM_PENDING: '待客户确认', COMPLETED: '已完成', REJECTED: '已驳回', CANCELLED: '已取消' } as Record<string, string>)[status || ''] ?? (status || '—')
}
function startedByLabel(startedBy: ReturnReceipt['startedBy']) { return startedBy === 'SKU' ? 'SKU 查询' : '退货单号' }
function formatDateTime(value?: string | null) {
@@ -101,34 +215,62 @@ 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.createPage) { resetForm(); void loadOperationWarehouses() }
else 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.createPage && !props.detailPage) void load()
})
onBeforeUnmount(clearPhotoUrls)
</script>
<template>
<section class="return-orders-page">
<div class="page-heading"><div><h2>退货签收</h2><p>预报退货 SKU 和数量,仓库 PDA 实收后自动更新签收状态。</p></div><el-button v-if="canCreate" 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>
</div>
<el-table v-loading="loading" class="return-table-card" :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>
<div class="return-pagination"><span class="return-pagination-total">共 {{ total }} 条</span><el-select v-model="query.size" class="return-page-size-select" aria-label="每页条数" @change="query.page=1; load()"><el-option v-for="size in [15, 30, 50, 100]" :key="size" :label="`${size}条/页`" :value="size" /></el-select><el-pagination :current-page="query.page" :page-size="query.size" :total="total" :hide-on-single-page="false" layout="prev, pager, next" @current-change="changePage" /></div>
<template v-if="props.createPage">
<article class="return-create-surface">
<header class="return-create-header">
<div><h2>新建退货预报</h2><p>填写预计退回的 SKU 和数量,仓库签收后将自动更新实收结果。</p></div>
<el-button @click="returnToList"><ArrowLeft :size="16" />返回列表</el-button>
</header>
<div class="return-create-body">
<el-form label-width="150px" 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="客户参考号" 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" :rows="3" placeholder="填写退货说明(选填)" /></el-form-item>
</el-form>
<div class="items-title"><span>SKU 预报明细</span><el-button text type="primary" @click="addItem"><Plus :size="15" />添加 SKU</el-button></div>
<div class="return-relabel-option"><span>换标</span><el-checkbox v-model="form.needRelabel">需要换标</el-checkbox></div>
<div class="items-hint">客户只需填写预计退回的 SKU 和数量;快递单号由仓库签收时按实际包裹扫描绑定。</div>
<el-table :data="form.items" row-key="id" size="small" border>
<el-table-column label="操作" width="80" fixed="left"><template #default="{ $index }"><el-button link type="danger" :disabled="form.items.length === 1" @click="removeItem($index)">删除</el-button></template></el-table-column>
<el-table-column label="SKU" min-width="240"><template #default="{ row }"><div><el-input v-model="row.skuCode" :class="{ 'duplicate-sku-input': duplicateSkuIds.has(row.id) }" placeholder="填写 SKU" @blur="row.skuCode = row.skuCode.trim()" /><div v-if="duplicateSkuIds.has(row.id)" class="duplicate-sku-error">该 SKU 已在明细中填写</div></div></template></el-table-column>
<el-table-column label="中文品名" min-width="320"><template #default="{ row }"><el-input v-model="row.productNameZh" placeholder="填写中文品名(选填)" /></template></el-table-column>
<el-table-column label="预报数量" width="180"><template #default="{ row }"><el-input-number v-model="row.expectedQuantity" :min="1" controls-position="right" placeholder="数量" /></template></el-table-column>
</el-table>
</div>
<footer class="return-create-actions"><el-button @click="cancelCreate">取消</el-button><el-button v-if="canCreate" type="primary" :loading="creating" @click="submit">提交预报</el-button></footer>
</article>
</template>
<el-dialog v-model="showCreate" title="新建退货预报" width="1120px" destroy-on-close>
<template v-else-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-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, row.needRelabel)" :class="statusClass(row.status, row.needRelabel)" effect="light">{{ statusLabel(row.status, row.needRelabel) }}</el-tag></template>
<template #relabelStatus="{ row }"><el-tag v-if="row.relabelStatus" :type="relabelStatusType(row.relabelStatus)" effect="light">{{ relabelStatusLabel(row.relabelStatus) }}</el-tag><span v-else>—</span></template>
<template #createdAt="{ row }">{{ formatDateTime(row.createdAt) }}</template>
</DataTable>
</div>
<el-dialog v-model="showCreate" :title="editingReturnNo ? '编辑退货预报' : '新建退货预报'" 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>
@@ -145,36 +287,122 @@ 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 v-if="canCreate" type="primary" :loading="creating" @click="submit">提交预报</el-button></template>
<template #footer><el-button @click="showCreate=false; editingReturnNo=''">取消</el-button><el-button v-if="canCreate" type="primary" :loading="creating" @click="submit">{{ editingReturnNo ? '保存修改' : '提交预报' }}</el-button></template>
</el-dialog>
<el-dialog v-model="showDetail" title="退货签收详情" width="min(980px, calc(100vw - 32px))" top="8vh" class="return-detail-dialog" destroy-on-close>
</template>
<article v-else v-loading="loading" class="return-detail-surface">
<template v-if="selected">
<div class="detail-summary"><div><span>退货单号</span><strong>{{ selected.order.returnNo }}</strong></div><div><span>状态</span><el-tag :type="statusType(selected.order.status)">{{ statusLabel(selected.order.status) }}</el-tag></div><div><span>海外仓</span><strong>{{ operationWarehouseLabel(selected.order.overseasWarehouseId) }}</strong></div><div><span>实收 / 预报</span><strong>{{ selected.order.receivedQuantity }} / {{ selected.order.expectedQuantity }}</strong></div><div><span>海外仓联系人</span><strong>{{ selected.order.warehouseContactName || '—' }}</strong></div><div><span>联系电话</span><strong>{{ selected.order.warehouseContactPhone || '—' }}</strong></div></div>
<p v-if="selected.remark" class="remark detail-remark">备注:{{ selected.remark }}</p>
<section class="detail-section"><h3>预报与实收</h3><el-table :data="selected.items" size="small" border><el-table-column prop="skuCode" label="SKU" min-width="180" /><el-table-column prop="productNameZh" label="中文品名" min-width="220" /><el-table-column prop="expectedQuantity" label="预报" width="100" /><el-table-column prop="receivedQuantity" label="实收" width="100" /></el-table></section>
<section class="detail-section"><h3>签收批次</h3><el-table :data="selected.receipts" size="small" border><el-table-column prop="receiptBatchNo" label="批次号" min-width="160" /><el-table-column prop="receiptNo" label="签收单号" min-width="160" /><el-table-column prop="cartonMark" label="箱号" min-width="170" /><el-table-column prop="trackingNo" label="快递单号" min-width="150"><template #default="{ row }">{{ row.trackingNo || '无快递单号' }}</template></el-table-column><el-table-column label="开始方式" width="110"><template #default="{ row }">{{ startedByLabel(row.startedBy) }}</template></el-table-column><el-table-column prop="receivedQuantity" label="实收" width="80" /><el-table-column label="签收时间" min-width="170"><template #default="{ row }">{{ formatDateTime(row.signedAt) }}</template></el-table-column></el-table></section>
<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>
<div class="return-detail-actions"><el-button v-if="canCreateRelabel && canApplyRelabel(selected.order)" type="primary" plain @click="openRelabel(selected.order)">贴标申请</el-button><el-tag :type="statusType(selected.order.status, selected.order.needRelabel)" :class="statusClass(selected.order.status, selected.order.needRelabel)" effect="light">{{ statusLabel(selected.order.status, selected.order.needRelabel) }}</el-tag></div>
</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.relabelNo || '—' }}</strong></div>
<div><span>换标单状态</span><strong>{{ relabelStatusLabel(selected.order.relabelStatus) }}</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>打托明细({{ selected.pallets?.length || 0 }} 托)</h3>
<el-collapse v-if="selected.pallets?.length" class="return-pallet-list">
<el-collapse-item v-for="pallet in selected.pallets" :key="pallet.palletCode" :name="pallet.palletCode">
<template #title><strong>{{ pallet.palletCode }}</strong><span class="return-pallet-summary">{{ pallet.skuTypes }} 种 SKU · {{ pallet.totalQuantity }} 件</span></template>
<el-table :data="pallet.items" size="small" border>
<el-table-column prop="skuCode" label="SKU" min-width="180" />
<el-table-column prop="productNameZh" label="中文品名" min-width="220"><template #default="{ row }">{{ row.productNameZh || '—' }}</template></el-table-column>
<el-table-column prop="quantity" label="数量" width="100" />
</el-table>
</el-collapse-item>
</el-collapse>
<el-empty v-else description="暂无打托记录" :image-size="64" />
</section>
<section class="return-detail-section">
<h3>退货打托照片({{ selected.photos?.length || 0 }} 张)</h3>
<div v-if="selected.photos?.length" class="return-photo-grid">
<button v-for="photo in selected.photos" :key="photo.fileId" class="return-photo" type="button" @click="previewPhoto(photo.fileId)">
<img v-if="photoUrls[String(photo.fileId)]" :src="photoUrls[String(photo.fileId)]" :alt="photo.fileName">
<span v-else>照片加载失败</span>
<small>{{ photo.fileName }}</small>
</button>
</div>
<el-empty v-else description="暂无退货打托照片" :image-size="64" />
</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>
<template #footer><el-button type="primary" @click="showDetail = false">关闭</el-button></template>
</el-dialog>
</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%; }
.return-table-card { overflow:hidden; border:1px solid #e7ebf1; border-radius:4px; background:#fff; box-shadow:0 1px 3px rgba(25,42,70,.04); }
.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-create-surface { overflow:hidden; border:1px solid var(--client-border); border-radius:var(--client-radius); background:#fff; }
.return-create-header { display:flex; min-height:68px; align-items:center; justify-content:space-between; gap:20px; padding:12px 20px; border-bottom:1px solid var(--client-border); }
.return-create-header h2 { margin:0; color:#1f2d45; font-size:20px; }
.return-create-header p { margin:4px 0 0; color:#7c899c; font-size:12px; }
.return-create-body { padding:18px 20px 14px; }
.return-create-body :deep(.el-select), .return-create-body :deep(.el-input-number) { width:100%; }
.return-create-body .items-title { margin-top:8px; }
.return-relabel-option { display:flex; align-items:center; gap:16px; margin:8px 0; color:#526176; }
.return-create-actions { display:flex; align-items:center; justify-content:flex-end; gap:10px; padding:12px 20px; border-top:1px solid var(--client-border); background:#fbfcfe; }
.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-status--pending-review { --el-tag-bg-color:#fff7e6; --el-tag-border-color:#f4c26b; --el-tag-text-color:#a86100; }
.return-status--pending-receipt { --el-tag-bg-color:#edf5ff; --el-tag-border-color:#8bbcff; --el-tag-text-color:#1764c0; }
.return-status--partially-received { --el-tag-bg-color:#f5efff; --el-tag-border-color:#b99aee; --el-tag-text-color:#6f42b5; }
.return-status--relabel { --el-tag-bg-color:#fff1e8; --el-tag-border-color:#f1a36f; --el-tag-text-color:#bd4d08; }
.return-status--completed { --el-tag-bg-color:#edf9f0; --el-tag-border-color:#8bd29b; --el-tag-text-color:#23823b; }
.return-status--rejected { --el-tag-bg-color:#fff0f0; --el-tag-border-color:#ee9696; --el-tag-text-color:#c73535; }
.return-status--cancelled { --el-tag-bg-color:#f2f4f7; --el-tag-border-color:#c7cdd6; --el-tag-text-color:#667085; }
.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; }
.detail-summary { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:18px 24px; padding:18px; border:1px solid #e5ebf3; border-radius:6px; background:#f8faff; }
.detail-summary div { display:flex; min-width:0; flex-direction:column; gap:6px; }
.detail-summary span { color:#7c899c; font-size:12px; }
.detail-summary strong { overflow-wrap:anywhere; color:#26364f; font-size:14px; }
.detail-remark { margin:14px 0 0; padding:10px 12px; border-left:3px solid #7aa7dc; background:#f7faff; color:#526176; }
.detail-section h3 { margin:22px 0 10px; color:#26364f; font-size:15px; }
.return-detail-dialog :deep(.el-dialog__body) { padding-top:4px; }
@media (max-width: 760px) { .return-orders-page { padding:12px; } .page-heading { align-items:stretch; flex-direction:column; } .filter-bar { grid-template-columns:1fr 1fr; } }
@media (max-width: 720px) { .detail-summary { grid-template-columns:repeat(2,minmax(0,1fr)); } .return-pagination { padding:12px; } }
.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-actions { display:flex; align-items:center; gap:8px; }
.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; }
.return-pallet-list { padding:0 16px 12px; }
.return-pallet-summary { margin-left:12px; color:#7c899c; font-size:12px; font-weight:400; }
.return-photo-grid { display:grid; grid-template-columns:repeat(5,minmax(0,1fr)); gap:12px; padding:0 16px 16px; }
.return-photo { min-width:0; overflow:hidden; padding:0; border:1px solid #dfe6ef; border-radius:6px; color:#526176; background:#fff; cursor:pointer; }
.return-photo img,.return-photo>span { display:flex; width:100%; height:120px; align-items:center; justify-content:center; object-fit:cover; background:#f4f7fb; }
.return-photo small { display:block; overflow:hidden; padding:7px; text-overflow:ellipsis; white-space:nowrap; }
@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: 900px) { .return-photo-grid { grid-template-columns:repeat(3,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; } .return-photo-grid { grid-template-columns:repeat(2,minmax(0,1fr)); } }
</style>
@@ -1,8 +1,11 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { ArrowLeft, Download, Eye, Plus, Printer, Search, Trash2, Upload, X } from '@lucide/vue'
import SingleLineText from '@/components/common/SingleLineText.vue'
import { ArrowLeft, Download, Plus, Printer, Trash2, Upload, X } from '@lucide/vue'
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 {
createDiscard, createRelabel, createTransfer, getDiscard, getRelabel, getTransfer,
@@ -22,6 +25,7 @@ import {
type MerchantWarehouseDetail,
} from '@/modules/first-leg/api/firstLeg'
import { loadAllWarehouseRecords } from '@/modules/first-leg/utils/warehousePagination'
import { createReturnRelabel, uploadReturnRelabelFile } from '../api/returnOrders'
type ServiceType = 'labeling' | 'transfer' | 'disposal' | 'relabel'
type PageMode = 'list' | 'create' | 'edit' | 'detail'
@@ -30,11 +34,15 @@ type RelabelTemplateType = 'carton' | 'sku' | 'mixed'
interface CargoRow { id: number; included?: boolean; sku: string; name: string; quantity: number; availableQuantity?: number; sourceOrderId?: string; sourceInventoryBalanceId?: string; sourceInventoryPositionId?: string; sourceInboundOrderId?: string; returnCartonId?: string; sourceInboundOrderNo?: string; sourceInboundCode?: string; sourceWarehouseName?: string; palletNo?: string; warehouseLocationCode?: string; fnsku: string; shipmentNo: string; newSkuCode: string; shippingMark: string; customerCartonMarks: string; cartonMark: string; fbaCartonMark: string; destinationWarehouseId: string; remark: string }
interface SkuRelabelRow { id: number; sourceRowId: number | null; cartonMark: string; originalSku: string; newSku: string; quantity: number; fbaCartonMark: string; destinationWarehouseId: string; remark: string }
interface ImportedRelabelBinding extends SkuRelabelRow { templateType: RelabelTemplateType; selected: boolean; shipmentNo: string }
interface ReturnRelabelSkuRow { skuCode: string; productNameZh: string; availableQuantity: number; selected: boolean; quantity: number }
interface ServiceConfig { title: string; createTitle: string; numberLabel: string; emptyText: string; columns: string[] }
interface TransferCarton { cartonMark: string; customerCartonMark?: string; cartonIndex: number; totalCartons: number; status: string }
const props = defineProps<{ service: ServiceType }>()
const props = withDefaults(defineProps<{ service: ServiceType; createPage?: boolean; detailPage?: boolean; detailNo?: string }>(), { createPage: false, detailPage: false, detailNo: '' })
const auth = useAuthStore()
const router = useRouter()
const route = useRoute()
const detailNumber = computed(() => props.detailNo || String(route.params.detailNo || ''))
const canCreate = computed(() => auth.hasPermission(({
labeling: 'merchant:overseas-warehouse:relabel:create',
transfer: 'merchant:overseas-warehouse:transfer:create',
@@ -52,6 +60,15 @@ const configs: Record<ServiceType, ServiceConfig> = {
relabel: { title: '退货换标', createTitle: '新建退货换标单', numberLabel: '退货换标单号', emptyText: '暂无退货换标订单', columns: ['操作', '退货换标单号', '来源 FBA 仓', '目的 FBA 仓', 'SKU 汇总', '退件总数', '是否换标', '换标状态', '尾程状态', '创建时间'] },
}
const config = computed(() => configs[props.service])
const listQueryModel = computed<QueryModel>({
get: () => ({ ...filters, createdDates: [filters.start, filters.end].filter(Boolean) }),
set: (value) => Object.assign(filters, {
number: String(value.number || ''), inboundNo: String(value.inboundNo || ''), warehouse: String(value.warehouse || ''),
destination: String(value.destination || ''), status: String(value.status || ''),
start: Array.isArray(value.createdDates) ? String(value.createdDates[0] || '') : '',
end: Array.isArray(value.createdDates) ? String(value.createdDates[1] || '') : '',
}),
})
const statusOptions = computed(() => props.service === 'labeling'
? [{ label: '待处理', value: 'PENDING_PROCESSING' }, { label: '处理中', value: 'PROCESSING' }, { label: '换标完成', value: 'COMPLETED' }]
: props.service === 'disposal'
@@ -65,7 +82,7 @@ const statusOptions = computed(() => props.service === 'labeling'
{ label: '部分收货', value: 'PARTIALLY_RECEIVED' }, { label: '已收货', value: 'RECEIVED' },
{ label: '审核驳回', value: 'REJECTED' }]
: [{ label: '待审核', value: 'PENDING_REVIEW' }, { label: '已通过', value: 'APPROVED' }, { label: '待分配', value: 'PENDING_ASSIGNMENT' }, { label: '已分配', value: 'ALLOCATED' }, { label: '已出库', value: 'OUTBOUNDED' }, { label: '部分收货', value: 'PARTIALLY_RECEIVED' }, { label: '已收货', value: 'RECEIVED' }, { label: '已拒绝', value: 'REJECTED' }])
const mode = ref<PageMode>('list')
const mode = ref<PageMode>(props.detailPage ? 'detail' : props.createPage ? 'create' : 'list')
const filters = reactive({ number: '', inboundNo: '', warehouse: '', destination: '', status: '', start: '', end: '' })
const form = reactive({ customerNo: '', inboundNo: '', sourceWarehouse: '', warehouse: '', destination: '', privateDestinationAddress: '', privateDestinationPostalCode: '', privateDestinationContact: '', privateDestinationPhone: '', address: '', contact: '', phone: '', pickupContact: '', pickupPhone: '', relabelContact: '', relabelPhone: '', deliveryMethod: '', relabelDeliveryMethod: '', trackingNo: '', reason: '', requirement: '', relabel: false, remark: '', labelDeadline: '', estimatedArrivalAt: '', estimatedShipAt: '', fbaShipmentNo: '', volumeCbm: 0.01 })
const transferDestinationType = ref<'NONE' | 'FBA' | 'PRIVATE'>('NONE')
@@ -85,11 +102,6 @@ const shippingLabelFile = ref<File | null>(null)
const skuRelabelRows = ref<SkuRelabelRow[]>([])
const importedRelabelBindings = ref<ImportedRelabelBinding[]>([])
const cartonDetailRows = computed(() => cargoRows.value.filter((row) => row.included))
const labelingModeHint = computed(() => labelingMode.value === 'carton'
? '只替换外箱箱唛,不修改箱内商品 SKU 标签;整张申请统一使用订单信息中的 FBA 目的仓。'
: labelingMode.value === 'sku'
? '只替换箱内商品 SKU 标签,保留原箱唛;同一箱唛可以添加多条 SKU 明细。'
: '同一箱同时替换商品 SKU 标签和外箱箱唛;整张申请统一使用订单信息中的 FBA 目的仓。')
const submittedRows = ref<Record<string, string>[]>([])
const page = ref(1)
const pageSize = ref(15)
@@ -106,12 +118,12 @@ interface ServiceDetail extends Record<string, unknown> {
estimatedShipAt?: string; actualShipAt?: string; fbaShipmentNo?: string; trackingNo?: string
remark?: string; needRelabel?: boolean; labelDeadline?: string; relabelContactName?: string
relabelContactPhone?: string; relabelDeliveryMethod?: string
createdAt?: string
items?: Array<{ skuCode?: string; productNameZh?: string; packageCount?: number; shippingMark?: string }>
relabelBindings?: Array<{ operationType?: string; customerCartonMark?: string; originalSku?: string; newSku?: string; quantity?: number; fbaCartonMark?: string; destinationWarehouseCode?: string; remark?: string }>
cartons?: TransferCarton[]; pallets?: TransferPallet[]; measurementPhotos?: TransferMeasurementPhoto[]; customerConfirmedAt?: string
}
const selectedDetail = ref<ServiceDetail | null>(null)
const detailError = ref('')
const selectedRelabelDetail = ref<RelabelDetail | null>(null)
const completionPhotoUrls = reactive<Record<string, string>>({})
const completionPhotoLoading = reactive<Record<string, boolean>>({})
@@ -128,23 +140,52 @@ const operationWarehouses = ref<MerchantWarehouse[]>([])
const fbaWarehousesLoading = ref(false)
const fbaWarehousesError = ref('')
const operationWarehousesError = ref('')
const listQueryFields = computed<QueryField[]>(() => {
const fields: QueryField[] = [
{ key: 'number', label: config.value.numberLabel, type: 'input', placeholder: `请输入${config.value.numberLabel}` },
]
if (props.service !== 'labeling' && props.service !== 'relabel') fields.push({ key: 'inboundNo', label: '进仓编号', type: 'input', placeholder: '请输入进仓编号' })
fields.push({
key: 'warehouse', label: props.service === 'relabel' ? '来源 FBA 仓' : '操作仓', type: 'select',
options: (props.service === 'relabel' ? fbaWarehouses.value : operationWarehouses.value).map((warehouse) => ({ label: warehouseLabel(warehouse), value: String(warehouse.id) })),
})
if (props.service === 'transfer' || props.service === 'relabel') fields.push({ key: 'destination', label: '目的 FBA 仓', type: 'input', placeholder: '请输入仓代码' })
fields.push(
{ key: 'status', label: '订单状态', type: 'select', options: statusOptions.value },
{ key: 'createdDates', label: '创建时间', type: 'daterange' },
)
return fields
})
const listColumns = computed<DataTableColumn[]>(() => config.value.columns.map((label, index) => index === 0
? { key: 'actions', label, slotName: 'actions', width: props.service === 'transfer' ? 210 : 160, fixed: 'left' }
: { key: String(index - 1), prop: String(index - 1), label, minWidth: index === 1 ? 190 : 130 }))
const discardCandidates = ref<import('../api/warehouseServices').DiscardCandidate[]>([])
const selectedDiscardOrderId = ref('')
const discardCandidatesLoading = ref(false)
const relabelCandidates = ref<RelabelInventoryCandidate[]>([])
const selectedRelabelInboundOrderId = ref('')
const relabelCandidatesLoading = ref(false)
const returnRelabelSkuRows = ref<ReturnRelabelSkuRow[]>([])
const isReturnRelabelSource = computed(() => props.service === 'labeling' && selectedRelabelInboundOrderId.value.startsWith('RETURN_ORDER:'))
const selectedReturnOrderNo = computed(() => isReturnRelabelSource.value ? selectedRelabelInboundOrderId.value.slice('RETURN_ORDER:'.length) : '')
const requiresBothTotalFiles = computed(() => (props.service === 'labeling' || (props.service === 'transfer' && form.relabel)) && !isReturnRelabelSource.value && labelingMode.value === 'carton_and_sku')
function inventoryQuantity(value: number | string | null | undefined) {
const quantity = Number(value)
return Number.isFinite(quantity) && quantity > 0 ? quantity : 0
}
const relabelInboundOptions = computed(() => {
const grouped = new Map<string, { value: string; inboundOrderNo: string; inboundCode: string; availablePackages: number }>()
const grouped = new Map<string, { value: string; inboundOrderNo: string; inboundCode: string; availablePackages: number; quantityUnit: '箱' | '件' }>()
for (const candidate of relabelCandidates.value) {
const value = candidate.sourceType === 'RETURN_CARTON' ? `RETURN_CARTON:${candidate.returnCartonId}` : String(candidate.inboundOrderId)
const returnOrderNo = candidate.inboundOrderNo || String(candidate.returnCartonId || '')
const value = candidate.sourceType === 'RETURN_CARTON' ? `RETURN_ORDER:${returnOrderNo}` : String(candidate.inboundOrderId)
const current = grouped.get(value)
if (current) current.availablePackages += candidate.availablePackages
if (current) current.availablePackages += inventoryQuantity(candidate.availablePackages)
else grouped.set(value, {
value,
inboundOrderNo: candidate.sourceType === 'RETURN_CARTON' ? `退货合箱 ${candidate.cartonMark || '—'}` : (candidate.inboundOrderNo || '—'),
inboundCode: candidate.sourceType === 'RETURN_CARTON' ? '仓库唛头库存' : (candidate.inboundCode || '—'),
availablePackages: candidate.availablePackages,
inboundOrderNo: candidate.sourceType === 'RETURN_CARTON' ? `退货单 ${candidate.inboundOrderNo || '—'}` : (candidate.inboundOrderNo || '—'),
inboundCode: candidate.sourceType === 'RETURN_CARTON' ? '已签收退货库存' : (candidate.inboundCode || '—'),
availablePackages: inventoryQuantity(candidate.availablePackages),
quantityUnit: candidate.sourceType === 'RETURN_CARTON' ? '件' : '箱',
})
}
return [...grouped.values()]
@@ -165,7 +206,15 @@ const skuTotalCreateInput = ref<HTMLInputElement | null>(null)
let cargoSequence = 0
let skuRelabelSequence = 0
watch(() => props.service, () => { mode.value = 'list'; page.value = 1; resetFilters(); resetForm(); void loadPage() })
watch(() => [props.service, props.createPage, props.detailPage, detailNumber.value] as const, () => {
mode.value = props.detailPage ? 'detail' : props.createPage ? 'create' : 'list'
page.value = 1
resetFilters()
resetForm()
if (props.createPage) void openCreate()
else if (props.detailPage && detailNumber.value) void loadDetailPage()
else void loadPage()
})
watch(mode, (value) => { if (value !== 'detail') { clearCompletionPhotos(); clearMeasurementPhotos() } })
watch(() => form.relabel, (value) => {
if (props.service === 'transfer' && value) {
@@ -194,7 +243,12 @@ async function loadPage() {
if (props.service === 'labeling' || props.service === 'relabel' || props.service === 'transfer' || props.service === 'disposal') await loadWarehouses()
await load()
}
onMounted(() => { void loadPage(); void nextTick(refreshWarehouseMarkLabels) })
onMounted(() => {
if (props.createPage) void openCreate()
else if (props.detailPage && detailNumber.value) void loadDetailPage()
else void loadPage()
void nextTick(refreshWarehouseMarkLabels)
})
onBeforeUnmount(() => { clearCompletionPhotos(); clearMeasurementPhotos() })
function resetFilters() { Object.assign(filters, { number: '', inboundNo: '', warehouse: '', destination: '', status: '', start: '', end: '' }) }
function resetForm() {
@@ -209,17 +263,44 @@ function resetForm() {
selectedDiscardOrderId.value = ''
relabelCandidates.value = []
selectedRelabelInboundOrderId.value = ''
cartonTotalFile.value = null
skuTotalFile.value = null
shippingLabelFile.value = null
if (cartonTotalCreateInput.value) cartonTotalCreateInput.value.value = ''
if (skuTotalCreateInput.value) skuTotalCreateInput.value.value = ''
transferDestinationType.value = 'NONE'
selectedOperationWarehouse.value = null
editingTransferNo.value = ''
}
async function openCreate() {
resetForm(); mode.value = 'create'
if (props.service === 'labeling' || props.service === 'relabel' || props.service === 'transfer') void loadWarehouses()
if (props.service === 'labeling') return
if (props.service === 'labeling' || props.service === 'relabel' || props.service === 'transfer') await loadWarehouses()
if (props.service === 'labeling') {
const requestedReturnNo = String(route.query.returnNo || '').trim()
const requestedWarehouseId = String(route.query.warehouseId || '').trim()
if (requestedReturnNo && requestedWarehouseId && operationWarehouses.value.some(warehouse => String(warehouse.id) === requestedWarehouseId)) {
form.warehouse = requestedWarehouseId
await selectOperationWarehouse()
const sourceValue = `RETURN_ORDER:${requestedReturnNo}`
if (relabelInboundOptions.value.some(option => option.value === sourceValue)) {
selectedRelabelInboundOrderId.value = sourceValue
selectRelabelInventory(sourceValue)
} else ElMessage.warning(`退货单 ${requestedReturnNo} 暂无可申请贴标的库存`)
}
return
}
if (props.service !== 'disposal') { addCargoRow(); return }
}
function returnToList() {
if (props.createPage || props.detailPage) {
const routeName = ({ labeling: 'overseas-labeling', transfer: 'overseas-storage-transfer', disposal: 'overseas-cargo-disposal', relabel: 'overseas-return-relabel' } as Record<ServiceType, string>)[props.service]
void router.push({ name: routeName })
return
}
mode.value = 'list'
}
async function openEdit(transferNo: string) {
try {
resetForm()
@@ -280,6 +361,36 @@ async function selectDiscardWarehouse() {
}
function createCargoRow(): CargoRow { return { id: ++cargoSequence, sku: '', name: '', quantity: 1, fnsku: '', shipmentNo: '', newSkuCode: '', shippingMark: '', customerCartonMarks: '', cartonMark: '', fbaCartonMark: '', destinationWarehouseId: '', remark: '' } }
function addCargoRow() { cargoRows.value.push(createCargoRow()) }
function escapePrintHtml(value: string) {
return value.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;').replaceAll('"', '&quot;').replaceAll("'", '&#39;')
}
function printTransferDraftCartonMarks() {
if (props.service !== 'transfer') return
const validRows = cargoRows.value.filter((row) => row.sku.trim() && Number(row.quantity) > 0)
if (!validRows.length || validRows.some((row) => !Number.isInteger(Number(row.quantity)))) {
ElMessage.warning('请先填写商品 SKU 和对应的整数箱数'); return
}
const totalCartons = validRows.reduce((sum, row) => sum + Number(row.quantity), 0)
if (totalCartons > 500) { ElMessage.warning('单次最多打印 500 张箱唛'); return }
const batch = new Date().toISOString().replace(/\D/g, '').slice(2, 14)
const labels: Array<{ mark: string; sku: string; index: number }> = []
let cartonIndex = 0
for (const [rowIndex, row] of validRows.entries()) {
let marks = transferCustomerMarks(row)
if (marks.length !== Number(row.quantity)) {
marks = Array.from({ length: Number(row.quantity) }, (_, index) => `CT-${batch}-${String(rowIndex + 1).padStart(2, '0')}-${String(index + 1).padStart(3, '0')}`)
row.customerCartonMarks = marks.join('\n')
}
for (const mark of marks) labels.push({ mark, sku: row.sku.trim(), index: ++cartonIndex })
}
const printWindow = window.open('', '_blank')
if (!printWindow) { ElMessage.warning('浏览器阻止了打印窗口,请允许弹窗后重试'); return }
printWindow.opener = null
const sections = labels.map((label) => `<section class="label"><div class="title">仓储转运客户箱唛</div><div class="mark">${escapePrintHtml(label.mark)}</div><div class="sku">SKU:${escapePrintHtml(label.sku)}</div><div class="meta">${label.index} / ${labels.length}</div></section>`).join('')
printWindow.document.open()
printWindow.document.write(`<!doctype html><html><head><meta charset="utf-8"><title>仓储转运客户箱唛</title><style>@page{size:100mm 100mm;margin:8mm}body{margin:0;font-family:Arial,'Microsoft YaHei',sans-serif;color:#111}.label{height:84mm;display:flex;flex-direction:column;align-items:center;justify-content:center;page-break-after:always;border:1px solid #222;box-sizing:border-box;padding:8mm}.title{font-size:18px;font-weight:700}.mark{margin:11mm 0 6mm;font-size:26px;font-weight:700;letter-spacing:1px;word-break:break-all;text-align:center}.sku{font-size:17px;font-weight:650}.meta{font-size:12px;margin-top:4mm}</style></head><body>${sections}</body></html>`)
printWindow.document.close(); printWindow.focus(); printWindow.print()
}
function addTransferRelabelRow() { transferRelabelRows.value.push(createCargoRow()) }
function clearTransferRelabelRows() {
transferRelabelRows.value = []
@@ -308,8 +419,14 @@ function applyExistingCartonBinding(row: SkuRelabelRow) {
if (!row.destinationWarehouseId) row.destinationWarehouseId = mapping.destinationWarehouseId
}
function selectShippingLabel(event: Event) { shippingLabelFile.value = (event.target as HTMLInputElement).files?.[0] || null }
function selectCartonTotalFile(event: Event) { cartonTotalFile.value = (event.target as HTMLInputElement).files?.[0] || null }
function selectSkuTotalFile(event: Event) { skuTotalFile.value = (event.target as HTMLInputElement).files?.[0] || null }
function selectCartonTotalFile(event: Event) {
cartonTotalFile.value = (event.target as HTMLInputElement).files?.[0] || null
if (cartonTotalFile.value && !requiresBothTotalFiles.value) clearCreateTotalFile('SKU_TOTAL')
}
function selectSkuTotalFile(event: Event) {
skuTotalFile.value = (event.target as HTMLInputElement).files?.[0] || null
if (skuTotalFile.value && !requiresBothTotalFiles.value) clearCreateTotalFile('CARTON_TOTAL')
}
function clearCreateTotalFile(scope: 'CARTON_TOTAL' | 'SKU_TOTAL') {
if (saving.value) return
const file = scope === 'CARTON_TOTAL' ? cartonTotalFile : skuTotalFile
@@ -606,7 +723,8 @@ function warehouseDisplay(value?: string) {
function statusLabel(value: string) {
if (props.service === 'labeling') {
if (value === 'PROCESSING') return '处理中'
return ['CUSTOMER_CONFIRM_PENDING', 'READY_FOR_OUTBOUND', 'COMPLETED'].includes(value) ? '换标完成' : '待处理'
if (value === 'CUSTOMER_CONFIRM_PENDING') return '待客户确认'
return ['READY_FOR_OUTBOUND', 'COMPLETED'].includes(value) ? '换标完成' : '待处理'
}
if (props.service === 'transfer') {
const transferLabels: Record<string, string> = {
@@ -670,10 +788,51 @@ async function load() {
} catch (error) { submittedRows.value = []; total.value = 0; ElMessage.error(error instanceof Error ? error.message : '列表加载失败') }
finally { loading.value = false }
}
function query() { page.value = 1; void load() }
function changePage(value: number) { page.value = value; void load() }
function changePageSize(value: number) { pageSize.value = value; page.value = 1; void load() }
function query() { if (page.value === 1) void load(); else page.value = 1 }
function resetListQuery() { resetFilters(); query() }
function listRowActions(candidate: object): TableAction[] {
const item = candidate as Record<string, string>
const actions: TableAction[] = [{ key: 'detail', label: '详情', run: () => openDetail(item) }]
if (canCreate.value && props.service === 'transfer' && item.__status === 'DRAFT') actions.push({ key: 'edit', label: '编辑', run: () => openEdit(item['0']) })
if (canConfirmTransfer.value && props.service === 'transfer' && item.__status === 'WAIT_CUSTOMER_CONFIRMATION') actions.push({ key: 'confirm-measurement', label: '确认测量', run: () => confirmTransferFromList(item['0']) })
if (canConfirmRelabel.value && props.service === 'labeling' && item.__status === 'CUSTOMER_CONFIRM_PENDING' && !item.__confirmedAt) actions.push({ key: 'confirm-relabel', label: '确认换标', run: () => confirmRelabelFromList(item['0']) })
return actions
}
async function submitReturnRelabel() {
const items = returnRelabelSkuRows.value.filter(item => item.selected)
.map(item => ({ skuCode: item.skuCode, quantity: Number(item.quantity) }))
if (!selectedReturnOrderNo.value || !items.length) { ElMessage.warning('请至少选择一个需要贴标的 SKU'); return }
if (items.some(item => item.quantity < 1 || item.quantity > (returnRelabelSkuRows.value.find(row => row.skuCode === item.skuCode)?.availableQuantity || 0))) {
ElMessage.warning('本次贴标数量必须大于 0,且不能超过该 SKU 的库存总数'); return
}
const destination = fbaWarehouses.value.find(warehouse => warehouse.warehouseCode === form.destination)
if (!destination) { ElMessage.warning('请选择 FBA 目的仓'); return }
const selectedFile = cartonTotalFile.value || skuTotalFile.value
const fileScope = cartonTotalFile.value ? 'CARTON_TOTAL' as const : 'SKU_TOTAL' as const
if (!selectedFile) { ElMessage.warning('请上传箱唛总文件或 SKU 总文件(二选一)'); return }
try { await ElMessageBox.confirm(`确认提交退货单 ${selectedReturnOrderNo.value} 的贴标申请吗?`, '提交确认', { confirmButtonText: '提交', cancelButtonText: '取消' }) } catch { return }
saving.value = true
try {
const created = await createReturnRelabel(selectedReturnOrderNo.value, {
items,
fileScope,
destinationFbaWarehouseId: String(destination.id),
destinationFbaWarehouseCode: destination.warehouseCode,
cartonRemark: form.remark.trim() || undefined,
idempotencyKey: crypto.randomUUID(),
})
await uploadReturnRelabelFile(created.relabelNo, fileScope, selectedFile)
ElMessage.success(`贴标申请 ${created.relabelNo} 已提交,等待审核`)
await router.push({ name: 'overseas-labeling' })
} catch (error) { ElMessage.error(error instanceof Error ? error.message : '退货贴标申请提交失败,请重试') }
finally { saving.value = false }
}
watch([page, pageSize], ([nextPage, nextSize], [previousPage, previousSize]) => {
if (nextSize !== previousSize && nextPage !== 1) { page.value = 1; return }
void load()
})
async function submit(action: 'draft' | 'submit' = 'submit') {
if (isReturnRelabelSource.value) { await submitReturnRelabel(); return }
const primaryWarehouse = props.service === 'relabel' ? form.sourceWarehouse : form.warehouse
const relabelRows = labelingMode.value === 'carton' ? cartonDetailRows.value : skuRelabelRows.value
const invalidCargo = props.service === 'disposal'
@@ -690,6 +849,12 @@ async function submit(action: 'draft' | 'submit' = 'submit') {
if (props.service === 'labeling' && (!form.labelDeadline || !form.contact || !form.phone)) {
ElMessage.warning('请填写贴标截止时间和海外仓联系人信息'); return
}
if (props.service === 'labeling' && requiresBothTotalFiles.value && (!cartonTotalFile.value || !skuTotalFile.value)) {
ElMessage.warning('SKU 和箱唛都换时,必须同时上传箱唛总文件和 SKU 总文件'); return
}
if (props.service === 'labeling' && !requiresBothTotalFiles.value && !cartonTotalFile.value && !skuTotalFile.value) {
ElMessage.warning('请上传箱唛总文件或 SKU 总文件(二选一)'); return
}
if (props.service === 'labeling' && labelingMode.value !== 'sku' && !form.deliveryMethod) {
ElMessage.warning('请选择换标完成后的派送方式'); return
}
@@ -725,6 +890,8 @@ async function submit(action: 'draft' | 'submit' = 'submit') {
if (props.service === 'transfer' && form.relabel) {
if (!form.labelDeadline || !form.relabelContact || !form.relabelPhone || (labelingMode.value !== 'sku' && !form.relabelDeliveryMethod)) { ElMessage.warning('请填写换标截止时间、换标联系人和换标后派送方式'); return }
if (!transferRelabelDefaultDestination.value) { ElMessage.warning('请选择整票换标货物的 FBA 目的仓'); return }
if (action === 'submit' && requiresBothTotalFiles.value && (!cartonTotalFile.value || !skuTotalFile.value)) { ElMessage.warning('SKU 和箱唛都换时,必须同时上传箱唛总文件和 SKU 总文件'); return }
if (action === 'submit' && !requiresBothTotalFiles.value && !cartonTotalFile.value && !skuTotalFile.value) { ElMessage.warning('请上传箱唛总文件或 SKU 总文件(二选一)'); return }
const relabelRows = transferRelabelRows.value
const allMarks = relabelRows.flatMap(transferCustomerMarks)
if (!relabelRows.length || relabelRows.some((row) => transferCustomerMarks(row).length !== row.quantity)) { ElMessage.warning('每个换标明细的客户箱唛数量必须与换标数量一致'); return }
@@ -752,12 +919,14 @@ async function submit(action: 'draft' | 'submit' = 'submit') {
if (shippingLabelFile.value) await uploadRelabelShippingLabel(created.application.relabelNo, shippingLabelFile.value)
} catch (error) {
// Creation already committed: leave the creation form even if the detail reload fails.
mode.value = 'list'
await load()
await openDetail({ '0': created.application.relabelNo })
if (props.createPage) await router.push({ name: 'overseas-labeling-detail', params: { detailNo: created.application.relabelNo } })
else { mode.value = 'list'; await load(); await openDetail({ '0': created.application.relabelNo }) }
await ElMessageBox.alert(`申请 ${created.application.relabelNo} 已创建,部分附件上传失败:${error instanceof Error ? error.message : '上传失败'}。请在原单详情补传失败附件,不要重复下单。`, '申请已创建,附件待补传', { confirmButtonText: '知道了' })
return
}
ElMessage.success(`${config.value.title}提交成功`)
await router.push({ name: 'overseas-labeling-detail', params: { detailNo: created.application.relabelNo } })
return
} else if (props.service === 'disposal') {
const items = cargoRows.value.filter((item) => item.quantity > 0).map((item) => {
if (!item.sourceOrderId || item.sourceOrderId !== selectedDiscardOrderId.value || item.quantity > Number(item.availableQuantity || 0)) {
@@ -779,17 +948,37 @@ async function submit(action: 'draft' | 'submit' = 'submit') {
const transferNo = String(created.transferNo || '')
if (!transferNo) throw new Error('转运草稿已保存,但未返回转运单号')
if (action === 'submit') {
await submitTransfer(transferNo)
const submitted = await submitTransfer(transferNo)
if (form.relabel) {
const relabelNo = String(submitted.relabelNo || created.relabelNo || '')
try {
if (!relabelNo) throw new Error('转运单已提交,但未返回关联换标单号')
if (cartonTotalFile.value) await uploadRelabelFile(relabelNo, 'CARTON_TOTAL', cartonTotalFile.value)
if (skuTotalFile.value) await uploadRelabelFile(relabelNo, 'SKU_TOTAL', skuTotalFile.value)
} catch (error) {
await ElMessageBox.alert(`转运单 ${transferNo} 已提交,但换标总文件上传失败:${error instanceof Error ? error.message : '上传失败'}。请进入关联换标单详情补传,不要重复提交转运单。`, '转运单已提交,附件待补传', { confirmButtonText: '知道了' })
await openDetail({ '0': transferNo })
return
}
}
ElMessage.success('转运单已提交审核')
await openDetail({ '0': transferNo })
return
}
}
ElMessage.success(props.service === 'transfer' ? '转运单草稿保存成功' : `${config.value.title}提交成功`); mode.value = 'list'; await load()
ElMessage.success(props.service === 'transfer' ? '转运单草稿保存成功' : `${config.value.title}提交成功`)
if (props.createPage) await router.push({ name: props.service === 'relabel' ? 'overseas-return-relabel' : 'overseas-labeling' })
else { mode.value = 'list'; await load() }
} catch (error) { ElMessage.error(error instanceof Error ? error.message : '提交失败') }
finally { saving.value = false }
}
async function openDetail(value: Record<string, string>) {
if (['labeling', 'transfer', 'disposal'].includes(props.service) && !props.detailPage) {
const routeName = ({ labeling: 'overseas-labeling-detail', transfer: 'overseas-storage-transfer-detail', disposal: 'overseas-cargo-disposal-detail' } as Partial<Record<ServiceType, string>>)[props.service]
if (routeName) void router.push({ name: routeName, params: { detailNo: value['0'] } })
return
}
detailError.value = ''
try {
const detail = props.service === 'labeling' ? await getRelabel(value['0']) : props.service === 'disposal' ? await getDiscard(value['0']) : await getTransfer(value['0'])
const discardSummary = props.service === 'disposal' ? (detail as { summary?: ServiceDetail }).summary : undefined
@@ -798,7 +987,11 @@ async function openDetail(value: Record<string, string>) {
mode.value = 'detail'
if (props.service === 'labeling') void loadCompletionPhotos()
if (props.service === 'transfer') void loadMeasurementPhotos()
} catch (error) { ElMessage.error(error instanceof Error ? error.message : '详情加载失败') }
} catch (error) { selectedDetail.value = null; selectedRelabelDetail.value = null; detailError.value = error instanceof Error ? error.message : '详情加载失败'; mode.value = 'detail'; ElMessage.error(detailError.value) }
}
async function loadDetailPage() {
await loadWarehouses()
await openDetail({ '0': detailNumber.value })
}
async function confirmTransferMeasurements() {
const transferNo = String(selectedDetail.value?.transferNo || selectedDetail.value?.['0'] || '')
@@ -963,15 +1156,31 @@ async function selectOperationWarehouse() {
}
function selectRelabelInventory(inboundOrderId: string) {
const rows: CargoRow[] = []
for (const candidate of relabelCandidates.value.filter((item) => (item.sourceType === 'RETURN_CARTON' ? `RETURN_CARTON:${item.returnCartonId}` : String(item.inboundOrderId)) === inboundOrderId)) {
if (candidate.sourceType === 'RETURN_CARTON') rows.push({
id: ++cargoSequence, sku: candidate.skuCode || '', name: candidate.productNameZh || '退货合箱库存',
included: true, quantity: 1, availableQuantity: 1, returnCartonId: String(candidate.returnCartonId),
sourceInboundOrderNo: candidate.inboundOrderNo, sourceWarehouseName: candidate.overseasWarehouseName,
fnsku: '', shipmentNo: candidate.inboundOrderNo || candidate.cartonMark || '', newSkuCode: '', shippingMark: '', customerCartonMarks: '',
cartonMark: candidate.cartonMark || '', fbaCartonMark: '', destinationWarehouseId: candidate.destinationFbaWarehouseCode || '', remark: '',
})
else for (const position of candidate.positions) rows.push(...Array.from({ length: Math.min(position.availablePackages, 500) }, () => ({
const selectedCandidates = relabelCandidates.value.filter((item) => {
const returnOrderNo = item.inboundOrderNo || String(item.returnCartonId || '')
return (item.sourceType === 'RETURN_CARTON' ? `RETURN_ORDER:${returnOrderNo}` : String(item.inboundOrderId)) === inboundOrderId
})
returnRelabelSkuRows.value = []
if (inboundOrderId.startsWith('RETURN_ORDER:')) {
const grouped = new Map<string, ReturnRelabelSkuRow>()
for (const candidate of selectedCandidates) {
const skuItems = candidate.skuItems?.length
? candidate.skuItems
: candidate.skuCode ? [{ skuCode: candidate.skuCode, quantity: inventoryQuantity(candidate.availablePackages) }] : []
for (const item of skuItems) {
const skuCode = item.skuCode.trim()
if (!skuCode) continue
const key = skuCode.toUpperCase()
const current = grouped.get(key)
const quantity = inventoryQuantity(item.quantity)
if (current) current.availableQuantity += quantity
else grouped.set(key, { skuCode, productNameZh: candidate.skuCode?.trim().toUpperCase() === key ? (candidate.productNameZh || '') : '', availableQuantity: quantity, selected: false, quantity })
}
}
returnRelabelSkuRows.value = [...grouped.values()]
labelingMode.value = 'sku'
} else for (const candidate of selectedCandidates) {
for (const position of candidate.positions) rows.push(...Array.from({ length: Math.min(inventoryQuantity(position.availablePackages), 500) }, () => ({
id: ++cargoSequence, sku: candidate.skuCode || '', name: candidate.productNameZh || '库存货物',
included: false, quantity: 1, availableQuantity: 1,
sourceInventoryBalanceId: String(candidate.inventoryBalanceId), sourceInventoryPositionId: String(position.positionId),
@@ -989,53 +1198,36 @@ function selectRelabelInventory(inboundOrderId: string) {
shippingLabelFile.value = null
cartonTotalFile.value = null
skuTotalFile.value = null
if (labelingMode.value !== 'carton') addSkuRelabelRow()
if (!isReturnRelabelSource.value && labelingMode.value !== 'carton') addSkuRelabelRow()
}
</script>
<template>
<section class="warehouse-service-page">
<section class="warehouse-service-page" :class="{ 'warehouse-service-page--detail': mode === 'detail', 'warehouse-service-page--return-relabel': isReturnRelabelSource }">
<template v-if="mode === 'list'">
<div class="warehouse-breadcrumb"><span>首页</span><b>/</b><strong>{{ config.title }}</strong></div>
<div class="warehouse-page-heading"><h2>{{ config.title }}</h2><el-button v-if="canCreate" type="primary" @click="openCreate"><Plus :size="16" />{{ props.service === 'relabel' ? '新建退货换标单' : props.service === 'transfer' ? '新增订单' : `新增${config.title}` }}</el-button></div>
<div class="warehouse-card warehouse-filter-card">
<div class="warehouse-filter-grid">
<label><span>{{ config.numberLabel }}</span><el-input v-model="filters.number" :placeholder="`请输入${config.numberLabel}`" clearable /></label>
<label v-if="props.service !== 'labeling' && props.service !== 'relabel'"><span>进仓编号</span><el-input v-model="filters.inboundNo" placeholder="请输入进仓编号" clearable /></label>
<label><span>{{ props.service === 'relabel' ? '来源 FBA 仓' : '操作仓' }}</span><el-select v-model="filters.warehouse" clearable :loading="fbaWarehousesLoading" placeholder="全部"><el-option label="全部" value="" /><el-option v-for="warehouse in (props.service === 'relabel' ? fbaWarehouses : operationWarehouses)" :key="warehouse.id" :label="warehouseLabel(warehouse)" :value="String(warehouse.id)" /></el-select></label>
<label v-if="props.service === 'transfer' || props.service === 'relabel'"><span>目的 FBA 仓</span><el-input v-model="filters.destination" placeholder="请输入仓代码" clearable /></label>
<label><span>订单状态</span><el-select v-model="filters.status" placeholder="全部状态" clearable><el-option v-for="option in statusOptions" :key="option.value" :label="option.label" :value="option.value" /></el-select></label>
<label><span>创建时间</span><el-date-picker v-model="filters.start" type="date" placeholder="开始日期" value-format="YYYY-MM-DD" /></label>
<label><span>&nbsp;</span><el-date-picker v-model="filters.end" type="date" placeholder="结束日期" value-format="YYYY-MM-DD" /></label>
</div>
<div class="warehouse-filter-actions"><el-button type="primary" @click="query"><Search :size="16" />查询</el-button><el-button @click="resetFilters(); query()">重置</el-button></div>
</div>
<div v-loading="loading" class="warehouse-card warehouse-table-card">
<div v-if="props.service === 'transfer' && submittedRows.some((item) => item.__status === 'WAIT_CUSTOMER_CONFIRMATION')" class="warehouse-hint">部分入库完成订单已有 PDA 实测数据,请在对应行确认测量数据。</div>
<div class="warehouse-table-scroll"><table><thead><tr><th v-for="column in config.columns" :key="column">{{ column }}</th></tr></thead><tbody><tr v-for="(row, rowIndex) in submittedRows" :key="rowIndex"><td><div class="warehouse-list-actions"><button class="warehouse-link" type="button" @click="openDetail(row)"><Eye :size="15" />查看</button><el-button v-if="canCreate && props.service === 'transfer' && row.__status === 'DRAFT'" native-type="button" size="small" type="primary" @click="openEdit(row['0'])">编辑</el-button><el-button v-if="canConfirmTransfer && props.service === 'transfer' && row.__status === 'WAIT_CUSTOMER_CONFIRMATION'" size="small" type="primary" @click="confirmTransferFromList(row['0'])">确认测量数据</el-button><el-button v-if="canConfirmRelabel && props.service === 'labeling' && row.__status === 'CUSTOMER_CONFIRM_PENDING' && !row.__confirmedAt" size="small" type="primary" @click="confirmRelabelFromList(row['0'])">换标完成确认</el-button></div></td><td v-for="(_, index) in config.columns.slice(1)" :key="index"><SingleLineText :value="row[String(index)]" /></td></tr><tr v-if="submittedRows.length === 0"><td :colspan="config.columns.length"><div class="warehouse-empty">{{ config.emptyText }}</div></td></tr></tbody></table></div>
<div class="warehouse-pagination">
<span class="warehouse-pagination-total">共 {{ total }} 条</span>
<el-select v-model="pageSize" class="warehouse-page-size-select" aria-label="每页条数" @change="changePageSize"><el-option v-for="size in [15, 30, 50, 100]" :key="size" :label="`${size}条/页`" :value="size" /></el-select>
<el-pagination v-model:current-page="page" :page-size="pageSize" :total="total" :hide-on-single-page="false" layout="prev, pager, next" @current-change="changePage" />
</div>
<QueryToolbar v-model="listQueryModel" layout="grid" :ariaLabel="`${config.title}筛选`" :fields="listQueryFields" :search-on-reset="false" @search="query" @reset="resetListQuery" />
<div class="warehouse-card warehouse-table-card">
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="submittedRows" :columns="listColumns" :loading="loading" :total="total" :page-sizes="[15, 30, 50, 100]" :row-actions="listRowActions" :empty-text="config.emptyText">
<template #toolbar-actions><el-button v-if="canCreate" type="primary" @click="openCreate"><Plus :size="16" />{{ props.service === 'relabel' ? '新建退货换标单' : props.service === 'transfer' ? '新增订单' : `新增${config.title}` }}</el-button></template>
<template v-if="props.service === 'transfer' && submittedRows.some((item) => item.__status === 'WAIT_CUSTOMER_CONFIRMATION')" #before-table><div class="warehouse-list-notice">部分入库完成订单已有 PDA 实测数据,请在对应行确认测量数据。</div></template>
</DataTable>
</div>
</template>
<template v-else-if="mode === 'create' || mode === 'edit'">
<div class="warehouse-breadcrumb"><button type="button" @click="mode = 'list'">{{ config.title }}</button><b>/</b><strong>{{ mode === 'edit' ? '编辑' : '新增' }}</strong></div>
<div class="warehouse-page-heading"><h2>{{ mode === 'edit' ? `编辑转运草稿 ${editingTransferNo}` : config.createTitle }}</h2><el-button @click="mode = 'list'"><ArrowLeft :size="16" />返回列表</el-button></div>
<div class="warehouse-page-heading"><h2>{{ mode === 'edit' ? `编辑转运草稿 ${editingTransferNo}` : config.createTitle }}</h2><el-button @click="returnToList"><ArrowLeft :size="16" />返回列表</el-button></div>
<form :class="{ 'warehouse-transfer-form': props.service === 'transfer', 'warehouse-transfer-form--relabel': props.service === 'transfer' && form.relabel }" @submit.prevent="submit('submit')">
<div class="warehouse-card warehouse-form-section warehouse-transfer-main-card"><h3>{{ props.service === 'transfer' ? '入库与转运信息' : props.service === 'relabel' ? '退货信息' : props.service === 'labeling' ? '订单信息' : '基础信息' }}</h3>
<div class="warehouse-card warehouse-form-section warehouse-transfer-main-card"><h3>{{ props.service === 'transfer' ? '转运信息' : props.service === 'relabel' ? '退货信息' : props.service === 'labeling' ? '订单信息' : '基础信息' }}</h3>
<div class="warehouse-form-grid">
<label v-if="props.service === 'disposal'" class="warehouse-span-2"><span><i>*</i>操作仓</span><el-select v-model="form.warehouse" filterable clearable :loading="fbaWarehousesLoading" :disabled="Boolean(operationWarehousesError)" placeholder="请选择海外仓" @change="selectDiscardWarehouse"><el-option v-for="warehouse in operationWarehouses" :key="warehouse.id" :label="warehouseLabel(warehouse)" :value="String(warehouse.id)" /></el-select><small v-if="operationWarehousesError" class="warehouse-hint">{{ operationWarehousesError }}</small><small v-else class="warehouse-hint">请先选择本次废弃货物所在的海外仓。</small></label>
<label v-if="props.service === 'disposal'" class="warehouse-span-2"><span><i>*</i>选择入库单</span><el-select v-model="selectedDiscardOrderId" filterable clearable :loading="discardCandidatesLoading" :disabled="!form.warehouse" no-data-text="当前海外仓暂无可废弃库存" placeholder="请先选择操作仓,再选择有可用库存的入库单" @change="selectDiscardInbound"><el-option v-for="candidate in discardCandidates" :key="candidate.orderId" :label="`${candidate.inboundOrderNo || candidate.inboundCode || '—'}|${candidate.inboundCode || '—'}|可用 ${candidate.availablePackages} 箱`" :value="candidate.orderId" /></el-select><small class="warehouse-hint">仅显示当前操作仓下一件代发中仍有可用箱数的入库单。</small></label>
<label v-if="props.service === 'relabel'"><span><i>*</i>进仓编号</span><el-input v-model="form.inboundNo" placeholder="请输入已有入仓编号" /></label>
<label v-if="props.service === 'relabel'"><span><i>*</i>来源 FBA 仓库</span><el-select v-model="form.sourceWarehouse" :loading="fbaWarehousesLoading" :disabled="Boolean(fbaWarehousesError)" placeholder="请选择平台维护的 FBA 仓"><el-option v-for="warehouse in fbaWarehouses" :key="warehouse.id" :label="warehouseLabel(warehouse)" :value="warehouse.warehouseCode" /></el-select></label>
<label v-else-if="props.service === 'disposal'"><span>进仓编号</span><el-input v-model="form.inboundNo" readonly /></label>
<label v-else><span><i>*</i>{{ props.service === 'labeling' ? '海外操作仓(中转仓)' : props.service === 'transfer' ? '入库海外仓' : '操作仓' }}</span><el-select v-model="form.warehouse" :loading="fbaWarehousesLoading || operationWarehouseDetailLoading" :disabled="Boolean(operationWarehousesError)" clearable :placeholder="props.service === 'transfer' ? '请选择海外仓' : '请选择国外中转仓'" @change="props.service === 'labeling' ? selectOperationWarehouse() : props.service === 'transfer' && selectTransferWarehouse()"><el-option v-for="warehouse in operationWarehouses" :key="warehouse.id" :label="warehouseLabel(warehouse)" :value="String(warehouse.id)" /></el-select><small v-if="operationWarehousesError" class="warehouse-hint">{{ operationWarehousesError }}</small><small v-else-if="props.service === 'labeling'" class="warehouse-hint">选择后仅查询当前客户在该中转仓的可用库存。</small><small v-else-if="props.service === 'transfer'" class="warehouse-hint">本批货物将先进入该海外仓完成签收和入库。</small></label>
<label v-if="props.service === 'labeling'" class="warehouse-span-2"><span><i>*</i>选择有库存的历史入仓单</span><el-select v-model="selectedRelabelInboundOrderId" filterable clearable :loading="relabelCandidatesLoading" :disabled="!form.warehouse" no-data-text="当前中转仓暂无已入库可用库存" placeholder="请选择曾经下单且当前仍有库存的入仓单" @change="selectRelabelInventory"><el-option v-for="option in relabelInboundOptions" :key="option.value" :label="`${option.inboundOrderNo}|入仓码 ${option.inboundCode}|可用 ${option.availablePackages} 箱`" :value="option.value" /></el-select><small class="warehouse-hint">仅显示当前客户在所选中转仓已经完成入库且仍有可用库存的历史入仓单。</small></label>
<label v-if="props.service === 'labeling' && labelingMode !== 'sku'"><span><i>*</i>FBA 目的仓</span><el-select v-model="form.destination" :loading="fbaWarehousesLoading" :disabled="Boolean(fbaWarehousesError)" filterable placeholder="请选择本张申请统一的 FBA 目的仓"><el-option v-for="warehouse in fbaWarehouses" :key="warehouse.id" :label="warehouseLabel(warehouse)" :value="warehouse.warehouseCode" /></el-select><small class="warehouse-hint">整张贴标申请只能选择一个目的仓,全部明细统一使用该目的仓。</small></label>
<div v-if="props.service === 'transfer'" class="warehouse-choice-field warehouse-span-2"><span><i>*</i>货物如何送到海外仓</span><el-radio-group v-model="form.deliveryMethod" @change="selectTransferInboundMethod"><el-radio-button value="WAREHOUSE_PICKUP">仓库上门提货</el-radio-button><el-radio-button value="CUSTOMER_DELIVERY">客户送货到仓</el-radio-button></el-radio-group><small>仓库上门提货:填写货物当前地址;客户送货到仓:下方自动展示海外仓收货信息。</small></div>
<label v-else><span><i>*</i>{{ props.service === 'labeling' ? '海外操作仓(中转仓)' : props.service === 'transfer' ? '入库海外仓' : '操作仓' }}</span><el-select v-model="form.warehouse" :loading="fbaWarehousesLoading || operationWarehouseDetailLoading" :disabled="Boolean(operationWarehousesError)" clearable :placeholder="props.service === 'transfer' ? '请选择海外仓' : '请选择国外中转仓'" @change="props.service === 'labeling' ? selectOperationWarehouse() : props.service === 'transfer' && selectTransferWarehouse()"><el-option v-for="warehouse in operationWarehouses" :key="warehouse.id" :label="warehouseLabel(warehouse)" :value="String(warehouse.id)" /></el-select><small v-if="operationWarehousesError" class="warehouse-hint">{{ operationWarehousesError }}</small></label>
<label v-if="props.service === 'labeling'" class="warehouse-span-2"><span><i>*</i>选择有库存的入仓单或退货单</span><el-select v-model="selectedRelabelInboundOrderId" filterable clearable :loading="relabelCandidatesLoading" :disabled="!form.warehouse" no-data-text="当前中转仓暂无可用库存" placeholder="请选择当前仍有可用库存的入仓单或退货单" @change="selectRelabelInventory"><el-option v-for="option in relabelInboundOptions" :key="option.value" :label="`${option.inboundOrderNo}|${option.inboundCode}|可用 ${option.availablePackages} ${option.quantityUnit}`" :value="option.value" /></el-select></label>
<label v-if="props.service === 'labeling' && (isReturnRelabelSource || labelingMode !== 'sku')"><span><i>*</i>FBA 目的仓</span><el-select v-model="form.destination" :loading="fbaWarehousesLoading" :disabled="Boolean(fbaWarehousesError)" filterable placeholder="请选择本张申请统一的 FBA 目的仓"><el-option v-for="warehouse in fbaWarehouses" :key="warehouse.id" :label="warehouseLabel(warehouse)" :value="warehouse.warehouseCode" /></el-select></label>
<div v-if="props.service === 'transfer'" class="warehouse-choice-field warehouse-span-2"><span><i>*</i>入仓方式</span><el-radio-group v-model="form.deliveryMethod" @change="selectTransferInboundMethod"><el-radio-button value="WAREHOUSE_PICKUP">仓库上门提货</el-radio-button><el-radio-button value="CUSTOMER_DELIVERY">客户送货到仓</el-radio-button></el-radio-group></div>
<label v-if="props.service === 'transfer' && form.deliveryMethod === 'WAREHOUSE_PICKUP'" class="pickup-address-field"><span><i>*</i>提货地址</span><el-input v-model="form.address" placeholder="请输入仓库上门提货的详细地址" /></label>
<label v-if="props.service === 'transfer' && form.deliveryMethod === 'WAREHOUSE_PICKUP'"><span><i>*</i>提货联系人</span><el-input v-model="form.pickupContact" placeholder="请输入提货联系人" maxlength="128" /></label>
<label v-if="props.service === 'transfer' && form.deliveryMethod === 'WAREHOUSE_PICKUP'"><span><i>*</i>提货联系人电话</span><el-input v-model="form.pickupPhone" placeholder="请输入提货联系人电话" maxlength="64" /></label>
@@ -1051,15 +1243,15 @@ function selectRelabelInventory(inboundOrderId: string) {
<label v-if="props.service === 'labeling'"><span><i>*</i>海外仓签收联系人<em v-if="form.warehouse && !form.contact" class="warehouse-unmaintained">未维护</em></span><el-input v-model="form.contact" placeholder="选择海外操作仓后自动带出" /></label>
<label v-if="props.service === 'labeling'"><span><i>*</i>海外仓联系电话<em v-if="form.warehouse && !form.phone" class="warehouse-unmaintained">未维护</em></span><el-input v-model="form.phone" placeholder="选择海外操作仓后自动带出" /></label>
<label v-if="props.service === 'labeling'"><span><i>*</i>贴标截止时间</span><el-date-picker v-model="form.labelDeadline" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择截止时间" /></label>
<label v-if="props.service === 'labeling' && labelingMode !== 'sku'"><span><i>*</i>换标后派送方式</span><el-select v-model="form.deliveryMethod" placeholder="请选择派送方式"><el-option label="卡车派送" value="TRUCK" /><el-option label="快递派送" value="EXPRESS" /><el-option label="卡车自提" value="TRUCK_PICKUP" /><el-option label="快递自提" value="EXPRESS_PICKUP" /></el-select><small class="warehouse-hint">卡车派送进入预排车;其余方式确认换标完成后进入换标出库。</small></label>
<label v-if="props.service === 'labeling' && labelingMode !== 'sku'"><span><i>*</i>换标后派送方式</span><el-select v-model="form.deliveryMethod" placeholder="请选择派送方式"><el-option label="卡车派送" value="TRUCK" /><el-option label="快递派送" value="EXPRESS" /><el-option label="卡车自提" value="TRUCK_PICKUP" /><el-option label="快递自提" value="EXPRESS_PICKUP" /></el-select></label>
<div v-if="props.service === 'labeling' && labelingMode !== 'sku' && (form.deliveryMethod === 'EXPRESS' || form.deliveryMethod === 'EXPRESS_PICKUP')" class="warehouse-checkbox-field"><span>是否打印面单</span><el-checkbox v-model="form.relabel">需要仓库打印面单</el-checkbox></div>
<label v-if="canManageRelabelFiles && props.service === 'labeling' && labelingMode !== 'sku' && (form.deliveryMethod === 'EXPRESS' || form.deliveryMethod === 'EXPRESS_PICKUP') && form.relabel"><span><i>*</i>上传面单文件</span><input class="warehouse-native-file" type="file" accept="application/pdf,image/*,.pdf" @change="selectShippingLabel"><small class="warehouse-hint">支持 PDF 或图片,最大 20 MB。</small></label>
<label v-if="canManageRelabelFiles && props.service === 'labeling' && labelingMode !== 'sku' && (form.deliveryMethod === 'EXPRESS' || form.deliveryMethod === 'EXPRESS_PICKUP') && form.relabel"><span><i>*</i>上传面单文件</span><input class="warehouse-native-file" type="file" accept="application/pdf,image/*,.pdf" @change="selectShippingLabel"></label>
<label v-if="props.service === 'relabel'" class="warehouse-span-2"><span><i>*</i>详细派送地址</span><el-input v-model="form.address" placeholder="请输入派送地址" /></label>
<label v-if="props.service === 'transfer' || props.service === 'relabel'"><span><i>*</i>预估总体积(CBM)</span><el-input-number v-model="form.volumeCbm" :min="0" :precision="4" :step="0.0001" controls-position="right" placeholder="请输入预估总体积" /></label>
<label v-if="props.service === 'transfer' || props.service === 'relabel'"><span><i>*</i>{{ props.service === 'transfer' ? '预估体积(CBM)' : '预估总体积(CBM)' }}</span><el-input-number v-model="form.volumeCbm" :min="0" :precision="4" :step="0.0001" controls-position="right" placeholder="请输入预估总体积" /></label>
<label v-if="props.service === 'transfer' || props.service === 'relabel'"><span><i>*</i>{{ props.service === 'transfer' ? '预计入仓时间' : '预计到达时间' }}</span><el-date-picker v-model="form.estimatedArrivalAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" :placeholder="props.service === 'transfer' ? '请选择预计入仓时间' : '请选择预计到达时间'" /></label>
<label v-if="props.service === 'transfer'"><span>预计发货时间</span><el-date-picker v-model="form.estimatedShipAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="选填,可线下确认后补充" /><small class="warehouse-hint">暂未确定时可不填写,不影响货物先入海外仓。</small></label>
<div v-if="props.service === 'transfer'" class="warehouse-choice-field warehouse-span-2"><span><i>*</i>是否有目的去向</span><el-radio-group v-model="transferDestinationType" @change="selectTransferDestinationType"><el-radio-button value="NONE">暂未确定</el-radio-button><el-radio-button value="FBA">FBA 仓</el-radio-button><el-radio-button value="PRIVATE">私人仓</el-radio-button></el-radio-group><small>暂未确定也可以提交;完成入库后可在新建出库单中批量绑定目的地。</small></div>
<label v-if="props.service === 'transfer' && transferDestinationType === 'FBA'"><span><i>*</i>未换标货物目的 FBA 仓</span><el-select v-model="form.destination" :loading="fbaWarehousesLoading" :disabled="Boolean(fbaWarehousesError)" filterable placeholder="请选择平台维护的 FBA 仓"><el-option v-for="warehouse in fbaWarehouses" :key="warehouse.id" :label="warehouseLabel(warehouse)" :value="warehouse.warehouseCode" /></el-select><small class="warehouse-hint">此处只表示不换标箱子的去向;需要换箱唛的箱子在换标明细中单独选择目的仓。</small></label>
<label v-if="props.service === 'transfer'"><span>预计发货时间</span><el-date-picker v-model="form.estimatedShipAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="选填" /></label>
<div v-if="props.service === 'transfer'" class="warehouse-choice-field warehouse-span-2"><span><i>*</i>目的去向</span><el-radio-group v-model="transferDestinationType" @change="selectTransferDestinationType"><el-radio-button value="NONE">暂未确定</el-radio-button><el-radio-button value="FBA">FBA 仓</el-radio-button><el-radio-button value="PRIVATE">私人仓</el-radio-button></el-radio-group></div>
<label v-if="props.service === 'transfer' && transferDestinationType === 'FBA'"><span><i>*</i>FBA 目的仓</span><el-select v-model="form.destination" :loading="fbaWarehousesLoading" :disabled="Boolean(fbaWarehousesError)" filterable placeholder="请选择 FBA 仓"><el-option v-for="warehouse in fbaWarehouses" :key="warehouse.id" :label="warehouseLabel(warehouse)" :value="warehouse.warehouseCode" /></el-select></label>
<label v-if="props.service === 'transfer' && transferDestinationType === 'FBA'"><span>FBA 货件编号</span><el-input v-model="form.fbaShipmentNo" placeholder="选填,可后续补充" /></label>
<label v-if="props.service === 'transfer' && transferDestinationType === 'PRIVATE'" class="warehouse-span-2"><span><i>*</i>私人仓地址</span><el-input v-model="form.privateDestinationAddress" maxlength="1000" placeholder="请输入私人仓完整地址" /></label>
<label v-if="props.service === 'transfer' && transferDestinationType === 'PRIVATE'"><span><i>*</i>邮编</span><el-input v-model="form.privateDestinationPostalCode" maxlength="32" placeholder="请输入邮编" /></label>
@@ -1068,66 +1260,86 @@ function selectRelabelInventory(inboundOrderId: string) {
<label v-if="props.service === 'relabel'"><span><i>*</i>FBA 货件编号</span><el-input v-model="form.fbaShipmentNo" placeholder="请输入 FBA 货件编号" /></label>
<label v-if="props.service === 'labeling'"><span>贴标要求</span><el-input v-model="form.requirement" placeholder="请输入贴标要求" /></label>
<label v-else-if="props.service === 'relabel' || (props.service === 'transfer' && form.deliveryMethod === 'CUSTOMER_DELIVERY')"><span>物流跟踪号</span><el-input v-model="form.trackingNo" placeholder="请输入物流跟踪号(选填)" /></label>
<label class="warehouse-span-2"><span>订单备注</span><el-input v-model="form.remark" type="textarea" :rows="3" placeholder="请输入备注信息" /></label>
<div v-if="props.service === 'transfer'" class="warehouse-choice-field warehouse-span-2"><span><i>*</i>是否需要换标</span><el-radio-group v-model="form.relabel"><el-radio-button :value="false">不需要换标</el-radio-button><el-radio-button :value="true">需要换标</el-radio-button></el-radio-group><small>{{ form.relabel ? '本次提交将同时登记换标申请,入库完成后自动进入换标作业。' : '入库完成且已有目的仓后,可直接进入排车流程。' }}</small></div>
<label class="warehouse-span-2"><span>备注</span><el-input v-model="form.remark" type="textarea" :rows="2" placeholder="选填" /></label>
<div v-if="props.service === 'transfer'" class="warehouse-choice-field warehouse-span-2"><span><i>*</i>换标服务</span><el-radio-group v-model="form.relabel"><el-radio-button :value="false">不需要</el-radio-button><el-radio-button :value="true">需要</el-radio-button></el-radio-group></div>
</div>
</div>
<div class="warehouse-card warehouse-form-section warehouse-transfer-cargo-card">
<template v-if="props.service === 'labeling'">
<div class="warehouse-section-heading warehouse-labeling-heading">
<h3>货物明细</h3>
<div class="warehouse-template-actions">
<el-button v-if="canManageRelabelFiles" size="small" @click="chooseCreateTotalFile('CARTON_TOTAL')"><Upload :size="15" />上传箱唛总文件</el-button>
<el-button v-if="canManageRelabelFiles" size="small" @click="chooseCreateTotalFile('SKU_TOTAL')"><Upload :size="15" />上传 SKU 总文件</el-button>
<el-button size="small" :disabled="!cargoRows.length" @click="downloadRelabelTemplate('sku')"><Download :size="15" />下载 SKU 关联模板</el-button>
<el-button size="small" :disabled="!cargoRows.length" @click="skuTemplateInput?.click()"><Upload :size="15" />上传 SKU 关联</el-button>
<el-button size="small" :disabled="!cargoRows.length" @click="downloadRelabelTemplate('carton')"><Download :size="15" />下载箱号关联模板</el-button>
<el-button size="small" :disabled="!cargoRows.length" @click="cartonTemplateInput?.click()"><Upload :size="15" />上传箱号关联</el-button>
<el-button size="small" :disabled="!cargoRows.length" @click="downloadRelabelTemplate('mixed')"><Download :size="15" />下载 SKU 混箱模板</el-button>
<el-button size="small" :disabled="!cargoRows.length" @click="mixedTemplateInput?.click()"><Upload :size="15" />上传 SKU 混箱</el-button>
<input ref="skuTemplateInput" class="warehouse-hidden-input" type="file" accept=".csv,text/csv" @change="importRelabelTemplate($event, 'sku')">
<input ref="cartonTemplateInput" class="warehouse-hidden-input" type="file" accept=".csv,text/csv" @change="importRelabelTemplate($event, 'carton')">
<input ref="mixedTemplateInput" class="warehouse-hidden-input" type="file" accept=".csv,text/csv" @change="importRelabelTemplate($event, 'mixed')">
<input ref="cartonTotalCreateInput" class="warehouse-hidden-input" type="file" accept="application/pdf,.pdf" @change="selectCartonTotalFile">
<input ref="skuTotalCreateInput" class="warehouse-hidden-input" type="file" accept="application/pdf,.pdf" @change="selectSkuTotalFile">
</div>
<div><h3>货物明细</h3></div>
</div>
<div v-if="canManageRelabelFiles" class="warehouse-upload-summary">
<span>箱唛总文件:{{ cartonTotalFile?.name || '未选择' }}<button v-if="cartonTotalFile" type="button" class="warehouse-file-clear" title="清除箱唛总文件" aria-label="清除箱唛总文件" :disabled="saving" @click="clearCreateTotalFile('CARTON_TOTAL')"><X :size="14" /></button></span>
<span>SKU 总文件:{{ skuTotalFile?.name || '未选择' }}<button v-if="skuTotalFile" type="button" class="warehouse-file-clear" title="清除 SKU 总文件" aria-label="清除 SKU 总文件" :disabled="saving" @click="clearCreateTotalFile('SKU_TOTAL')"><X :size="14" /></button></span>
<div class="warehouse-file-groups" :class="{ 'warehouse-file-groups--single': isReturnRelabelSource }">
<section v-if="canManageRelabelFiles" class="warehouse-file-group warehouse-file-group--primary">
<div class="warehouse-file-group__heading"><div><strong><span>1</span>贴标总文件</strong></div><em>{{ requiresBothTotalFiles ? '必传 · 两份文件' : '必传 · 二选一' }}</em></div>
<div class="warehouse-file-options">
<div class="warehouse-file-option" :class="{ 'is-selected': Boolean(cartonTotalFile) }"><div class="warehouse-file-option__name"><strong>箱唛总文件</strong></div><el-button :type="cartonTotalFile ? 'primary' : 'default'" plain @click="chooseCreateTotalFile('CARTON_TOTAL')"><Upload :size="15" />{{ cartonTotalFile ? '重新上传' : '选择文件' }}</el-button><div class="warehouse-file-option__status" :class="{ 'is-empty': !cartonTotalFile }"><span>{{ cartonTotalFile?.name || '尚未选择文件' }}</span><button v-if="cartonTotalFile" type="button" class="warehouse-file-clear" title="清除箱唛总文件" aria-label="清除箱唛总文件" :disabled="saving" @click="clearCreateTotalFile('CARTON_TOTAL')"><X :size="14" /></button></div></div>
<div class="warehouse-file-choice-or" :class="{ 'is-both': requiresBothTotalFiles }">{{ requiresBothTotalFiles ? '和' : '或' }}</div>
<div class="warehouse-file-option" :class="{ 'is-selected': Boolean(skuTotalFile) }"><div class="warehouse-file-option__name"><strong>SKU 总文件</strong></div><el-button :type="skuTotalFile ? 'primary' : 'default'" plain @click="chooseCreateTotalFile('SKU_TOTAL')"><Upload :size="15" />{{ skuTotalFile ? '重新上传' : '选择文件' }}</el-button><div class="warehouse-file-option__status" :class="{ 'is-empty': !skuTotalFile }"><span>{{ skuTotalFile?.name || '尚未选择文件' }}</span><button v-if="skuTotalFile" type="button" class="warehouse-file-clear" title="清除 SKU 总文件" aria-label="清除 SKU 总文件" :disabled="saving" @click="clearCreateTotalFile('SKU_TOTAL')"><X :size="14" /></button></div></div>
</div>
</section>
<section v-if="!isReturnRelabelSource" class="warehouse-file-group">
<div class="warehouse-file-group__heading"><div><strong><span>2</span>明细关联模板</strong></div><em>按需使用</em></div>
<div class="warehouse-template-list">
<div><span><strong>SKU 关联</strong></span><el-button size="small" :disabled="!cargoRows.length" @click="downloadRelabelTemplate('sku')"><Download :size="15" />下载模板</el-button><el-button type="primary" plain size="small" :disabled="!cargoRows.length" @click="skuTemplateInput?.click()"><Upload :size="15" />上传文件</el-button></div>
<div><span><strong>箱号关联</strong></span><el-button size="small" :disabled="!cargoRows.length" @click="downloadRelabelTemplate('carton')"><Download :size="15" />下载模板</el-button><el-button type="primary" plain size="small" :disabled="!cargoRows.length" @click="cartonTemplateInput?.click()"><Upload :size="15" />上传文件</el-button></div>
<div><span><strong>SKU 混箱</strong></span><el-button size="small" :disabled="!cargoRows.length" @click="downloadRelabelTemplate('mixed')"><Download :size="15" />下载模板</el-button><el-button type="primary" plain size="small" :disabled="!cargoRows.length" @click="mixedTemplateInput?.click()"><Upload :size="15" />上传文件</el-button></div>
</div>
</section>
</div>
<div class="warehouse-labeling-mode">
<input ref="skuTemplateInput" class="warehouse-hidden-input" type="file" accept=".csv,text/csv" @change="importRelabelTemplate($event, 'sku')"><input ref="cartonTemplateInput" class="warehouse-hidden-input" type="file" accept=".csv,text/csv" @change="importRelabelTemplate($event, 'carton')"><input ref="mixedTemplateInput" class="warehouse-hidden-input" type="file" accept=".csv,text/csv" @change="importRelabelTemplate($event, 'mixed')"><input ref="cartonTotalCreateInput" class="warehouse-hidden-input" type="file" accept="application/pdf,.pdf" @change="selectCartonTotalFile"><input ref="skuTotalCreateInput" class="warehouse-hidden-input" type="file" accept="application/pdf,.pdf" @change="selectSkuTotalFile">
<div v-if="isReturnRelabelSource" class="warehouse-table-scroll">
<table class="warehouse-edit-table warehouse-return-relabel-table">
<thead><tr><th>选择</th><th>SKU</th><th>中文品名</th><th>库存总数</th><th>本次贴标数量</th></tr></thead>
<tbody><tr v-for="row in returnRelabelSkuRows" :key="row.skuCode"><td><el-checkbox v-model="row.selected" /></td><td><strong>{{ row.skuCode }}</strong></td><td>{{ row.productNameZh || '—' }}</td><td><strong>{{ row.availableQuantity }}</strong></td><td><el-input-number v-model="row.quantity" :disabled="!row.selected" :min="1" :max="row.availableQuantity" :precision="0" controls-position="right" placeholder="请输入贴标数量" /></td></tr><tr v-if="returnRelabelSkuRows.length === 0"><td colspan="5"><div class="warehouse-empty">该退货单暂无可申请贴标的 SKU 库存</div></td></tr></tbody>
</table>
</div>
<div v-if="!isReturnRelabelSource" class="warehouse-labeling-mode">
<span>换标类型</span>
<el-radio-group v-model="labelingMode" aria-label="换标类型">
<el-radio-button value="carton">只换箱唛</el-radio-button>
<el-radio-button value="sku">只换 SKU 标签</el-radio-button>
<el-radio-button value="carton_and_sku">SKU 和箱唛都换</el-radio-button>
</el-radio-group>
<p>{{ labelingModeHint }}</p>
</div>
<div v-if="importedRelabelBindings.length" class="warehouse-import-preview">
<div v-if="!isReturnRelabelSource && importedRelabelBindings.length" class="warehouse-import-preview">
<div class="warehouse-section-heading"><div><strong>待导入绑定关系</strong><p>可只勾选本次需要换标的部分,也可以全选后一次导入。</p></div><div><el-checkbox :model-value="importedRelabelBindings.every((item) => item.selected)" @change="selectAllImportedBindings(Boolean($event))">全选</el-checkbox><el-button type="primary" size="small" @click="importSelectedBindingsToDetails">导入到明细</el-button></div></div>
<div class="warehouse-table-scroll"><table class="warehouse-binding-preview-table"><thead><tr><th>选择</th><th>关系类型</th><th>仓库唛头</th><th>旧 SKU</th><th>新 SKU</th><th>数量</th><th>新 FBA 箱唛</th></tr></thead><tbody><tr v-for="item in importedRelabelBindings" :key="item.id"><td><el-checkbox v-model="item.selected" /></td><td>{{ item.templateType === 'mixed' ? 'SKU 混箱换标' : item.templateType === 'carton' ? '箱号关联' : 'SKU 关联' }}</td><td>{{ item.cartonMark }}</td><td>{{ item.originalSku || '—' }}</td><td>{{ item.newSku || '—' }}</td><td>{{ item.quantity }}</td><td>{{ item.fbaCartonMark || '—' }}</td></tr></tbody></table></div>
</div>
<div class="warehouse-section-heading warehouse-tab-actions">
<div><strong>绑定明细</strong><p>上传后在下方回显,也可以手动添加;SKU 与箱号关联通过仓库唛头合并。系统只提供大概库存位置,不代表箱唛已绑定具体托盘。</p></div>
<div v-if="!isReturnRelabelSource" class="warehouse-section-heading warehouse-tab-actions">
<div><strong>绑定明细</strong></div>
<div v-if="labelingMode === 'carton'"><el-button type="primary" size="small" @click="addRelabelCargoRow"><Plus :size="15" />手动添加行</el-button><el-button size="small" @click="clearRelabelRows">清空</el-button></div>
<div v-else><el-button type="primary" size="small" @click="addSkuRelabelRow"><Plus :size="15" />添加 SKU 明细</el-button><el-button size="small" @click="skuRelabelRows = []">清空</el-button></div>
</div>
<div v-if="labelingMode === 'carton'" class="warehouse-table-scroll"><table class="warehouse-edit-table warehouse-label-table"><thead><tr><th>商品 SKU</th><th>中文品名 *</th><th>FBA 货件编号 *</th><th>箱数</th><th>仓库唛头 *</th><th>新 FBA 箱唛 *</th><th>备注</th><th>操作</th></tr></thead><tbody><tr v-for="row in cartonDetailRows" :key="row.id"><td><el-input v-model="row.sku" placeholder="选填" /></td><td><el-input v-model="row.name" placeholder="请输入中文品名" /></td><td><el-input v-model="row.shipmentNo" placeholder="请输入FBA货件编号" /></td><td><strong>1 箱</strong></td><td><el-input v-model="row.cartonMark" placeholder="请输入仓库唛头" /></td><td><el-input v-model="row.fbaCartonMark" placeholder="请输入新的 FBA 箱唛" /></td><td><el-input v-model="row.remark" type="textarea" :rows="2" maxlength="1000" show-word-limit placeholder="请输入明细备注" /></td><td><button type="button" class="warehouse-delete" title="移出本次明细" @click="removeCargoRow(row.id)"><Trash2 :size="16" />移出</button></td></tr><tr v-if="cartonDetailRows.length === 0"><td colspan="8"><div class="warehouse-empty">暂无本次换标明细,请上传关系后导入或手动添加</div></td></tr></tbody></table></div>
<div v-if="!isReturnRelabelSource && labelingMode === 'carton'" class="warehouse-table-scroll"><table class="warehouse-edit-table warehouse-label-table"><thead><tr><th>商品 SKU</th><th>中文品名 *</th><th>FBA 货件编号 *</th><th>箱数</th><th>仓库唛头 *</th><th>新 FBA 箱唛 *</th><th>备注</th><th>操作</th></tr></thead><tbody><tr v-for="row in cartonDetailRows" :key="row.id"><td><el-input v-model="row.sku" placeholder="选填" /></td><td><el-input v-model="row.name" placeholder="请输入中文品名" /></td><td><el-input v-model="row.shipmentNo" placeholder="请输入FBA货件编号" /></td><td><strong>1 箱</strong></td><td><el-input v-model="row.cartonMark" placeholder="请输入仓库唛头" /></td><td><el-input v-model="row.fbaCartonMark" placeholder="请输入新的 FBA 箱唛" /></td><td><el-input v-model="row.remark" type="textarea" :rows="2" maxlength="1000" show-word-limit placeholder="请输入明细备注" /></td><td><button type="button" class="warehouse-delete" title="移出本次明细" @click="removeCargoRow(row.id)"><Trash2 :size="16" />移出</button></td></tr><tr v-if="cartonDetailRows.length === 0"><td colspan="8"><div class="warehouse-empty">暂无本次换标明细,请上传关系后导入或手动添加</div></td></tr></tbody></table></div>
<div v-else class="warehouse-table-scroll"><table class="warehouse-edit-table warehouse-sku-label-table"><thead><tr><th>仓库唛头 *</th><th>旧 SKU *</th><th>新 SKU *</th><th>换标数量(件)*</th><th v-if="labelingMode === 'carton_and_sku'">新 FBA 箱唛 *</th><th>备注</th><th>操作</th></tr></thead><tbody><tr v-for="row in skuRelabelRows" :key="row.id"><td><el-input v-model="row.cartonMark" placeholder="请输入仓库唛头" @change="applyExistingCartonBinding(row)" /></td><td><el-input v-model="row.originalSku" placeholder="请输入旧 SKU" @change="applyExistingSkuBinding(row)" /></td><td><el-input v-model="row.newSku" placeholder="请输入新 SKU" /></td><td><el-input-number v-model="row.quantity" :min="1" :precision="0" controls-position="right" placeholder="请输入件数" /></td><td v-if="labelingMode === 'carton_and_sku'"><el-input v-model="row.fbaCartonMark" placeholder="请输入新的 FBA 箱唛" /></td><td><el-input v-model="row.remark" placeholder="请输入备注" /></td><td><button type="button" class="warehouse-delete" title="删除" @click="removeSkuRelabelRow(row.id)"><Trash2 :size="16" />删除</button></td></tr><tr v-if="skuRelabelRows.length === 0"><td :colspan="labelingMode === 'carton_and_sku' ? 7 : 6"><div class="warehouse-empty">暂无绑定明细,请点击“添加 SKU 明细”</div></td></tr></tbody></table></div>
</template>
<template v-else>
<div class="warehouse-section-heading"><h3>货物明细</h3><div v-if="props.service !== 'disposal'"><el-button type="primary" size="small" @click="addCargoRow"><Plus :size="15" />添加行</el-button><el-button size="small" @click="cargoRows = []">清空</el-button></div></div>
<div class="warehouse-section-heading" :class="{ 'warehouse-step-heading': props.service === 'transfer' }"><div><h3><span v-if="props.service === 'transfer'" class="warehouse-step-number">1</span>货物明细</h3></div><div v-if="props.service !== 'disposal'" class="warehouse-heading-actions"><el-button v-if="props.service === 'transfer'" size="small" :disabled="!cargoRows.some((row) => row.sku.trim() && row.quantity > 0)" @click="printTransferDraftCartonMarks"><Printer :size="15" />打印唛头</el-button><el-button type="primary" size="small" @click="addCargoRow"><Plus :size="15" />添加行</el-button><el-button size="small" @click="cargoRows = []">清空</el-button></div></div>
<div class="warehouse-table-scroll"><table class="warehouse-edit-table"><thead><tr><th>商品 SKU{{ props.service === 'transfer' ? '' : ' *' }}</th><th>中文品名{{ props.service === 'transfer' ? '' : ' *' }}</th><th v-if="props.service === 'disposal'">可用箱数</th><th v-if="props.service === 'relabel'">FNSKU / 箱唛</th><th>{{ props.service === 'disposal' ? '本次废弃箱数' : '预计箱数' }} *</th><th v-if="props.service !== 'disposal'">操作</th></tr></thead><tbody><tr v-for="row in cargoRows" :key="row.id"><td><el-input v-model="row.sku" :readonly="props.service === 'disposal'" :placeholder="props.service === 'transfer' ? '选填' : '请输入商品 SKU'" /></td><td><el-input v-model="row.name" :readonly="props.service === 'disposal'" :placeholder="props.service === 'transfer' ? '选填' : '请输入中文品名'" /></td><td v-if="props.service === 'disposal'"><strong>{{ row.availableQuantity }} 箱</strong></td><td v-if="props.service === 'relabel'"><el-input v-model="row.shippingMark" placeholder="请输入FNSKU或箱唛" /></td><td><el-input-number v-model="row.quantity" :min="props.service === 'disposal' ? 0 : 1" :max="row.availableQuantity" controls-position="right" placeholder="请输入箱数" /></td><td v-if="props.service !== 'disposal'"><button type="button" class="warehouse-delete" title="删除" @click="removeCargoRow(row.id)"><Trash2 :size="16" />删除</button></td></tr><tr v-if="cargoRows.length === 0"><td :colspan="props.service === 'disposal' ? 4 : props.service === 'relabel' ? 5 : 4"><div class="warehouse-empty">{{ props.service === 'disposal' ? '请先选择入库单,系统将自动带出 SKU 和可用箱数' : '暂无货物明细,请点击“添加行”' }}</div></td></tr></tbody></table></div>
</template>
</div>
<div v-if="props.service === 'transfer' && form.relabel" class="warehouse-card warehouse-form-section warehouse-transfer-relabel">
<div class="warehouse-section-heading warehouse-labeling-heading"><div><h3>货物明细</h3><p>换标明细与贴标申请保持一致;客户只填写自己的箱唛,我方箱唛由系统生成。</p></div><div class="warehouse-template-actions"><el-button size="small" :disabled="!cargoRows.length" @click="downloadRelabelTemplate('sku')"><Download :size="15" />下载 SKU 关联模板</el-button><el-button size="small" :disabled="!cargoRows.length" @click="skuTemplateInput?.click()"><Upload :size="15" />上传 SKU 关联</el-button><el-button size="small" :disabled="!cargoRows.length" @click="downloadRelabelTemplate('carton')"><Download :size="15" />下载箱号关联模板</el-button><el-button size="small" :disabled="!cargoRows.length" @click="cartonTemplateInput?.click()"><Upload :size="15" />上传箱号关联</el-button><el-button size="small" :disabled="!cargoRows.length" @click="downloadRelabelTemplate('mixed')"><Download :size="15" />下载 SKU 混箱模板</el-button><el-button size="small" :disabled="!cargoRows.length" @click="mixedTemplateInput?.click()"><Upload :size="15" />上传 SKU 混箱</el-button><input ref="skuTemplateInput" class="warehouse-hidden-input" type="file" accept=".csv,text/csv" @change="importRelabelTemplate($event, 'sku')"><input ref="cartonTemplateInput" class="warehouse-hidden-input" type="file" accept=".csv,text/csv" @change="importRelabelTemplate($event, 'carton')"><input ref="mixedTemplateInput" class="warehouse-hidden-input" type="file" accept=".csv,text/csv" @change="importRelabelTemplate($event, 'mixed')"></div></div>
<div class="warehouse-section-heading warehouse-labeling-heading warehouse-step-heading"><div><h3><span class="warehouse-step-number">2</span>换标申请</h3></div></div>
<div class="warehouse-form-grid warehouse-transfer-relabel-fields"><label><span><i>*</i>换标截止时间</span><el-date-picker v-model="form.labelDeadline" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择截止时间" /></label><label><span><i>*</i>换标联系人</span><el-input v-model="form.relabelContact" placeholder="选择海外仓后自动带出,也可手动填写" maxlength="128" /></label><label><span><i>*</i>换标联系人电话</span><el-input v-model="form.relabelPhone" placeholder="选择海外仓后自动带出,也可手动填写" maxlength="64" /></label><label v-if="labelingMode !== 'sku'"><span><i>*</i>换标后派送方式</span><el-select v-model="form.relabelDeliveryMethod" placeholder="请选择派送方式"><el-option label="卡车派送" value="TRUCK" /><el-option label="快递派送" value="EXPRESS" /><el-option label="卡车自提" value="TRUCK_PICKUP" /><el-option label="快递自提" value="EXPRESS_PICKUP" /></el-select></label><label class="warehouse-span-2"><span><i>*</i>整票换标货物目的 FBA 仓</span><el-select v-model="transferRelabelDefaultDestination" :loading="fbaWarehousesLoading" :disabled="Boolean(fbaWarehousesError)" filterable placeholder="请选择整票货物的目的仓"><el-option v-for="warehouse in fbaWarehouses" :key="warehouse.id" :label="warehouseLabel(warehouse)" :value="warehouse.warehouseCode" /></el-select><small class="warehouse-hint">本票所有换标货物统一前往该目的仓,暂不支持一票多个目的仓。</small></label><label class="warehouse-span-2"><span>贴标要求</span><el-input v-model="form.requirement" type="textarea" :rows="2" maxlength="1000" show-word-limit placeholder="请输入贴标要求(选填)" /></label></div>
<div class="warehouse-labeling-mode"><span>换标类型</span><el-radio-group v-model="labelingMode" aria-label="换标类型"><el-radio-button value="carton">只换箱唛</el-radio-button><el-radio-button value="sku">只换 SKU 标签</el-radio-button><el-radio-button value="carton_and_sku">SKU 和箱唛都换</el-radio-button></el-radio-group><p>{{ labelingModeHint }}</p></div>
<div class="warehouse-labeling-mode"><span>换标类型</span><el-radio-group v-model="labelingMode" aria-label="换标类型"><el-radio-button value="carton">只换箱唛</el-radio-button><el-radio-button value="sku">只换 SKU 标签</el-radio-button><el-radio-button value="carton_and_sku">SKU 和箱唛都换</el-radio-button></el-radio-group></div>
<div class="warehouse-transfer-file-groups">
<section v-if="canManageRelabelFiles" class="warehouse-file-group warehouse-transfer-total-files">
<div class="warehouse-file-group__heading"><div><strong>贴标总文件</strong></div><em>{{ requiresBothTotalFiles ? '必传 · 两份文件' : '必传 · 二选一' }}</em></div>
<div class="warehouse-file-options">
<div class="warehouse-file-option" :class="{ 'is-selected': Boolean(cartonTotalFile) }"><div class="warehouse-file-option__name"><strong>箱唛总文件</strong></div><el-button :type="cartonTotalFile ? 'primary' : 'default'" plain @click="chooseCreateTotalFile('CARTON_TOTAL')"><Upload :size="15" />{{ cartonTotalFile ? '重新上传' : '选择文件' }}</el-button><div class="warehouse-file-option__status" :class="{ 'is-empty': !cartonTotalFile }"><span>{{ cartonTotalFile?.name || '尚未选择文件' }}</span><button v-if="cartonTotalFile" type="button" class="warehouse-file-clear" title="清除箱唛总文件" aria-label="清除箱唛总文件" :disabled="saving" @click="clearCreateTotalFile('CARTON_TOTAL')"><X :size="14" /></button></div></div>
<div class="warehouse-file-choice-or" :class="{ 'is-both': requiresBothTotalFiles }">{{ requiresBothTotalFiles ? '和' : '或' }}</div>
<div class="warehouse-file-option" :class="{ 'is-selected': Boolean(skuTotalFile) }"><div class="warehouse-file-option__name"><strong>SKU 总文件</strong></div><el-button :type="skuTotalFile ? 'primary' : 'default'" plain @click="chooseCreateTotalFile('SKU_TOTAL')"><Upload :size="15" />{{ skuTotalFile ? '重新上传' : '选择文件' }}</el-button><div class="warehouse-file-option__status" :class="{ 'is-empty': !skuTotalFile }"><span>{{ skuTotalFile?.name || '尚未选择文件' }}</span><button v-if="skuTotalFile" type="button" class="warehouse-file-clear" title="清除 SKU 总文件" aria-label="清除 SKU 总文件" :disabled="saving" @click="clearCreateTotalFile('SKU_TOTAL')"><X :size="14" /></button></div></div>
</div>
<input ref="cartonTotalCreateInput" class="warehouse-hidden-input" type="file" accept="application/pdf,.pdf" @change="selectCartonTotalFile"><input ref="skuTotalCreateInput" class="warehouse-hidden-input" type="file" accept="application/pdf,.pdf" @change="selectSkuTotalFile">
</section>
<section class="warehouse-file-group warehouse-transfer-template-group">
<div class="warehouse-file-group__heading"><div><strong><span>3</span>明细关联模板</strong></div><em>按需使用</em></div><div class="warehouse-template-list"><div><span><strong>SKU 关联</strong></span><el-button size="small" :disabled="!cargoRows.length" @click="downloadRelabelTemplate('sku')"><Download :size="15" />下载模板</el-button><el-button type="primary" plain size="small" :disabled="!cargoRows.length" @click="skuTemplateInput?.click()"><Upload :size="15" />上传文件</el-button></div><div><span><strong>箱号关联</strong></span><el-button size="small" :disabled="!cargoRows.length" @click="downloadRelabelTemplate('carton')"><Download :size="15" />下载模板</el-button><el-button type="primary" plain size="small" :disabled="!cargoRows.length" @click="cartonTemplateInput?.click()"><Upload :size="15" />上传文件</el-button></div><div><span><strong>SKU 混箱</strong></span><el-button size="small" :disabled="!cargoRows.length" @click="downloadRelabelTemplate('mixed')"><Download :size="15" />下载模板</el-button><el-button type="primary" plain size="small" :disabled="!cargoRows.length" @click="mixedTemplateInput?.click()"><Upload :size="15" />上传文件</el-button></div></div>
<input ref="skuTemplateInput" class="warehouse-hidden-input" type="file" accept=".csv,text/csv" @change="importRelabelTemplate($event, 'sku')"><input ref="cartonTemplateInput" class="warehouse-hidden-input" type="file" accept=".csv,text/csv" @change="importRelabelTemplate($event, 'carton')"><input ref="mixedTemplateInput" class="warehouse-hidden-input" type="file" accept=".csv,text/csv" @change="importRelabelTemplate($event, 'mixed')">
</section>
</div>
<div v-if="importedRelabelBindings.length" class="warehouse-import-preview"><div class="warehouse-section-heading"><div><strong>待导入绑定关系</strong><p>可只勾选本次需要换标的部分,也可以全选后一次导入。</p></div><div><el-checkbox :model-value="importedRelabelBindings.every((item) => item.selected)" @change="selectAllImportedBindings(Boolean($event))">全选</el-checkbox><el-button type="primary" size="small" @click="importSelectedBindingsToDetails">导入到明细</el-button></div></div><div class="warehouse-table-scroll"><table class="warehouse-binding-preview-table"><thead><tr><th>选择</th><th>关系类型</th><th>客户箱唛号</th><th>旧 SKU</th><th>新 SKU</th><th>数量</th><th>新 FBA 箱唛</th></tr></thead><tbody><tr v-for="item in importedRelabelBindings" :key="item.id"><td><el-checkbox v-model="item.selected" /></td><td>{{ item.templateType === 'mixed' ? 'SKU 混箱换标' : item.templateType === 'carton' ? '箱号关联' : 'SKU 关联' }}</td><td>{{ item.cartonMark }}</td><td>{{ item.originalSku || '—' }}</td><td>{{ item.newSku || '—' }}</td><td>{{ item.quantity }}</td><td>{{ item.fbaCartonMark || '—' }}</td></tr></tbody></table></div></div>
<div class="warehouse-section-heading warehouse-tab-actions"><div><strong>绑定明细</strong><p>上传后在下方回显,也可以手动添加;客户箱唛作为仓库唛头来源,系统我方箱唛不需要手填。</p></div><div><el-button type="primary" size="small" @click="addTransferRelabelRow"><Plus :size="15" />添加 SKU 明细</el-button><el-button size="small" @click="clearTransferRelabelRows">清空</el-button></div></div>
<div class="warehouse-section-heading warehouse-tab-actions warehouse-binding-heading"><div><strong><span class="warehouse-step-number">4</span>绑定明细</strong></div><div><el-button type="primary" size="small" @click="addTransferRelabelRow"><Plus :size="15" />添加 SKU 明细</el-button><el-button size="small" @click="clearTransferRelabelRows">清空</el-button></div></div>
<div v-if="labelingMode === 'carton'" class="warehouse-table-scroll"><table class="warehouse-edit-table warehouse-transfer-mark-table"><thead><tr><th>客户箱唛号 *</th><th>旧 SKU</th><th>新 FBA 箱唛 *</th><th>备注</th><th>操作</th></tr></thead><tbody><tr v-for="row in transferRelabelRows" :key="row.id"><td><el-input v-model="row.customerCartonMarks" type="textarea" :rows="2" placeholder="请输入客户箱唛号" /></td><td><el-input :model-value="row.sku" readonly /></td><td><el-input v-model="row.fbaCartonMark" placeholder="请输入新的 FBA 箱唛" /></td><td><el-input v-model="row.remark" type="textarea" :rows="2" placeholder="请输入备注" /></td><td><button type="button" class="warehouse-delete" title="删除该明细" @click="transferRelabelRows = transferRelabelRows.filter((item) => item.id !== row.id)"><Trash2 :size="16" />删除</button></td></tr><tr v-if="transferRelabelRows.length === 0"><td colspan="5"><div class="warehouse-empty">暂无绑定明细,请点击“添加换标明细”</div></td></tr></tbody></table></div>
<div v-else-if="labelingMode === 'sku'" class="warehouse-table-scroll"><table class="warehouse-edit-table warehouse-transfer-mark-table"><thead><tr><th>客户箱唛号 *</th><th>旧 SKU *</th><th>新 SKU *</th><th>换标数量(件)*</th><th>备注</th><th>操作</th></tr></thead><tbody><tr v-for="row in transferRelabelRows" :key="row.id"><td><el-input v-model="row.customerCartonMarks" type="textarea" :rows="2" placeholder="请输入客户箱唛号" /></td><td><el-input :model-value="row.sku" readonly /></td><td><el-input v-model="row.newSkuCode" placeholder="请输入新 SKU" /></td><td><el-input-number v-model="row.quantity" :min="1" :precision="0" controls-position="right" placeholder="请输入箱数" /></td><td><el-input v-model="row.remark" type="textarea" :rows="2" placeholder="请输入备注" /></td><td><button type="button" class="warehouse-delete" title="删除该明细" @click="transferRelabelRows = transferRelabelRows.filter((item) => item.id !== row.id)"><Trash2 :size="16" />删除</button></td></tr><tr v-if="transferRelabelRows.length === 0"><td colspan="6"><div class="warehouse-empty">暂无绑定明细,请点击“添加 SKU 明细”</div></td></tr></tbody></table></div>
<div v-else class="warehouse-table-scroll"><table class="warehouse-edit-table warehouse-transfer-mark-table"><thead><tr><th>客户箱唛号 *</th><th>旧 SKU *</th><th>新 SKU *</th><th>换标数量(件)*</th><th>新 FBA 箱唛 *</th><th>备注</th><th>操作</th></tr></thead><tbody><tr v-for="row in transferRelabelRows" :key="row.id"><td><el-input v-model="row.customerCartonMarks" type="textarea" :rows="2" placeholder="请输入客户箱唛号" /></td><td><el-input :model-value="row.sku" readonly /></td><td><el-input v-model="row.newSkuCode" placeholder="请输入新 SKU" /></td><td><el-input-number v-model="row.quantity" :min="1" :precision="0" controls-position="right" placeholder="请输入数量" /></td><td><el-input v-model="row.fbaCartonMark" placeholder="请输入新的 FBA 箱唛" /></td><td><el-input v-model="row.remark" type="textarea" :rows="2" placeholder="请输入备注" /></td><td><button type="button" class="warehouse-delete" title="删除该明细" @click="transferRelabelRows = transferRelabelRows.filter((item) => item.id !== row.id)"><Trash2 :size="16" />删除</button></td></tr><tr v-if="transferRelabelRows.length === 0"><td colspan="7"><div class="warehouse-empty">暂无绑定明细,请点击“添加 SKU 明细”</div></td></tr></tbody></table></div>
@@ -1137,9 +1349,13 @@ function selectRelabelInventory(inboundOrderId: string) {
</template>
<template v-else>
<div class="warehouse-breadcrumb"><button type="button" @click="mode = 'list'">{{ config.title }}</button><b>/</b><strong>订单详情</strong></div>
<div class="warehouse-page-heading"><h2>{{ config.title }}详情</h2><div><el-button v-if="canCancelDiscard && props.service === 'disposal' && selectedDetail?.status === 'PENDING_REVIEW'" type="danger" @click="cancelSelectedDiscard">取消申请</el-button><el-button v-if="canCreate && props.service === 'transfer' && selectedDetail?.status === 'DRAFT'" type="primary" @click="openEdit(String(selectedDetail?.transferNo || selectedDetail?.['0'] || ''))">编辑</el-button><el-button v-if="canConfirmTransfer && props.service === 'transfer' && selectedDetail?.status === 'WAIT_CUSTOMER_CONFIRMATION'" type="primary" @click="confirmTransferMeasurements">确认测量数据</el-button><el-button @click="mode = 'list'"><ArrowLeft :size="16" />返回列表</el-button></div></div>
<div class="warehouse-card warehouse-form-section"><h3>订单概览</h3><div v-if="props.service === 'transfer'" class="warehouse-detail-grid"><div><span>转运单号</span><strong>{{ selectedDetail?.transferNo || '—' }}</strong></div><div><span>入库箱数</span><strong>{{ selectedDetail?.packageCount ?? '—' }}</strong></div><div><span>海外仓</span><strong>{{ warehouseDisplay(selectedDetail?.operatingWarehouseId) }}</strong></div><div><span>FBA 目的仓</span><strong>{{ selectedDetail?.destinationWarehouseName || warehouseDisplay(selectedDetail?.destinationWarehouseCode || selectedDetail?.fbaWarehouseCode) }}</strong></div><div><span>入仓方式</span><strong>{{ deliveryMethodLabel(selectedDetail?.deliveryMethod || '') }}</strong></div><div><span>是否换标</span><strong>{{ selectedDetail?.needRelabel ? '是' : '否' }}</strong></div><div><span>状态</span><strong>{{ statusLabel(selectedDetail?.status || '') }}</strong></div><div><span>创建时间</span><strong>{{ formatDateTime(selectedDetail?.createdAt) }}</strong></div></div><div v-else class="warehouse-detail-grid"><div v-for="(column, index) in config.columns.slice(1)" :key="column"><span>{{ column }}</span><strong>{{ selectedDetail?.[String(index)] || '—' }}</strong></div></div></div>
<header class="warehouse-detail-header">
<button type="button" class="warehouse-detail-back" @click="returnToList"><ArrowLeft :size="16" />返回列表</button>
<div class="warehouse-detail-title"><strong>{{ props.service === 'labeling' ? selectedRelabelDetail?.application.relabelNo : selectedDetail?.transferNo || selectedDetail?.['0'] || `${config.title}详情` }}</strong><span>{{ config.title }}详情</span></div>
<div class="warehouse-detail-actions"><el-button v-if="canCancelDiscard && props.service === 'disposal' && selectedDetail?.status === 'PENDING_REVIEW'" type="danger" @click="cancelSelectedDiscard">取消申请</el-button><el-button v-if="canCreate && props.service === 'transfer' && selectedDetail?.status === 'DRAFT'" type="primary" @click="openEdit(String(selectedDetail?.transferNo || selectedDetail?.['0'] || ''))">编辑</el-button><el-button v-if="canConfirmTransfer && props.service === 'transfer' && selectedDetail?.status === 'WAIT_CUSTOMER_CONFIRMATION'" type="primary" @click="confirmTransferMeasurements">确认测量数据</el-button><el-tag effect="light">{{ statusLabel(props.service === 'labeling' ? selectedRelabelDetail?.application.status || '' : String(selectedDetail?.status || '')) }}</el-tag></div>
</header>
<div v-if="detailError" class="warehouse-detail-error" role="alert"><strong>{{ detailError }}</strong><el-button @click="loadDetailPage">重新加载</el-button></div>
<div v-else class="warehouse-card warehouse-form-section"><h3>订单概览</h3><div v-if="props.service === 'labeling'" class="warehouse-detail-grid"><div><span>操作仓</span><strong>{{ warehouseDisplay(selectedRelabelDetail?.application.overseasWarehouseId) }}</strong></div><div><span>客户参考号</span><strong>{{ selectedRelabelDetail?.application.customerOrderNo || '—' }}</strong></div><div><span>SKU 数</span><strong>{{ selectedRelabelDetail?.application.itemCount ?? '—' }}</strong></div><div><span>总箱数</span><strong>{{ selectedRelabelDetail?.application.totalPackageCount ?? '—' }}</strong></div><div><span>贴标截止时间</span><strong>{{ formatDateTime(selectedRelabelDetail?.application.labelDeadline) }}</strong></div><div><span>仓库联系人</span><strong>{{ selectedRelabelDetail?.application.contactName || '—' }}</strong></div><div><span>联系电话</span><strong>{{ selectedRelabelDetail?.application.contactPhone || '—' }}</strong></div><div><span>创建时间</span><strong>{{ formatDateTime(selectedRelabelDetail?.application.createdAt) }}</strong></div></div><div v-else class="warehouse-detail-grid"><div v-for="(column, index) in config.columns.slice(1)" :key="column"><span>{{ column }}</span><strong>{{ selectedDetail?.[String(index)] || '—' }}</strong></div></div></div>
<div v-if="props.service === 'transfer'" class="warehouse-card warehouse-form-section warehouse-transfer-cargo-card">
<div class="warehouse-section-heading"><h3>货物明细</h3><el-button v-if="selectedDetail?.status !== 'DRAFT'" native-type="button" type="primary" @click="printTransferCartons(String(selectedDetail?.transferNo || selectedDetail?.['0'] || ''))"><Printer :size="16" />打印箱唛</el-button></div>
<div class="warehouse-table-scroll"><table class="warehouse-edit-table"><thead><tr><th>商品 SKU</th><th>中文品名</th><th>预计箱数</th><th>客户箱唛号</th></tr></thead><tbody><tr v-for="(item, index) in selectedDetail?.items || []" :key="`${item.skuCode}-${index}`"><td>{{ item.skuCode || '—' }}</td><td>{{ item.productNameZh || '—' }}</td><td>{{ item.packageCount ?? '—' }}</td><td>{{ item.shippingMark || '—' }}</td></tr><tr v-if="!selectedDetail?.items?.length"><td colspan="4"><div class="warehouse-empty">暂无货物明细</div></td></tr></tbody></table></div>
@@ -1153,7 +1369,7 @@ function selectRelabelInventory(inboundOrderId: string) {
<div class="warehouse-section-heading"><h3>换标完成与出库</h3></div>
<div class="warehouse-detail-grid warehouse-fulfillment-grid">
<div><span>派送方式</span><strong>{{ deliveryMethodLabel(selectedRelabelDetail?.application.deliveryMethod || '') || '无需出库' }}</strong></div>
<div><span>履约状态</span><strong>{{ selectedRelabelDetail?.application.fulfillmentStatus || '—' }}</strong></div>
<div><span>履约状态</span><strong>{{ statusLabel(selectedRelabelDetail?.application.fulfillmentStatus || '') }}</strong></div>
<div><span>客户确认时间</span><strong>{{ formatDateTime(selectedRelabelDetail?.application.customerConfirmedAt) }}</strong></div>
</div>
<div class="warehouse-evidence"><span>仓库完工照片</span><div v-if="selectedRelabelDetail?.completionEvidence?.photoFileIds?.length" class="warehouse-photo-grid"><div v-for="(fileId, index) in selectedRelabelDetail.completionEvidence.photoFileIds" :key="fileId" class="warehouse-photo-item"><el-image v-if="completionPhotoUrls[fileId]" :src="completionPhotoUrls[fileId]" :preview-src-list="completionPhotoPreviewUrls" :initial-index="completionPhotoPreviewUrls.indexOf(completionPhotoUrls[fileId])" fit="cover" preview-teleported /><div v-else-if="completionPhotoLoading[fileId]" class="warehouse-photo-placeholder">照片 {{ index + 1 }} 加载中…</div><button v-else class="warehouse-photo-placeholder warehouse-photo-retry" type="button" @click="loadCompletionPhoto(fileId)">{{ completionPhotoErrors[fileId] ? `照片 ${index + 1} 加载失败,点击重试` : `加载照片 ${index + 1}` }}</button></div></div><strong v-else>仓库尚未上传完工照片</strong></div>
@@ -1185,24 +1401,29 @@ function selectRelabelInventory(inboundOrderId: string) {
</template>
<style scoped>
.warehouse-service-page{color:#25324a}.warehouse-breadcrumb{display:flex;align-items:center;gap:9px;margin:0 0 14px;color:#9aa3b2;font-size:12px}.warehouse-breadcrumb b{font-weight:400}.warehouse-breadcrumb strong{color:#606b7c}.warehouse-breadcrumb button{padding:0;border:0;background:transparent;color:#4376b8;cursor:pointer}.warehouse-page-heading{display:flex;align-items:center;justify-content:space-between;margin-bottom:16px}.warehouse-page-heading h2{margin:0;color:#17233a;font-size:22px;letter-spacing:0}.warehouse-card{margin-bottom:12px;border:1px solid #e7ebf1;border-radius:4px;background:#fff;box-shadow:0 1px 3px rgba(25,42,70,.04)}.warehouse-filter-card{padding:20px 22px 16px}.warehouse-filter-grid{display:grid;grid-template-columns:repeat(4,minmax(180px,1fr));gap:16px 20px}.warehouse-filter-grid label,.warehouse-form-grid label{display:flex;min-width:0;flex-direction:column;gap:7px}.warehouse-filter-grid label>span,.warehouse-form-grid label>span{color:#596579;font-size:12px}.warehouse-filter-actions{display:flex;justify-content:flex-end;gap:8px;margin-top:18px}.warehouse-table-card{overflow:hidden}.warehouse-table-scroll{overflow-x:auto}table{width:100%;min-width:940px;border-collapse:collapse;font-size:12px}th{height:44px;padding:0 14px;border-bottom:1px solid #e8edf3;background:#f7f9fc;color:#4d5a6e;font-weight:650;text-align:left;white-space:nowrap}td{height:48px;padding:8px 14px;border-bottom:1px solid #eef1f5;color:#526176;white-space:nowrap}.warehouse-empty{display:grid;height:150px;color:#a0a9b7;place-items:center}.warehouse-link,.warehouse-delete{display:inline-flex;align-items:center;gap:5px;padding:4px;border:0;background:transparent;color:#2176d2;cursor:pointer}.warehouse-delete{color:#d64545}.warehouse-pagination{display:flex;align-items:center;justify-content:flex-end;gap:8px;padding:14px 18px;color:#778398;font-size:12px}.warehouse-pagination span{margin-right:8px}.warehouse-page-number{width:28px;height:28px;border:1px solid #2176d2;border-radius:3px;background:#2176d2;color:#fff}.warehouse-form-section{padding:22px}.warehouse-form-section h3{margin:0 0 18px;padding-bottom:12px;border-bottom:1px solid #edf0f4;color:#26364f;font-size:15px;letter-spacing:0}.warehouse-form-grid{display:grid;grid-template-columns:repeat(2,minmax(240px,1fr));gap:18px 28px}.warehouse-form-grid i{margin-right:3px;color:#e24b4b;font-style:normal}.warehouse-span-2{grid-column:1/-1}.warehouse-section-heading{display:flex;align-items:center;justify-content:space-between}.warehouse-section-heading h3{flex:1}.warehouse-edit-table td{padding:9px 10px}.warehouse-edit-table :deep(.el-input-number){width:130px}.warehouse-hint{margin:12px 0 0;color:#8792a2;font-size:12px}.warehouse-submit-bar{display:flex;justify-content:center;gap:10px;padding:12px 0 30px}.warehouse-detail-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:24px}.warehouse-detail-grid div{display:flex;flex-direction:column;gap:7px}.warehouse-detail-grid span{color:#8a95a5;font-size:12px}.warehouse-detail-grid strong{color:#334157;font-size:13px}:deep(.el-button){border-radius:4px}:deep(.el-button span){display:inline-flex;align-items:center;gap:6px}:deep(.el-select),:deep(.el-date-editor.el-input){width:100%}@media(max-width:1100px){.warehouse-filter-grid{grid-template-columns:repeat(2,1fr)}.warehouse-detail-grid{grid-template-columns:repeat(2,1fr)}}
.warehouse-service-page{display:grid;gap:12px;color:#25324a}.warehouse-breadcrumb{display:flex;align-items:center;gap:9px;margin:0 0 14px;color:#9aa3b2;font-size:12px}.warehouse-breadcrumb b{font-weight:400}.warehouse-breadcrumb strong{color:#606b7c}.warehouse-breadcrumb button{padding:0;border:0;background:transparent;color:#4376b8;cursor:pointer}.warehouse-page-heading{display:flex;align-items:center;justify-content:space-between;margin-bottom:16px}.warehouse-page-heading h2{margin:0;color:#17233a;font-size:22px;letter-spacing:0}.warehouse-card{margin-bottom:0;border:1px solid var(--client-border);border-radius:var(--client-radius);background:#fff;box-shadow:none}.warehouse-filter-card{padding:20px 22px 16px}.warehouse-filter-grid{display:grid;grid-template-columns:repeat(4,minmax(180px,1fr));gap:16px 20px}.warehouse-filter-grid label,.warehouse-form-grid label{display:flex;min-width:0;flex-direction:column;gap:7px}.warehouse-filter-grid label>span,.warehouse-form-grid label>span{color:#596579;font-size:12px}.warehouse-filter-actions{display:flex;justify-content:flex-end;gap:8px;margin-top:18px}.warehouse-table-card{overflow:hidden}.warehouse-list-notice{padding:8px 12px;border-bottom:1px solid var(--client-border);background:#fff8e8;color:#8a6116;font-size:12px}.warehouse-table-scroll{overflow-x:auto}table{width:100%;min-width:940px;border-collapse:collapse;font-size:12px}th{height:44px;padding:0 14px;border-bottom:1px solid #e8edf3;background:#f7f9fc;color:#4d5a6e;font-weight:650;text-align:left;white-space:nowrap}td{height:48px;padding:8px 14px;border-bottom:1px solid #eef1f5;color:#526176;white-space:nowrap}.warehouse-empty{display:grid;height:72px;color:#a0a9b7;place-items:center}.warehouse-link,.warehouse-delete{display:inline-flex;align-items:center;gap:5px;padding:4px;border:0;background:transparent;color:#2176d2;cursor:pointer}.warehouse-delete{color:#d64545}.warehouse-pagination{display:flex;align-items:center;justify-content:flex-end;gap:8px;padding:14px 18px;color:#778398;font-size:12px}.warehouse-pagination span{margin-right:8px}.warehouse-page-number{width:28px;height:28px;border:1px solid #2176d2;border-radius:3px;background:#2176d2;color:#fff}.warehouse-form-section{padding:22px}.warehouse-form-section h3{margin:0 0 18px;padding-bottom:12px;border-bottom:1px solid #edf0f4;color:#26364f;font-size:15px;letter-spacing:0}.warehouse-form-grid{display:grid;grid-template-columns:repeat(2,minmax(240px,1fr));gap:18px 28px}.warehouse-form-grid i{margin-right:3px;color:#e24b4b;font-style:normal}.warehouse-span-2{grid-column:1/-1}.warehouse-section-heading{display:flex;align-items:center;justify-content:space-between}.warehouse-section-heading h3{flex:1}.warehouse-edit-table td{padding:9px 10px}.warehouse-edit-table :deep(.el-input-number){width:130px}.warehouse-hint{margin:12px 0 0;color:#8792a2;font-size:12px}.warehouse-submit-bar{display:flex;justify-content:center;gap:10px;padding:12px 0 30px}.warehouse-detail-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:24px}.warehouse-detail-grid div{display:flex;flex-direction:column;gap:7px}.warehouse-detail-grid span{color:#8a95a5;font-size:12px}.warehouse-detail-grid strong{color:#334157;font-size:13px}:deep(.el-button){border-radius:4px}:deep(.el-button span){display:inline-flex;align-items:center;gap:6px}:deep(.el-select),:deep(.el-date-editor.el-input){width:100%}@media(max-width:1100px){.warehouse-filter-grid{grid-template-columns:repeat(2,1fr)}.warehouse-detail-grid{grid-template-columns:repeat(2,1fr)}}
.warehouse-service-page--detail{display:block;overflow:hidden;border:1px solid var(--client-border);border-radius:var(--client-radius);background:#fff}.warehouse-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)}.warehouse-detail-back{display:inline-flex;align-items:center;gap:5px;padding:5px 0;border:0;background:transparent;color:var(--el-color-primary);cursor:pointer}.warehouse-detail-title{display:flex;min-width:0;flex-direction:column;gap:3px}.warehouse-detail-title strong{overflow:hidden;color:#1f2d45;font-size:18px;text-overflow:ellipsis;white-space:nowrap}.warehouse-detail-title span{color:#7c899c;font-size:12px}.warehouse-detail-actions{display:flex;align-items:center;gap:8px}.warehouse-service-page--detail>.warehouse-card{border:0;border-top:1px solid var(--client-border);border-radius:0}.warehouse-service-page--detail>.warehouse-detail-header+.warehouse-card{border-top:0}.warehouse-service-page--detail>.warehouse-form-section{padding:0}.warehouse-service-page--detail .warehouse-form-section h3{min-height:40px;margin:0;padding:11px 16px 9px;border-bottom:1px solid #edf1f5;font-size:14px}.warehouse-service-page--detail .warehouse-detail-grid{grid-template-columns:repeat(4,minmax(0,1fr));gap:0;margin:0}.warehouse-service-page--detail .warehouse-detail-grid>div{min-height:48px;flex-direction:row;align-items:center;gap:10px;padding:8px 14px;border-right:1px solid #edf1f5;border-bottom:1px solid #edf1f5}.warehouse-service-page--detail .warehouse-detail-grid span{flex:0 0 90px}.warehouse-service-page--detail .warehouse-detail-grid strong{min-width:0;overflow-wrap:anywhere}.warehouse-service-page--detail .warehouse-fulfillment-grid{margin-bottom:0}.warehouse-service-page--detail .warehouse-evidence{margin:12px 14px}.warehouse-service-page--detail .warehouse-completion-remark,.warehouse-service-page--detail .warehouse-hint{margin:10px 14px}.warehouse-service-page--detail .warehouse-table-scroll table{margin:0}.warehouse-service-page--detail .warehouse-empty{height:64px}@media(max-width:1100px){.warehouse-service-page--detail .warehouse-detail-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(max-width:720px){.warehouse-detail-header{grid-template-columns:1fr auto}.warehouse-detail-back{grid-column:1/-1}.warehouse-detail-actions{justify-content:flex-end}}
.warehouse-detail-error{display:flex;min-height:220px;align-items:center;justify-content:center;gap:12px;color:#b42318}
.warehouse-checkbox-field{display:flex;min-height:32px;align-items:center;justify-content:flex-start;gap:16px}.warehouse-checkbox-field>span{color:#596579;font-size:12px}.warehouse-checkbox-field :deep(.el-checkbox){margin-right:0}
.warehouse-choice-field{display:grid;grid-template-columns:150px auto 1fr;align-items:center;gap:14px;min-height:40px;padding:12px 14px;border:1px solid #e1e8f2;border-radius:5px;background:#f8faff}.warehouse-choice-field>span{color:#34445c;font-size:13px;font-weight:650}.warehouse-choice-field>span i{margin-right:3px;color:#e24b4b;font-style:normal}.warehouse-choice-field>small{color:#7a879a;font-size:12px}.warehouse-receiving-card{display:grid;grid-template-columns:2fr 1fr 1fr;gap:22px;padding:15px 16px;border:1px solid #cfe0f5;border-radius:5px;background:#f6faff}.warehouse-receiving-card div{display:flex;min-width:0;flex-direction:column;gap:6px}.warehouse-receiving-card span{color:#7c899c;font-size:12px}.warehouse-receiving-card strong{overflow-wrap:anywhere;color:#314561;font-size:13px;font-weight:600}
.warehouse-hidden-input{display:none}.warehouse-label-table{min-width:1120px}.warehouse-attachment-tip{margin:10px 0 12px;color:#7d8797;font-size:12px}.warehouse-mark-mapping{display:flex;flex-wrap:wrap;gap:8px;margin:4px 0 8px;padding:12px;border:1px solid #dce7f5;border-radius:5px;background:#f7faff}.warehouse-mark-mapping strong{width:100%;color:#34445c}.warehouse-mark-mapping span{padding:5px 9px;border-radius:3px;background:#eaf2fc;color:#3e6593;font-size:12px}
.warehouse-upload-summary{display:flex;flex-wrap:wrap;gap:8px 24px;margin:-4px 0 14px;padding:9px 12px;border:1px solid #dce7f5;border-radius:4px;background:#f8fbff;color:#596579;font-size:12px}
.warehouse-upload-summary>span{display:inline-flex;align-items:center;gap:6px;overflow-wrap:anywhere}
.warehouse-file-groups{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);gap:16px;margin:0 0 18px}.warehouse-file-groups--single{grid-template-columns:minmax(0,1fr)}.warehouse-file-group{min-width:0;overflow:hidden;border:1px solid #dde5f0;border-radius:8px;background:#fbfcfe;box-shadow:0 1px 2px rgba(31,54,86,.04)}.warehouse-file-group--primary{border-color:#cbdcf2;background:linear-gradient(180deg,#f7faff 0,#fbfdff 100%)}.warehouse-file-group__heading{display:flex;align-items:flex-start;justify-content:space-between;gap:16px;padding:14px 16px;border-bottom:1px solid #e7edf5;background:rgba(255,255,255,.68)}.warehouse-file-group__heading strong{display:flex;align-items:center;gap:8px;color:#2d405c;font-size:14px}.warehouse-file-group__heading strong>span{display:inline-grid;width:22px;height:22px;border-radius:50%;background:#3478c8;color:#fff;font-size:12px;place-items:center}.warehouse-file-group__heading em{flex-shrink:0;padding:4px 8px;border-radius:10px;background:#edf4fd;color:#4774aa;font-size:11px;font-style:normal}.warehouse-file-options{display:grid;grid-template-columns:minmax(0,1fr) 28px minmax(0,1fr);align-items:stretch;padding:14px}.warehouse-file-option{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:10px 12px;align-items:center;min-width:0;padding:12px;border:1px dashed #ced8e6;border-radius:6px;background:#fff}.warehouse-file-option.is-selected{border-style:solid;border-color:#79a8df;background:#f5f9ff}.warehouse-file-option__name strong{color:#34445a;font-size:13px}.warehouse-file-option__status{display:flex;grid-column:1/-1;align-items:center;justify-content:space-between;gap:8px;min-width:0;padding:7px 8px;border-radius:4px;background:#eaf3ff;color:#376da9;font-size:11px}.warehouse-file-option__status>span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.warehouse-file-option__status.is-empty{background:#f4f6f8;color:#9aa4b2}.warehouse-file-choice-or{display:grid;color:#9ba6b5;font-size:11px;place-items:center}.warehouse-file-choice-or.is-both{color:#3478c8;font-weight:700}.warehouse-template-list{display:flex;flex-direction:column;padding:7px 14px}.warehouse-template-list>div{display:grid;grid-template-columns:minmax(150px,1fr) auto auto;align-items:center;gap:8px;padding:9px 0;border-bottom:1px solid #edf1f6}.warehouse-template-list>div:last-child{border-bottom:0}.warehouse-template-list strong{color:#3f4e63;font-size:12px}.warehouse-transfer-template-group{margin:0 0 14px}@media(max-width:1180px){.warehouse-file-groups{grid-template-columns:minmax(0,1fr)}}@media(max-width:720px){.warehouse-file-options{grid-template-columns:minmax(0,1fr)}.warehouse-file-choice-or{height:24px}.warehouse-template-list>div{grid-template-columns:minmax(0,1fr) auto}.warehouse-template-list>div>span{grid-column:1/-1}}
.warehouse-file-clear{display:inline-flex;align-items:center;justify-content:center;flex-shrink:0;width:24px;height:24px;border:0;border-radius:4px;background:transparent;color:var(--muted);cursor:pointer}
.warehouse-file-clear:hover:not(:disabled){color:#dc2626;background:#fee2e2}
.warehouse-file-clear:disabled{opacity:.5;cursor:not-allowed}
.warehouse-unmaintained{margin-left:8px;color:#e6a23c;font-size:12px;font-style:normal}
.warehouse-labeling-tabs{margin-top:-8px}.warehouse-labeling-heading{align-items:flex-start}.warehouse-labeling-heading h3{margin-bottom:16px}.warehouse-template-actions{display:flex;gap:8px;padding-bottom:12px}.warehouse-tab-actions{align-items:center;margin:2px 0 14px}.warehouse-tab-actions p{margin:0;color:#7d8797;font-size:12px}.warehouse-tab-actions>div{display:flex;gap:8px}.warehouse-sku-notice{margin:0 0 14px;padding:10px 12px;border:1px solid #f2d39b;border-radius:4px;background:#fff8e8;color:#8a6116;font-size:12px}.warehouse-sku-label-table{min-width:980px}.warehouse-sku-label-table :deep(.el-input-number){width:160px}:deep(.warehouse-labeling-tabs>.el-tabs__header){margin-bottom:18px}:deep(.warehouse-labeling-tabs .el-tabs__item){height:42px;padding:0 26px;font-weight:600}.warehouse-labeling-mode{display:flex;align-items:center;gap:14px;margin:0 0 16px;padding:14px 16px;border:1px solid #dce5f5;border-radius:5px;background:#f7f9ff}.warehouse-labeling-mode>span{color:#34445c;font-size:13px;font-weight:650;white-space:nowrap}.warehouse-labeling-mode p{margin:0;color:#71809a;font-size:12px}.warehouse-labeling-mode :deep(.el-radio-group){display:flex;flex-shrink:0}.warehouse-labeling-mode :deep(.el-radio-button__inner){padding:8px 14px}
.warehouse-relabel-detail-table{min-width:1420px}.warehouse-relabel-detail-table td strong{color:#253f67}
.warehouse-service-page--return-relabel .warehouse-table-scroll:has(> .warehouse-return-relabel-table)~.warehouse-table-scroll{display:none}
.warehouse-native-file{min-height:32px;padding:5px 8px;border:1px solid #dcdfe6;border-radius:4px;background:#fff;color:#606266}.warehouse-fulfillment-grid{margin-bottom:18px}.warehouse-evidence{display:flex;align-items:flex-start;gap:16px;padding:14px;border:1px solid #e7ebf1;border-radius:4px;background:#f8fafc}.warehouse-evidence>span{padding-top:6px;color:#71809a;font-size:12px;white-space:nowrap}.warehouse-evidence>strong{padding-top:6px;color:#8a95a5;font-size:12px;font-weight:400}.warehouse-photo-grid{display:grid;grid-template-columns:repeat(4,140px);gap:12px}.warehouse-photo-item{width:140px;height:105px;overflow:hidden;border:1px solid #dce3ed;border-radius:5px;background:#fff}.warehouse-photo-item :deep(.el-image){display:block;width:100%;height:100%;cursor:zoom-in}.warehouse-photo-placeholder{display:flex;width:100%;height:100%;align-items:center;justify-content:center;padding:10px;border:0;background:#f4f7fb;color:#8792a2;font-size:12px;text-align:center}.warehouse-photo-retry{cursor:pointer}.warehouse-completion-remark{margin:10px 0 0;color:#596579;font-size:12px}
.warehouse-measurement-evidence{margin-top:16px}
.warehouse-template-actions{flex-wrap:wrap;justify-content:flex-end}.warehouse-import-preview{margin:0 0 16px;padding:14px;border:1px solid #cfe0f5;border-radius:5px;background:#f8fbff}.warehouse-import-preview .warehouse-section-heading{margin-bottom:10px}.warehouse-import-preview .warehouse-section-heading>div{display:flex;align-items:center;gap:10px}.warehouse-import-preview p{margin:0;color:#7d8797;font-size:12px}.warehouse-binding-preview-table{min-width:980px;background:#fff}
.warehouse-transfer-relabel .warehouse-labeling-heading>div:first-child{min-width:0}.warehouse-transfer-relabel .warehouse-labeling-heading h3{margin-bottom:6px}.warehouse-transfer-relabel .warehouse-labeling-heading p{margin:0 0 16px;color:#7d8797;font-size:12px}.warehouse-transfer-relabel-fields{margin-bottom:18px}.warehouse-transfer-mark-table{min-width:1420px}.warehouse-transfer-mark-table td{vertical-align:top}.warehouse-transfer-mark-table :deep(.el-textarea){min-width:220px}.warehouse-transfer-mark-table :deep(.el-input-number){width:150px}.warehouse-count-error{color:#d64545}
.warehouse-transfer-form{display:grid;grid-template-columns:minmax(0,1fr);gap:12px}.warehouse-transfer-form--relabel{grid-template-columns:minmax(0,1fr) minmax(0,1.35fr);align-items:start}.warehouse-transfer-form--relabel .warehouse-transfer-main-card{grid-column:1;grid-row:1}.warehouse-transfer-form--relabel .warehouse-transfer-cargo-card{grid-column:1;grid-row:2}.warehouse-transfer-form--relabel .warehouse-transfer-relabel{grid-column:2;grid-row:1 / span 2;margin-bottom:0}.warehouse-transfer-form--relabel .warehouse-transfer-submit-bar{grid-column:1 / -1}.warehouse-transfer-form :deep(.el-input__wrapper),.warehouse-transfer-form :deep(.el-textarea__inner),.warehouse-transfer-form :deep(.el-select__wrapper),.warehouse-transfer-form :deep(.el-date-editor.el-input__wrapper){min-height:32px}.warehouse-transfer-form .warehouse-form-section{padding:16px}.warehouse-transfer-form .warehouse-form-section h3{margin-bottom:12px;padding-bottom:9px;font-size:14px}.warehouse-transfer-form .warehouse-form-grid{gap:12px 16px}.warehouse-transfer-form .warehouse-choice-field{min-height:34px;padding:8px 10px;grid-template-columns:118px auto 1fr;gap:10px}.warehouse-transfer-form .warehouse-choice-field>span{font-size:12px}.warehouse-transfer-form .warehouse-choice-field small{font-size:11px}.warehouse-transfer-form .warehouse-receiving-card{gap:12px;padding:10px 12px}.warehouse-transfer-form .warehouse-section-heading{margin-bottom:8px}.warehouse-transfer-form .warehouse-edit-table{font-size:11px}.warehouse-transfer-form .warehouse-edit-table th{height:36px;padding:0 9px}.warehouse-transfer-form .warehouse-edit-table td{height:42px;padding:6px 7px}.warehouse-transfer-form .warehouse-transfer-mark-table :deep(.el-textarea){min-width:150px}.warehouse-transfer-form .warehouse-transfer-mark-table :deep(.el-input-number){width:112px}.warehouse-transfer-form .warehouse-hint{margin-top:5px;font-size:11px}.warehouse-transfer-form .warehouse-labeling-mode{gap:9px;margin-bottom:10px;padding:9px 10px}.warehouse-transfer-form .warehouse-labeling-mode>span{font-size:12px}.warehouse-transfer-form .warehouse-labeling-mode p{font-size:11px}.warehouse-transfer-form .warehouse-labeling-mode :deep(.el-radio-button__inner){padding:6px 9px}.warehouse-transfer-form .warehouse-template-actions{gap:5px;padding-bottom:8px}.warehouse-transfer-form .warehouse-template-actions :deep(.el-button){padding:6px 8px;font-size:11px}.warehouse-transfer-form .warehouse-submit-bar{padding:4px 0 18px}.warehouse-transfer-form .warehouse-submit-bar :deep(.el-button){min-height:32px;padding:7px 14px}.warehouse-transfer-form .warehouse-transfer-relabel{overflow:hidden}.warehouse-transfer-form .warehouse-transfer-relabel-fields{margin-bottom:10px}
.warehouse-transfer-relabel .warehouse-labeling-heading>div:first-child{min-width:0}.warehouse-transfer-relabel .warehouse-labeling-heading h3{margin-bottom:6px}.warehouse-transfer-relabel .warehouse-labeling-heading p{margin:0 0 16px;color:#7d8797;font-size:12px}.warehouse-transfer-relabel-fields{margin-bottom:18px}.warehouse-transfer-file-groups{display:grid;grid-template-columns:minmax(0,1.15fr) minmax(360px,.85fr);align-items:stretch;gap:14px;margin-bottom:14px}.warehouse-transfer-total-files,.warehouse-transfer-template-group{height:100%;margin:0}.warehouse-transfer-mark-table{min-width:1420px}.warehouse-transfer-mark-table td{vertical-align:top}.warehouse-transfer-mark-table :deep(.el-textarea){min-width:220px}.warehouse-transfer-mark-table :deep(.el-input-number){width:150px}.warehouse-count-error{color:#d64545}
.warehouse-transfer-form{display:grid;grid-template-columns:minmax(0,1fr);gap:12px}.warehouse-transfer-form--relabel{grid-template-columns:minmax(0,1fr) minmax(0,1.35fr);align-items:start}.warehouse-transfer-form--relabel .warehouse-transfer-main-card{grid-column:1;grid-row:1}.warehouse-transfer-form--relabel .warehouse-transfer-cargo-card{grid-column:1;grid-row:2}.warehouse-transfer-form--relabel .warehouse-transfer-relabel{grid-column:2;grid-row:1 / span 2;margin-bottom:0}.warehouse-transfer-form--relabel .warehouse-transfer-submit-bar{grid-column:1 / -1}.warehouse-transfer-form :deep(.el-input__wrapper),.warehouse-transfer-form :deep(.el-textarea__inner),.warehouse-transfer-form :deep(.el-select__wrapper),.warehouse-transfer-form :deep(.el-date-editor.el-input__wrapper){min-height:32px}.warehouse-transfer-form .warehouse-form-section{padding:16px}.warehouse-transfer-form .warehouse-form-section h3{margin-bottom:12px;padding-bottom:9px;font-size:14px}.warehouse-transfer-form .warehouse-form-grid{gap:12px 16px}.warehouse-transfer-form .warehouse-choice-field{min-height:34px;padding:8px 10px;grid-template-columns:118px minmax(0,1fr);gap:10px}.warehouse-transfer-form .warehouse-choice-field>span{font-size:12px}.warehouse-transfer-form .warehouse-receiving-card{gap:12px;padding:10px 12px}.warehouse-transfer-form .warehouse-section-heading{margin-bottom:8px}.warehouse-transfer-form .warehouse-edit-table{font-size:11px}.warehouse-transfer-form .warehouse-edit-table th{height:36px;padding:0 9px}.warehouse-transfer-form .warehouse-edit-table td{height:42px;padding:6px 7px}.warehouse-transfer-form .warehouse-transfer-mark-table :deep(.el-textarea){min-width:150px}.warehouse-transfer-form .warehouse-transfer-mark-table :deep(.el-input-number){width:112px}.warehouse-transfer-form .warehouse-hint{margin-top:5px;font-size:11px}.warehouse-transfer-form .warehouse-labeling-mode{gap:9px;margin-bottom:10px;padding:9px 10px}.warehouse-transfer-form .warehouse-labeling-mode>span{font-size:12px}.warehouse-transfer-form .warehouse-labeling-mode :deep(.el-radio-button__inner){padding:6px 9px}.warehouse-transfer-form .warehouse-template-actions{display:flex;flex-wrap:wrap;justify-content:flex-end;gap:5px;padding-bottom:8px}.warehouse-transfer-form .warehouse-template-actions :deep(.el-button){padding:6px 8px;font-size:11px}.warehouse-transfer-form .warehouse-submit-bar{padding:4px 0 18px}.warehouse-transfer-form .warehouse-submit-bar :deep(.el-button){min-height:32px;padding:7px 14px}.warehouse-transfer-form .warehouse-transfer-relabel{overflow:hidden}.warehouse-transfer-form .warehouse-transfer-relabel-fields{margin-bottom:10px}.warehouse-step-heading{align-items:flex-start}.warehouse-step-heading h3{display:flex;align-items:center;gap:9px}.warehouse-step-number{display:inline-grid;flex:0 0 auto;width:24px;height:24px;border-radius:50%;background:#3478c8;color:#fff;font-size:12px;place-items:center}.warehouse-heading-actions{display:flex;flex-wrap:wrap;justify-content:flex-end;gap:8px}.warehouse-transfer-template-group{margin:0 0 14px;padding:12px 14px;border:1px solid #dde5f0;border-radius:7px;background:#fbfcfe}.warehouse-binding-heading{margin-top:14px;padding:12px 14px;border:1px solid #dde5f0;border-radius:7px;background:#f8faff}.warehouse-binding-heading strong{display:flex;align-items:center;gap:8px;color:#34445c;font-size:13px}
@media(max-width:1250px){.warehouse-transfer-form--relabel{grid-template-columns:minmax(0,1fr) minmax(0,1fr)}.warehouse-transfer-form .warehouse-template-actions{justify-content:flex-start}.warehouse-transfer-form .warehouse-labeling-heading{align-items:flex-start;flex-direction:column}}
@media(max-width:1180px){.warehouse-transfer-file-groups{grid-template-columns:minmax(0,1fr)}}
.warehouse-transfer-form .pickup-address-field{grid-column:span 1}
/* Keep the count and controls visible together on narrow list pages. */
.warehouse-pagination { justify-content: flex-end; }
@@ -17,11 +17,32 @@ test('总文件可以独立清除并重选同名文件,提交时禁止清除',
assert.match(view, /aria-label="清除 SKU 总文件" :disabled="saving"/)
})
test('贴标申请从当前客户仍有库存的历史入仓单选择来源', () => {
test('贴标文件按总文件和关联模板分组展示', () => {
assert.match(view, /warehouse-file-groups/)
assert.match(view, /<span>1<\/span>贴标总文件/)
assert.match(view, /必传 · 二选一/)
assert.match(view, /必传 · 两份文件/)
assert.match(view, /<span>2<\/span>明细关联模板/)
assert.match(view, /SKU 关联/)
assert.match(view, /箱号关联/)
assert.match(view, /SKU 混箱/)
assert.match(view, /warehouse-file-groups--single/)
})
test('贴标申请移除操作项下方的解释文字,保留必要状态', () => {
assert.doesNotMatch(view, /选择后仅查询当前客户在该中转仓的可用库存/)
assert.doesNotMatch(view, /托盘仅作为库内承载位置/)
assert.doesNotMatch(view, /整张贴标申请只能选择一个目的仓/)
assert.doesNotMatch(view, /SKU 与箱号关联通过仓库唛头合并/)
assert.match(view, /必传 · 二选一/)
assert.match(view, /必传 · 两份文件/)
})
test('贴标申请可从当前客户仍有库存的入仓单或退货单选择来源', () => {
assert.match(view, /海外操作仓(中转仓)/)
assert.match(view, /选择有库存的历史入仓单/)
assert.match(view, /曾经下单且当前仍有库存的入仓单/)
assert.match(view, /当前中转仓暂无已入库可用库存/)
assert.match(view, /选择有库存的入仓单或退货单/)
assert.match(view, /quantityUnit: candidate\.sourceType === 'RETURN_CARTON' \? '件' : '箱'/)
assert.match(view, /当前中转仓暂无可用库存/)
assert.match(view, /relabelInboundOptions/)
assert.match(view, /listRelabelInventoryCandidates\(form\.warehouse\)/)
assert.match(api, /relabel-applications\/inventory-candidates/)
@@ -38,12 +59,11 @@ test('客户贴标表单不展示客户单号、托盘和内部库存来源列',
test('贴标申请保留关联模板下载和上传入口', () => {
assert.match(view, /downloadRelabelTemplate/)
assert.match(view, /下载 SKU 关联模板/)
assert.match(view, /上传 SKU 关联/)
assert.match(view, /下载箱号关联模板/)
assert.match(view, /上传箱号关联/)
assert.match(view, /下载 SKU 混箱模板/)
assert.match(view, /上传 SKU 混箱/)
assert.match(view, /<strong>SKU 关联<\/strong>/)
assert.match(view, /<strong>箱号关联<\/strong>/)
assert.match(view, /<strong>SKU 混箱<\/strong>/)
assert.match(view, />下载模板<\/el-button>/)
assert.match(view, />上传文件<\/el-button>/)
assert.match(view, /skuTemplateInput\?\.click\(\)/)
assert.match(view, /cartonTemplateInput\?\.click\(\)/)
assert.match(view, /importRelabelTemplate/)
@@ -51,7 +71,7 @@ test('贴标申请保留关联模板下载和上传入口', () => {
assert.match(view, /'明细行标识', '旧箱唛', '旧SKU标签', '新SKU标签'/)
assert.match(view, /const includeDestination = false/)
assert.doesNotMatch(view, /const includeDestination = props\.service !== 'transfer'/)
assert.match(view, /SKU 与箱号关联通过仓库唛头合并/)
assert.match(view, /<strong>绑定明细<\/strong>/)
assert.match(view, /<th>仓库唛头 \*<\/th>/)
assert.doesNotMatch(view, /库存位置ID/)
})
@@ -76,7 +96,7 @@ test('上传关系支持部分或全量选择后导入本次换标明细', () =>
})
test('箱唛换标一行固定代表一箱', () => {
const cartonTable = view.match(/<div v-if="labelingMode === 'carton'" class="warehouse-table-scroll">.+?(?=<div v-else class="warehouse-table-scroll">)/s)?.[0]
const cartonTable = view.match(/<div v-if="!isReturnRelabelSource && labelingMode === 'carton'" class="warehouse-table-scroll">.+?(?=<div v-else class="warehouse-table-scroll">)/s)?.[0]
assert.ok(cartonTable)
assert.match(cartonTable, /<th>箱数<\/th>/)
assert.match(cartonTable, /<td><strong>1 箱<\/strong><\/td>/)
@@ -115,22 +135,47 @@ test('箱唛相关换标必须选择四选一派送方式', () => {
assert.match(view, /labelingMode\.value !== 'sku' && !form\.deliveryMethod/)
})
test('一次申请单选库存来源且保留普通入仓或退货合箱归属', () => {
test('一次申请单选库存来源且退货库存按退货单汇总', () => {
assert.doesNotMatch(view, /multiple filterable collapse-tags/)
assert.match(view, /RETURN_CARTON:\$\{item\.returnCartonId\}/)
assert.match(view, /RETURN_ORDER:\$\{returnOrderNo\}/)
assert.match(view, /current\.availablePackages \+= inventoryQuantity\(candidate\.availablePackages\)/)
assert.match(view, /availablePackages: inventoryQuantity\(candidate\.availablePackages\)/)
assert.match(view, /const quantity = Number\(value\)/)
assert.match(view, /interface ReturnRelabelSkuRow/)
assert.match(view, /current\.availableQuantity \+= quantity/)
assert.match(view, /库存总数/)
assert.match(view, /本次贴标数量/)
assert.match(view, /returnRelabelSkuRows\.value = \[\.\.\.grouped\.values\(\)\]/)
assert.match(view, /createReturnRelabel\(selectedReturnOrderNo\.value/)
assert.match(view, /uploadReturnRelabelFile\(created\.relabelNo, fileScope, selectedFile\)/)
assert.doesNotMatch(view, /candidate\.sourceType === 'RETURN_CARTON'\) rows\.push\(\.\.\.Array\.from/)
assert.match(view, /sourceInventoryBalanceId: String\(candidate\.inventoryBalanceId\)/)
assert.match(view, /sourceInventoryPositionId: String\(position\.positionId\)/)
assert.match(view, /sourceInboundOrderId: String\(candidate\.inboundOrderId\)/)
assert.match(view, /returnCartonId: String\(candidate\.returnCartonId\)/)
assert.match(api, /sourceInventoryBalanceId\?: string; sourceInventoryPositionId\?: string; sourceInboundOrderId\?: string; returnCartonId\?: string/)
})
test('退货贴标总文件二选一且提交使用退货专用链路', () => {
assert.match(view, /cartonTotalFile\.value && !requiresBothTotalFiles\.value\) clearCreateTotalFile\('SKU_TOTAL'\)/)
assert.match(view, /skuTotalFile\.value && !requiresBothTotalFiles\.value\) clearCreateTotalFile\('CARTON_TOTAL'\)/)
assert.match(view, /fileScope = cartonTotalFile\.value \? 'CARTON_TOTAL' as const : 'SKU_TOTAL' as const/)
assert.match(view, /destinationFbaWarehouseId: String\(destination\.id\)/)
})
test('SKU 和箱唛都换时两份总文件必须同时上传', () => {
assert.match(view, /labelingMode\.value === 'carton_and_sku'/)
assert.match(view, /requiresBothTotalFiles\.value && \(!cartonTotalFile\.value \|\| !skuTotalFile\.value\)/)
assert.match(view, /必须同时上传箱唛总文件和 SKU 总文件/)
assert.match(view, /requiresBothTotalFiles \? '和' : '或'/)
assert.match(view, /必传 · 两份文件/)
})
test('海外操作仓和FBA目的仓使用独立字段', () => {
assert.match(api, /operationWarehouseId: string/)
assert.match(api, /destinationFbaWarehouseCode\?: string/)
assert.match(view, /operationWarehouseId: form\.warehouse/)
assert.match(view, /destinationFbaWarehouseCode: form\.destination/)
assert.match(view, /整张贴标申请只能选择一个目的仓/)
assert.match(view, /<el-select v-model="form\.destination"[^\n]+FBA 目的仓/)
assert.doesNotMatch(view, /v-model="row\.destinationWarehouseId"[^\n]+请选择 FBA 目的仓/)
})
@@ -164,8 +209,8 @@ test('贴标详情展示箱唛、SKU 子明细和独立FBA目的仓', () => {
})
test('客户在换标列表确认完工且照片使用二进制下载', () => {
assert.match(view, /row\.__status === 'CUSTOMER_CONFIRM_PENDING'/)
assert.match(view, /confirmRelabelFromList\(row\['0'\]\)/)
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/)
})
@@ -179,5 +224,6 @@ test('客户详情进入页面后直接回显完工照片并支持大图预览',
})
test('进入复核后客户订单回显换标完成状态', () => {
assert.match(view, /\['CUSTOMER_CONFIRM_PENDING', 'READY_FOR_OUTBOUND', 'COMPLETED'\]\.includes\(value\) \? '换标完成'/)
assert.match(view, /value === 'CUSTOMER_CONFIRM_PENDING'\) return '待客户确认'/)
assert.match(view, /\['READY_FOR_OUTBOUND', 'COMPLETED'\]\.includes\(value\) \? '换标完成'/)
})
@@ -5,6 +5,12 @@ import test from 'node:test'
const view = readFileSync(new URL('./WarehouseServiceView.vue', import.meta.url), 'utf8')
const api = readFileSync(new URL('../api/warehouseServices.ts', import.meta.url), 'utf8')
test('贴标总文件与关联模板保持左右分组,退货来源使用专用明细', () => {
assert.match(view, /class="warehouse-file-groups" :class="\{ 'warehouse-file-groups--single': isReturnRelabelSource \}"/)
assert.match(view, /warehouse-service-page--return-relabel/)
assert.match(view, /const sourceValue = `RETURN_ORDER:\$\{requestedReturnNo\}`/)
})
test('仓储转运先选择入仓方式并按方式切换地址信息', () => {
assert.match(view, /value="WAREHOUSE_PICKUP">仓库上门提货/)
assert.match(view, /value="CUSTOMER_DELIVERY">客户送货到仓/)
@@ -14,14 +20,14 @@ test('仓储转运先选择入仓方式并按方式切换地址信息', () => {
})
test('仓储转运支持无目的地、FBA 仓和私人仓并显式选择是否换标', () => {
assert.match(view, /是否有目的去向/)
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(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/)
@@ -30,13 +36,11 @@ test('仓储转运支持无目的地、FBA 仓和私人仓并显式选择是否
})
test('仓储转运换标整票只允许一个目的仓', () => {
assert.match(view, /props\.service === 'transfer' && transferDestinationType === 'FBA'[^\n]+未换标货物目的 FBA 仓/)
assert.match(view, /此处只表示不换标箱子的去向/)
assert.match(view, /props\.service === 'transfer' && transferDestinationType === 'FBA'[^\n]+FBA 目的仓/)
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] || ''
@@ -56,15 +60,14 @@ test('仓储转运不要求客户填写内部入仓号和我方箱唛,但换
assert.doesNotMatch(transferConfig, /进仓编号/)
assert.doesNotMatch(transferSubmit, /inboundCode|sourceLastlegOrderIds/)
assert.match(view, /货物明细/)
assert.match(view, /客户只填写自己的箱唛,我方箱唛由系统生成/)
assert.match(view, /客户箱唛号 \*/)
assert.match(view, /downloadTransferCartonMarkTemplate/)
assert.match(view, /importTransferCartonMarks/)
assert.match(view, /下载 SKU 关联模板/)
assert.match(view, /上传 SKU 关联/)
assert.match(view, /下载箱号关联模板/)
assert.match(view, /上传箱号关联/)
assert.match(view, /下载 SKU 混箱模板/)
assert.match(view, /上传 SKU 混箱/)
assert.match(view, /<strong>SKU 关联<\/strong>/)
assert.match(view, /<strong>箱号关联<\/strong>/)
assert.match(view, /<strong>SKU 混箱<\/strong>/)
assert.match(view, />下载模板<\/el-button>/)
assert.match(view, />上传文件<\/el-button>/)
assert.match(view, /换标类型/)
assert.match(view, /v-if="labelingMode === 'carton'"/)
assert.match(view, /v-else-if="labelingMode === 'sku'"/)
@@ -80,9 +83,50 @@ test('仓储转运不要求客户填写内部入仓号和我方箱唛,但换
assert.doesNotMatch(view, /<th v-if="props\.service === 'transfer'">FNSKU \/ 箱唛<\/th>/)
})
test('仓储转运货物明细可按 SKU 和箱数打印客户预贴箱唛', () => {
assert.match(view, /printTransferDraftCartonMarks/)
assert.match(view, /请先填写商品 SKU 和对应的整数箱数/)
assert.match(view, /Array\.from\(\{ length: Number\(row\.quantity\) \}/)
assert.match(view, /row\.customerCartonMarks = marks\.join\('\\n'\)/)
assert.match(view, /仓储转运客户箱唛/)
assert.match(view, /@click="printTransferDraftCartonMarks"/)
assert.match(view, /打印唛头/)
assert.match(view, /单次最多打印 500 张箱唛/)
})
test('仓储转运换标资料按步骤分组展示', () => {
assert.match(view, /<span v-if="props\.service === 'transfer'" class="warehouse-step-number">1<\/span>货物明细/)
assert.match(view, /<span class="warehouse-step-number">2<\/span>换标申请/)
assert.match(view, /<strong><span>3<\/span>明细关联模板<\/strong>/)
assert.match(view, /<span class="warehouse-step-number">4<\/span>绑定明细/)
assert.match(view, /warehouse-transfer-template-group/)
assert.match(view, /warehouse-transfer-relabel/)
})
test('仓储转运需要换标时上传总文件并挂到关联换标单', () => {
const transferRelabelSection = view.split('warehouse-transfer-relabel">')[1]?.split('warehouse-transfer-submit-bar')[0] || ''
assert.match(transferRelabelSection, /warehouse-transfer-total-files/)
assert.match(transferRelabelSection, />贴标总文件</)
assert.match(transferRelabelSection, />箱唛总文件</)
assert.match(transferRelabelSection, />SKU 总文件</)
assert.match(view, /action === 'submit' && requiresBothTotalFiles\.value/)
assert.match(view, /const submitted = await submitTransfer\(transferNo\)/)
assert.match(view, /submitted\.relabelNo \|\| created\.relabelNo/)
assert.match(view, /uploadRelabelFile\(relabelNo, 'CARTON_TOTAL', cartonTotalFile\.value\)/)
assert.match(view, /uploadRelabelFile\(relabelNo, 'SKU_TOTAL', skuTotalFile\.value\)/)
assert.match(view, /转运单已提交,附件待补传/)
})
test('仓储转运换标总文件与明细模板在桌面端左右排列', () => {
assert.match(view, /class="warehouse-transfer-file-groups"/)
assert.match(view, /\.warehouse-transfer-file-groups\{display:grid;grid-template-columns:minmax\(0,1\.15fr\) minmax\(360px,\.85fr\)/)
assert.match(view, /\.warehouse-transfer-total-files,\.warehouse-transfer-template-group\{height:100%;margin:0\}/)
assert.match(view, /@media\(max-width:1180px\)\{\.warehouse-transfer-file-groups\{grid-template-columns:minmax\(0,1fr\)\}\}/)
})
test('上门提货不提交物流号,需要换标时随转运单登记申请', () => {
assert.match(view, /trackingNo:\s*form\.deliveryMethod === 'CUSTOMER_DELIVERY'\s*\?[^:]+:\s*undefined/)
assert.match(view, /本次提交将同时登记换标申请/)
assert.match(view, /relabelItems: form\.relabel/)
assert.match(view, /form\.deliveryMethod === 'CUSTOMER_DELIVERY'.*物流跟踪号/s)
assert.match(view, /提货联系人/)
assert.match(view, /联系人电话/)
@@ -102,14 +146,14 @@ test('仓储转运列表操作仓使用海外仓接口数据', () => {
assert.match(view, /loadAllWarehouseRecords\('OVERSEAS', getMerchantWarehouses\)/)
assert.match(view, /warehouseDisplay\(item\.operatingWarehouseId\)/)
assert.match(view, /\['OVERSEAS', 'OVERSEAS_PARTNER'\]/)
assert.match(view, /v-for="warehouse in \(props\.service === 'relabel' \? fbaWarehouses : operationWarehouses\)"/)
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.match(view, /v-model="form\.estimatedShipAt"[^>]+placeholder="选填"/)
assert.doesNotMatch(view, /!form\.estimatedShipAt && '预计发货时间'/)
assert.match(view, /estimatedShipAt: form\.estimatedShipAt/)
assert.match(view, /实际发货时间由仓库出库或 PDA 完成后回填/)
@@ -140,8 +184,8 @@ test('仓储转运详情按接口具名字段展示订单概览', () => {
})
test('PDA 打托完成后客户可从列表或详情确认并查看实测数据', () => {
assert.match(view, /row\.__status === 'WAIT_CUSTOMER_CONFIRMATION'/)
assert.match(view, /confirmTransferFromList\(row\['0'\]\)/)
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)
@@ -167,7 +211,7 @@ test('仓储转运详情自动加载 PDA 测量照片并释放 Blob URL', () =>
})
test('PDA 完成后主单显示入库完成并保留客户确认测量入口', () => {
assert.match(view, /row\.__status === 'WAIT_CUSTOMER_CONFIRMATION'.*确认测量数据/s)
assert.match(view, /item\.__status === 'WAIT_CUSTOMER_CONFIRMATION'.*确认测量/s)
assert.match(view, /selectedDetail\?\.status === 'WAIT_CUSTOMER_CONFIRMATION'.*确认测量数据/s)
assert.doesNotMatch(view, /确认实测并入库/)
})
@@ -179,7 +223,7 @@ test('仓储转运可在新建页直接提交,也可保存草稿后编辑提
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, /row\.__status === 'DRAFT'[^>]*[\s\S]*?@click="openEdit\(row\['0'\]\)"[^>]*>编辑/)
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\)/)
+1
View File
@@ -26,6 +26,7 @@ interface ClientRouteBase {
description?: string
permission?: string
workspaceTab?: boolean
workspaceTitle?: string
keepAlive?: boolean
}
}
+34 -44
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 customer-relevant business pages 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: ['我的消息'],
},
],
)
})
@@ -99,7 +86,6 @@ test('flattens visible leaves with unique paths', () => {
assert.deepEqual(paths, [
'/',
'/orders/first-leg',
'/first-leg/quick-order',
'/cross-border/procurement',
'/cross-border/domestic-trucking',
'/cross-border/pallet-shipping',
@@ -107,10 +93,6 @@ test('flattens visible leaves with unique paths', () => {
'/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 +109,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 +124,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 +184,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 +194,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 +214,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', () => {
@@ -354,7 +335,7 @@ test('keeps the dashboard route permission synchronized with navigation', () =>
assert.match(dashboardRoute, /permission: 'dashboard:view'/)
})
test('keeps only the unified my-orders placeholder cataloged while activating implemented business pages', () => {
test('keeps internal operational pages routable without exposing them in customer navigation', () => {
const routerSource = [
'first-leg',
'cross-border',
@@ -371,15 +352,24 @@ test('keeps only the unified my-orders placeholder cataloged while activating im
['overseas-warehouse/cargo-disposal', 'WarehouseServiceView.vue'],
['overseas-warehouse/return-relabel', 'ReturnOrdersView.vue'],
['overseas-warehouse/return-inventory', 'ReturnInventoryView.vue'],
['overseas-warehouse/return-cartons', 'ReturnInventoryView.vue'],
['orders/my', 'MyOrdersView.vue'],
['orders/inbound', 'InboundOrdersView.vue'],
['orders/delivery', 'DeliveryOrdersView.vue'],
]
const hiddenActivePaths = new Set([
'first-leg/quick-order',
'orders/inbound',
'orders/delivery',
'overseas-warehouse/return-inventory',
'overseas-warehouse/return-cartons',
])
for (const [path, view] of routes) {
if (path === 'first-leg/quick-order' || path.startsWith('cross-border/') || path.startsWith('overseas-warehouse/') || path === 'orders/inbound' || path === 'orders/delivery') {
if (path !== 'orders/my') {
assert.match(routerSource, new RegExp(`path: '${path}'[\\s\\S]*?\\./views/${view.replace('.', '\\.')}[\\s\\S]*?release: 'active'[\\s\\S]*?permission:`))
assert.equal(getNavigationLeaves(clientNavigation).some((item) => item.path === `/${path}`), true)
const visible = !hiddenActivePaths.has(path)
assert.equal(getNavigationLeaves(clientNavigation).some((item) => item.path === `/${path}`), visible)
continue
}
assert.match(routerSource, new RegExp(`path: '${path}'[\\s\\S]*?\\./views/${view.replace('.', '\\.')}[\\s\\S]*?release: 'prototype'[\\s\\S]*?permission:`))
+72 -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,70 @@ 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('cross-border-procurement'),
requireLeaf('cross-border-domestic-trucking'),
requireLeaf('cross-border-pallet-shipping'),
],
},
{
key: 'overseas-warehouse-center',
title: '海外仓',
icon: Warehouse,
children: [
requireLeaf('overseas-labeling'),
requireLeaf('overseas-storage-transfer'),
requireLeaf('overseas-cargo-disposal'),
requireLeaf('overseas-return-relabel', '退货换标'),
],
},
{
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 +112,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 +148,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; }