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/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..691c0f4 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') @@ -38,11 +39,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/first-leg/api/unifiedOrders.ts b/src/modules/first-leg/api/unifiedOrders.ts index 944a78f..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 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 2feaf38..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' } 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 22a670f..cd9d91a 100644 --- a/src/modules/first-leg/views/FirstLegOrdersView.test.mjs +++ b/src/modules/first-leg/views/FirstLegOrdersView.test.mjs @@ -3,6 +3,8 @@ 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') @@ -70,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', () => { @@ -89,11 +91,11 @@ 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, /[\s\S]{0,400}class="order-reference"/) 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, /\.prototype-order-table :deep\(\.el-table__body-wrapper td\.el-table__cell\) \{ height: 50px;/) + assert.match(tableSource, /height: var\(--client-table-row-height\)/) }) test('shows one unified progress line and C02 business summaries without detail history', () => { @@ -231,26 +233,26 @@ test('shows and searches the inbound code after a draft is submitted', () => { assert.match(source, /—<\/span>/) }) -test('keeps the seven-stage progress and business summary usable on mobile', () => { - assert.match(progressSource, /grid-template-columns: repeat\(7, 82px\)/) +test('keeps all progress stages and business summary usable on mobile', () => { + assert.match(progressSource, /grid-auto-columns: 82px/) assert.match(progressSource, /overflow-x: auto/) assert.match(source, /\.fulfillment-summary \{ grid-template-columns: 1fr; \}/) }) test('keeps the first viewport compact and moves create into the table toolbar', () => { - const tableToolbarSource = source.slice(source.indexOf('class="order-table-toolbar"'), source.indexOf('class="order-eligibility is-pending"')) - assert.match(source, /class="order-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>共 \{\{ filteredOrders\.length \}\} 条<\/span>/) - assert.match(source, /class="order-table-toolbar"[\s\S]*order:first-leg:create[\s\S]*创建订单[\s\S]*class="order-eligibility is-pending"/) + assert.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('')), /下载唛头|导出订单信息|创建订单/) assert.match(source, /client-main:has\(> \.first-leg-page:not\(\.is-create-route\)\) > \.client-page-heading > div\) \{ position: absolute; width: 1px; height: 1px;/) }) test('uses the prototype query grid and only shows the qualification notice before approval', () => { - assert.match(source, /\.first-leg-query-toolbar \{ display: block;/) - assert.match(source, /query-toolbar__filters\) \{ display: grid; min-width: 0; grid-template-columns: repeat\(4,/) - assert.match(source, /query-toolbar__actions\) \{ justify-content: flex-start; margin-top: 14px;/) + assert.match(querySource, /\.query-toolbar--grid \{ display: block;/) + assert.match(querySource, /query-toolbar__filters \{ display: grid; grid-template-columns: repeat\(4,/) + assert.match(querySource, /query-toolbar__actions \{ margin-top: 12px;/) assert.match(source, /v-if="companySubmissionBlocked && !supportingDataErrors\.some/) assert.doesNotMatch(source, /企业资料已审核,可正常提交订单/) assert.doesNotMatch(source, /order-eligibility\.is-approved/) @@ -399,6 +401,9 @@ test('loads only active server channels for the selected domestic warehouse', () assert.match(source, /availablePricingTaxModes/) assert.match(source, /:loading="channelLoading"/) assert.match(source, /:disabled="!form\.receivingWarehouseId \|\| channelLoading"/) + assert.match(source, /label="报价包税方式" prop="pricingTaxMode" required class="pricing-tax-mode-field"/) + assert.match(source, /\.compact-transport-grid > :deep\(\.pricing-tax-mode-field\) \{ display: none; \}/) + assert.doesNotMatch(source, /\.compact-transport-grid > :deep\(\.el-form-item:nth-child\(2\)\)/) assert.match(source, /当前收货仓暂无可用渠道/) assert.match(source, /渠道加载失败/) assert.match(source, /@click="retryPricingChannels"/) @@ -537,7 +542,9 @@ 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('')) const entries = templateSource.match(/确认计费重<\/el-button>/g) ?? [] - assert.equal(entries.length, 2) + 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/) }) @@ -595,7 +602,7 @@ test('opens customer customs documents only for server-confirmed loaded tax-refu assert.match(source, /order\.status !== 'LOADED'/) assert.match(source, /item\.orderId === order\.id && item\.taxMode === 'TAX_REFUND'/) assert.match(source, /getMerchantCustomsForOrder\(order\.id\)/) - assert.match(source, /v-if="eligibleCustomsDetail\(row as FirstLegOrder\)"[\s\S]*?报关资料/) + assert.match(source, /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, /(null) const batchImportError = ref('') const fixedOrderColumns: DataTableColumn[] = [ { key: 'selection', type: 'selection', width: 48, fixed: 'left', reserveSelection: true }, - { key: 'actions', label: '操作', width: 216, fixed: 'left', align: 'center', headerAlign: 'center', slotName: 'actions' }, - { key: 'order', label: '订单号', width: 190, fixed: 'left', align: 'left', headerAlign: 'left', showOverflowTooltip: true, slotName: 'order' }, + { 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' }, ] const selectableOrderColumns: DataTableColumn[] = [ - { key: 'inboundCode', label: '入仓码', width: 176, align: 'left', headerAlign: 'left', showOverflowTooltip: true, slotName: 'inboundCode' }, + { 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' }, { key: 'pickupMethod', label: '交货方式', width: 112, align: 'center', headerAlign: 'center', slotName: 'pickupMethod' }, @@ -122,7 +124,6 @@ const selectableOrderColumns: DataTableColumn[] = [ { key: 'receivingWarehouse', label: '国内收货仓', width: 150, align: 'left', headerAlign: 'left', showOverflowTooltip: true, slotName: 'receivingWarehouse' }, { key: 'destinationWarehouse', label: '目的仓', width: 120, align: 'left', headerAlign: 'left', showOverflowTooltip: true, slotName: 'destinationWarehouse' }, { key: 'destinationType', label: '目的仓类型', width: 112, align: 'center', headerAlign: 'center', slotName: 'destinationType' }, - { key: 'status', label: '当前订单状态', width: 126, align: 'center', headerAlign: 'center', slotName: 'status' }, { key: 'packageCount', label: '箱数', width: 82, align: 'center', headerAlign: 'center', slotName: 'packageCount' }, { key: 'expressCompany', label: '快递公司', width: 128, align: 'left', headerAlign: 'left', showOverflowTooltip: true, slotName: 'expressCompany' }, { key: 'trackingNumber', label: '快递单号', width: 158, align: 'left', headerAlign: 'left', showOverflowTooltip: true, slotName: 'trackingNumber' }, @@ -1157,6 +1158,23 @@ async function autofillWarehouseSelections() { markEditorClean() } +function getOrderRowActions(row: object): TableAction[] { + const order = row as FirstLegOrder + const actions: TableAction[] = [ + { key: 'detail', label: '详情', run: () => openDetail(order) }, + ] + if (canConfirmInboundFee.value && order.businessSummary.customerFeeConfirmationRequired) { + actions.push({ key: 'fee', label: '确认计费重', run: () => openFeeConfirmation(order) }) + } + if (canEditOrder.value && canClientEditFirstLegOrder(order.status)) { + actions.push({ key: 'edit', label: '编辑', loading: openingEditor.value, run: () => openEdit(order) }) + } + if (eligibleCustomsDetail(order)) { + actions.push({ key: 'customs', label: '报关资料', run: () => openCustomsDocuments(order) }) + } + return actions +} + async function openCreate() { if (availableWarehouses.value.length === 0) { ElMessage.warning(supportingDataLoading.value @@ -1860,14 +1878,14 @@ async function searchOrders(model: QueryModel) {