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

This commit is contained in:
luo
2026-07-27 16:30:29 +08:00
parent cc6fc9243e
commit f0a2b84e53
75 changed files with 2926 additions and 534 deletions

View File

@@ -1,6 +1,6 @@
<template>
<ArtTableFullScreen>
<div class="device-task-page" id="table-full-screen">
<div v-if="isPlatformAccount" class="device-task-page" id="table-full-screen">
<!-- 搜索栏 -->
<ArtSearchBar
v-model:filter="searchForm"
@@ -10,6 +10,16 @@
@search="handleSearch"
></ArtSearchBar>
<div v-if="pollingError || pollingForbidden" class="task-polling-error">
<ElAlert
type="warning"
:closable="false"
show-icon
:title="pollingForbidden ? '当前账号无权查看该任务' : pollingError || '任务状态获取失败'"
/>
<ElButton size="small" @click="polling.retry">重试</ElButton>
</div>
<ElCard shadow="never" class="art-table-card">
<!-- 表格头部 -->
<ArtTableHeader
@@ -19,10 +29,11 @@
>
<template #left>
<ElButton
v-if="isPlatformAccount && hasAuth(JULY_PERMISSIONS.deviceAllocation.page)"
type="primary"
:icon="Upload"
@click="importDialogVisible = true"
v-permission="'device_task:bulk_import'"
v-permission="JULY_PERMISSIONS.deviceAllocation.page"
>
批量导入设备
</ElButton>
@@ -57,8 +68,8 @@
<template #title>
<div style="line-height: 1.8">
<p><strong>导入说明</strong></p>
<p>1. 请先下载 Excel 模板文件按照模板格式填写设备信息</p>
<p>2. 仅支持 Excel 格式.xlsx单次最多导入 1000 </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>
@@ -77,7 +88,23 @@
</ElButton>
</div>
<ElRow :gutter="20">
<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">
<ElCol :span="12">
<ElFormItem label="批次号" prop="batch_no">
<ElInput
@@ -108,18 +135,18 @@
</ElCol>
</ElRow>
<ElUpload
<ElUpload
ref="uploadRef"
drag
:auto-upload="false"
:on-change="handleFileChange"
:limit="1"
accept=".xlsx"
>
accept=".xlsx,.csv"
>
<el-icon class="el-icon--upload"><upload-filled /></el-icon>
<div class="el-upload__text"> Excel 文件拖到此处<em>点击选择</em></div>
<div class="el-upload__text">将文件拖到此处<em>点击选择</em></div>
<template #tip>
<div class="el-upload__tip">只能上传 .xlsx 格式的 Excel 文件且不超过 300MB</div>
<div class="el-upload__tip">导入设备支持 .xlsx批量分配支持单列 .csv</div>
</template>
</ElUpload>
@@ -140,8 +167,8 @@
<script setup lang="ts">
import { h } from 'vue'
import { useRouter } from 'vue-router'
import { ref, reactive, onMounted } 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'
@@ -149,16 +176,29 @@
import type { SearchFormItem } from '@/types'
import { useCheckedColumns } from '@/composables/useCheckedColumns'
import { useAuth } from '@/composables/useAuth'
import { useAsyncTaskPolling } from '@/composables/useAsyncTaskPolling'
import { useUserStore } from '@/store/modules/user'
import { formatDateTime } from '@/utils/business/format'
import { StorageService } from '@/api/modules/storage'
import type { DeviceImportTask, DeviceImportTaskStatus, RealnamePolicy } from '@/types/api/device'
import type {
DeviceImportTask,
DeviceImportTaskDetail,
DeviceImportTaskQueryParams,
DeviceImportTaskStatus,
RealnamePolicy
} 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' })
const router = useRouter()
const route = useRoute()
const { hasAuth } = useAuth()
const userStore = useUserStore()
const isPlatformAccount = computed(() => [1, 2].includes(Number(userStore.info.user_type)))
const loading = ref(false)
const tableRef = ref()
@@ -168,14 +208,16 @@
const importDialogVisible = ref(false)
const importForm = reactive({
batch_no: '',
realname_policy: '' as '' | 'none' | 'before_order' | 'after_order'
realname_policy: '' as '' | 'none' | 'before_order' | 'after_order',
operation_type: 'import' as 'import' | 'assign_shop' | 'assign_series',
target_id: ''
})
// 搜索表单初始值
const initialSearchState = {
status: undefined,
batch_no: '',
dateRange: undefined as any,
dateRange: undefined as string[] | undefined,
start_time: '',
end_time: ''
}
@@ -247,6 +289,23 @@
const taskList = ref<DeviceImportTask[]>([])
const polling = useAsyncTaskPolling<DeviceImportTaskDetail>({
storageKey: 'device-import-active-task',
autoRestore: false,
fetchTask: async (taskId) => {
const res = await DeviceService.getImportTaskDetail(taskId)
if (res.code !== 0 || !res.data) throw new Error(res.msg || '获取设备任务失败')
return res.data
},
isForbidden: (error) => {
const response = (error as { response?: { status?: number; data?: { code?: number } } })
?.response
return response?.status === 403 || response?.data?.code === 403
}
})
const pollingError = polling.error
const pollingForbidden = polling.forbidden
// 获取状态标签类型
const getStatusType = (status: DeviceImportTaskStatus) => {
switch (status) {
@@ -265,6 +324,7 @@
// 查看详情
const viewDetail = (row: DeviceImportTask) => {
if (!isPlatformAccount.value) return
router.push({
path: RoutesAlias.TaskDetail,
query: {
@@ -276,7 +336,7 @@
// 处理名称点击
const handleNameClick = (row: DeviceImportTask) => {
if (hasAuth('device_task:view_detail')) {
if (isPlatformAccount.value && hasAuth('device_task:view_detail')) {
viewDetail(row)
} else {
ElMessage.warning('您没有查看详情的权限')
@@ -385,15 +445,31 @@
}
])
watch(polling.task, () => {
if (isPlatformAccount.value) void getTableData()
})
onMounted(() => {
getTableData()
if (!isPlatformAccount.value) return
void getTableData()
const routeTaskId = Number(route.query.task_id)
if (routeTaskId) {
void polling.start(routeTaskId)
} else if (polling.taskId.value) {
void polling.retry()
}
})
// 获取设备任务列表
const getTableData = async () => {
if (!isPlatformAccount.value) {
taskList.value = []
pagination.total = 0
return
}
loading.value = true
try {
const params: any = {
const params: DeviceImportTaskQueryParams = {
page: pagination.page,
page_size: pagination.pageSize,
status: searchForm.status,
@@ -406,13 +482,6 @@
params.end_time = searchForm.dateRange[1]
}
// 清理空值
Object.keys(params).forEach((key) => {
if (params[key] === '' || params[key] === undefined) {
delete params[key]
}
})
const res = await DeviceService.getImportTasks(params)
if (res.code === 0) {
const taskItems = (res.data as typeof res.data & { items?: DeviceImportTask[] | null })
@@ -422,6 +491,7 @@
}
} catch (error) {
console.error(error)
ElMessage.error(normalizeApiError(error).message)
} finally {
loading.value = false
}
@@ -473,8 +543,9 @@
}
// 文件选择变化
const handleFileChange = (uploadFile: any) => {
const maxSize = 300 * 1024 * 1024
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) {
ElMessage.error('文件大小不能超过 300MB')
uploadRef.value?.clearFiles()
@@ -482,13 +553,23 @@
return
}
if (uploadFile.raw && !uploadFile.raw.name.endsWith('.xlsx')) {
ElMessage.error('只能上传 .xlsx 格式的 Excel 文件')
if (uploadFile.raw && (isCsvAllocation ? !uploadFile.raw.name.endsWith('.csv') : !uploadFile.raw.name.endsWith('.xlsx'))) {
ElMessage.error(isCsvAllocation ? '批量分配只能上传 .csv 文件' : '设备导入只能上传 .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] : []
}
@@ -510,24 +591,33 @@
clearFiles()
importForm.batch_no = ''
importForm.realname_policy = ''
importForm.operation_type = 'import'
importForm.target_id = ''
importDialogVisible.value = false
}
// 提交上传
const submitUpload = async () => {
if (!fileList.value.length) {
ElMessage.warning('请先选择 Excel 文件')
return
}
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
}
const file = fileList.value[0]
uploading.value = true
try {
ElMessage.info('正在准备上传...')
const uploadUrlRes = await StorageService.getUploadUrl({
file_name: file.name,
content_type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
purpose: 'iot_import'
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'
})
if (uploadUrlRes.code !== 0) {
@@ -538,18 +628,20 @@
const { upload_url, file_key } = uploadUrlRes.data
ElMessage.info('正在上传文件...')
await StorageService.uploadFile(
upload_url,
file,
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
)
await StorageService.uploadFile(upload_url, file, contentType)
ElMessage.info('正在创建导入任务...')
const importRes = await DeviceService.importDevices({
file_key,
batch_no: importForm.batch_no || undefined,
realname_policy: (importForm.realname_policy || undefined) as RealnamePolicy | undefined
})
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
})
if (importRes.code !== 0) {
ElMessage.error(importRes.msg || '创建导入任务失败')
@@ -557,8 +649,11 @@
}
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({
@@ -568,7 +663,7 @@
})
} catch (error: any) {
console.error('设备导入失败:', error)
console.log(error.message || '设备导入失败')
ElMessage.error(normalizeApiError(error).message || error.message || '设备导入失败')
} finally {
uploading.value = false
}
@@ -576,6 +671,7 @@
// 从行数据下载失败数据
const downloadFailDataByRow = async (row: DeviceImportTask) => {
if (!isPlatformAccount.value) return
try {
const res = await DeviceService.getImportTaskDetail(row.id)
if (res.code === 0 && res.data) {
@@ -622,6 +718,7 @@
}
const downloadTaskFile = async (row: DeviceImportTask) => {
if (!isPlatformAccount.value) return
const fileKey = row.file_name?.trim()
if (!fileKey) {
ElMessage.warning('当前任务没有可下载的原始文件')
@@ -639,6 +736,7 @@
// 获取操作按钮
const getActions = (row: DeviceImportTask) => {
if (!isPlatformAccount.value) return []
const actions: any[] = []
const showDownloadFileAction = false
@@ -664,6 +762,13 @@
<style lang="scss" scoped>
.device-task-page {
.task-polling-error {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
}
:deep(.el-icon--upload) {
margin-bottom: 16px;
font-size: 67px;

View File

@@ -59,11 +59,14 @@
import type { DeviceImportTaskDetail } 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'
defineOptions({ name: 'TaskDetail' })
const router = useRouter()
const route = useRoute()
const userStore = useUserStore()
const isPlatformAccount = computed(() => [1, 2].includes(Number(userStore.info.user_type)))
type TaskType = 'card' | 'device'
type TaskDetail = IotCardImportTaskDetail | DeviceImportTaskDetail
@@ -244,6 +247,12 @@
taskType.value = queryTaskType
}
if (taskType.value === 'device' && !isPlatformAccount.value) {
ElMessage.error('当前账号无权查看设备任务详情')
goBack()
return
}
loading.value = true
try {
if (taskType.value === 'device') {