Files
one-pipe-system/src/views/finance/refund/detail.vue
luo d9d07422a9
All checks were successful
构建并部署前端到测试环境 / build-and-deploy (push) Successful in 5m44s
fix: update some files
2026-08-18 17:36:41 +08:00

582 lines
18 KiB
Vue

<template>
<div class="refund-detail-page">
<ElCard shadow="never">
<!-- 页面头部 -->
<div class="detail-header">
<ElButton @click="handleBack">
<template #icon>
<ElIcon><ArrowLeft /></ElIcon>
</template>
返回
</ElButton>
<h2 class="detail-title">{{ pageTitle }}</h2>
<ElButton
v-if="refund && canResubmit(refund) && hasAuth('refund:resubmit')"
type="primary"
@click="handleShowResubmit"
>
重新申请
</ElButton>
</div>
<!-- 详情内容 -->
<DetailPage v-if="refund" :sections="detailSections" :data="refund" />
<!-- 加载中 -->
<div v-if="loading" class="loading-container">
<ElIcon class="is-loading"><Loading /></ElIcon>
<span>加载中...</span>
</div>
</ElCard>
<PaymentVoucherDialog :file-keys="refundVoucherFileKeys" @close="refundVoucherFileKeys = []" />
<!-- 重新提交对话框 -->
<ElDialog
v-model="resubmitDialogVisible"
title="重新申请退款"
width="500px"
@closed="handleResubmitDialogClosed"
>
<ElForm
ref="resubmitFormRef"
:model="resubmitForm"
:rules="resubmitRules"
label-width="120px"
>
<ElFormItem label="退款单号">
<span>{{ refund?.refund_no }}</span>
</ElFormItem>
<ElFormItem label="申请退款金额">
<ElInputNumber
v-model="resubmitForm.requested_refund_amount"
:min="1"
:precision="2"
:step="1"
style="width: 100%"
placeholder="不填则使用原金额"
/>
</ElFormItem>
<ElFormItem label="实收金额">
<ElInputNumber
v-model="resubmitForm.actual_received_amount"
:min="1"
:precision="2"
:step="1"
style="width: 100%"
placeholder="不填则使用原金额"
/>
</ElFormItem>
<ElFormItem label="退款原因">
<ElInput
v-model="resubmitForm.refund_reason"
type="textarea"
:rows="3"
maxlength="1000"
show-word-limit
placeholder="请输入退款原因"
/>
</ElFormItem>
<ElFormItem label="退款凭证" prop="refund_voucher_key">
<VoucherUpload
ref="resubmitUploadRef"
v-model="resubmitForm.refund_voucher_key"
voucher-name="退款凭证"
@uploading-change="resubmitVoucherUploading = $event"
@change="resubmitFormRef?.validateField('refund_voucher_key')"
@files-change="resubmitForm.attachments = $event"
/>
</ElFormItem>
</ElForm>
<template #footer>
<div class="dialog-footer">
<ElButton @click="resubmitDialogVisible = false">取消</ElButton>
<ElButton
type="primary"
@click="handleResubmitRefund"
:loading="resubmitLoading || resubmitVoucherUploading"
:disabled="resubmitVoucherUploading"
>
{{ resubmitVoucherUploading ? '凭证上传中...' : '确认申请' }}
</ElButton>
</div>
</template>
</ElDialog>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, computed, h, reactive } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElButton, ElCard, ElIcon, ElMessage } from 'element-plus'
import { ArrowLeft, Loading } from '@element-plus/icons-vue'
import DetailPage from '@/components/common/DetailPage.vue'
import type { DetailSection } from '@/components/common/DetailPage.vue'
import { RefundService } from '@/api/modules'
import type { Refund, ResubmitRefundRequest, RefundAttachment } from '@/types/api'
import { fenToYuan, formatDateTime, yuanToFen } from '@/utils/business/format'
import { toVoucherKeyList, getErrorMessage } from '@/utils/business'
import PaymentVoucherDialog from '@/components/business/PaymentVoucherDialog.vue'
import VoucherUpload from '@/components/business/VoucherUpload.vue'
import { useAuth } from '@/composables/useAuth'
import { useUserStore } from '@/store/modules/user'
defineOptions({ name: 'RefundDetail' })
const route = useRoute()
const router = useRouter()
const { hasAuth } = useAuth()
const userStore = useUserStore()
const isRestrictedCustomerRole = computed(() => [3, 4].includes(Number(userStore.info.user_type)))
const loading = ref(false)
const refund = ref<Refund | null>(null)
const refundVoucherFileKeys = ref<string[]>([])
const pageTitle = computed(() => `退款详情`)
const resubmitDialogVisible = ref(false)
const resubmitLoading = ref(false)
const resubmitVoucherUploading = ref(false)
const resubmitFormRef = ref()
const resubmitUploadRef = ref<InstanceType<typeof VoucherUpload>>()
const resubmitRules = ref()
const resubmitForm = reactive<{
requested_refund_amount?: number
actual_received_amount?: number
refund_voucher_key: string[]
attachments: RefundAttachment[]
refund_reason?: string
}>({
requested_refund_amount: undefined,
actual_received_amount: undefined,
refund_voucher_key: [],
attachments: [],
refund_reason: ''
})
const formatCurrency = (amount: number | null | undefined): string => {
if (amount === undefined || amount === null || Number.isNaN(amount)) return '-'
return `¥${(amount / 100).toFixed(2)}`
}
const getApprovalProviderText = (item: Refund) => {
const provider = item.approval_provider || item.approval_source || item.approval?.source
if (provider === 'wecom') return '企微'
if (provider === 'legacy') return '历史审批'
if (provider === 'none') return '无'
return provider || '-'
}
const getRefundApprovalStatusText = (item: Refund) => {
return item.approval_status_name || item.approval?.status_name || item.approval?.status || '-'
}
const isHiddenIdKey = (key: string) => {
const normalizedKey = key.toLowerCase()
return normalizedKey === 'id' || normalizedKey === 'userid' || normalizedKey.endsWith('_id')
}
const sanitizeStructuredValue = (value: unknown): unknown => {
if (Array.isArray(value)) return value.map((item) => sanitizeStructuredValue(item))
if (!value || typeof value !== 'object') return value
return Object.fromEntries(
Object.entries(value)
.filter(([key]) => !isHiddenIdKey(key))
.map(([key, item]) => [key, sanitizeStructuredValue(item)])
)
}
const formatStructuredValue = (value: unknown) => {
if (value === undefined || value === null || value === '') return '-'
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
return String(value)
}
return JSON.stringify(value, null, 2)
}
const renderStructuredValue = (value: unknown) =>
h('pre', { class: 'structured-value' }, formatStructuredValue(sanitizeStructuredValue(value)))
const renderTimeline = (timeline: unknown) => {
if (!Array.isArray(timeline) || timeline.length === 0) return h('span', '-')
return h(
'div',
{ class: 'approval-timeline' },
timeline.map((item: any) => {
const title = item.status_name || item.status || item.content || '审批节点'
const operator = item.operator_name || ''
const time = item.time || item.timestamp || ''
const comment = item.comment || item.content || ''
return h('div', { class: 'approval-timeline__item' }, [
h('strong', title),
operator || time ? h('span', `${operator}${operator && time ? ' · ' : ''}${time}`) : null,
comment && comment !== title ? h('div', comment) : null
])
})
)
}
const getRefundAttachmentKeys = (item: Refund) => {
const keys = item.attachments?.map((attachment) => attachment.file_key).filter(Boolean) || []
return keys.length ? keys : toVoucherKeyList(item.refund_voucher_key)
}
const detailSections = computed((): DetailSection[] => [
{
title: '退款业务信息',
fields: [
{ label: '退款单号', prop: 'refund_no' },
{ label: '店铺名称', prop: 'shop_name' },
{ label: '订单号', prop: 'order_no' },
{ label: '资产标识符', prop: 'asset_identifier' },
{ label: '资产类型', prop: 'asset_type' },
{ label: '退款状态', prop: 'status_name', formatter: (value) => value || '-' },
...(!isRestrictedCustomerRole.value
? [
{
label: '提交人',
prop: 'submitter_name',
formatter: (value: string | null | undefined) => value || '-'
}
]
: []),
{
label: '申请退款金额',
formatter: (_, data) => formatCurrency(data.requested_refund_amount)
},
{
label: '实际退款金额',
formatter: (_, data) => formatCurrency(data.approved_refund_amount)
},
{
label: '实收金额',
formatter: (_, data) => formatCurrency(data.actual_received_amount)
},
...(!isRestrictedCustomerRole.value
? [
{
label: '退款原因',
prop: 'refund_reason',
formatter: (value: string | null | undefined) => value || '-',
fullWidth: true
},
{
label: '拒绝原因',
prop: 'reject_reason',
formatter: (value: string | null | undefined) => value || '-',
fullWidth: true
},
{
label: '备注',
prop: 'remark',
formatter: (value: string | null | undefined) => value || '-',
fullWidth: true
}
]
: []),
{
label: '退款凭证',
fullWidth: true,
render: (data) =>
getRefundAttachmentKeys(data).length
? h(
ElButton,
{
type: 'primary',
link: true,
onClick: () => {
refundVoucherFileKeys.value = getRefundAttachmentKeys(data)
}
},
() => '查看退款凭证'
)
: h('span', '-')
},
{
label: '资产重置',
formatter: (_, data) => (data.asset_reset ? '是' : '否')
},
{
label: '佣金扣除',
formatter: (_, data) => (data.commission_deducted ? '是' : '否')
},
{ label: '创建时间', prop: 'created_at', formatter: (value) => formatDateTime(value) },
{
label: '审批时间',
prop: 'processed_at',
formatter: (value) => (value ? formatDateTime(value) : '-')
},
{ label: '更新时间', prop: 'updated_at', formatter: (value) => formatDateTime(value) }
]
},
{
title: '企微审批信息',
fields: [
...(!isRestrictedCustomerRole.value
? [
{
label: '审批渠道',
formatter: (_: unknown, data: Refund) => getApprovalProviderText(data)
}
]
: []),
{
label: '审批来源',
formatter: (_, data) => data.approval?.source || data.approval_source || '-'
},
{ label: '审批单号', prop: 'approval.sp_no' },
{
label: '审批状态',
formatter: (_, data) => getRefundApprovalStatusText(data)
},
...(!isRestrictedCustomerRole.value
? [
{
label: '当前审批人摘要',
formatter: (_: unknown, data: Refund) => data.current_approver_summary || '-'
}
]
: []),
{ label: '模板版本', prop: 'approval.template_version' },
{
label: '申请人',
render: (data) => renderStructuredValue(data.approval?.applicant)
},
{
label: '审批人',
render: (data) => renderStructuredValue(data.approval?.approvers)
},
{
label: '审批意见',
render: (data) => renderStructuredValue(data.approval?.comments),
fullWidth: true
},
{
label: '审批附件',
render: (data) => {
const keys = (data.approval?.attachments || [])
.map((item: RefundAttachment) => item.file_key)
.filter(Boolean)
return keys.length
? h(
ElButton,
{
type: 'primary',
link: true,
onClick: () => (refundVoucherFileKeys.value = keys)
},
() => '查看审批附件'
)
: h('span', '-')
}
},
{
label: '审批时间线',
render: (data) => renderTimeline(data.approval?.timeline),
fullWidth: true
}
]
},
{
title: '业务处理结果',
fields: [
...(!isRestrictedCustomerRole.value
? [
{
label: '处理状态',
formatter: (_: unknown, data: Refund) =>
data.processing_status_name || data.processing_status || '-'
}
]
: []),
{
label: '失败摘要',
formatter: (_, data) => data.processing_failure_summary || data.error_summary || '-',
fullWidth: true
},
{
label: '处理提示',
formatter: (_, data) => data.processing_message || '-',
fullWidth: true
},
{
label: '业务处理结果',
render: (data) =>
renderStructuredValue(
data.business_process_result || data.approval?.business_process_result
),
fullWidth: true
}
]
}
])
const fetchRefundDetail = async (id: number) => {
loading.value = true
try {
const res = await RefundService.getRefundById(id)
if (res.code === 0) {
refund.value = res.data
}
} catch (error) {
console.error(error)
} finally {
loading.value = false
}
}
// 返回上一页
const handleBack = () => {
router.back()
}
onMounted(() => {
const id = route.params.id as string
if (id) {
fetchRefundDetail(Number(id))
}
})
const handleResubmitDialogClosed = () => {
resubmitFormRef.value?.resetFields()
resubmitForm.requested_refund_amount = undefined
resubmitForm.actual_received_amount = undefined
resubmitForm.refund_voucher_key = []
resubmitForm.attachments = []
resubmitForm.refund_reason = ''
resubmitVoucherUploading.value = false
resubmitUploadRef.value?.clearFiles(false)
}
const canResubmit = (item: Refund) => {
const statusText = [item.approval_status, item.approval_status_name, item.status_name]
.filter(Boolean)
.join(' ')
.toLowerCase()
return (
item.status === 3 ||
statusText.includes('reject') ||
statusText.includes('revoke') ||
statusText.includes('delete') ||
statusText.includes('驳回') ||
statusText.includes('撤销') ||
statusText.includes('删除')
)
}
const handleShowResubmit = () => {
if (!refund.value || !canResubmit(refund.value)) return
resubmitForm.requested_refund_amount =
fenToYuan(refund.value.requested_refund_amount) || undefined
resubmitForm.actual_received_amount =
fenToYuan(refund.value.actual_received_amount) || undefined
resubmitForm.refund_voucher_key = []
resubmitForm.attachments = []
resubmitForm.refund_reason = refund.value.refund_reason
resubmitDialogVisible.value = true
}
const handleResubmitRefund = async () => {
if (!resubmitFormRef.value || !refund.value) return
if (resubmitVoucherUploading.value) {
ElMessage.warning('退款凭证上传中,请稍候')
return
}
const refundId = refund.value.id
await resubmitFormRef.value.validate(async (valid: boolean) => {
if (valid) {
resubmitLoading.value = true
try {
const data: ResubmitRefundRequest = {
requested_refund_amount: yuanToFen(resubmitForm.requested_refund_amount),
actual_received_amount: yuanToFen(resubmitForm.actual_received_amount),
refund_reason: resubmitForm.refund_reason || undefined,
attachments: resubmitForm.attachments
}
await RefundService.resubmitRefund(refundId, data)
ElMessage.success('重新申请成功')
resubmitDialogVisible.value = false
await fetchRefundDetail(refundId)
} catch (error) {
ElMessage.error(getErrorMessage(error, '重新申请失败'))
} finally {
resubmitLoading.value = false
}
}
})
}
</script>
<style scoped lang="scss">
.refund-detail-page {
padding: 20px;
.detail-header {
display: flex;
gap: 16px;
align-items: center;
margin-bottom: 24px;
.detail-title {
flex: 1;
margin: 0;
font-size: 20px;
font-weight: 600;
color: var(--el-text-color-primary);
}
}
.structured-value {
max-width: 100%;
margin: 0;
overflow-x: auto;
font: inherit;
line-height: 1.6;
white-space: pre-wrap;
}
.approval-timeline {
display: flex;
flex-direction: column;
gap: 12px;
&__item {
display: flex;
flex-direction: column;
gap: 4px;
padding-left: 12px;
border-left: 2px solid var(--el-color-primary-light-5);
span {
color: var(--el-text-color-secondary);
}
}
}
.loading-container {
display: flex;
flex-direction: column;
gap: 12px;
align-items: center;
justify-content: center;
padding: 60px 0;
color: var(--el-text-color-secondary);
.el-icon {
font-size: 32px;
}
}
}
</style>