feat: 七月迭代公共状态与异步任务交互
All checks were successful
构建并部署前端到测试环境 / build-and-deploy (push) Successful in 5m59s

This commit is contained in:
luo
2026-07-24 11:48:02 +08:00
parent 15122e225a
commit a0c44ff538
14 changed files with 677 additions and 63 deletions

View File

@@ -0,0 +1,63 @@
## Context
设备批量分配、批量订购和导出都属于可能耗时的异步操作,但现有页面和任务模型不统一。产品要求以相同的状态语义、进度结构、轮询节奏和恢复行为覆盖这些入口,同时不改变已有业务接口中未涉及的字段。
## Goals / Non-Goals
- Goals: 统一三类页面的公共状态展示、任务进度展示、任务恢复、轮询和错误重试行为。
- Goals: 为导出任务补齐稳定的任务标识、计数、错误摘要和更新时间字段。
- Non-Goals: 不在前端实现任务调度、任务执行、失败明细生成或后端状态流转。
- Non-Goals: 不新增“部分成功”状态,不替换既有业务接口路径,不删除导出接口原字段。
## Decisions
- Decision: 使用固定五态映射:`1=待处理``2=处理中``3=已完成``4=已失败``5=已取消`
- Rationale: 三类任务使用同一状态语义;部分成功由计数表达,避免增加状态分支。
- Decision: 将任务轮询实现为可复用的 composable 或等价公共模块,由页面提供任务详情查询函数和终态判断函数。
- Rationale: 轮询节奏、页面可见性处理和清理逻辑必须只维护一份,避免各页面出现漂移。
- Decision: 任务恢复使用持久化的业务入口标识与 `task_id`,恢复时优先查询任务详情;任务进入终态后清理当前入口的活动任务引用。
- Rationale: 页面刷新后可以继续展示原任务,同时终态任务不会阻止用户创建新的任务。
- Decision: 轮询采用单次调度而非固定高频定时器,延迟序列为 2 秒、3 秒、5 秒,随后保持 10 秒;页面隐藏时取消待执行调度,重新可见时立即查询一次。
- Rationale: 降低无效请求,并满足页面恢复后的即时反馈要求。
- Decision: 任务失败优先使用后端安全字段 `error_summary` 作为用户文案;`error_code` 只用于前端分类和日志,不直接展示底层技术错误。
- Rationale: 保证用户可理解,同时避免泄露内部错误信息。
## Data Model
- `task_id`: 稳定任务标识,用于持久化和恢复查询。
- `status`: 五态任务状态。
- `total_count`: 任务总数。
- `success_count`: 成功数。
- `failed_count`: 失败数。
- `failure_details`: 失败明细;具体字段由业务任务详情接口定义。
- `error_code`: 稳定错误分类码。
- `error_summary`: 可安全展示的中文错误摘要。
- `updated_at`: 任务最近更新时间。
## Risks / Trade-offs
- Risk: 不同业务详情接口的失败明细字段不完全一致。
- Mitigation: 公共交互层只约束失败明细可展示,业务页面负责将各自接口字段映射为统一展示模型。
- Risk: 页面刷新时本地保存的任务已被删除或无权限访问。
- Mitigation: 清理失效任务引用,并按 403 或普通接口失败规则展示对应状态,不重新创建任务。
- Risk: 页面不可见期间任务已进入终态。
- Mitigation: 页面恢复可见后立即执行一次详情查询,并根据返回状态停止轮询。
## Migration Plan
1. 明确三类任务接口的 `task_id` 创建响应和详情查询契约。
2. 实现公共任务状态、进度模型和轮询/恢复逻辑。
3. 先接入设备批量分配、批量订购和导出页面,保留各业务原有提交参数。
4. 更新导出任务列表和详情类型,兼容旧字段并优先使用新增统一字段。
5. 验证状态、重试、权限、页面刷新、页面可见性和部分成功场景。
## Open Questions
- 批量分配和批量订购的任务详情接口路径及失败明细字段,需要以后端实际契约为准补入实施任务。
- `task_id` 的存储范围需要按业务入口区分,还是由统一任务中心集中管理,需要实施前确认。

View File

