diff --git a/AGENTS.md b/AGENTS.md index a679be38..385d7d2c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -41,6 +41,7 @@ | 登录、会话与双区域 Token | `modules/auth/`、`core/auth/` | 国内 `auth/`、`iam/`;海外请求客户端再读 `shared/api/AGENTS.md` | | 启动与懒加载资源恢复 | `main.ts`、`core/routing/navigationRecovery.ts` | 仅识别动态模块/Chunk 资源过期;30 秒内同目标最多自动恢复一次,存在未保存编辑时必须先确认 | | 工作台与指标 | `modules/dashboard/` | 指标所属模块与服务端,不复制领域契约 | +| FBA 无匹配价格、人工定价 | `modules/inbounds/fee/api.ts` 的 `createManualInboundQuote`、`types.ts`;页面消费沿 `InboundFeePanel.vue` 核对 | `POST /manual-quote` 提交计费重量、体积、单价、原因和 `Idempotency-Key`;Backend `PlatformFbaPricingController.manualQuote` → `FbaPricingService.manualQuote`,权限 `firstleg:inbound:confirm-send-fee`。仅 FBA、已有实际测量且当前价格未命中时允许,客户已确认费用禁止改价;`MANUAL_QUOTE` 快照允许价格记录与版本为空,不伪造价格簿关联。API 回归 `modules/inbounds/fee/api.test.ts`,不能以 API 存在代替页面接入完成 | | PDA版本、APK下载、WGT热更新发布与停发 | `modules/pda-releases/pages/PdaReleasesView.vue`、`modules/pda-releases/api/pdaReleases.ts`、`modules/pda-releases/routes.ts` | 系统菜单 `/system/pda-releases`;列表权限 `platform:pda-release:list`,上传/发布/停发权限 `platform:pda-release:manage`。国内/海外按 `domestic/overseas` 分开查询,上传只创建草稿,发布与停发需确认;管理请求使用国内 HTTP 的 `/platform/v1/files/pda-releases`,后端见 `file/AGENTS.md`,设备安装与重启查对应 PDA 的更新工具 | | 客户、企业审核、客户统计 | `modules/customers/` | `iam/`、`auth/`、`file/`、`firstleg/customerstatistics/`;公司联系电话只作企业联系信息,不修改管理员/成员登录手机号且不要求跨企业唯一。审核必填资质仅营业执照与统一社会信用代码;法人身份证选填,可随资质包提交,也可在已有营业执照后单独补传或替换 | | 供应商、国内整柜 | `modules/suppliers/`、`modules/domestic-containers/` | Backend `warehouse/` 及对应 OpenAPI | diff --git a/scripts/deploy-production.sh b/scripts/deploy-production.sh index e3499ef7..c27fb0b1 100644 --- a/scripts/deploy-production.sh +++ b/scripts/deploy-production.sh @@ -62,12 +62,12 @@ compose() { case "$service" in admin-web) base_port=9001 - nginx_config=/www/server/panel/vhost/nginx/admin.buggoglobal.com.conf + 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/app.buggoglobal.com.conf + nginx_config=/www/server/panel/vhost/nginx/00-blue-green-client-web.conf legacy_container=firstleg-prod-client-web ;; *) echo "不支持蓝绿部署的前端服务: $service" >&2; exit 2 ;; @@ -102,7 +102,7 @@ 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"; else target_port=$((base_port + 10000)); fi +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 diff --git a/src/deployment/blueGreenDeployment.test.mjs b/src/deployment/blueGreenDeployment.test.mjs index 9fa90096..ab0275c3 100644 --- a/src/deployment/blueGreenDeployment.test.mjs +++ b/src/deployment/blueGreenDeployment.test.mjs @@ -15,7 +15,11 @@ test('production frontend starts and validates the candidate before switching ng }) test('production frontend supports both slots and restores nginx on failure', () => { + 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 "\$nginx_config" "\$backup"/) assert.match(script, /cp -f "\$backup" "\$nginx_config"/) assert.match(script, /docker rm -f "\$candidate"/) diff --git a/src/modules/iam/pages/RolesView.test.ts b/src/modules/iam/pages/RolesView.test.ts index eb805e05..d1472728 100644 --- a/src/modules/iam/pages/RolesView.test.ts +++ b/src/modules/iam/pages/RolesView.test.ts @@ -104,6 +104,7 @@ describe('RolesView', () => { it('restores saved permission ids after the dialog tree has mounted', () => { expect(rolesSource).toContain('@opened="initializePermissionSelection"') expect(rolesSource).toContain('treeRef.value?.setCheckedKeys([...form.permissionIds])') + expect(rolesSource).not.toContain('{{ data.code }}') expect(rolesSource).not.toContain('nextTick(() =>') }) diff --git a/src/modules/iam/pages/RolesView.vue b/src/modules/iam/pages/RolesView.vue index 3300bfad..26efde9d 100644 --- a/src/modules/iam/pages/RolesView.vue +++ b/src/modules/iam/pages/RolesView.vue @@ -266,7 +266,7 @@ onMounted(loadRoles) - + @@ -279,7 +279,6 @@ onMounted(loadRoles) .role-editor-layout > .role-editor-permissions.density-form-section { min-width: 0; padding: 0 0 0 var(--admin-space-5); border-left: 1px solid var(--cc-border); } .permission-tree { max-height: 380px; overflow-x: hidden; overflow-y: auto; } .permission-node span { min-width: 0; } -.permission-node code { max-width: 58%; white-space: normal; text-align: right; } .permission-node.is-navigation-group span { font-weight: 650; } :global(.role-editor-dialog) { max-height: min(760px, calc(100vh - 32px)); display: flex; flex-direction: column; } :global(.role-editor-dialog .el-dialog__body) { min-height: 0; overflow-y: auto; } diff --git a/src/modules/inbounds/fee/InboundFeePanel.test.ts b/src/modules/inbounds/fee/InboundFeePanel.test.ts index 3930dfb7..c53e01cc 100644 --- a/src/modules/inbounds/fee/InboundFeePanel.test.ts +++ b/src/modules/inbounds/fee/InboundFeePanel.test.ts @@ -381,4 +381,5 @@ describe('InboundFeePanel', () => { expect(document.body.textContent).toContain('系统已刷新最新快照') expect(document.body.textContent).toContain('当前输入仍保留') }) + }) diff --git a/src/modules/inbounds/fee/InboundFeePanel.vue b/src/modules/inbounds/fee/InboundFeePanel.vue index ed9609f0..50f7ce59 100644 --- a/src/modules/inbounds/fee/InboundFeePanel.vue +++ b/src/modules/inbounds/fee/InboundFeePanel.vue @@ -47,6 +47,7 @@ const loadError = ref(null) const adjustmentError = ref(null) const conflictRefreshed = ref(false) const snapshot = ref(null) +const hasLoadedOnce = ref(false) const adjustOpen = ref(false) const trialOpen = ref(false) const trialLoading = ref(false) @@ -183,12 +184,25 @@ async function loadFee(options: { preserveAdjustment?: boolean } = {}) { if (!options.preserveAdjustment && adjustOpen.value) fillAdjustmentForm(result) } catch (error) { if (sequence !== loadSequence) return - snapshot.value = null - emit('loaded', null) - if (error instanceof ApiResponseError && error.code === 'A0404') notFound.value = true - else loadError.value = getApiErrorDetails(error, '费用快照加载失败,请稍后重试') + const hadSnapshot = Boolean(snapshot.value) + if (error instanceof ApiResponseError && error.code === 'A0404') { + notFound.value = !hadSnapshot + if (!hadSnapshot) { + snapshot.value = null + emit('loaded', null) + } + } else { + loadError.value = getApiErrorDetails(error, '费用快照加载失败,请稍后重试') + if (!hadSnapshot) { + snapshot.value = null + emit('loaded', null) + } + } } finally { - if (sequence === loadSequence) loading.value = false + if (sequence === loadSequence) { + loading.value = false + hasLoadedOnce.value = true + } } } @@ -353,6 +367,7 @@ watch( () => { adjustOpen.value = false snapshot.value = null + hasLoadedOnce.value = false void loadFee() }, { immediate: true }, @@ -435,11 +450,18 @@ defineExpose({ reload: loadFee })

