feat: 批量订购上传支付进度与失败明细
All checks were successful
构建并部署前端到测试环境 / build-and-deploy (push) Successful in 8m34s

This commit is contained in:
luo
2026-07-24 15:45:55 +08:00
parent e043fd86d1
commit 9a84cd0d31
16 changed files with 931 additions and 2 deletions

View File

@@ -0,0 +1,31 @@
import request from '@/utils/http'
import type {
BulkPurchaseCreateApiResponse,
BulkPurchaseItemsApiResponse,
BulkPurchaseTaskApiResponse
} from '@/types/api'
export class BulkPurchaseService {
static createTask(data: FormData): Promise<BulkPurchaseCreateApiResponse> {
return request.post<BulkPurchaseCreateApiResponse>({
url: '/api/admin/bulk-purchases',
data
})
}
static getTask(taskId: number): Promise<BulkPurchaseTaskApiResponse> {
return request.get<BulkPurchaseTaskApiResponse>({
url: `/api/admin/bulk-purchases/${taskId}`
})
}
static getItems(
taskId: number,
params?: { page?: number; size?: number; status?: string | number }
): Promise<BulkPurchaseItemsApiResponse> {
return request.get<BulkPurchaseItemsApiResponse>({
url: `/api/admin/bulk-purchases/${taskId}/items`,
params
})
}
}

View File

@@ -34,6 +34,7 @@ export { PollingMonitorService } from './pollingMonitor'
export { SuperAdminService } from './superAdmin'
export { ExportTaskService } from './exportTask'
export { OrderPackageInvalidateTaskService } from './orderPackageInvalidateTask'
export { BulkPurchaseService } from './bulkPurchase'
// TODO: 按需添加其他业务模块
// export { SettingService } from './setting'

View File

