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:
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>
|
||||
Reference in new issue
Block a user