diff --git a/AGENTS.md b/AGENTS.md index 47159940..8bb0adf6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -55,7 +55,7 @@ | 入库单扣货、解除、凭证与历史 | `modules/inbounds/pages/InboundOrdersView.vue`、详情工作区、`modules/inbounds/core/api.ts` | 列表展示服务端 `holdFlag/currentHoldReason` 并支持筛选;详情只按 `canHold/canRelease/actionBlockReason` 开放操作。扣货与解除提交当前 `version`、原因和独立幂等键,可选凭证先按明确客户与入库单上传;历史文件只调用专用下载入口,不把上传成功但未提交命令的文件冒充扣货证据 | | 客户专属价格簿、柜级阶梯运费与受控调价 | 列表/上传 `modules/fba-pricing/carrier-rates/pages/CarrierRatesView.vue`,版本详情/仓库映射/确认 `modules/fba-pricing/carrier-rates/pages/CarrierRateDetailView.vue`,路由 `modules/fba-pricing/routes.ts`;`modules/inbounds/fee/`、`modules/unified-orders/components/InboundFeeDetailManager.vue` | 列表与上传后按 `versionId` 进入 `fba-carrier-rate-detail` 独立详情页,价格行只读;回归入口 `modules/fba-pricing/carrier-rates/pages/CarrierRatesView.test.ts`。价格簿页面显式选择公共报价或具体客户;列表、上传、确认只作用于该 `customerId`,不得混合版本。计价优先消费客户专属 ACTIVE 版本,无专属版本才回退公共 ACTIVE;柜级阶梯金额和各客户分摊只展示服务端结果。调整计费量只能在服务端返回的上下 10% 范围内并提交当前费用版本、原因与二次确认;私人仓尾程派送费使用稳定 `PRIVATE_LAST_MILE_DELIVERY` 代码且只对 `PRIVATE` 目的地开放;一件代发须在预装确认前完成试算,计价后保持服务端 `WAIT_PALLETIZE` 状态 | | 财务、客户资金账户与柜级公共费用分摊 | `modules/finance/`,继续读该目录 `AGENTS.md`;客户账户 `pages/FinanceCustomerAccountsView.vue`,柜费 `modules/loading-lists/components/ContainerFeesPanel.vue`、`api/containerFees.ts` | `finance/`、`firstleg/`、`file/`、`docs/财务管理/`;实际、冻结、可用余额、授信与流水只消费 Finance 权威结果,充值审核和手工充扣携带版本与幂等键。跨客户公共柜费只按服务端柜内客户/订单事实分摊,金额必须守恒,创建与审核、驳回、调整、作废均整组原子处理 | -| 货柜双工作台、排柜、预装/装柜清单、真实柜号后补与整柜报关 | 国内 `modules/container-loading/pages/DomesticContainersView.vue`、海外 `OverseasContainersView.vue`,共享详情实现 `ContainerDetailView.vue`;预装详情 `modules/loading-lists/pages/PreloadPlanDetailView.vue`,资料/PDA 证据 `components/ContainerOverviewPanel.vue`,报关 `components/ContainerCustomsPanel.vue` | 国内“送柜报关”只消费 `/containers/domestic` 与服务端 `allowedActions`,承载模拟柜、订舱关联、智能装柜;模拟柜创建选择与智能装柜一致的 9 类计划渠道,未关联订舱前实际渠道为空,订舱关联候选按计划渠道组匹配、装柜清单页签、出口报关和国内出仓;创建柜次时自动生成唯一预装计划,预装详情先调用 `/loading-plans/{planId}/lifecycle`,完成后深链进入国内货柜详情的装柜清单页签,不再注册独立正式清单导航和路由。订舱关联允许真实集装箱号为空,但确认预装和创建 PDA 任务前必须补齐;历史已建但缺号的任务仍可在预装详情首次补录,保存后恢复 PDA 可进入状态;全买单柜继续使用 `complete-all-buy`。海外“清关提柜”只消费 `/containers/overseas`,承载船节点、目的港清关、DO/PU、码头车队和还空;原预约/实际提柜展示位改为整柜预计/实际到仓,预计到仓在列表内人工快捷维护并经海外接口同步整柜尾程订单,实际到仓只读海外 PDA 首次开始收货时间。还空截止事实由服务端基于卸柜完成时间加 72 小时计算,前端不重算。两端统一使用 `containerBookingId`,列表范围由服务端 `scope=ACTIVE/HISTORY` 分页,禁止浏览器筛选或本地推导写资格。后端为 `LoadingListService`、`firstleg/customs/container/`、`firstleg/terminaldrayage/` 和 `lastleg-service/`,文件归 `file/` | +| 货柜双工作台、排柜、预装/装柜清单、真实柜号后补与整柜报关 | 国内 `modules/container-loading/pages/DomesticContainersView.vue`、海外 `OverseasContainersView.vue`,共享详情实现 `ContainerDetailView.vue`;预装详情 `modules/loading-lists/pages/PreloadPlanDetailView.vue`,资料/PDA 证据 `components/ContainerOverviewPanel.vue`,报关 `components/ContainerCustomsPanel.vue` | 国内“送柜报关”只消费 `/containers/domestic` 与服务端 `allowedActions`,承载模拟柜、订舱关联、智能装柜;模拟柜创建选择与智能装柜一致的 9 类计划渠道,未关联订舱前实际渠道为空,订舱关联候选按计划渠道组匹配并同时展示订舱号与提单号、装柜清单页签、出口报关和国内出仓;创建柜次时自动生成唯一预装计划,预装详情先调用 `/loading-plans/{planId}/lifecycle`,完成后深链进入国内货柜详情的装柜清单页签,不再注册独立正式清单导航和路由。订舱关联允许真实集装箱号为空,但确认预装和创建 PDA 任务前必须补齐;历史已建但缺号的任务仍可在预装详情首次补录,保存后恢复 PDA 可进入状态;全买单柜继续使用 `complete-all-buy`。海外“清关提柜”只消费 `/containers/overseas`,承载船节点、目的港清关、DO/PU、码头车队和还空;原预约/实际提柜展示位改为整柜预计/实际到仓,预计到仓在列表内人工快捷维护并经海外接口同步整柜尾程订单,实际到仓只读海外 PDA 首次开始收货时间。还空截止事实由服务端基于卸柜完成时间加 72 小时计算,前端不重算。两端统一使用 `containerBookingId`,列表范围由服务端 `scope=ACTIVE/HISTORY` 分页,禁止浏览器筛选或本地推导写资格。后端为 `LoadingListService`、`firstleg/customs/container/`、`firstleg/terminaldrayage/` 和 `lastleg-service/`,文件归 `file/` | | 合柜报关 / 同柜不可拆组 | `modules/unified-orders/api/coLoadGroups.ts`、`pages/UnifiedOrdersView.vue`;装柜消费 `modules/container-loading/pages/SmartLoadingWorkbenchView.vue` | 前端只提交订单 ID;Backend `firstleg/loading/coload/` 展开并冻结每票全部可用入仓片段。成员数是入仓片段数,不等于订单票数;只要求同国内仓、同渠道,允许跨客户及不同目的仓港;分配、移除、取消均整组处理 | | 全局智能装柜工作台、入仓码候选与箱数分配 | `modules/container-loading/components/SmartLoadingDialog.vue` 内嵌 `pages/GlobalSmartLoadingView.vue`,柜次页 `SmartLoadingWorkbenchView.vue`,以及 `smartLoadingChannelGroups.ts`、`smartLoadingItems.ts`、`api/platformContainerLoading.ts`;全局页当前不注册独立路由 | Backend `firstleg/` 的 `/inbound-candidates`、`/containers`、`/inbound-assignments`、`/plans/{planId}/pending-review-assignments`、`/group-assignments`、`/unpicked-items`。`orderStatuses` 最多四项且未传表示全部未完成状态;候选状态多选覆盖待审核、待测量、待分柜、装柜中,状态只用于筛选展示,按钮资格以服务端 `canAssign` 为准;待审核/待测量可提前占位但不得提前确认预装或启动 PDA。计划箱数编辑上限、已用和未用展示只消费服务端 `totalPackageCount/remainingPackageCount`,不得从候选页或本地规格重新拼算。人工加入允许跨渠道,智能推荐仍按柜次渠道。柜次容量和限重取服务端字段;同柜组作为单卡整组分页、分配和移除,任一成员已拣时整组保留 | | 国内货柜履约订舱资料、打印、关联候选与出仓前船期 | `modules/ocean-bookings/` 的 `components/OceanBookingDocumentsPanel.vue`、`OceanBookingPrintDialog.vue`,以及 `modules/container-loading/`、`modules/port-schedules/` | 订舱草稿按当前版本维护资料,放舱后资料与表单只读;打印对草稿与已放舱订舱均开放,按当前已保存资料生成,未填写字段显示“—”。`platformOceanBookings.ts` 的 `/link-candidates` 只消费服务端分页,`containerCount` 是柜型需求 `quantity` 总和;Backend `firstleg/oceanbooking/`、`file/`、`print/`、`firstleg/portschedule/` | diff --git a/Jenkinsfile b/Jenkinsfile index 0dec6ca3..6f0ae53c 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -1,3 +1,37 @@ +def sendProductionWeComNotification(String status, String color, String detail) { + withCredentials([string(credentialsId: 'wecom-prod-webhook', variable: 'WECOM_WEBHOOK')]) { + String sourceVersion = env.SOURCE_TAG ?: env.GIT_COMMIT ?: '—' + String content = """## 生产部署通知 +> 项目:${env.JOB_NAME} +> 构建:[#${env.BUILD_NUMBER}](${env.BUILD_URL}) +> 提交:${sourceVersion} +> 状态:${status} +> 详情:${detail} +> 时间:${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')}" @@ -161,6 +195,17 @@ pipeline { } post { + success { + script { + if (env.JOB_BASE_NAME?.endsWith('-prod')) { + try { + sendProductionWeComNotification('部署成功', 'info', 'Admin 生产前端已完成发布和部署检查') + } catch (Exception error) { + echo "企业微信通知失败,不影响原构建结果:${error.message}" + } + } + } + } failure { sh ''' if [ "${IS_PRODUCTION:-false}" = "true" ]; then @@ -171,6 +216,26 @@ 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.test.yml b/docker-compose.test.yml index f522117c..3f75c718 100644 --- a/docker-compose.test.yml +++ b/docker-compose.test.yml @@ -11,9 +11,11 @@ services: restart: unless-stopped ports: - "${HOST_PORT:-9001}:80" + extra_hosts: + - "host.docker.internal:host-gateway" environment: # Runtime URL used by the secure customer-workbench handoff. Each # environment may override it without rebuilding the Admin image. CLIENT_WEB_URL: "${CLIENT_WEB_URL:-https://test-app.buggoglobal.com/}" - 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/src/deployment/nginxConfig.test.ts b/src/deployment/nginxConfig.test.ts index a296661a..55caf51f 100644 --- a/src/deployment/nginxConfig.test.ts +++ b/src/deployment/nginxConfig.test.ts @@ -39,8 +39,11 @@ describe('admin nginx deployment config', () => { }) it('declares an explicit overseas gateway upstream in container deployments', () => { - expect(testCompose).toContain('API_UPSTREAM: "http://test-api-cn.buggoglobal.com"') - expect(testCompose).toContain('OVERSEAS_API_UPSTREAM: "http://test-api-global.buggoglobal.com"') + expect(testCompose).toContain('- "host.docker.internal:host-gateway"') + expect(testCompose).toContain('API_UPSTREAM: "${API_UPSTREAM:-http://host.docker.internal:9000}"') + expect(testCompose).toContain('OVERSEAS_API_UPSTREAM: "${OVERSEAS_API_UPSTREAM:-http://host.docker.internal:9300}"') + expect(testCompose).not.toContain('http://test-api-cn.buggoglobal.com') + expect(testCompose).not.toContain('http://test-api-global.buggoglobal.com') expect(testCompose).toContain('CLIENT_WEB_URL: "${CLIENT_WEB_URL:-https://test-app.buggoglobal.com/}"') expect(testCompose).not.toContain('CLIENT_WEB_URL: "http://8.163.') expect(developmentCompose).toContain('CLIENT_WEB_URL: "${CLIENT_WEB_URL:-https://dev-app.buggoglobal.com/}"') diff --git a/src/modules/container-loading/components/OceanBookingLinkDialog.test.ts b/src/modules/container-loading/components/OceanBookingLinkDialog.test.ts index 2f04920a..fa8e958f 100644 --- a/src/modules/container-loading/components/OceanBookingLinkDialog.test.ts +++ b/src/modules/container-loading/components/OceanBookingLinkDialog.test.ts @@ -21,7 +21,7 @@ const container = { } satisfies ContainerBookingResource const candidate = { - oceanBookingId: '101', bookingNo: 'OB-101', requirementId: '202', containerType: '40HQ', capacityCbm: 68, + oceanBookingId: '101', bookingNo: 'OB-101', billOfLadingNo: 'BL-101', requirementId: '202', containerType: '40HQ', capacityCbm: 68, maxWeightKg: 19500, requiredQuantity: 2, allocatedQuantity: 1, remainingQuantity: 1, customerName: '测试客户', warehouseName: '义乌仓', channelName: '美森正班', } @@ -36,7 +36,7 @@ describe('OceanBookingLinkDialog', () => { const wrapper = mount(OceanBookingLinkDialog, { props: { modelValue: true, container }, global: { plugins: [ElementPlus] } }) await flushPromises() expect(listOceanBookingLinkCandidates).toHaveBeenCalledExactlyOnceWith({ warehouseId: '8', channelGroupCode: 'MATSON_CLX', containerType: '40HQ', page: 1, size: 200 }) - expect((wrapper.vm as unknown as { candidates: Array<{ label: string }> }).candidates[0]?.label).toContain('OB-101 · 40HQ × 1') + expect((wrapper.vm as unknown as { candidates: Array<{ label: string }> }).candidates[0]?.label).toContain('OB-101 · 提单号 BL-101 · 40HQ × 1') wrapper.unmount() }) diff --git a/src/modules/container-loading/components/OceanBookingLinkDialog.vue b/src/modules/container-loading/components/OceanBookingLinkDialog.vue index 91633f8b..0eefd8fc 100644 --- a/src/modules/container-loading/components/OceanBookingLinkDialog.vue +++ b/src/modules/container-loading/components/OceanBookingLinkDialog.vue @@ -51,7 +51,7 @@ async function loadCandidates() { key: `${candidate.oceanBookingId}:${candidate.requirementId}`, oceanBookingId: candidate.oceanBookingId, requirementId: candidate.requirementId, - label: `${candidate.bookingNo} · ${candidate.containerType} × ${candidate.remainingQuantity} · ${candidate.customerName?.trim() || '未关联客户'} · ${candidate.warehouseName} · ${candidate.channelName}`, + label: `${candidate.bookingNo} · 提单号 ${candidate.billOfLadingNo} · ${candidate.containerType} × ${candidate.remainingQuantity} · ${candidate.customerName?.trim() || '未关联客户'} · ${candidate.warehouseName} · ${candidate.channelName}`, })) } catch (cause) { if (sequence === requestSequence) error.value = getApiErrorDetails(cause, '已放舱订舱候选加载失败') diff --git a/src/modules/container-loading/pages/ContainersView.test.ts b/src/modules/container-loading/pages/ContainersView.test.ts index 490ac617..e1334e6d 100644 --- a/src/modules/container-loading/pages/ContainersView.test.ts +++ b/src/modules/container-loading/pages/ContainersView.test.ts @@ -374,7 +374,7 @@ describe('ContainersView', () => { await flushPromises() expect(wrapper.find('button[aria-label="查看 CB-1"]').exists()).toBe(true) - expect(wrapper.find('.el-table__body button[aria-label="更多货柜操作"]').exists()).toBe(true) + expect(wrapper.find('.el-table__body button[aria-label="更多货柜操作"]').exists()).toBe(false) expect(wrapper.findAll('button').some((button) => button.text().trim() === '编辑')).toBe(false) }) @@ -453,14 +453,16 @@ describe('ContainersView', () => { it('renders container business actions as buttons without changing the transport gate', () => { const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/ContainersView.vue'), 'utf8') - expect(source).toContain('size="small" type="primary" plain :aria-label="containerViewLabel') - expect(source).toContain("hasAction(row as ContainerWorkbenchRow, 'EDIT_CONTAINER')") + expect(source).toContain("import RowActions, { type RowActionItem } from '@/shared/components/RowActions.vue'") + expect(source).toContain("{ key: 'view', label: '查看', ariaLabel: containerViewLabel(row), priority: 500") + expect(source).toContain("hasAction(row, 'EDIT_CONTAINER')") + expect(source).toContain(' { it.each([undefined, null, '', '客户一'])('renders optional booking customer %s without leaking a missing value', async (customerName) => { vi.mocked(listOceanBookingLinkCandidates).mockResolvedValueOnce({ records: [{ - oceanBookingId: '315', bookingNo: 'OB-OPTIONAL-CUSTOMER', requirementId: '45315', + oceanBookingId: '315', bookingNo: 'OB-OPTIONAL-CUSTOMER', billOfLadingNo: 'BL-OPTIONAL-CUSTOMER', requirementId: '45315', containerType: '40HQ', remainingQuantity: 1, customerName, warehouseName: '义乌仓', channelName: 'EXX海卡', }], page: 1, size: 200, total: 1, pages: 1 } as never) const router = createTestRouter() @@ -667,7 +669,7 @@ describe('ContainersView', () => { Array.from(document.body.querySelectorAll('.el-dropdown-menu__item')) .filter((item) => item.textContent?.includes('关联已放舱订舱')).at(-1)!.click() await flushPromises() - const label = `OB-OPTIONAL-CUSTOMER · 40HQ × 1 · ${customerName || '未关联客户'} · 义乌仓 · EXX海卡` + const label = `OB-OPTIONAL-CUSTOMER · 提单号 BL-OPTIONAL-CUSTOMER · 40HQ × 1 · ${customerName || '未关联客户'} · 义乌仓 · EXX海卡` expect(document.body.textContent).toContain(label) expect((wrapper.findComponent(OceanBookingLinkDialog).vm as unknown as { candidates: Array<{ label: string }> }).candidates[0]?.label).toBe(label) wrapper.unmount() @@ -698,9 +700,9 @@ describe('ContainersView', () => { expect(listOceanBookingLinkCandidates).toHaveBeenCalledTimes(1) expect(listOceanBookingLinkCandidates).toHaveBeenCalledWith({ warehouseId: '193000000000200002', channelGroupCode: 'EXX', containerType: '45HQ', page: 1, size: 200 }) expect(getOceanBooking).not.toHaveBeenCalled() - resolveCandidates({ records: [{ oceanBookingId: '315', bookingNo: 'OB-CAPACITY-SNAPSHOT', requirementId: '45315', containerType: '45HQ', capacityCbm: 85, maxWeightKg: 28500, requiredQuantity: 1, allocatedQuantity: 0, remainingQuantity: 1, customerName: '客户', warehouseName: '东莞仓', channelName: '纽约铁路' }], page: 1, size: 200, total: 1, pages: 1 }) + resolveCandidates({ records: [{ oceanBookingId: '315', bookingNo: 'OB-CAPACITY-SNAPSHOT', billOfLadingNo: 'BL-CAPACITY-SNAPSHOT', requirementId: '45315', containerType: '45HQ', capacityCbm: 85, maxWeightKg: 28500, requiredQuantity: 1, allocatedQuantity: 0, remainingQuantity: 1, customerName: '客户', warehouseName: '东莞仓', channelName: '纽约铁路' }], page: 1, size: 200, total: 1, pages: 1 }) await flushPromises() - expect(document.body.textContent).toContain('OB-CAPACITY-SNAPSHOT · 45HQ × 1') + expect(document.body.textContent).toContain('OB-CAPACITY-SNAPSHOT · 提单号 BL-CAPACITY-SNAPSHOT · 45HQ × 1') expect((wrapper.findComponent(OceanBookingLinkDialog).vm as unknown as { candidates: Array<{ key: string }> }).candidates[0]?.key).toBe('315:45315') wrapper.unmount() }) @@ -729,9 +731,9 @@ describe('ContainersView', () => { await flushPromises() Array.from(document.body.querySelectorAll('.el-dropdown-menu__item')) .filter((item) => item.textContent?.includes('关联已放舱订舱')).at(-1)!.click() - resolveSecond({ records: [{ oceanBookingId: '303', bookingNo: 'OB-LATEST', requirementId: '404', containerType: '40HQ', capacityCbm: 68, maxWeightKg: 19500, requiredQuantity: 1, allocatedQuantity: 0, remainingQuantity: 1, customerName: '客户', warehouseName: '义乌仓', channelName: '美森正班海卡包税' }], page: 1, size: 200, total: 1, pages: 1 }) + resolveSecond({ records: [{ oceanBookingId: '303', bookingNo: 'OB-LATEST', billOfLadingNo: 'BL-LATEST', requirementId: '404', containerType: '40HQ', capacityCbm: 68, maxWeightKg: 19500, requiredQuantity: 1, allocatedQuantity: 0, remainingQuantity: 1, customerName: '客户', warehouseName: '义乌仓', channelName: '美森正班海卡包税' }], page: 1, size: 200, total: 1, pages: 1 }) await flushPromises() - resolveFirst({ records: [{ oceanBookingId: '101', bookingNo: 'OB-STALE', requirementId: '202', containerType: '40HQ', capacityCbm: 68, maxWeightKg: 19500, requiredQuantity: 1, allocatedQuantity: 0, remainingQuantity: 1, customerName: '客户', warehouseName: '义乌仓', channelName: '美森正班海卡包税' }], page: 1, size: 200, total: 1, pages: 1 }) + resolveFirst({ records: [{ oceanBookingId: '101', bookingNo: 'OB-STALE', billOfLadingNo: 'BL-STALE', requirementId: '202', containerType: '40HQ', capacityCbm: 68, maxWeightKg: 19500, requiredQuantity: 1, allocatedQuantity: 0, remainingQuantity: 1, customerName: '客户', warehouseName: '义乌仓', channelName: '美森正班海卡包税' }], page: 1, size: 200, total: 1, pages: 1 }) await flushPromises() expect(listOceanBookingLinkCandidates).toHaveBeenCalledTimes(2) diff --git a/src/modules/container-loading/pages/ContainersView.vue b/src/modules/container-loading/pages/ContainersView.vue index d9c9283d..14de5a37 100644 --- a/src/modules/container-loading/pages/ContainersView.vue +++ b/src/modules/container-loading/pages/ContainersView.vue @@ -3,7 +3,6 @@ import '@/styles/adminDensity.css' import { computed, onMounted, reactive, ref, watch } from 'vue' import ContainerSimulationCreate from '../components/ContainerSimulationCreate.vue' import { useRouter } from 'vue-router' -import { MoreHorizontal } from '@lucide/vue' import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus' import { useAccessPreviewStore } from '@/core/access/accessPreview' import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam' @@ -17,6 +16,7 @@ import AppDialog from '@/shared/components/AppDialog.vue' import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue' import ListPageLayout from '@/shared/components/ListPageLayout.vue' import LoadErrorState from '@/shared/components/LoadErrorState.vue' +import RowActions, { type RowActionItem } from '@/shared/components/RowActions.vue' import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue' import { CONTAINER_SPECIFICATIONS, DEFAULT_CONTAINER_TYPE, applyContainerSpecification, containerSpecification } from '@/shared/domain/containerSpecifications' import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http' @@ -638,6 +638,30 @@ function handleMoreAction(command: string, source: object) { if (command.startsWith('drayage:')) { openDrayageAction(row, command.slice('drayage:'.length) as Exclude); return } if (command === 'documents' && isOverseas.value && hasAction(row as ContainerWorkbenchRow, 'MANAGE_DESTINATION_DOCUMENTS')) void router.push({ name: 'overseas-container-detail', params: { containerId: row.id }, query: { action: command } }) } +function containerRowActions(row: ContainerWorkbenchRow): RowActionItem[] { + const processLabel = row.planStatus === 'EDITING' ? '装柜' : row.planStatus === 'LOADING' ? '预装清单' : row.planStatus === 'COMPLETED' ? '装柜清单' : '' + const canOpenProcess = row.planStatus === 'EDITING' ? canSmartLoading.value : row.planStatus === 'LOADING' ? canViewPreload.value : row.planStatus === 'COMPLETED' ? canViewLoadingList.value : false + return [ + { key: 'view', label: '查看', ariaLabel: containerViewLabel(row), priority: 500, visible: canView.value }, + { key: 'edit', label: '编辑', priority: 450, visible: isDomestic.value && canEdit.value && hasAction(row, 'EDIT_CONTAINER') }, + { key: 'process', label: processLabel, priority: 400, tone: 'primary', visible: isDomestic.value && Boolean(processLabel) && canOpenProcess }, + { key: 'empty-return', label: '确认还柜', priority: 350, tone: 'primary', visible: canDrayage(row, 'RECORD_EMPTY_RETURN') }, + { key: 'send-email', label: '发送邮件', priority: 300, tone: 'primary', visible: canDrayage(row, 'SEND_PICKUP_EMAIL'), loading: drayageSavingIds.value.includes(row.id) }, + { key: 'overview', label: '轨迹与日志', priority: 200, visible: canView.value }, + { key: 'email-history', label: '邮件记录', priority: 150, visible: canView.value && isOverseas.value }, + { key: 'drayage-history', label: '码头拖车记录', priority: 140, visible: canView.value && isOverseas.value }, + { key: 'documents', label: '清关 / DO / PU', priority: 130, visible: canView.value && isOverseas.value && canOverseasDocumentEdit.value && hasAction(row, 'MANAGE_DESTINATION_DOCUMENTS') }, + { key: 'link', label: '关联已放舱订舱', priority: 120, visible: canView.value && isDomestic.value && canEdit.value && hasAction(row, 'EDIT_CONTAINER') && row.sourceType === 'SIMULATION' && !row.oceanBookingId }, + ] +} +function handleContainerRowAction(command: string, row: ContainerWorkbenchRow) { + if (command === 'view') openDetail(row) + else if (command === 'edit') void openEdit(row) + else if (command === 'process') goContainerProcess(row) + else if (command === 'empty-return') openDrayageAction(row, 'RECORD_EMPTY_RETURN') + else if (command === 'send-email') void sendPickupEmail(row) + else handleMoreAction(command, row) +} async function openEdit(source: object) { const row = source as ContainerBookingResource editorError.value = null @@ -721,7 +745,7 @@ onMounted(async () => { await Promise.all([loadCustomers(), loadRows()]) })
- + diff --git a/src/modules/customers/pages/CustomersView.test.ts b/src/modules/customers/pages/CustomersView.test.ts index 7c354dc2..486bf67c 100644 --- a/src/modules/customers/pages/CustomersView.test.ts +++ b/src/modules/customers/pages/CustomersView.test.ts @@ -194,9 +194,13 @@ describe('CustomersView', () => { expect(customersSource).not.toContain(' { + it('keeps qualification file controls usable when the editor dialog is narrow', () => { expect(customersSource).toContain('class="qualification-validity" role="cell"') expect(customersSource).toContain('grid-template-columns:170px minmax(0,1fr) 220px') + expect(customersSource).toContain('.qualification-table { container-type:inline-size;') + expect(customersSource).toContain('@container (max-width:660px)') + expect(customersSource).toContain('.qualification-row { grid-template-columns:112px minmax(0,1fr); }') + expect(customersSource).toContain('.qualification-label { grid-row:span 2; align-items:center; }') expect(customersSource).toContain('.qualification-validity :deep(.el-date-editor) { width:100%; min-width:0; }') expect(customersSource).toContain('.qualification-file-name { min-width:0;') expect(readFileSync(resolve(process.cwd(), 'src/modules/customers/customerDensity.css'), 'utf8')).toContain('max-width: none; overflow-wrap: anywhere; white-space: normal;') @@ -313,8 +317,41 @@ describe('CustomersView', () => { expect(updatePlatformTenant).toHaveBeenCalledWith(fullTenant.id, expect.objectContaining({ contactPhone: '0755-12345678' })) expect(wrapper.findComponent(AppDialog).props('modelValue')).toBe(false) + expect(document.body.textContent).toContain('客户资料已保存') + expect(document.body.textContent).not.toContain('尚未提交审核') expect(document.body.textContent).toContain('不会修改企业管理员登录手机号') expect(customersSource).not.toContain('联系电话(登录用户名)') + expect(customersSource.indexOf('if (!failures.length) editorOpen.value = false')).toBeLessThan(customersSource.indexOf('await loadCustomers()')) + }) + + it('allows an active customer to replace qualifications and select a validity date', async () => { + const activeTenant = { ...fullTenant, status: 'ACTIVE' as const } + vi.mocked(listPlatformTenants).mockResolvedValue({ records: [activeTenant], page: 1, size: 10, total: 1, pages: 1 }) + vi.mocked(getPlatformTenant).mockResolvedValue(activeTenant) + vi.mocked(listTenantQualifications).mockResolvedValue([ + { + id: 'qualification-license', tenantId: fullTenant.id, qualificationType: 'BUSINESS_LICENSE', + certificateNo: fullTenant.unifiedSocialCreditCode, documentUrl: '/files/license.png', status: 'APPROVED', reviewComment: null, validTo: null, + }, + { + id: 'qualification-freight', tenantId: fullTenant.id, qualificationType: 'FREIGHT_FORWARDER_CERTIFICATE', + certificateNo: null, documentUrl: '/files/freight.png', status: 'APPROVED', reviewComment: null, validTo: null, + }, + ]) + mount(CustomersView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } }) + await flushPromises() + tableButtonByText('编辑')?.click() + await flushPromises() + + const fileInputs = [...document.querySelectorAll('.qualification-file input[type="file"]')] + const validityInputs = [...document.querySelectorAll('.qualification-validity input')] + expect(fileInputs).toHaveLength(4) + expect(fileInputs.every((input) => !input.disabled)).toBe(true) + expect(validityInputs.map((input) => input.disabled)).toEqual([true, false, true, false]) + + selectQualificationFile(0, new File(['license-new'], 'license-new.png', { type: 'image/png' })) + await flushPromises() + expect(validityInputs[0]?.disabled).toBe(false) }) it('does not count rejected or superseded history as a current qualification when resubmitting', async () => { @@ -785,7 +822,7 @@ describe('CustomersView', () => { await flushPromises() expect(document.querySelectorAll('.el-table__body button[aria-label^="更多客户操作:"]').length).toBe(3) - expect(document.querySelectorAll('.el-table__body .table-actions > .el-button').length).toBe(9) + expect(document.querySelectorAll('.el-table__body .row-actions > .el-button').length).toBe(9) }) it('keeps the action row usable when all customer identifiers are missing', async () => { diff --git a/src/modules/customers/pages/CustomersView.vue b/src/modules/customers/pages/CustomersView.vue index 7e6f38d3..c2ed4ba4 100644 --- a/src/modules/customers/pages/CustomersView.vue +++ b/src/modules/customers/pages/CustomersView.vue @@ -3,9 +3,10 @@ import '@/styles/adminDensity.css' import '@/modules/customers/customerDensity.css' import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue' import { useRoute, useRouter } from 'vue-router' -import { Building2, CheckCircle2, CircleCheck, FileCheck2, FileText, MoreHorizontal, ShieldCheck, ShieldX } from '@lucide/vue' +import { Building2, CheckCircle2, CircleCheck, FileCheck2, FileText, ShieldCheck, ShieldX } from '@lucide/vue' import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus' import { getApiErrorDetails, getApiErrorMessage, type ApiErrorDetails } from '@/shared/api/http' +import RowActions, { type RowActionItem } from '@/shared/components/RowActions.vue' import { createPlatformExportTask } from '@/modules/downloads' import { approveEnterpriseReview, @@ -228,7 +229,8 @@ let reviewLoadSequence = 0 let editorLoadSequence = 0 const editorTitle = computed(() => editingId.value ? '编辑客户' : '新增客户') const editorDialogWidth = 'min(1120px, calc(100vw - 28px))' -const canUploadQualifications = computed(() => !editorStatus.value || ['PROFILE_REQUIRED', 'REJECTED'].includes(editorStatus.value)) +const canUploadQualifications = computed(() => !editorStatus.value + || ['PROFILE_REQUIRED', 'REJECTED', 'ACTIVE', 'DISABLED'].includes(editorStatus.value)) const canSubmitReview = computed(() => Boolean(editingId.value) && ['PROFILE_REQUIRED', 'REJECTED'].includes(editorStatus.value || '')) const uploadingQualifications = computed(() => qualifications.value.some((item) => item.uploading)) const hasSelectedQualifications = computed(() => qualifications.value.some((item) => item.file)) @@ -603,19 +605,22 @@ async function saveCustomer() { editorStatus.value = saved.status savedFormSnapshot.value = JSON.stringify(form.value) const failures = hadSelectedQualifications ? await uploadSelectedQualifications(String(saved.id)) : [] + const canSubmitSavedCustomerForReview = ['PROFILE_REQUIRED', 'REJECTED'].includes(saved.status) if (failures.length) { ElMessage.warning(`客户资料已保存,但${failures.join('、')}上传失败;失败文件已保留,请修正后再次保存`) - } else if (hadBusinessLicense && hadLegalPersonId) { + } else if (canSubmitSavedCustomerForReview && hadBusinessLicense && hadLegalPersonId) { ElMessage.success('营业执照、统一社会信用代码和选填的法人身份证已保存,尚未提交审核') - } else if (hadBusinessLicense) { + } else if (canSubmitSavedCustomerForReview && hadBusinessLicense) { ElMessage.success('营业执照和统一社会信用代码已保存,法人身份证可按需补充,尚未提交审核') - } else if (hadSelectedQualifications) { + } else if (canSubmitSavedCustomerForReview && hadSelectedQualifications) { ElMessage.success('客户资料和已选资质已保存,尚未提交审核') + } else if (hadSelectedQualifications) { + ElMessage.success('客户资料和已选资质已保存') } else { ElMessage.success('客户资料已保存') } - await loadCustomers() if (!failures.length) editorOpen.value = false + await loadCustomers() } catch (error) { ElMessage.error(getApiErrorMessage(error, '客户资料保存失败,请稍后重试')) } finally { saving.value = false } } @@ -674,17 +679,6 @@ function customerSecondaryActions(row: PlatformTenantResource): CustomerRowActio ...(canArchive(row) ? ['delete' as const] : []), ] } -function customerPrimaryActionCount(row: PlatformTenantResource) { - return Number(access.can('platform:customer:list')) - + Number(access.can('platform:customer:edit') && row.status !== 'ARCHIVED') - + Number(hasReviewAction(row)) -} -function customerDirectSecondaryActions(row: PlatformTenantResource) { - return customerSecondaryActions(row).slice(0, Math.max(0, 3 - customerPrimaryActionCount(row))) -} -function customerOverflowActions(row: PlatformTenantResource) { - return customerSecondaryActions(row).slice(Math.max(0, 3 - customerPrimaryActionCount(row))) -} function customerMoreActionsLabel(row: PlatformTenantResource) { const identity = row.tenantName || row.tenantCode || String(row.id || '当前客户') return `更多客户操作:${identity}` @@ -706,6 +700,27 @@ function customerActionDisabled(action: CustomerRowAction) { if (action === 'delete') return archiveSubmitting.value return false } +function customerRowActions(row: PlatformTenantResource): RowActionItem[] { + return [ + { key: 'detail', label: '详情', priority: 500, visible: access.can('platform:customer:list') }, + { key: 'edit', label: '编辑', priority: 400, visible: access.can('platform:customer:edit') && row.status !== 'ARCHIVED' }, + { key: 'review', label: '审核', priority: 300, tone: 'warning', visible: hasReviewAction(row), disabled: reviewSubmitting.value }, + ...customerSecondaryActions(row).map((action, index): RowActionItem => ({ + key: action, + label: customerActionLabel(row, action), + priority: 200 - index, + tone: customerActionType(row, action), + disabled: customerActionDisabled(action), + divided: action === 'delete', + })), + ] +} +function handleCustomerRowAction(row: PlatformTenantResource, command: string) { + if (command === 'detail') showDetail(row) + else if (command === 'edit') openEdit(row) + else if (command === 'review') openDecision(row, access.can('platform:customer:approve') ? 'approve' : 'reject') + else handleMoreAction(row, command) +} function handleMoreAction(row: PlatformTenantResource, command: string) { if (command === 'reset-password') openResetPassword(row) if (command === 'impersonate') openImpersonation(row) @@ -1055,18 +1070,7 @@ onMounted(() => { void loadCustomers() }) - + @@ -1112,7 +1116,7 @@ onMounted(() => { void loadCustomers() })
{{ draft.file?.name || draft.uploadedName || '未选择文件' }}{{ draft.error }}
-
+

支持 PDF、JPG、PNG,单个文件不超过 5MB;* 为提交审核必填项。

@@ -1222,7 +1226,7 @@ onMounted(() => { void loadCustomers() }) .capital-input { display:grid; grid-template-columns:minmax(0,1fr) 130px; width:100%; gap:var(--admin-space-3, 8px); } .capital-input :deep(.el-input-number) { width:100%; } .capital-input :deep(.el-select) { width:100%; } .qualification-heading { display:flex; justify-content:flex-end; margin-bottom:var(--admin-space-5, 12px); } .qualification-hint { margin:var(--admin-space-5, 12px) 0 0; color:var(--cc-muted); font-size:var(--admin-font-caption, 12px); line-height:1.6; } .qualification-hint span { color:var(--el-color-danger); } .qualification-label > span { color:var(--el-color-danger); } .qualification-credit-code { max-width:420px; margin-top:var(--admin-space-6, 16px); } .qualification-credit-code small { display:block; margin-top:var(--admin-space-2, 6px); color:var(--cc-muted); font-size:var(--admin-font-caption, 11px); line-height:1.5; } .field-hint { display:block; margin-top:var(--admin-space-2, 6px); color:var(--cc-muted); line-height:1.5; } -.qualification-table { overflow:hidden; margin-top:14px; border:1px solid var(--cc-border); border-radius:8px; } .qualification-row { display:grid; grid-template-columns:170px minmax(0,1fr) 220px; min-width:0; border-top:1px solid var(--cc-border); } .qualification-row:first-child { border-top:0; } .qualification-row > * { display:flex; min-width:0; align-items:center; } .qualification-header { min-height:44px; background:#f7f8fc; color:var(--cc-muted); font-size:var(--admin-font-caption, 12px); font-weight:600; } +.qualification-table { container-type:inline-size; overflow:hidden; margin-top:14px; border:1px solid var(--cc-border); border-radius:8px; } .qualification-row { display:grid; grid-template-columns:170px minmax(0,1fr) 220px; min-width:0; border-top:1px solid var(--cc-border); } .qualification-row:first-child { border-top:0; } .qualification-row > * { display:flex; min-width:0; align-items:center; } .qualification-header { min-height:44px; background:#f7f8fc; color:var(--cc-muted); font-size:var(--admin-font-caption, 12px); font-weight:600; } .qualification-label { gap:5px; font-weight:600; } .qualification-label svg { margin-left:auto; color:var(--el-color-success); } .qualification-optional { padding:2px 5px; border-radius:var(--admin-radius, 4px); background:var(--el-fill-color-light); color:var(--cc-muted); font-size:var(--admin-font-caption, 10px); font-weight:500; line-height:1.4; } .qualification-file { gap:var(--admin-space-5, 12px); } .qualification-file-control { display:flex; min-width:0; flex:1; flex-wrap:wrap; align-items:center; gap:var(--admin-space-3, 8px); } .file-picker { position:relative; display:inline-flex; flex:0 0 auto; align-items:center; gap:7px; overflow:hidden; padding:0 var(--admin-space-5, 12px); border:1px solid var(--cc-border); border-radius:6px; background:var(--admin-surface, #fff); color:var(--cc-text); cursor:pointer; } .file-picker:focus-within { outline:2px solid color-mix(in srgb,var(--cc-primary) 35%,transparent); outline-offset:2px; } .file-picker.is-disabled { cursor:not-allowed; opacity:.6; } .file-picker input { position:absolute; inset:0; width:100%; height:100%; opacity:0; cursor:inherit; } .qualification-file-name { min-width:0; overflow:hidden; color:var(--cc-muted); font-size:var(--admin-font-caption, 12px); text-overflow:ellipsis; } .qualification-validity :deep(.el-date-editor) { width:100%; min-width:0; } .qualification-error { width:100%; color:var(--el-color-danger); line-height:1.4; } @@ -1230,6 +1234,12 @@ onMounted(() => { void loadCustomers() }) .qualification-preview-trigger:hover { border-color:var(--cc-primary); color:var(--cc-primary); } .qualification-preview-trigger:focus-visible { outline:2px solid color-mix(in srgb,var(--cc-primary) 42%,transparent); outline-offset:2px; } .qualification-preview-trigger:disabled { cursor:wait; opacity:.72; } +@container (max-width:660px) { + .qualification-row { grid-template-columns:112px minmax(0,1fr); } + .qualification-header { display:none; } + .qualification-label { grid-row:span 2; align-items:center; } + .qualification-file,.qualification-validity { min-height:44px; } +} .qualification-preview-trigger img { width:100%; height:100%; object-fit:contain; background:var(--admin-surface, #fff); } .qualification-preview-placeholder { display:grid; place-items:center; gap:2px; } .qualification-preview-placeholder small { font-size:var(--admin-font-caption, 10px); font-weight:700; } diff --git a/src/modules/finance/pages/FinanceCustomerAccountsView.test.ts b/src/modules/finance/pages/FinanceCustomerAccountsView.test.ts index c0d5b51c..04678b36 100644 --- a/src/modules/finance/pages/FinanceCustomerAccountsView.test.ts +++ b/src/modules/finance/pages/FinanceCustomerAccountsView.test.ts @@ -107,15 +107,13 @@ describe('FinanceCustomerAccountsView delivery', () => { it('keeps high-frequency account actions visible and groups the remaining actions', () => { expect(page).toContain("{ key: 'actions', label: '操作', width: 290, fixed: 'left', slotName: 'actions' }") - expect(page).toContain('@click="openAccount(row, false)">详情') - expect(page).toContain("openAdjustment(row as FinanceAccountSummary, 'RECHARGE')") - expect(page).toContain("openAdjustment(row as FinanceAccountSummary, 'DEDUCTION')") - expect(page).toContain('aria-label="更多客户账户操作"') - expect(page).toContain('授信') - expect(page).toContain('账期') - expect(page).not.toContain('') - expect(page).toContain('class="row-actions density-actions"') - expect(page).toContain('white-space: nowrap') + expect(page).toContain("import RowActions, { type RowActionItem } from '@/shared/components/RowActions.vue'") + expect(page).toContain("{ key: 'detail', label: '详情', priority: 300 }") + expect(page).toContain("{ key: 'recharge', label: '充值', priority: 250") + expect(page).toContain("{ key: 'deduction', label: '扣款', priority: 200") + expect(page).toContain("{ key: 'credit', label: '授信', priority: 100") + expect(page).toContain("{ key: 'terms', label: '账期', visible: canUpdateTerms.value") + expect(page).toContain(' { @@ -134,7 +132,7 @@ describe('FinanceCustomerAccountsView delivery', () => { expect(actions.findAll('button').map(button => button.text().trim())).toEqual(labels) expect(actions.find('.el-dropdown').exists()).toBe(overflow) if (overflow) { - const trigger = actions.get('button[aria-label="更多客户账户操作"]') + const trigger = actions.get('button[aria-label^="更多客户账户操作"]') expect(trigger.text()).toBe('') expect(trigger.find('svg').exists()).toBe(true) } diff --git a/src/modules/finance/pages/FinanceCustomerAccountsView.vue b/src/modules/finance/pages/FinanceCustomerAccountsView.vue index f161c4b5..76a2356a 100644 --- a/src/modules/finance/pages/FinanceCustomerAccountsView.vue +++ b/src/modules/finance/pages/FinanceCustomerAccountsView.vue @@ -3,7 +3,7 @@ import '@/styles/adminDensity.css' import '../financeDensity.css' import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue' import { useRoute, useRouter } from 'vue-router' -import { ArrowLeft, CreditCard, Download, MinusCircle, MoreHorizontal, PlusCircle, Save } from '@lucide/vue' +import { ArrowLeft, CreditCard, Download, MinusCircle, PlusCircle, Save } from '@lucide/vue' import { ElMessage } from 'element-plus' import { useAccessPreviewStore } from '@/core/access/accessPreview' import { createPlatformExportTask } from '@/modules/downloads' @@ -18,6 +18,7 @@ import FormSection from '@/shared/components/FormSection.vue' import ListPageLayout from '@/shared/components/ListPageLayout.vue' import LoadErrorState from '@/shared/components/LoadErrorState.vue' import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue' +import RowActions, { type RowActionItem } from '@/shared/components/RowActions.vue' import { showExportTaskCreatedDialog } from '@/shared/components/exportTaskCreatedDialog' import { adjustFinanceAccount, downloadFinanceRechargeVoucher, getFinanceCustomerTerms, listFinanceAccountLedger, listFinanceAccounts, listFinanceRechargeRequests, reviewFinanceRechargeRequest, updateFinanceCreditLimit, updateFinanceCustomerTerms } from '../api/finance' import { financeBusinessTypeLabel } from '../status' @@ -270,6 +271,21 @@ function handleAccountMore(command: string, account: FinanceAccountSummary) { if (command === 'credit') openCreditLimit(account) if (command === 'terms') void openAccount(account as unknown as Record, true) } +function accountActions(account: FinanceAccountSummary): RowActionItem[] { + return [ + { key: 'detail', label: '详情', priority: 300 }, + { key: 'recharge', label: '充值', priority: 250, visible: canAdjust.value }, + { key: 'deduction', label: '扣款', priority: 200, tone: 'danger', visible: canAdjust.value }, + { key: 'credit', label: '授信', priority: 100, visible: canAdjust.value }, + { key: 'terms', label: '账期', visible: canUpdateTerms.value, divided: canAdjust.value }, + ] +} +function handleAccountAction(command: string, account: FinanceAccountSummary) { + if (command === 'detail') void openAccount(account as unknown as Record, false) + else if (command === 'recharge') openAdjustment(account, 'RECHARGE') + else if (command === 'deduction') openAdjustment(account, 'DEDUCTION') + else handleAccountMore(command, account) +} async function submitCreditLimit() { if (!adjustmentAccount.value) return const raw = creditLimitForm.amount.trim() @@ -425,7 +441,7 @@ onBeforeUnmount(clearVoucherPreview)
- + diff --git a/src/modules/finance/pages/FinanceCustomerArapView.test.ts b/src/modules/finance/pages/FinanceCustomerArapView.test.ts index 3885197f..ceb2f5eb 100644 --- a/src/modules/finance/pages/FinanceCustomerArapView.test.ts +++ b/src/modules/finance/pages/FinanceCustomerArapView.test.ts @@ -113,7 +113,7 @@ describe('FinanceCustomerArapView G2 operations', () => { expect(actions.findAll('button').map(button => button.text().trim())).toEqual(labels) expect(actions.find('.el-dropdown').exists()).toBe(overflow) if (overflow) { - const trigger = actions.get('button[title="更多费用来源操作"]') + const trigger = actions.get('button[title^="更多费用来源操作"]') expect(trigger.text()).toBe('') await trigger.trigger('click') await flushPromises() @@ -268,18 +268,18 @@ describe('FinanceCustomerArapView G2 operations', () => { expect(page).not.toContain('max-width: 520px') }) - it('renders permitted fee-source row operations as shared outlined text buttons', () => { + it('renders permitted fee-source row operations through the shared row-actions component', () => { const page = readFileSync(resolve(process.cwd(), 'src/modules/finance/pages/FinanceCustomerArapView.vue'), 'utf8') - expect(page).toContain('@click="openDetail(row)">详情') - expect(page).toContain("@click=\"openMutation(row, 'SUBMIT')\"") - expect(page).toContain("@click=\"openMutation(row, 'ADJUST')\"") + expect(page).toContain("import RowActions, { type RowActionItem } from '@/shared/components/RowActions.vue'") + expect(page).toContain("{ key: 'detail', label: '详情', priority: 300") + expect(page).toContain("{ key: 'submit', label: '提交审核', priority: 200") + expect(page).toContain("{ key: 'adjust', label: '调整', priority: 150") + expect(page).toContain("{ key: 'void', label: '作废', tone: 'danger'") expect(page).toContain('!isUpstreamSource(row)') expect(page).toContain('!isUpstreamSource(detail)') expect(page).toContain("['INBOUND_FEE', 'CONTAINER_FEE'].includes(type)") expect(page).toContain("=== 'LASTLEG'") - const actions = page.slice(page.indexOf('