feat(mobile-oa-h5): inline payment detail transfers aligned with web dialog

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zlwuandCursor committed 2026-10-17 11:12:23 +08:00
1 parent c825c170e7
commit d355cd0640
7 files changed
+272 -128

No files matched your search

@@ -0,0 +1,146 @@
<template>
<section class="payment-detail-transfers">
<div class="payment-detail-transfers__title">应缴批次</div>
<van-empty
v-if="!localSchedules.length"
description="暂无应缴批次"
image-size="48"
/>
<article
v-for="(sch, index) in localSchedules"
:key="sch.id || index"
class="payment-detail-transfers__period"
>
<header class="payment-detail-transfers__head">
<strong>
第 {{ sch.scheduleNo || index + 1 }} 期 · 应缴 {{ formatMoney(sch.dueAmount) }} · {{ displayDate(sch.dueDate) }}
</strong>
<van-tag class="market-tag" :type="scheduleStatusTone(sch)" round>
{{ scheduleStatusLabel(sch) }}
</van-tag>
</header>
<div class="payment-detail-transfers__metrics">
<div class="payment-detail-transfers__metric">
<span>已缴</span>
<b>{{ formatMoney(sch.paidAmount) }}</b>
</div>
<div class="payment-detail-transfers__metric">
<span>欠缴</span>
<b>{{ formatMoney(sch.arrearsAmount) }}</b>
</div>
</div>
<ScheduleTransferEditor
:schedule="sch"
:transfer-types="transferTypes"
:market-role="marketRole"
:context-id="String(detail.id || '')"
:disabled="false"
@update:schedule="onScheduleUpdate(index, $event)"
@changed="$emit('refresh')"
/>
</article>
</section>
</template>
<script setup>
import { ref, watch } from 'vue'
import { formatMoney } from '../money.js'
import { resolveStatusTag, paymentScheduleStatus } from '../status-tag-registry.js'
import ScheduleTransferEditor from './ScheduleTransferEditor.vue'
const props = defineProps({
detail: { type: Object, required: true },
marketRole: { type: String, required: true },
transferTypes: { type: Array, default: () => [] }
})
defineEmits(['refresh'])
const localSchedules = ref([])
watch(
() => props.detail?.schedules,
(schedules) => {
localSchedules.value = (Array.isArray(schedules) ? schedules : []).map((row) => ({ ...row }))
},
{ immediate: true, deep: true }
)
function displayDate(value) {
const text = String(value || '').trim()
return /^\d{8}$/.test(text)
? `${text.slice(0, 4)}-${text.slice(4, 6)}-${text.slice(6, 8)}`
: text || '—'
}
function scheduleStatusLabel(schedule) {
return resolveStatusTag(paymentScheduleStatus, schedule?.scheduleStatus).label
}
function scheduleStatusTone(schedule) {
return resolveStatusTag(paymentScheduleStatus, schedule?.scheduleStatus).tagType
}
function onScheduleUpdate(index, nextSchedule) {
const next = [...localSchedules.value]
next[index] = nextSchedule
localSchedules.value = next
}
</script>
<style scoped>
.payment-detail-transfers {
margin: 12px 0 0;
}
.payment-detail-transfers__title {
margin: 0 0 10px;
padding: 0 2px;
font-size: 15px;
font-weight: 700;
color: #1f2a26;
}
.payment-detail-transfers__period {
margin-bottom: 12px;
border: 1px solid #eef4f1;
border-radius: 12px;
background: #fafcfb;
overflow: hidden;
}
.payment-detail-transfers__head {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
padding: 12px;
background: #e9f9f2;
}
.payment-detail-transfers__head strong {
flex: 1;
min-width: 140px;
font-size: 13px;
color: #1f2a26;
}
.payment-detail-transfers__metrics {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px 12px;
padding: 12px;
border-bottom: 1px dashed #eef4f1;
}
.payment-detail-transfers__metric span {
display: block;
color: #6b7c76;
font-size: 11px;
}
.payment-detail-transfers__metric b {
font-size: 13px;
color: #1f2a26;
}
</style>
@@ -463,7 +463,7 @@ test('market forms share attachment cards and preserve their existing value shap
assert.match(presignForm, /createPendingMarketAttachment/)
})
test('payment detail presents every web field and nested transfer field with Chinese labels', () => {
test('payment detail presents summary overview fields with Chinese labels', () => {
const sections = getMarketModule('payment').detailSections({
protocolNo: 'P-1',
motherName: '张三',
@@ -498,24 +498,21 @@ test('payment detail presents every web field and nested transfer field with Chi
}]
})
assert.deepEqual(sections.map((section) => section.title), ['缴费概览', '应缴批次'])
assert.deepEqual(sections.map((section) => section.title), ['缴费概览'])
assert.equal(sections.find((section) => section.key === 'schedules'), undefined)
assert.deepEqual(
sections[0].fields.map((field) => field.label),
['客户', '联系电话', '样本编码', '缴费方式', '合同额', '已缴 / 待缴', '整体进度', '协议状态', '缴费进度']
)
assert.equal(sections[1].cards[0].variant, 'schedule')
assert.deepEqual(
sections[1].cards[0].fields.map((field) => field.label),
['应缴金额', '应缴日', '已缴金额', '欠缴金额']
)
const transfer = sections[1].cards[0].cards[0]
assert.equal(transfer.variant, 'transfer')
assert.equal(transfer.amount, '¥3,000.00')
assert.match(transfer.meta, /微信转账/)
assert.deepEqual(
transfer.fields.map((field) => field.label),
['备注', '财务备注', '转账凭证']
)
})
test('payment detail hosts PaymentDetailTransfersPanel and hides transfer bottom actions', () => {
const detail = source('../views/market/MarketModuleDetail.vue')
assert.match(detail, /PaymentDetailTransfersPanel/)
assert.match(detail, /filterPaymentDetailBottomActions/)
const panel = source('./components/PaymentDetailTransfersPanel.vue')
assert.match(panel, /ScheduleTransferEditor/)
assert.match(panel, /应缴批次/)
})
test('payment cards reproduce the Web Excel columns and operation labels', () => {
@@ -27,7 +27,7 @@ test('payment chase renders payment and protocol payloads with their native deta
})
assert.equal(paymentSections[0].key, 'summary')
assert.equal(paymentSections[1].cards.length, 1)
assert.equal(paymentSections.find((section) => section.key === 'schedules'), undefined)
assert.equal(protocolSections[0].key, 'protocol')
})
@@ -111,12 +111,16 @@ test('protocol adapters preserve nested detail sections', () => {
'customer',
'samples',
'payment',
'storageCert',
'insurance',
'refundRecords',
'audits',
'statusChange',
'attachments'
'attachments',
'statusChange'
])
const protocolLabels = protocol.flatMap((section) => (section.fields || []).map((field) => field.label))
assert.ok(!protocolLabels.includes('协议编号'))
assert.ok(protocolLabels.includes('母亲姓名'))
assert.ok(!protocolLabels.includes('新生儿评分(5分钟)'))
assert.equal(protocol.at(-1).key, 'statusChange')
})
test('attachment-bearing detail adapters expose typed values instead of path text', () => {
@@ -135,14 +139,9 @@ test('attachment-bearing detail adapters expose typed values instead of path tex
const payment = getMarketModule('payment').detailSections({
schedules: [{ transfers: [{ transferAttachments: 'payment-proof.png' }] }]
})
const paymentFiles = payment[1].cards[0].cards[0].fields.find(
(field) => field.label === '转账凭证'
)
assert.deepEqual(paymentFiles, {
label: '转账凭证',
type: 'attachments',
value: 'payment-proof.png'
})
assert.equal(payment.find((section) => section.key === 'schedules'), undefined)
assert.equal(payment.length, 1)
assert.equal(payment[0].key, 'summary')
const presign = getMarketModule('presign').detailSections({
transfers: [{ transferAttachments: 'deposit-proof.png' }]
@@ -217,7 +216,7 @@ test('payment detail recognizes the converted cell-storage protocol status', ()
assert.equal(detail[0].fields.find((field) => field.label === '协议状态').value, '转细胞存储')
})
test('payment detail presents real backend progress, schedule statuses, and combined pay mode', () => {
test('payment detail presents real backend progress and combined pay mode in summary only', () => {
const sections = getMarketModule('payment').detailSections({
payMode: 'djfq',
paymentProgress: '部分已缴',
@@ -228,13 +227,10 @@ test('payment detail presents real backend progress, schedule statuses, and comb
]
})
const summary = sections.find((section) => section.key === 'summary')
const schedules = sections.find((section) => section.key === 'schedules').cards
assert.equal(sections.find((section) => section.key === 'schedules'), undefined)
assert.equal(summary.fields.find((field) => field.label === '缴费方式').value, '趸交+分期')
assert.equal(summary.fields.find((field) => field.label === '缴费进度').value, '部分已缴')
assert.equal(schedules.find((card) => card.key === 'open').statusText, '待缴清')
assert.equal(schedules.find((card) => card.key === 'cleared').statusText, '已缴清')
assert.equal(schedules.find((card) => card.key === 'void').statusText, '已作废')
})
test('payment progress dfj aligns with web-admin samplePaymentProgress 待缴费', () => {
@@ -278,13 +274,12 @@ test('protocol read-only history uses Web audit and void type labels', () => {
assert.equal(audits.cards[0].statusText, '审核退回')
assert.equal(
audits.cards[0].fields.find((field) => field.label === '审核状态').value,
'审核退回'
)
assert.equal(
status.fields.find((field) => field.label === '作废类型').value,
'租户自定义作废'
audits.cards[0].fields.find((field) => field.label === '审核状态'),
undefined
)
assert.equal(status.fields.length, 1)
assert.equal(status.fields[0].value, '暂无状态变更记录')
assert.equal(status.cards.length, 0)
})
test('donation detail shows void type and preserves unknown original values', () => {
@@ -292,15 +287,19 @@ test('donation detail shows void type and preserves unknown original values', ()
source: 'research_donation',
basic: {
donationStatus: 'yzf',
voidTime: '2026-10-01 09:30:00',
voidDesc: '客户申请',
voidType: 'legacy-unknown',
voidTypeLabel: 'legacy-unknown'
}
})
const status = sections.find((section) => section.key === 'statusChange')
assert.equal(status.cards.length, 1)
assert.equal(status.cards[0].statusText, '作废')
assert.equal(
status.fields.find((field) => field.label === '作废类型').value,
'legacy-unknown'
status.cards[0].fields.find((field) => field.label === '变更说明').value,
'作废类型:legacy-unknown;客户申请'
)
})
@@ -342,7 +341,7 @@ test('detail adapters hide unresolved references and dictionary codes but preser
...(section.fields || []).map((field) => field.value),
...(section.cards || []).flatMap((card) => card.fields.map((field) => field.value))
])
assert.ok(values.includes('P-RAW-1'))
assert.ok(!values.includes('P-RAW-1'))
assert.ok(values.includes('S-RAW-1'))
assert.ok(values.includes('未知人员'))
assert.ok(values.includes('未知'))
@@ -360,6 +359,7 @@ test('detail adapters hide unresolved references and dictionary codes but preser
assert.equal(freight[0].fields.find((field) => field.label === '创建人').value, '未知人员')
assert.equal(freight[1].cards[0].fields.find((field) => field.label === '样本编码').value, 'S-RAW-2')
assert.equal(freight[1].cards[0].fields.find((field) => field.label === '来源').value, '未知')
assert.ok(!freight[1].cards[0].fields.some((field) => field.label === '协议单号'))
const presign = getMarketModule('presign').detailSections({
protocolNo: 'P-RAW-2', regionId: 'region-raw', regionName: 'region-raw',
@@ -368,7 +368,7 @@ test('detail adapters hide unresolved references and dictionary codes but preser
payMode: 'raw-pay-mode', orderStatus: 'raw-order-status',
transfers: [{ transferType: 'raw-transfer', payStatus: 'raw-pay-status' }]
})
assert.equal(presign[0].fields.find((field) => field.label === '协议编号').value, 'P-RAW-2')
assert.ok(!presign[0].fields.some((field) => field.label === '协议编号'))
assert.equal(presign[0].fields.find((field) => field.label === '采集地区').value, '未知')
assert.equal(presign[0].fields.find((field) => field.label === '市场专员').value, '未知人员')
assert.equal(presign[0].fields.find((field) => field.label === '缴费方式').value, '未知')
@@ -377,14 +377,23 @@ test('detail adapters hide unresolved references and dictionary codes but preser
assert.equal(presign[1].cards[0].statusText, '未知')
const payment = getMarketModule('payment').detailSections({
protocolNo: 'P-RAW-3', payMode: 'raw-pay-mode', protocolStatus: 'raw-status',
progressStatus: 'raw-progress', schedules: [{ scheduleStatus: 'raw-schedule', transfers: [{ transferType: 'raw-transfer', payStatus: 'raw-pay-status' }] }]
protocolNo: 'P-RAW-3',
motherName: '客户甲',
sampleCode: 'S-RAW-3',
payMode: 'raw-pay-mode',
protocolStatus: 'raw-status',
progressStatus: 'raw-progress',
schedules: [{ scheduleStatus: 'raw-schedule', transfers: [{ transferType: 'raw-transfer', payStatus: 'raw-pay-status' }] }]
})
const paymentLabels = payment.flatMap((section) => (section.fields || []).map((field) => field.label))
const paymentValues = payment.flatMap((section) => [
...(section.fields || []).map((field) => field.value),
...(section.cards || []).flatMap((card) => [card.statusText, ...(card.cards || []).flatMap((nested) => [nested.statusText, ...nested.fields.map((field) => field.value)])])
])
assert.ok(paymentValues.includes('P-RAW-3'))
assert.ok(!paymentLabels.includes('协议号'))
assert.ok(!paymentValues.includes('P-RAW-3'))
assert.ok(paymentValues.includes('客户甲'))
assert.ok(paymentValues.includes('S-RAW-3'))
for (const raw of ['raw-pay-mode', 'raw-status', 'raw-progress', 'raw-schedule', 'raw-transfer', 'raw-pay-status']) {
assert.ok(!paymentValues.includes(raw), `must not display ${raw}`)
}
@@ -2,48 +2,19 @@ import { formatMoney } from '../money.js'
import {
resolveStatusTag,
samplePaymentProgress,
paymentScheduleStatus,
payStatus,
payMode,
protocolStatus,
donationStatus
} from '../status-tag-registry.js'
const TRANSFER_TYPE = Object.freeze({
wxzz: '微信转账', wx: '微信转账', wechat: '微信转账',
zzbzz: '支付宝转账', zfb: '支付宝转账', alipay: '支付宝转账',
yhkzz: '银行卡转账', bank: '银行卡转账',
xj: '现金', cash: '现金', lszz: '乐刷'
})
function label(map, value) {
const key = String(value || '').trim()
return map[key] || (key ? '未知' : '—')
}
function money(value) {
return formatMoney(value)
}
function displayDate(value) {
const text = String(value || '').trim()
return /^\d{8}$/.test(text)
? `${text.slice(0, 4)}-${text.slice(4, 6)}-${text.slice(6, 8)}`
: text || '—'
}
function progressTag(code) {
return resolveStatusTag(samplePaymentProgress, code)
}
function scheduleTag(code) {
return resolveStatusTag(paymentScheduleStatus, code)
}
function transferPayTag(code) {
return resolveStatusTag(payStatus, code)
}
function payModeTag(code) {
return resolveStatusTag(payMode, code)
}
@@ -57,47 +28,6 @@ function protocolStatusTag(code) {
return resolveStatusTag(protocolStatus, key)
}
function transferCard(transfer, index) {
const pay = transferPayTag(transfer?.payStatus)
const transferType = label(TRANSFER_TYPE, transfer?.transferType)
const transferTime = transfer?.transferTime || '—'
return {
key: String(transfer?.id || `transfer-${index}`),
title: `转账笔 ${index + 1}`,
variant: 'transfer',
transferIndex: index + 1,
amount: money(transfer?.transferAmount),
meta: `${transferType} · ${transferTime}`,
statusText: pay.label,
statusTone: pay.tagType,
fields: [
{ label: '备注', value: transfer?.remark || '—' },
{ label: '财务备注', value: transfer?.financeRemark || '—' },
{ label: '转账凭证', type: 'attachments', value: transfer?.transferAttachments || '' }
]
}
}
function scheduleCard(schedule, index) {
const status = scheduleTag(schedule?.scheduleStatus)
const transfers = schedule?.transfers || []
return {
key: String(schedule?.id || `schedule-${index}`),
title: `第 ${schedule?.scheduleNo || index + 1} 期`,
variant: 'schedule',
cardsLabel: '转账记录',
statusText: status.label,
statusTone: status.tagType,
fields: [
{ label: '应缴金额', value: money(schedule?.dueAmount), money: true },
{ label: '应缴日', value: displayDate(schedule?.dueDate) },
{ label: '已缴金额', value: money(schedule?.paidAmount), money: true },
{ label: '欠缴金额', value: money(schedule?.arrearsAmount), money: true }
],
cards: transfers.map(transferCard)
}
}
export default {
key: 'payment',
resource: 'payments',
@@ -139,7 +69,6 @@ export default {
key: 'summary',
title: '缴费概览',
fields: [
{ label: '协议号', value: detail?.protocolNo || '—' },
{ label: '客户', value: detail?.motherName || '—' },
{ label: '联系电话', value: detail?.motherPhone || '—' },
{ label: '样本编码', value: detail?.sampleCode || '—' },
@@ -150,12 +79,6 @@ export default {
{ label: '协议状态', value: protocol.label, tag: true, tagType: protocol.tagType },
{ label: '缴费进度', value: progress.label, tag: true, tagType: progress.tagType }
]
},
{
key: 'schedules',
title: '应缴批次',
fields: [],
cards: (detail?.schedules || []).map(scheduleCard)
}
]
},
+15
View File
@@ -1,6 +1,15 @@
/** 缴费信息入口进入协议详情时的只读压制 */
const PROTOCOL_WRITE_CODES = new Set(['edit', 'delete', 'create'])
/** 缴费详情底栏隐藏的转账动作(改由 PaymentDetailTransfersPanel 内联办理) */
const PAYMENT_TRANSFER_BOTTOM_CODES = new Set([
'register-transfer',
'edit-transfer',
'delete-payment-transfer',
'submit',
'withdraw'
])
export function isPaymentEntryView(query) {
return String(query?.from || '') === 'payment'
}
@@ -16,3 +25,9 @@ export function filterProtocolActionsForPaymentEntry(actions) {
(action) => !PROTOCOL_WRITE_CODES.has(actionCode(action))
)
}
export function filterPaymentDetailBottomActions(actions) {
return (Array.isArray(actions) ? actions : []).filter(
(action) => !PAYMENT_TRANSFER_BOTTOM_CODES.has(actionCode(action))
)
}
+23 -1
View File
@@ -2,7 +2,8 @@ import test from 'node:test'
import assert from 'node:assert/strict'
import {
isPaymentEntryView,
filterProtocolActionsForPaymentEntry
filterProtocolActionsForPaymentEntry,
filterPaymentDetailBottomActions
} from './payment-entry.js'
test('isPaymentEntryView detects from=payment', () => {
@@ -26,3 +27,24 @@ test('filterProtocolActionsForPaymentEntry hides edit and delete', () => {
[{ code: 'view' }]
)
})
test('filterPaymentDetailBottomActions hides transfer bottom actions', () => {
assert.deepEqual(
filterPaymentDetailBottomActions([
'view',
'register-transfer',
'edit-transfer',
'delete-payment-transfer',
'submit',
'withdraw'
]),
['view']
)
assert.deepEqual(
filterPaymentDetailBottomActions([
{ code: 'register-transfer', scheduleId: 's1' },
{ code: 'view' }
]),
[{ code: 'view' }]
)
})
@@ -29,6 +29,13 @@
:cards="section.cards"
:raw="section.raw || section"
/>
<PaymentDetailTransfersPanel
v-if="context.module === 'payment' && detail"
:detail="detail"
:market-role="context.marketRole"
:transfer-types="paymentTransferTypes"
@refresh="loadDetail"
/>
</template>
<van-empty v-else description="暂无详情数据" />
<template #bottom>
@@ -50,6 +57,7 @@ import {
deletePresignOrder,
deletePresignTransfer,
fetchMarketDetail,
fetchMarketFormOptions,
performMarketAction,
submitPresignTransfer,
withdrawPresignTransfer
@@ -57,7 +65,8 @@ import {
import { collectAllowedActions } from '../../market/action-policy.js'
import {
isPaymentEntryView,
filterProtocolActionsForPaymentEntry
filterProtocolActionsForPaymentEntry,
filterPaymentDetailBottomActions
} from '../../market/payment-entry.js'
import { marketErrorMessage } from '../../market/market-error.js'
import { getMarketModule } from '../../market/module-registry.js'
@@ -79,6 +88,7 @@ import {
import MarketDetailSection from '../../market/components/MarketDetailSection.vue'
import MarketBottomBar from '../../market/components/MarketBottomBar.vue'
import MarketPageShell from '../../market/components/MarketPageShell.vue'
import PaymentDetailTransfersPanel from '../../market/components/PaymentDetailTransfersPanel.vue'
const route = useRoute()
const router = useRouter()
@@ -94,9 +104,13 @@ const id = computed(() => String(route.params.id || '').trim())
const moduleTitle = computed(() => MARKET_MODULE_TITLES[context.value.module])
const roleTitle = computed(() => MARKET_ROLE_TITLES[context.value.marketRole])
const moduleContract = computed(() => getMarketModule(context.value.module))
const summarySubtitle = computed(
() => detail.value?.subtitle || detail.value?.code || detail.value?.basic?.protocolNo || detail.value?.basic?.donationNo || detail.value?.protocolNo || '业务详情'
)
const summarySubtitle = computed(() => {
const current = detail.value || {}
if (context.value.module === 'protocol') {
return current.subtitle || '业务详情'
}
return current.subtitle || current.code || current.basic?.donationNo || '业务详情'
})
const summaryTitle = computed(
() => detail.value?.motherName || detail.value?.customer?.motherName || detail.value?.card?.motherName || detail.value?.title || detail.value?.name || detail.value?.code || moduleTitle.value
)
@@ -109,6 +123,7 @@ const statusTone = computed(() => resolveStatusTone(
))
const actionPending = ref(false)
const pendingAction = ref('')
const paymentTransferTypes = ref([])
const allowedActions = computed(() => {
const raw = collectAllowedActions(
context.value.module,
@@ -118,6 +133,9 @@ const allowedActions = computed(() => {
if (context.value.module === 'protocol' && isPaymentEntryView(route.query)) {
return filterProtocolActionsForPaymentEntry(raw)
}
if (context.value.module === 'payment') {
return filterPaymentDetailBottomActions(raw)
}
return raw
})
const sections = computed(() => {
@@ -323,6 +341,20 @@ async function loadDetail() {
: undefined
})
detailState.value = finishMarketDetailRequest(detailState.value, requestId, result)
if (requestContext.module === 'payment') {
try {
const options = await fetchMarketFormOptions(requestContext, {
businessId: requestIdValue
}, { signal: abortController.signal })
if (requestId !== requestSequence) return
paymentTransferTypes.value = options?.transferTypes || []
} catch (optionsError) {
if (optionsError?.name === 'AbortError') return
paymentTransferTypes.value = []
}
} else {
paymentTransferTypes.value = []
}
} catch (error) {
if (error?.name === 'AbortError') return
detailState.value = failMarketDetailRequest(