feat: 新增导出
All checks were successful
构建并部署前端到测试环境 / build-and-deploy (push) Successful in 5m19s

This commit is contained in:
luo
2026-06-16 11:35:45 +08:00
parent 908367ba5d
commit 2a8f4e40d6
19 changed files with 1347 additions and 0 deletions

View File

@@ -0,0 +1,73 @@
## Context
`docs/api.md` 定义了统一导出任务接口,支持 `device``iot_card` 两个场景,导出格式支持 `xlsx` / `csv`。前端需要提供两个业务列表的导出入口,以及一个可复用的导出任务管理视角,用于查看任务进度、查看详情、下载结果和取消任务。
现有项目使用 Vue 3 + TypeScript + Element PlusAPI 按业务模块放在 `src/api/modules`,类型放在 `src/types/api`,按钮权限通过 `useAuth``v-permission` 接入。
## Goals / Non-Goals
- Goals: 提供统一导出任务 API 封装、可复用创建弹窗、资产管理下导出列表页面、IoT 卡和设备列表导出入口、按钮权限控制。
- Goals: 创建导出任务时传入当前列表检索条件快照,避免用户修改筛选条件后影响已经提交的任务。
- Non-Goals: 不实现后端导出任务调度、文件生成、对象存储上传或 JWT 鉴权逻辑。
- Non-Goals: 不替换现有导入任务页面,也不将导出任务与导入任务合并成同一个页面。
- Non-Goals: 不在前端根据 `file_key` 拼接下载地址,下载必须使用详情接口返回的 `download_url`
## Decisions
- Decision: 新增独立能力 `export-task-management`,不复用导入任务能力命名。
- Rationale: 导出任务接口、状态流转、下载逻辑和业务入口与导入任务不同,独立能力更容易扩展到更多导出场景。
- Decision: 创建导出任务弹窗做成业务无关组件,只通过 props 接收 `scene``query``format`、标题和说明文案。
- Rationale: IoT 卡管理和设备管理只需传入不同场景与当前筛选条件,后续其他业务列表可以复用同一组件。
- Decision: 导出列表页面做成可按 `scene` 筛选的管理视角,而不是为 `device``iot_card` 各建一套页面。
- Rationale: `GET /api/admin/export-tasks` 已提供 `scene` 过滤,统一列表能减少重复代码,也支持未来新增默认场景视角。
- Decision: 下载操作先调用 `GET /api/admin/export-tasks/{id}`,再使用详情中的 `download_url`
- Rationale: 文档说明下载地址仅在已完成任务详情中返回且有效期 24 小时,列表中的 `file_key` 不应被前端直接用于下载。
- Decision: 权限编码按业务入口隔离。
- Rationale: 创建任务属于来源业务列表权限,导出任务列表中的详情、下载、取消属于导出任务管理权限,避免用户拥有某个列表导出权限后自动获得管理全部导出任务的权限。
## Data Model
- `scene`: `device` / `iot_card`
- `format`: `xlsx` / `csv`
- `status`: `1` 待处理、`2` 处理中、`3` 已完成、`4` 失败、`5` 已取消。
- `query`: 任意对象,保存发起导出时的业务列表筛选条件快照。
## Permission Codes
- `iot_card:export`: IoT 卡管理页导出按钮。
- `devices:export`: 设备管理页导出按钮。
- `export_task:list`: 导出列表页面访问和查询入口。
- `export_task:detail`: 导出列表行详情操作。
- `export_task:download`: 导出列表行下载操作。
- `export_task:cancel`: 导出列表行取消操作。
最终权限编码以产品/后端权限配置为准;如需调整,应在实施前同步更新本提案与任务。
## Risks / Trade-offs
- Risk: 业务列表筛选字段与后端导出任务 `query` 支持字段不一致。
- Mitigation: 复用列表请求参数构造逻辑,并在提测时分别验证 `iot_card``device` 场景。
- Risk: 下载地址有效期为 24 小时,用户在旧页面点击下载时可能失效。
- Mitigation: 每次下载前重新调用详情接口获取最新可用的 `download_url`
- Risk: 取消处理中任务时后端可能只是标记 `cancel_requested`,不是立即变成已取消。
- Mitigation: 取消成功后刷新列表并展示后端返回的状态和提示信息。
## Migration Plan
1. 增加 API、类型、路由、菜单和组件。
2. 在 IoT 卡管理和设备管理接入导出弹窗。
3. 新增导出列表页面并接入详情、下载、取消操作。
4. 接入权限编码并验证不同权限组合。
5. 如后端权限菜单需要同步,按上述权限编码补齐配置。
## Open Questions
- 导出格式是否需要在第一版开放给用户选择,还是固定 `xlsx`
- 权限编码是否采用本设计建议,还是需对齐后端已有权限命名。
- 导出列表是否需要默认按当前入口过滤场景,还是资产管理下统一列表默认展示全部场景。

View File

@@ -0,0 +1,44 @@
# Change: 新增统一导出任务管理
## Why
资产管理中的 IoT 卡与设备列表需要按当前检索条件执行全量导出,直接在前端同步导出容易超时且无法追踪进度。`docs/api.md` 已定义统一导出任务接口,需要在后台管理端补齐创建导出任务、查看任务列表、查看详情、下载结果和取消任务的前端能力。
同时,导出入口会覆盖多个业务列表和后续可扩展的导出视角,必须做成可复用的弹窗与任务管理能力,并按现有 RBAC 体系为所有按钮和操作补充权限编码。
## What Changes
- 在资产管理下新增“导出管理”,并在其下新增“导出列表”页面。
- 新增统一导出任务前端 API 与类型契约,对接:
- `GET /api/admin/export-tasks`
- `POST /api/admin/export-tasks`
- `GET /api/admin/export-tasks/{id}`
- `POST /api/admin/export-tasks/{id}/cancel`
- 新增可复用的创建导出任务弹窗,打开时说明“导出规则基于列表中的检索条件全量导出”。
- 在 IoT 卡管理页增加导出按钮,默认创建 `scene=iot_card` 的导出任务。
- 在设备管理页增加导出按钮,默认创建 `scene=device` 的导出任务。
- 导出列表支持分页、场景、状态、创建时间范围筛选,列表数据调用导出任务列表接口,详情/下载调用详情接口,取消操作调用取消接口。
- 所有新增按钮和行操作均需要接入权限编码,不允许无权限用户看到或触发对应操作。
## Impact
- Affected specs:
- `export-task-management`
- Affected code:
- `src/api/modules/exportTask.ts`
- `src/types/api/exportTask.ts`
- `src/types/api/index.ts`
- `src/api/modules/index.ts`
- `src/router/routesAlias.ts`
- `src/router/routes/asyncRoutes.ts`
- `src/views/asset-management/iot-card-management/index.vue`
- `src/views/asset-management/device-list/index.vue`
- `src/views/asset-management/export-task-management/export-task-list/index.vue`
- `src/components/business/ExportTaskCreateDialog.vue`
- 菜单与国际化文案配置文件
- Dependencies:
- 依赖后端按 `docs/api.md` 提供导出任务接口和 JWT 鉴权。
- 依赖现有 `useAuth` / `v-permission` 权限基础设施。
- 导出创建时传入的 `query` 需要与对应业务列表接口的检索参数保持一致。
- Breaking changes:
- 无。新增页面、按钮、API 模块和类型均为增量能力。

