feat(mobile-oa-h5): add ProtocolSamplePaymentPanel for mobile protocol forms

Mobile card UI for per-sample pay modes, schedules, and autofill rules.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zlwuandCursor committed 2026-10-13 15:30:22 +08:00
1 parent 20a8bf538a
commit 3354e66d82
1 file changed
+501
@@ -0,0 +1,501 @@
<template>
<section v-if="syncedPayments.length" class="market-form-section protocol-sample-pay">
<h2>缴费信息</h2>
<p class="protocol-sample-pay__hint">按样本配置缴费方案,规则与管理端一致</p>
<div
v-for="(pay, pIdx) in syncedPayments"
:key="pay.sampleCode || pIdx"
class="protocol-sample-pay__card"
>
<div class="protocol-sample-pay__head">
<strong>{{ sampleTitle(pay) }}</strong>
<span class="protocol-sample-pay__code">{{ pay.sampleCode || '—' }}</span>
</div>
<van-cell-group inset>
<van-field
:model-value="payModeLabel(pay.payMode)"
is-link
readonly
label="缴费模式"
placeholder="请选择"
:disabled="disabled"
@click="!disabled && openPayModeSheet(pIdx)"
/>
<van-field
:model-value="pay.contractAmount == null ? '' : String(pay.contractAmount)"
type="number"
label="样本合同金额"
placeholder="请输入"
:disabled="disabled"
@update:model-value="(v) => onContractChange(pIdx, v)"
/>
<template v-if="pay.payMode === 'dj'">
<van-field
:model-value="formatYmd(pay.lumpSumDeadline)"
is-link
readonly
label="趸交截止时间"
placeholder="选择日期"
:disabled="disabled"
@click="!disabled && openDatePicker(pIdx, 'lumpSumDeadline')"
/>
</template>
<template v-else>
<van-field
:model-value="pay.installmentCount == null ? '' : String(pay.installmentCount)"
type="digit"
:label="pay.payMode === 'fgd' ? '期数' : '分期期数'"
:disabled="disabled"
@update:model-value="(v) => onFqField(pIdx, 'installmentCount', v)"
@blur="rebuildAt(pIdx)"
/>
<van-field
:model-value="pay.installmentIntervalMonths == null ? '' : String(pay.installmentIntervalMonths)"
type="number"
label="每期间隔(月)"
:disabled="disabled"
@update:model-value="(v) => onFqField(pIdx, 'installmentIntervalMonths', v)"
@blur="rebuildAt(pIdx)"
/>
<van-field
:model-value="formatYmd(pay.installmentStartDate)"
is-link
readonly
label="分期开始时间"
placeholder="选择日期"
:disabled="disabled"
@click="!disabled && openDatePicker(pIdx, 'installmentStartDate')"
/>
<van-field
:model-value="pay.installmentAdvanceDays == null ? '' : String(pay.installmentAdvanceDays)"
type="digit"
label="提前催缴预警(天)"
:disabled="disabled"
@update:model-value="(v) => onFqField(pIdx, 'installmentAdvanceDays', v)"
@blur="rebuildAt(pIdx)"
/>
</template>
</van-cell-group>
<div class="protocol-sample-pay__schedules">
<div class="protocol-sample-pay__sub">应缴批次</div>
<van-empty
v-if="!(pay.schedules || []).length"
description="请完善缴费参数后自动生成"
image-size="48"
/>
<van-cell-group
v-for="(sch, sIdx) in pay.schedules || []"
:key="sch.id || sIdx"
inset
class="protocol-sample-pay__sch"
>
<div class="protocol-sample-pay__sch-title">第 {{ sch.scheduleNo || sIdx + 1 }} 期</div>
<van-field
:model-value="sch.dueAmount == null ? '' : String(sch.dueAmount)"
type="number"
label="应缴费金额"
:disabled="disabled || isScheduleLocked(sch)"
@update:model-value="(v) => onDueAmountChange(pIdx, sIdx, v)"
/>
<van-field
:model-value="formatYmd(sch.dueDate)"
is-link
readonly
label="应缴费时间"
placeholder="选择日期"
:disabled="disabled || isScheduleLocked(sch)"
@click="!disabled && !isScheduleLocked(sch) && openScheduleDate(pIdx, sIdx)"
/>
</van-cell-group>
</div>
</div>
<van-action-sheet
v-model:show="modeSheetVisible"
:actions="modeSheetActions"
cancel-text="取消"
close-on-click-action
@select="onPayModeSelect"
/>
<van-popup v-model:show="datePickerVisible" position="bottom" round>
<van-date-picker
v-model="datePickerValue"
title="选择日期"
:min-date="minDate"
:max-date="maxDate"
@confirm="onDateConfirm"
@cancel="datePickerVisible = false"
/>
</van-popup>
</section>
</template>
<script setup>
import { computed, ref, watch } from 'vue'
import { showConfirmDialog, showToast } from 'vant'
import {
applyConsolidateRemainingToPayment,
applyExpandRemainingToInstallment,
applyLumpSumDeadlineToLastDueDate,
availableSamplePayModes,
calcLastPeriodDueAmount,
DEFAULT_INSTALLMENT_ADVANCE_DAYS,
rebuildSampleSchedules,
resolvePerSamplePaymentDefaults,
samplePaymentHasConfirmedTransfer,
shouldAutoFillLastPeriodDue
} from '../protocol-sample-payment.js'
const props = defineProps({
modelValue: { type: Array, default: () => [] },
samples: { type: Array, default: () => [] },
disabled: { type: Boolean, default: false }
})
const emit = defineEmits(['update:modelValue'])
const lastPeriodDueOverrideByKey = ref({})
const modeSheetVisible = ref(false)
const modeSheetPayIdx = ref(-1)
const modeSheetActions = ref([])
const datePickerVisible = ref(false)
const datePickerValue = ref(['2026', '10', '13'])
const dateTarget = ref(null)
const minDate = new Date(2020, 0, 1)
const maxDate = new Date(2040, 11, 31)
const syncedPayments = computed(() => props.modelValue || [])
const PAY_MODE_LABELS = { dj: '趸交', fq: '分期', fgd: '分改趸' }
function payModeLabel(mode) {
return PAY_MODE_LABELS[String(mode || '')] || '请选择'
}
function sampleTitle(pay) {
const sample = (props.samples || []).find(
(s) => String(s.sampleCode || '') === String(pay.sampleCode || '')
)
return String(sample?.sampleTypeName || sample?.sampleName || '样本')
}
function formatYmd(raw) {
const compact = String(raw || '').replace(/-/g, '')
if (!/^\d{8}$/.test(compact)) return ''
return `${compact.slice(0, 4)}-${compact.slice(4, 6)}-${compact.slice(6, 8)}`
}
function toYmdParts(raw) {
const compact = String(raw || '').replace(/-/g, '')
if (/^\d{8}$/.test(compact)) {
return [compact.slice(0, 4), compact.slice(4, 6), compact.slice(6, 8)]
}
const now = new Date()
return [
String(now.getFullYear()),
String(now.getMonth() + 1).padStart(2, '0'),
String(now.getDate()).padStart(2, '0')
]
}
function emitList(list) {
emit('update:modelValue', list)
}
function samplePayKey(pay, pIdx) {
return String(pay.sampleCode || pay.protocolSampleId || pIdx)
}
function isScheduleLocked(sch) {
return (sch.transfers || []).some((t) => t.payStatus === 'yjf')
}
function activeScheduleIndices(schedules) {
const idx = []
;(schedules || []).forEach((s, i) => {
if (String(s.scheduleStatus || 'open') !== 'void' && !s._deleted) idx.push(i)
})
return idx
}
function emptyDjPayment(sample) {
const defaults = resolvePerSamplePaymentDefaults(sample, 'dj')
const amount = defaults.contractAmount
return rebuildSampleSchedules({
sampleCode: String(sample.sampleCode || ''),
protocolSampleId: sample.id || '',
payMode: 'dj',
contractAmount: amount,
lumpSumDeadline: '',
installmentCount: null,
installmentIntervalMonths: null,
installmentStartDate: '',
installmentAdvanceDays: DEFAULT_INSTALLMENT_ADVANCE_DAYS,
installmentRatios: [],
schedules: []
})
}
watch(
() => props.samples,
(samples) => {
const list = [...(props.modelValue || [])]
const byCode = new Map(list.map((p) => [String(p.sampleCode || ''), p]))
const next = []
for (const sample of samples || []) {
const code = String(sample.sampleCode || '').trim()
if (!code) continue
if (byCode.has(code)) {
const prev = { ...byCode.get(code), protocolSampleId: sample.id || byCode.get(code).protocolSampleId }
next.push(prev)
} else {
next.push(emptyDjPayment(sample))
}
}
const same =
next.length === list.length &&
next.every((p, i) => p.sampleCode === list[i]?.sampleCode)
if (!same) emitList(next)
},
{ deep: true, immediate: true }
)
function patchPay(pIdx, patch, rebuild = false) {
const list = [...(props.modelValue || [])]
let next = { ...list[pIdx], ...patch }
if (rebuild) next = rebuildSampleSchedules(next)
list[pIdx] = next
emitList(list)
}
function rebuildAt(pIdx) {
patchPay(pIdx, {}, true)
}
function onContractChange(pIdx, raw) {
const n = raw === '' || raw == null ? null : Number(raw)
patchPay(pIdx, { contractAmount: Number.isFinite(n) ? n : null }, true)
}
function onFqField(pIdx, key, raw) {
const n = raw === '' || raw == null ? null : Number(raw)
patchPay(pIdx, { [key]: Number.isFinite(n) ? n : null }, false)
}
function onDueAmountChange(pIdx, sIdx, raw) {
const list = [...(props.modelValue || [])]
const pay = { ...list[pIdx] }
const schedules = [...(pay.schedules || [])]
const n = raw === '' || raw == null ? null : Number(raw)
schedules[sIdx] = {
...schedules[sIdx],
dueAmount: Number.isFinite(n) ? n : null
}
const mode = String(pay.payMode || '')
const key = samplePayKey(pay, pIdx)
const activeIdx = activeScheduleIndices(schedules)
if (mode === 'fq' && activeIdx.length >= 2) {
const lastIdx = activeIdx[activeIdx.length - 1]
if (sIdx === lastIdx) {
lastPeriodDueOverrideByKey.value = { ...lastPeriodDueOverrideByKey.value, [key]: true }
} else if (activeIdx.slice(0, -1).includes(sIdx)) {
lastPeriodDueOverrideByKey.value = { ...lastPeriodDueOverrideByKey.value, [key]: false }
const lastSch = schedules[lastIdx]
const canFill = shouldAutoFillLastPeriodDue({
lastManualOverride: false,
lastLocked: isScheduleLocked(lastSch)
})
if (canFill) {
const priors = activeIdx.slice(0, -1).map((i) => schedules[i]?.dueAmount)
const lastAmt = calcLastPeriodDueAmount(pay.contractAmount, priors)
if (lastAmt != null) {
schedules[lastIdx] = { ...schedules[lastIdx], dueAmount: lastAmt }
}
}
}
}
pay.schedules = schedules
pay.installmentRatios = schedules.map((s) => {
const amt = Number(s.dueAmount)
return Number.isFinite(amt) && amt > 0 ? amt : 0
})
list[pIdx] = pay
emitList(list)
}
function openPayModeSheet(pIdx) {
const pay = props.modelValue?.[pIdx]
if (!pay) return
modeSheetPayIdx.value = pIdx
modeSheetActions.value = availableSamplePayModes(pay).map((value) => ({
name: PAY_MODE_LABELS[value] || value,
value
}))
modeSheetVisible.value = true
}
async function onPayModeSelect(action) {
const pIdx = modeSheetPayIdx.value
const mode = action.value
const list = props.modelValue || []
const prev = list[pIdx]
const prevMode = String(prev?.payMode || '')
if (!prev || mode === prevMode) return
const hasPaid = samplePaymentHasConfirmedTransfer(prev)
const schCount = (prev.schedules || []).filter(
(s) => String(s.scheduleStatus || 'open') !== 'void' && !s._deleted
).length
try {
if (prevMode === 'fq' && (mode === 'dj' || mode === 'fgd') && schCount > 0) {
const label = mode === 'dj' ? '趸交' : '分改趸'
await showConfirmDialog({
title: `确认改为${label}`,
message: hasPaid
? `改为${label}将保留已缴期,并把后续未缴合并至当前未清期。是否继续?`
: `改为${label}将把各期未缴合并为当前未清期。是否继续?`
})
const nextList = [...list]
nextList[pIdx] = applyConsolidateRemainingToPayment({ ...nextList[pIdx] }, mode)
emitList(nextList)
return
}
if (hasPaid && (prevMode === 'dj' || prevMode === 'fgd') && mode === 'fq') {
await showConfirmDialog({
title: '确认改为分期',
message: '改为分期将保留已缴期,剩余金额按分期期数自动生成后续应缴批次。是否继续?'
})
const cur = { ...list[pIdx] }
const count = Number(cur.installmentCount) || 6
const interval = Number(cur.installmentIntervalMonths) || 1
let startDate = String(cur.installmentStartDate || '').trim()
if (!startDate) startDate = String(cur.lumpSumDeadline || '').trim()
if (!startDate) {
const d = new Date()
startDate = `${d.getFullYear()}${String(d.getMonth() + 1).padStart(2, '0')}${String(d.getDate()).padStart(2, '0')}`
}
const nextList = [...list]
nextList[pIdx] = applyExpandRemainingToInstallment(cur, {
installmentCount: count,
installmentIntervalMonths: interval,
installmentStartDate: startDate,
installmentAdvanceDays: cur.installmentAdvanceDays ?? DEFAULT_INSTALLMENT_ADVANCE_DAYS
})
emitList(nextList)
return
}
const sample = (props.samples || []).find(
(s) => String(s.sampleCode || '') === String(prev?.sampleCode || '')
)
const presetMode = mode === 'fq' || mode === 'fgd' ? 'fq' : 'dj'
const defaults = resolvePerSamplePaymentDefaults(sample, presetMode)
const patch = {
payMode: mode,
contractAmount: defaults.contractAmount ?? prev?.contractAmount ?? null
}
if (mode === 'fq' || mode === 'fgd') {
patch.installmentCount = mode === 'fgd' ? 3 : defaults.installmentCount || 6
patch.installmentIntervalMonths = 1
patch.installmentAdvanceDays = DEFAULT_INSTALLMENT_ADVANCE_DAYS
patch.installmentRatios = []
} else {
patch.installmentCount = null
patch.installmentRatios = []
}
patchPay(pIdx, patch, true)
} catch (error) {
if (error === 'cancel' || error?.message === 'cancel') return
showToast(error?.message || '无法切换缴费模式')
}
}
function openDatePicker(pIdx, field) {
dateTarget.value = { type: 'pay', pIdx, field }
datePickerValue.value = toYmdParts(props.modelValue?.[pIdx]?.[field])
datePickerVisible.value = true
}
function openScheduleDate(pIdx, sIdx) {
dateTarget.value = { type: 'schedule', pIdx, sIdx }
datePickerValue.value = toYmdParts(props.modelValue?.[pIdx]?.schedules?.[sIdx]?.dueDate)
datePickerVisible.value = true
}
function onDateConfirm({ selectedValues }) {
const parts = selectedValues || datePickerValue.value
const ymd = `${parts[0]}${parts[1]}${parts[2]}`
const target = dateTarget.value
datePickerVisible.value = false
if (!target) return
if (target.type === 'pay') {
if (target.field === 'lumpSumDeadline') {
const list = [...(props.modelValue || [])]
const pay = { ...list[target.pIdx] }
pay.lumpSumDeadline = ymd
if (String(pay.payMode || '') === 'dj') {
pay.schedules = applyLumpSumDeadlineToLastDueDate(
[...(pay.schedules || [])],
ymd,
isScheduleLocked
)
}
list[target.pIdx] = pay
emitList(list)
return
}
patchPay(target.pIdx, { [target.field]: ymd }, target.field === 'installmentStartDate')
return
}
if (target.type === 'schedule') {
const list = [...(props.modelValue || [])]
const pay = { ...list[target.pIdx] }
const schedules = [...(pay.schedules || [])]
schedules[target.sIdx] = { ...schedules[target.sIdx], dueDate: ymd }
pay.schedules = schedules
list[target.pIdx] = pay
emitList(list)
}
}
</script>
<style scoped>
.protocol-sample-pay__hint {
margin: 0 16px 12px;
color: #64748b;
font-size: 12px;
}
.protocol-sample-pay__card {
margin-bottom: 16px;
}
.protocol-sample-pay__head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 16px 8px;
}
.protocol-sample-pay__code {
color: #64748b;
font-size: 12px;
}
.protocol-sample-pay__sub {
padding: 12px 16px 8px;
font-size: 13px;
color: #334155;
font-weight: 600;
}
.protocol-sample-pay__sch {
margin-bottom: 8px;
}
.protocol-sample-pay__sch-title {
padding: 10px 16px 0;
font-size: 13px;
color: #0a9f6d;
font-weight: 600;
}
</style>