import assert from 'node:assert/strict' import { mkdir } from 'node:fs/promises' import { chromium } from 'playwright' // 本地 H5 交互回归:所有 API 均由 fixture 拦截,不写真实业务库。 const origin = process.env.QUOTE_TEST_ORIGIN || 'http://localhost:3006' const artifacts = '/tmp/shanxia-quote-e2e' await mkdir(artifacts, { recursive: true }) const browser = await chromium.launch({ headless: true, channel: 'chrome' }) const context = await browser.newContext({ viewport: { width: 375, height: 812 }, isMobile: true, deviceScaleFactor: 1 }) const page = await context.newPage() const pageErrors = [] const calls = [] page.on('pageerror', (error) => pageErrors.push(error.message)) const quote = { id: 'quote-1', quoteNo: 'BJ-20261017-001', quoteTitle: '山峡化工年度报价', quoteType: 'purchase', customerId: 'customer-1', customerName: '宜昌测试客户', contactName: '张经理', contactMobile: '13800138000', version: 'V1', parentId: '0', isLatest: 1, totalAmount: 128600, discountRate: 95, discountAmount: 6430, finalAmount: 122170, validityDays: 30, quoteDate: '2026-10-17', expireDate: '2026-11-16', status: 'draft', ownerUserId: 'user-1', ownerUserName: '测试销售', paymentTerms: '月结 30 天', deliveryTerms: '送货上门', freight: 500, barrelDeposit: 1000, attachments: JSON.stringify([{ ossId: '/fixtures/报价说明.pdf', category: 'other' }]), remark: 'fixture 报价单', createBy: 'user-1', createTime: '2026-10-17 09:00:00', productList: [ { id: 'product-line-1', standardProductName: '工业甲醇', model: '优级品', quantity: 10, unit: 'ton', price: 12000, subtotal: 120000 } ] } let approvals = [ { id: 'approval-1', quoteId: quote.id, quoteNo: quote.quoteNo, step: 1, approverId: 'user-1', approverName: '测试审批人', approvalResult: 'pending', approvalOpinion: '', approvalTime: '', createTime: '2026-10-17 10:00:00' } ] let slowNextPage2 = false let failNextPage2 = false let forbidNextList = false const dict = { crm_quote_status: [ { value: 'draft', label: '草稿', tagType: 'info' }, { value: 'submitted', label: '待审批', tagType: 'warning' }, { value: 'accepted', label: '已审批', tagType: 'success' }, { value: 'rejected', label: '已驳回', tagType: 'danger' } ], crm_product_unit: [{ value: 'ton', label: '吨', tagType: 'info' }] } await context.addInitScript(() => { window.wx = { miniProgram: { navigateBack() {}, reLaunch() {}, getEnv(callback) { callback({ miniprogram: true }) } } } }) await context.route('**/api/**', async (route) => { const request = route.request() const url = new URL(request.url()) if (!url.pathname.startsWith('/api/')) return route.continue() const path = url.pathname.replace(/^\/api/, '') let body = {} try { body = request.postDataJSON() || {} } catch { // 非 JSON 请求不参与本用例的 body 断言。 } calls.push({ path, body }) let data = {} let code = 200 let msg = 'ok' if (path === '/web/auth/mp-webview-exchange') { data = { token: 'fixture-token', refreshToken: 'fixture-refresh', userInfo: { id: 'user-1', userId: 'user-1', userName: '测试用户', role: 'admin', roles: ['admin'] } } } else if (path === '/web/admin/dict-data/all') { data = dict } else if (path === '/web/admin/users/options') { data = [{ value: 'user-1', label: '测试审批人', userNo: 'approver' }] } else if (path === '/web/crm/quote/list') { if (forbidNextList) { forbidNextList = false code = 403 msg = '无权限' } else if (Number(body.pageNum ?? body.page) === 2 && failNextPage2) { failNextPage2 = false code = 500 msg = '分页测试失败' } else { if (Number(body.pageNum ?? body.page) === 2 && slowNextPage2) { slowNextPage2 = false await new Promise((resolve) => setTimeout(resolve, 350)) } data = { list: Number(body.pageNum ?? body.page) === 2 ? [{ ...quote, id: 'quote-2', quoteNo: 'BJ-002' }] : [quote], total: 21 } } } else if (path === '/web/crm/quote/detail') { data = quote } else if (path === '/web/crm/quote/product/list') { data = { list: quote.productList, total: quote.productList.length } } else if (path === '/web/crm/quote/approval/list') { data = { list: approvals } } else if (path === '/web/crm/quote-approver-setting/get') { data = { approverIds: ['user-1'], status: '0' } } else if (path === '/web/crm/quote/submit') { quote.status = 'submitted' data = true } else if (path === '/web/crm/quote/approval/approve') { approvals = approvals.map((item) => ({ ...item, approvalResult: body.approvalResult, approvalOpinion: body.approvalOpinion, approvalTime: '2026-10-17 11:00:00' })) quote.status = body.approvalResult === 'approved' ? 'accepted' : 'rejected' data = true } else if (path === '/web/crm/quote/invalid' || path === '/web/crm/quote/delete') { data = true } else if (path === '/web/crm/quote/versions') { data = [quote] } else if (path === '/web/admin/attachment/preview-url') { data = { previewUrl: 'about:blank#quote-preview' } } await route.fulfill({ contentType: 'application/json', body: JSON.stringify({ code, msg, data }) }) }) const click = (name) => page.getByRole('button', { name, exact: true }).filter({ visible: true }).click({ force: true }) const lastCall = (path) => calls.findLast((item) => item.path === path) async function openMoreSheet() { await page.locator('.mp-wv-fab-bar .mp-wv-btn-more:visible').click({ force: true }) const sheet = page.getByRole('dialog').filter({ hasText: '更多操作' }) await sheet.waitFor() return sheet } async function assertNoOverflow(width) { await page.setViewportSize({ width, height: 812 }) assert.equal( await page.evaluate(() => document.documentElement.scrollWidth > innerWidth), false, `页面在 ${width}px 不应横向溢出` ) } async function assertTouchTarget(locator, label) { const box = await locator.boundingBox() assert.ok((box?.width || 0) >= 44, `${label} 宽度应至少 44px`) assert.ok((box?.height || 0) >= 28 && (box?.height || 0) <= 32, `${label} 上下内间距减半后高约 30px`) } try { await page.goto(`${origin}/#/m/crm/quote?mpTicket=quote-manage-fixture`) await page.getByRole('heading', { name: '报价单管理', exact: true }).waitFor() await page.getByRole('button', { name: `查看报价单:${quote.quoteTitle}` }).waitFor() const listOrder = await Promise.all( ['.mp-wv-header', '.mp-quote-view-tabs', '.mp-quote-dd', '.mp-quote-list-card'].map( async (selector) => (await page.locator(selector).first().boundingBox())?.y ?? -1 ) ) assert.deepEqual( [...listOrder].sort((a, b) => a - b), listOrder ) assert.ok(await page.locator('.mp-quote-dd .van-dropdown-menu__item').count() >= 3) assert.equal(await page.locator('.mp-wv-speed-dial').count(), 1) const firstManageCard = page.locator('.mp-quote-list-card').first() assert.ok(await firstManageCard.getByText('更多字段', { exact: true }).isVisible()) assert.ok(await firstManageCard.getByRole('button', { name: '复制', exact: true }).isVisible()) assert.ok( await firstManageCard.getByRole('button', { name: '提交审批', exact: true }).isVisible() ) assert.ok(await firstManageCard.getByRole('button', { name: '查看', exact: true }).isVisible()) await assertTouchTarget( page.getByRole('button', { name: '加载更多', exact: true }), '加载更多' ) await click('快捷操作') await assertTouchTarget( page.getByRole('button', { name: '新增报价单', exact: true }), 'Speed Dial 新增报价单' ) await click('快捷操作') const search = page.getByRole('searchbox', { name: '搜索报价单' }) await search.fill('临时检索') const searchCallsBeforeEnter = calls.filter((item) => item.path === '/web/crm/quote/list').length await Promise.all([ page.waitForResponse((response) => response.url().endsWith('/web/crm/quote/list')), search.press('Enter') ]) assert.equal( calls.filter((item) => item.path === '/web/crm/quote/list').length, searchCallsBeforeEnter + 1 ) assert.equal(lastCall('/web/crm/quote/list').body.keyword, '临时检索') await Promise.all([ page.waitForResponse((response) => response.url().endsWith('/web/crm/quote/list')), search.fill('') ]) assert.equal(lastCall('/web/crm/quote/list').body.keyword, undefined) await click('全部类型') await Promise.all([ page.waitForResponse((response) => response.url().endsWith('/web/crm/quote/list')), click('采购') ]) assert.equal(lastCall('/web/crm/quote/list').body.quoteType, 'purchase') await click('更多') const filterSheet = page.getByRole('dialog').filter({ hasText: '更多筛选' }) assert.ok(await filterSheet.getByText('报价日期', { exact: true }).isVisible()) assert.ok(await filterSheet.getByText('金额(元)', { exact: true }).isVisible()) await filterSheet.locator('input[type="search"]').fill('年度') await filterSheet.locator('input[type="date"]').nth(0).fill('2026-10-01') await filterSheet.locator('input[type="date"]').nth(1).fill('2026-10-31') await filterSheet.locator('input[type="number"]').nth(0).fill('100') await filterSheet.locator('input[type="number"]').nth(1).fill('200000') await assertTouchTarget( filterSheet.getByRole('button', { name: '确定', exact: true }), '筛选确定' ) await Promise.all([ page.waitForResponse((response) => response.url().endsWith('/web/crm/quote/list')), filterSheet.getByRole('button', { name: '确定', exact: true }).click() ]) assert.deepEqual( { keyword: lastCall('/web/crm/quote/list').body.keyword, quoteDateBegin: lastCall('/web/crm/quote/list').body.quoteDateBegin, quoteDateEnd: lastCall('/web/crm/quote/list').body.quoteDateEnd, minAmount: lastCall('/web/crm/quote/list').body.minAmount, maxAmount: lastCall('/web/crm/quote/list').body.maxAmount }, { keyword: '年度', quoteDateBegin: '2026-10-01', quoteDateEnd: '2026-10-31', minAmount: 100, maxAmount: 200000 } ) slowNextPage2 = true const stalePageRequest = page.waitForRequest( (request) => request.url().endsWith('/web/crm/quote/list') && request.postDataJSON()?.pageNum === 2 ) void click('加载更多') await stalePageRequest await click('采购') await Promise.all([ page.waitForResponse((response) => response.url().endsWith('/web/crm/quote/list')), click('销售') ]) await page.waitForTimeout(450) assert.equal(await page.getByText('BJ-002', { exact: true }).count(), 0) failNextPage2 = true await Promise.all([ page.waitForResponse((response) => response.url().endsWith('/web/crm/quote/list')), click('加载更多') ]) await page.getByText('分页测试失败', { exact: true }).waitFor() assert.ok(await page.getByRole('button', { name: '加载更多', exact: true }).isVisible()) await click('加载更多') await page.getByText('BJ-002', { exact: true }).waitFor() assert.equal(lastCall('/web/crm/quote/list').body.pageNum, 2) await click('快捷操作') assert.ok(await page.getByRole('button', { name: '新增报价单', exact: true }).isVisible()) await click('快捷操作') await page.waitForFunction(() => document.querySelectorAll('.el-message').length === 0) await page.screenshot({ path: `${artifacts}/manage-list-375.png`, fullPage: true }) await assertNoOverflow(375) await firstManageCard.getByRole('button', { name: '复制', exact: true }).click() await page.waitForURL(/\/m\/crm\/quote\/new\?copyFrom=quote-1/) await page.goto(`${origin}/#/m/crm/quote?mpTicket=quote-manage-actions`) await page.getByRole('heading', { name: '报价单管理', exact: true }).waitFor() await firstManageCard.getByRole('button', { name: '提交审批', exact: true }).click() await page.getByRole('heading', { name: '报价单详情', exact: true }).waitFor() const submitSheet = page.getByRole('dialog').filter({ hasText: '提交审批' }) await submitSheet.waitFor() await assertTouchTarget( submitSheet.getByRole('button', { name: '确认提交', exact: true }), '提交审批确认' ) assert.doesNotMatch(page.url(), /action=/) await submitSheet.getByRole('button', { name: '关闭', exact: true }).click() assert.equal(await page.locator('.mp-wv-header').getByText(quote.quoteTitle).count(), 0) assert.equal(await page.locator('.mp-wv-header__sub').count(), 0) for (const button of await page.locator('.mp-wv-fab-bar .mp-wv-btn').all()) { const box = await button.boundingBox() assert.ok((box?.width || 0) >= 44 && (box?.height || 0) >= 28 && (box?.height || 0) <= 32) } await click('编辑') await page.waitForURL(/\/m\/crm\/quote\/quote-1\/edit/) await page.goBack() await page.getByRole('heading', { name: '报价单详情', exact: true }).waitFor() let actionSheet = await openMoreSheet() const deleteAction = actionSheet.getByRole('button', { name: '删除', exact: true }) assert.equal(await deleteAction.isDisabled(), false) await deleteAction.click() const deleteDialog = page.getByRole('dialog', { name: '提示', exact: true }) await deleteDialog.getByRole('button', { name: '取消', exact: true }).click() await deleteDialog.waitFor({ state: 'hidden' }) await actionSheet.waitFor({ state: 'hidden' }) actionSheet = await openMoreSheet() await actionSheet.getByRole('button', { name: '作废', exact: true }).click() const voidDialog = page.getByRole('dialog', { name: '作废报价单', exact: true }) await voidDialog.getByRole('button', { name: '取消', exact: true }).click() await voidDialog.waitFor({ state: 'hidden' }) await actionSheet.waitFor({ state: 'hidden' }) actionSheet = await openMoreSheet() await actionSheet.getByRole('button', { name: '复制新增', exact: true }).click() await page.waitForURL(/\/m\/crm\/quote\/new\?copyFrom=quote-1/) await page.goBack() await page.getByRole('heading', { name: '报价单详情', exact: true }).waitFor() actionSheet = await openMoreSheet() await actionSheet.getByRole('button', { name: '版本历史', exact: true }).click() await page.waitForURL(/\/m\/crm\/quote\/quote-1\/versions/) await page.getByRole('heading', { name: '版本历史', exact: true }).waitFor() await page.goBack() await page.getByRole('heading', { name: '报价单详情', exact: true }).waitFor() await click('产品明细') await page.getByText('工业甲醇', { exact: true }).waitFor() await click('审批信息') await page.locator('.mp-crm-approval-timeline').waitFor() await click('附件') const attachment = page.locator('.mp-crm-att-grid .att-item').first() const attachmentBox = await attachment.boundingBox() assert.equal(Math.round(attachmentBox?.width || 0), 72) assert.equal(Math.round(attachmentBox?.height || 0), 72) assert.ok(await page.getByText('报价说明.pdf', { exact: true }).isVisible()) await Promise.all([ page.waitForResponse((response) => response.url().endsWith('/web/admin/attachment/preview-url') ), attachment.locator('.att-preview').click() ]) assert.equal(lastCall('/web/admin/attachment/preview-url').body.fileName, '报价说明.pdf') const previewDialog = page.getByRole('dialog', { name: '报价说明.pdf', exact: true }) await previewDialog.waitFor() await previewDialog.locator('.el-dialog__headerbtn').click() await previewDialog.waitFor({ state: 'hidden' }) await page .locator('.mp-quote-detail-tab') .filter({ hasText: '基础信息' }) .evaluate((button) => button.click()) await page.getByText(quote.quoteNo, { exact: true }).waitFor() await page.waitForFunction(() => document.querySelectorAll('.el-message').length === 0) await page.screenshot({ path: `${artifacts}/manage-detail-375.png`, fullPage: true }) await click('提交审批') await page.getByRole('dialog').filter({ hasText: '提交审批' }).waitFor() await click('确认提交') await page.getByText('待审批', { exact: true }).filter({ visible: true }).first().waitFor() assert.deepEqual(lastCall('/web/crm/quote/submit').body.approverIds, ['user-1']) approvals = [ { ...approvals[0], id: 'approval-prior', step: '1', approverId: 'user-2', approverName: '前置审批人', approvalResult: 'pending' }, { ...approvals[0], id: 'approval-current', step: 2, approverId: 'user-1', approverName: '测试审批人', approvalResult: 'pending' } ] await page.goto(`${origin}/#/m/crm/quote/quote-1?from=approve&mpTicket=quote-direct-fixture`) await page.getByRole('heading', { name: '报价单详情', exact: true }).waitFor() await page.reload() await page.getByRole('heading', { name: '报价单详情', exact: true }).waitFor() assert.equal(await page.getByRole('button', { name: '通过', exact: true }).count(), 0) assert.equal(await page.getByRole('button', { name: '驳回', exact: true }).count(), 0) approvals = [ { ...approvals[0], id: 'approval-rejected-prior', step: 1, approverId: 'user-2', approvalResult: 'rejected' }, { ...approvals[1], id: 'approval-current-after-rejected', step: 2, approverId: 'user-1', approvalResult: 'pending' } ] await page.reload() await page.getByRole('heading', { name: '报价单详情', exact: true }).waitFor() assert.equal(await page.getByRole('button', { name: '通过', exact: true }).count(), 0) assert.equal(await page.getByRole('button', { name: '驳回', exact: true }).count(), 0) approvals = [ { ...approvals[1], id: 'approval-1', step: 1, approvalResult: 'pending' } ] quote.status = 'draft' await page.reload() await page.getByRole('heading', { name: '报价单详情', exact: true }).waitFor() assert.equal(await page.getByRole('button', { name: '通过', exact: true }).count(), 0) assert.equal(await page.getByRole('button', { name: '驳回', exact: true }).count(), 0) quote.status = 'submitted' await page.goto(`${origin}/#/m/crm/quote/approval?mpTicket=quote-approve-fixture`) await page.getByRole('heading', { name: '报价单审批', exact: true }).waitFor() await page.getByRole('button', { name: `查看报价单:${quote.quoteTitle}` }).waitFor() assert.equal(await page.locator('.mp-wv-speed-dial').count(), 0) assert.equal(lastCall('/web/crm/quote/list').body.pendingApprovalUserId, 'user-1') let approvalCard = page.locator('.mp-quote-list-card').first() for (const label of ['驳回', '通过']) { const button = approvalCard.getByRole('button', { name: label, exact: true }) assert.ok(await button.isVisible()) } await approvalCard.getByRole('button', { name: '驳回', exact: true }).click() await page.getByRole('heading', { name: '报价单详情', exact: true }).waitFor() assert.match(page.url(), /from=approve/) const rejectSheet = page.getByRole('dialog').filter({ hasText: '驳回报价单' }) await rejectSheet.waitFor() const approveCallsBeforeRequired = calls.filter( (item) => item.path === '/web/crm/quote/approval/approve' ).length const rejectConfirm = rejectSheet.getByRole('button', { name: '确认驳回', exact: true }) await assertTouchTarget(rejectConfirm, '驳回确认') await rejectConfirm.evaluate((button) => button.click()) await page.getByText('驳回时请填写拒绝理由', { exact: true }).waitFor() assert.equal( calls.filter((item) => item.path === '/web/crm/quote/approval/approve').length, approveCallsBeforeRequired ) await rejectSheet.locator('textarea').fill('资料不完整') await rejectConfirm.evaluate((button) => button.click()) const rejectDialog = page.locator('.van-dialog.mp-crm-dialog').filter({ hasText: '确认驳回' }) await rejectDialog.waitFor() await rejectDialog.getByRole('button', { name: '确认驳回', exact: true }).click() await rejectDialog.waitFor({ state: 'hidden' }) await rejectSheet.waitFor({ state: 'hidden' }) assert.equal(lastCall('/web/crm/quote/approval/approve').body.approvalResult, 'rejected') assert.equal(lastCall('/web/crm/quote/approval/approve').body.approvalOpinion, '资料不完整') quote.status = 'submitted' approvals = [ { ...approvals[0], id: 'approval-1', step: 1, approvalResult: 'pending', approvalOpinion: '', approvalTime: '' } ] await page.goto(`${origin}/#/m/crm/quote/approval?mpTicket=quote-approve-again`) await page.getByRole('heading', { name: '报价单审批', exact: true }).waitFor() approvalCard = page.locator('.mp-quote-list-card').first() await approvalCard.getByRole('button', { name: '通过', exact: true }).click() const approveSheet = page.getByRole('dialog').filter({ hasText: '通过报价单' }) await approveSheet.waitFor() const approveConfirm = approveSheet.getByRole('button', { name: '确认通过', exact: true }) await assertTouchTarget(approveConfirm, '通过确认') await approveConfirm.evaluate((button) => button.click()) const confirmDialog = page.locator('.van-dialog.mp-crm-dialog').filter({ hasText: '确认通过' }) await confirmDialog.waitFor() await confirmDialog.getByRole('button', { name: '确认', exact: true }).click() await confirmDialog.waitFor({ state: 'hidden' }) await approveSheet.waitFor({ state: 'hidden' }) await page.getByText('已审批', { exact: true }).filter({ visible: true }).first().waitFor() assert.equal(lastCall('/web/crm/quote/approval/approve').body.id, 'approval-1') assert.equal(lastCall('/web/crm/quote/approval/approve').body.approvalResult, 'approved') await page.waitForFunction(() => document.querySelectorAll('.el-message').length === 0) await assertNoOverflow(414) await page.screenshot({ path: `${artifacts}/approve-detail-414.png`, fullPage: true }) await page.getByRole('button', { name: '返回报价单审批', exact: true }).click() await page.getByRole('heading', { name: '报价单审批', exact: true }).waitFor() assert.match(page.url(), /\/m\/crm\/quote\/approval/) await Promise.all([ page.waitForResponse((response) => response.url().endsWith('/web/crm/quote/list')), click('已审批') ]) assert.equal(lastCall('/web/crm/quote/list').body.approvedByUserId, 'user-1') assert.equal(await page.getByRole('button', { name: '驳回', exact: true }).count(), 0) assert.equal(await page.getByRole('button', { name: '通过', exact: true }).count(), 0) await page.screenshot({ path: `${artifacts}/approve-list-414.png`, fullPage: true }) forbidNextList = true await page.goto(`${origin}/#/m/crm/quote?mpTicket=quote-forbidden-fixture`) await page.getByText('无权限', { exact: true }).waitFor() assert.equal(await page.locator('.mp-quote-list-card').count(), 0) assert.equal(await page.locator('.mp-wv-speed-dial').count(), 0) assert.deepEqual(pageErrors, []) console.log( JSON.stringify({ result: 'PASS', assertions: '管理/审批列表与卡片动作、搜索清空、更多筛选、分页乱序与失败回滚、独立详情直达刷新、列表/Speed Dial/Sheet/FAB 44px触达、空操作底栏、最早审批步骤与前置驳回/报价状态门禁、驳回必填与通过、附件解析预览、403、375/414布局', apiCalls: calls.length, screenshots: artifacts }) ) } catch (error) { await page.screenshot({ path: `${artifacts}/failure.png`, fullPage: true }) console.error(await page.locator('body').innerText()) console.error('Page errors:', pageErrors) console.error('API calls:', calls) throw error } finally { await browser.close() }