View File

@@ -0,0 +1,146 @@
## ADDED Requirements
### Requirement: Export Task API Integration
The admin frontend SHALL provide a typed API integration for unified export tasks using the REST contract from `docs/api.md`.
#### Scenario: Query export tasks with filters
- **GIVEN** 用户打开导出列表页面
- **WHEN** 用户按分页、场景、状态或创建时间范围查询导出任务
- **THEN** 系统 MUST call `GET /api/admin/export-tasks`
- **AND** 系统 MUST support optional query parameters `page`, `page_size`, `scene`, `status`, `start_time`, and `end_time`
- **AND** 系统 MUST parse `data.page`, `data.size`, `data.total`, and `data.items` from the response
#### Scenario: Create export task
- **GIVEN** 用户在业务列表中确认创建导出任务
- **WHEN** 系统提交导出任务
- **THEN** 系统 MUST call `POST /api/admin/export-tasks`
- **AND** 请求体 MUST include required `scene` and `format`
- **AND** 请求体 MAY include `query` containing the current list filter conditions
- **AND** 系统 MUST handle the response fields `task_id`, `task_no`, `status`, `status_name`, and `message`
#### Scenario: Fetch export task detail
- **GIVEN** 用户需要查看或下载某个导出任务
- **WHEN** 系统读取任务详情
- **THEN** 系统 MUST call `GET /api/admin/export-tasks/{id}` with the numeric task id
- **AND** 系统 MUST support detail fields including progress, row counts, shard counts, `file_key`, `error_message`, timestamps, `download_url`, and `download_expires_at`
- **AND** 系统 MUST treat `download_url` as available only when the task is completed and the backend returns it
#### Scenario: Cancel export task
- **GIVEN** 用户取消待处理或处理中的导出任务
- **WHEN** 系统提交取消请求
- **THEN** 系统 MUST call `POST /api/admin/export-tasks/{id}/cancel`
- **AND** 系统 MUST handle `task_id`, `status`, `status_name`, `cancel_requested`, and `message` from the response
### Requirement: Reusable Export Task Creation Dialog
The admin frontend SHALL provide a reusable dialog for creating export tasks from business list pages.
#### Scenario: Explain full export rule before task creation
- **GIVEN** 用户点击业务列表中的导出按钮
- **WHEN** 导出弹窗打开
- **THEN** 弹窗 MUST clearly state that export rules are based on the current list search conditions and export the full matched result set
- **AND** 弹窗 MUST NOT imply that only the current page rows will be exported
#### Scenario: Create IoT card export task from IoT card management
- **GIVEN** 用户在 IoT 卡管理页设置了列表检索条件
- **AND** 当前用户具备 IoT 卡导出权限
- **WHEN** 用户确认创建导出任务
- **THEN** 系统 MUST submit `scene="iot_card"`
- **AND** 系统 MUST submit the current IoT card list filter conditions as `query`
#### Scenario: Create device export task from device management
- **GIVEN** 用户在设备管理页设置了列表检索条件
- **AND** 当前用户具备设备导出权限
- **WHEN** 用户确认创建导出任务
- **THEN** 系统 MUST submit `scene="device"`
- **AND** 系统 MUST submit the current device list filter conditions as `query`
#### Scenario: Snapshot current filters at submission time
- **GIVEN** 用户修改业务列表筛选条件并重新搜索
- **WHEN** 用户随后打开弹窗并确认导出
- **THEN** 系统 MUST use the latest applied search conditions in the export task `query`
- **AND** 已提交任务的 `query` MUST NOT change when the user later edits the page filters
### Requirement: Asset Export Task List Page
The admin frontend SHALL add an export task list under Asset Management > Export Management for viewing and operating export tasks.
#### Scenario: Render export management navigation
- **GIVEN** 当前用户具备导出任务列表访问权限
- **WHEN** 系统渲染资产管理菜单
- **THEN** 系统 MUST provide an Export Management group under Asset Management
- **AND** 系统 MUST provide an Export List entry under that group
- **AND** 导出列表路由 MUST load the export task list page
#### Scenario: Display export task rows
- **GIVEN** 导出任务列表接口返回任务记录
- **WHEN** 页面渲染表格
- **THEN** 页面 MUST display task number, scene, status name, progress, format, total rows, processed rows, shard counts, file key, error message, and task timestamps when present
- **AND** 页面 MUST use stable empty placeholders for missing optional fields
#### Scenario: View task detail from row action
- **GIVEN** 用户具备导出任务详情权限
- **WHEN** 用户点击某条任务的详情操作
- **THEN** 系统 MUST call the task detail API for that row
- **AND** 页面 MUST present the returned task detail without losing the current list filters and pagination state
#### Scenario: Download completed task from row action
- **GIVEN** 某条导出任务状态为已完成
- **AND** 当前用户具备导出任务下载权限
- **WHEN** 用户点击下载操作
- **THEN** 系统 MUST call the task detail API to obtain `download_url`
- **AND** 系统 MUST open or download the file using the returned `download_url`
- **AND** 系统 MUST NOT construct a download URL from `file_key` on the frontend
#### Scenario: Cancel cancellable task from row action
- **GIVEN** 某条导出任务状态为待处理或处理中
- **AND** 当前用户具备导出任务取消权限
- **WHEN** 用户确认取消该任务
- **THEN** 系统 MUST call the cancel API for that task
- **AND** 系统 MUST refresh the list or update the row using the backend response
#### Scenario: Hide unavailable row actions
- **GIVEN** 某条导出任务状态为已完成、失败或已取消
- **WHEN** 页面渲染该行操作
- **THEN** 页面 MUST NOT show the cancel action for that row
- **AND** 页面 MUST show the download action only when the task is completed and the user has download permission
### Requirement: Export Task Permissions
The admin frontend MUST gate every export-task-related button and row action with explicit permission codes.
#### Scenario: Hide business export buttons without permission
- **GIVEN** 当前用户 lacks the IoT card export permission
- **WHEN** 用户打开 IoT 卡管理页
- **THEN** 页面 MUST NOT display the IoT card export button
- **AND** 缺少设备导出权限时,设备管理页 MUST NOT display the device export button
#### Scenario: Hide export task management operations without permission
- **GIVEN** 当前用户可以访问导出列表但 lacks download or cancel permissions
- **WHEN** 页面渲染导出任务行操作
- **THEN** 页面 MUST NOT display the download action without download permission
- **AND** 页面 MUST NOT display the cancel action without cancel permission
#### Scenario: Keep permissions isolated by entry point
- **GIVEN** 当前用户具备 `iot_card` 导出权限但不具备 `device` 导出权限
- **WHEN** 用户分别访问 IoT 卡管理页和设备管理页
- **THEN** IoT 卡管理页 MAY display its export button
- **AND** 设备管理页 MUST NOT display its export button because of the IoT card permission

