feat: 线下登记延期先传凭证自动识别交易单号,弹窗显示套餐名称
Some checks failed
构建并部署前端到测试环境 / deploy (push) Has been cancelled

- 登记线下付款时收款凭证放到最前,上传后自动识别交易单号预填(复用凭证识别,需人工核对,不覆盖手填)
- 试算后自动带入实收金额
- 延期弹窗显示套餐名称(及资产),不再显示套餐记录 ID
This commit is contained in:
邹进
2026-11-07 22:26:38 +09:00
parent c907d6e21f
commit 0d094a0537
5 changed files with 136 additions and 7 deletions

View File

@@ -32,9 +32,21 @@
value="special"
label="特殊延期" /></ElSelect
></ElFormItem>
<ElFormItem v-if="mode === 'offline'" label="收款凭证" required
><div class="silence-receipt"
><VoucherUpload
v-model="vouchers"
key="vouchers-offline"
:max-count="5"
@uploading-change="(value) => (uploadingReceipt = value)"
/><p class="silence-ocr-tip"
>先上传收款凭证,系统会自动识别交易单号,可在下方核对修改。</p
></div
></ElFormItem
>
<template v-if="usageId">
<ElFormItem label="套餐记录"
>{{ usageId }}
<ElFormItem label="套餐"
>{{ packageLabel }}
<ElButton link :loading="eligibilityLoading" @click="loadEligibility"
>刷新资格</ElButton
></ElFormItem
@@ -111,7 +123,15 @@
:value="item.id"
:label="item.name" /></ElSelect
></ElFormItem>
<ElFormItem label="交易单号" required><ElInput v-model="tradeNo" /></ElFormItem>
<ElFormItem label="交易单号" required
><ElInput
v-model="tradeNo"
:placeholder="ocrLoading ? '正在识别凭证中的交易单号…' : '请输入或核对交易单号'"
@input="tradeNoTouched = true"
/><span v-if="ocrMessage" :class="['silence-ocr-tip', ocrTone]">{{
ocrMessage
}}</span></ElFormItem
>
<ElFormItem v-if="mode === 'offline'" label="实收金额(元)" required
><ElInput v-model="paidYuan" inputmode="decimal"
/></ElFormItem>
@@ -122,7 +142,7 @@
value-format="YYYY-MM-DDTHH:mm:ssZ"
format="YYYY-MM-DD HH:mm:ss"
/></ElFormItem>
<ElFormItem label="收款凭证" required
<ElFormItem v-if="mode !== 'offline'" label="收款凭证" required
><VoucherUpload
v-model="vouchers"
:key="`vouchers-${mode}`"
@@ -184,6 +204,7 @@
import QrcodeVue from 'qrcode.vue'
import { PackageSilenceService as Service } from '@/api/modules/packageSilence'
import { EmployeeCollectionService } from '@/api/modules/employeeCollection'
import { AgentRechargeService } from '@/api/modules/agentRecharge'
import { useAuth } from '@/composables/useAuth'
import { useUserStore } from '@/store/modules/user'
import { useSchedulerRequest } from '@/composables/useSchedulerRequest'
@@ -212,10 +233,22 @@
import SilenceTable from './SilenceTable.vue'
// allowed 未传时视为允许:Vue 会把未传的布尔 prop 当成 false,不设默认值时资产详情入口一律报"无权执行此操作"
const props = withDefaults(
defineProps<{ usageId?: number; allowed?: boolean; batchMode?: boolean }>(),
defineProps<{
usageId?: number
allowed?: boolean
batchMode?: boolean
packageName?: string
assetIdentifier?: string
}>(),
{ allowed: true, batchMode: false }
)
const emit = defineEmits<{ close: []; changed: [] }>()
// 展示套餐名称(及资产),不展示内部套餐记录 ID;调用方未传名称时才退回显示 ID
const packageLabel = computed(() => {
const name = props.packageName?.trim()
if (!name) return props.usageId ? `套餐记录 ${props.usageId}` : '-'
return props.assetIdentifier ? `${name}(${props.assetIdentifier})` : name
})
const { hasAuth } = useAuth(),
user = useUserStore()
const mode = ref<'online' | 'offline' | 'special'>(
@@ -293,6 +326,62 @@
quote.value.forUsage === props.usageId &&
Number.isSafeInteger(quote.value.amount))
)
// 线下登记:上传第一张收款凭证后自动识别交易单号(复用代理充值的凭证识别,只预填、需人工核对)
const ocrLoading = ref(false),
ocrMessage = ref(''),
ocrTone = ref<'info' | 'success' | 'warning'>('info'),
tradeNoTouched = ref(false)
let ocrSequence = 0,
ocrKey = ''
async function recognizeTradeNo(key: string) {
if (!key || key === ocrKey) return
ocrKey = key
const current = ++ocrSequence
ocrLoading.value = true
ocrTone.value = 'info'
ocrMessage.value = '正在识别交易单号…'
try {
const res = await AgentRechargeService.recognizePaymentVoucher({ payment_voucher_key: key })
if (current !== ocrSequence) return
const transactionNo = res.code === 0 ? res.data?.external_transaction_no?.trim() : ''
if (!transactionNo) {
ocrTone.value = 'warning'
ocrMessage.value = '未识别到交易单号,请手动填写'
return
}
if (!tradeNoTouched.value || !tradeNo.value.trim()) {
tradeNo.value = transactionNo
tradeNoTouched.value = false
}
ocrTone.value = 'success'
ocrMessage.value = `已识别交易单号 ${transactionNo},请对照凭证核对`
} catch {
if (current !== ocrSequence) return
ocrTone.value = 'warning'
ocrMessage.value = '识别失败,请手动填写交易单号'
} finally {
if (current === ocrSequence) ocrLoading.value = false
}
}
watch(
() => [mode.value, vouchers.value[0]] as const,
([currentMode, firstKey]) => {
if (currentMode === 'offline' && firstKey) void recognizeTradeNo(firstKey)
if (!firstKey) {
ocrKey = ''
ocrMessage.value = ''
}
}
)
// 线下登记的实收金额必须等于试算金额:试算后自动带入,仍可修改
watch(
() => quote.value?.amount,
(amount) => {
if (mode.value === 'offline' && Number.isSafeInteger(amount) && !paidYuan.value) {
paidYuan.value = (Number(amount) / 100).toFixed(2)
}
}
)
let active = true,
version = 0,
timer: ReturnType<typeof setTimeout> | undefined
@@ -319,6 +408,11 @@
basis.value = ''
methodId.value = undefined
tradeNo.value = ''
tradeNoTouched.value = false
ocrSequence++
ocrKey = ''
ocrLoading.value = false
ocrMessage.value = ''
paidYuan.value = ''
paidAt.value = ''
batchItems.value = []
@@ -597,4 +691,23 @@
margin-left: 0 !important;
}
}
.silence-receipt {
width: 100%;
}
.silence-ocr-tip {
margin: 4px 0 0;
font-size: 12px;
line-height: 1.5;
color: var(--el-text-color-secondary);
&.success {
color: var(--el-color-success);
}
&.warning {
color: var(--el-color-warning);
}
}
</style>

