This commit is contained in:
@@ -10,7 +10,11 @@
|
||||
</div>
|
||||
<div class="card-header-right">
|
||||
<ElButton
|
||||
v-if="cardInfo?.asset_type === 'card' && hasAuth(JULY_PERMISSIONS.speedTier.view)"
|
||||
v-if="
|
||||
cardInfo?.asset_type === 'card' &&
|
||||
hasAuth(JULY_PERMISSIONS.speedTier.view) &&
|
||||
hasAuth(JULY_PERMISSIONS.speedTier.update)
|
||||
"
|
||||
type="primary"
|
||||
link
|
||||
@click="emit('showSpeedLimit')"
|
||||
|
||||
@@ -270,6 +270,7 @@
|
||||
v-model="createRefundDialogVisible"
|
||||
:initial-order-id="currentRefundOrderId"
|
||||
:initial-order-no="currentRefundOrderNo"
|
||||
:initial-package-usage-id="currentRefundPackageUsageId"
|
||||
@success="handleRefundSuccess"
|
||||
/>
|
||||
|
||||
@@ -279,12 +280,7 @@
|
||||
width="30%"
|
||||
@closed="resetUsedDataForm"
|
||||
>
|
||||
<ElForm
|
||||
ref="usedDataFormRef"
|
||||
:model="usedDataForm"
|
||||
:rules="usedDataRules"
|
||||
label-width="90px"
|
||||
>
|
||||
<ElForm ref="usedDataFormRef" :model="usedDataForm" :rules="usedDataRules" label-width="90px">
|
||||
<ElFormItem label="套餐名称">
|
||||
<span>{{ selectedPackage?.package_name || '-' }}</span>
|
||||
</ElFormItem>
|
||||
@@ -416,6 +412,7 @@
|
||||
const createRefundDialogVisible = ref(false)
|
||||
const currentRefundOrderId = ref<number | undefined>(undefined)
|
||||
const currentRefundOrderNo = ref<string | undefined>(undefined)
|
||||
const currentRefundPackageUsageId = ref<number | undefined>(undefined)
|
||||
const selectedPackage = ref<PackageInfo>()
|
||||
const usedDataDialogVisible = ref(false)
|
||||
const expiresAtDialogVisible = ref(false)
|
||||
@@ -474,10 +471,12 @@
|
||||
}
|
||||
currentRefundOrderId.value = row.order_id
|
||||
currentRefundOrderNo.value = row.order_no
|
||||
currentRefundPackageUsageId.value = row.package_usage_id ?? row.id
|
||||
createRefundDialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleRefundSuccess = () => {
|
||||
currentRefundPackageUsageId.value = undefined
|
||||
emit('refresh')
|
||||
}
|
||||
|
||||
|
||||
@@ -1,39 +1,66 @@
|
||||
<template>
|
||||
<ElDialog v-model="visible" title="设置限速" width="500px">
|
||||
<ElDialog
|
||||
v-model="visible"
|
||||
title="设置限速"
|
||||
width="500px"
|
||||
:lock-scroll="true"
|
||||
:z-index="4000"
|
||||
modal-class="speed-limit-modal"
|
||||
>
|
||||
<ElForm ref="formRef" :model="form" :rules="rules" label-width="120px">
|
||||
<ElFormItem label="固定档位" prop="code">
|
||||
<ElSelect v-model="form.code" style="width: 100%">
|
||||
<ElOption v-for="tier in speedTiers" :key="tier.code" :label="tier.label" :value="tier.code" />
|
||||
<ElSelect
|
||||
v-model="form.code"
|
||||
style="width: 100%"
|
||||
:teleported="true"
|
||||
append-to="body"
|
||||
popper-class="speed-limit-select-popper"
|
||||
:popper-style="{ zIndex: 4001 }"
|
||||
>
|
||||
<ElOption
|
||||
v-for="tier in speedTiers"
|
||||
:key="tier.code"
|
||||
:label="tier.label"
|
||||
:value="tier.code"
|
||||
/>
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
</ElForm>
|
||||
<template #footer>
|
||||
<ElButton @click="handleCancel">取消</ElButton>
|
||||
<ElButton type="primary" @click="handleConfirm" :loading="loading"> 确认设置 </ElButton>
|
||||
<ElButton
|
||||
type="primary"
|
||||
@click="handleConfirm"
|
||||
:loading="confirmLoading"
|
||||
:disabled="confirmLoading"
|
||||
>
|
||||
确认设置
|
||||
</ElButton>
|
||||
</template>
|
||||
</ElDialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, watch } from 'vue'
|
||||
import { ref, reactive, computed, onUnmounted, watch } from 'vue'
|
||||
import { ElDialog, ElForm, ElFormItem, ElSelect, ElOption, ElButton } from 'element-plus'
|
||||
import type { FormInstance, FormRules } from 'element-plus'
|
||||
|
||||
interface Props {
|
||||
modelValue: boolean
|
||||
confirmLoading?: boolean
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:modelValue', value: boolean): void
|
||||
(e: 'confirm', data: { code: -1 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 }): void
|
||||
(e: 'confirm', data: { code: -1 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 }): void
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
confirmLoading: false
|
||||
})
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const formRef = ref<FormInstance>()
|
||||
const loading = ref(false)
|
||||
|
||||
const speedTiers = [
|
||||
{ code: -1, label: '不限速' },
|
||||
{ code: 0, label: '0kbps' },
|
||||
@@ -59,10 +86,25 @@
|
||||
})
|
||||
|
||||
// 监听对话框打开,重置表单
|
||||
watch(visible, (newVal) => {
|
||||
if (newVal) {
|
||||
form.code = 3
|
||||
}
|
||||
const setLayoutScrollLock = (locked: boolean) => {
|
||||
document
|
||||
.querySelector<HTMLElement>('.layouts')
|
||||
?.classList.toggle('speed-limit-scroll-locked', locked)
|
||||
}
|
||||
|
||||
watch(
|
||||
visible,
|
||||
(newVal) => {
|
||||
setLayoutScrollLock(newVal)
|
||||
if (newVal) {
|
||||
form.code = 3
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
onUnmounted(() => {
|
||||
setLayoutScrollLock(false)
|
||||
})
|
||||
|
||||
const handleCancel = () => {
|
||||
@@ -70,6 +112,7 @@
|
||||
}
|
||||
|
||||
const handleConfirm = async () => {
|
||||
if (props.confirmLoading) return
|
||||
if (!formRef.value) return
|
||||
|
||||
try {
|
||||
@@ -83,4 +126,28 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped></style>
|
||||
<style lang="scss">
|
||||
.speed-limit-modal .el-dialog__body {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.speed-limit-select-popper {
|
||||
z-index: 4001 !important;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.speed-limit-select-popper .el-select-dropdown__wrap,
|
||||
.speed-limit-select-popper .el-scrollbar__wrap {
|
||||
max-height: none !important;
|
||||
overflow-y: visible !important;
|
||||
}
|
||||
|
||||
.speed-limit-modal {
|
||||
overflow: hidden;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.layouts.speed-limit-scroll-locked {
|
||||
overflow: hidden !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -36,8 +36,8 @@
|
||||
@show-switch-card="showSwitchCardDialog"
|
||||
@show-switch-mode="showSwitchModeDialog"
|
||||
@show-set-wifi="showSetWiFiDialog"
|
||||
@show-realname-policy="showRealnamePolicyDialog"
|
||||
@show-speed-limit="speedLimitDialogVisible = true"
|
||||
@show-realname-policy="showRealnamePolicyDialog"
|
||||
@show-speed-limit="speedLimitDialogVisible = true"
|
||||
@show-update-realname-status="showUpdateRealnameStatusDialog"
|
||||
@enable-binding-card="handleEnableBindingCard"
|
||||
@disable-binding-card="handleDisableBindingCard"
|
||||
@@ -174,7 +174,11 @@
|
||||
:current-realname-status="bindingCardRealnameStatusValue"
|
||||
@success="handleBindingCardRealnameStatusSuccess"
|
||||
/>
|
||||
<SpeedLimitDialog v-model="speedLimitDialogVisible" @confirm="handleSpeedTierConfirm" />
|
||||
<SpeedLimitDialog
|
||||
v-model="speedLimitDialogVisible"
|
||||
:confirm-loading="speedTierSubmitting"
|
||||
@confirm="handleSpeedTierConfirm"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -183,7 +187,10 @@
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage, ElCard, ElEmpty } from 'element-plus'
|
||||
import { RoutesAlias } from '@/router/routesAlias'
|
||||
import { CardService, DeviceService } from '@/api/modules'
|
||||
import { CardService, DeviceService } from '@/api/modules'
|
||||
import { JULY_PERMISSIONS } from '@/config/constants'
|
||||
import { useAuth } from '@/composables/useAuth'
|
||||
import { normalizeApiError } from '@/utils/business/apiError'
|
||||
import {
|
||||
formatRemainingTime,
|
||||
FrontendRateLimitError,
|
||||
@@ -204,9 +211,9 @@
|
||||
SwitchModeDialog,
|
||||
WiFiConfigDialog,
|
||||
PackageRechargeDialog,
|
||||
DailyRecordsDialog,
|
||||
OrderHistoryDialog,
|
||||
SpeedLimitDialog
|
||||
DailyRecordsDialog,
|
||||
OrderHistoryDialog,
|
||||
SpeedLimitDialog
|
||||
} from './components/dialogs'
|
||||
import SwitchCardDialog from '@/components/device/SwitchCardDialog.vue'
|
||||
import RealnamePolicyDialog from '@/components/device/RealnamePolicyDialog.vue'
|
||||
@@ -235,6 +242,7 @@
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { hasAuth } = useAuth()
|
||||
|
||||
// ========== 组件引用 ==========
|
||||
const assetSearchCardRef = ref<InstanceType<typeof AssetSearchCard>>()
|
||||
@@ -294,6 +302,7 @@
|
||||
// 绑定卡实名状态更新
|
||||
const bindingCardRealnameStatusDialogVisible = ref(false)
|
||||
const speedLimitDialogVisible = ref(false)
|
||||
const speedTierSubmitting = ref(false)
|
||||
const bindingCardRealnameStatusIccid = ref('')
|
||||
const bindingCardRealnameStatusValue = ref<number>(0)
|
||||
|
||||
@@ -806,12 +815,34 @@
|
||||
}
|
||||
}
|
||||
const handleSpeedTierConfirm = async (data: { code: -1 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 }) => {
|
||||
if (!cardInfo.value?.iccid) return
|
||||
const response = await CardService.setSpeedTier(cardInfo.value.iccid, data.code)
|
||||
if (response.code === 0) {
|
||||
if (!hasAuth(JULY_PERMISSIONS.speedTier.update)) {
|
||||
ElMessage.error('暂无设置限速权限')
|
||||
return
|
||||
}
|
||||
|
||||
const iccid = cardInfo.value?.iccid
|
||||
if (!iccid) {
|
||||
ElMessage.error('当前卡缺少 ICCID,无法设置限速')
|
||||
return
|
||||
}
|
||||
if (speedTierSubmitting.value) return
|
||||
|
||||
speedTierSubmitting.value = true
|
||||
try {
|
||||
const response = await CardService.setSpeedTier(iccid, data.code)
|
||||
if (response.code !== 0) {
|
||||
ElMessage.error(response.msg || '限速设置失败')
|
||||
return
|
||||
}
|
||||
|
||||
ElMessage.success(`限速设置成功:${response.data.speed_tier_name}`)
|
||||
speedLimitDialogVisible.value = false
|
||||
await handleRefresh()
|
||||
} catch (error) {
|
||||
console.error('设置限速失败:', error)
|
||||
ElMessage.error(normalizeApiError(error).message)
|
||||
} finally {
|
||||
speedTierSubmitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -17,29 +17,6 @@
|
||||
@refresh="handleRefresh"
|
||||
>
|
||||
<template #left>
|
||||
<ElButton
|
||||
type="primary"
|
||||
@click="handleBatchAllocate"
|
||||
:disabled="!selectedDevices.length"
|
||||
v-permission="'device:batch_allocate'"
|
||||
>
|
||||
批量分配
|
||||
</ElButton>
|
||||
<ElButton
|
||||
@click="handleBatchRecall"
|
||||
:disabled="!selectedDevices.length"
|
||||
v-permission="'device:batch_recall'"
|
||||
>
|
||||
批量回收
|
||||
</ElButton>
|
||||
<ElButton
|
||||
type="info"
|
||||
:disabled="!selectedDevices.length"
|
||||
@click="handleBatchSetSeries"
|
||||
v-permission="'device:batch_set_series'"
|
||||
>
|
||||
批量设置套餐系列
|
||||
</ElButton>
|
||||
<ElButton
|
||||
type="primary"
|
||||
:disabled="!selectedDevices.length"
|
||||
@@ -2299,8 +2276,8 @@
|
||||
return
|
||||
}
|
||||
|
||||
const successCount = res.data?.success_count ?? assetIds.length
|
||||
ElMessage.success(`批量修改实名顺序成功:${successCount}/${assetIds.length}`)
|
||||
const successCount = res.data?.success_count ?? assetIds.length
|
||||
ElMessage.success(`批量修改实名顺序成功:${successCount}/${assetIds.length}`)
|
||||
batchRealnamePolicyDialogVisible.value = false
|
||||
selectedDevices.value = []
|
||||
await getTableData()
|
||||
@@ -2373,19 +2350,6 @@
|
||||
})
|
||||
}
|
||||
|
||||
// 批量分配
|
||||
const handleBatchAllocate = async () => {
|
||||
if (selectedDevices.value.length === 0) {
|
||||
ElMessage.warning('请先选择要分配的设备')
|
||||
return
|
||||
}
|
||||
allocateForm.target_shop_id = undefined
|
||||
allocateForm.remark = ''
|
||||
allocateResult.value = null
|
||||
shopCascadeOptions.value = []
|
||||
allocateDialogVisible.value = true
|
||||
}
|
||||
|
||||
// 确认批量分配
|
||||
const handleConfirmAllocate = async () => {
|
||||
if (!allocateFormRef.value) return
|
||||
@@ -2436,17 +2400,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
// 批量回收
|
||||
const handleBatchRecall = () => {
|
||||
if (selectedDevices.value.length === 0) {
|
||||
ElMessage.warning('请先选择要回收的设备')
|
||||
return
|
||||
}
|
||||
recallForm.remark = ''
|
||||
recallResult.value = null
|
||||
recallDialogVisible.value = true
|
||||
}
|
||||
|
||||
// 确认批量回收
|
||||
const handleConfirmRecall = async () => {
|
||||
recallLoading.value = true
|
||||
@@ -2636,22 +2589,6 @@
|
||||
})
|
||||
}
|
||||
|
||||
// 批量设置套餐系列
|
||||
const handleBatchSetSeries = async () => {
|
||||
seriesBindingResult.value = null
|
||||
Object.assign(seriesBindingForm, createInitialSeriesBindingState(), {
|
||||
selection_type:
|
||||
selectedDevices.value.length > 0 ? DeviceSelectionType.LIST : DeviceSelectionType.FILTER
|
||||
})
|
||||
await Promise.all([
|
||||
loadPackageSeriesList(),
|
||||
loadSearchBatchNoOptions(),
|
||||
loadSeriesBindingShopCascadeOptions()
|
||||
])
|
||||
seriesBindingDialogVisible.value = true
|
||||
seriesBindingFormRef.value?.clearValidate()
|
||||
}
|
||||
|
||||
// 加载套餐系列列表(支持名称搜索,默认20条)
|
||||
const loadPackageSeriesList = async (seriesName?: string) => {
|
||||
seriesLoading.value = true
|
||||
@@ -2680,24 +2617,6 @@
|
||||
await loadPackageSeriesList(query || undefined)
|
||||
}
|
||||
|
||||
const loadSeriesBindingShopCascadeOptions = async () => {
|
||||
try {
|
||||
const res = await ShopService.getShopsCascade({
|
||||
parent_id: undefined,
|
||||
exclude_self: true
|
||||
})
|
||||
if (res.code === 0) {
|
||||
seriesBindingShopCascadeOptions.value = (res.data || []).map((item: any) => ({
|
||||
value: item.id,
|
||||
label: item.shop_name,
|
||||
leaf: !item.has_children
|
||||
}))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载批量设置套餐系列根级店铺失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const buildSeriesBindingRequest = (): BatchSetDeviceSeriesBindingRequest | null => {
|
||||
const request: BatchSetDeviceSeriesBindingRequest = {
|
||||
selection_type: seriesBindingForm.selection_type,
|
||||
|
||||
@@ -18,6 +18,9 @@
|
||||
<ElButton type="primary" @click="showCreateDialog" v-permission="'exchange:create'">
|
||||
创建换货单
|
||||
</ElButton>
|
||||
<ElButton v-if="hasAuth('exchange:export')" @click="exportDialogVisible = true">
|
||||
导出
|
||||
</ElButton>
|
||||
</template>
|
||||
</ArtTableHeader>
|
||||
|
||||
@@ -41,6 +44,14 @@
|
||||
</template>
|
||||
</ArtTable>
|
||||
|
||||
<ExportTaskCreateDialog
|
||||
v-model="exportDialogVisible"
|
||||
scene="exchange"
|
||||
:query="exportQuery"
|
||||
confirm-permission="exchange:export"
|
||||
title="导出换货"
|
||||
/>
|
||||
|
||||
<!-- 创建换货单对话框 -->
|
||||
<ElDialog
|
||||
v-model="createDialogVisible"
|
||||
@@ -393,6 +404,7 @@
|
||||
import type { SearchFormItem } from '@/types'
|
||||
import { useCheckedColumns } from '@/composables/useCheckedColumns'
|
||||
import { useAuth } from '@/composables/useAuth'
|
||||
import ExportTaskCreateDialog from '@/components/business/ExportTaskCreateDialog.vue'
|
||||
import { formatDateTime } from '@/utils/business/format'
|
||||
import {
|
||||
getApprovalStatusText,
|
||||
@@ -413,6 +425,7 @@
|
||||
const shipFormRef = ref<FormInstance>()
|
||||
const createDialogVisible = ref(false)
|
||||
const createLoading = ref(false)
|
||||
const exportDialogVisible = ref(false)
|
||||
const tableRef = ref()
|
||||
const createFormRef = ref<FormInstance>()
|
||||
|
||||
@@ -438,6 +451,18 @@
|
||||
created_at_end: ''
|
||||
})
|
||||
|
||||
const exportQuery = computed(() => {
|
||||
const [startDate, endDate] = searchForm.created_at_range || []
|
||||
return {
|
||||
status: searchForm.status,
|
||||
flow_type: searchForm.flow_type,
|
||||
old_asset_keyword: searchForm.old_asset_keyword || undefined,
|
||||
new_asset_keyword: searchForm.new_asset_keyword || undefined,
|
||||
created_at_start: startDate || searchForm.created_at_start || undefined,
|
||||
created_at_end: endDate || searchForm.created_at_end || undefined
|
||||
}
|
||||
})
|
||||
|
||||
// 创建换货单表单
|
||||
const createForm = reactive<{
|
||||
exchange_reason: string
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
v-model:filter="searchForm"
|
||||
:items="searchFormItems"
|
||||
:show-expand="true"
|
||||
label-width="85"
|
||||
label-width="100px"
|
||||
@reset="handleReset"
|
||||
@search="handleSearch"
|
||||
/>
|
||||
@@ -17,7 +17,10 @@
|
||||
@refresh="handleRefresh"
|
||||
>
|
||||
<template #left>
|
||||
<div class="page-intro"> 仅展示可预计且尚未过期的资产,0-3 天资产优先显示。 </div>
|
||||
<div class="page-intro">
|
||||
临期资产 {{ summary.total_count }} 条(卡 {{ summary.card_count }},设备
|
||||
{{ summary.device_count }}),0-3 天资产优先显示。
|
||||
</div>
|
||||
</template>
|
||||
</ArtTableHeader>
|
||||
|
||||
@@ -47,11 +50,12 @@
|
||||
<script setup lang="ts">
|
||||
import { h, onMounted, reactive, ref, computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElTag } from 'element-plus'
|
||||
import { ElMessage, ElTag } from 'element-plus'
|
||||
import { AssetService, PackageManageService, ShopService } from '@/api/modules'
|
||||
import type {
|
||||
AssetType,
|
||||
ExpiringAssetItem,
|
||||
ExpiringAssetSummary,
|
||||
ExpiringAssetType,
|
||||
ExpiringAssetQueryParams,
|
||||
PackageResponse,
|
||||
ShopResponse
|
||||
@@ -72,6 +76,12 @@
|
||||
const shopOptions = ref<ShopResponse[]>([])
|
||||
const packageOptions = ref<PackageResponse[]>([])
|
||||
const items = ref<ExpiringAssetItem[]>([])
|
||||
const summary = reactive<ExpiringAssetSummary>({
|
||||
card_count: 0,
|
||||
device_count: 0,
|
||||
total_count: 0,
|
||||
window_days: 15
|
||||
})
|
||||
const searchForm = reactive<ExpiringAssetQueryParams>({
|
||||
asset_type: undefined,
|
||||
keyword: '',
|
||||
@@ -91,7 +101,7 @@
|
||||
type: 'select',
|
||||
config: { clearable: true, placeholder: '全部' },
|
||||
options: [
|
||||
{ label: '网卡', value: 'card' },
|
||||
{ label: '物联网卡', value: 'iot_card' },
|
||||
{ label: '设备', value: 'device' }
|
||||
]
|
||||
},
|
||||
@@ -137,13 +147,25 @@
|
||||
label: '最小剩余天数',
|
||||
prop: 'days_min',
|
||||
type: 'input',
|
||||
config: { clearable: true, inputmode: 'numeric', placeholder: '最小天数' }
|
||||
config: {
|
||||
clearable: true,
|
||||
type: 'number',
|
||||
min: 0,
|
||||
max: 15,
|
||||
placeholder: '最小天数'
|
||||
}
|
||||
},
|
||||
{
|
||||
label: '最大剩余天数',
|
||||
prop: 'days_max',
|
||||
type: 'input',
|
||||
config: { clearable: true, inputmode: 'numeric', placeholder: '最大天数' }
|
||||
config: {
|
||||
clearable: true,
|
||||
type: 'number',
|
||||
min: 0,
|
||||
max: 15,
|
||||
placeholder: '最大天数'
|
||||
}
|
||||
},
|
||||
{
|
||||
label: '预计到期起始',
|
||||
@@ -170,15 +192,15 @@
|
||||
]
|
||||
|
||||
const getExpiryClass = (level?: string | null) => {
|
||||
if (level === 'critical' || level === '0_3') return 'expiry-critical'
|
||||
if (level === 'warning' || level === '4_7') return 'expiry-warning'
|
||||
if (level === 'notice' || level === '8_15') return 'expiry-notice'
|
||||
if (level === 'red') return 'expiry-critical'
|
||||
if (level === 'purple') return 'expiry-warning'
|
||||
if (level === 'pink') return 'expiry-notice'
|
||||
return ''
|
||||
}
|
||||
|
||||
const getExpiryTagType = (level?: string | null) => {
|
||||
if (level === 'critical' || level === '0_3') return 'danger'
|
||||
if (level === 'warning' || level === '4_7') return 'warning'
|
||||
if (level === 'red') return 'danger'
|
||||
if (level === 'purple') return 'warning'
|
||||
return 'info'
|
||||
}
|
||||
|
||||
@@ -215,7 +237,7 @@
|
||||
prop: 'asset_type',
|
||||
label: '资产类型',
|
||||
width: 100,
|
||||
formatter: (row: ExpiringAssetItem) => (row.asset_type === 'card' ? '网卡' : '设备')
|
||||
formatter: (row: ExpiringAssetItem) => (row.asset_type === 'iot_card' ? '物联网卡' : '设备')
|
||||
},
|
||||
{
|
||||
prop: 'identifier',
|
||||
@@ -246,7 +268,7 @@
|
||||
h(
|
||||
'span',
|
||||
{ class: getExpiryClass(row.expiry_level) },
|
||||
formatDateTime(row.estimated_final_expires_at)
|
||||
formatDateTime(row.estimated_final_expires_at) || '-'
|
||||
)
|
||||
},
|
||||
{
|
||||
@@ -254,10 +276,8 @@
|
||||
label: '剩余天数',
|
||||
width: 100,
|
||||
formatter: (row: ExpiringAssetItem) =>
|
||||
h(
|
||||
ElTag,
|
||||
{ type: getExpiryTagType(row.expiry_level), size: 'small' },
|
||||
() => `${row.days_until_final_expiry}天`
|
||||
h(ElTag, { type: getExpiryTagType(row.expiry_level), size: 'small' }, () =>
|
||||
row.days_until_final_expiry == null ? '-' : `${row.days_until_final_expiry}天`
|
||||
)
|
||||
},
|
||||
{
|
||||
@@ -271,15 +291,43 @@
|
||||
const loadAssets = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await AssetService.getExpiringAssets({
|
||||
...searchForm,
|
||||
const toInteger = (value: unknown) => {
|
||||
if (value === undefined || value === null || value === '') return undefined
|
||||
const numberValue = Number(value)
|
||||
return Number.isInteger(numberValue) ? numberValue : undefined
|
||||
}
|
||||
const params: ExpiringAssetQueryParams = {
|
||||
page: pagination.currentPage,
|
||||
size: pagination.pageSize
|
||||
})
|
||||
page_size: pagination.pageSize
|
||||
}
|
||||
|
||||
if (searchForm.asset_type) params.asset_type = searchForm.asset_type
|
||||
const keyword = searchForm.keyword?.trim()
|
||||
if (keyword) params.keyword = keyword
|
||||
if (searchForm.shop_id !== undefined && searchForm.shop_id !== null) {
|
||||
params.shop_id = searchForm.shop_id
|
||||
}
|
||||
if (searchForm.package_id !== undefined && searchForm.package_id !== null) {
|
||||
params.package_id = searchForm.package_id
|
||||
}
|
||||
const daysMin = toInteger(searchForm.days_min)
|
||||
const daysMax = toInteger(searchForm.days_max)
|
||||
if (daysMin !== undefined) params.days_min = daysMin
|
||||
if (daysMax !== undefined) params.days_max = daysMax
|
||||
if (searchForm.expires_from) params.expires_from = searchForm.expires_from
|
||||
if (searchForm.expires_to) params.expires_to = searchForm.expires_to
|
||||
|
||||
const response = await AssetService.getExpiringAssets(params)
|
||||
if (response.code === 0 && response.data) {
|
||||
items.value = response.data.items || []
|
||||
pagination.total = response.data.total || 0
|
||||
Object.assign(summary, response.data.summary)
|
||||
} else {
|
||||
ElMessage.error(response.msg || '获取临期资产失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取临期资产失败:', error)
|
||||
ElMessage.error('获取临期资产失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
@@ -318,21 +366,20 @@
|
||||
const goToAsset = (row: ExpiringAssetItem) => {
|
||||
router.push({
|
||||
path: RoutesAlias.AssetInformation,
|
||||
query: row.asset_type === 'card' ? { iccid: row.identifier } : { virtual_no: row.identifier }
|
||||
query:
|
||||
row.asset_type === 'iot_card' ? { iccid: row.identifier } : { virtual_no: row.identifier }
|
||||
})
|
||||
}
|
||||
|
||||
const getActions = (row: ExpiringAssetItem) => [
|
||||
{ label: '查看资产', type: 'primary' as const, handler: () => goToAsset(row) },
|
||||
...(row.can_renew
|
||||
? [{ label: '续费', type: 'primary' as const, handler: () => goToAsset(row) }]
|
||||
: [])
|
||||
{ label: '查看资产', type: 'primary' as const, handler: () => goToAsset(row) }
|
||||
]
|
||||
|
||||
onMounted(() => {
|
||||
const assetType = route.query.asset_type
|
||||
if (assetType === 'card' || assetType === 'device')
|
||||
searchForm.asset_type = assetType as AssetType
|
||||
if (assetType === 'card' || assetType === 'iot_card' || assetType === 'device') {
|
||||
searchForm.asset_type = assetType === 'card' ? 'iot_card' : (assetType as ExpiringAssetType)
|
||||
}
|
||||
void Promise.all([loadAssets(), searchShops(), searchPackages()])
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
<template>
|
||||
<ExportTaskList />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import ExportTaskList from '../export-task-list/index.vue'
|
||||
|
||||
defineOptions({ name: 'ExportAgentRechargeTaskList' })
|
||||
</script>
|
||||
@@ -0,0 +1,9 @@
|
||||
<template>
|
||||
<ExportTaskList />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import ExportTaskList from '../export-task-list/index.vue'
|
||||
|
||||
defineOptions({ name: 'ExportAgentWalletTransactionTaskList' })
|
||||
</script>
|
||||
@@ -0,0 +1,9 @@
|
||||
<template>
|
||||
<ExportTaskList />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import ExportTaskList from '../export-task-list/index.vue'
|
||||
|
||||
defineOptions({ name: 'ExportExchangeTaskList' })
|
||||
</script>
|
||||
@@ -0,0 +1,9 @@
|
||||
<template>
|
||||
<ExportTaskList />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import ExportTaskList from '../export-task-list/index.vue'
|
||||
|
||||
defineOptions({ name: 'ExportPackageTaskList' })
|
||||
</script>
|
||||
@@ -0,0 +1,9 @@
|
||||
<template>
|
||||
<ExportTaskList />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import ExportTaskList from '../export-task-list/index.vue'
|
||||
|
||||
defineOptions({ name: 'ExportRefundTaskList' })
|
||||
</script>
|
||||
@@ -14,7 +14,6 @@
|
||||
<ElButton v-if="canDownloadTask" type="primary" @click="downloadTask">
|
||||
下载文件
|
||||
</ElButton>
|
||||
<ElButton v-if="canCancelTask" type="danger" @click="cancelTask"> 取消任务 </ElButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -41,7 +40,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, h, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElButton, ElCard, ElEmpty, ElIcon, ElMessage, ElMessageBox, ElTag } from 'element-plus'
|
||||
import { ElButton, ElCard, ElEmpty, ElIcon, ElMessage, ElTag } from 'element-plus'
|
||||
import { ArrowLeft, Loading } from '@element-plus/icons-vue'
|
||||
import DetailPage from '@/components/common/DetailPage.vue'
|
||||
import type { DetailSection } from '@/components/common/DetailPage.vue'
|
||||
@@ -104,22 +103,11 @@
|
||||
return status ? statusTypeMap[status] : 'info'
|
||||
}
|
||||
|
||||
const canCancelTask = computed(
|
||||
() =>
|
||||
canViewDetail.value &&
|
||||
[ExportTaskStatus.PENDING, ExportTaskStatus.PROCESSING].includes(
|
||||
taskDetail.value?.status as ExportTaskStatus
|
||||
) &&
|
||||
!!taskPermissions.value &&
|
||||
hasAuth(taskPermissions.value.cancel)
|
||||
)
|
||||
|
||||
const detailSections = computed((): DetailSection[] => [
|
||||
{
|
||||
title: '任务基本信息',
|
||||
fields: [
|
||||
{ label: '任务编号', prop: 'task_no', formatter: (value: string) => value || '-' },
|
||||
{ label: '任务ID', prop: 'task_id', formatter: (value: number) => String(value ?? '-') },
|
||||
{
|
||||
label: '导出场景',
|
||||
render: (data: ExportTaskDetail) => h(ElTag, {}, () => getExportTaskSceneName(data.scene))
|
||||
@@ -269,35 +257,6 @@
|
||||
window.open(taskDetail.value.download_url, '_blank')
|
||||
}
|
||||
|
||||
const cancelTask = () => {
|
||||
if (!taskDetail.value) return
|
||||
if (!canCancelTask.value) {
|
||||
ElMessage.warning('您没有取消该导出任务的权限')
|
||||
return
|
||||
}
|
||||
|
||||
ElMessageBox.confirm(`确定取消导出任务 ${taskDetail.value.task_no} 吗?`, '取消确认', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
}).then(async () => {
|
||||
try {
|
||||
const res = await ExportTaskService.cancelExportTask(
|
||||
taskDetail.value!.task_id ?? taskDetail.value!.id
|
||||
)
|
||||
if (res.code === 0) {
|
||||
ElMessage.success(res.data?.message || '任务取消成功')
|
||||
polling.retry()
|
||||
} else {
|
||||
ElMessage.error(res.msg || '取消导出任务失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('取消导出任务失败:', error)
|
||||
ElMessage.error('取消导出任务失败')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadTaskDetail()
|
||||
})
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
:total="pagination.total"
|
||||
:marginTop="10"
|
||||
:actions="getActions"
|
||||
:actionsWidth="180"
|
||||
:actionsWidth="120"
|
||||
@size-change="handleSizeChange"
|
||||
@current-change="handleCurrentChange"
|
||||
>
|
||||
@@ -41,7 +41,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, h, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox, ElProgress, ElTag } from 'element-plus'
|
||||
import { ElMessage, ElProgress, ElTag } from 'element-plus'
|
||||
import { ExportTaskService } from '@/api/modules'
|
||||
import { useAuth } from '@/composables/useAuth'
|
||||
import { useCheckedColumns } from '@/composables/useCheckedColumns'
|
||||
@@ -231,6 +231,12 @@
|
||||
}
|
||||
}
|
||||
|
||||
const toRfc3339 = (date: string, endOfDay = false) => {
|
||||
if (!date) return undefined
|
||||
if (date.includes('T')) return date
|
||||
return `${date}T${endOfDay ? '23:59:59' : '00:00:00'}+08:00`
|
||||
}
|
||||
|
||||
const getTableData = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
@@ -240,8 +246,8 @@
|
||||
page_size: pagination.pageSize,
|
||||
scene: currentScene.value,
|
||||
status: searchForm.status,
|
||||
start_time: searchForm.start_time || undefined,
|
||||
end_time: searchForm.end_time || undefined
|
||||
start_time: toRfc3339(searchForm.start_time),
|
||||
end_time: toRfc3339(searchForm.end_time, true)
|
||||
})
|
||||
|
||||
if (res.code === 0 && res.data) {
|
||||
@@ -347,32 +353,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
const cancelTask = (row: ExportTaskItem) => {
|
||||
if (!hasAuth(getRowPermissions(row).cancel)) {
|
||||
ElMessage.warning('您没有取消导出任务的权限')
|
||||
return
|
||||
}
|
||||
|
||||
ElMessageBox.confirm(`确定取消导出任务 ${row.task_no} 吗?`, '取消确认', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
}).then(async () => {
|
||||
try {
|
||||
const res = await ExportTaskService.cancelExportTask(row.task_id ?? row.id)
|
||||
if (res.code === 0) {
|
||||
ElMessage.success(res.data?.message || '任务取消成功')
|
||||
getTableData()
|
||||
} else {
|
||||
ElMessage.error(res.msg || '取消导出任务失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('取消导出任务失败:', error)
|
||||
ElMessage.error('取消导出任务失败')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const getActions = (row: ExportTaskItem) => {
|
||||
const actions: any[] = []
|
||||
const permissions = getRowPermissions(row)
|
||||
@@ -381,13 +361,6 @@
|
||||
actions.push({ label: '下载', handler: () => downloadTask(row), type: 'primary' })
|
||||
}
|
||||
|
||||
if (
|
||||
[ExportTaskStatus.PENDING, ExportTaskStatus.PROCESSING].includes(row.status) &&
|
||||
hasAuth(permissions.cancel)
|
||||
) {
|
||||
actions.push({ label: '取消', handler: () => cancelTask(row), type: 'danger' })
|
||||
}
|
||||
|
||||
return actions
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 列,支持 VirtualNo、IMEI 或 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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user