diff --git a/Jenkinsfile b/Jenkinsfile index 25b1c5c..3b003a9 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -1,6 +1,6 @@ pipeline { agent { - label "${env.JOB_BASE_NAME?.endsWith('-dev') ? 'java-dev' : 'java-test'}" + label "${env.JOB_BASE_NAME?.endsWith('-prod') ? 'java-builder' : (env.JOB_BASE_NAME?.endsWith('-dev') ? 'java-dev' : 'java-test')}" } environment { @@ -10,6 +10,7 @@ pipeline { REGISTRY_CREDENTIALS = 'gitea-registry-token' NODE_BASE_IMAGE = '172.31.198.7:10082/java-project/node:22-alpine' NGINX_BASE_IMAGE = '172.31.198.7:10082/java-project/nginx:1.27-alpine' + PRODUCTION_IMAGE_REPOSITORY = 'git.buggoglobal.com/html/client-web' } options { @@ -22,6 +23,17 @@ pipeline { steps { script { env.IS_DEVELOPMENT = env.JOB_BASE_NAME?.endsWith('-dev') ? 'true' : 'false' + env.IS_PRODUCTION = env.JOB_BASE_NAME?.endsWith('-prod') ? 'true' : 'false' + if (env.IS_PRODUCTION == 'true') { + sh ''' + set -eu + git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main + test "$(git rev-parse HEAD)" = "$(git rev-parse origin/main)" || { + echo "生产部署只允许 origin/main 当前提交;请先合并代码到 main" >&2 + exit 1 + } + ''' + } env.SOURCE_TAG = sh(script: 'git rev-parse --short=12 HEAD', returnStdout: true).trim() } sh 'git log -1 --oneline' @@ -29,7 +41,7 @@ pipeline { } stage('构建并部署测试前端') { - when { expression { env.IS_DEVELOPMENT != 'true' } } + when { expression { env.IS_DEVELOPMENT != 'true' && env.IS_PRODUCTION != 'true' } } steps { withCredentials([usernamePassword(credentialsId: env.REGISTRY_CREDENTIALS, usernameVariable: 'REGISTRY_USER', passwordVariable: 'REGISTRY_PASSWORD')]) { sh ''' @@ -50,6 +62,61 @@ pipeline { } } + stage('构建并发布生产镜像') { + when { expression { env.IS_PRODUCTION == 'true' } } + steps { + withCredentials([usernamePassword(credentialsId: env.REGISTRY_CREDENTIALS, usernameVariable: 'REGISTRY_USER', passwordVariable: 'REGISTRY_PASSWORD')]) { + sh ''' + set -eu + export DOCKER_CONFIG="${WORKSPACE}@tmp/docker-auth" + rm -rf "$DOCKER_CONFIG" + mkdir -p "$DOCKER_CONFIG" + cleanup_registry() { + docker logout git.buggoglobal.com >/dev/null 2>&1 || true + rm -rf "$DOCKER_CONFIG" + } + trap cleanup_registry EXIT HUP INT TERM + printf %s "$REGISTRY_PASSWORD" | docker login -u "$REGISTRY_USER" --password-stdin git.buggoglobal.com + image="$PRODUCTION_IMAGE_REPOSITORY:$SOURCE_TAG" + docker build \ + --build-arg NODE_IMAGE="$NODE_BASE_IMAGE" \ + --build-arg NGINX_IMAGE="$NGINX_BASE_IMAGE" \ + --build-arg VITE_ADMIN_WEB_ORIGIN=https://admin.buggoglobal.com \ + -t "$image" . + docker push "$image" + ''' + } + } + } + + 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 { + lock(resource: 'firstleg-production-client-web-deploy', inversePrecedence: true) { + withCredentials([ + usernamePassword(credentialsId: 'gitea-production-pull-token', usernameVariable: 'REGISTRY_USER', passwordVariable: 'REGISTRY_PASSWORD'), + sshUserPrivateKey(credentialsId: 'firstleg-production-ssh', keyFileVariable: 'TARGET_SSH_KEY', usernameVariable: 'TARGET_SSH_USER'), + string(credentialsId: 'firstleg-production-host', variable: 'TARGET_SSH_HOST'), + file(credentialsId: 'firstleg-production-known-hosts', variable: 'TARGET_SSH_KNOWN_HOSTS') + ]) { + withEnv([ + 'TARGET_DEPLOY_DIR=/data/firstleg/deploy/frontend', + 'TARGET_SSH_TARGET=' + env.TARGET_SSH_USER + '@' + env.TARGET_SSH_HOST + ]) { + sh "sh scripts/deploy-production.sh client-web '${env.PRODUCTION_IMAGE_REPOSITORY}' '${env.SOURCE_TAG}' docker-compose.production.yml" + } + } + } + } + } + stage('构建并部署开发前端') { when { expression { env.IS_DEVELOPMENT == 'true' } } steps { @@ -82,7 +149,7 @@ pipeline { } stage('部署检查') { - when { expression { env.IS_DEVELOPMENT != 'true' } } + when { expression { env.IS_DEVELOPMENT != 'true' && env.IS_PRODUCTION != 'true' } } steps { sh ''' set -eu @@ -96,7 +163,9 @@ pipeline { post { failure { sh ''' - if [ "${IS_DEVELOPMENT:-false}" = "true" ]; then + if [ "${IS_PRODUCTION:-false}" = "true" ]; then + echo "生产部署失败,请查看上方远程部署日志" + elif [ "${IS_DEVELOPMENT:-false}" = "true" ]; then docker logs --tail=100 client-web-development || true else docker compose -f docker-compose.test.yml logs --tail=100 || true diff --git a/docker-compose.production.yml b/docker-compose.production.yml new file mode 100644 index 0000000..167b779 --- /dev/null +++ b/docker-compose.production.yml @@ -0,0 +1,19 @@ +name: firstleg-prod-client-web + +services: + client-web: + image: ${IMAGE_REPOSITORY:?IMAGE_REPOSITORY is required}:${IMAGE_TAG:?IMAGE_TAG is required} + container_name: firstleg-prod-client-web + restart: unless-stopped + ports: + - "127.0.0.1:${HOST_PORT:-9002}:80" + environment: + API_UPSTREAM: "http://gateway:9000" + OVERSEAS_API_UPSTREAM: "http://gateway-overseas:9300" + networks: + - infra-network + +networks: + infra-network: + external: true + name: firstleg-prod-infra-network diff --git a/docker-compose.test.yml b/docker-compose.test.yml index c3d40ea..697c93c 100644 --- a/docker-compose.test.yml +++ b/docker-compose.test.yml @@ -12,6 +12,8 @@ services: restart: unless-stopped ports: - "${HOST_PORT:-9002}:80" + extra_hosts: + - "host.docker.internal:host-gateway" environment: - API_UPSTREAM: "http://test-api-cn.buggoglobal.com" - OVERSEAS_API_UPSTREAM: "http://test-api-global.buggoglobal.com" + API_UPSTREAM: "${API_UPSTREAM:-http://host.docker.internal:9000}" + OVERSEAS_API_UPSTREAM: "${OVERSEAS_API_UPSTREAM:-http://host.docker.internal:9300}" diff --git a/scripts/deploy-production.sh b/scripts/deploy-production.sh new file mode 100644 index 0000000..821a711 --- /dev/null +++ b/scripts/deploy-production.sh @@ -0,0 +1,93 @@ +#!/usr/bin/env sh +set -eu + +if [ "$#" -ne 4 ]; then + echo "用法: $0 <服务名> <镜像仓库> <镜像标签> " >&2 + exit 2 +fi + +SERVICE="$1" +IMAGE_REPOSITORY="$2" +IMAGE_TAG="$3" +COMPOSE_FILE="$4" +IMAGE="$IMAGE_REPOSITORY:$IMAGE_TAG" +REGISTRY_HOST="${IMAGE_REPOSITORY%%/*}" +SSH_TARGET="${TARGET_SSH_TARGET:?TARGET_SSH_TARGET 未设置}" +SSH_KEY="${TARGET_SSH_KEY:?TARGET_SSH_KEY 未设置}" +SSH_KNOWN_HOSTS="${TARGET_SSH_KNOWN_HOSTS:?TARGET_SSH_KNOWN_HOSTS 未设置}" +REMOTE_DIR="${TARGET_DEPLOY_DIR:?TARGET_DEPLOY_DIR 未设置}" +: "${REGISTRY_USER:?REGISTRY_USER 未设置}" +: "${REGISTRY_PASSWORD:?REGISTRY_PASSWORD 未设置}" + +run_ssh() { + ssh -i "$SSH_KEY" -o BatchMode=yes -o StrictHostKeyChecking=yes \ + -o UserKnownHostsFile="$SSH_KNOWN_HOSTS" -o ConnectTimeout=10 "$SSH_TARGET" "$@" +} + +run_scp() { + scp -i "$SSH_KEY" -o BatchMode=yes -o StrictHostKeyChecking=yes \ + -o UserKnownHostsFile="$SSH_KNOWN_HOSTS" -o ConnectTimeout=10 "$@" +} + +run_ssh install -d -m 750 "$REMOTE_DIR" +REMOTE_COMPOSE="$REMOTE_DIR/docker-compose.$SERVICE.yml" +run_scp "$COMPOSE_FILE" "$SSH_TARGET:$REMOTE_COMPOSE.tmp" +run_ssh sh -s -- "$REMOTE_COMPOSE.tmp" "$REMOTE_COMPOSE" <<'REMOTE_PREPARE' +set -eu +compose_tmp="$1" +compose_target="$2" +chmod 644 "$compose_tmp" +mv -f "$compose_tmp" "$compose_target" +REMOTE_PREPARE + +printf '%s' "$REGISTRY_PASSWORD" | run_ssh docker login -u "$REGISTRY_USER" --password-stdin "$REGISTRY_HOST" +trap 'run_ssh docker logout "$REGISTRY_HOST" >/dev/null 2>&1 || true' EXIT HUP INT TERM +run_ssh docker pull "$IMAGE" + +run_ssh sh -s -- "$REMOTE_DIR" "$SERVICE" "$IMAGE_REPOSITORY" "$IMAGE_TAG" <<'REMOTE_DEPLOY' +set -eu +remote_dir="$1" +service="$2" +image_repository="$3" +image_tag="$4" +compose_file="docker-compose.$service.yml" +project_name="firstleg-prod-$service" +cd "$remote_dir" +export IMAGE_REPOSITORY="$image_repository" IMAGE_TAG="$image_tag" + +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 ;; +esac +previous_tag="${previous_image#"$image_repository:"}" +if [ "$previous_tag" = "$image_tag" ]; then + echo "旧镜像与新镜像一致,无法回滚" >&2 + 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 +REMOTE_DEPLOY diff --git a/shared/src/index.ts b/shared/src/index.ts index 42c4559..a1a89da 100644 --- a/shared/src/index.ts +++ b/shared/src/index.ts @@ -91,6 +91,7 @@ export const FIRST_LEG_ORDER_STATUSES = [ { code: 'LOADING', label: '装柜中' }, { code: 'LOADED', label: '装柜完成' }, { code: 'DELIVERED', label: '派送完成' }, + { code: 'COMPLETED', label: '已完成' }, ] as const export type FirstLegOrderStatus = (typeof FIRST_LEG_ORDER_STATUSES)[number]['code'] diff --git a/src/components/common/DataTable.vue b/src/components/common/DataTable.vue index 6a6016f..438411b 100644 --- a/src/components/common/DataTable.vue +++ b/src/components/common/DataTable.vue @@ -44,6 +44,7 @@ export function createPaginationItems(page: number, pageCount: number): Paginati diff --git a/src/components/common/tableActions.test.mjs b/src/components/common/tableActions.test.mjs new file mode 100644 index 0000000..2324e95 --- /dev/null +++ b/src/components/common/tableActions.test.mjs @@ -0,0 +1,28 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { splitTableActions, runTableAction } from './tableActions.ts' + +test('shows up to three permitted actions and folds only when there are more', () => { + const actions = Array.from({ length: 5 }, (_, index) => ({ key: String(index), label: String(index), run() {} })) + for (const count of [0, 1, 2, 3]) { + const result = splitTableActions(actions.slice(0, count)) + assert.equal(result.visible.length, count) + assert.equal(result.overflow.length, 0) + } + for (const count of [4, 5]) { + const result = splitTableActions(actions.slice(0, count)) + assert.deepEqual(result.visible, actions.slice(0, 2)) + assert.deepEqual(result.overflow, actions.slice(2, count)) + } +}) + +test('disabled and loading actions cannot execute or disappear from the layout', () => { + let count = 0 + const action = { key: 'edit', label: '编辑', run() { count++ } } + runTableAction({ ...action, disabled: true }) + runTableAction({ ...action, loading: true }) + assert.equal(count, 0) + runTableAction(action) + assert.equal(count, 1) + assert.equal(splitTableActions([{ ...action, disabled: true }]).visible.length, 1) +}) diff --git a/src/components/common/tableActions.ts b/src/components/common/tableActions.ts new file mode 100644 index 0000000..68facc8 --- /dev/null +++ b/src/components/common/tableActions.ts @@ -0,0 +1,18 @@ +export interface TableAction { + key: string + label: string + disabled?: boolean + loading?: boolean + run: () => unknown +} + +// Callers supply only permitted actions; disabled actions retain their place. +export function splitTableActions(actions: readonly TableAction[]) { + return actions.length > 3 + ? { visible: actions.slice(0, 2), overflow: actions.slice(2) } + : { visible: actions, overflow: [] } +} + +export function runTableAction(action: TableAction) { + if (!action.disabled && !action.loading) return action.run() +} diff --git a/src/deployment/nginxConfig.test.mjs b/src/deployment/nginxConfig.test.mjs index f7632ce..2a1ced2 100644 --- a/src/deployment/nginxConfig.test.mjs +++ b/src/deployment/nginxConfig.test.mjs @@ -5,6 +5,7 @@ import test from 'node:test' const config = readFileSync(new URL('../../nginx/default.conf.template', import.meta.url), 'utf8') const testCompose = readFileSync(new URL('../../docker-compose.test.yml', import.meta.url), 'utf8') const developmentCompose = readFileSync(new URL('../../docker-compose.development.yml', import.meta.url), 'utf8') +const productionCompose = readFileSync(new URL('../../docker-compose.production.yml', import.meta.url), 'utf8') const jenkinsfile = readFileSync(new URL('../../Jenkinsfile', import.meta.url), 'utf8') const dockerfile = readFileSync(new URL('../../Dockerfile', import.meta.url), 'utf8') @@ -21,8 +22,10 @@ test('does not cache the SPA entry while keeping hashed assets immutable', () => }) test('routes domestic and overseas browser APIs to their test gateways without duplicate api prefixes', () => { - assert.match(testCompose, /API_UPSTREAM: "http:\/\/test-api-cn\.buggoglobal\.com"/) - assert.match(testCompose, /OVERSEAS_API_UPSTREAM: "http:\/\/test-api-global\.buggoglobal\.com"/) + assert.match(testCompose, /- "host\.docker\.internal:host-gateway"/) + assert.match(testCompose, /API_UPSTREAM: "\$\{API_UPSTREAM:-http:\/\/host\.docker\.internal:9000\}"/) + assert.match(testCompose, /OVERSEAS_API_UPSTREAM: "\$\{OVERSEAS_API_UPSTREAM:-http:\/\/host\.docker\.internal:9300\}"/) + assert.doesNotMatch(testCompose, /http:\/\/test-api-(?:cn|global)\.buggoglobal\.com/) assert.match(testCompose, /VITE_ADMIN_WEB_ORIGIN: "\$\{VITE_ADMIN_WEB_ORIGIN:-https:\/\/test-admin\.buggoglobal\.com\}"/) assert.match(config, /location \/overseas-api\/ \{[\s\S]*?rewrite \^\/overseas-api\/\(\.\*\)\$ \/api\/\$1 break;[\s\S]*?proxy_pass \$\{OVERSEAS_API_UPSTREAM\};/) assert.match(config, /proxy_set_header Host \$proxy_host;/) @@ -38,11 +41,16 @@ test('connects the development client proxy to both development gateways', () => assert.doesNotMatch(developmentCompose, /test-api|prod(?:uction)?/i) }) -test('runs each deployment on its target node without controller-local SSH files', () => { - assert.match(jenkinsfile, /env\.JOB_BASE_NAME\?\.endsWith\('-dev'\) \? 'java-dev' : 'java-test'/) +test('keeps local environments on target nodes and uses credential-managed SSH for production', () => { + assert.match(jenkinsfile, /env\.JOB_BASE_NAME\?\.endsWith\('-prod'\) \? 'java-builder'/) assert.match(jenkinsfile, /DEVELOPMENT_COMPOSE_PROJECT = 'client-web'/) assert.match(jenkinsfile, /docker compose -p "\$DEVELOPMENT_COMPOSE_PROJECT" -f "\$DEVELOPMENT_COMPOSE_FILE" up -d/) - assert.doesNotMatch(jenkinsfile, /\/var\/jenkins_home\/\.ssh|docker save|\bssh\b/) + assert.doesNotMatch(jenkinsfile, /\/var\/jenkins_home\/\.ssh|docker save/) + 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, /name: firstleg-prod-infra-network/) }) test('uses authenticated internal base images in CI', () => { diff --git a/src/modules/cross-border/components/BusinessOrdersView.vue b/src/modules/cross-border/components/BusinessOrdersView.vue index e7e23ce..9bba647 100644 --- a/src/modules/cross-border/components/BusinessOrdersView.vue +++ b/src/modules/cross-border/components/BusinessOrdersView.vue @@ -13,6 +13,7 @@ import { type TransportDetail, type TransportSummary, } from '../api/businessOrders' +import { CONTAINER_SPECIFICATIONS, DEFAULT_CONTAINER_TYPE } from '../containerSpecifications' const props = defineProps<{ kind: CrossBorderBusinessKind }>() const auth = useAuthStore() @@ -155,7 +156,7 @@ function emptyForm(): BusinessForm { return { purchaseCategory: '', productName: '', productSpecification: '', productUrl: '', purchaseQuantity: 1, transportMode: 'SHARED_TRUCK', palletRequired: 'NO', transportType: 'PORT_PICKUP', origin: '', originAddress: '', - destination: '', destinationAddress: '', containerType: '', palletCount: 1, packageCount: 0, + destination: '', destinationAddress: '', containerType: DEFAULT_CONTAINER_TYPE, palletCount: 1, packageCount: 0, totalWeightKg: 0, totalVolumeCbm: 0, currency: 'USD', contactName: '', contactInfo: '', requestedDate: '', receivingAddress: '', remark: '', } @@ -328,7 +329,7 @@ onMounted(() => void load()) - + diff --git a/src/modules/cross-border/containerSpecifications.ts b/src/modules/cross-border/containerSpecifications.ts new file mode 100644 index 0000000..d92fb62 --- /dev/null +++ b/src/modules/cross-border/containerSpecifications.ts @@ -0,0 +1,7 @@ +export const CONTAINER_SPECIFICATIONS = [ + { type: '40GP', recommendedMinCbm: 56, capacityCbm: 58, maxWeightKg: 19500 }, + { type: '40HQ', recommendedMinCbm: 65, capacityCbm: 68, maxWeightKg: 19500 }, + { type: '45HQ', recommendedMinCbm: 82, capacityCbm: 86, maxWeightKg: 19500 }, +] as const + +export const DEFAULT_CONTAINER_TYPE = '40HQ' as const diff --git a/src/modules/first-leg/api/unifiedOrders.ts b/src/modules/first-leg/api/unifiedOrders.ts index e32787f..8101851 100644 --- a/src/modules/first-leg/api/unifiedOrders.ts +++ b/src/modules/first-leg/api/unifiedOrders.ts @@ -9,6 +9,7 @@ export type ApiUnifiedOrderMainStatus = | 'LOADING' | 'LOADED' | 'DELIVERED' + | 'COMPLETED' export interface MerchantUnifiedMeasurementSummary { reviewStatus: string | null @@ -59,6 +60,7 @@ export interface MerchantUnifiedOrderRow { lxCode?: string | null mainStatus: ApiUnifiedOrderMainStatus nextAction: string | null + customerFeeConfirmationRequired: boolean internalOrderStatus: ApiFirstLegStatus | null customerId: number | string | null customerName: string diff --git a/src/modules/first-leg/components/OrderProgressPanel.vue b/src/modules/first-leg/components/OrderProgressPanel.vue index d56f20c..bcdc0cc 100644 --- a/src/modules/first-leg/components/OrderProgressPanel.vue +++ b/src/modules/first-leg/components/OrderProgressPanel.vue @@ -6,6 +6,7 @@ import { FIRST_LEG_PROGRESS_STAGES, getFirstLegProgressIndex, getFirstLegStatusLabel, + getFirstLegStatusTone, } from '@/modules/first-leg/utils/firstLegOrder' const props = defineProps<{ order: FirstLegOrder }>() @@ -25,7 +26,7 @@ function stageState(index: number) {

订单进度

国内入仓、组托、装柜及后续履约同步 - + {{ getFirstLegStatusLabel(order.status) }} @@ -49,7 +50,7 @@ function stageState(index: number) { .progress-heading h3 { margin: 0; color: var(--navy); } .progress-heading h3 { font-size: 15px; } .progress-heading span { color: var(--muted); font-size: 11px; } -.progress-stages { display: grid; grid-template-columns: repeat(7, minmax(76px, 1fr)); margin: 16px 0 0; padding: 0; overflow-x: auto; list-style: none; } +.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 li:first-child::before { display: none; } @@ -60,7 +61,7 @@ function stageState(index: number) { .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; } @media (max-width: 720px) { - .progress-stages { grid-template-columns: repeat(7, 82px); padding-bottom: 8px; } + .progress-stages { grid-auto-columns: 82px; padding-bottom: 8px; } .progress-heading { align-items: flex-start; } } diff --git a/src/modules/first-leg/store/firstLegOrders.test.mjs b/src/modules/first-leg/store/firstLegOrders.test.mjs index 9cdeba9..272f2ba 100644 --- a/src/modules/first-leg/store/firstLegOrders.test.mjs +++ b/src/modules/first-leg/store/firstLegOrders.test.mjs @@ -60,7 +60,7 @@ test('maps transport and status fields instead of treating container mode as tra assert.match(storeSource, /status === 'SUBMITTED'\) return 'PENDING_REVIEW'/) assert.match(storeSource, /status === 'REJECTED'\) return 'PENDING_REVIEW'/) assert.match(storeSource, /status === 'APPROVED' \|\| status === 'ACCEPTED' \|\| status === 'PROCESSING'\) return 'WAIT_MEASURE'/) - assert.match(storeSource, /status === 'COMPLETED'\) return 'LOADED'/) + assert.match(storeSource, /status === 'COMPLETED'\) return 'COMPLETED'/) assert.doesNotMatch(storeSource, /status === 'COMPLETED'\) return 'DELIVERED'/) }) diff --git a/src/modules/first-leg/store/firstLegOrders.ts b/src/modules/first-leg/store/firstLegOrders.ts index 75703f4..d202fc2 100644 --- a/src/modules/first-leg/store/firstLegOrders.ts +++ b/src/modules/first-leg/store/firstLegOrders.ts @@ -45,7 +45,7 @@ function clientStatus(status: ApiFirstLegStatus): FirstLegOrder['status'] { // 后端联调任务曾出现 REJECTED,但一期已确认订单不设驳回状态;兼容旧数据时仍按待审核展示。 if (status === 'REJECTED') return 'PENDING_REVIEW' if (status === 'APPROVED' || status === 'ACCEPTED' || status === 'PROCESSING') return 'WAIT_MEASURE' - if (status === 'COMPLETED') return 'LOADED' + if (status === 'COMPLETED') return 'COMPLETED' if (status === 'CANCELLED') return 'PENDING_REVIEW' return 'PENDING_REVIEW' } @@ -198,6 +198,7 @@ function timeline(detail: MerchantFirstLegOrderDetail): FirstLegOrderTimelineIte function businessSummary(summary?: MerchantUnifiedOrderRow): FirstLegOrder['businessSummary'] { return { + customerFeeConfirmationRequired: summary?.customerFeeConfirmationRequired ?? false, totalPackageCount: summary?.totalPackageCount ?? null, actualGrossWeightKg: summary?.actualGrossWeightKg ?? null, actualVolumeCbm: summary?.actualVolumeCbm ?? null, diff --git a/src/modules/first-leg/types/firstLegOrder.ts b/src/modules/first-leg/types/firstLegOrder.ts index d663a29..3d7abce 100644 --- a/src/modules/first-leg/types/firstLegOrder.ts +++ b/src/modules/first-leg/types/firstLegOrder.ts @@ -64,6 +64,7 @@ export interface FirstLegOrderTimelineItem { } export interface FirstLegOrderBusinessSummary { + customerFeeConfirmationRequired: boolean totalPackageCount: number | null actualGrossWeightKg: number | null actualVolumeCbm: number | null diff --git a/src/modules/first-leg/utils/firstLegOrder.test.mjs b/src/modules/first-leg/utils/firstLegOrder.test.mjs index ec768bb..c412805 100644 --- a/src/modules/first-leg/utils/firstLegOrder.test.mjs +++ b/src/modules/first-leg/utils/firstLegOrder.test.mjs @@ -8,6 +8,7 @@ import { FIRST_LEG_PROGRESS_STAGES, getFirstLegProgressIndex, getFirstLegStatusLabel, + getFirstLegStatusTone, getFirstLegTransportModeLabel, summarizeFirstLegCargo, validateFirstLegOrderDraft, @@ -67,7 +68,7 @@ test('uses the confirmed status and transport labels', () => { assert.equal(getFirstLegStatusLabel('DELIVERED'), '派送完成') assert.equal(getFirstLegTransportModeLabel('FCL'), '海运整箱 FCL') assert.deepEqual(FIRST_LEG_PROGRESS_STAGES.map((item) => item.code), [ - 'PENDING_REVIEW', 'WAIT_MEASURE', 'WAIT_PALLETIZE', 'WAIT_SPLIT', 'LOADING', 'LOADED', 'DELIVERED', + 'PENDING_REVIEW', 'WAIT_MEASURE', 'WAIT_PALLETIZE', 'WAIT_SPLIT', 'LOADING', 'LOADED', 'DELIVERED', 'COMPLETED', ]) assert.equal(getFirstLegProgressIndex('WAIT_PALLETIZE'), 2) }) @@ -197,3 +198,17 @@ test('counts one physical carton series for SKU lines in the same mixed-carton g second.packageCount = 9 assert.ok(validateFirstLegOrderDraft(draft).includes('同箱 SKU 的箱数、包装类型、箱规、重量和体积必须一致')) }) + +test('distinguishes first-leg draft, review, active handling and completion', () => { + assert.equal(getFirstLegStatusTone('DRAFT'), 'info') + assert.equal(getFirstLegStatusTone('PENDING_REVIEW'), 'warning') + for (const status of ['WAIT_MEASURE', 'WAIT_PALLETIZE', 'WAIT_SPLIT', 'LOADING']) { + assert.equal(getFirstLegStatusTone(status), 'primary') + } + assert.equal(getFirstLegStatusTone('LOADED'), 'success') + assert.equal(getFirstLegStatusTone('DELIVERED'), 'success') + assert.equal(getFirstLegStatusTone('COMPLETED'), 'success') + assert.equal(getFirstLegStatusLabel('COMPLETED'), '已完成') + assert.equal(getFirstLegStatusLabel('UNRECOGNIZED'), '未知状态') + assert.equal(getFirstLegStatusTone('UNRECOGNIZED'), 'info') +}) diff --git a/src/modules/first-leg/utils/firstLegOrder.ts b/src/modules/first-leg/utils/firstLegOrder.ts index 169679c..129667f 100644 --- a/src/modules/first-leg/utils/firstLegOrder.ts +++ b/src/modules/first-leg/utils/firstLegOrder.ts @@ -15,7 +15,7 @@ const transportModeLabels = new Map( ) export function getFirstLegStatusLabel(status: FirstLegOrderStatus) { - return statusLabels.get(status) ?? status + return statusLabels.get(status) ?? '未知状态' } export function getFirstLegTransportModeLabel(mode: FirstLegTransportMode | null) { @@ -23,9 +23,11 @@ export function getFirstLegTransportModeLabel(mode: FirstLegTransportMode | null return transportModeLabels.get(mode) ?? mode } -export function getFirstLegStatusTone(status: FirstLegOrderStatus): 'success' | 'warning' | 'info' | 'danger' { - if (status === 'DELIVERED') return 'success' - if (status === 'DRAFT' || status === 'PENDING_REVIEW') return 'warning' +export function getFirstLegStatusTone(status: FirstLegOrderStatus): 'success' | 'warning' | 'info' | 'primary' { + if (status === 'DELIVERED' || status === 'LOADED' || status === 'COMPLETED') return 'success' + if (status === 'DRAFT') return 'info' + if (status === 'PENDING_REVIEW') return 'warning' + if (['WAIT_MEASURE', 'WAIT_PALLETIZE', 'WAIT_SPLIT', 'LOADING'].includes(status)) return 'primary' return 'info' } diff --git a/src/modules/first-leg/views/FirstLegOrdersView.test.mjs b/src/modules/first-leg/views/FirstLegOrdersView.test.mjs index 232db1b..cd9d91a 100644 --- a/src/modules/first-leg/views/FirstLegOrdersView.test.mjs +++ b/src/modules/first-leg/views/FirstLegOrdersView.test.mjs @@ -3,11 +3,14 @@ import { readFileSync } from 'node:fs' 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 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') 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'/) @@ -69,7 +72,7 @@ test('renders dynamic cargo lines and a responsive editor dialog', () => { 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, /\.desktop-order-table \{ display: none; \}/) + assert.match(source, /\.prototype-order-table :deep\(> \.el-table\), \.prototype-order-table :deep\(> \.data-table__pagination\) \{ display: none; \}/) }) test('uses the full editor dialog width for cargo details and cost estimates', () => { @@ -88,15 +91,15 @@ test('uses the shared query toolbar and data table without metric cards', () => assert.match(source, /import QueryToolbar, \{ type QueryField, type QueryModel \}/) assert.match(source, /import DataTable, \{ type DataTableColumn \}/) assert.match(source, /const filters = ref/) - assert.match(source, //) + assert.match(source, //) assert.match(source, /async function searchOrders\(model: QueryModel\)/) assert.match(source, /await store\.load\(\{/) assert.match(source, / { +test('adds download actions to the order-list toolbar', () => { assert.match(source, /下载唛头/) assert.match(source, /导出订单信息/) assert.match(source, /function downloadShippingMarks\(\)/) @@ -187,7 +190,7 @@ test('keeps dense order identifiers on one line and aligns columns by content ty assert.doesNotMatch(source, /