diff --git a/AGENTS.md b/AGENTS.md index da6bea4..71eb0a5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 | diff --git a/Jenkinsfile b/Jenkinsfile index 3b003a9..88d2dda 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -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} +> 状态:${status} +${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}" + } + } + } } } } diff --git a/docker-compose.production.yml b/docker-compose.production.yml index 167b779..d721c62 100644 --- a/docker-compose.production.yml +++ b/docker-compose.production.yml @@ -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 diff --git a/public/customer-recharge-qr.png b/public/customer-recharge-qr.png new file mode 100644 index 0000000..8bcdde6 Binary files /dev/null and b/public/customer-recharge-qr.png differ diff --git a/scripts/deploy-production.sh b/scripts/deploy-production.sh index 821a711..c27fb0b 100644 --- a/scripts/deploy-production.sh +++ b/scripts/deploy-production.sh @@ -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 diff --git a/src/components.d.ts b/src/components.d.ts index f15b3f0..bcd7883 100644 --- a/src/components.d.ts +++ b/src/components.d.ts @@ -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'] diff --git a/src/components/ClientSidebarMenu.vue b/src/components/ClientSidebarMenu.vue index 9fb3e6e..4c66e7f 100644 --- a/src/components/ClientSidebarMenu.vue +++ b/src/components/ClientSidebarMenu.vue @@ -22,8 +22,6 @@ const activeParentKeys = computed(() => new Set(getNavigationParentKeys(visibleN const openedKeys = ref>(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) diff --git a/src/components/common/DataTable.test.mjs b/src/components/common/DataTable.test.mjs new file mode 100644 index 0000000..4466aa9 --- /dev/null +++ b/src/components/common/DataTable.test.mjs @@ -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; \}/) +}) diff --git a/src/components/common/DataTable.vue b/src/components/common/DataTable.vue index 438411b..3ef382c 100644 --- a/src/components/common/DataTable.vue +++ b/src/components/common/DataTable.vue @@ -143,8 +143,8 @@ function setPage(value: number) { diff --git a/src/modules/first-leg/views/FirstLegOrdersView.test.mjs b/src/modules/first-leg/views/FirstLegOrdersView.test.mjs index 46e4b2d..56962c2 100644 --- a/src/modules/first-leg/views/FirstLegOrdersView.test.mjs +++ b/src/modules/first-leg/views/FirstLegOrdersView.test.mjs @@ -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, //) 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, /]*:page="isCreatePage"/) assert.match(editorShellSource, /
/) @@ -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, / { }) 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, /