@@ -0,0 +1,34 @@
# Change: 统一公共状态与异步任务交互
## Why
设备批量分配、批量订购和导出页面目前可能分别处理加载、失败、进度和任务恢复,容易产生不同的状态语义和重试行为。需要建立一套可复用的公共交互规则,让用户在不同批量操作页面获得一致的反馈,并避免页面刷新或切换后重复创建任务。
## What Changes
- 为设备批量分配、批量订购和导出页面统一加载中、空数据、无权限、接口失败和重试状态。
- 统一异步任务的五态语义:待处理、处理中、已完成、已失败、已取消。
- 统一任务进度数据展示:任务状态、总数、成功数、失败数和失败明细。
- 明确部分成功的表达方式:任务进入已完成状态,通过成功数和失败数表达部分成功,不新增部分成功状态。
- 创建任务成功后保存 `task_id`,页面刷新或重新进入时恢复原任务详情,不重复创建任务。
- 对待处理和处理中任务按 2 秒、3 秒、5 秒递增轮询,之后最大间隔 10 秒;进入终态后停止轮询。
- 页面不可见时暂停轮询,恢复可见后立即刷新一次。
- 统一 403、瞬时接口失败和任务失败的用户反馈及重试规则。
-`GET /api/admin/export-tasks``GET /api/admin/export-tasks/{id}` 增加兼容性字段契约:`task_id``total_count``success_count``failed_count``error_code``error_summary``updated_at`;原字段不删除、不改名。
## Impact
- Affected specs:
- `async-task-interaction`
- `export-task-management`
- Affected code:
- 批量分配页面及其设备、IOT 卡任务提交逻辑
- 批量订购页面及其任务提交逻辑
- 导出任务 API 类型和导出任务列表/详情页面
- 可复用异步任务状态、轮询和任务恢复逻辑
- 统一错误、空数据、无权限和重试状态组件或页面状态配置
- Dependencies:
- 后端批量分配、批量订购和导出接口必须返回可关联的 `task_id`,并提供按 `task_id` 查询任务详情的能力。
- 后端导出接口按本提案新增兼容字段,且继续保留现有字段。
- Breaking changes:
- 无。导出接口仅新增兼容字段;前端统一交互规则不改变既有任务创建接口的必填参数。

View File