@@ -12,6 +12,7 @@
drag
multiple
:auto-upload="false"
:accept="accept"
:on-change="handleFileChange"
:on-remove="handleRemoveFile"
list-type="picture"
@@ -20,7 +21,7 @@
<div class="voucher-upload__text">{{ voucherName }}拖到此处<em>点击上传</em></div>
<template #tip>
<div class="el-upload__tip">
支持多张图片或附件最多上传 {{ maxCount }} 个文件可粘贴文件
{{ tip || `支持多张图片或附件,最多上传 ${maxCount} 个文件;可粘贴文件。` }}
</div>
</template>
<template #file="{ file }">
@@ -61,11 +62,15 @@
modelValue?: string[] | string
voucherName?: string
maxCount?: number
accept?: string
tip?: string
}
const props = withDefaults(defineProps<Props>(), {
voucherName: '凭证',
maxCount: 5
maxCount: 5,
accept: '',
tip: ''
})
const emit = defineEmits<{
@@ -171,6 +176,20 @@
const file = uploadFile.raw
if (!file) return
if (props.accept) {
const accepted = props.accept.split(',').some((type) => {
const value = type.trim().toLowerCase()
return value.startsWith('.')
? file.name.toLowerCase().endsWith(value)
: file.type.toLowerCase() === value
})
if (!accepted) {
ElMessage.warning(`只能上传 ${props.accept} 格式的文件`)
removeUploadFile(uploadFile)
return
}
}
if (!selectedUploadUids.has(uploadFile.uid)) {
if (selectedUploadUids.size >= getMaxCount()) {
showMaxCountWarning()

View File

@@ -0,0 +1,7 @@
export const BULK_PURCHASE_PERMISSIONS = {
page: 'bulk_purchase:view',
create: 'bulk_purchase:create',
detail: 'bulk_purchase:detail',
items: 'bulk_purchase:items',
template: 'bulk_purchase:template'
} as const

View File

@@ -34,3 +34,6 @@ export * from './enableStatus'
// 导出任务相关
export * from './exportTask'
// 批量订购相关
export * from './bulkPurchase'

View File

@@ -1,5 +1,6 @@
import { RoutesAlias } from '../routesAlias'
import { AppRouteRecord } from '@/types/router'
import { BULK_PURCHASE_PERMISSIONS } from '@/config/constants/bulkPurchase'
/**
* 菜单列表、异步路由
@@ -525,6 +526,17 @@ export const asyncRoutes: AppRouteRecord[] = [
isHide: true,
keepAlive: false
}
},
// 批量订购
{
path: 'bulk-purchase',
name: 'BulkPurchase',
component: RoutesAlias.BulkPurchase,
meta: {
title: '批量订购套餐',
permissions: [BULK_PURCHASE_PERMISSIONS.page],
keepAlive: true
}
}
]
},

View File

@@ -68,6 +68,7 @@ export enum RoutesAlias {
// 订单管理
OrderList = '/order-management/order-list', // 订单列表
OrderDetail = '/order-management/order-list/detail', // 订单详情
BulkPurchase = '/order-management/bulk-purchase', // 批量订购
// 财务管理
AgentRecharge = '/finance/agent-recharge', // 代理充值

View File

@@ -0,0 +1,67 @@
import type { BaseResponse } from './common'
export type BulkPurchasePaymentMethod = 'wallet' | 'offline'
export enum BulkPurchaseTaskStatus {
PENDING = 1,
PROCESSING = 2,
COMPLETED = 3,
FAILED = 4,
CANCELED = 5
}
export interface BulkPurchaseCreateResponse {
task_id: number
task_no?: string
status?: BulkPurchaseTaskStatus
status_name?: string
message?: string
}
export interface BulkPurchaseTask {
id?: number
task_id: number
task_no?: string
status: BulkPurchaseTaskStatus
status_name?: string
total_count?: number
success_count?: number
failed_count?: number
total_amount?: number
success_amount?: number
failed_amount?: number
amount_summary?: Record<string, number | string>
error_code?: string
error_summary?: string
created_at?: string
updated_at?: string
started_at?: string
completed_at?: string
}
export interface BulkPurchaseItem {
id?: number
row_number?: number
line?: number
asset_identifier?: string
iccid?: string
virtual_no?: string
package_code?: string
status?: string | number
status_name?: string
error_code?: string
error_reason?: string
error_summary?: string
}
export interface BulkPurchaseItemsResponse {
items: BulkPurchaseItem[]
page?: number
size?: number
page_size?: number
total?: number
}
export type BulkPurchaseCreateApiResponse = BaseResponse<BulkPurchaseCreateResponse>
export type BulkPurchaseTaskApiResponse = BaseResponse<BulkPurchaseTask>
export type BulkPurchaseItemsApiResponse = BaseResponse<BulkPurchaseItemsResponse>

View File

@@ -116,3 +116,6 @@ export * from './asyncTask'
// 订单套餐批量作废任务相关
export * from './orderPackageInvalidateTask'
// 批量订购任务相关
export * from './bulkPurchase'

View File

@@ -0,0 +1,495 @@
<template>
<ArtTableFullScreen>
<div class="bulk-purchase-page" id="table-full-screen">
<ElCard shadow="never" class="art-table-card">
<template #header>
<div class="page-header">
<div>
<h2>批量订购套餐</h2>
<p>同一批次只能选择一种支付方式系统按任务结果逐行处理订单</p>
</div>
<ElButton
v-if="hasAuth(BULK_PURCHASE_PERMISSIONS.template)"
tag="a"
href="/templates/bulk-purchase-template.xlsx"
download="批量订购模板.xlsx"
>
下载模板
</ElButton>
</div>
</template>
<ElForm ref="formRef" :model="form" :rules="rules" label-width="110px" class="create-form">
<ElFormItem label="代理商" prop="shop_id">
<ElSelect
v-model="form.shop_id"
filterable
remote
reserve-keyword
clearable
placeholder="请选择代理商"
:remote-method="searchShops"
:loading="shopLoading"
style="width: 100%"
>
<ElOption
v-for="shop in shopOptions"
:key="shop.id"
:label="`${shop.shop_name} (${shop.shop_code})`"
:value="shop.id"
/>
</ElSelect>
</ElFormItem>
<ElFormItem label="支付方式" prop="payment_method">
<ElRadioGroup v-model="form.payment_method" @change="handlePaymentMethodChange">
<ElRadio value="wallet">代理钱包</ElRadio>
<ElRadio value="offline">线下支付</ElRadio>
</ElRadioGroup>
</ElFormItem>
<ElFormItem label="订单文件" prop="orderFile">
<VoucherUpload
ref="orderUploadRef"
v-model="orderFileKeys"
voucher-name="订单文件"
:max-count="1"
accept=".xlsx,.xls,.csv"
tip="支持 .xlsx、.xls 或 .csv 文件"
@uploading-change="orderFileUploading = $event"
@change="formRef?.validateField('orderFile')"
/>
</ElFormItem>
<ElFormItem
v-if="form.payment_method === 'offline'"
label="整批支付凭证"
prop="voucherFile"
>
<VoucherUpload
ref="voucherUploadRef"
v-model="voucherFileKeys"
voucher-name="整批支付凭证"
@uploading-change="voucherUploading = $event"
@change="formRef?.validateField('voucherFile')"
/>
</ElFormItem>
<ElAlert
v-if="submitting || voucherUploading || orderFileUploading"
type="info"
:closable="false"
show-icon
:title="
voucherUploading
? '支付凭证上传中,请稍候'
: orderFileUploading
? '订单文件上传中,请稍候'
: '批量订购文件上传及任务创建中,请勿重复提交'
"
/>
<div class="form-actions">
<ElButton
type="primary"
:loading="submitting || voucherUploading || orderFileUploading"
:disabled="
voucherUploading || orderFileUploading || !hasAuth(BULK_PURCHASE_PERMISSIONS.create)
"
@click="submitTask"
>
创建批量订购任务
</ElButton>
<ElButton v-if="taskDetail" @click="resetTask">重新创建</ElButton>
</div>
</ElForm>
</ElCard>
<ElCard
v-if="taskDetail && hasAuth(BULK_PURCHASE_PERMISSIONS.detail)"
shadow="never"
class="art-table-card task-card"
>
<template #header>
<div class="task-header">
<span>任务结果</span>
<ElTag :type="getStatusType(taskDetail.status)">
{{ taskDetail.status_name || getStatusName(taskDetail.status) }}
</ElTag>
</div>
</template>
<ElDescriptions :column="4" border>
<ElDescriptionsItem label="任务号">{{
taskDetail.task_no || taskDetail.task_id
}}</ElDescriptionsItem>
<ElDescriptionsItem label="总数">{{ taskDetail.total_count ?? 0 }}</ElDescriptionsItem>
<ElDescriptionsItem label="成功数">{{
taskDetail.success_count ?? 0
}}</ElDescriptionsItem>
<ElDescriptionsItem label="失败数">{{ taskDetail.failed_count ?? 0 }}</ElDescriptionsItem>
<ElDescriptionsItem label="总金额">{{
formatAmount(taskDetail.total_amount)
}}</ElDescriptionsItem>
<ElDescriptionsItem label="成功金额">{{
formatAmount(taskDetail.success_amount)
}}</ElDescriptionsItem>
<ElDescriptionsItem label="失败金额">{{
formatAmount(taskDetail.failed_amount)
}}</ElDescriptionsItem>
<ElDescriptionsItem label="更新时间">{{
formatDateTime(taskDetail.updated_at)
}}</ElDescriptionsItem>
</ElDescriptions>
<ElAlert
v-if="taskDetail.error_summary"
class="task-error"
type="error"
:closable="false"
:title="taskDetail.error_summary"
/>
<div v-if="hasAuth(BULK_PURCHASE_PERMISSIONS.items)" class="items-toolbar">
<ElSelect v-model="itemStatus" clearable placeholder="筛选行状态" @change="reloadItems">
<ElOption label="成功" value="success" />
<ElOption label="失败" value="failed" />
<ElOption label="处理中" value="processing" />
</ElSelect>
<ElButton @click="reloadItems">刷新结果</ElButton>
</div>
<ElTable
v-if="hasAuth(BULK_PURCHASE_PERMISSIONS.items)"
v-loading="itemsLoading"
:data="items"
border
>
<ElTableColumn label="行号" width="90">
<template #default="scope">{{
scope.row.row_number ?? scope.row.line ?? '-'
}}</template>
</ElTableColumn>
<ElTableColumn label="资产" min-width="180">
<template #default="scope">{{ getAssetIdentifier(scope.row) }}</template>
</ElTableColumn>
<ElTableColumn prop="package_code" label="套餐编码" min-width="150" />
<ElTableColumn label="状态" width="110">
<template #default="scope">{{
scope.row.status_name || scope.row.status || '-'
}}</template>
</ElTableColumn>
<ElTableColumn label="错误原因" min-width="240" show-overflow-tooltip>
<template #default="scope">
{{ scope.row.error_summary || scope.row.error_reason || scope.row.error_code || '-' }}
</template>
</ElTableColumn>
</ElTable>
<div v-if="hasAuth(BULK_PURCHASE_PERMISSIONS.items)" class="pagination-wrapper">
<ElPagination
v-model:current-page="itemsPage"
v-model:page-size="itemsSize"
layout="total, sizes, prev, pager, next"
:total="itemsTotal"
@current-change="loadItems"
@size-change="reloadItems"
/>
</div>
</ElCard>
</div>
</ArtTableFullScreen>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import {
ElAlert,
ElButton,
ElCard,
ElDescriptions,
ElDescriptionsItem,
ElForm,
ElMessage,
ElOption,
ElPagination,
ElRadio,
ElRadioGroup,
ElSelect,
ElTable,
ElTableColumn,
ElTag
} from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import VoucherUpload from '@/components/business/VoucherUpload.vue'
import { BulkPurchaseService, ShopService } from '@/api/modules'
import type {
BulkPurchaseItem,
BulkPurchasePaymentMethod,
BulkPurchaseTask,
ShopResponse
} from '@/types/api'
import { BulkPurchaseTaskStatus } from '@/types/api'
import { useAuth } from '@/composables/useAuth'
import { useAsyncTaskPolling } from '@/composables/useAsyncTaskPolling'
import { formatDateTime, formatMoney } from '@/utils/business/format'
import { BULK_PURCHASE_PERMISSIONS } from '@/config/constants/bulkPurchase'
defineOptions({ name: 'BulkPurchase' })
const route = useRoute()
const router = useRouter()
const { hasAuth } = useAuth()
const formRef = ref<FormInstance>()
const orderUploadRef = ref<InstanceType<typeof VoucherUpload>>()
const voucherUploadRef = ref<InstanceType<typeof VoucherUpload>>()
const shopOptions = ref<ShopResponse[]>([])
const shopLoading = ref(false)
const submitting = ref(false)
const orderFileKeys = ref<string[]>([])
const orderFileUploading = ref(false)
const voucherFileKeys = ref<string[]>([])
const voucherUploading = ref(false)
const itemStatus = ref<string | undefined>()
const items = ref<BulkPurchaseItem[]>([])
const itemsLoading = ref(false)
const itemsPage = ref(1)
const itemsSize = ref(20)
const itemsTotal = ref(0)
const requestId = ref('')
const form = reactive<{
shop_id?: number
payment_method: BulkPurchasePaymentMethod
}>({
shop_id: undefined,
payment_method: 'wallet'
})
const rules = computed<FormRules>(() => ({
shop_id: [{ required: true, message: '请选择代理商', trigger: 'change' }],
payment_method: [{ required: true, message: '请选择支付方式', trigger: 'change' }],
orderFile: [
{
validator: (_rule: unknown, _value: unknown, callback: (error?: Error) => void) => {
if (orderFileKeys.value.length === 0) callback(new Error('请上传订单文件'))
else callback()
},
trigger: 'change'
}
],
voucherFile: [
{
validator: (_rule: unknown, _value: unknown, callback: (error?: Error) => void) => {
if (form.payment_method === 'offline' && voucherFileKeys.value.length === 0) {
callback(new Error('线下支付必须上传整批支付凭证'))
} else callback()
},
trigger: 'change'
}
]
}))
const polling = useAsyncTaskPolling<BulkPurchaseTask>({
storageKey: 'bulk-purchase-active-task',
fetchTask: async (taskId) => {
const res = await BulkPurchaseService.getTask(taskId)
if (res.code !== 0 || !res.data) throw new Error(res.msg || '获取批量订购任务失败')
return res.data
}
})
const taskDetail = polling.task
const getRequestId = () => {
if (requestId.value) return requestId.value
requestId.value =
typeof crypto !== 'undefined' && crypto.randomUUID
? crypto.randomUUID()
: `${Date.now()}-${Math.random().toString(16).slice(2)}`
return requestId.value
}
const searchShops = async (query: string) => {
shopLoading.value = true
try {
const res = await ShopService.getShops({
page: 1,
page_size: 20,
shop_name: query || undefined
})
if (res.code === 0) shopOptions.value = res.data.items || []
} finally {
shopLoading.value = false
}
}
const handlePaymentMethodChange = (value: string | number | boolean | undefined) => {
if (value === 'wallet') {
voucherFileKeys.value = []
voucherUploadRef.value?.clearFiles()
formRef.value?.clearValidate('voucherFile')
}
}
const submitTask = async () => {
if (
!hasAuth(BULK_PURCHASE_PERMISSIONS.create) ||
submitting.value ||
voucherUploading.value ||
orderFileUploading.value
)
return
const valid = await formRef.value?.validate().catch(() => false)
if (!valid || !form.shop_id || orderFileKeys.value.length === 0) return
submitting.value = true
try {
const data = new FormData()
data.append('shop_id', String(form.shop_id))
data.append('payment_method', form.payment_method)
data.append('file', orderFileKeys.value[0])
data.append('request_id', getRequestId())
if (form.payment_method === 'offline' && voucherFileKeys.value.length > 0) {
data.append('voucher_file', voucherFileKeys.value[0])
}
const res = await BulkPurchaseService.createTask(data)
if (res.code !== 0 || !res.data?.task_id) {
ElMessage.error(res.msg || '创建批量订购任务失败')
return
}
ElMessage.success(res.data.message || '批量订购任务已创建')
requestId.value = ''
await router.replace({
path: route.path,
query: { task_id: String(res.data.task_id) }
})
await polling.start(res.data.task_id)
await loadItems()
} catch (error: any) {
ElMessage.error(error?.message || '创建批量订购任务失败')
} finally {
submitting.value = false
}
}
const loadItems = async () => {
const taskId = taskDetail.value?.task_id
if (!taskId || !hasAuth(BULK_PURCHASE_PERMISSIONS.items)) return
itemsLoading.value = true
try {
const res = await BulkPurchaseService.getItems(taskId, {
page: itemsPage.value,
size: itemsSize.value,
status: itemStatus.value
})
if (res.code === 0 && res.data) {
items.value = res.data.items || []
itemsTotal.value = res.data.total || 0
} else {
ElMessage.error(res.msg || '获取批量订购明细失败')
}
} catch (error: any) {
ElMessage.error(error?.message || '获取批量订购明细失败')
} finally {
itemsLoading.value = false
}
}
const reloadItems = () => {
itemsPage.value = 1
void loadItems()
}
const resetTask = () => {
polling.clear()
requestId.value = ''
items.value = []
itemsTotal.value = 0
itemsPage.value = 1
router.replace({ path: route.path })
}
const getStatusName = (status: BulkPurchaseTaskStatus) => {
const names: Record<BulkPurchaseTaskStatus, string> = {
[BulkPurchaseTaskStatus.PENDING]: '待处理',
[BulkPurchaseTaskStatus.PROCESSING]: '处理中',
[BulkPurchaseTaskStatus.COMPLETED]: '已完成',
[BulkPurchaseTaskStatus.FAILED]: '已失败',
[BulkPurchaseTaskStatus.CANCELED]: '已取消'
}
return names[status] || '-'
}
const getStatusType = (status: BulkPurchaseTaskStatus) => {
if (status === BulkPurchaseTaskStatus.COMPLETED) return 'success'
if (status === BulkPurchaseTaskStatus.FAILED) return 'danger'
if (status === BulkPurchaseTaskStatus.PROCESSING) return 'warning'
return 'info'
}
const formatAmount = (amount?: number) => (amount === undefined ? '-' : formatMoney(amount))
const getAssetIdentifier = (item: BulkPurchaseItem) =>
item.asset_identifier || item.iccid || item.virtual_no || '-'
watch(taskDetail, () => {
itemsPage.value = 1
void loadItems()
})
onMounted(() => {
if (hasAuth(BULK_PURCHASE_PERMISSIONS.create)) void searchShops('')
const routeTaskId = Number(route.query.task_id)
if (routeTaskId && routeTaskId !== polling.taskId.value) void polling.start(routeTaskId)
})
</script>
<style scoped lang="scss">
.bulk-purchase-page {
.page-header,
.task-header,
.form-actions,
.items-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.page-header h2 {
margin: 0;
font-size: 20px;
}
.page-header p {
margin: 8px 0 0;
color: var(--el-text-color-secondary);
}
.create-form {
max-width: 760px;
}
.task-card {
margin-top: 16px;
}
.task-error {
margin-top: 16px;
}
.items-toolbar {
justify-content: flex-start;
margin: 20px 0 12px;
}
.pagination-wrapper {
display: flex;
justify-content: flex-end;
margin-top: 16px;
}
}
</style>

View File

@@ -22,6 +22,13 @@
<ElButton @click="showCreateDialog" v-permission="'orders:add'">{{
'创建订单'
}}</ElButton>
<ElButton
v-if="hasAuth(BULK_PURCHASE_PERMISSIONS.create)"
type="primary"
@click="router.push(RoutesAlias.BulkPurchase)"
>
批量订购
</ElButton>
<ElButton v-if="hasAuth('orders:export')" @click="exportDialogVisible = true">{{
'导出'
}}</ElButton>
@@ -278,6 +285,7 @@
import PaymentVoucherDialog from '@/components/business/PaymentVoucherDialog.vue'
import ExportTaskCreateDialog from '@/components/business/ExportTaskCreateDialog.vue'
import VoucherUpload from '@/components/business/VoucherUpload.vue'
import { BULK_PURCHASE_PERMISSIONS } from '@/config/constants/bulkPurchase'
defineOptions({ name: 'OrderList' })