View File

@@ -0,0 +1,51 @@
## 1. Proposal Review
- [x] 1.1 确认导出按钮权限编码IoT 卡管理使用 `iot_card:export`,设备管理使用 `devices:export`
- [x] 1.2 确认导出任务管理权限编码:使用 `export_task:list``export_task:detail``export_task:download``export_task:cancel`
- [x] 1.3 确认导出格式默认 `xlsx`,并允许用户在弹窗中切换 `xlsx` / `csv`
- [x] 1.4 确认 IoT 卡和设备列表现有筛选参数作为导出任务 `query` 传给后端。
## 2. API And Types
- [x] 2.1 新增导出任务状态、场景、格式、列表项、详情、创建请求、取消响应等 TypeScript 类型。
- [x] 2.2 新增 `ExportTaskService`,实现列表、创建、详情、取消接口。
- [x] 2.3 在 API 和类型入口文件导出新增模块。
- [x] 2.4 对齐统一响应结构 `code``msg``timestamp``data` 和分页结构 `page``size``total``items`
## 3. Reusable Export Dialog
- [x] 3.1 新增可复用创建导出任务弹窗组件,支持传入 `scene`、当前列表查询条件和默认导出格式。
- [x] 3.2 弹窗中明确展示导出规则说明:导出规则基于列表中的检索条件全量导出。
- [x] 3.3 提交时调用创建导出任务接口,并在成功后提示任务已创建。
- [x] 3.4 确保弹窗确认按钮和触发按钮均受对应权限控制。
## 4. Business Entry Points
- [x] 4.1 在 IoT 卡管理页增加导出按钮,打开弹窗时默认 `scene=iot_card`,并传入当前筛选条件快照。
- [x] 4.2 在设备管理页增加导出按钮,打开弹窗时默认 `scene=device`,并传入当前筛选条件快照。
- [x] 4.3 确保重置筛选、分页切换、重新搜索后导出使用最新检索条件。
## 5. Export Task List Page
- [x] 5.1 在资产管理下新增“导出管理”分组和“导出列表”路由、菜单、路由别名。
- [x] 5.2 导出列表支持分页、场景、状态、创建开始时间、创建结束时间筛选。
- [x] 5.3 表格展示任务编号、场景、状态、进度、格式、行数、分片、错误信息和时间字段;列表不展示文件 key。
- [x] 5.4 任务编号支持点击进入导出任务详情页,行操作支持下载已完成任务、取消待处理/处理中任务。
- [x] 5.5 下载操作必须先调用详情接口获取 `download_url`,且仅在已完成任务可用。
- [x] 5.6 取消操作必须调用取消接口,并在成功后刷新列表。
## 6. Permissions And UX
- [x] 6.1 为所有新增按钮和行操作添加权限编码检查。
- [x] 6.2 无权限时不显示对应入口,不依赖禁用态作为唯一保护。
- [x] 6.3 对创建、详情、下载、取消失败提供稳定错误提示,不破坏列表渲染。
## 7. Verification
- [x] 7.1 验证 IoT 卡管理页可按当前筛选条件创建 `iot_card` 导出任务。
- [x] 7.2 验证设备管理页可按当前筛选条件创建 `device` 导出任务。
- [x] 7.3 验证导出列表分页和筛选参数与接口契约一致,且文件 key 不在列表字段中展示。
- [x] 7.4 验证点击任务编号进入详情页,已完成任务可通过详情接口获取下载地址并下载。
- [x] 7.5 验证待处理/处理中任务可取消,已完成/失败/已取消任务不可取消。
- [x] 7.6 验证所有新增按钮和操作在无权限时不可见。
- [x] 7.7 运行类型检查和相关 lint新增文件 lint 通过,整仓构建受既有 TypeScript 债务阻塞。

12
pnpm-workspace.yaml Normal file
View File

@@ -0,0 +1,12 @@
allowBuilds:
'@parcel/watcher': true
core-js: true
cwebp-bin: true
es5-ext: true
esbuild: true
gifsicle: true
jpegtran-bin: true
mozjpeg: true
optipng-bin: true
pngquant-bin: true
vue-demi: true

View File

