663 lines
20 KiB
Vue
663 lines
20 KiB
Vue
<template>
|
||
<ArtTableFullScreen>
|
||
<div class="bulk-purchase-page" id="table-full-screen">
|
||
<ElDrawer
|
||
v-model="createDrawerVisible"
|
||
title="批量订购套餐"
|
||
direction="rtl"
|
||
size="40%"
|
||
:close-on-click-modal="!createDrawerBusy"
|
||
:close-on-press-escape="!createDrawerBusy"
|
||
@closed="handleCreateDrawerClosed"
|
||
>
|
||
<ElForm ref="formRef" :model="form" :rules="rules" label-width="100px" class="create-form">
|
||
<ElFormItem label="套餐系列" prop="series_id">
|
||
<ElSelect
|
||
v-model="form.series_id"
|
||
filterable
|
||
remote
|
||
clearable
|
||
:remote-method="handleSeriesSearch"
|
||
:loading="seriesLoading"
|
||
placeholder="请输入系列名称搜索"
|
||
style="width: 100%"
|
||
@change="handleSeriesChange"
|
||
@visible-change="handleSeriesVisibleChange"
|
||
>
|
||
<ElOption
|
||
v-for="series in seriesOptions"
|
||
:key="series.id"
|
||
:label="series.series_name"
|
||
:value="series.id"
|
||
/>
|
||
</ElSelect>
|
||
</ElFormItem>
|
||
|
||
<ElFormItem label="套餐" prop="package_id">
|
||
<ElSelect
|
||
v-model="form.package_id"
|
||
filterable
|
||
remote
|
||
clearable
|
||
:remote-method="handlePackageSearch"
|
||
:loading="packageLoading"
|
||
:disabled="!form.series_id"
|
||
placeholder="请先选择套餐系列"
|
||
style="width: 100%"
|
||
@visible-change="handlePackageVisibleChange"
|
||
>
|
||
<ElOption
|
||
v-for="pkg in packageOptions"
|
||
:key="pkg.id"
|
||
:label="pkg.package_name"
|
||
:value="pkg.id"
|
||
>
|
||
<span class="package-option">{{ pkg.package_name }}</span>
|
||
</ElOption>
|
||
<template #empty>
|
||
<div class="package-empty">
|
||
{{
|
||
packageLoading
|
||
? '正在加载套餐...'
|
||
: form.series_id
|
||
? '该套餐系列下暂无可用套餐'
|
||
: '请先选择套餐系列'
|
||
}}
|
||
</div>
|
||
</template>
|
||
</ElSelect>
|
||
<div
|
||
v-if="form.series_id && !packageLoading && packageOptions.length === 0"
|
||
class="package-empty-tip"
|
||
>
|
||
该套餐系列下暂无可用套餐
|
||
</div>
|
||
</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"
|
||
purpose="batch_purchase"
|
||
:max-size-mb="10"
|
||
single-column-csv
|
||
:max-csv-rows="1000"
|
||
accept=".csv"
|
||
tip="仅支持 UTF-8 单列 CSV,最多 1000 行,最大 10MB"
|
||
@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="createDrawerBusy"
|
||
type="info"
|
||
:closable="false"
|
||
show-icon
|
||
:title="
|
||
voucherUploading
|
||
? '支付凭证上传中,请稍候'
|
||
: orderFileUploading
|
||
? '订单文件上传中,请稍候'
|
||
: '批量订购文件上传及任务创建中,请勿重复提交'
|
||
"
|
||
/>
|
||
</ElForm>
|
||
|
||
<template #footer>
|
||
<div class="drawer-footer">
|
||
<ElButton :disabled="createDrawerBusy" @click="createDrawerVisible = false">
|
||
取消
|
||
</ElButton>
|
||
<ElButton
|
||
type="primary"
|
||
:loading="createDrawerBusy"
|
||
:disabled="!hasAuth(BULK_PURCHASE_PERMISSIONS.create)"
|
||
@click="submitTask"
|
||
>
|
||
创建批量订购任务
|
||
</ElButton>
|
||
</div>
|
||
</template>
|
||
</ElDrawer>
|
||
|
||
<ElCard
|
||
v-if="hasAuth(BULK_PURCHASE_PERMISSIONS.detail)"
|
||
shadow="never"
|
||
class="art-table-card task-list-card"
|
||
>
|
||
<template #header>
|
||
<div class="task-list-header">
|
||
<span>批量订购任务</span>
|
||
<div class="task-list-toolbar">
|
||
<ElButton
|
||
v-if="hasAuth(BULK_PURCHASE_PERMISSIONS.template)"
|
||
tag="a"
|
||
href="/templates/bulk-purchase-template.csv"
|
||
download="批量订购套餐模板.csv"
|
||
>
|
||
下载模板
|
||
</ElButton>
|
||
<ElButton
|
||
v-if="hasAuth(BULK_PURCHASE_PERMISSIONS.create)"
|
||
type="primary"
|
||
@click="openCreateDrawer"
|
||
>
|
||
批量订购套餐
|
||
</ElButton>
|
||
<ElSelect
|
||
v-model="taskListStatus"
|
||
clearable
|
||
placeholder="筛选任务状态"
|
||
style="width: 150px"
|
||
@change="handleTaskListStatusChange"
|
||
>
|
||
<ElOption label="待处理" :value="BulkPurchaseTaskStatus.PENDING" />
|
||
<ElOption label="处理中" :value="BulkPurchaseTaskStatus.PROCESSING" />
|
||
<ElOption label="已完成" :value="BulkPurchaseTaskStatus.COMPLETED" />
|
||
<ElOption label="已失败" :value="BulkPurchaseTaskStatus.FAILED" />
|
||
<ElOption label="已取消" :value="BulkPurchaseTaskStatus.CANCELED" />
|
||
</ElSelect>
|
||
<ElButton :loading="taskListLoading" @click="loadTaskList">刷新</ElButton>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<ElTable v-loading="taskListLoading" :data="taskList" border>
|
||
<ElTableColumn label="任务号" min-width="200" show-overflow-tooltip>
|
||
<template #default="scope">
|
||
<ElButton type="primary" link @click="showTask(scope.row)">
|
||
{{ scope.row.task_no || '-' }}
|
||
</ElButton>
|
||
</template>
|
||
</ElTableColumn>
|
||
<ElTableColumn prop="file_name" label="订单文件" min-width="180" show-overflow-tooltip />
|
||
<ElTableColumn
|
||
prop="package_name"
|
||
label="套餐名称"
|
||
min-width="180"
|
||
show-overflow-tooltip
|
||
/>
|
||
<ElTableColumn
|
||
prop="package_code"
|
||
label="套餐编码"
|
||
min-width="180"
|
||
show-overflow-tooltip
|
||
/>
|
||
<ElTableColumn label="支付方式" width="110">
|
||
<template #default="scope">{{
|
||
getPaymentMethodName(scope.row.payment_method)
|
||
}}</template>
|
||
</ElTableColumn>
|
||
<ElTableColumn label="状态" width="110">
|
||
<template #default="scope">
|
||
<ElTag :type="getStatusType(scope.row.status)">
|
||
{{ scope.row.status_name || getStatusName(scope.row.status) }}
|
||
</ElTag>
|
||
</template>
|
||
</ElTableColumn>
|
||
<ElTableColumn prop="total_count" label="总数" width="90" />
|
||
<ElTableColumn prop="success_count" label="成功数" width="90" />
|
||
<ElTableColumn prop="fail_count" label="失败数" width="90" />
|
||
<ElTableColumn label="创建时间" width="180">
|
||
<template #default="scope">{{ formatDateTime(scope.row.created_at) }}</template>
|
||
</ElTableColumn>
|
||
</ElTable>
|
||
|
||
<div class="pagination-wrapper">
|
||
<ElPagination
|
||
v-model:current-page="taskListPage"
|
||
v-model:page-size="taskListSize"
|
||
layout="total, sizes, prev, pager, next"
|
||
:total="taskListTotal"
|
||
@current-change="loadTaskList"
|
||
@size-change="handleTaskListSizeChange"
|
||
/>
|
||
</div>
|
||
</ElCard>
|
||
</div>
|
||
</ArtTableFullScreen>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
|
||
import { useRouter } from 'vue-router'
|
||
import {
|
||
ElAlert,
|
||
ElButton,
|
||
ElCard,
|
||
ElDrawer,
|
||
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, PackageManageService, PackageSeriesService } from '@/api/modules'
|
||
import type {
|
||
BulkPurchasePaymentMethod,
|
||
BulkPurchaseTask,
|
||
PackageResponse,
|
||
PackageSeriesResponse
|
||
} from '@/types/api'
|
||
import { BulkPurchaseTaskStatus } from '@/types/api'
|
||
import { useAuth } from '@/composables/useAuth'
|
||
import { formatDateTime } from '@/utils/business/format'
|
||
import { BULK_PURCHASE_PERMISSIONS } from '@/config/constants/bulkPurchase'
|
||
import { RoutesAlias } from '@/router/routesAlias'
|
||
|
||
defineOptions({ name: 'BulkPurchase' })
|
||
|
||
const router = useRouter()
|
||
const { hasAuth } = useAuth()
|
||
const formRef = ref<FormInstance>()
|
||
const orderUploadRef = ref<InstanceType<typeof VoucherUpload>>()
|
||
const voucherUploadRef = ref<InstanceType<typeof VoucherUpload>>()
|
||
const createDrawerVisible = ref(false)
|
||
const submitting = ref(false)
|
||
const orderFileKeys = ref<string[]>([])
|
||
const orderFileUploading = ref(false)
|
||
const voucherFileKeys = ref<string[]>([])
|
||
const voucherUploading = ref(false)
|
||
const seriesOptions = ref<PackageSeriesResponse[]>([])
|
||
const seriesLoading = ref(false)
|
||
const packageOptions = ref<PackageResponse[]>([])
|
||
const packageLoading = ref(false)
|
||
const taskList = ref<BulkPurchaseTask[]>([])
|
||
const taskListLoading = ref(false)
|
||
const taskListPage = ref(1)
|
||
const taskListSize = ref(20)
|
||
const taskListTotal = ref(0)
|
||
const taskListStatus = ref<BulkPurchaseTaskStatus | undefined>()
|
||
const taskStatusPollInterval = 2000
|
||
let taskStatusPollTimer: number | undefined
|
||
let taskStatusPollSession = 0
|
||
const createDrawerBusy = computed(
|
||
() => submitting.value || voucherUploading.value || orderFileUploading.value
|
||
)
|
||
|
||
const form = reactive<{
|
||
series_id?: number
|
||
package_id?: number
|
||
payment_method: BulkPurchasePaymentMethod
|
||
}>({
|
||
series_id: undefined,
|
||
package_id: undefined,
|
||
payment_method: 'wallet'
|
||
})
|
||
|
||
const rules = computed<FormRules>(() => ({
|
||
series_id: [{ required: true, message: '请选择套餐系列', trigger: 'change' }],
|
||
package_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 loadPackages = async (query = '') => {
|
||
if (!form.series_id) {
|
||
packageOptions.value = []
|
||
return
|
||
}
|
||
|
||
packageLoading.value = true
|
||
try {
|
||
const res = await PackageManageService.getPackages({
|
||
page: 1,
|
||
page_size: 20,
|
||
package_name: query.trim() || undefined,
|
||
series_id: form.series_id,
|
||
status: 1,
|
||
shelf_status: 1
|
||
})
|
||
if (res.code !== 0) {
|
||
ElMessage.error(res.msg || '获取套餐列表失败')
|
||
return
|
||
}
|
||
|
||
packageOptions.value = (res.data?.items || []).filter(
|
||
(item) => item.status === 1 && item.shelf_status === 1
|
||
)
|
||
} catch (error: any) {
|
||
ElMessage.error(error?.message || '获取套餐列表失败')
|
||
} finally {
|
||
packageLoading.value = false
|
||
}
|
||
}
|
||
|
||
const handlePackageSearch = (query: string) => {
|
||
if (form.series_id) void loadPackages(query)
|
||
}
|
||
|
||
const handlePackageVisibleChange = (visible: boolean) => {
|
||
if (visible && form.series_id && packageOptions.value.length === 0) void loadPackages()
|
||
}
|
||
|
||
const openCreateDrawer = () => {
|
||
createDrawerVisible.value = true
|
||
if (seriesOptions.value.length === 0) void loadSeries()
|
||
}
|
||
|
||
const handleCreateDrawerClosed = () => {
|
||
formRef.value?.resetFields()
|
||
form.series_id = undefined
|
||
form.package_id = undefined
|
||
form.payment_method = 'wallet'
|
||
orderFileKeys.value = []
|
||
voucherFileKeys.value = []
|
||
orderUploadRef.value?.clearFiles(false)
|
||
voucherUploadRef.value?.clearFiles(false)
|
||
orderFileUploading.value = false
|
||
voucherUploading.value = false
|
||
}
|
||
|
||
const loadSeries = async (query = '') => {
|
||
seriesLoading.value = true
|
||
try {
|
||
const res = await PackageSeriesService.getPackageSeries({
|
||
page: 1,
|
||
page_size: 20,
|
||
series_name: query.trim() || undefined,
|
||
status: 1
|
||
})
|
||
if (res.code !== 0) {
|
||
ElMessage.error(res.msg || '获取套餐系列失败')
|
||
return
|
||
}
|
||
seriesOptions.value = res.data?.items || []
|
||
} catch (error: any) {
|
||
ElMessage.error(error?.message || '获取套餐系列失败')
|
||
} finally {
|
||
seriesLoading.value = false
|
||
}
|
||
}
|
||
|
||
const handleSeriesSearch = (query: string) => {
|
||
void loadSeries(query)
|
||
}
|
||
|
||
const handleSeriesVisibleChange = (visible: boolean) => {
|
||
if (visible && seriesOptions.value.length === 0) void loadSeries()
|
||
}
|
||
|
||
const handleSeriesChange = () => {
|
||
form.package_id = undefined
|
||
packageOptions.value = []
|
||
if (form.series_id) void loadPackages()
|
||
}
|
||
|
||
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.package_id || orderFileKeys.value.length === 0) return
|
||
|
||
submitting.value = true
|
||
try {
|
||
const data = {
|
||
file_key: orderFileKeys.value[0],
|
||
package_id: form.package_id,
|
||
payment_method: form.payment_method,
|
||
...(form.payment_method === 'offline' ? { voucher_keys: voucherFileKeys.value } : {})
|
||
}
|
||
|
||
const res = await BulkPurchaseService.createTask(data)
|
||
const taskId = res.data?.task_id ?? res.data?.id
|
||
if (res.code !== 0 || !taskId) {
|
||
ElMessage.error(res.msg || '创建批量订购任务失败')
|
||
return
|
||
}
|
||
|
||
createDrawerVisible.value = false
|
||
await loadTaskList()
|
||
startTaskStatusPolling(taskId, res.data?.status ?? BulkPurchaseTaskStatus.PENDING)
|
||
ElMessage.success(res.data.message || '批量订购任务已创建')
|
||
} catch (error: any) {
|
||
ElMessage.error(error?.message || '创建批量订购任务失败')
|
||
} finally {
|
||
submitting.value = false
|
||
}
|
||
}
|
||
|
||
const loadTaskList = async () => {
|
||
if (!hasAuth(BULK_PURCHASE_PERMISSIONS.detail)) return
|
||
taskListLoading.value = true
|
||
try {
|
||
const res = await BulkPurchaseService.getTasks({
|
||
page: taskListPage.value,
|
||
page_size: taskListSize.value,
|
||
status: taskListStatus.value
|
||
})
|
||
if (res.code !== 0) {
|
||
ElMessage.error(res.msg || '获取批量订购任务列表失败')
|
||
return
|
||
}
|
||
|
||
taskList.value = res.data?.items || []
|
||
taskListTotal.value = res.data?.total || 0
|
||
} catch (error: any) {
|
||
ElMessage.error(error?.message || '获取批量订购任务列表失败')
|
||
} finally {
|
||
taskListLoading.value = false
|
||
}
|
||
}
|
||
|
||
const handleTaskListStatusChange = () => {
|
||
taskListPage.value = 1
|
||
void loadTaskList()
|
||
}
|
||
|
||
const handleTaskListSizeChange = () => {
|
||
taskListPage.value = 1
|
||
void loadTaskList()
|
||
}
|
||
|
||
const stopTaskStatusPolling = () => {
|
||
taskStatusPollSession += 1
|
||
if (taskStatusPollTimer !== undefined) {
|
||
window.clearTimeout(taskStatusPollTimer)
|
||
taskStatusPollTimer = undefined
|
||
}
|
||
}
|
||
|
||
const startTaskStatusPolling = (
|
||
taskId: number,
|
||
initialStatus = BulkPurchaseTaskStatus.PENDING
|
||
) => {
|
||
stopTaskStatusPolling()
|
||
const session = taskStatusPollSession
|
||
|
||
const poll = async () => {
|
||
if (session !== taskStatusPollSession) return
|
||
|
||
try {
|
||
const response = await BulkPurchaseService.getTasks({
|
||
page: 1,
|
||
page_size: 100
|
||
})
|
||
if (response.code === 0) {
|
||
const task = response.data.items.find((item) => (item.task_id ?? item.id) === taskId)
|
||
if (task && task.status !== initialStatus) {
|
||
await loadTaskList()
|
||
stopTaskStatusPolling()
|
||
return
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.error('轮询批量订购任务状态失败:', error)
|
||
}
|
||
|
||
if (session === taskStatusPollSession) {
|
||
taskStatusPollTimer = window.setTimeout(() => void poll(), taskStatusPollInterval)
|
||
}
|
||
}
|
||
|
||
void poll()
|
||
}
|
||
|
||
const showTask = (task: BulkPurchaseTask) => {
|
||
const taskId = task.task_id ?? task.id
|
||
if (!taskId) return
|
||
|
||
void router.push(`${RoutesAlias.BulkPurchaseDetail}/${taskId}`)
|
||
}
|
||
|
||
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 getPaymentMethodName = (method?: BulkPurchasePaymentMethod) => {
|
||
return method === 'offline' ? '线下支付' : method === 'wallet' ? '代理钱包' : '-'
|
||
}
|
||
|
||
onMounted(() => {
|
||
void loadTaskList()
|
||
})
|
||
|
||
onBeforeUnmount(() => {
|
||
stopTaskStatusPolling()
|
||
})
|
||
</script>
|
||
|
||
<style scoped lang="scss">
|
||
.bulk-purchase-page {
|
||
.task-list-header {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 12px;
|
||
}
|
||
|
||
.task-list-toolbar {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 10px;
|
||
align-items: center;
|
||
}
|
||
|
||
.package-option {
|
||
display: block;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
}
|
||
|
||
.package-empty {
|
||
padding: 8px 0;
|
||
color: var(--el-color-danger);
|
||
text-align: center;
|
||
}
|
||
|
||
.package-empty-tip {
|
||
margin-top: 4px;
|
||
font-size: 12px;
|
||
line-height: 1.5;
|
||
color: var(--el-color-danger);
|
||
}
|
||
|
||
.create-form {
|
||
padding-bottom: 8px;
|
||
}
|
||
|
||
.drawer-footer {
|
||
display: flex;
|
||
gap: 12px;
|
||
justify-content: flex-end;
|
||
}
|
||
|
||
.pagination-wrapper {
|
||
display: flex;
|
||
justify-content: flex-end;
|
||
margin-top: 16px;
|
||
}
|
||
|
||
@media (max-width: 768px) {
|
||
.task-list-header {
|
||
align-items: flex-start;
|
||
flex-direction: column;
|
||
}
|
||
|
||
.task-list-toolbar {
|
||
width: 100%;
|
||
|
||
.el-select {
|
||
flex: 1;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
</style>
|