@@ -0,0 +1,147 @@
## ADDED Requirements
### Requirement: Unified Async Task States
The admin frontend SHALL use one state vocabulary for device batch allocation, batch ordering, and export tasks: `1=待处理`, `2=处理中`, `3=已完成`, `4=已失败`, and `5=已取消`.
#### Scenario: Render pending and processing states
- **GIVEN** 异步任务状态分别为待处理或处理中
- **WHEN** 页面展示任务
- **THEN** 页面 MUST 使用对应的统一状态文案和视觉状态
- **AND** 页面 MUST treat both states as active states that can be polled
#### Scenario: Render terminal states
- **GIVEN** 异步任务状态为已完成、已失败或已取消
- **WHEN** 页面展示任务
- **THEN** 页面 MUST 使用对应的统一终态文案和视觉状态
- **AND** 页面 MUST stop automatic polling for that task
#### Scenario: Represent partial success
- **GIVEN** 任务执行结束且成功数大于零、失败数大于零
- **WHEN** 页面展示任务结果
- **THEN** 页面 MUST show status as 已完成
- **AND** 页面 MUST show total count, success count, failed count, and available failure details
- **AND** 页面 MUST NOT introduce or display 部分成功 as a separate task status
### Requirement: Unified Common Page States
The admin frontend SHALL provide consistent loading, empty, forbidden, request failure, and retry interactions on the three supported async-task pages.
#### Scenario: Show loading state
- **GIVEN** 页面正在请求任务列表或任务详情
- **WHEN** 请求尚未完成
- **THEN** 页面 MUST show a loading state
- **AND** 页面 MUST prevent duplicate requests caused by repeated automatic triggers
#### Scenario: Show empty state
- **GIVEN** 任务查询成功但没有任务数据
- **WHEN** 页面完成渲染
- **THEN** 页面 MUST show the unified empty state
- **AND** 页面 MUST keep the page search or task creation entry available when the user has permission
#### Scenario: Handle forbidden response
- **GIVEN** 任务列表或详情接口 responds with HTTP 403
- **WHEN** 页面 handles the response
- **THEN** 页面 MUST show an explicit no-permission state
- **AND** 页面 MUST NOT automatically retry the request
#### Scenario: Retry transient request failure
- **GIVEN** a task list or detail request fails for a transient reason other than 403
- **WHEN** 页面 handles the response
- **THEN** 页面 MUST preserve already loaded task data and user input
- **AND** 页面 MUST provide an explicit retry action
- **AND** automatic polling MUST continue only after a successful retry or a subsequent successful refresh
### Requirement: Unified Task Progress Model
The admin frontend SHALL normalize task progress into task status, total count, success count, failed count, and failure details for device batch allocation, batch ordering, and export tasks.
#### Scenario: Display active task progress
- **GIVEN** a task is pending or processing
- **WHEN** task details are available
- **THEN** 页面 MUST display the current status and total count
- **AND** 页面 MUST display success count and failed count when returned
- **AND** 页面 MUST update the displayed values after each successful refresh
#### Scenario: Display failed task details
- **GIVEN** a task is failed or has failed items
- **WHEN** 页面 renders the task result
- **THEN** 页面 MUST display a safe failure summary when available
- **AND** 页面 MUST display failure details when the business detail API provides them
### Requirement: Task Creation And Recovery
The admin frontend SHALL persist the `task_id` returned by an async task creation request and use it to recover the task instead of creating a duplicate task after refresh or re-entry.
#### Scenario: Save task after creation
- **GIVEN** 用户确认设备批量分配、批量订购或导出操作
- **WHEN** task creation succeeds and returns `task_id`
- **THEN** 系统 MUST persist the task identifier for the corresponding business entry
- **AND** 系统 MUST start the unified task detail interaction for that task
#### Scenario: Recover task after page refresh
- **GIVEN** 当前业务入口存在已保存的 active `task_id`
- **WHEN** 用户刷新页面或重新进入该入口
- **THEN** 系统 MUST query the existing task detail using that `task_id`
- **AND** 系统 MUST NOT create another task automatically
#### Scenario: Clear task reference after terminal state
- **GIVEN** a recovered or newly created task reaches a terminal state
- **WHEN** 页面 handles the terminal task response
- **THEN** 系统 MUST stop polling
- **AND** 系统 MUST retain the result for display while allowing a later operation to create a new task
### Requirement: Visibility-Aware Incremental Polling
The admin frontend SHALL poll active async tasks at 2 seconds, 3 seconds, and 5 seconds after creation or refresh, then use an interval no longer than 10 seconds until the task reaches a terminal state.
#### Scenario: Poll active task with increasing delay
- **GIVEN** a task is in 待处理 or 处理中
- **WHEN** the previous task detail request succeeds and the task remains active
- **THEN** the next refresh MUST be scheduled after 2 seconds, then 3 seconds, then 5 seconds
- **AND** subsequent refreshes MUST NOT be more than 10 seconds apart
#### Scenario: Pause polling while page is hidden
- **GIVEN** an active task is being polled
- **WHEN** the browser page becomes not visible
- **THEN** the frontend MUST pause or cancel the pending poll
- **AND** the frontend MUST NOT issue automatic polling requests while the page remains hidden
#### Scenario: Refresh immediately when page becomes visible
- **GIVEN** an active task was paused because the page was hidden
- **WHEN** the page becomes visible again
- **THEN** the frontend MUST request the task detail immediately
- **AND** the frontend MUST resume the incremental polling schedule only if the task remains active
### Requirement: Safe Async Task Error Messaging
The admin frontend SHALL use a safe user-facing error summary for task failures and SHALL NOT expose a raw error code or low-level technical error as the primary user message.
#### Scenario: Show task failure summary
- **GIVEN** a failed task response contains `error_summary`
- **WHEN** 页面展示失败原因
- **THEN** 页面 MUST show `error_summary` as the primary failure message
- **AND** 页面 MUST NOT use `error_code` as the user-facing text
#### Scenario: Fallback when safe summary is absent
- **GIVEN** a failed task response does not contain a safe error summary
- **WHEN** 页面展示失败原因
- **THEN** 页面 MUST show a generic actionable failure message
- **AND** 页面 MUST keep the raw error code and technical error out of the primary user-facing message

View File

@@ -0,0 +1,28 @@
## MODIFIED Requirements
### Requirement: Export Task API Integration
The admin frontend SHALL provide a typed API integration for unified export tasks using the existing REST contract and the following compatibility additions. Existing response fields MUST remain available and MUST NOT be renamed or removed.
#### Scenario: Query export tasks with unified fields
- **GIVEN** 用户打开任一导出任务页面
- **WHEN** 用户按页面固定场景、状态、分页或创建时间范围查询导出任务
- **THEN** 系统 MUST call `GET /api/admin/export-tasks`
- **AND** 每条任务 MUST support `task_id`, `total_count`, `success_count`, `failed_count`, `error_code`, `error_summary`, and `updated_at`
- **AND** 原有任务字段 MUST remain parseable by the frontend
#### Scenario: Fetch export task detail with unified fields
- **GIVEN** 用户查看或恢复一个导出任务
- **WHEN** 系统按 `task_id` 查询任务详情
- **THEN** 系统 MUST call `GET /api/admin/export-tasks/{id}`
- **AND** 详情 MUST support `task_id`, `total_count`, `success_count`, `failed_count`, `error_code`, `error_summary`, and `updated_at`
- **AND** 详情 MUST continue to support existing download and timestamp fields when returned
#### Scenario: Preserve compatibility with missing optional additions
- **GIVEN** 后端暂未返回某个新增兼容字段
- **WHEN** 前端解析导出任务列表或详情
- **THEN** 系统 MUST keep the response usable with stable empty or zero placeholders
- **AND** 系统 MUST NOT fail solely because an optional unified field is absent

