feat: 7月迭代
All checks were successful
构建并部署前端到测试环境 / build-and-deploy (push) Successful in 4m21s

This commit is contained in:
luo
2026-07-28 14:00:22 +08:00
parent f0a2b84e53
commit 2706c52a47
63 changed files with 4113 additions and 3945 deletions

View File

@@ -0,0 +1,334 @@
<template>
<ArtTableFullScreen>
<div class="bulk-purchase-detail-page" id="table-full-screen">
<ElCard shadow="never" class="art-table-card">
<div class="detail-header">
<ElButton @click="goBack">
<template #icon>
<ElIcon><ArrowLeft /></ElIcon>
</template>
返回
</ElButton>
<h2 class="detail-title">批量订购任务详情</h2>
<ElTag v-if="taskDetail" :type="getStatusType(taskDetail.status)">
{{ taskDetail.status_name || getStatusName(taskDetail.status) }}
</ElTag>
</div>
<div v-if="loading && !taskDetail" class="detail-loading">
<ElIcon class="is-loading" :size="36"><Loading /></ElIcon>
<div>加载中...</div>
</div>
<template v-else-if="taskDetail">
<ElDescriptions :column="4" border>
<ElDescriptionsItem label="任务号">{{ taskDetail.task_no || '-' }}</ElDescriptionsItem>
<ElDescriptionsItem label="订单文件" :span="2">
<span class="ellipsis-value" :title="taskDetail.file_name || '-'">
{{ taskDetail.file_name || '-' }}
</span>
</ElDescriptionsItem>
<ElDescriptionsItem label="套餐名称">
<span class="ellipsis-value" :title="taskDetail.package_name || '-'">
{{ taskDetail.package_name || '-' }}
</span>
</ElDescriptionsItem>
<ElDescriptionsItem label="套餐编码">
<span class="ellipsis-value" :title="taskDetail.package_code || '-'">
{{ taskDetail.package_code || '-' }}
</span>
</ElDescriptionsItem>
<ElDescriptionsItem label="支付方式">
{{ getPaymentMethodName(taskDetail.payment_method) }}
</ElDescriptionsItem>
<ElDescriptionsItem label="总数">{{ taskDetail.total_count ?? 0 }}</ElDescriptionsItem>
<ElDescriptionsItem label="成功数">{{
taskDetail.success_count ?? 0
}}</ElDescriptionsItem>
<ElDescriptionsItem label="失败数">{{ taskDetail.fail_count ?? 0 }}</ElDescriptionsItem>
<ElDescriptionsItem label="创建人">{{
taskDetail.creator_name || '-'
}}</ElDescriptionsItem>
<ElDescriptionsItem label="支付凭证">
{{
taskDetail.voucher_keys?.length
? `已上传 ${taskDetail.voucher_keys.length}`
: '未上传'
}}
</ElDescriptionsItem>
<ElDescriptionsItem label="创建时间">{{
formatDateTime(taskDetail.created_at)
}}</ElDescriptionsItem>
<ElDescriptionsItem label="开始时间">{{
formatDateTime(taskDetail.started_at)
}}</ElDescriptionsItem>
<ElDescriptionsItem label="完成时间">{{
formatDateTime(taskDetail.completed_at)
}}</ElDescriptionsItem>
<ElDescriptionsItem label="更新时间">{{
formatDateTime(taskDetail.updated_at)
}}</ElDescriptionsItem>
<ElDescriptionsItem v-if="taskDetail.error_message" label="错误信息" :span="4">
{{ taskDetail.error_message }}
</ElDescriptionsItem>
</ElDescriptions>
<ElAlert
v-if="taskDetail.error_summary"
class="task-error"
type="error"
:closable="false"
:title="taskDetail.error_summary"
/>
<template v-if="hasAuth(BULK_PURCHASE_PERMISSIONS.items)">
<div class="items-toolbar">
<ElSelect
v-model="itemStatus"
clearable
placeholder="筛选行状态"
style="width: 140px"
>
<ElOption label="成功" :value="BulkPurchaseTaskStatus.COMPLETED" />
<ElOption label="失败" :value="BulkPurchaseTaskStatus.FAILED" />
</ElSelect>
<ElButton :loading="loading" @click="refreshTask">刷新结果</ElButton>
</div>
<ElTable v-loading="loading" :data="paginatedItems" border>
<ElTableColumn label="行号" width="90">
<template #default="scope">{{ scope.row.line ?? '-' }}</template>
</ElTableColumn>
<ElTableColumn label="资产" min-width="180">
<template #default="scope">{{ getAssetIdentifier(scope.row) }}</template>
</ElTableColumn>
<ElTableColumn label="状态" width="110">
<template #default="scope">
<ElTag :type="getItemStatusType(scope.row.status)">
{{ scope.row.status_name || getItemStatusName(scope.row.status) }}
</ElTag>
</template>
</ElTableColumn>
<ElTableColumn prop="order_no" label="订单号" min-width="180" show-overflow-tooltip />
<ElTableColumn label="金额" width="120">
<template #default="scope">{{ formatMoney(scope.row.amount) }}</template>
</ElTableColumn>
<ElTableColumn label="处理结果" min-width="240" show-overflow-tooltip>
<template #default="scope">{{ getItemReason(scope.row) }}</template>
</ElTableColumn>
</ElTable>
<div class="pagination-wrapper">
<ElPagination
v-model:current-page="itemsPage"
v-model:page-size="itemsSize"
layout="total, sizes, prev, pager, next"
:total="filteredItems.length"
@size-change="handleItemSizeChange"
/>
</div>
</template>
</template>
<ElEmpty v-else :description="forbidden ? '暂无查看权限' : '暂无任务详情'" />
</ElCard>
</div>
</ArtTableFullScreen>
</template>
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import {
ElAlert,
ElButton,
ElCard,
ElDescriptions,
ElDescriptionsItem,
ElEmpty,
ElIcon,
ElMessage,
ElOption,
ElPagination,
ElSelect,
ElTable,
ElTableColumn,
ElTag
} from 'element-plus'
import { ArrowLeft, Loading } from '@element-plus/icons-vue'
import { BulkPurchaseService } from '@/api/modules'
import type { BulkPurchaseItem, BulkPurchasePaymentMethod, BulkPurchaseTask } 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: 'BulkPurchaseDetail' })
const route = useRoute()
const router = useRouter()
const { hasAuth } = useAuth()
const itemStatus = ref<BulkPurchaseTaskStatus | undefined>()
const itemsPage = ref(1)
const itemsSize = ref(20)
const taskId = Number(route.params.id)
const polling = useAsyncTaskPolling<BulkPurchaseTask>({
storageKey: `bulk-purchase-detail:${taskId}`,
autoRestore: false,
fetchTask: async (id) => {
const res = await BulkPurchaseService.getTask(id)
if (res.code === 403) {
const error = new Error('暂无查看该批量订购任务详情的权限') as Error & { status?: number }
error.status = 403
throw error
}
if (res.code !== 0 || !res.data) {
throw new Error(res.msg || '获取批量订购任务详情失败')
}
return res.data
},
isForbidden: (error: any) => error?.status === 403 || error?.response?.status === 403
})
const taskDetail = polling.task
const loading = polling.loading
const forbidden = polling.forbidden
const loadTaskDetail = async () => {
if (!taskId) {
ElMessage.error('缺少任务 ID 参数')
goBack()
return
}
await polling.start(taskId)
if (polling.error.value) ElMessage.error(polling.error.value)
if (polling.forbidden.value) ElMessage.warning('暂无查看该批量订购任务详情的权限')
}
const goBack = () => {
router.back()
}
const refreshTask = () => {
void polling.retry()
}
const getStatusName = (status?: BulkPurchaseTaskStatus) => {
const names: Record<BulkPurchaseTaskStatus, string> = {
[BulkPurchaseTaskStatus.PENDING]: '待处理',
[BulkPurchaseTaskStatus.PROCESSING]: '处理中',
[BulkPurchaseTaskStatus.COMPLETED]: '已完成',
[BulkPurchaseTaskStatus.FAILED]: '已失败',
[BulkPurchaseTaskStatus.CANCELED]: '已取消'
}
return status ? 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' ? '代理钱包' : '-'
}
const getAssetIdentifier = (item: BulkPurchaseItem) =>
item.asset_identifier || item.iccid || item.virtual_no || '-'
const getItemStatusName = (status?: BulkPurchaseTaskStatus) => {
if (status === BulkPurchaseTaskStatus.COMPLETED) return '成功'
if (status === BulkPurchaseTaskStatus.FAILED) return '失败'
return '-'
}
const getItemStatusType = (status?: BulkPurchaseTaskStatus) => {
if (status === BulkPurchaseTaskStatus.COMPLETED) return 'success'
if (status === BulkPurchaseTaskStatus.FAILED) return 'danger'
return 'info'
}
const getItemReason = (item: BulkPurchaseItem) =>
item.reason || item.error_summary || item.error_reason || '-'
const filteredItems = computed(() => {
const items = taskDetail.value?.items || []
if (itemStatus.value === undefined) return items
return items.filter((item) => item.status === itemStatus.value)
})
const paginatedItems = computed(() => {
const start = (itemsPage.value - 1) * itemsSize.value
return filteredItems.value.slice(start, start + itemsSize.value)
})
const handleItemSizeChange = () => {
itemsPage.value = 1
}
watch(itemStatus, () => {
itemsPage.value = 1
})
onMounted(() => {
void loadTaskDetail()
})
</script>
<style scoped lang="scss">
.bulk-purchase-detail-page {
.detail-header {
display: flex;
gap: 16px;
align-items: center;
padding-bottom: 16px;
.detail-title {
margin: 0;
font-size: 20px;
font-weight: 600;
color: var(--el-text-color-primary);
}
}
.detail-loading {
display: flex;
flex-direction: column;
gap: 12px;
align-items: center;
justify-content: center;
min-height: 220px;
color: var(--el-text-color-secondary);
}
.ellipsis-value {
display: inline-block;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
vertical-align: bottom;
white-space: nowrap;
}
.task-error {
margin-top: 16px;
}
.items-toolbar {
display: flex;
gap: 12px;
align-items: center;
margin: 20px 0 12px;
}
.pagination-wrapper {
display: flex;
justify-content: flex-end;
margin-top: 16px;
}
}
</style>