需要权限:firstleg:fba-pricing:view

-
+
正在加载费用快照
+ + +

{{ loadError.message }}

+ +
+

{{ loadError.message }}

@@ -597,6 +619,7 @@ defineExpose({ reload: loadFee }) .fee-heading-actions .el-button { margin-left: 0; } .fee-refresh-button .is-spinning { animation: fee-refresh-spin 0.8s linear infinite; } .fee-loading { padding: 20px 0; } +.fee-refresh-error { margin-bottom: 10px; } .inbound-fee-panel :deep(.el-alert p) { margin: 5px 0 0; line-height: 1.55; } .inbound-fee-panel :deep(.el-alert .el-button) { margin-top: 8px; } .empty-note { margin: 0; color: var(--cc-muted); font-size: 12px; } diff --git a/src/modules/inbounds/fee/api.test.ts b/src/modules/inbounds/fee/api.test.ts index 95f7aab9..0725148d 100644 --- a/src/modules/inbounds/fee/api.test.ts +++ b/src/modules/inbounds/fee/api.test.ts @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { ApiResponseError, http } from '@/shared/api/http' import { adjustPublishedInboundFee, + createManualInboundQuote, confirmInboundFee, createFeeActionIdempotencyKey, createFeeAdjustmentIdempotencyKey, @@ -69,6 +70,17 @@ describe('inbound fee API', () => { }) }) + it('creates a manual FBA quote through the dedicated fallback endpoint', async () => { + vi.mocked(http.post).mockResolvedValue({ code: '200', message: 'success', data: { ...snapshot, rateCode: 'MANUAL_QUOTE' } }) + const payload = { selectedWeightKg: 62.5, selectedVolumeCbm: 0.384, unitPrice: 12.2, reason: 'FBA价格未维护' } + + await expect(createManualInboundQuote('10001', '88', payload, 'fee-manual-1')) + .resolves.toMatchObject({ rateCode: 'MANUAL_QUOTE' }) + expect(http.post).toHaveBeenCalledWith('/platform/v1/inbounds/88/fees/manual-quote', payload, { + params: { tenantId: '10001' }, headers: { 'Idempotency-Key': 'fee-manual-1' }, + }) + }) + it('preserves backend conflict metadata for stale-version recovery', async () => { vi.mocked(http.put).mockResolvedValue({ code: 'A0409', message: '费用已被其他操作员修改,请刷新后重试', data: null, diff --git a/src/modules/inbounds/fee/api.ts b/src/modules/inbounds/fee/api.ts index 27993cfb..cebe6bf9 100644 --- a/src/modules/inbounds/fee/api.ts +++ b/src/modules/inbounds/fee/api.ts @@ -23,6 +23,16 @@ export async function calculateInboundFee(tenantId: string, inboundId: string, p return unwrapApiResponse(response) } +export async function createManualInboundQuote(tenantId: string, inboundId: string, payload: { + selectedWeightKg: number; selectedVolumeCbm: number; unitPrice: number; reason: string +}, idempotencyKey: string) { + const response = await http.post>( + `${feeBasePath(inboundId)}/manual-quote`, payload, + { params: { tenantId }, headers: { 'Idempotency-Key': idempotencyKey } }, + ) + return unwrapApiResponse(response) +} + async function transitionInboundFee( tenantId: string, inboundId: string, @@ -63,7 +73,7 @@ export function createFeeAdjustmentIdempotencyKey() { return createFeeActionIdempotencyKey('adjust') } -export function createFeeActionIdempotencyKey(action: 'adjust' | 'calculate' | 'confirm' | 'send') { +export function createFeeActionIdempotencyKey(action: 'adjust' | 'calculate' | 'manual' | 'confirm' | 'send') { const uuid = globalThis.crypto?.randomUUID?.() if (uuid) return `fee-${action}-${uuid}` return `fee-${action}-${Date.now()}-${Math.random().toString(36).slice(2, 12)}` diff --git a/src/modules/inbounds/fee/types.ts b/src/modules/inbounds/fee/types.ts index 1e3b814b..f658cd07 100644 --- a/src/modules/inbounds/fee/types.ts +++ b/src/modules/inbounds/fee/types.ts @@ -13,9 +13,9 @@ export interface InboundFeeSnapshot { chargeRuleId: string ruleCode: string chargeRuleVersion: number - carrierRateId: string + carrierRateId: string | null rateCode: string - carrierRateVersion: number + carrierRateVersion: number | null customerId: string customerName: string originWarehouseId: string @@ -45,7 +45,7 @@ export interface InboundFeeSnapshot { additionalAmount: number totalAmount: number currency: string - rateEffectiveFrom: string + rateEffectiveFrom: string | null rateEffectiveTo: string | null calculatedAt: string confirmedAt: string | null diff --git a/src/modules/inbounds/index.ts b/src/modules/inbounds/index.ts index 1aadb9cf..8772c58b 100644 --- a/src/modules/inbounds/index.ts +++ b/src/modules/inbounds/index.ts @@ -8,6 +8,7 @@ export { default as InboundFeePanel } from './fee/InboundFeePanel.vue' export { adjustPublishedInboundFee, calculateInboundFee, + createManualInboundQuote, confirmInboundFee, createFeeAdjustmentIdempotencyKey, createFeeActionIdempotencyKey, diff --git a/src/modules/overseas-warehouse/pages/LastMileOrdersView.behavior.test.ts b/src/modules/overseas-warehouse/pages/LastMileOrdersView.behavior.test.ts index 8181f3f3..ec295788 100644 --- a/src/modules/overseas-warehouse/pages/LastMileOrdersView.behavior.test.ts +++ b/src/modules/overseas-warehouse/pages/LastMileOrdersView.behavior.test.ts @@ -176,9 +176,9 @@ describe('LastMileOrdersView pending order and container conversion', () => { it.each([ [[], ['查看'], false], - [['inbound-order:create'], ['查看', '入库计划'], false], - [['last-mile-order:edit'], ['查看', '暂扣订单', '人工修改节点'], false], - [['inbound-order:create', 'last-mile-order:edit'], ['查看', '入库计划', '暂扣订单', ''], true], + [['inbound-order:create'], ['查看', '生成入库计划'], false], + [['last-mile-order:edit'], ['查看', '人工修改节点'], false], + [['inbound-order:create', 'last-mile-order:edit'], ['查看', '生成入库计划', '人工修改节点'], false], ])('only collapses row actions above three for %j', async (allowed, labels, overflow) => { canAccess.mockImplementation(code => allowed.some(action => code === 'platform:overseas-warehouse:' + action)) vi.mocked(listLastMileOrders).mockResolvedValue({ records: [{ id: 'order-1', customerId, orderNo: 'LM-1', currentStatus: 'PENDING_ASSIGNMENT', orderType: 'FIRST_LEG' }], page: 1, size: 10, total: 1, pages: 1 }) @@ -213,9 +213,9 @@ describe('LastMileOrdersView pending order and container conversion', () => { }) it.each([ - ['PENDING_ASSIGNMENT', true], - ['INBOUND_COMPLETED', false], - ])('only enables a new hold after inbound completion for %s', async (currentStatus, disabled) => { + ['PENDING_ASSIGNMENT', false], + ['INBOUND_COMPLETED', true], + ])('only shows a new hold after inbound completion for %s', async (currentStatus, visible) => { canAccess.mockImplementation(code => code === 'platform:overseas-warehouse:last-mile-order:edit') vi.mocked(listLastMileOrders).mockResolvedValue({ records: [{ id: 'order-1', customerId, orderNo: 'LM-1', currentStatus, orderType: 'FIRST_LEG', detained: false, @@ -223,8 +223,38 @@ describe('LastMileOrdersView pending order and container conversion', () => { const wrapper = await mountView() const holdButton = wrapper.get('.el-table__body-wrapper .row-actions').findAll('button') - .find(item => item.text().includes('暂扣订单'))! - expect((holdButton.element as HTMLButtonElement).disabled).toBe(disabled) + .find(item => item.text().includes('暂扣订单')) + expect(Boolean(holdButton)).toBe(visible) + if (holdButton) expect((holdButton.element as HTMLButtonElement).disabled).toBe(false) + }) + + it('keeps release hold available for an already detained order', async () => { + canAccess.mockImplementation(code => code === 'platform:overseas-warehouse:last-mile-order:edit') + vi.mocked(listLastMileOrders).mockResolvedValue({ records: [{ + id: 'order-held', customerId, orderNo: 'LM-HELD', currentStatus: 'READY_FOR_DISPATCH', orderType: 'FIRST_LEG', detained: true, + }], page: 1, size: 10, total: 1, pages: 1 }) + + const wrapper = await mountView() + const releaseButton = wrapper.get('.el-table__body-wrapper .row-actions').findAll('button') + .find(item => item.text().includes('解除暂扣')) + expect(releaseButton).toBeTruthy() + expect((releaseButton!.element as HTMLButtonElement).disabled).toBe(false) + }) + + it('removes generation and hold actions after an inbound plan has been submitted', async () => { + canAccess.mockReturnValue(true) + vi.mocked(listLastMileOrders).mockResolvedValue({ records: [{ + id: 'order-planned', customerId, orderNo: 'LM-PLANNED', currentStatus: 'INBOUND_PLANNED', orderType: 'FIRST_LEG', detained: false, + }], page: 1, size: 10, total: 1, pages: 1 }) + + const wrapper = await mountView() + const actions = wrapper.get('.el-table__body-wrapper .row-actions') + expect(actions.text()).toContain('查看') + expect(actions.text()).not.toContain('生成入库计划') + expect(actions.text()).not.toContain('暂扣订单') + + await selectRows(wrapper, [(wrapper.findComponent(DataTable).props('data') as object[])[0]]) + expect((button(wrapper, '生成入库计划')!.element as HTMLButtonElement).disabled).toBe(true) }) it('keeps the original pending order table and does not render a view switch or container table', async () => { @@ -342,7 +372,7 @@ describe('LastMileOrdersView pending order and container conversion', () => { expect(payload).not.toHaveProperty('plannedWarehouseName') }) - it('allows a historically held order to generate its inbound plan', async () => { + it('blocks a held order from generating its inbound plan', async () => { const wrapper = await openPending() await selectRows(wrapper, [selectedRow({ detained: true, @@ -352,10 +382,10 @@ describe('LastMileOrdersView pending order and container conversion', () => { await button(wrapper, '生成入库计划')!.trigger('click') await flushPromises() - expect(getContainerInboundPlanPreview).toHaveBeenCalledWith(bookingId) + expect(getContainerInboundPlanPreview).not.toHaveBeenCalled() const confirm = [...document.body.querySelectorAll('button')] .find((item) => item.textContent?.includes('保存并生成入库单')) as HTMLButtonElement - expect(confirm.disabled).toBe(false) + expect(confirm).toBeUndefined() }) it('blocks manual plan creation when the cabinet has no frozen expected arrival time', async () => { diff --git a/src/modules/overseas-warehouse/pages/LastMileOrdersView.vue b/src/modules/overseas-warehouse/pages/LastMileOrdersView.vue index 194fffbf..d231dcea 100644 --- a/src/modules/overseas-warehouse/pages/LastMileOrdersView.vue +++ b/src/modules/overseas-warehouse/pages/LastMileOrdersView.vue @@ -445,6 +445,7 @@ function isRejectedReturn(source: object) { return row.orderTypeCode === 'RETURN_RELABEL' && row.currentStatusCode === 'REJECTED' } function hasSelectedRejectedReturn() { return selectedRows.value.some(isRejectedReturn) } +function hasSelectedInboundPlan() { return selectedRows.value.some(hasInboundPlan) } async function loadTransferWarehouseOptions() { transferWarehouseLoading.value = true transferWarehouseError.value = null @@ -500,6 +501,7 @@ function openInboundPlan(source?: object) { const target = source ? source as LastMileOrderListItem : (selectedRows.value.length === 1 ? selectedRows.value[0] : null) if (!target) { ElMessage.warning('生成入库计划时请选择 1 条尾程订单'); return } if (isRejectedReturn(target)) { ElMessage.warning('已驳回的退货预报只能查看详情'); return } + if (hasInboundPlan(target)) { ElMessage.warning('当前订单已提交入库计划,不能重复生成'); return } if (target.detained) { ElMessage.warning('订单处于暂扣状态,解除暂扣后才能生成入库计划'); return } actionTarget.value = target const volumeMinimum = typeof target.volume === 'number' && target.volume > 0 ? Math.ceil(target.volume / 2) : 1 @@ -811,13 +813,27 @@ function hasRelabelActions(row: LastMileOrderListItem) { && Boolean(row.relabelOrderNo) && (canConfirmRelabel(row) || canReworkRelabel(row)) } +const inboundPlanCreatedStatuses = new Set([ + 'INBOUND_PLANNED', + 'PENDING_RECEIVING', + 'WAIT_PDA_RECEIVING', + 'INBOUND_MEASURING', + 'WAIT_CUSTOMER_CONFIRMATION', + 'PARTIALLY_RECEIVED', + 'INBOUND_COMPLETED', +]) +function hasInboundPlan(row: LastMileOrderListItem) { + return inboundPlanCreatedStatuses.has(row.currentStatusCode) + || Boolean(row.inboundOrderNo && row.inboundOrderNo !== '—') +} function lastMileRowActions(row: LastMileOrderListItem): RowActionItem[] { + const inboundPlanCreated = hasInboundPlan(row) return [ { key: 'detail', label: '查看', priority: 500 }, { key: 'review-return', label: '审核', priority: 460, tone: 'warning', visible: access.can(permission.returnOrderReview) && row.orderTypeCode === 'RETURN_RELABEL' && row.currentStatusCode === 'PENDING_REVIEW' }, { key: 'review', label: '审核', priority: 450, tone: 'warning', visible: access.can(permission.warehouseTransferApprove) && row.orderTypeCode === 'WAREHOUSE_TRANSFER' && row.currentStatusCode === 'PENDING_REVIEW' }, - { key: 'inbound-plan', label: '入库计划', priority: 400, visible: access.can(permission.inboundOrderCreate) }, - { key: 'hold', label: row.detained ? '解除暂扣' : '暂扣订单', priority: 300, tone: 'warning', visible: access.can(permission.lastMileOrderTransfer), disabled: !row.detained && row.currentStatusCode !== 'INBOUND_COMPLETED' }, + { key: 'inbound-plan', label: '生成入库计划', priority: 400, visible: access.can(permission.inboundOrderCreate) && !inboundPlanCreated }, + { key: 'hold', label: row.detained ? '解除暂扣' : '暂扣订单', priority: 300, tone: 'warning', visible: access.can(permission.lastMileOrderTransfer) && (row.detained || row.currentStatusCode === 'INBOUND_COMPLETED') }, { key: 'manual-nodes', label: '人工修改节点', priority: 200, visible: access.can(permission.lastMileOrderTransfer) }, { key: 'confirm-relabel', label: '换标完成确认', priority: 150, visible: access.can(permission.relabelOrderStatus) && Boolean(row.relabelOrderNo) && canConfirmRelabel(row) }, { key: 'rework-relabel', label: '重新换标', priority: 100, tone: 'danger', divided: true, visible: access.can(permission.relabelOrderStatus) && Boolean(row.relabelOrderNo) && canReworkRelabel(row) }, @@ -925,7 +941,7 @@ onMounted(() => { void load() })
- + diff --git a/src/modules/pda-releases/pages/PdaReleasesView.test.ts b/src/modules/pda-releases/pages/PdaReleasesView.test.ts index 25766476..43cd6411 100644 --- a/src/modules/pda-releases/pages/PdaReleasesView.test.ts +++ b/src/modules/pda-releases/pages/PdaReleasesView.test.ts @@ -24,4 +24,12 @@ describe('PDA release workbench', () => { expect(page).toContain('row.current') expect(page).toContain('ElMessageBox.confirm') }) + + it('uses the standard dense list workspace without repeating the route title', () => { + expect(page).toContain('class="surface-panel table-panel pda-release-panel"') + expect(page).toContain('