View File

@@ -0,0 +1,44 @@
## 1. Contract And Model
- [ ] 1.1 确认设备批量分配、批量订购和导出任务创建响应均返回稳定的 `task_id`
- [ ] 1.2 确认三类任务按 `task_id` 查询详情的接口路径、状态字段和失败明细字段。
- [x] 1.3 新增统一任务状态、进度、错误摘要和任务恢复类型,兼容各业务字段别名。
- [x] 1.4 更新导出任务列表和详情类型,补充 `task_id``total_count``success_count``failed_count``error_code``error_summary``updated_at`
## 2. Shared Interaction
- [ ] 2.1 实现统一加载中、空数据、无权限、接口失败和重试状态的展示规则。
- [x] 2.2 实现五态任务状态映射,并明确终态为已完成、已失败或已取消。
- [ ] 2.3 实现统一任务进度展示,支持总数、成功数、失败数和失败明细。
- [ ] 2.4 实现任务创建后的 `task_id` 持久化、刷新恢复和失效任务清理。
- [x] 2.5 实现 2 秒、3 秒、5 秒递增且最大 10 秒的轮询调度。
- [x] 2.6 实现页面不可见暂停轮询、恢复可见立即刷新和组件卸载清理。
## 3. Business Pages
- [ ] 3.1 接入设备批量分配页面,创建成功后进入统一任务交互流程。
- [ ] 3.2 接入批量订购页面,创建成功后进入统一任务交互流程。
- [x] 3.3 接入导出任务列表和详情页面,统一展示任务进度、失败摘要和更新时间。
- [ ] 3.4 保证部分成功任务仍展示为已完成,并显示成功数与失败数,不引入新状态。
- [ ] 3.5 保证页面刷新或重新进入时恢复任务,不重复提交创建请求。
## 4. Error And Permission Handling
- [x] 4.1 403 响应展示无权限状态并停止自动重试。
- [x] 4.2 瞬时接口失败保留已有任务数据和用户输入,并提供明确的手动重试入口。
- [x] 4.3 任务失败优先展示 `error_summary`,不直接展示 `error_code` 或底层技术错误。
- [ ] 4.4 验证无权限用户无法看到或触发对应批量操作、任务详情和重试操作。
## 5. Verification
- [ ] 5.1 覆盖五态及部分成功的单元测试。
- [ ] 5.2 覆盖轮询延迟、终态停止、页面隐藏暂停和恢复立即刷新测试。
- [ ] 5.3 覆盖页面刷新恢复任务及避免重复创建测试。
- [ ] 5.4 覆盖 403、瞬时失败、任务失败和安全错误文案测试。
- [x] 5.5 运行类型检查、lint并对已接入的导出任务页面进行回归验证。
## Implementation Notes
- 设备批量分配当前仍由 `/api/admin/devices/allocate` 同步返回结果,未返回 `task_id`,因此未接入异步轮询。
- 当前订单页面仅提供单笔订单创建,仓库中未发现批量订购创建接口或任务详情接口,因此未新增虚构的批量任务流程。
- 设备批量分配和批量订购需待后端补充异步任务创建及详情契约后继续实施 1.1、1.2、3.1、3.2、3.4、3.5、4.4 及相关测试。

View File

