fix(前端): 修复续算金额文案和长弹窗越界
跨月续算时将历史匹配回款与指导成本标为计算依据,避免页面误称为本次金额,并明确本期应付为续算差额。\n\n统一弹窗顶部间距与最大高度计算,保留正文滚动和底部操作区;新增隔离浏览器回归覆盖首次计提、续算状态及多种视口。\n\n验证:pnpm build;node frontend/e2e/ledger-dialog-regression.mjs。
This commit is contained in:
parent
71b7846a79
commit
02fb06e0af
127
frontend/e2e/ledger-dialog-regression.mjs
Normal file
127
frontend/e2e/ledger-dialog-regression.mjs
Normal file
@ -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', '<!doctype html><html lang="zh-CN"><meta charset="UTF-8"><div id="app"></div><script type="module" src="/@id/__x00__virtual:ledger-dialog-regression"></script></html>')
|
||||||
|
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()
|
||||||
|
}
|
||||||
@ -25,7 +25,9 @@
|
|||||||
|
|
||||||
--ys-ink: #1c2333;
|
--ys-ink: #1c2333;
|
||||||
--ys-ink-2: #5b6577;
|
--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: rgba(28, 35, 51, 0.09);
|
||||||
--ys-line-soft: rgba(28, 35, 51, 0.065);
|
--ys-line-soft: rgba(28, 35, 51, 0.065);
|
||||||
@ -45,6 +47,8 @@
|
|||||||
--ys-radius: 10px;
|
--ys-radius: 10px;
|
||||||
--ys-radius-sm: 7px;
|
--ys-radius-sm: 7px;
|
||||||
--ys-ease: cubic-bezier(0.25, 0.1, 0.25, 1);
|
--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:
|
--ys-shadow-card:
|
||||||
0 1px 2px rgba(28, 35, 51, 0.04),
|
0 1px 2px rgba(28, 35, 51, 0.04),
|
||||||
0 3px 10px rgba(28, 35, 51, 0.03);
|
0 3px 10px rgba(28, 35, 51, 0.03);
|
||||||
@ -649,6 +653,119 @@ span.cell-num {
|
|||||||
padding: 18px 20px;
|
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 {
|
.el-table__empty-text {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
@ -675,6 +792,11 @@ span.cell-num {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
:root {
|
||||||
|
--el-transition-duration: 0s;
|
||||||
|
--el-transition-duration-fast: 0s;
|
||||||
|
}
|
||||||
|
|
||||||
.el-button,
|
.el-button,
|
||||||
.el-input__wrapper,
|
.el-input__wrapper,
|
||||||
.el-select__wrapper,
|
.el-select__wrapper,
|
||||||
|
|||||||
64
frontend/src/components/payroll/LedgerCalculationDetails.vue
Normal file
64
frontend/src/components/payroll/LedgerCalculationDetails.vue
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
import type { LedgerRow } from '@/api/calc'
|
||||||
|
|
||||||
|
const props = defineProps<{ ledger?: LedgerRow | null; loading?: boolean }>()
|
||||||
|
const snapshot = computed(() => props.ledger?.snapshot_json ?? {})
|
||||||
|
const event = computed(() => (snapshot.value.event ?? {}) as Record<string, unknown>)
|
||||||
|
const isCarryForward = computed(() => snapshot.value.engine === 'FIFO' && snapshot.value.first_accrual === false)
|
||||||
|
const lineDetails = computed(() => Array.isArray(snapshot.value.line_details) ? snapshot.value.line_details : [])
|
||||||
|
const allocations = computed(() => Array.isArray(snapshot.value.alloc_plan) ? snapshot.value.alloc_plan : [])
|
||||||
|
const categories: Record<string, string> = { municipal_water: '市政水利', rail_transit: '轨道交通', mixed: '混合类别' }
|
||||||
|
const text = (value: unknown): string => value === null || value === undefined || value === '' ? '—' : String(value)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section v-loading="loading" class="calculation-details" aria-label="计算依据">
|
||||||
|
<p v-if="loading" role="status">正在加载计算依据…</p>
|
||||||
|
<template v-else-if="ledger">
|
||||||
|
<p v-if="isCarryForward" class="form-hint">本期为跨月续算,原匹配回款和指导成本仅作计算依据;本期应付为本期续算差额。</p>
|
||||||
|
<el-descriptions :column="2" size="small" border>
|
||||||
|
<el-descriptions-item label="核算方式">{{ ledger.calc_type === 2 ? '经理分成汇总' : snapshot.engine === 'FIFO' ? '按发货顺序平账' : '单笔绩效核算' }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="归属月份">{{ ledger.settlement_month }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="发货日期">{{ text(ledger.ship_date) }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="到账日期">{{ text(ledger.receipt_date) }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="回款天数">{{ text(ledger.payment_days) }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="计提比例">{{ ledger.rate_text }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="折算毛利润(元)">{{ text(ledger.converted_profit) }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="计提绩效(元)">{{ text(ledger.performance_amount) }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="本期应付(元)">{{ text(ledger.payable_amount) }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="累计回款比例">{{ ledger.weight_r === null ? '—' : `${ledger.weight_r}%` }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item v-if="event.allocated_cost !== undefined" :label="isCarryForward ? '原匹配指导成本分摊(元)' : '本次指导成本分摊(元)'">{{ text(event.allocated_cost) }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item v-if="event.amount !== undefined" :label="isCarryForward ? '原匹配回款(元)' : '本次匹配回款(元)'">{{ text(event.amount) }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item v-if="snapshot.rate_note" label="计提说明" :span="2">{{ text(snapshot.rate_note) }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item v-if="ledger.calc_reason" label="重算原因" :span="2">{{ ledger.calc_reason }}</el-descriptions-item>
|
||||||
|
</el-descriptions>
|
||||||
|
<el-table v-if="lineDetails.length" :data="lineDetails" size="small" class="calculation-lines">
|
||||||
|
<el-table-column label="产品类别" min-width="110"><template #default="{ row }">{{ categories[row.product_category] ?? '其他类别' }}</template></el-table-column>
|
||||||
|
<el-table-column prop="revenue" label="销售额(元)" min-width="120" align="right" />
|
||||||
|
<el-table-column prop="profit" label="毛利润(元)" min-width="120" align="right" />
|
||||||
|
<el-table-column prop="converted" label="折算毛利润(元)" min-width="145" align="right" />
|
||||||
|
</el-table>
|
||||||
|
<el-table v-if="allocations.length" :data="allocations" size="small" class="calculation-lines">
|
||||||
|
<el-table-column prop="personnel_name" label="分配人员" min-width="120" />
|
||||||
|
<el-table-column prop="ratio" label="分配比例(%)" min-width="120" align="right" />
|
||||||
|
</el-table>
|
||||||
|
</template>
|
||||||
|
<p v-else>暂无可查看的计算依据。</p>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.calculation-details {
|
||||||
|
padding: 16px;
|
||||||
|
color: var(--ys-ink-2);
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
.calculation-lines {
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
.calculation-details :deep(.el-descriptions__body) {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Loading…
Reference in New Issue
Block a user