feat: 新增退款凭证
All checks were successful
构建并部署前端到测试环境 / build-and-deploy (push) Successful in 4m27s

This commit is contained in:
sexygoat
2026-05-27 14:58:37 +08:00
parent fe0093972f
commit 6654972ddb
14 changed files with 521 additions and 47 deletions

View File

@@ -1,5 +1,5 @@
<template>
<ElDialog v-model="dialogVisible" title="创建退款申请" width="30%" @closed="handleDialogClosed">
<ElDialog v-model="dialogVisible" title="创建退款申请" width="40%" @closed="handleDialogClosed">
<ElForm ref="formRef" :model="formData" :rules="formRules" label-width="120px">
<ElFormItem label="订单号" prop="order_id">
<ElSelect
@@ -26,7 +26,7 @@
<ElFormItem label="申请退款金额" prop="requested_refund_amount">
<ElInputNumber
v-model="formData.requested_refund_amount"
:min="0.01"
:min="0"
:precision="2"
:step="1"
style="width: 100%"
@@ -46,12 +46,36 @@
placeholder="请输入退款原因"
/>
</ElFormItem>
<ElFormItem label="退款凭证" prop="refund_voucher_key">
<ElUpload
ref="uploadRef"
class="refund-voucher-upload"
drag
:auto-upload="false"
:on-change="handleVoucherFileChange"
:on-remove="handleRemoveVoucher"
accept="image/*"
list-type="picture"
multiple
>
<ElIcon class="refund-voucher-upload__icon"><UploadFilled /></ElIcon>
<div class="refund-voucher-upload__text">将退款凭证拖到此处<em>点击上传</em></div>
<template #tip>
<div class="el-upload__tip">支持所有图片格式可上传多张</div>
</template>
</ElUpload>
</ElFormItem>
</ElForm>
<template #footer>
<div class="dialog-footer">
<ElButton @click="dialogVisible = false">取消</ElButton>
<ElButton type="primary" @click="handleSubmit" :loading="submitLoading">
确认创建
<ElButton
type="primary"
@click="handleSubmit"
:loading="submitLoading || voucherUploading"
:disabled="voucherUploading"
>
{{ voucherUploading ? '图片上传中...' : '确认创建' }}
</ElButton>
</div>
</template>
@@ -59,10 +83,11 @@
</template>
<script setup lang="ts">
import { ref, reactive, computed, watch } from 'vue'
import { ref, reactive, computed, watch, nextTick } from 'vue'
import { ElMessage } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import { RefundService, OrderService } from '@/api/modules'
import { UploadFilled } from '@element-plus/icons-vue'
import type { FormInstance, FormRules, UploadFile, UploadInstance } from 'element-plus'
import { RefundService, OrderService, StorageService } from '@/api/modules'
import type { CreateRefundRequest, Order } from '@/types/api'
import { formatMoney, yuanToFen } from '@/utils/business/format'
@@ -84,26 +109,32 @@
})
const formRef = ref<FormInstance>()
const uploadRef = ref<UploadInstance>()
const submitLoading = ref(false)
const voucherUploading = ref(false)
const voucherUploadingCount = ref(0)
const orderSearchLoading = ref(false)
const orderSearchOptions = ref<Order[]>([])
let activeVoucherUploadBatch = 0
const formData = reactive<{
order_id: number | null
requested_refund_amount?: number
actual_received_amount: number
refund_reason: string
refund_voucher_key?: string
}>({
order_id: null,
requested_refund_amount: undefined,
actual_received_amount: 0,
refund_reason: ''
refund_reason: '',
refund_voucher_key: undefined
})
const validateRefundAmount = (rule: any, value: number, callback: any) => {
const requestedRefundAmount = yuanToFen(value) ?? 0
if (value === undefined || value === null || value <= 0) {
callback(new Error('申请退款金额必须大于0'))
if (value === undefined || value === null || value < 0) {
callback(new Error('申请退款金额不能小于0'))
} else if (requestedRefundAmount > formData.actual_received_amount) {
callback(new Error('申请退款金额不能大于实收金额'))
} else {
@@ -116,7 +147,8 @@
requested_refund_amount: [
{ required: true, message: '请输入申请退款金额', trigger: 'blur' },
{ validator: validateRefundAmount, trigger: 'blur' }
]
],
refund_voucher_key: [{ required: true, message: '请上传退款凭证', trigger: 'change' }]
})
const hasInitialOrder = computed(() => !!props.initialOrderId || !!props.initialOrderNo)
@@ -132,7 +164,8 @@
try {
const params: any = {
page: 1,
page_size: 20
page_size: 20,
payment_status: 2
}
if (query) {
params.order_no = query
@@ -170,15 +203,104 @@
const handleDialogClosed = () => {
formRef.value?.resetFields()
activeVoucherUploadBatch += 1
voucherUploadingCount.value = 0
voucherUploading.value = false
formData.order_id = null
formData.requested_refund_amount = undefined
formData.actual_received_amount = 0
formData.refund_reason = ''
formData.refund_voucher_key = undefined
voucherFileKeyMap.clear()
removedVoucherUploadUids.clear()
orderSearchOptions.value = []
uploadRef.value?.clearFiles()
}
const voucherFileKeyMap = new Map<number, string>()
const removedVoucherUploadUids = new Set<number>()
const refreshPaymentVoucherKey = () => {
const keys = Array.from(voucherFileKeyMap.values())
formData.refund_voucher_key = keys.length ? keys.join(',') : undefined
}
const removeUploadFile = (uploadFile: UploadFile) => {
uploadRef.value?.handleRemove(uploadFile)
}
const handleVoucherFileChange = async (uploadFile: UploadFile) => {
const file = uploadFile.raw
if (!file) return
if (!file.type.startsWith('image/')) {
ElMessage.error('只能上传图片文件!')
removeUploadFile(uploadFile)
return
}
const uploadBatch = activeVoucherUploadBatch
removedVoucherUploadUids.delete(uploadFile.uid)
voucherUploadingCount.value += 1
voucherUploading.value = voucherUploadingCount.value > 0
try {
ElMessage.info('正在上传退款凭证...')
const uploadUrlRes = await StorageService.getUploadUrl({
file_name: file.name,
content_type: file.type,
purpose: 'attachment'
})
if (uploadUrlRes.code !== 0) {
ElMessage.error(uploadUrlRes.msg || '获取上传地址失败')
removeUploadFile(uploadFile)
return
}
const { upload_url, file_key } = uploadUrlRes.data
await StorageService.uploadFile(upload_url, file, file.type)
if (
uploadBatch !== activeVoucherUploadBatch ||
removedVoucherUploadUids.has(uploadFile.uid)
) {
return
}
voucherFileKeyMap.set(uploadFile.uid, file_key)
refreshPaymentVoucherKey()
ElMessage.success('上传成功')
await nextTick(() => {
formRef.value?.validateField('refund_voucher_key')
})
} catch (error: any) {
if (uploadBatch !== activeVoucherUploadBatch) return
console.error('上传退款凭证失败:', error)
ElMessage.error(error.message || '上传失败,请重试')
voucherFileKeyMap.delete(uploadFile.uid)
refreshPaymentVoucherKey()
removeUploadFile(uploadFile)
} finally {
if (uploadBatch === activeVoucherUploadBatch) {
voucherUploadingCount.value = Math.max(0, voucherUploadingCount.value - 1)
voucherUploading.value = voucherUploadingCount.value > 0
}
}
}
const handleRemoveVoucher = (uploadFile: UploadFile) => {
removedVoucherUploadUids.add(uploadFile.uid)
voucherFileKeyMap.delete(uploadFile.uid)
refreshPaymentVoucherKey()
}
const handleSubmit = async () => {
if (!formRef.value) return
if (voucherUploading.value) {
ElMessage.warning('退款凭证上传中,请稍候')
return
}
await formRef.value.validate(async (valid) => {
if (valid) {
@@ -188,6 +310,7 @@
order_id: formData.order_id!,
requested_refund_amount: yuanToFen(formData.requested_refund_amount) ?? 0,
actual_received_amount: formData.actual_received_amount,
refund_voucher_key: formData.refund_voucher_key!,
refund_reason: formData.refund_reason || undefined
}
await RefundService.createRefund(data)
@@ -213,3 +336,35 @@
}
})
</script>
<style scoped lang="scss">
.refund-voucher-upload {
width: 100%;
:deep(.el-upload) {
width: 100%;
}
:deep(.el-upload-dragger) {
width: 100%;
padding: 24px 16px;
border-radius: 8px;
}
&__icon {
margin-bottom: 12px;
font-size: 28px;
color: var(--el-color-primary);
}
&__text {
font-size: 14px;
color: var(--el-text-color-regular);
em {
color: var(--el-color-primary);
font-style: normal;
}
}
}
</style>

View File

@@ -1,7 +1,7 @@
<template>
<ElImageViewer
v-if="visible"
:url-list="[url]"
:url-list="urls"
:initial-index="0"
hide-on-click-modal
@close="handleClose"
@@ -24,24 +24,32 @@
}>()
const visible = ref(false)
const url = ref('')
const urls = ref<string[]>([])
watch(
() => props.fileKey,
async (val) => {
if (val) {
url.value = ''
urls.value = []
visible.value = false
const fileKeys = val
.split(',')
.map((key) => key.trim())
.filter(Boolean)
try {
const res = await StorageService.batchDownloadUrls({
file_keys: [val]
file_keys: fileKeys
})
if (res.code === 0 && res.data?.urls?.[val]) {
url.value = res.data.urls[val]
visible.value = true
if (res.code === 0) {
urls.value = fileKeys.map((key) => res.data.urls[key]).filter(Boolean)
visible.value = urls.value.length > 0
}
} catch (error) {
console.error('获取支付凭证失败:', error)
}
} else {
visible.value = false
urls.value = []
}
},
{ immediate: true }
@@ -49,7 +57,7 @@
const handleClose = () => {
visible.value = false
url.value = ''
urls.value = []
emit('close')
}
</script>

View File

@@ -26,6 +26,7 @@ export interface Refund {
actual_received_amount: number // 实收金额(分)
status: RefundStatus
refund_reason: string
refund_voucher_key?: string // 退款凭证 file_key多个以英文逗号分隔
reject_reason: string
remark: string
asset_reset: boolean
@@ -62,6 +63,7 @@ export interface CreateRefundRequest {
order_id: number
requested_refund_amount: number // 申请退款金额(分)
actual_received_amount: number // 实收金额(分)
refund_voucher_key: string // 退款凭证对象存储 file_key多个以英文逗号分隔
refund_reason?: string
}

View File

@@ -22,6 +22,14 @@
</div>
</ElCard>
<ElImageViewer
v-if="voucherPreviewVisible"
:url-list="refundVoucherUrls"
:initial-index="voucherPreviewIndex"
hide-on-click-modal
@close="voucherPreviewVisible = false"
/>
<!-- 审批通过对话框 -->
<ElDialog
v-model="approveDialogVisible"
@@ -162,13 +170,13 @@
</template>
<script setup lang="ts">
import { ref, onMounted, computed, h } from 'vue'
import { ref, onMounted, computed, h, reactive } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElCard, ElButton, ElIcon, ElMessage, ElTag } from 'element-plus'
import { ElCard, ElImageViewer, ElIcon, ElMessage, ElTag } 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 { RefundService, StorageService } from '@/api/modules'
import type {
Refund,
RefundStatus,
@@ -177,7 +185,6 @@
ResubmitRefundRequest
} from '@/types/api'
import { formatDateTime, yuanToFen } from '@/utils/business/format'
import { RoutesAlias } from '@/router/routesAlias'
defineOptions({ name: 'RefundDetail' })
@@ -186,6 +193,9 @@
const loading = ref(false)
const refund = ref<Refund | null>(null)
const refundVoucherUrls = ref<string[]>([])
const voucherPreviewVisible = ref(false)
const voucherPreviewIndex = ref(0)
const pageTitle = computed(() => `退款详情`)
@@ -250,6 +260,14 @@
return statusMap[status] || '-'
}
const getVoucherKeys = (data: Refund): string[] => {
const voucherKey = data.refund_voucher_key || ''
return voucherKey
.split(',')
.map((key) => key.trim())
.filter(Boolean)
}
const detailSections = computed((): DetailSection[] => [
{
title: '退款信息',
@@ -287,6 +305,24 @@
render: (data) =>
h(ElTag, { type: getStatusType(data.status) }, () => getStatusText(data.status))
},
{
label: '退款凭证',
fullWidth: true,
render: () =>
refundVoucherUrls.value.length
? h(
'div',
{ class: 'refund-voucher-list' },
refundVoucherUrls.value.map((url, index) =>
h('img', {
class: 'refund-voucher-image',
src: url,
onClick: () => handlePreviewRefundVoucher(index)
})
)
)
: h('span', '-')
},
{
label: '资产重置',
render: (data) =>
@@ -354,6 +390,7 @@
const res = await RefundService.getRefundById(id)
if (res.code === 0) {
refund.value = res.data
await loadRefundVoucherUrls(res.data)
}
} catch (error) {
console.error(error)
@@ -367,6 +404,26 @@
router.back()
}
const loadRefundVoucherUrls = async (data: Refund) => {
const fileKeys = getVoucherKeys(data)
refundVoucherUrls.value = []
if (!fileKeys.length) return
try {
const res = await StorageService.batchDownloadUrls({ file_keys: fileKeys })
if (res.code === 0) {
refundVoucherUrls.value = fileKeys.map((key) => res.data.urls[key]).filter(Boolean)
}
} catch (error) {
console.error('获取退款凭证失败:', error)
}
}
const handlePreviewRefundVoucher = (index: number) => {
voucherPreviewIndex.value = index
voucherPreviewVisible.value = true
}
onMounted(() => {
const id = route.params.id as string
if (id) {
@@ -395,7 +452,8 @@
const handleApproveRefund = async () => {
if (!approveFormRef.value || !refund.value) return
await approveFormRef.value.validate(async (valid) => {
const refundId = refund.value.id
await approveFormRef.value.validate(async (valid: boolean) => {
if (valid) {
approveLoading.value = true
try {
@@ -404,10 +462,10 @@
remark: approveForm.remark || undefined
}
await RefundService.approveRefund(refund.value.id, data)
await RefundService.approveRefund(refundId, data)
ElMessage.success('审批通过成功')
approveDialogVisible.value = false
await fetchRefundDetail(refund.value.id)
await fetchRefundDetail(refundId)
} catch (error) {
console.error(error)
} finally {
@@ -420,14 +478,15 @@
const handleRejectRefund = async () => {
if (!rejectFormRef.value || !refund.value) return
await rejectFormRef.value.validate(async (valid) => {
const refundId = refund.value.id
await rejectFormRef.value.validate(async (valid: boolean) => {
if (valid) {
rejectLoading.value = true
try {
await RefundService.rejectRefund(refund.value.id, rejectForm)
await RefundService.rejectRefund(refundId, rejectForm)
ElMessage.success('审批拒绝成功')
rejectDialogVisible.value = false
await fetchRefundDetail(refund.value.id)
await fetchRefundDetail(refundId)
} catch (error) {
console.error(error)
} finally {
@@ -440,7 +499,8 @@
const handleResubmitRefund = async () => {
if (!resubmitFormRef.value || !refund.value) return
await resubmitFormRef.value.validate(async (valid) => {
const refundId = refund.value.id
await resubmitFormRef.value.validate(async (valid: boolean) => {
if (valid) {
resubmitLoading.value = true
try {
@@ -450,10 +510,10 @@
refund_reason: resubmitForm.refund_reason || undefined
}
await RefundService.resubmitRefund(refund.value.id, data)
await RefundService.resubmitRefund(refundId, data)
ElMessage.success('重新提交成功')
resubmitDialogVisible.value = false
await fetchRefundDetail(refund.value.id)
await fetchRefundDetail(refundId)
} catch (error) {
console.error(error)
} finally {
@@ -495,5 +555,21 @@
font-size: 32px;
}
}
:deep(.refund-voucher-list) {
display: flex;
flex-wrap: wrap;
gap: 12px;
line-height: 1;
}
:deep(.refund-voucher-image) {
width: 96px;
height: 96px;
cursor: zoom-in;
object-fit: cover;
border: 1px solid var(--el-border-color);
border-radius: 6px;
}
}
</style>

View File

@@ -36,7 +36,7 @@
:marginTop="10"
:actions="getActions"
:actionsWidth="220"
:inlineActionsCount="3"
:inlineActionsCount="2"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
>
@@ -48,6 +48,9 @@
<!-- 创建退款申请对话框 -->
<CreateRefundDialog v-model="createDialogVisible" @success="handleCreateSuccess" />
<!-- 退款凭证预览 -->
<PaymentVoucherDialog :file-key="refundVoucherFileKey" @close="refundVoucherFileKey = ''" />
<!-- 审批通过对话框 -->
<ElDialog
v-model="approveDialogVisible"
@@ -248,6 +251,7 @@
import { useCheckedColumns } from '@/composables/useCheckedColumns'
import { fenToYuan, formatDateTime, yuanToFen } from '@/utils/business/format'
import { RoutesAlias } from '@/router/routesAlias'
import PaymentVoucherDialog from '@/components/business/PaymentVoucherDialog.vue'
import { useAuth } from '@/composables/useAuth'
@@ -269,6 +273,7 @@
const returnDialogVisible = ref(false)
const resubmitDialogVisible = ref(false)
const currentRefund = ref<Refund | null>(null)
const refundVoucherFileKey = ref('')
// 搜索表单初始值
const initialSearchState: RefundQueryParams = {
@@ -364,7 +369,6 @@
{ label: '审批时间', prop: 'processed_at' }
]
const createFormRef = ref<FormInstance>()
const approveFormRef = ref<FormInstance>()
const rejectFormRef = ref<FormInstance>()
const returnFormRef = ref<FormInstance>()
@@ -856,6 +860,7 @@
// 获取操作按钮
const getActions = (row: Refund) => {
const actions: any[] = []
const voucherKey = row.refund_voucher_key
// 待审批状态可以打回重填、审批通过或审批拒绝
if (row.status === 1) {
@@ -894,8 +899,22 @@
})
}
if (voucherKey && hasAuth('refund:view_voucher')) {
actions.push({
label: '查看退款凭证',
handler: () => handleViewRefundVoucher(row),
type: 'primary'
})
}
return actions
}
const handleViewRefundVoucher = (row: Refund) => {
const voucherKey = row.refund_voucher_key
if (!voucherKey) return
refundVoucherFileKey.value = voucherKey
}
</script>
<style scoped lang="scss">

View File

@@ -212,7 +212,7 @@
将支付凭证拖到此处<em>点击上传</em>
</div>
<template #tip>
<div class="el-upload__tip">支持 jpgpng 格式文件大小不超过 5MB</div>
<div class="el-upload__tip">支持所有图片格式</div>
</template>
</ElUpload>
</ElFormItem>
@@ -534,14 +534,6 @@
return
}
// 验证文件大小
const isLt5M = file.size / 1024 / 1024 < 5
if (!isLt5M) {
ElMessage.error('图片大小不能超过 5MB!')
uploadRef.value?.clearFiles()
return
}
const requestId = ++activeVoucherUploadId
voucherUploading.value = true
createForm.payment_voucher_key = undefined