@@ -113,6 +113,9 @@
if (res.code === 0) {
ElMessage.success(res.data?.message || '导出任务已创建')
if (res.data?.task_id) {
localStorage.setItem(`export-task-active:${props.scene}`, String(res.data.task_id))
}
emit('success', res.data)
visible.value = false
} else {

View File

@@ -0,0 +1,178 @@
import {
computed,
onBeforeUnmount,
onMounted,
ref,
shallowRef,
type Ref,
type ShallowRef
} from 'vue'
import { isAsyncTaskActive, isAsyncTaskTerminal, type AsyncTaskProgress } from '@/types/api'
const POLL_DELAYS = [2000, 3000, 5000]
const MAX_POLL_DELAY = 10000
export interface AsyncTaskPollingOptions<T extends AsyncTaskProgress> {
storageKey: string
fetchTask: (taskId: number) => Promise<T>
isForbidden?: (error: unknown) => boolean
autoRestore?: boolean
}
export interface AsyncTaskPollingState<T extends AsyncTaskProgress> {
taskId: Ref<number | null>
task: ShallowRef<T | null>
loading: Ref<boolean>
forbidden: Ref<boolean>
error: Ref<string | null>
isActive: Readonly<Ref<boolean>>
start: (taskId: number) => Promise<void>
retry: () => Promise<void>
stop: () => void
clear: () => void
}
const readStoredTaskId = (storageKey: string): number | null => {
try {
const value = Number(localStorage.getItem(storageKey))
return Number.isInteger(value) && value > 0 ? value : null
} catch {
return null
}
}
const writeStoredTaskId = (storageKey: string, taskId: number | null) => {
try {
if (taskId) localStorage.setItem(storageKey, String(taskId))
else localStorage.removeItem(storageKey)
} catch {
// Storage may be unavailable in private browsing or restricted webviews.
}
}
const getErrorMessage = (error: any) =>
error?.response?.data?.msg || error?.message || '获取导出任务详情失败'
export function useAsyncTaskPolling<T extends AsyncTaskProgress>(
options: AsyncTaskPollingOptions<T>
): AsyncTaskPollingState<T> {
const taskId = ref<number | null>(readStoredTaskId(options.storageKey))
const task = shallowRef<T | null>(null)
const loading = ref(false)
const forbidden = ref(false)
const error = ref<string | null>(null)
const isActive = computed(() => isAsyncTaskActive(task.value?.status))
let timer: number | undefined
let pollIndex = 0
let requestInFlight = false
const clearTimer = () => {
if (timer !== undefined) {
window.clearTimeout(timer)
timer = undefined
}
}
const stop = () => {
clearTimer()
}
const clear = () => {
stop()
taskId.value = null
task.value = null
error.value = null
forbidden.value = false
writeStoredTaskId(options.storageKey, null)
}
const schedule = () => {
clearTimer()
if (document.hidden || !isActive.value || !taskId.value) return
const delay = POLL_DELAYS[pollIndex] ?? MAX_POLL_DELAY
pollIndex += 1
timer = window.setTimeout(() => {
timer = undefined
void refresh()
}, delay)
}
const refresh = async () => {
if (!taskId.value || requestInFlight || document.hidden) return
requestInFlight = true
loading.value = true
forbidden.value = false
error.value = null
try {
const nextTask = await options.fetchTask(taskId.value)
task.value = nextTask
if (isAsyncTaskTerminal(nextTask.status)) {
stop()
writeStoredTaskId(options.storageKey, null)
} else {
schedule()
}
} catch (requestError) {
if (options.isForbidden?.(requestError) ?? false) {
forbidden.value = true
stop()
} else {
error.value = getErrorMessage(requestError)
stop()
}
} finally {
loading.value = false
requestInFlight = false
}
}
const start = async (nextTaskId: number) => {
if (!Number.isInteger(nextTaskId) || nextTaskId <= 0) return
taskId.value = nextTaskId
task.value = null
pollIndex = 0
writeStoredTaskId(options.storageKey, nextTaskId)
await refresh()
}
const retry = async () => {
if (!taskId.value) return
await refresh()
}
const handleVisibilityChange = () => {
if (document.hidden) {
stop()
return
}
if (taskId.value && isActive.value) {
pollIndex = 0
void refresh()
}
}
onMounted(() => {
document.addEventListener('visibilitychange', handleVisibilityChange)
if (options.autoRestore !== false && taskId.value) void refresh()
})
onBeforeUnmount(() => {
stop()
document.removeEventListener('visibilitychange', handleVisibilityChange)
})
return {
taskId,
task: task as ShallowRef<T | null>,
loading,
forbidden,
error,
isActive,
start,
retry,
stop,
clear
}
}

View File

@@ -0,0 +1,28 @@
export enum AsyncTaskStatus {
PENDING = 1,
PROCESSING = 2,
COMPLETED = 3,
FAILED = 4,
CANCELED = 5
}
export interface AsyncTaskProgress {
task_id?: number
status: number
status_name?: string
total_count?: number
success_count?: number
failed_count?: number
failure_details?: unknown[] | null
error_code?: string
error_summary?: string
updated_at?: string
}
export const isAsyncTaskActive = (status?: AsyncTaskStatus) =>
status === AsyncTaskStatus.PENDING || status === AsyncTaskStatus.PROCESSING
export const isAsyncTaskTerminal = (status?: AsyncTaskStatus) =>
status === AsyncTaskStatus.COMPLETED ||
status === AsyncTaskStatus.FAILED ||
status === AsyncTaskStatus.CANCELED

View File

@@ -21,23 +21,30 @@ export interface ExportTaskQueryParams extends PaginationParams {
export interface ExportTaskItem {
id: number
task_id?: number
task_no: string
scene: ExportTaskScene
status: ExportTaskStatus
status_name: string
progress: number
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
total_rows?: number
processed_rows?: number
total_shards?: number
success_shards?: number
failed_shards?: number
file_key?: string
error_message?: string
cancel_requested?: boolean
total_count?: number
success_count?: number
failed_count?: number
error_code?: string
error_summary?: string
updated_at?: string
created_at: string
started_at: string
completed_at: string
started_at?: string
completed_at?: string
}
export interface ExportTaskListResponse {

View File

@@ -111,5 +111,8 @@ export * from './pollingMonitor'
// 导出任务相关
export * from './exportTask'
// 通用异步任务相关
export * from './asyncTask'
// 订单套餐批量作废任务相关
export * from './orderPackageInvalidateTask'

View File

@@ -87,8 +87,10 @@ declare module 'vue' {
ElAlert: typeof import('element-plus/es')['ElAlert']
ElAvatar: typeof import('element-plus/es')['ElAvatar']
ElButton: typeof import('element-plus/es')['ElButton']
ElCalendar: typeof import('element-plus/es')['ElCalendar']
ElCard: typeof import('element-plus/es')['ElCard']
ElCascader: typeof import('element-plus/es')['ElCascader']
ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
ElCol: typeof import('element-plus/es')['ElCol']
ElConfigProvider: typeof import('element-plus/es')['ElConfigProvider']
ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
@@ -111,7 +113,9 @@ declare module 'vue' {
ElOption: typeof import('element-plus/es')['ElOption']
ElPagination: typeof import('element-plus/es')['ElPagination']
ElPopover: typeof import('element-plus/es')['ElPopover']
ElProgress: typeof import('element-plus/es')['ElProgress']
ElRadio: typeof import('element-plus/es')['ElRadio']
ElRadioButton: typeof import('element-plus/es')['ElRadioButton']
ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
ElRow: typeof import('element-plus/es')['ElRow']
ElScrollbar: typeof import('element-plus/es')['ElScrollbar']
@@ -123,6 +127,8 @@ declare module 'vue' {
ElTabPane: typeof import('element-plus/es')['ElTabPane']
ElTabs: typeof import('element-plus/es')['ElTabs']
ElTag: typeof import('element-plus/es')['ElTag']
ElTimeline: typeof import('element-plus/es')['ElTimeline']
ElTimelineItem: typeof import('element-plus/es')['ElTimelineItem']
ElTooltip: typeof import('element-plus/es')['ElTooltip']
ElTreeSelect: typeof import('element-plus/es')['ElTreeSelect']
ElUpload: typeof import('element-plus/es')['ElUpload']

View File

@@ -992,7 +992,6 @@
try {
const res = await CardService.getStandaloneIotCards({
is_standalone: true,
status: 1,
page: 1,
page_size: 10
})
@@ -1015,7 +1014,6 @@
oldDeviceSearchLoading.value = true
try {
const res = await DeviceService.getDevices({
status: 1,
page: 1,
page_size: 10
})
@@ -1045,7 +1043,6 @@
try {
const res = await CardService.getStandaloneIotCards({
is_standalone: true,
status: 1,
keyword: query,
page: 1,
page_size: 20
@@ -1075,7 +1072,6 @@
oldDeviceSearchLoading.value = true
try {
const res = await DeviceService.getDevices({
status: 1,
keyword: query,
page: 1,
page_size: 20
@@ -1113,8 +1109,6 @@
try {
const res = await CardService.getStandaloneIotCards({
is_standalone: true,
status: 1,
shop_id: shopId,
keyword: query,
page: 1,
page_size: 20
@@ -1174,13 +1168,14 @@
try {
const res = await CardService.getStandaloneIotCards({
is_standalone: true,
status: 1,
shop_id: shopId,
page: 1,
page_size: 10
})
if (res.code === 0 && res.data) {
newIotCardOptions.value = res.data.items || []
if (newIotCardOptions.value.length === 0) {
ElMessage.info('暂无同店铺可用的新资产')
}
}
} catch (error) {
console.error('加载IoT卡列表失败:', error)
@@ -1228,8 +1223,6 @@
newDeviceSearchLoading.value = true
try {
const res = await DeviceService.getDevices({
status: 1,
shop_id: shopId,
keyword: query,
page: 1,
page_size: 20
@@ -1287,13 +1280,14 @@
newDeviceSearchLoading.value = true
try {
const res = await DeviceService.getDevices({
status: 1,
shop_id: shopId,
page: 1,
page_size: 10
})
if (res.code === 0 && res.data) {
newDeviceOptions.value = res.data.items || []
if (newDeviceOptions.value.length === 0) {
ElMessage.info('暂无同店铺可用的新资产')
}
}
} catch (error) {
console.error('加载设备列表失败:', error)

View File

@@ -18,25 +18,28 @@
</div>
</div>
<div v-if="loading" class="detail-loading">
<div v-if="polling.loading && !taskDetail" class="detail-loading">
<ElIcon class="is-loading" :size="36">
<Loading />
</ElIcon>
<div>加载中...</div>
</div>
<DetailPage
v-else-if="taskDetail && canViewDetail"
v-if="taskDetail && canViewDetail"
:sections="detailSections"
:data="taskDetail"
/>
<ElEmpty v-else :description="forbidden ? '暂无权限查看该导出任务详情' : '暂无详情'" />
<ElEmpty
v-else
:description="polling.forbidden ? '暂无权限查看该导出任务详情' : '暂无详情'"
/>
</ElCard>
</div>
</ArtTableFullScreen>
</template>
<script setup lang="ts">
import { computed, h, onMounted, ref } from 'vue'
import { computed, h, onMounted } 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'
@@ -48,15 +51,29 @@
import { formatDateTime } from '@/utils/business/format'
import type { ExportTaskDetail } from '@/types/api'
import { ExportTaskStatus } from '@/types/api'
import { useAsyncTaskPolling } from '@/composables/useAsyncTaskPolling'
defineOptions({ name: 'ExportTaskDetail' })
const route = useRoute()
const router = useRouter()
const { hasAuth } = useAuth()
const loading = ref(false)
const forbidden = ref(false)
const taskDetail = ref<ExportTaskDetail | null>(null)
const polling = useAsyncTaskPolling<ExportTaskDetail>({
storageKey: `export-task-detail:${String(route.query.id || '')}`,
autoRestore: false,
fetchTask: async (taskId) => {
const res = await ExportTaskService.getExportTaskDetail(taskId)
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 taskPermissions = computed(
() => getExportTaskSceneConfig(taskDetail.value?.scene)?.permissions
@@ -102,6 +119,7 @@
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))
@@ -132,11 +150,17 @@
prop: 'completed_at',
formatter: (value: string) => formatDateTime(value) || '-'
},
{
label: '更新时间',
prop: 'updated_at',
formatter: (value: string) => formatDateTime(value) || '-'
},
{
label: '错误信息',
prop: 'error_message',
prop: 'error_summary',
fullWidth: true,
formatter: (value: string) => value || '-'
formatter: (value: string, data: ExportTaskDetail) =>
data.error_summary || data.error_message || value || '-'
}
]
},
@@ -144,6 +168,22 @@
title: '处理进度',
fields: [
{ label: '进度', prop: 'progress', formatter: (value: number) => `${value ?? 0}%` },
{
label: '总数',
prop: 'total_count',
formatter: (value: number, data: ExportTaskDetail) =>
String(value ?? data.total_rows ?? 0)
},
{
label: '成功数',
prop: 'success_count',
formatter: (value: number) => String(value ?? 0)
},
{
label: '失败数',
prop: 'failed_count',
formatter: (value: number) => String(value ?? 0)
},
{ label: '总行数', prop: 'total_rows', formatter: (value: number) => String(value ?? 0) },
{
label: '已处理行数',
@@ -203,33 +243,16 @@
const getTaskId = () => Number(route.query.id)
const getTaskDetail = async () => {
const loadTaskDetail = async () => {
const taskId = getTaskId()
if (!taskId) {
ElMessage.error('缺少任务ID参数')
goBack()
return
}
loading.value = true
forbidden.value = false
try {
const res = await ExportTaskService.getExportTaskDetail(taskId)
if (res.code === 0) {
taskDetail.value = res.data
if (!canViewDetail.value) {
forbidden.value = true
ElMessage.warning('您没有查看该导出任务详情的权限')
}
} else {
ElMessage.error(res.msg || '获取导出任务详情失败')
}
} catch (error) {
console.error('获取导出任务详情失败:', error)
ElMessage.error('获取导出任务详情失败')
} finally {
loading.value = false
}
await polling.start(taskId)
if (polling.error.value) ElMessage.error(polling.error.value)
if (polling.forbidden.value) ElMessage.warning('您没有查看该导出任务详情的权限')
}
const downloadTask = () => {
@@ -259,10 +282,12 @@
type: 'warning'
}).then(async () => {
try {
const res = await ExportTaskService.cancelExportTask(taskDetail.value!.id)
const res = await ExportTaskService.cancelExportTask(
taskDetail.value!.task_id ?? taskDetail.value!.id
)
if (res.code === 0) {
ElMessage.success(res.data?.message || '任务取消成功')
getTaskDetail()
polling.retry()
} else {
ElMessage.error(res.msg || '取消导出任务失败')
}
@@ -274,7 +299,7 @@
}
onMounted(() => {
getTaskDetail()
loadTaskDetail()
})
</script>

View File

@@ -49,8 +49,9 @@
import { RoutesAlias } from '@/router/routesAlias'
import { getExportTaskSceneConfig } from '@/config/constants'
import type { SearchFormItem } from '@/types'
import type { ExportTaskItem, ExportTaskScene } from '@/types/api'
import type { ExportTaskDetail, ExportTaskItem, ExportTaskScene } from '@/types/api'
import { ExportTaskStatus } from '@/types/api'
import { useAsyncTaskPolling } from '@/composables/useAsyncTaskPolling'
defineOptions({ name: 'ExportTaskList' })
@@ -116,11 +117,15 @@
{ label: '任务编号', prop: 'task_no' },
{ label: '状态', prop: 'status_name' },
{ label: '进度', prop: 'progress' },
{ label: '总数', prop: 'total_count' },
{ label: '成功数', prop: 'success_count' },
{ label: '失败数', prop: 'failed_count' },
{ label: '格式', prop: 'format' },
{ label: '总行数', prop: 'total_rows' },
{ label: '已处理行数', prop: 'processed_rows' },
{ label: '分片', prop: 'total_shards' },
{ label: '错误信息', prop: 'error_message' },
{ label: '错误信息', prop: 'error_summary' },
{ label: '更新时间', prop: 'updated_at' },
{ label: '创建时间', prop: 'created_at' }
]
@@ -153,6 +158,24 @@
width: 150,
formatter: (row: ExportTaskItem) => h(ElProgress, { percentage: row.progress || 0 })
},
{
prop: 'total_count',
label: '总数',
width: 100,
formatter: (row: ExportTaskItem) => row.total_count ?? row.total_rows ?? 0
},
{
prop: 'success_count',
label: '成功数',
width: 100,
formatter: (row: ExportTaskItem) => row.success_count ?? 0
},
{
prop: 'failed_count',
label: '失败数',
width: 100,
formatter: (row: ExportTaskItem) => row.failed_count ?? 0
},
{ prop: 'format', label: '格式', width: 90 },
{ prop: 'total_rows', label: '总行数', width: 110 },
{ prop: 'processed_rows', label: '已处理行数', width: 120 },
@@ -162,12 +185,24 @@
width: 130,
formatter: (row: ExportTaskItem) => `${row.success_shards || 0}/${row.total_shards || 0}`
},
{ prop: 'error_message', label: '错误信息', minWidth: 180, showOverflowTooltip: true },
{
prop: 'error_summary',
label: '错误信息',
minWidth: 180,
showOverflowTooltip: true,
formatter: (row: ExportTaskItem) => row.error_summary || row.error_message || '-'
},
{
prop: 'created_at',
label: '创建时间',
width: 180,
formatter: (row: ExportTaskItem) => formatDateTime(row.created_at) || '-'
},
{
prop: 'updated_at',
label: '更新时间',
width: 180,
formatter: (row: ExportTaskItem) => formatDateTime(row.updated_at) || '-'
}
])
@@ -257,6 +292,25 @@
return res.data
}
const activeTaskPolling = useAsyncTaskPolling<ExportTaskDetail>({
storageKey: computed(() => `export-task-active:${currentScene.value}`).value,
fetchTask: async (taskId) => {
const res = await ExportTaskService.getExportTaskDetail(taskId)
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
})
watch(activeTaskPolling.task, () => {
if (activeTaskPolling.task.value) getTableData()
})
const getRowPermissions = (row: ExportTaskItem) => {
return getExportTaskSceneConfig(row.scene)?.permissions || currentPermissions.value
}
@@ -269,7 +323,7 @@
router.push({
path: RoutesAlias.ExportTaskDetail,
query: { id: row.id }
query: { id: row.task_id ?? row.id }
})
}
@@ -280,7 +334,7 @@
}
try {
const detail = await getTaskDetail(row.id)
const detail = await getTaskDetail(row.task_id ?? row.id)
if (!detail.download_url) {
ElMessage.warning('当前任务暂无可用下载地址')
return
@@ -305,7 +359,7 @@
type: 'warning'
}).then(async () => {
try {
const res = await ExportTaskService.cancelExportTask(row.id)
const res = await ExportTaskService.cancelExportTask(row.task_id ?? row.id)
if (res.code === 0) {
ElMessage.success(res.data?.message || '任务取消成功')
getTableData()