View File

@@ -71,7 +71,7 @@
v-if="row.silence.extendable && (hasAuth(P.extend) || hasAuth(P.offline))"
link
type="primary"
@click="extendId = row.package_usage_id"
@click="openExtension(row)"
>延期</ElButton
>
</template>
@@ -140,6 +140,8 @@
<SilenceExtendDialog
v-if="extendId || batchVisible"
:usage-id="extendId"
:package-name="extendRow?.package_name"
:asset-identifier="extendRow?.asset_identifier"
:batch-mode="batchVisible"
:allowed="allowed"
@close="closeExtension"
@@ -232,6 +234,7 @@
changing = ref(false)
const recordsId = ref<number>(),
extendId = ref<number>(),
extendRow = ref<Record<string, any>>(),
batchVisible = ref(false),
policyVisible = ref(false),
policyId = ref<number>(),
@@ -533,6 +536,10 @@
actionError.value = gatewayError(e)
}
}
function openExtension(row: Record<string, any>) {
extendRow.value = row
extendId.value = row.package_usage_id
}
async function matchPolicy() {
if (!canManagePolicy.value || !matchPackage.value || matching.value) return
const scope = pollingScope(user),

View File

@@ -42,6 +42,8 @@
<SilenceExtendDialog
v-if="extendVisible && usageId"
:usage-id="usageId"
:package-name="packageName"
:asset-identifier="assetIdentifier"
@close="closeExtension"
@changed="dirty = true"
/>
@@ -56,7 +58,12 @@
import type { PackageSilenceInfo } from '@/types/api/packageSilence'
import { silenceStatusLabel } from '@/utils/business/packageSilence'
import { formatDateTime } from '@/utils/business/format'
const props = defineProps<{ silence?: PackageSilenceInfo | null; usageId?: number }>()
const props = defineProps<{
silence?: PackageSilenceInfo | null
usageId?: number
packageName?: string
assetIdentifier?: string
}>()
const emit = defineEmits<{ changed: [] }>()
const { hasAuth } = useAuth()
const recordsVisible = ref(false),