View File

@@ -0,0 +1,506 @@
<template>
<ArtTableFullScreen>
<div class="bulk-purchase-page" id="table-full-screen">
<ElDrawer
v-model="createDrawerVisible"
title="批量订购套餐"
direction="rtl"
size="520px"
:close-on-click-modal="!createDrawerBusy"
:close-on-press-escape="!createDrawerBusy"
@closed="handleCreateDrawerClosed"
>
<ElForm ref="formRef" :model="form" :rules="rules" label-width="110px" class="create-form">
<ElFormItem label="套餐" prop="package_id">
<ElSelect
v-model="form.package_id"
filterable
remote
clearable
:remote-method="handlePackageSearch"
:loading="packageLoading"
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>
</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"
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, 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 } from '@/api/modules'
import type { BulkPurchasePaymentMethod, BulkPurchaseTask, PackageResponse } 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 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 createDrawerBusy = computed(
() => submitting.value || voucherUploading.value || orderFileUploading.value
)
const form = reactive<{
package_id?: number
payment_method: BulkPurchasePaymentMethod
}>({
package_id: undefined,
payment_method: 'wallet'
})
const rules = computed<FormRules>(() => ({
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 = '') => {
packageLoading.value = true
try {
const res = await PackageManageService.getPackages({
page: 1,
page_size: 20,
package_name: query.trim() || undefined,
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) => {
void loadPackages(query)
}
const handlePackageVisibleChange = (visible: boolean) => {
if (visible && packageOptions.value.length === 0) void loadPackages()
}
const openCreateDrawer = () => {
createDrawerVisible.value = true
if (packageOptions.value.length === 0) void loadPackages()
}
const handleCreateDrawerClosed = () => {
formRef.value?.resetFields()
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 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
}
ElMessage.success(res.data.message || '批量订购任务已创建')
createDrawerVisible.value = false
await router.push(`${RoutesAlias.BulkPurchaseDetail}/${taskId}`)
} 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 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()
})
</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;
}
.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>

View File

@@ -0,0 +1,506 @@
<template>
<ArtTableFullScreen>
<div class="device-batch-allocation-page" id="table-full-screen">
<ElDrawer
v-model="createDrawerVisible"
title="设备批量分配"
direction="rtl"
size="520px"
:close-on-click-modal="!createDrawerBusy"
:close-on-press-escape="!createDrawerBusy"
@closed="handleCreateDrawerClosed"
>
<ElForm ref="formRef" :model="form" label-width="110px" class="create-form">
<ElFormItem label="操作类型" prop="operation_type">
<ElSelect
v-model="form.operation_type"
style="width: 100%"
@change="handleOperationTypeChange"
>
<ElOption label="分配目标代理" value="assign_shop" />
<ElOption label="设置套餐系列" value="assign_series" />
<ElOption label="批量回收设备" value="recall" />
</ElSelect>
</ElFormItem>
<ElFormItem v-if="requiresTargetId" label="目标 ID" prop="target_id">
<ElSelect
v-model="form.target_id"
filterable
remote
clearable
:remote-method="handleTargetSearch"
:loading="targetLoading"
:placeholder="targetPlaceholder"
style="width: 100%"
@visible-change="handleTargetVisibleChange"
>
<template v-if="form.operation_type === 'assign_shop'">
<ElOption
v-for="shop in shopOptions"
:key="shop.id"
:label="shop.shop_name"
:value="shop.id"
/>
</template>
<template v-else>
<ElOption
v-for="series in seriesOptions"
:key="series.id"
:label="series.series_name"
:value="series.id"
/>
</template>
</ElSelect>
</ElFormItem>
<ElFormItem v-else label="回收目标">
<ElTag type="warning">回收到平台库存</ElTag>
</ElFormItem>
<ElFormItem label="设备文件" prop="file_key">
<VoucherUpload
ref="uploadRef"
v-model="fileKeys"
voucher-name="设备标识文件"
:max-count="1"
purpose="device_batch_allocation"
:max-size-mb="10"
single-column-csv
:max-csv-rows="1000"
accept=".csv"
tip="仅支持 UTF-8 单列 CSV最多 1000 行,最大 10MB"
@uploading-change="fileUploading = $event"
/>
</ElFormItem>
<ElAlert
v-if="createDrawerBusy"
type="info"
:closable="false"
show-icon
:title="fileUploading ? '设备文件上传中,请稍候' : '任务创建中,请勿重复提交'"
/>
</ElForm>
<template #footer>
<div class="drawer-footer">
<ElButton :disabled="createDrawerBusy" @click="createDrawerVisible = false">
取消
</ElButton>
<ElButton
type="primary"
:loading="submitting"
:disabled="!hasAuth(JULY_PERMISSIONS.deviceAllocation.create)"
@click="submitTask"
>
创建分配任务
</ElButton>
</div>
</template>
</ElDrawer>
<ElCard 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(JULY_PERMISSIONS.deviceAllocation.create)"
tag="a"
href="/templates/device-batch-allocation-template.csv"
download="设备批量分配模板.csv"
>
下载模板
</ElButton>
<ElButton
v-if="hasAuth(JULY_PERMISSIONS.deviceAllocation.create)"
type="primary"
@click="openCreateDrawer"
>
设备批量分配
</ElButton>
<ElSelect
v-model="taskListOperationType"
clearable
placeholder="筛选操作类型"
style="width: 170px"
@change="handleTaskListFilterChange"
>
<ElOption label="分配目标代理" value="assign_shop" />
<ElOption label="设置套餐系列" value="assign_series" />
<ElOption label="批量回收设备" value="recall" />
</ElSelect>
<ElSelect
v-model="taskListStatus"
clearable
placeholder="筛选任务状态"
style="width: 150px"
@change="handleTaskListFilterChange"
>
<ElOption label="待处理" :value="DeviceImportTaskStatus.PENDING" />
<ElOption label="处理中" :value="DeviceImportTaskStatus.PROCESSING" />
<ElOption label="已完成" :value="DeviceImportTaskStatus.COMPLETED" />
<ElOption label="失败" :value="DeviceImportTaskStatus.FAILED" />
</ElSelect>
<ElButton :loading="taskListLoading" @click="loadTaskList">刷新</ElButton>
</div>
</div>
</template>
<ElTable v-loading="taskListLoading" :data="taskList" border>
<ElTableColumn label="任务号" min-width="210" show-overflow-tooltip>
<template #default="scope">
<ElButton
v-if="hasAuth(JULY_PERMISSIONS.deviceAllocation.detail)"
type="primary"
link
@click="showTask(scope.row)"
>
{{ scope.row.task_no || '-' }}
</ElButton>
<span v-else>{{ scope.row.task_no || '-' }}</span>
</template>
</ElTableColumn>
<ElTableColumn label="操作类型" width="150">
<template #default="scope">
<ElTag :type="getOperationTagType(scope.row.operation_type)">
{{ getOperationLabel(scope.row.operation_type) }}
</ElTag>
</template>
</ElTableColumn>
<ElTableColumn
prop="operation_name"
label="操作名称"
min-width="170"
show-overflow-tooltip
>
<template #default="scope">
{{ scope.row.operation_name || getOperationLabel(scope.row.operation_type) }}
</template>
</ElTableColumn>
<ElTableColumn label="目标 ID" width="110">
<template #default="scope">{{ scope.row.target_id ?? '-' }}</template>
</ElTableColumn>
<ElTableColumn label="状态" width="110">
<template #default="scope">
<ElTag :type="getStatusTagType(scope.row.status)">
{{ scope.row.status_name || scope.row.status_text || '-' }}
</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 prop="skip_count" label="跳过数" width="90" />
<ElTableColumn label="创建时间" width="180">
<template #default="scope">{{ formatDateTime(scope.row.created_at) }}</template>
</ElTableColumn>
<ElTableColumn label="完成时间" width="180">
<template #default="scope">
{{ scope.row.completed_at ? formatDateTime(scope.row.completed_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, onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import {
ElAlert,
ElButton,
ElCard,
ElDrawer,
ElForm,
ElMessage,
ElOption,
ElPagination,
ElSelect,
ElTable,
ElTableColumn,
ElTag
} from 'element-plus'
import type { FormInstance } from 'element-plus'
import VoucherUpload from '@/components/business/VoucherUpload.vue'
import { DeviceService, PackageSeriesService, ShopService } from '@/api/modules'
import { DeviceImportTaskStatus } from '@/types/api/device'
import type {
DeviceBatchAllocationRequest,
DeviceImportTask,
DeviceImportTaskOperationType
} from '@/types/api/device'
import type { PackageSeriesResponse, ShopResponse } from '@/types/api'
import { useAuth } from '@/composables/useAuth'
import { formatDateTime } from '@/utils/business/format'
import { JULY_PERMISSIONS } from '@/config/constants'
import { RoutesAlias } from '@/router/routesAlias'
import { normalizeApiError } from '@/utils/business/apiError'
defineOptions({ name: 'DeviceBatchAllocation' })
const router = useRouter()
const { hasAuth } = useAuth()
const formRef = ref<FormInstance>()
const uploadRef = ref<InstanceType<typeof VoucherUpload>>()
const createDrawerVisible = ref(false)
const submitting = ref(false)
const fileUploading = ref(false)
const fileKeys = ref<string[]>([])
const taskList = ref<DeviceImportTask[]>([])
const taskListLoading = ref(false)
const taskListPage = ref(1)
const taskListSize = ref(20)
const taskListTotal = ref(0)
const taskListStatus = ref<DeviceImportTaskStatus | undefined>()
const taskListOperationType = ref<Exclude<DeviceImportTaskOperationType, 'import'> | undefined>()
const shopOptions = ref<ShopResponse[]>([])
const seriesOptions = ref<PackageSeriesResponse[]>([])
const targetLoading = ref(false)
const form = reactive<{
operation_type: Exclude<DeviceImportTaskOperationType, 'import'>
target_id?: number
}>({
operation_type: 'assign_shop',
target_id: undefined
})
const requiresTargetId = computed(() =>
['assign_shop', 'assign_series'].includes(form.operation_type)
)
const targetPlaceholder = computed(() =>
form.operation_type === 'assign_shop' ? '请选择启用的目标店铺' : '请选择启用的套餐系列'
)
const createDrawerBusy = computed(() => submitting.value || fileUploading.value)
const getOperationLabel = (operationType: DeviceImportTaskOperationType) => {
switch (operationType) {
case 'assign_shop':
return '分配目标代理'
case 'assign_series':
return '设置套餐系列'
case 'recall':
return '批量回收设备'
default:
return '设备导入'
}
}
const getOperationTagType = (operationType: DeviceImportTaskOperationType) =>
operationType === 'recall' ? 'warning' : 'primary'
const getStatusTagType = (status: DeviceImportTaskStatus) => {
switch (status) {
case DeviceImportTaskStatus.PENDING:
return 'info'
case DeviceImportTaskStatus.PROCESSING:
return 'warning'
case DeviceImportTaskStatus.COMPLETED:
return 'success'
case DeviceImportTaskStatus.FAILED:
return 'danger'
default:
return 'info'
}
}
const openCreateDrawer = () => {
if (!hasAuth(JULY_PERMISSIONS.deviceAllocation.create)) return
createDrawerVisible.value = true
}
const loadTargetOptions = async (query = '') => {
if (form.operation_type === 'recall') return
targetLoading.value = true
try {
if (form.operation_type === 'assign_shop') {
const res = await ShopService.getShops({
page: 1,
page_size: 20,
status: 1,
shop_name: query || undefined
})
if (res.code === 0) shopOptions.value = res.data.items || []
} else {
const res = await PackageSeriesService.getPackageSeries({
page: 1,
page_size: 20,
status: 1,
series_name: query || undefined
})
if (res.code === 0) seriesOptions.value = res.data.items || []
}
} catch (error) {
console.error('获取批量分配目标失败:', error)
ElMessage.error(normalizeApiError(error).message)
} finally {
targetLoading.value = false
}
}
const handleTargetSearch = (query: string) => {
void loadTargetOptions(query)
}
const handleTargetVisibleChange = (visible: boolean) => {
if (
visible &&
(form.operation_type === 'assign_shop'
? !shopOptions.value.length
: !seriesOptions.value.length)
) {
void loadTargetOptions()
}
}
const handleOperationTypeChange = () => {
form.target_id = undefined
shopOptions.value = []
seriesOptions.value = []
if (form.operation_type !== 'recall') void loadTargetOptions()
}
const handleCreateDrawerClosed = () => {
formRef.value?.resetFields()
form.operation_type = 'assign_shop'
form.target_id = undefined
shopOptions.value = []
seriesOptions.value = []
fileKeys.value = []
fileUploading.value = false
uploadRef.value?.clearFiles()
}
const handleTaskListFilterChange = () => {
taskListPage.value = 1
void loadTaskList()
}
const loadTaskList = async () => {
taskListLoading.value = true
try {
const res = await DeviceService.getImportTasks({
page: taskListPage.value,
page_size: taskListSize.value,
status: taskListStatus.value,
operation_type: taskListOperationType.value
})
if (res.code !== 0) {
ElMessage.error(res.msg || '获取设备批量分配任务失败')
return
}
taskList.value = (res.data.items || []).filter((item) => item.operation_type !== 'import')
taskListTotal.value = res.data.total || 0
} catch (error) {
console.error('获取设备批量分配任务失败:', error)
ElMessage.error(normalizeApiError(error).message)
} finally {
taskListLoading.value = false
}
}
const handleTaskListSizeChange = () => {
taskListPage.value = 1
void loadTaskList()
}
const showTask = (row: DeviceImportTask) => {
router.push({
path: RoutesAlias.TaskDetail,
query: { id: row.id, task_type: 'device' }
})
}
const submitTask = async () => {
if (createDrawerBusy.value) return
if (!fileKeys.value[0]) {
ElMessage.warning('请先上传设备标识文件')
return
}
if (requiresTargetId.value && !form.target_id) {
ElMessage.warning('请选择目标店铺或套餐系列')
return
}
const request: DeviceBatchAllocationRequest = {
file_key: fileKeys.value[0],
operation_type: form.operation_type
}
if (requiresTargetId.value) request.target_id = form.target_id
submitting.value = true
try {
const res = await DeviceService.createAllocationTask(request)
if (res.code !== 0) {
ElMessage.error(res.msg || '创建设备批量分配任务失败')
return
}
createDrawerVisible.value = false
await loadTaskList()
ElMessage.success(`任务已创建:${res.data.task_no}`)
} catch (error) {
console.error('创建设备批量分配任务失败:', error)
ElMessage.error(normalizeApiError(error).message)
} finally {
submitting.value = false
}
}
onMounted(() => {
void loadTaskList()
})
</script>
<style lang="scss" scoped>
.device-batch-allocation-page {
.task-list-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.task-list-toolbar {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 10px;
}
.pagination-wrapper {
display: flex;
justify-content: flex-end;
padding-top: 16px;
}
.drawer-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
}
@media (max-width: 900px) {
.task-list-header {
align-items: flex-start;
flex-direction: column;
}
.task-list-toolbar {
justify-content: flex-start;
}
}
}
</style>

View File

@@ -1,14 +1,13 @@
<template>
<ArtTableFullScreen>
<div v-if="isPlatformAccount" class="device-task-page" id="table-full-screen">
<!-- 搜索栏 -->
<ArtSearchBar
v-model:filter="searchForm"
:items="searchFormItems"
:show-expand="false"
@reset="handleReset"
@search="handleSearch"
></ArtSearchBar>
/>
<div v-if="pollingError || pollingForbidden" class="task-polling-error">
<ElAlert
@@ -21,26 +20,18 @@
</div>
<ElCard shadow="never" class="art-table-card">
<!-- 表格头部 -->
<ArtTableHeader
:columnList="columnOptions"
v-model:columns="columnChecks"
@refresh="handleRefresh"
>
<template #left>
<ElButton
v-if="isPlatformAccount && hasAuth(JULY_PERMISSIONS.deviceAllocation.page)"
type="primary"
:icon="Upload"
@click="importDialogVisible = true"
v-permission="JULY_PERMISSIONS.deviceAllocation.page"
>
<ElButton type="primary" :icon="Upload" @click="importDialogVisible = true">
批量导入设备
</ElButton>
</template>
</ArtTableHeader>
<!-- 表格 -->
<ArtTable
ref="tableRef"
row-key="id"
@@ -62,21 +53,20 @@
</ElCard>
</div>
<!-- 导入对话框 -->
<ElDialog v-model="importDialogVisible" title="批量导入设备" width="40%" align-center>
<ElAlert type="info" :closable="false" style="margin-bottom: 20px">
<template #title>
<div style="line-height: 1.8">
<p><strong>导入说明</strong></p>
<p>1. 设备分配使用单列 UTF-8 CSV导入设备仍使用 Excel 模板</p>
<p>2. CSV 单次最多 1000 文件不超过 10MB</p>
<p>3. 列格式请设置为文本格式避免长数字被转为科学计数法</p>
<p>4. <strong>重要列顺序固定不可调整</strong>系统按位置读取不识别列名</p>
<p style="color: var(--el-color-primary)">5. 必填列虚拟号1列</p>
<p>1. 设备导入使用 Excel 模板文件大小不能超过 300MB</p>
<p>2. 列格式请设置为文本格式避免长数字被转为科学计数法</p>
<p>3. <strong>列顺序固定不可调整</strong>系统按位置读取不识别列名</p>
<p style="color: var(--el-color-primary)">
4. 必填列设备标识 1 支持 VirtualNoIMEI SN
</p>
<p>
6.
可选列SN设备名称设备型号设备类型IMEI制造商最大SIM槽数默认4有效范围1-4卡1~卡4
ICCID
5. 可选列SN设备名称设备型号设备类型IMEI制造商最大 SIM 槽数 1~ 4
ICCID
</p>
</div>
</template>
@@ -88,23 +78,7 @@
</ElButton>
</div>
<ElRow :gutter="20">
<ElCol :span="12">
<ElFormItem label="任务类型">
<ElSelect v-model="importForm.operation_type" style="width: 100%">
<ElOption label="导入设备" value="import" />
<ElOption label="分配目标代理" value="assign_shop" />
<ElOption label="设置套餐系列" value="assign_series" />
</ElSelect>
</ElFormItem>
</ElCol>
<ElCol v-if="importForm.operation_type !== 'import'" :span="12">
<ElFormItem label="目标 ID">
<ElInput v-model="importForm.target_id" placeholder="代理店铺或套餐系列 ID" />
</ElFormItem>
</ElCol>
</ElRow>
<ElRow :gutter="20">
<ElRow :gutter="20">
<ElCol :span="12">
<ElFormItem label="批次号" prop="batch_no">
<ElInput
@@ -135,18 +109,18 @@
</ElCol>
</ElRow>
<ElUpload
<ElUpload
ref="uploadRef"
drag
:auto-upload="false"
:on-change="handleFileChange"
:limit="1"
accept=".xlsx,.csv"
>
accept=".xlsx"
>
<el-icon class="el-icon--upload"><upload-filled /></el-icon>
<div class="el-upload__text">将文件拖到此处<em>点击选择</em></div>
<div class="el-upload__text">将文件拖到此处<em>点击选择</em></div>
<template #tip>
<div class="el-upload__tip">导入设备支持 .xlsx批量分配支持单列 .csv</div>
<div class="el-upload__tip">设备导入仅支持 .xlsx 文件</div>
</template>
</ElUpload>
@@ -166,12 +140,11 @@
</template>
<script setup lang="ts">
import { h } from 'vue'
import { h, onMounted, reactive, ref, watch, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ref, reactive, onMounted, computed, watch } from 'vue'
import { DeviceService } from '@/api/modules'
import { ElMessage, ElTag } from 'element-plus'
import { Download, UploadFilled, Upload } from '@element-plus/icons-vue'
import { Download, Upload, UploadFilled } from '@element-plus/icons-vue'
import type { UploadInstance } from 'element-plus'
import type { SearchFormItem } from '@/types'
import { useCheckedColumns } from '@/composables/useCheckedColumns'
@@ -189,7 +162,6 @@
} from '@/types/api/device'
import { RoutesAlias } from '@/router/routesAlias'
import { generatePackageCode } from '@/utils/codeGenerator'
import { JULY_PERMISSIONS } from '@/config/constants'
import { normalizeApiError } from '@/utils/business/apiError'
defineOptions({ name: 'DeviceTask' })
@@ -208,40 +180,23 @@
const importDialogVisible = ref(false)
const importForm = reactive({
batch_no: '',
realname_policy: '' as '' | 'none' | 'before_order' | 'after_order',
operation_type: 'import' as 'import' | 'assign_shop' | 'assign_series',
target_id: ''
realname_policy: '' as '' | RealnamePolicy
})
// 搜索表单初始值
const initialSearchState = {
status: undefined,
status: undefined as DeviceImportTaskStatus | undefined,
batch_no: '',
dateRange: undefined as string[] | undefined,
start_time: '',
end_time: ''
dateRange: undefined as string[] | undefined
}
// 搜索表单
const searchForm = reactive({ ...initialSearchState })
const pagination = reactive({ page: 1, pageSize: 20, total: 0 })
// 分页
const pagination = reactive({
page: 1,
pageSize: 20,
total: 0
})
// 搜索表单配置
const searchFormItems: SearchFormItem[] = [
{
label: '任务状态',
prop: 'status',
type: 'select',
config: {
clearable: true,
placeholder: '全部'
},
config: { clearable: true, placeholder: '全部' },
options: () => [
{ label: '待处理', value: 1 },
{ label: '处理中', value: 2 },
@@ -253,10 +208,7 @@
label: '批次号',
prop: 'batch_no',
type: 'input',
config: {
clearable: true,
placeholder: '请输入批次号'
}
config: { clearable: true, placeholder: '请输入批次号' }
},
{
label: '创建时间',
@@ -271,7 +223,6 @@
}
]
// 列配置
const columnOptions = [
{ label: '任务编号', prop: 'task_no' },
{ label: '任务状态', prop: 'status' },
@@ -288,7 +239,6 @@
]
const taskList = ref<DeviceImportTask[]>([])
const polling = useAsyncTaskPolling<DeviceImportTaskDetail>({
storageKey: 'device-import-active-task',
autoRestore: false,
@@ -306,7 +256,6 @@
const pollingError = polling.error
const pollingForbidden = polling.forbidden
// 获取状态标签类型
const getStatusType = (status: DeviceImportTaskStatus) => {
switch (status) {
case 1:
@@ -322,19 +271,14 @@
}
}
// 查看详情
const viewDetail = (row: DeviceImportTask) => {
if (!isPlatformAccount.value) return
router.push({
path: RoutesAlias.TaskDetail,
query: {
id: row.id,
task_type: 'device'
}
query: { id: row.id, task_type: 'device' }
})
}
// 处理名称点击
const handleNameClick = (row: DeviceImportTask) => {
if (isPlatformAccount.value && hasAuth('device_task:view_detail')) {
viewDetail(row)
@@ -343,15 +287,14 @@
}
}
// 动态列配置
const { columnChecks, columns } = useCheckedColumns(() => [
{
prop: 'task_no',
label: '任务编号',
width: 220,
showOverflowTooltip: true,
formatter: (row: DeviceImportTask) => {
return h(
formatter: (row: DeviceImportTask) =>
h(
'span',
{
style: 'color: var(--el-color-primary); cursor: pointer; text-decoration: underline;',
@@ -362,48 +305,31 @@
},
row.task_no
)
}
},
{
prop: 'status',
label: '任务状态',
width: 100,
formatter: (row: DeviceImportTask) => {
return h(ElTag, { type: getStatusType(row.status) }, () => row.status_text)
}
},
{
prop: 'total_count',
label: '总数',
width: 80
formatter: (row: DeviceImportTask) =>
h(ElTag, { type: getStatusType(row.status) }, () => row.status_name || row.status_text)
},
{ prop: 'total_count', label: '总数', width: 80 },
{
prop: 'success_count',
label: '成功数',
width: 80,
formatter: (row: DeviceImportTask) => {
return h('span', { style: { color: 'var(--el-color-success)' } }, row.success_count)
}
formatter: (row: DeviceImportTask) =>
h('span', { style: { color: 'var(--el-color-success)' } }, row.success_count)
},
{
prop: 'fail_count',
label: '失败数',
width: 80,
formatter: (row: DeviceImportTask) => {
return h('span', { style: { color: 'var(--el-color-danger)' } }, row.fail_count)
}
},
{
prop: 'skip_count',
label: '跳过数',
width: 80
},
{
prop: 'batch_no',
label: '批次号',
width: 180,
showOverflowTooltip: true
formatter: (row: DeviceImportTask) =>
h('span', { style: { color: 'var(--el-color-danger)' } }, row.fail_count)
},
{ prop: 'skip_count', label: '跳过数', width: 80 },
{ prop: 'batch_no', label: '批次号', width: 180, showOverflowTooltip: true },
{
prop: 'started_at',
label: '开始时间',
@@ -424,12 +350,7 @@
showOverflowTooltip: true,
formatter: (row: DeviceImportTask) => row.error_message || '-'
},
{
prop: 'file_name',
label: '文件名',
minWidth: 180,
showOverflowTooltip: true
},
{ prop: 'file_name', label: '文件名', minWidth: 180, showOverflowTooltip: true },
{
prop: 'creator_name',
label: '操作人',
@@ -460,7 +381,6 @@
}
})
// 获取设备任务列表
const getTableData = async () => {
if (!isPlatformAccount.value) {
taskList.value = []
@@ -473,10 +393,9 @@
page: pagination.page,
page_size: pagination.pageSize,
status: searchForm.status,
operation_type: 'import',
batch_no: searchForm.batch_no || undefined
}
// 处理时间范围
if (searchForm.dateRange && Array.isArray(searchForm.dateRange)) {
params.start_time = searchForm.dateRange[0]
params.end_time = searchForm.dateRange[1]
@@ -484,9 +403,7 @@
const res = await DeviceService.getImportTasks(params)
if (res.code === 0) {
const taskItems = (res.data as typeof res.data & { items?: DeviceImportTask[] | null })
.items
taskList.value = taskItems || []
taskList.value = res.data.items || []
pagination.total = res.data.total || 0
}
} catch (error) {
@@ -497,37 +414,30 @@
}
}
// 重置搜索
const handleReset = () => {
Object.assign(searchForm, { ...initialSearchState })
pagination.page = 1
getTableData()
void getTableData()
}
// 搜索
const handleSearch = () => {
pagination.page = 1
getTableData()
void getTableData()
}
// 刷新表格
const handleRefresh = () => {
getTableData()
}
const handleRefresh = () => void getTableData()
// 处理表格分页变化
const handleSizeChange = (newPageSize: number) => {
pagination.pageSize = newPageSize
getTableData()
void getTableData()
}
const handleCurrentChange = (newCurrentPage: number) => {
pagination.page = newCurrentPage
getTableData()
void getTableData()
}
// 下载模板
const downloadTemplate = async () => {
const downloadTemplate = () => {
try {
const link = document.createElement('a')
link.href = new URL('@/template/设备导入模板.xlsx', import.meta.url).href
@@ -542,107 +452,75 @@
}
}
// 文件选择变化
const handleFileChange = async (uploadFile: any) => {
const isCsvAllocation = importForm.operation_type !== 'import'
const maxSize = (isCsvAllocation ? 10 : 300) * 1024 * 1024
if (uploadFile.raw && uploadFile.raw.size > maxSize) {
const handleFileChange = (uploadFile: any) => {
const file = uploadFile.raw as File | undefined
if (!file) return
if (file.size > 300 * 1024 * 1024) {
ElMessage.error('文件大小不能超过 300MB')
uploadRef.value?.clearFiles()
fileList.value = []
return
}
if (uploadFile.raw && (isCsvAllocation ? !uploadFile.raw.name.endsWith('.csv') : !uploadFile.raw.name.endsWith('.xlsx'))) {
ElMessage.error(isCsvAllocation ? '批量分配只能上传 .csv 文件' : '设备导入只能上传 .xlsx 文件')
if (!file.name.toLowerCase().endsWith('.xlsx')) {
ElMessage.error('设备导入只能上传 .xlsx 文件')
uploadRef.value?.clearFiles()
fileList.value = []
return
}
if (isCsvAllocation && uploadFile.raw) {
const rows = (await uploadFile.raw.text()).replace(/^\uFEFF/, '').split(/\r?\n/).filter(Boolean)
if (rows.length > 1001 || rows.some((row: string) => row.includes(','))) {
ElMessage.error('设备分配 CSV 必须是单列且最多 1000 行数据')
uploadRef.value?.clearFiles()
fileList.value = []
return
}
}
fileList.value = uploadFile.raw ? [uploadFile.raw] : []
fileList.value = [file]
}
// 清空文件
const clearFiles = () => {
uploadRef.value?.clearFiles()
fileList.value = []
}
// 生成批次号
const handleGenerateBatchNo = () => {
const code = generatePackageCode().replace('PKG', 'DEV')
importForm.batch_no = code
importForm.batch_no = generatePackageCode().replace('PKG', 'DEV')
ElMessage.success('批次号生成成功')
}
// 取消导入
const handleCancelImport = () => {
clearFiles()
importForm.batch_no = ''
importForm.realname_policy = ''
importForm.operation_type = 'import'
importForm.target_id = ''
importDialogVisible.value = false
}
// 提交上传
const submitUpload = async () => {
if (!isPlatformAccount.value || !hasAuth(JULY_PERMISSIONS.deviceAllocation.page)) return
if (!fileList.value.length) {
ElMessage.warning('请先选择文件')
return
}
if (importForm.operation_type !== 'import' && !Number(importForm.target_id)) {
ElMessage.warning('请输入有效的目标 ID')
return
}
if (!isPlatformAccount.value || !fileList.value.length) {
ElMessage.warning('请先选择文件')
return
}
const file = fileList.value[0]
uploading.value = true
try {
ElMessage.info('正在准备上传...')
const isAllocation = importForm.operation_type !== 'import'
const contentType = isAllocation ? 'text/csv' : 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
const uploadUrlRes = await StorageService.getUploadUrl({
file_name: file.name,
content_type: contentType,
purpose: isAllocation ? 'device_batch_allocation' : 'iot_import'
const uploadUrlRes = await StorageService.getUploadUrl({
file_name: file.name,
content_type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
purpose: 'iot_import'
})
if (uploadUrlRes.code !== 0) {
ElMessage.error(uploadUrlRes.msg || '获取上传地址失败')
return
}
const { upload_url, file_key } = uploadUrlRes.data
ElMessage.info('正在上传文件...')
await StorageService.uploadFile(upload_url, file, contentType)
ElMessage.info(isAllocation ? '正在创建分配任务...' : '正在创建导入任务...')
const importRes = isAllocation
? await DeviceService.createAllocationTask({
file_key,
operation_type: importForm.operation_type as 'assign_shop' | 'assign_series',
target_id: Number(importForm.target_id)
})
: await DeviceService.importDevices({
file_key,
batch_no: importForm.batch_no || undefined,
realname_policy: (importForm.realname_policy || undefined) as RealnamePolicy | undefined
})
await StorageService.uploadFile(
upload_url,
file,
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
)
ElMessage.info('正在创建设备导入任务...')
const importRes = await DeviceService.importDevices({
file_key,
batch_no: importForm.batch_no || undefined,
realname_policy: (importForm.realname_policy || undefined) as RealnamePolicy | undefined
})
if (importRes.code !== 0) {
ElMessage.error(importRes.msg || '创建导入任务失败')
return
@@ -650,12 +528,10 @@
const taskNo = importRes.data.task_no
const taskId = importRes.data.task_id
handleCancelImport()
await router.replace({ path: route.path, query: { task_id: String(taskId) } })
await polling.start(taskId)
await getTableData()
ElMessage.success({
message: `导入任务已创建!任务编号:${taskNo}`,
duration: 3000,
@@ -669,62 +545,51 @@
}
}
// 从行数据下载失败数据
const downloadFailDataByRow = async (row: DeviceImportTask) => {
if (!isPlatformAccount.value) return
try {
const res = await DeviceService.getImportTaskDetail(row.id)
if (res.code === 0 && res.data) {
const detail = res.data
const failReasons =
detail.failed_items?.map((item: any) => ({
line: item.line || '-',
deviceNo: item.virtual_no || '-',
message: item.reason || '未知错误'
})) || []
if (failReasons.length === 0) {
ElMessage.warning('没有失败数据可下载')
return
}
const headers = ['行号', '设备编号', '失败原因']
const csvRows = [
headers.join(','),
...failReasons.map((item: any) =>
[item.line, `\t${item.deviceNo}`, `"${item.message}"`].join(',')
)
]
const csvContent = csvRows.join('\n')
const BOM = '\uFEFF'
const blob = new Blob([BOM + csvContent], { type: 'text/csv;charset=utf-8;' })
const link = document.createElement('a')
const url = URL.createObjectURL(blob)
link.setAttribute('href', url)
link.setAttribute('download', `导入失败数据_${row.batch_no}.csv`)
link.style.visibility = 'hidden'
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
ElMessage.success('失败数据下载成功')
if (res.code !== 0 || !res.data) return
const failReasons =
res.data.failed_items?.map((item) => ({
line: item.line || '-',
deviceNo: item.device_identifier || item.virtual_no || '-',
message: item.reason || '未知错误'
})) || []
if (failReasons.length === 0) {
ElMessage.warning('没有失败数据可下载')
return
}
const csvRows = [
['行号', '设备编号', '失败原因'].join(','),
...failReasons.map((item) =>
[item.line, `\t${item.deviceNo}`, `"${item.message.replaceAll('"', '""')}"`].join(',')
)
]
const blob = new Blob(['\uFEFF' + csvRows.join('\n')], {
type: 'text/csv;charset=utf-8;'
})
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `导入失败数据_${row.batch_no}.csv`
link.click()
URL.revokeObjectURL(url)
ElMessage.success('失败数据下载成功')
} catch (error) {
console.error('下载失败数据失败:', error)
ElMessage.error('下载失败数据失败')
}
}
const downloadTaskFile = async (row: DeviceImportTask) => {
if (!isPlatformAccount.value) return
const fileKey = row.file_name?.trim()
const fileKey = row.file_key || row.file_name?.trim()
if (!fileKey) {
ElMessage.warning('当前任务没有可下载的原始文件')
return
}
try {
await StorageService.downloadFileByKey(fileKey)
ElMessage.success('原始文件下载已开始')
@@ -734,20 +599,12 @@
}
}
// 获取操作按钮
const getActions = (row: DeviceImportTask) => {
if (!isPlatformAccount.value) return []
const actions: any[] = []
const showDownloadFileAction = false
if (showDownloadFileAction && row.file_name?.trim() && hasAuth('device_task:download_file')) {
actions.push({
label: '下载文件',
handler: () => downloadTaskFile(row),
type: 'primary'
})
if (row.file_key && hasAuth('device_task:download_file')) {
actions.push({ label: '下载文件', handler: () => downloadTaskFile(row), type: 'primary' })
}
if (row.fail_count > 0 && hasAuth('device_task:download_fail_data')) {
actions.push({
label: '失败数据',
@@ -755,7 +612,6 @@
type: 'danger'
})
}
return actions
}
</script>

View File

@@ -2,7 +2,6 @@
<ArtTableFullScreen>
<div class="task-detail-page" id="table-full-screen">
<ElCard shadow="never" class="art-table-card">
<!-- 页面头部 -->
<div class="detail-header">
<ElButton @click="goBack">
<template #icon>
@@ -10,35 +9,59 @@
</template>
返回
</ElButton>
<h2 class="detail-title">任务详情</h2>
<h2 class="detail-title">{{ taskTitle }}</h2>
</div>
<!-- 使用 DetailPage 组件显示任务信息 -->
<DetailPage v-if="taskDetail" :sections="detailSections" :data="taskDetail" />
<!-- 失败记录 -->
<div class="failure-section" v-if="taskDetail?.fail_count && taskDetail.fail_count > 0">
<div v-if="taskDetail?.fail_count && taskDetail.fail_count > 0" class="result-section">
<ElDivider content-position="left">
<span class="section-title">失败记录 ({{ taskDetail.fail_count }})</span>
<span class="section-title"
>{{ resultAction }}失败记录 ({{ taskDetail.fail_count }})</span
>
</ElDivider>
<ElTable :data="taskDetail.failed_items || []" border style="width: 100%">
<ElTableColumn prop="line" label="行号" width="100" />
<ElTableColumn v-if="taskType === 'card'" prop="iccid" label="ICCID" min-width="180" />
<ElTableColumn v-else prop="virtual_no" label="设备" min-width="180" />
<ElTableColumn prop="reason" label="失败原因" min-width="300" />
<ElTableColumn v-else label="设备标识" min-width="180" show-overflow-tooltip>
<template #default="scope">
{{ scope.row.device_identifier || scope.row.virtual_no || '-' }}
</template>
</ElTableColumn>
<ElTableColumn prop="reason" :label="`${resultAction}失败原因`" min-width="300" />
</ElTable>
</div>
<!-- 跳过记录 -->
<div class="skipped-section" v-if="taskDetail?.skip_count && taskDetail.skip_count > 0">
<div v-if="taskDetail?.skip_count && taskDetail.skip_count > 0" class="result-section">
<ElDivider content-position="left">
<span class="section-title">跳过记录 ({{ taskDetail.skip_count }})</span>
<span class="section-title"
>{{ resultAction }}跳过记录 ({{ taskDetail.skip_count }})</span
>
</ElDivider>
<ElTable :data="taskDetail.skipped_items || []" border style="width: 100%">
<ElTableColumn prop="line" label="行号" width="100" />
<ElTableColumn v-if="taskType === 'card'" prop="iccid" label="ICCID" min-width="180" />
<ElTableColumn v-else prop="virtual_no" label="设备" min-width="180" />
<ElTableColumn prop="reason" label="跳过原因" min-width="300" />
<ElTableColumn v-else label="设备标识" min-width="180" show-overflow-tooltip>
<template #default="scope">
{{ scope.row.device_identifier || scope.row.virtual_no || '-' }}
</template>
</ElTableColumn>
<ElTableColumn prop="reason" :label="`${resultAction}跳过原因`" min-width="300" />
</ElTable>
</div>
<div v-if="warningCount > 0" class="result-section">
<ElDivider content-position="left">
<span class="section-title">{{ resultAction }}警告记录 ({{ warningCount }})</span>
</ElDivider>
<ElTable :data="warningItems" border style="width: 100%">
<ElTableColumn prop="line" label="行号" width="100" />
<ElTableColumn label="设备标识" min-width="180" show-overflow-tooltip>
<template #default="scope">
{{ scope.row.device_identifier || scope.row.virtual_no || '-' }}
</template>
</ElTableColumn>
<ElTableColumn prop="reason" label="警告原因" min-width="300" />
</ElTable>
</div>
</ElCard>
@@ -47,16 +70,15 @@
</template>
<script setup lang="ts">
import { computed, h } from 'vue'
import { computed, h, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ref, onMounted } from 'vue'
import { CardService, DeviceService } from '@/api/modules'
import { ElDivider, ElIcon, ElMessage, ElTable, ElTableColumn, ElTag } from 'element-plus'
import type { TagProps } from 'element-plus'
import { ArrowLeft } from '@element-plus/icons-vue'
import { formatDateTime } from '@/utils/business/format'
import type { IotCardImportTaskDetail } from '@/types/api/card'
import type { DeviceImportTaskDetail } from '@/types/api/device'
import type { DeviceImportTaskDetail, DeviceImportTaskOperationType } from '@/types/api/device'
import type { DetailSection } from '@/components/common/DetailPage.vue'
import DetailPage from '@/components/common/DetailPage.vue'
import { useUserStore } from '@/store/modules/user'
@@ -72,12 +94,53 @@
type TaskDetail = IotCardImportTaskDetail | DeviceImportTaskDetail
const taskDetail = ref<TaskDetail | null>(null)
const loading = ref(false)
const taskType = ref<TaskType>('card')
const loading = ref(false)
const getOperationLabel = (operationType?: DeviceImportTaskOperationType) => {
switch (operationType) {
case 'assign_shop':
return '分配目标代理'
case 'assign_series':
return '设置套餐系列'
case 'recall':
return '批量回收设备'
case 'import':
return '导入设备'
default:
return '设备任务'
}
}
const deviceTask = computed(() =>
taskType.value === 'device' ? (taskDetail.value as DeviceImportTaskDetail | null) : null
)
const taskTitle = computed(() =>
taskType.value === 'device'
? `${getOperationLabel(deviceTask.value?.operation_type)}任务详情`
: 'ICCID导入任务详情'
)
const resultAction = computed(() => {
if (taskType.value !== 'device') return '导入'
switch (deviceTask.value?.operation_type) {
case 'assign_shop':
return '分配'
case 'assign_series':
return '设置套餐系列'
case 'recall':
return '回收'
default:
return '导入'
}
})
const warningCount = computed(() =>
taskType.value === 'device' ? deviceTask.value?.warning_count || 0 : 0
)
const warningItems = computed(() =>
taskType.value === 'device' ? deviceTask.value?.warning_items || [] : []
)
// 获取状态标签类型
const getStatusType = (status?: number): TagProps['type'] => {
if (!status) return 'info'
switch (status) {
case 1:
return 'info'
@@ -92,8 +155,54 @@
}
}
// 详情页配置
const detailSections = computed((): DetailSection[] => {
const deviceFields: DetailSection['fields'] =
taskType.value === 'device'
? [
{
label: '操作名称',
prop: 'operation_name',
formatter: (value: string) =>
value || getOperationLabel(deviceTask.value?.operation_type)
},
{
label: '目标 ID',
prop: 'target_id',
formatter: (value: number | null) => String(value ?? '-')
}
]
: []
const cardFields: DetailSection['fields'] =
taskType.value === 'card'
? [
{
label: '运营商',
prop: 'carrier_name',
formatter: (value: string) => value || '-'
},
{
label: '卡业务类型',
render: (data: any) => {
if (!data.card_category) return h('span', '-')
return h(
ElTag,
{
type: data.card_category === 'industry' ? 'warning' : 'success',
size: 'small'
},
() =>
data.card_category === 'normal'
? '普通卡'
: data.card_category === 'industry'
? '行业卡'
: data.card_category
)
}
}
]
: []
return [
{
title: '任务基本信息',
@@ -101,69 +210,44 @@
{ label: '任务编号', prop: 'task_no', formatter: (value: string) => value || '-' },
{
label: '任务类型',
render: () => {
return h(
render: () =>
h(
ElTag,
{ type: taskType.value === 'device' ? 'warning' : 'primary', size: 'small' },
() => (taskType.value === 'device' ? '设备导入' : 'ICCID导入')
() =>
taskType.value === 'device'
? getOperationLabel(deviceTask.value?.operation_type)
: 'ICCID导入'
)
}
},
...deviceFields,
{ label: '批次号', prop: 'batch_no', formatter: (value: string) => value || '-' },
{ label: '文件名', prop: 'file_name', formatter: (value: string) => value || '-' },
{
label: '操作人',
prop: 'creator_name',
formatter: (value: string) => value || '-'
},
{ label: '操作人', prop: 'creator_name', formatter: (value: string) => value || '-' },
{
label: '任务状态',
render: (data: TaskDetail) => {
return h(ElTag, { type: getStatusType(data.status) }, () => data.status_text)
}
render: (data: TaskDetail) =>
h(
ElTag,
{ type: getStatusType(data.status) },
() => (data as DeviceImportTaskDetail).status_name || data.status_text || '-'
)
},
...(taskType.value === 'card'
? [
{
label: '运营商',
prop: 'carrier_name',
formatter: (value: any) => value || '-'
},
{
label: '卡业务类型',
render: (data: any) => {
if (!data.card_category) return h('span', '-')
return h(
ElTag,
{
type: data.card_category === 'industry' ? 'warning' : 'success',
size: 'small'
},
() =>
data.card_category === 'normal'
? '普通卡'
: data.card_category === 'industry'
? '行业卡'
: data.card_category
)
}
}
]
: []),
...cardFields,
{
label: '创建时间',
prop: 'created_at',
formatter: (value: Date) => (value ? formatDateTime(value) : '-')
formatter: (value: string) => (value ? formatDateTime(value) : '-')
},
{
label: '开始处理时间',
prop: 'started_at',
formatter: (value: Date) => (value ? formatDateTime(value) : '-')
formatter: (value: string | null) => (value ? formatDateTime(value) : '-')
},
{
label: '完成时间',
prop: 'completed_at',
formatter: (value: Date) => (value ? formatDateTime(value) : '-')
formatter: (value: string | null) => (value ? formatDateTime(value) : '-')
},
...(taskDetail.value?.error_message
? [
@@ -171,13 +255,12 @@
label: '错误信息',
prop: 'error_message',
fullWidth: true,
render: (data: TaskDetail) => {
return h(
render: (data: TaskDetail) =>
h(
'span',
{ style: { color: 'var(--el-color-danger)' } },
data.error_message || ''
)
}
}
]
: [])
@@ -189,64 +272,57 @@
fields: [
{
label: '总数',
render: (data: TaskDetail) => {
return h(
render: (data: TaskDetail) =>
h(
'span',
{
style: {
fontSize: '16px',
fontWeight: 'bold',
color: 'var(--el-color-primary)'
}
style: { fontSize: '16px', fontWeight: 'bold', color: 'var(--el-color-primary)' }
},
String(data.total_count || 0)
)
}
},
{
label: '成功数',
render: (data: TaskDetail) => {
return h(ElTag, { type: 'success' }, () => String(data.success_count || 0))
}
render: (data: TaskDetail) =>
h(ElTag, { type: 'success' }, () => String(data.success_count || 0))
},
{
label: '失败数',
render: (data: TaskDetail) => {
return h(ElTag, { type: 'danger' }, () => String(data.fail_count || 0))
}
render: (data: TaskDetail) =>
h(ElTag, { type: 'danger' }, () => String(data.fail_count || 0))
},
{
label: '跳过数',
render: (data: TaskDetail) => {
return h(ElTag, { type: 'warning' }, () => String(data.skip_count || 0))
}
}
render: (data: TaskDetail) =>
h(ElTag, { type: 'warning' }, () => String(data.skip_count || 0))
},
...(taskType.value === 'device'
? [
{
label: '警告数',
render: (data: TaskDetail) =>
h(ElTag, { type: 'info' }, () =>
String((data as DeviceImportTaskDetail).warning_count || 0)
)
}
]
: [])
]
}
]
})
// 返回列表
const goBack = () => {
router.back()
}
const goBack = () => router.back()
// 获取任务详情
const getTaskDetail = async () => {
const taskId = route.query.id
const queryTaskType = route.query.task_type as TaskType | undefined
if (!taskId) {
ElMessage.error('缺少任务ID参数')
ElMessage.error('缺少任务 ID 参数')
goBack()
return
}
// 设置任务类型
if (queryTaskType) {
taskType.value = queryTaskType
}
if (queryTaskType) taskType.value = queryTaskType
if (taskType.value === 'device' && !isPlatformAccount.value) {
ElMessage.error('当前账号无权查看设备任务详情')
goBack()
@@ -256,28 +332,22 @@
loading.value = true
try {
if (taskType.value === 'device') {
// 获取设备导入任务详情
const res = await DeviceService.getImportTaskDetail(Number(taskId))
if (res.code === 0) {
taskDetail.value = res.data
}
if (res.code === 0) taskDetail.value = res.data
} else {
// 获取ICCID导入任务详情
const res = await CardService.getIotCardImportTaskDetail(Number(taskId))
if (res.code === 0) {
taskDetail.value = res.data
}
if (res.code === 0) taskDetail.value = res.data
}
} catch (error) {
console.error(error)
console.log('获取任务详情失败')
console.error('获取任务详情失败:', error)
ElMessage.error('获取任务详情失败')
} finally {
loading.value = false
}
}
onMounted(() => {
getTaskDetail()
void getTaskDetail()
})
</script>
@@ -285,8 +355,8 @@
.task-detail-page {
.detail-header {
display: flex;
gap: 16px;
align-items: center;
gap: 16px;
padding-bottom: 16px;
.detail-title {
@@ -297,8 +367,7 @@
}
}
.failure-section,
.skipped-section {
.result-section {
margin-top: 20px;
.section-title {