@@ -0,0 +1,28 @@
import { BaseService } from '../BaseService'
import type {
CancelExportTaskApiResponse,
CreateExportTaskApiResponse,
CreateExportTaskRequest,
ExportTaskDetail,
ExportTaskDetailApiResponse,
ExportTaskListApiResponse,
ExportTaskQueryParams
} from '@/types/api'
export class ExportTaskService extends BaseService {
static getExportTasks(params?: ExportTaskQueryParams): Promise<ExportTaskListApiResponse> {
return this.get<ExportTaskListApiResponse>('/api/admin/export-tasks', params)
}
static createExportTask(data: CreateExportTaskRequest): Promise<CreateExportTaskApiResponse> {
return this.post<CreateExportTaskApiResponse>('/api/admin/export-tasks', data)
}
static getExportTaskDetail(id: number): Promise<ExportTaskDetailApiResponse> {
return this.getOne<ExportTaskDetail>(`/api/admin/export-tasks/${id}`)
}
static cancelExportTask(id: number): Promise<CancelExportTaskApiResponse> {
return this.post<CancelExportTaskApiResponse>(`/api/admin/export-tasks/${id}/cancel`, {})
}
}

View File

@@ -31,6 +31,7 @@ export { PollingConfigService } from './pollingConfig'
export { ManualTriggerService } from './manualTrigger'
export { PollingMonitorService } from './pollingMonitor'
export { SuperAdminService } from './superAdmin'
export { ExportTaskService } from './exportTask'
// TODO: 按需添加其他业务模块
// export { SettingService } from './setting'

View File

@@ -0,0 +1,144 @@
<template>
<ElDialog v-model="visible" :title="title" width="520px" destroy-on-close>
<ElAlert type="info" :closable="false" show-icon class="export-rule-alert">
<template #title>{{ description }}</template>
</ElAlert>
<ElForm label-width="100px" class="export-task-form">
<ElFormItem label="导出场景">
<ElTag>{{ sceneName }}</ElTag>
</ElFormItem>
<ElFormItem label="导出格式">
<ElRadioGroup v-model="form.format">
<ElRadio label="xlsx">XLSX</ElRadio>
<ElRadio label="csv">CSV</ElRadio>
</ElRadioGroup>
</ElFormItem>
</ElForm>
<template #footer>
<div class="dialog-footer">
<ElButton @click="visible = false">取消</ElButton>
<ElButton
v-if="!confirmPermission || hasAuth(confirmPermission)"
type="primary"
:loading="submitting"
@click="submit"
>
创建导出任务
</ElButton>
</div>
</template>
</ElDialog>
</template>
<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { ExportTaskService } from '@/api/modules'
import { useAuth } from '@/composables/useAuth'
import type { CreateExportTaskResponse, ExportTaskFormat, ExportTaskScene } from '@/types/api'
const props = withDefaults(
defineProps<{
modelValue: boolean
scene: ExportTaskScene
query?: Record<string, unknown>
defaultFormat?: ExportTaskFormat
title?: string
description?: string
confirmPermission?: string
}>(),
{
query: () => ({}),
defaultFormat: 'xlsx',
title: '创建导出任务',
description: '导出规则基于列表中的检索条件全量导出,不仅导出当前分页数据。'
}
)
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void
(e: 'success', value: CreateExportTaskResponse): void
}>()
const { hasAuth } = useAuth()
const submitting = ref(false)
const form = reactive({
format: props.defaultFormat
})
const visible = computed({
get: () => props.modelValue,
set: (value: boolean) => emit('update:modelValue', value)
})
const sceneName = computed(() => {
const sceneMap: Record<ExportTaskScene, string> = {
device: '设备管理',
iot_card: 'IoT卡管理'
}
return sceneMap[props.scene]
})
watch(
() => props.modelValue,
(opened) => {
if (opened) {
form.format = props.defaultFormat
}
}
)
const getNormalizedQuery = () => {
const query: Record<string, unknown> = {}
Object.entries(props.query || {}).forEach(([key, value]) => {
if (value === undefined || value === null || value === '') return
if (Array.isArray(value) && value.length === 0) return
query[key] = value
})
return query
}
const submit = async () => {
if (props.confirmPermission && !hasAuth(props.confirmPermission)) {
ElMessage.warning('您没有创建导出任务的权限')
return
}
submitting.value = true
try {
const res = await ExportTaskService.createExportTask({
scene: props.scene,
format: form.format,
query: getNormalizedQuery()
})
if (res.code === 0) {
ElMessage.success(res.data?.message || '导出任务已创建')
emit('success', res.data)
visible.value = false
} else {
ElMessage.error(res.msg || '创建导出任务失败')
}
} catch (error) {
console.error('创建导出任务失败:', error)
ElMessage.error('创建导出任务失败')
} finally {
submitting.value = false
}
}
</script>
<style scoped lang="scss">
.export-rule-alert {
margin-bottom: 18px;
}
.export-task-form {
padding-top: 4px;
}
</style>

View File

@@ -445,6 +445,9 @@
"enterpriseDevices": "Enterprise Devices",
"recordsManagement": "Records Management",
"taskManagement": "Task Management",
"exportTaskManagement": "Export Management",
"exportTaskList": "Export List",
"exportTaskDetail": "Export Task Detail",
"exchangeManagement": "Exchange Management",
"exchangeDetail": "Exchange Order Detail"
},

View File

@@ -379,6 +379,9 @@
"enterpriseDevices": "企业设备列表",
"recordsManagement": "记录管理",
"taskManagement": "任务管理",
"exportTaskManagement": "导出管理",
"exportTaskList": "导出列表",
"exportTaskDetail": "导出任务详情",
"exchangeManagement": "换货管理",
"exchangeDetail": "换货单详情"
},

View File

@@ -440,6 +440,39 @@ export const asyncRoutes: AppRouteRecord[] = [
isHide: true,
keepAlive: false
}
},
// 导出管理
{
path: 'export-task-management',
name: 'ExportTaskManagement',
component: '',
meta: {
title: 'menus.assetManagement.exportTaskManagement',
keepAlive: true
},
children: [
{
path: 'export-task-list',
name: 'ExportTaskList',
component: RoutesAlias.ExportTaskList,
meta: {
title: 'menus.assetManagement.exportTaskList',
permissions: ['export_task:list'],
keepAlive: true
}
},
{
path: 'export-task-detail',
name: 'ExportTaskDetail',
component: RoutesAlias.ExportTaskDetail,
meta: {
title: 'menus.assetManagement.exportTaskDetail',
permissions: ['export_task:detail'],
isHide: true,
keepAlive: false
}
}
]
}
]
},

