Files
one-pipe-system/src/components/business/package-silence/SilenceExtendDialog.vue
邹进 8fe65ca66d
Some checks failed
构建并部署前端到测试环境 / deploy (push) Failing after 4m33s
fix: 资产详情办理沉默期延期报无权执行此操作,弹窗被表格固定列遮挡
- 延期弹窗、沉默期流水弹窗的 allowed 未传时默认允许:Vue 会把未传的布尔 prop 当成 false,资产详情入口一律被拦截
- 沉默期相关弹窗挂到 body,避免在表格固定列里渲染被其他固定列盖住
2026-11-07 20:10:59 +09:00

601 lines
21 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<ElDialog
:model-value="true"
append-to-body
title="办理沉默期延期"
width="min(760px, 96vw)"
destroy-on-close
@close="$emit('close')"
>
<ElAlert
title="金额和延期截止时间以服务端试算及处理结果为准;待审批或已支付不代表延期已生效。"
type="info"
:closable="false"
/>
<ElAlert
v-if="error || eligibilityError"
:title="error || eligibilityError"
type="error"
:closable="false"
/>
<ElForm label-width="140px" :disabled="submitting || !!order || !!batch">
<ElFormItem label="办理方式"
><ElSelect v-model="mode"
><ElOption
v-if="hasAuth(P.extend) && usageId"
value="online"
label="扫码付费延期" /><ElOption
v-if="hasAuth(P.offline) && usageId"
value="offline"
label="登记线下付款" /><ElOption
v-if="hasAuth(P.extend)"
value="special"
label="特殊延期" /></ElSelect
></ElFormItem>
<template v-if="usageId">
<ElFormItem label="套餐记录"
>{{ usageId }}
<ElButton link :loading="eligibilityLoading" @click="loadEligibility"
>刷新资格</ElButton
></ElFormItem
>
<ElFormItem v-if="eligibility" label="延期资格"
><span
>{{
eligibility.reason || (eligibility.can_extend ? '可以付费延期' : '不能付费延期')
}};特殊延期:{{
eligibility.special_extension_available ? '可申请' : '不可申请'
}}</span
></ElFormItem
>
<ElFormItem v-if="eligibility?.pending_order_no" label="待处理订单"
>{{ eligibility.pending_order_no }}
<ElButton link :disabled="submitting" @click="resumeOrder">查看订单</ElButton></ElFormItem
>
</template>
<ElFormItem label="延期天数" required
><ElInputNumber v-model="days" :min="1" :precision="0" :step="1" /><span
v-if="mode !== 'special' && eligibility"
>({{ eligibility.min_days ?? '-' }}~{{ eligibility.max_days ?? '-' }} 天)</span
></ElFormItem
>
<template v-if="mode !== 'special'">
<ElFormItem label="后端试算"
><ElButton :loading="quoteLoading" :disabled="!eligibility?.can_extend" @click="loadQuote"
>试算</ElButton
><span v-if="quote"
>实际延长 {{ quote.billed_days ?? '-' }} 天,{{ silenceAmount(quote.amount) }},新截止日
{{ quote.new_deadline || '-' }}(当日 23:59:59)</span
></ElFormItem
>
<ElFormItem v-if="mode === 'online'" label="支付方式"
><ElSelect v-model="paymentMethod"
><ElOption value="wechat" label="微信支付" /><ElOption
value="alipay"
label="支付宝" /></ElSelect
></ElFormItem>
</template>
<template v-else>
<ElFormItem label="延期类型" required
><ElSelect v-model="specialType"
><ElOption v-for="item in specialTypes" :key="item.value" v-bind="item" /></ElSelect
></ElFormItem>
<ElFormItem label="原因" required><ElInput v-model="reason" type="textarea" /></ElFormItem>
<ElFormItem label="依据" required><ElInput v-model="basis" type="textarea" /></ElFormItem>
<ElFormItem v-if="hasAuth(P.batch)" label="批量 CSV"
><ElUpload :auto-upload="false" :show-file-list="false" accept=".csv" :on-change="readCsv"
><ElButton>选择 CSV 明细</ElButton></ElUpload
><p
>表头 package_usage_id,days;每行一笔。选中后将替代当前单项。天数由后端按 30
天向上取整。</p
></ElFormItem
>
<ElFormItem v-if="batchItems.length" label="导入明细"
><span>{{ batchItems.length }} 项</span
><ElButton link @click="batchItems = []">清除</ElButton></ElFormItem
>
<ElFormItem v-if="specialType === 'discount'" label="实收合计(元)" required
><ElInput v-model="paidYuan" inputmode="decimal"
/></ElFormItem>
</template>
<template v-if="requiresReceipt">
<ElFormItem label="线下收款方式" required
><ElSelect
v-model="methodId"
filterable
:loading="methodsLoading"
@visible-change="(value) => value && loadMethods()"
><ElOption
v-for="item in methods"
:key="item.id"
:value="item.id"
:label="item.name" /></ElSelect
></ElFormItem>
<ElFormItem label="交易单号" required><ElInput v-model="tradeNo" /></ElFormItem>
<ElFormItem v-if="mode === 'offline'" label="实收金额(元)" required
><ElInput v-model="paidYuan" inputmode="decimal"
/></ElFormItem>
<ElFormItem label="付款时间" required
><ElDatePicker
v-model="paidAt"
type="datetime"
value-format="YYYY-MM-DDTHH:mm:ssZ"
format="YYYY-MM-DD HH:mm:ss"
/></ElFormItem>
<ElFormItem label="收款凭证" required
><VoucherUpload
v-model="vouchers"
:key="`vouchers-${mode}`"
:max-count="5"
@uploading-change="(value) => (uploadingReceipt = value)"
/></ElFormItem>
</template>
<ElFormItem label="申请记录/截图"
><VoucherUpload
v-model="evidence"
:max-count="5"
@uploading-change="(value) => (uploadingEvidence = value)"
/></ElFormItem>
</ElForm>
<ElAlert
v-if="order"
:title="`订单 ${order.order_no || '-'}:${order.status_name || order.status || '-'};实际生效以完成状态为准`"
type="info"
:closable="false"
/>
<div v-if="order?.status === 'pending_payment' && order.qr_content" class="silence-qr"
><QrcodeVue :value="order.qr_content" :size="200" level="M" /><p
>支付截止:{{ order.expire_at ? formatDateTime(order.expire_at) : '-' }}</p
></div
>
<ElButton v-if="order?.id" :loading="checking" @click="refreshOrder">刷新订单状态</ElButton>
<ElAlert
v-if="batch"
:title="`批次 ${batch.batch_no || '-'}:${batch.status || '-'};审批 ${batch.approval_status || '-'};成功 ${batch.succeeded_count ?? '-'},失败 ${batch.failed_count ?? '-'}`"
:closable="false"
/>
<SilenceTable v-if="batch?.items?.length" :rows="batch.items" :definitions="resultFields" />
<ElTable v-if="rowErrors.length" :data="rowErrors"
><ElTableColumn label="行号"
><template #default="{ row }">{{ row.row_no ?? row.row ?? '-' }}</template></ElTableColumn
><ElTableColumn label="失败原因"
><template #default="{ row }">{{
row.reason || row.message || '-'
}}</template></ElTableColumn
></ElTable
>
<template #footer
><ElButton @click="$emit('close')">关闭</ElButton
><ElButton
v-if="!order && !batch"
type="primary"
:loading="submitting"
:disabled="!canSubmit || uploadingReceipt || uploadingEvidence"
@click="submit"
>确认提交</ElButton
></template
>
</ElDialog>
</template>
<script setup lang="ts">
import { ref, computed, watch, onMounted, onBeforeUnmount, onDeactivated } from 'vue'
import { ElMessageBox } from 'element-plus'
import type { UploadFile } from 'element-plus'
import QrcodeVue from 'qrcode.vue'
import { PackageSilenceService as Service } from '@/api/modules/packageSilence'
import { EmployeeCollectionService } from '@/api/modules/employeeCollection'
import { useAuth } from '@/composables/useAuth'
import { useUserStore } from '@/store/modules/user'
import { useSchedulerRequest } from '@/composables/useSchedulerRequest'
import { pollingScope } from '@/utils/business/pollingScheduler'
import { PACKAGE_SILENCE_PERMISSIONS as P } from '@/config/constants/packageSilence'
import {
silenceData,
silenceId,
silenceDays,
silenceFen,
silenceDate,
silenceAmount,
parseSilenceCsv,
silenceRowsError,
SILENCE_SPECIAL_TYPES,
silenceOptions
} from '@/utils/business/silenceExtension'
import { gatewayError, requireGatewayAccess } from '@/utils/business/gateway'
import { formatDateTime } from '@/utils/business/format'
import type {
SilenceExtensionOrderResponse,
SilenceBatchResponse,
SilenceSpecialItem
} from '@/types/api/packageSilenceContract'
import VoucherUpload from '@/components/business/VoucherUpload.vue'
import SilenceTable from './SilenceTable.vue'
// allowed 未传时视为允许:Vue 会把未传的布尔 prop 当成 false,不设默认值时资产详情入口一律报"无权执行此操作"
const props = withDefaults(
defineProps<{ usageId?: number; allowed?: boolean; batchMode?: boolean }>(),
{ allowed: true, batchMode: false }
)
const emit = defineEmits<{ close: []; changed: [] }>()
const { hasAuth } = useAuth(),
user = useUserStore()
const mode = ref<'online' | 'offline' | 'special'>(
props.batchMode ? 'special' : hasAuth(P.extend) ? 'online' : 'offline'
)
const days = ref<number | undefined>(),
paymentMethod = ref('wechat'),
specialType = ref('free'),
reason = ref(''),
basis = ref('')
const methodId = ref<number>(),
tradeNo = ref(''),
paidYuan = ref(''),
paidAt = ref(''),
vouchers = ref<string[]>([]),
evidence = ref<string[]>([])
const batchItems = ref<SilenceSpecialItem[]>([]),
specialUncertain = ref(false),
error = ref(''),
submitting = ref(false),
checking = ref(false)
const uploadingReceipt = ref(false),
uploadingEvidence = ref(false),
methodsLoading = ref(false),
methods = ref<{ id: number; name: string }[]>([])
const order = ref<SilenceExtensionOrderResponse>(),
batch = ref<SilenceBatchResponse>(),
rowErrors = ref<ReturnType<typeof silenceRowsError>>([])
const specialTypes = silenceOptions(SILENCE_SPECIAL_TYPES)
const allowed = () =>
props.allowed !== false &&
(mode.value === 'offline' ? hasAuth(P.offline) : hasAuth(P.extend)) &&
(!props.batchMode || hasAuth(P.batch))
const eligibilityAllowed = computed(() => allowed() && !!props.usageId)
const {
data: eligibility,
loading: eligibilityLoading,
error: eligibilityError,
load: loadEligibility,
clear: clearEligibility
} = useSchedulerRequest(
async () => silenceData(await Service.eligibility(silenceId(props.usageId))),
() => eligibilityAllowed.value
)
const {
data: quote,
loading: quoteLoading,
load: requestQuote,
clear: clearQuote,
error: quoteError
} = useSchedulerRequest(
async () => {
const input = silenceDays(days.value),
usage = silenceId(props.usageId)
return { ...silenceData(await Service.quote(usage, input)), forDays: input, forUsage: usage }
},
() => eligibilityAllowed.value && !!eligibility.value?.can_extend
)
const requiresReceipt = computed(
() =>
mode.value === 'offline' ||
(mode.value === 'special' && specialType.value === 'discount' && Number(paidYuan.value) > 0)
)
const canSubmit = computed(
() =>
allowed() &&
!specialUncertain.value &&
(mode.value === 'special'
? batchItems.value.length
? hasAuth(P.batch)
: !!eligibility.value?.special_extension_available
: !!eligibility.value?.can_extend &&
!!quote.value?.valid &&
quote.value.forDays === days.value &&
quote.value.forUsage === props.usageId &&
Number.isSafeInteger(quote.value.amount))
)
let active = true,
version = 0,
timer: ReturnType<typeof setTimeout> | undefined
const requestIds = new Map<string, string>()
function stop() {
if (timer) clearTimeout(timer)
timer = undefined
}
function invalidate() {
version++
stop()
order.value = undefined
batch.value = undefined
rowErrors.value = []
error.value = ''
clearQuote()
clearEligibility()
vouchers.value = []
evidence.value = []
methods.value = []
methodsLoading.value = false
days.value = undefined
reason.value = ''
basis.value = ''
methodId.value = undefined
tradeNo.value = ''
paidYuan.value = ''
paidAt.value = ''
batchItems.value = []
}
watch(
() => [props.usageId, props.allowed, pollingScope(user)],
() => {
invalidate()
if (props.usageId && allowed()) void loadEligibility()
},
{ flush: 'sync' }
)
watch(
() => [mode.value, days.value],
() => {
clearQuote()
error.value = ''
},
{ flush: 'sync' }
)
watch(
() => allowed(),
(value) => {
if (!value) invalidate()
},
{ flush: 'sync' }
)
watch(quoteError, (value) => {
if (value) error.value = value
})
async function loadQuote() {
await requestQuote()
}
async function loadMethods() {
if (!allowed() || methodsLoading.value) return
const current = version,
scope = pollingScope(user)
methodsLoading.value = true
try {
const result = silenceData(
await EmployeeCollectionService.getPaymentMethods({
page: 1,
page_size: 100,
enabled: true
})
)
if (!active || current !== version || scope !== pollingScope(user) || !allowed()) return
const values = Array.isArray(result) ? result : result.items || []
methods.value = values.map((item) => ({ id: item.id, name: item.name }))
} catch (e) {
if (current === version) error.value = gatewayError(e)
} finally {
if (current === version) methodsLoading.value = false
}
}
async function readCsv(file: UploadFile) {
try {
requireGatewayAccess(allowed() && hasAuth(P.batch))
if (!file.raw || !file.name.toLowerCase().endsWith('.csv')) throw new Error('请选择 CSV 文件')
const current = version,
items = parseSilenceCsv(await file.raw.text())
if (active && current === version && allowed() && hasAuth(P.batch)) batchItems.value = items
} catch (e) {
error.value = gatewayError(e)
}
}
async function resumeOrder() {
const no = eligibility.value?.pending_order_no,
current = version
if (!no || !allowed()) return
try {
const result = silenceData(await Service.orders({ order_no: no, page: 1, page_size: 20 }))
if (active && current === version && allowed()) {
order.value = result.items?.find((item) => item.order_no === no)
schedule()
}
} catch (e) {
if (current === version) error.value = gatewayError(e)
}
}
function schedule() {
stop()
if (active && allowed() && ['pending_payment', 'paid'].includes(order.value?.status || ''))
timer = setTimeout(() => void refreshOrder(), 3000)
}
async function refreshOrder() {
stop()
const id = order.value?.id,
current = version,
scope = pollingScope(user)
if (!id || checking.value || !allowed()) return
checking.value = true
try {
const result = silenceData(await Service.order(id))
if (!active || current !== version || scope !== pollingScope(user) || !allowed()) return
order.value = result
if (result.status === 'completed') emit('changed')
schedule()
} catch (e) {
if (current === version) error.value = gatewayError(e)
} finally {
checking.value = false
}
}
async function submit() {
if (submitting.value) return
const current = version,
scope = pollingScope(user),
currentMode = mode.value
submitting.value = true
error.value = ''
rowErrors.value = []
let specialSent = false
try {
requireGatewayAccess(canSubmit.value && !uploadingReceipt.value && !uploadingEvidence.value)
const input =
currentMode === 'special' && batchItems.value.length ? 1 : silenceDays(days.value),
usage =
batchItems.value.length && currentMode === 'special'
? undefined
: silenceId(props.usageId)
const common = { package_usage_id: usage!, days: input, evidence_refs: [...evidence.value] }
const receipt = requiresReceipt.value
? {
offline_payment_method_id: silenceId(methodId.value, '收款方式'),
offline_trade_no: tradeNo.value.trim(),
paid_at: silenceDate(paidAt.value),
payment_voucher_keys: [...vouchers.value]
}
: undefined
if (
receipt &&
(!receipt.offline_trade_no ||
receipt.payment_voucher_keys.length < 1 ||
receipt.payment_voucher_keys.length > 5)
)
throw new Error('请填写交易单号,并上传 1~5 张收款凭证')
const amount =
currentMode === 'offline' || (currentMode === 'special' && specialType.value === 'discount')
? silenceFen(paidYuan.value)
: 0
if (currentMode === 'offline' && amount !== quote.value?.amount)
throw new Error('实收金额必须等于当前后端试算金额')
const items = batchItems.value.length
? batchItems.value.map((item) => ({ ...item }))
: [{ package_usage_id: usage!, days: input }]
if (currentMode === 'special' && (!reason.value.trim() || !basis.value.trim()))
throw new Error('请填写特殊延期原因和依据')
if (currentMode === 'special' && items.length > 1) requireGatewayAccess(hasAuth(P.batch))
const body =
currentMode === 'special'
? {
extension_type: specialType.value,
items,
reason: reason.value.trim(),
basis: basis.value.trim(),
amount,
evidence_refs: [...evidence.value],
...(receipt
? {
offline_payment_method_id: receipt.offline_payment_method_id,
offline_trade_no: receipt.offline_trade_no,
offline_paid_at: receipt.paid_at,
payment_voucher_keys: receipt.payment_voucher_keys
}
: {})
}
: currentMode === 'offline'
? { ...common, ...receipt!, paid_amount: amount }
: { ...common, payment_method: paymentMethod.value }
const fingerprint = JSON.stringify([currentMode, body])
if (!requestIds.has(fingerprint)) requestIds.set(fingerprint, crypto.randomUUID())
await ElMessageBox.confirm(
currentMode === 'special'
? `确认提交 ${items.length} 项特殊延期申请?结果以审批和逐项执行为准。`
: '确认提交延期订单?待支付或待审批并不代表已生效。',
'提交确认',
{ type: 'warning' }
)
requireGatewayAccess(
active &&
current === version &&
scope === pollingScope(user) &&
currentMode === mode.value &&
canSubmit.value
)
// 表单在确认框期间被锁定;同一快照重试复用幂等键,不自动重发写请求。
specialSent = currentMode === 'special'
const result =
currentMode === 'special'
? silenceData(await Service.special(body as Parameters<typeof Service.special>[0]))
: currentMode === 'offline'
? silenceData(
await Service.createOffline({
...body,
request_id: requestIds.get(fingerprint)!
} as Parameters<typeof Service.createOffline>[0])
)
: silenceData(
await Service.createQrcode({
...body,
request_id: requestIds.get(fingerprint)!
} as Parameters<typeof Service.createQrcode>[0])
)
if (!active || current !== version || scope !== pollingScope(user) || !allowed()) return
if (currentMode === 'special') batch.value = result as SilenceBatchResponse
else {
order.value = result as SilenceExtensionOrderResponse
schedule()
}
emit('changed')
} catch (e) {
if (active && current === version && e !== 'cancel' && e !== 'close') {
error.value = gatewayError(e)
rowErrors.value = silenceRowsError(e)
if (
specialSent &&
!(e as { code?: unknown; response?: unknown })?.code &&
!(e as { response?: unknown })?.response
) {
specialUncertain.value = true
error.value += '。提交结果未确认,请先到延期批次列表核对,不要重复提交。'
}
}
} finally {
submitting.value = false
}
}
const resultFields = [
['row_no', '行号'],
['asset_identifier', '资产'],
['package_name', '套餐'],
['status', '处理状态'],
['after_deadline_at', '延期后截止'],
['failure_summary', '失败原因']
].map(([prop, label]) => ({ prop, label }))
onMounted(() => {
if (props.usageId) void loadEligibility()
})
onDeactivated(() => {
active = false
invalidate()
})
onBeforeUnmount(() => {
active = false
invalidate()
})
</script>
<style scoped>
.silence-qr {
padding: 16px;
text-align: center;
}
.el-alert {
margin-bottom: 16px;
}
.el-form-item p {
margin: 0;
line-height: 1.5;
}
@media (width <= 640px) {
:deep(.el-form-item) {
flex-wrap: wrap;
}
:deep(.el-form-item__label) {
justify-content: flex-start;
width: 100% !important;
}
:deep(.el-form-item__content) {
flex-basis: 100%;
margin-left: 0 !important;
}
}
</style>