diff --git a/frontend/e2e/ledger-dialog-regression.mjs b/frontend/e2e/ledger-dialog-regression.mjs new file mode 100644 index 0000000..23741fa --- /dev/null +++ b/frontend/e2e/ledger-dialog-regression.mjs @@ -0,0 +1,127 @@ +/** 隔离渲染真实组件与样式,只使用合成台账,不连接业务接口。 */ +import assert from 'node:assert/strict' +import fs from 'node:fs' +import { createRequire } from 'node:module' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +import vue from '@vitejs/plugin-vue' +import { createServer } from 'vite' + +const require = createRequire(import.meta.url) +const { chromium } = require(process.env.PLAYWRIGHT_MODULE || 'C:/Users/Administrator/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright') +const root = fileURLToPath(new URL('../', import.meta.url)) +const output = path.resolve(process.env.UI_REGRESSION_OUTPUT || path.join(root, '../.ai/ui-review-20261102/regression')) +const virtualId = 'virtual:ledger-dialog-regression' +const harness = ` +import { createApp, h, ref } from 'vue' +import ElementPlus, { ElButton, ElDialog } from 'element-plus' +import 'element-plus/dist/index.css' +import '/src/assets/styles/index.css' +import LedgerCalculationDetails from '/src/components/payroll/LedgerCalculationDetails.vue' + +createApp({ setup() { + const ledger = ref(null), loading = ref(false), visible = ref(false), lineCount = ref(50) + window.setLedger = (row, pending = false) => { ledger.value = row; loading.value = pending } + window.showDialog = (count) => { lineCount.value = count; visible.value = true } + window.saved = 0 + return () => h('main', [ + h(LedgerCalculationDetails, { ledger: ledger.value, loading: loading.value }), + h(ElDialog, { modelValue: visible.value, 'onUpdate:modelValue': value => visible.value = value, + title: '长表单回归验证', width: '680px', destroyOnClose: true }, { + default: () => Array.from({ length: lineCount.value }, (_, i) => h('p', '表单内容 ' + (i + 1))), + footer: () => h(ElButton, { type: 'primary', onClick: () => { window.saved++; visible.value = false } }, () => '保存'), + }), + ]) +} }).use(ElementPlus).mount('#app') +` +const server = await createServer({ + configFile: false, root, cacheDir: path.join(output, 'vite-cache'), plugins: [vue(), { + name: 'ledger-dialog-regression', + resolveId: id => id === virtualId ? '\0' + virtualId : undefined, + load: id => id === '\0' + virtualId ? harness : undefined, + configureServer(server) { + server.middlewares.use('/__ui-regression', async (_request, response) => { + const html = await server.transformIndexHtml('/__ui-regression', '
') + response.setHeader('Content-Type', 'text/html; charset=utf-8') + response.end(html) + }) + }, + }], + server: { host: '127.0.0.1', port: 0 }, + optimizeDeps: { noDiscovery: true, include: ['vue', 'element-plus'] }, +}) +let browser +const checks = [], errors = [], layouts = [] +try { + fs.mkdirSync(output, { recursive: true }) + await server.listen() + const address = server.httpServer.address() + browser = await chromium.launch({ headless: true, channel: 'msedge' }) + const page = await browser.newPage({ viewport: { width: 1440, height: 800 }, reducedMotion: 'reduce' }) + page.on('pageerror', error => errors.push(error.message)) + await page.route(url => url.pathname.startsWith('/api/'), () => { throw new Error('隔离回归不应访问业务接口') }) + await page.goto(`http://127.0.0.1:${address.port}/__ui-regression`) + await page.waitForLoadState('networkidle') + const details = page.getByRole('region', { name: '计算依据' }) + const row = { calc_type: 1, settlement_month: '2026-04', ship_date: '2026-03-01', receipt_date: '2026-03-10', payment_days: 9, + rate_text: '1%', converted_profit: '0.00', performance_amount: '0.00', payable_amount: '0.06', weight_r: 100, + snapshot_json: { engine: 'FIFO', first_accrual: false, event: { amount: '80.00', allocated_cost: '40.00' } } } + await page.evaluate(row => window.setLedger(row), row) + await details.getByText('原匹配回款(元)', { exact: true }).waitFor() + assert.match(await details.innerText(), /本期为跨月续算/) + assert.equal(await details.getByText('本次匹配回款(元)', { exact: true }).count(), 0) + for (const [label, value] of [['原匹配回款(元)', '80.00'], ['原匹配指导成本分摊(元)', '40.00'], ['本期应付(元)', '0.06'], ['计提绩效(元)', '0.00']]) { + assert.equal((await details.getByText(label, { exact: true }).locator('xpath=following-sibling::td[1]').innerText()).trim(), value) + } + await page.screenshot({ path: path.join(output, 'carry-forward.png') }) + checks.push('跨月续算保留原匹配金额并明确历史依据,本期应付为 0.06 元') + await page.evaluate(row => window.setLedger({ ...row, snapshot_json: { ...row.snapshot_json, first_accrual: true } }), row) + await details.getByText('本次匹配回款(元)', { exact: true }).waitFor() + assert.equal(await details.getByText('本次指导成本分摊(元)', { exact: true }).count(), 1) + assert.doesNotMatch(await details.innerText(), /本期为跨月续算|原匹配/) + checks.push('首次计提沿用本次匹配说明') + await page.evaluate(row => window.setLedger({ ...row, snapshot_json: null }), row) + await details.getByText('单笔绩效核算', { exact: true }).waitFor() + assert.doesNotMatch(await details.innerText(), /本期为跨月续算|匹配回款/) + await page.evaluate(() => window.setLedger(null, true)) + await details.getByRole('status').getByText('正在加载计算依据…').waitFor() + await page.evaluate(() => window.setLedger(null)) + await details.getByText('暂无可查看的计算依据。', { exact: true }).waitFor() + checks.push('旧记录、加载中和空记录正常展示') + + const dialog = page.getByRole('dialog', { name: '长表单回归验证' }) + const panel = dialog.locator('.el-dialog') + for (const [width, height] of [[1440, 800], [1440, 600], [1366, 500], [768, 1024], [601, 600], [600, 600], [390, 844], [844, 390]]) { + await page.setViewportSize({ width, height }) + await page.evaluate(() => window.showDialog(50)) + await dialog.waitFor({ state: 'visible' }) + const layout = await panel.evaluate(el => { + const rect = el.getBoundingClientRect(), footer = el.querySelector('.el-dialog__footer').getBoundingClientRect() + const body = el.querySelector('.el-dialog__body') + body.scrollTop = body.scrollHeight + return { top: rect.top, bottom: rect.bottom, left: rect.left, right: rect.right, footerBottom: footer.bottom, + bodyScroll: body.scrollTop, bottomGap: parseFloat(getComputedStyle(el).getPropertyValue('--ys-dialog-bottom-gap')) } + }) + assert.ok(layout.top >= 0 && layout.bottom <= height - layout.bottomGap + 1, `${width}×${height} 弹窗越界:${JSON.stringify(layout)}`) + assert.ok(layout.left >= 0 && layout.right <= width, '弹窗横向越界') + assert.ok(layout.bodyScroll > 0 && layout.footerBottom <= height, '正文应可滚动且按钮可见') + if ((width === 1440 && height === 800) || width === 390) await page.screenshot({ path: path.join(output, `long-dialog-${width}.png`) }) + await dialog.getByRole('button', { name: '保存', exact: true }).click() + await dialog.waitFor({ state: 'hidden' }) + layouts.push({ width, height, ...layout }) + } + assert.equal(await page.evaluate(() => window.saved), layouts.length) + await page.evaluate(() => window.showDialog(1)) + await dialog.waitFor({ state: 'visible' }) + assert.ok((await panel.boundingBox()).height < 250, '短弹窗不应被撑满视口') + await dialog.getByRole('button', { name: '保存', exact: true }).click() + checks.push('8 种视口长弹窗正文滚动、底部留白及保存按钮可点击,短弹窗自然收缩') + assert.deepEqual(errors, []) + const result = { checks, layouts, pageErrors: errors } + fs.writeFileSync(path.join(output, 'result.json'), JSON.stringify(result, null, 2)) + console.log(JSON.stringify(result, null, 2)) +} finally { + await browser?.close() + await server.close() +} diff --git a/frontend/src/assets/styles/index.css b/frontend/src/assets/styles/index.css index 10cc895..57f0b10 100644 --- a/frontend/src/assets/styles/index.css +++ b/frontend/src/assets/styles/index.css @@ -25,7 +25,9 @@ --ys-ink: #1c2333; --ys-ink-2: #5b6577; - --ys-ink-3: #9aa3b2; + --ys-ink-3: #657086; + --el-text-color-secondary: var(--ys-ink-2); + --el-text-color-placeholder: var(--ys-ink-3); --ys-line: rgba(28, 35, 51, 0.09); --ys-line-soft: rgba(28, 35, 51, 0.065); @@ -45,6 +47,8 @@ --ys-radius: 10px; --ys-radius-sm: 7px; --ys-ease: cubic-bezier(0.25, 0.1, 0.25, 1); + --el-transition-duration: 0.18s; + --el-transition-duration-fast: 0.12s; --ys-shadow-card: 0 1px 2px rgba(28, 35, 51, 0.04), 0 3px 10px rgba(28, 35, 51, 0.03); @@ -649,6 +653,119 @@ span.cell-num { padding: 18px 20px; } +/* 弹窗共享同一组留白;长表单在正文内滚动,操作按钮始终可见。 */ +.el-dialog:not(.is-fullscreen) { + --ys-dialog-top-gap: min(8dvh, 64px); + --ys-dialog-bottom-gap: 24px; + display: flex; + flex-direction: column; + max-width: calc(100vw - 32px); + max-height: calc(100dvh - var(--ys-dialog-top-gap) - var(--ys-dialog-bottom-gap)); + margin-top: var(--ys-dialog-top-gap); + padding: 0; + overflow: hidden; + border-radius: var(--ys-radius); +} + +.el-dialog__header { + flex-shrink: 0; + margin-right: 0; + padding: 20px 52px 16px 24px; +} + +.el-dialog__title { + color: var(--ys-ink); + font-size: 16px; + font-weight: 600; + line-height: 1.5; +} + +.el-dialog__headerbtn { + top: 14px; + right: 14px; + width: 32px; + height: 32px; +} + +.el-dialog__body { + min-height: 0; + padding: 4px 24px 20px; + overflow-y: auto; + overscroll-behavior: contain; + color: var(--ys-ink-2); +} + +.el-dialog__footer { + display: flex; + flex: none; + flex-wrap: wrap; + align-items: center; + justify-content: flex-end; + gap: 10px; + padding: 16px 24px; + border-top: 1px solid var(--ys-line-soft); + background: var(--ys-mist); +} + +.el-dialog__footer > .el-button { + min-width: 88px; + min-height: 36px; + margin: 0; +} + +.el-drawer { + max-width: 100vw; +} + +.el-message-box { + max-width: calc(100vw - 32px); +} + +.el-message-box__btns { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 10px; + padding-top: 20px; +} + +.el-message-box__btns > .el-button { + min-width: 88px; + min-height: 36px; + margin: 0; +} + +.form-hint { + margin: 8px 0 0; + color: var(--ys-ink-2); + font-size: 12px; + line-height: 1.7; +} + +@media (max-width: 600px) { + .el-dialog:not(.is-fullscreen) { + --ys-dialog-top-gap: 12px; + --ys-dialog-bottom-gap: 12px; + max-width: calc(100vw - 24px); + } + + .el-dialog__header { + padding: 18px 48px 14px 16px; + } + + .el-dialog__body { + padding: 4px 16px 16px; + } + + .el-dialog__footer { + padding: 12px 16px; + } + + .el-dialog__footer > .el-button { + min-height: 40px; + } +} + /* 空状态:弱墨、留白,不喊叫 */ .el-table__empty-text { font-size: 13px; @@ -675,6 +792,11 @@ span.cell-num { } @media (prefers-reduced-motion: reduce) { + :root { + --el-transition-duration: 0s; + --el-transition-duration-fast: 0s; + } + .el-button, .el-input__wrapper, .el-select__wrapper, diff --git a/frontend/src/components/payroll/LedgerCalculationDetails.vue b/frontend/src/components/payroll/LedgerCalculationDetails.vue new file mode 100644 index 0000000..d4dc259 --- /dev/null +++ b/frontend/src/components/payroll/LedgerCalculationDetails.vue @@ -0,0 +1,64 @@ + + + + +