View File

@@ -60,6 +60,8 @@ export enum RoutesAlias {
IotCardTask = '/asset-management/task-management/iot-card-task', // IoT卡任务
DeviceTask = '/asset-management/task-management/device-task', // 设备任务
TaskDetail = '/asset-management/task-management/task-detail', // 任务详情IoT卡/设备任务详情)
ExportTaskList = '/asset-management/export-task-management/export-task-list', // 导出列表
ExportTaskDetail = '/asset-management/export-task-management/export-task-detail', // 导出任务详情
// 订单管理
OrderList = '/order-management/order-list', // 订单列表

View File

@@ -0,0 +1,80 @@
import type { BaseResponse, PaginationParams } from './common'
export type ExportTaskScene = 'device' | 'iot_card'
export type ExportTaskFormat = 'xlsx' | 'csv'
export enum ExportTaskStatus {
PENDING = 1,
PROCESSING = 2,
COMPLETED = 3,
FAILED = 4,
CANCELED = 5
}
export interface ExportTaskQueryParams extends PaginationParams {
scene?: ExportTaskScene
status?: ExportTaskStatus
start_time?: string
end_time?: string
}
export interface ExportTaskItem {
id: number
task_no: string
scene: ExportTaskScene
status: ExportTaskStatus
status_name: string
progress: number
format: ExportTaskFormat
total_rows: number
processed_rows: number
total_shards: number
success_shards: number
failed_shards: number
file_key: string
error_message: string
cancel_requested: boolean
created_at: string
started_at: string
completed_at: string
}
export interface ExportTaskListResponse {
page: number
size: number
total: number
items: ExportTaskItem[]
}
export interface CreateExportTaskRequest {
scene: ExportTaskScene
format: ExportTaskFormat
query?: Record<string, unknown>
}
export interface CreateExportTaskResponse {
task_id: number
task_no: string
status: ExportTaskStatus
status_name: string
message: string
}
export interface ExportTaskDetail extends ExportTaskItem {
download_url?: string
download_expires_at?: string
}
export interface CancelExportTaskResponse {
task_id: number
status: ExportTaskStatus
status_name: string
cancel_requested: boolean
message: string
}
export type ExportTaskListApiResponse = BaseResponse<ExportTaskListResponse>
export type CreateExportTaskApiResponse = BaseResponse<CreateExportTaskResponse>
export type ExportTaskDetailApiResponse = BaseResponse<ExportTaskDetail>
export type CancelExportTaskApiResponse = BaseResponse<CancelExportTaskResponse>

View File

@@ -97,3 +97,6 @@ export * from './manualTrigger'
// 轮询监控相关
export * from './pollingMonitor'
// 导出任务相关
export * from './exportTask'

View File

@@ -107,6 +107,7 @@ declare module 'vue' {
ElIcon: typeof import('element-plus/es')['ElIcon']
ElInput: typeof import('element-plus/es')['ElInput']
ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
ElLink: typeof import('element-plus/es')['ElLink']
ElMenu: typeof import('element-plus/es')['ElMenu']
ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
ElOption: typeof import('element-plus/es')['ElOption']
@@ -132,6 +133,7 @@ declare module 'vue' {
ElTreeSelect: typeof import('element-plus/es')['ElTreeSelect']
ElUpload: typeof import('element-plus/es')['ElUpload']
ElWatermark: typeof import('element-plus/es')['ElWatermark']
ExportTaskCreateDialog: typeof import('./../components/business/ExportTaskCreateDialog.vue')['default']
HorizontalSubmenu: typeof import('./../components/core/layouts/art-menus/art-horizontal-menu/widget/HorizontalSubmenu.vue')['default']
ImportDialog: typeof import('./../components/business/ImportDialog.vue')['default']
LoginLeftView: typeof import('./../components/core/views/login/LoginLeftView.vue')['default']

View File

@@ -41,6 +41,9 @@
>
批量设置套餐系列
</ElButton>
<ElButton type="primary" @click="showExportDialog" v-permission="'devices:export'">
导出
</ElButton>
</template>
</ArtTableHeader>
@@ -674,6 +677,13 @@
:identifier="operationLogsIdentifier"
download-permission="device:download_log_file"
/>
<ExportTaskCreateDialog
v-model="exportDialogVisible"
scene="device"
:query="exportQuery"
confirm-permission="devices:export"
title="导出设备"
/>
</ElCard>
</div>
</ArtTableFullScreen>
@@ -712,6 +722,7 @@
import type { PackageSeriesResponse } from '@/types/api'
import RealnamePolicyDialog from '@/components/device/RealnamePolicyDialog.vue'
import OperationLogsDialog from '@/components/business/OperationLogsDialog.vue'
import ExportTaskCreateDialog from '@/components/business/ExportTaskCreateDialog.vue'
defineOptions({ name: 'DeviceList' })
@@ -740,6 +751,7 @@
const selectedDevices = ref<Device[]>([])
const operationLogsDialogVisible = ref(false)
const operationLogsIdentifier = ref('')
const exportDialogVisible = ref(false)
const shopCascadeOptions = ref<any[]>([])
const shopCascadeProps = {
lazy: true,
@@ -1714,6 +1726,36 @@
}
}
const exportQuery = computed(() => {
const query: Record<string, unknown> = {
virtual_no: searchForm.virtual_no || undefined,
imei: searchForm.imei || undefined,
device_name: searchForm.device_name || undefined,
status: searchForm.status,
activation_status: searchForm.activation_status ?? undefined,
batch_no: searchForm.batch_no || undefined,
device_type: searchForm.device_type || undefined,
manufacturer: searchForm.manufacturer || undefined,
shop_id: searchForm.shop_id || undefined,
series_id: searchForm.series_id || undefined,
has_active_package: searchForm.has_active_package ?? undefined,
created_at_start: searchForm.created_at_start || undefined,
created_at_end: searchForm.created_at_end || undefined
}
Object.keys(query).forEach((key) => {
if (query[key] === undefined || query[key] === null || query[key] === '') {
delete query[key]
}
})
return query
})
const showExportDialog = () => {
exportDialogVisible.value = true
}
// 重置搜索
const handleReset = () => {
Object.assign(searchForm, { ...initialSearchState })

View File

@@ -0,0 +1,295 @@
<template>
<ArtTableFullScreen>
<div class="export-task-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>
<div class="detail-actions" v-if="taskDetail">
<ElButton
v-if="
taskDetail.status === ExportTaskStatus.COMPLETED && hasAuth('export_task:download')
"
type="primary"
@click="downloadTask"
>
下载文件
</ElButton>
<ElButton
v-if="canCancelTask && hasAuth('export_task:cancel')"
type="danger"
@click="cancelTask"
>
取消任务
</ElButton>
</div>
</div>
<div v-if="loading" class="detail-loading">
<ElIcon class="is-loading" :size="36">
<Loading />
</ElIcon>
<div>加载中...</div>
</div>
<DetailPage v-else-if="taskDetail" :sections="detailSections" :data="taskDetail" />
<ElEmpty v-else description="暂无详情" />
</ElCard>
</div>
</ArtTableFullScreen>
</template>
<script setup lang="ts">
import { computed, h, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElButton, ElCard, ElEmpty, ElIcon, ElMessage, ElMessageBox, 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'
import { ExportTaskService } from '@/api/modules'
import { useAuth } from '@/composables/useAuth'
import { formatDateTime } from '@/utils/business/format'
import type { ExportTaskDetail, ExportTaskScene } from '@/types/api'
import { ExportTaskStatus } from '@/types/api'
defineOptions({ name: 'ExportTaskDetail' })
const route = useRoute()
const router = useRouter()
const { hasAuth } = useAuth()
const loading = ref(false)
const taskDetail = ref<ExportTaskDetail | null>(null)
const getSceneName = (scene?: ExportTaskScene) => {
const sceneMap: Record<ExportTaskScene, string> = {
device: '设备管理',
iot_card: 'IoT卡管理'
}
return scene ? sceneMap[scene] : '-'
}
const getStatusTagType = (status?: ExportTaskStatus) => {
const statusTypeMap: Record<
ExportTaskStatus,
'primary' | 'success' | 'warning' | 'danger' | 'info'
> = {
[ExportTaskStatus.PENDING]: 'info',
[ExportTaskStatus.PROCESSING]: 'warning',
[ExportTaskStatus.COMPLETED]: 'success',
[ExportTaskStatus.FAILED]: 'danger',
[ExportTaskStatus.CANCELED]: 'info'
}
return status ? statusTypeMap[status] : 'info'
}
const canCancelTask = computed(() =>
[ExportTaskStatus.PENDING, ExportTaskStatus.PROCESSING].includes(
taskDetail.value?.status as ExportTaskStatus
)
)
const detailSections = computed((): DetailSection[] => [
{
title: '任务基本信息',
fields: [
{ label: '任务编号', prop: 'task_no', formatter: (value: string) => value || '-' },
{
label: '导出场景',
render: (data: ExportTaskDetail) => h(ElTag, {}, () => getSceneName(data.scene))
},
{
label: '任务状态',
render: (data: ExportTaskDetail) =>
h(ElTag, { type: getStatusTagType(data.status) }, () => data.status_name || '-')
},
{ label: '导出格式', prop: 'format', formatter: (value: string) => value || '-' },
{
label: '取消请求',
formatter: (_value: unknown, data: ExportTaskDetail) =>
data.cancel_requested ? '是' : '否'
},
{
label: '创建时间',
prop: 'created_at',
formatter: (value: string) => formatDateTime(value) || '-'
},
{
label: '开始时间',
prop: 'started_at',
formatter: (value: string) => formatDateTime(value) || '-'
},
{
label: '完成时间',
prop: 'completed_at',
formatter: (value: string) => formatDateTime(value) || '-'
},
{
label: '错误信息',
prop: 'error_message',
fullWidth: true,
formatter: (value: string) => value || '-'
}
]
},
{
title: '处理进度',
fields: [
{ label: '进度', prop: 'progress', formatter: (value: number) => `${value ?? 0}%` },
{ label: '总行数', prop: 'total_rows', formatter: (value: number) => String(value ?? 0) },
{
label: '已处理行数',
prop: 'processed_rows',
formatter: (value: number) => String(value ?? 0)
},
{
label: '分片进度',
formatter: (_value: unknown, data: ExportTaskDetail) =>
`${data.success_shards ?? 0}/${data.total_shards ?? 0}`
},
{
label: '失败分片',
prop: 'failed_shards',
formatter: (value: number) => String(value ?? 0)
}
]
},
{
title: '文件信息',
fields: [
{
label: '文件Key',
prop: 'file_key',
fullWidth: true,
formatter: (value: string) => value || '-'
},
{
label: '下载地址',
fullWidth: true,
render: (data: ExportTaskDetail) => {
if (!data.download_url) return h('span', '-')
return h(
ElButton,
{
type: 'primary',
link: true,
onClick: () => window.open(data.download_url, '_blank')
},
() => '打开下载地址'
)
}
},
{
label: '下载过期时间',
prop: 'download_expires_at',
formatter: (value: string) => formatDateTime(value) || '-'
}
]
}
])
const goBack = () => {
router.back()
}
const getTaskId = () => Number(route.query.id)
const getTaskDetail = async () => {
const taskId = getTaskId()
if (!taskId) {
ElMessage.error('缺少任务ID参数')
goBack()
return
}
loading.value = true
try {
const res = await ExportTaskService.getExportTaskDetail(taskId)
if (res.code === 0) {
taskDetail.value = res.data
} else {
ElMessage.error(res.msg || '获取导出任务详情失败')
}
} catch (error) {
console.error('获取导出任务详情失败:', error)
ElMessage.error('获取导出任务详情失败')
} finally {
loading.value = false
}
}
const downloadTask = () => {
if (!taskDetail.value?.download_url) {
ElMessage.warning('当前任务暂无可用下载地址')
return
}
window.open(taskDetail.value.download_url, '_blank')
}
const cancelTask = () => {
if (!taskDetail.value) return
ElMessageBox.confirm(`确定取消导出任务 ${taskDetail.value.task_no} 吗?`, '取消确认', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(async () => {
try {
const res = await ExportTaskService.cancelExportTask(taskDetail.value!.id)
if (res.code === 0) {
ElMessage.success(res.data?.message || '任务取消成功')
getTaskDetail()
} else {
ElMessage.error(res.msg || '取消导出任务失败')
}
} catch (error) {
console.error('取消导出任务失败:', error)
ElMessage.error('取消导出任务失败')
}
})
}
onMounted(() => {
getTaskDetail()
})
</script>
<style scoped lang="scss">
.export-task-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-actions {
display: flex;
gap: 10px;
margin-left: auto;
}
}
.detail-loading {
display: flex;
flex-direction: column;
gap: 12px;
align-items: center;
justify-content: center;
min-height: 220px;
color: var(--el-text-color-secondary);
}
}
</style>

View File

@@ -0,0 +1,357 @@
<template>
<ArtTableFullScreen>
<div class="export-task-list-page" id="table-full-screen">
<ArtSearchBar
v-model:filter="searchForm"
:items="searchFormItems"
label-width="110"
@reset="handleReset"
@search="handleSearch"
/>
<ElCard shadow="never" class="art-table-card">
<ArtTableHeader
:columnList="columnOptions"
v-model:columns="columnChecks"
@refresh="handleRefresh"
/>
<ArtTable
:loading="loading"
:data="taskList"
:currentPage="pagination.page"
:pageSize="pagination.pageSize"
:total="pagination.total"
:marginTop="10"
:actions="getActions"
:actionsWidth="180"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
>
<template #default>
<ElTableColumn v-for="col in columns" :key="col.prop || col.type" v-bind="col" />
</template>
</ArtTable>
</ElCard>
</div>
</ArtTableFullScreen>
</template>
<script setup lang="ts">
import { h, onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox, ElProgress, ElTag } from 'element-plus'
import { ExportTaskService } from '@/api/modules'
import { useAuth } from '@/composables/useAuth'
import { useCheckedColumns } from '@/composables/useCheckedColumns'
import { formatDateTime } from '@/utils/business/format'
import { RoutesAlias } from '@/router/routesAlias'
import type { SearchFormItem } from '@/types'
import type { ExportTaskItem, ExportTaskScene } from '@/types/api'
import { ExportTaskStatus } from '@/types/api'
defineOptions({ name: 'ExportTaskList' })
const router = useRouter()
const { hasAuth } = useAuth()
const loading = ref(false)
const taskList = ref<ExportTaskItem[]>([])
const initialSearchState = {
scene: undefined as ExportTaskScene | undefined,
status: undefined as ExportTaskStatus | undefined,
dateRange: [] as string[],
start_time: '',
end_time: ''
}
const searchForm = reactive({ ...initialSearchState })
const pagination = reactive({
page: 1,
pageSize: 20,
total: 0
})
const sceneOptions = [
{ label: '设备管理', value: 'device' },
{ label: 'IoT卡管理', value: 'iot_card' }
]
const statusOptions = [
{ label: '待处理', value: ExportTaskStatus.PENDING },
{ label: '处理中', value: ExportTaskStatus.PROCESSING },
{ label: '已完成', value: ExportTaskStatus.COMPLETED },
{ label: '失败', value: ExportTaskStatus.FAILED },
{ label: '已取消', value: ExportTaskStatus.CANCELED }
]
const searchFormItems: SearchFormItem[] = [
{
label: '导出场景',
prop: 'scene',
type: 'select',
config: { clearable: true, placeholder: '请选择导出场景' },
options: () => sceneOptions
},
{
label: '任务状态',
prop: 'status',
type: 'select',
config: { clearable: true, placeholder: '请选择任务状态' },
options: () => statusOptions
},
{
label: '创建时间',
prop: 'dateRange',
type: 'date',
config: {
type: 'daterange',
rangeSeparator: '至',
startPlaceholder: '开始时间',
endPlaceholder: '结束时间',
valueFormat: 'YYYY-MM-DD'
}
}
]
const columnOptions = [
{ label: '任务编号', prop: 'task_no' },
{ label: '场景', prop: 'scene' },
{ label: '状态', prop: 'status_name' },
{ label: '进度', prop: 'progress' },
{ label: '格式', prop: 'format' },
{ label: '总行数', prop: 'total_rows' },
{ label: '已处理行数', prop: 'processed_rows' },
{ label: '分片', prop: 'total_shards' },
{ label: '错误信息', prop: 'error_message' },
{ label: '创建时间', prop: 'created_at' }
]
const { columnChecks, columns } = useCheckedColumns(() => [
{
prop: 'task_no',
label: '任务编号',
minWidth: 170,
showOverflowTooltip: true,
formatter: (row: ExportTaskItem) =>
h(
'span',
{
class: 'task-no-link',
onClick: () => goDetail(row)
},
row.task_no || '-'
)
},
{
prop: 'scene',
label: '场景',
width: 120,
formatter: (row: ExportTaskItem) => getSceneName(row.scene)
},
{
prop: 'status_name',
label: '状态',
width: 110,
formatter: (row: ExportTaskItem) =>
h(ElTag, { type: getStatusTagType(row.status) }, () => row.status_name || '-')
},
{
prop: 'progress',
label: '进度',
width: 150,
formatter: (row: ExportTaskItem) => h(ElProgress, { percentage: row.progress || 0 })
},
{ prop: 'format', label: '格式', width: 90 },
{ prop: 'total_rows', label: '总行数', width: 110 },
{ prop: 'processed_rows', label: '已处理行数', width: 120 },
{
prop: 'total_shards',
label: '分片',
width: 130,
formatter: (row: ExportTaskItem) => `${row.success_shards || 0}/${row.total_shards || 0}`
},
{ prop: 'error_message', label: '错误信息', minWidth: 180, showOverflowTooltip: true },
{
prop: 'created_at',
label: '创建时间',
width: 180,
formatter: (row: ExportTaskItem) => formatDateTime(row.created_at) || '-'
}
])
const getSceneName = (scene?: ExportTaskScene) => {
const option = sceneOptions.find((item) => item.value === scene)
return option?.label || '-'
}
const getStatusTagType = (status: ExportTaskStatus) => {
const statusTypeMap: Record<
ExportTaskStatus,
'primary' | 'success' | 'warning' | 'danger' | 'info'
> = {
[ExportTaskStatus.PENDING]: 'info',
[ExportTaskStatus.PROCESSING]: 'warning',
[ExportTaskStatus.COMPLETED]: 'success',
[ExportTaskStatus.FAILED]: 'danger',
[ExportTaskStatus.CANCELED]: 'info'
}
return statusTypeMap[status] || 'info'
}
const syncDateRange = () => {
if (Array.isArray(searchForm.dateRange) && searchForm.dateRange.length === 2) {
searchForm.start_time = searchForm.dateRange[0] || ''
searchForm.end_time = searchForm.dateRange[1] || ''
} else {
searchForm.start_time = ''
searchForm.end_time = ''
}
}
const getTableData = async () => {
loading.value = true
try {
syncDateRange()
const res = await ExportTaskService.getExportTasks({
page: pagination.page,
page_size: pagination.pageSize,
scene: searchForm.scene,
status: searchForm.status,
start_time: searchForm.start_time || undefined,
end_time: searchForm.end_time || undefined
})
if (res.code === 0 && res.data) {
taskList.value = res.data.items || []
pagination.total = res.data.total || 0
} else {
ElMessage.error(res.msg || '获取导出任务列表失败')
}
} catch (error) {
console.error('获取导出任务列表失败:', error)
ElMessage.error('获取导出任务列表失败')
} finally {
loading.value = false
}
}
const handleReset = () => {
Object.assign(searchForm, { ...initialSearchState })
pagination.page = 1
getTableData()
}
const handleSearch = () => {
pagination.page = 1
getTableData()
}
const handleRefresh = () => {
getTableData()
}
const handleSizeChange = (pageSize: number) => {
pagination.pageSize = pageSize
getTableData()
}
const handleCurrentChange = (page: number) => {
pagination.page = page
getTableData()
}
const getTaskDetail = async (id: number) => {
const res = await ExportTaskService.getExportTaskDetail(id)
if (res.code !== 0 || !res.data) {
throw new Error(res.msg || '获取导出任务详情失败')
}
return res.data
}
const goDetail = (row: ExportTaskItem) => {
if (!hasAuth('export_task:detail')) {
ElMessage.warning('您没有查看导出任务详情的权限')
return
}
router.push({
path: RoutesAlias.ExportTaskDetail,
query: { id: row.id }
})
}
const downloadTask = async (row: ExportTaskItem) => {
try {
const detail = await getTaskDetail(row.id)
if (!detail.download_url) {
ElMessage.warning('当前任务暂无可用下载地址')
return
}
window.open(detail.download_url, '_blank')
} catch (error) {
console.error('下载导出文件失败:', error)
ElMessage.error(error instanceof Error ? error.message : '下载导出文件失败')
}
}
const cancelTask = (row: ExportTaskItem) => {
ElMessageBox.confirm(`确定取消导出任务 ${row.task_no} 吗?`, '取消确认', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(async () => {
try {
const res = await ExportTaskService.cancelExportTask(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[] = []
if (row.status === ExportTaskStatus.COMPLETED && hasAuth('export_task:download')) {
actions.push({ label: '下载', handler: () => downloadTask(row), type: 'primary' })
}
if (
[ExportTaskStatus.PENDING, ExportTaskStatus.PROCESSING].includes(row.status) &&
hasAuth('export_task:cancel')
) {
actions.push({ label: '取消', handler: () => cancelTask(row), type: 'danger' })
}
return actions
}
onMounted(() => {
getTableData()
})
</script>
<style scoped lang="scss">
.export-task-list-page {
height: 100%;
}
:deep(.task-no-link) {
color: var(--el-color-primary);
cursor: pointer;
&:hover {
text-decoration: underline;
}
}
</style>

View File

@@ -42,6 +42,9 @@
>
批量设置套餐系列
</ElButton>
<ElButton type="primary" @click="showExportDialog" v-permission="'iot_card:export'">
导出
</ElButton>
</template>
</ArtTableHeader>
@@ -628,6 +631,13 @@
:identifier="operationLogsIdentifier"
download-permission="iot_card:download_log_file"
/>
<ExportTaskCreateDialog
v-model="exportDialogVisible"
scene="iot_card"
:query="exportQuery"
confirm-permission="iot_card:export"
title="导出IoT卡"
/>
</ElCard>
</div>
</ArtTableFullScreen>
@@ -648,6 +658,7 @@
import { Loading } from '@element-plus/icons-vue'
import RealnamePolicyDialog from '@/components/device/RealnamePolicyDialog.vue'
import UpdateRealnameStatusDialog from '@/components/business/UpdateRealnameStatusDialog.vue'
import ExportTaskCreateDialog from '@/components/business/ExportTaskCreateDialog.vue'
import type { FormInstance, FormRules } from 'element-plus'
import type { SearchFormItem } from '@/types'
import { useCheckedColumns } from '@/composables/useCheckedColumns'
@@ -697,6 +708,7 @@
// 操作审计日志弹窗
const operationLogsDialogVisible = ref(false)
const operationLogsIdentifier = ref('')
const exportDialogVisible = ref(false)
// 套餐系列绑定相关
const seriesBindingDialogVisible = ref(false)
@@ -1709,6 +1721,22 @@
return actions
}
const exportQuery = computed(() => {
const query: Record<string, unknown> = {}
Object.entries(formFilters).forEach(([key, value]) => {
if (value === undefined || value === null || value === '') return
if (Array.isArray(value) && value.length === 0) return
query[key] = value
})
return query
})
const showExportDialog = () => {
exportDialogVisible.value = true
}
onMounted(() => {
getTableData()
})