feat: 批量订购上传支付进度与失败明细
All checks were successful
构建并部署前端到测试环境 / build-and-deploy (push) Successful in 8m34s

This commit is contained in:
luo
2026-07-24 15:45:55 +08:00
parent e043fd86d1
commit 9a84cd0d31
16 changed files with 931 additions and 2 deletions

View File

@@ -0,0 +1,65 @@
## Context
批量订购包含文件上传、异步任务处理、任务恢复和逐行失败查看四个阶段。任务创建需要绑定一个代理商和一种支付方式,线下支付还需要上传整批凭证;任务处理过程中可能出现部分成功和钱包余额不足,前端必须展示后端结果而不能把整批操作当成原子事务。
## Goals / Non-Goals
- Goals: 提供批量订单 Excel 上传、支付方式选择、任务进度展示、任务恢复和逐行失败明细。
- Goals: 使用 `request_id` 防止重复提交,并按任务 ID 查询服务端真实状态。
- Goals: 复用公共异步任务的五态语义和终态规则。
- Non-Goals: 不在前端解析 Excel 业务行、不执行订单创建、不计算订单金额、不回滚已成功订单。
- Non-Goals: 不改造现有单笔订单创建和单笔支付凭证上传流程。
- Non-Goals: 不新增后端模板下载接口。
## Decisions
- Decision: 批量订购页面使用独立任务视图,订单列表只提供入口,不把逐行结果嵌入订单列表表格。
- Rationale: 批量任务有独立生命周期和大量逐行结果,独立页面更适合恢复、轮询和分页查看。
- Decision: `payment_method` 在创建表单中为单选值,并在请求中对整批固定;线下支付时才允许提交 `voucher_file`
- Rationale: 产品明确一个批次不能混合支付方式,前端应避免生成含混请求。
- Decision: 使用前端静态 Excel 模板资源。
- Rationale: 产品明确模板下载不依赖后端动态生成,减少接口依赖。
- Decision: 使用 `request_id` 作为客户端幂等键,并在创建前生成一次、提交重试时复用同一值。
- Rationale: 防止网络重试或重复点击创建多个相同批次。
- Decision: 部分成功、钱包余额不足和逐行业务失败均由后端任务结果表达;前端只展示计数和失败明细,不自行推断或回滚。
- Rationale: 钱包扣款和订单事务边界属于后端职责,前端不能可靠重建。
## Data Model
- Create request: `shop_id``payment_method``file`、可选 `voucher_file``request_id`
- Task identity: `task_id``task_no`
- Task status: `1=待处理``2=处理中``3=已完成``4=已失败``5=已取消`;部分成功仍属于已完成终态。
- Task summary: status, total count, success count, failed count, amount summary, timestamps and safe error summary when returned.
- Item result: row number, asset identifier, package code, row status and safe error reason.
- Item query: task ID, page, size and optional row status filter.
## Risks / Trade-offs
- Risk: 后端金额字段名称或单位未在产品文档中明确。
- Mitigation: API 类型和页面以接口实际返回字段为准,统一标注金额单位;实施前补齐字段契约,前端不自行计算汇总。
- Risk: 文件或凭证上传成功但任务创建请求失败,可能留下孤立对象。
- Mitigation: 创建失败时保留用户选择和错误提示;对象清理策略由后端存储生命周期或接口约定处理。
- Risk: 任务详情恢复时任务已过期、删除或用户失去权限。
- Mitigation: 按页面权限和接口错误处理展示对应状态,不重复创建原任务。
## Migration Plan
1. 确认批量订购任务摘要、金额和逐行结果字段契约。
2. 新增 API、类型、权限和静态模板资源。
3. 实现批量订购入口、文件上传、线下凭证上传和幂等提交。
4. 实现任务详情、公共状态展示、轮询和 `task_id` 恢复。
5. 实现逐行结果分页、状态筛选和失败明细展示。
6. 验证钱包余额不足、部分成功、重复提交、刷新恢复和权限组合。
## Open Questions
- 批量订购任务摘要中的金额字段名称、金额单位和金额分类需要以后端接口文档确认。
- 失败明细中的资产字段是统一 `asset_identifier`,还是按资产类型返回 `iccid` / `virtual_no`,需要以后端确认。
- `voucher_file` 是单文件、文件数组还是已上传对象存储 key需要与 multipart 接口契约确认。
- 批量订购入口的最终路由、菜单名称和权限编码需要产品/后端确认。

View File

@@ -0,0 +1,38 @@
# Change: 新增批量订购任务与逐行结果
## Why
运营需要为同一个代理商批量导入套餐订单,并统一指定整批支付方式。当前订单页面只支持单笔创建,无法上传批量订单文件、跟踪处理进度或定位逐行失败原因,批量操作也无法安全处理线下支付凭证和钱包余额不足场景。
## What Changes
- 新增批量订购套餐入口,支持选择代理商、整批支付方式和上传 Excel 文件。
- 支持线下支付时上传整批支付凭证;一个批次禁止混合支付方式。
- 模板下载使用前端静态文件,不依赖后端模板接口。
- 新增批量订购任务 API提交 multipart 字段 `shop_id``payment_method``file``voucher_file``request_id`
- 创建成功后展示任务号、任务状态、总数、成功数、失败数和金额汇总。
- 支持按 `task_id` 恢复任务详情,并轮询进行中的任务。
- 新增逐行结果查询,支持分页和状态筛选。
- 失败明细展示行号、资产、套餐编码和错误原因。
- 部分成功作为任务终态;钱包余额不足只影响对应行或后续行,不回滚已经成功的订单。
- 接入现有 RBAC 权限体系,批量订购创建、任务详情、逐行结果和模板下载权限独立控制。
## Impact
- Affected specs:
- `bulk-purchase-task`
- `order-management`
- `async-task-interaction`(复用现有公共异步任务状态与恢复规则)
- Affected code:
- `src/api/modules/bulkPurchase.ts`
- `src/types/api/bulkPurchase.ts`
- `src/views/order-management/bulk-purchase/index.vue`
- `src/components/business/BulkPurchaseCreateDialog.vue`(如采用独立弹窗)
- `src/views/order-management/order-list/index.vue`(批量入口)
- 静态 Excel 模板资源、路由、菜单、权限和国际化配置
- Dependencies:
- 后端提供三个批量订购接口及 multipart 鉴权契约。
- 对象存储上传能力支持批量订单文件和线下支付凭证上传。
- 需要确认批量订购任务的完整响应字段和并发轮询策略与公共异步任务规范一致。
- Breaking changes:
- 无。新增 API、页面、权限和任务能力不修改单笔订单创建接口。

View File

@@ -0,0 +1,133 @@
## ADDED Requirements
### Requirement: Bulk Purchase Creation Form
The admin frontend SHALL provide a bulk purchase form that binds one uploaded batch to one shop and one payment method.
#### Scenario: Select batch purchase inputs
- **GIVEN** 用户打开批量订购入口
- **WHEN** 用户填写批量订购表单
- **THEN** 页面 MUST require a target shop, one payment method, and an Excel file
- **AND** 页面 MUST NOT provide a way to mix payment methods within the same batch
#### Scenario: Show voucher upload for offline payment
- **GIVEN** 用户选择线下支付
- **WHEN** 页面渲染批量订购表单
- **THEN** 页面 MUST show the batch voucher upload control
- **AND** 页面 MUST require a completed voucher upload before task creation
#### Scenario: Hide voucher upload for wallet payment
- **GIVEN** 用户选择代理钱包支付
- **WHEN** 页面渲染或提交批量订购表单
- **THEN** 页面 MUST NOT submit a voucher file
- **AND** 页面 MUST clear or ignore a voucher selected before switching to wallet payment
### Requirement: Bulk Purchase File Upload And Creation
The admin frontend SHALL create a bulk purchase task through `POST /api/admin/bulk-purchases` using multipart form data.
#### Scenario: Create bulk purchase task
- **GIVEN** 用户已选择代理商、支付方式并完成文件上传
- **WHEN** 用户确认创建批量订购任务
- **THEN** 系统 MUST submit multipart fields `shop_id`, `payment_method`, `file`, and `request_id`
- **AND** 系统 MUST submit `voucher_file` when the payment method is offline
- **AND** 系统 MUST save the returned `task_id` and `task_no`
#### Scenario: Prevent creation during upload
- **GIVEN** Excel 文件或线下支付凭证仍在上传
- **WHEN** 用户点击创建任务
- **THEN** 页面 MUST prevent task creation
- **AND** 页面 MUST show the upload-in-progress state until all required uploads finish
#### Scenario: Download static template
- **GIVEN** 用户打开批量订购表单
- **WHEN** 用户点击模板下载
- **THEN** 页面 MUST download the packaged frontend static Excel template
- **AND** 页面 MUST NOT require a template-generation API request
### Requirement: Bulk Purchase Task Summary And Status
The admin frontend SHALL display the server-provided bulk purchase task summary and use the shared five-state async task semantics.
#### Scenario: Display task summary
- **GIVEN** 批量订购任务创建成功或详情接口返回任务
- **WHEN** 页面展示任务详情
- **THEN** 页面 MUST display task ID or task number, status, total count, success count, failed count, and returned amount summaries
- **AND** 页面 MUST use server-provided values without recalculating amounts or counts from the uploaded file
#### Scenario: Treat partial success as terminal completion
- **GIVEN** 任务已处理完成且同时存在成功行和失败行
- **WHEN** 页面展示任务状态
- **THEN** 页面 MUST display the task as a completed terminal task
- **AND** 页面 MUST display success and failed counts separately
- **AND** 页面 MUST NOT introduce a separate partial-success status
#### Scenario: Handle wallet insufficiency without rollback
- **GIVEN** 代理钱包余额不足导致部分或后续行无法创建订单
- **WHEN** 页面展示任务结果
- **THEN** 页面 MUST show the affected rows as failed with their returned reasons
- **AND** 页面 MUST preserve and display rows that were already successful
- **AND** 页面 MUST NOT attempt a frontend rollback
### Requirement: Bulk Purchase Task Recovery And Polling
The admin frontend SHALL recover a bulk purchase task by `task_id` and poll its summary while it is active.
#### Scenario: Query task summary
- **GIVEN** 用户需要加载批量订购任务
- **WHEN** 页面查询任务摘要
- **THEN** 系统 MUST call `GET /api/admin/bulk-purchases/{task_id}`
- **AND** 页面 MUST update the task summary from the response
#### Scenario: Recover task after refresh
- **GIVEN** 页面存在已保存的 active `task_id`
- **WHEN** 用户刷新页面或重新进入批量订购任务页
- **THEN** 页面 MUST query the existing task by `task_id`
- **AND** 页面 MUST NOT create another bulk purchase task
#### Scenario: Stop polling at terminal state
- **GIVEN** 批量订购任务状态为已完成、已失败或已取消
- **WHEN** 页面收到任务摘要
- **THEN** 页面 MUST stop automatic polling
- **AND** 页面 MUST retain the terminal summary and failure counts for viewing
### Requirement: Bulk Purchase Item Results
The admin frontend SHALL provide paginated and status-filterable row results for a bulk purchase task.
#### Scenario: Query item results
- **GIVEN** 用户打开批量订购任务结果
- **WHEN** 页面加载逐行结果
- **THEN** 系统 MUST call `GET /api/admin/bulk-purchases/{task_id}/items`
- **AND** 系统 MUST support `page`, `size`, and optional `status` query parameters
#### Scenario: Display failed item details
- **GIVEN** 逐行结果接口返回失败行
- **WHEN** 页面渲染结果表格
- **THEN** 页面 MUST display row number, asset identifier, package code, and safe error reason
- **AND** 页面 MUST NOT expose raw backend stack traces or technical error details
### Requirement: Bulk Purchase Permissions
The admin frontend SHALL gate bulk purchase creation, task viewing, item-result viewing, and template download with explicit permissions.
#### Scenario: Hide unauthorized bulk purchase operations
- **GIVEN** 当前用户缺少某项批量订购权限
- **WHEN** 页面渲染对应入口或操作
- **THEN** 页面 MUST NOT display or enable that operation
- **AND** direct API failure MUST NOT be treated as permission to continue

View File

@@ -0,0 +1,46 @@
## 1. Contract And Types
- [ ] 1.1 确认 `POST /api/admin/bulk-purchases` 的 multipart 字段类型、文件字段格式和响应结构。
- [ ] 1.2 确认 `GET /api/admin/bulk-purchases/{task_id}` 的任务摘要字段、状态值、金额字段和错误字段。
- [ ] 1.3 确认 `GET /api/admin/bulk-purchases/{task_id}/items` 的逐行字段、分页结构和状态筛选参数。
- [x] 1.4 新增批量订购 API service、请求类型、任务摘要类型和逐行结果类型。
- [x] 1.5 明确 `request_id` 的生成、保存和重复提交响应处理规则。
## 2. Upload And Create
- [x] 2.1 新增批量订购入口和表单,支持选择代理商、支付方式和 Excel 文件。
- [x] 2.2 提供前端静态 Excel 模板下载,并限制上传文件类型和必要的文件状态。
- [x] 2.3 线下支付时显示整批支付凭证上传,钱包支付时隐藏或清理凭证字段。
- [x] 2.4 复用订单列表已有 `VoucherUpload` 组件展示凭证上传进度,上传未完成时禁止创建任务。
- [x] 2.5 创建请求携带 `shop_id``payment_method``file`、必要的 `voucher_file``request_id`
- [x] 2.6 防止重复点击和重复提交,创建成功后保存 `task_id` 并进入任务详情。
## 3. Task Progress And Recovery
- [x] 3.1 展示任务号、状态、总数、成功数、失败数、金额汇总和安全错误摘要。
- [x] 3.2 按公共异步任务规则轮询待处理和处理中的任务,并在终态停止。
- [x] 3.3 页面刷新或通过任务 ID 重新进入时恢复任务详情,不重复创建批次。
- [x] 3.4 将部分成功展示为已完成终态,并同时展示成功数和失败数。
- [x] 3.5 展示钱包余额不足导致的逐行或后续行失败,不回滚已成功订单。
## 4. Item Results
- [x] 4.1 新增逐行结果表格,展示行号、资产、套餐编码、状态和错误原因。
- [x] 4.2 支持按逐行状态筛选和分页查询。
- [x] 4.3 失败原因优先展示后端安全错误文案,不直接展示底层技术错误。
- [x] 4.4 任务详情和逐行结果保持当前任务 ID、筛选条件和分页状态。
## 5. Permissions And Verification
- [x] 5.1 为批量订购创建、任务详情、逐行结果和模板下载配置独立权限。
- [ ] 5.2 验证一个批次只能提交一种支付方式,线下支付缺少凭证时不能提交。
- [ ] 5.3 验证重复提交复用 `request_id`,不会创建重复任务。
- [ ] 5.4 验证空文件、上传失败、任务失败、部分成功和钱包余额不足场景。
- [ ] 5.5 验证页面刷新恢复、任务终态停止轮询和逐行失败筛选。
- [x] 5.6 运行 `openspec validate add-bulk-purchase-upload-task --strict`、类型检查、lint 和构建。
## Implementation Notes
- 后端接口文档未随仓库提供1.1、1.2、1.3 仍需联调确认精确金额字段、逐行字段和 multipart 文件格式;当前类型使用可选兼容字段。
- 5.2 至 5.5 需要接入真实后端后进行手工回归,当前已完成前端校验、权限、幂等键、恢复、轮询和筛选逻辑。
- 支付凭证复用 `src/components/business/VoucherUpload.vue`,提交 `voucher_file` 时使用该组件上传后返回的文件 key。

Binary file not shown.

View File

@@ -0,0 +1,31 @@
import request from '@/utils/http'
import type {
BulkPurchaseCreateApiResponse,
BulkPurchaseItemsApiResponse,
BulkPurchaseTaskApiResponse
} from '@/types/api'
export class BulkPurchaseService {
static createTask(data: FormData): Promise<BulkPurchaseCreateApiResponse> {
return request.post<BulkPurchaseCreateApiResponse>({
url: '/api/admin/bulk-purchases',
data
})
}
static getTask(taskId: number): Promise<BulkPurchaseTaskApiResponse> {
return request.get<BulkPurchaseTaskApiResponse>({
url: `/api/admin/bulk-purchases/${taskId}`
})
}
static getItems(
taskId: number,
params?: { page?: number; size?: number; status?: string | number }
): Promise<BulkPurchaseItemsApiResponse> {
return request.get<BulkPurchaseItemsApiResponse>({
url: `/api/admin/bulk-purchases/${taskId}/items`,
params
})
}
}

View File

@@ -34,6 +34,7 @@ export { PollingMonitorService } from './pollingMonitor'
export { SuperAdminService } from './superAdmin'
export { ExportTaskService } from './exportTask'
export { OrderPackageInvalidateTaskService } from './orderPackageInvalidateTask'
export { BulkPurchaseService } from './bulkPurchase'
// TODO: 按需添加其他业务模块
// export { SettingService } from './setting'

View File

@@ -12,6 +12,7 @@
drag
multiple
:auto-upload="false"
:accept="accept"
:on-change="handleFileChange"
:on-remove="handleRemoveFile"
list-type="picture"
@@ -20,7 +21,7 @@
<div class="voucher-upload__text">{{ voucherName }}拖到此处<em>点击上传</em></div>
<template #tip>
<div class="el-upload__tip">
支持多张图片或附件最多上传 {{ maxCount }} 个文件可粘贴文件
{{ tip || `支持多张图片或附件,最多上传 ${maxCount} 个文件;可粘贴文件。` }}
</div>
</template>
<template #file="{ file }">
@@ -61,11 +62,15 @@
modelValue?: string[] | string
voucherName?: string
maxCount?: number
accept?: string
tip?: string
}
const props = withDefaults(defineProps<Props>(), {
voucherName: '凭证',
maxCount: 5
maxCount: 5,
accept: '',
tip: ''
})
const emit = defineEmits<{
@@ -171,6 +176,20 @@
const file = uploadFile.raw
if (!file) return
if (props.accept) {
const accepted = props.accept.split(',').some((type) => {
const value = type.trim().toLowerCase()
return value.startsWith('.')
? file.name.toLowerCase().endsWith(value)
: file.type.toLowerCase() === value
})
if (!accepted) {
ElMessage.warning(`只能上传 ${props.accept} 格式的文件`)
removeUploadFile(uploadFile)
return
}
}
if (!selectedUploadUids.has(uploadFile.uid)) {
if (selectedUploadUids.size >= getMaxCount()) {
showMaxCountWarning()

View File

@@ -0,0 +1,7 @@
export const BULK_PURCHASE_PERMISSIONS = {
page: 'bulk_purchase:view',
create: 'bulk_purchase:create',
detail: 'bulk_purchase:detail',
items: 'bulk_purchase:items',
template: 'bulk_purchase:template'
} as const

View File

@@ -34,3 +34,6 @@ export * from './enableStatus'
// 导出任务相关
export * from './exportTask'
// 批量订购相关
export * from './bulkPurchase'

View File

@@ -1,5 +1,6 @@
import { RoutesAlias } from '../routesAlias'
import { AppRouteRecord } from '@/types/router'
import { BULK_PURCHASE_PERMISSIONS } from '@/config/constants/bulkPurchase'
/**
* 菜单列表、异步路由
@@ -525,6 +526,17 @@ export const asyncRoutes: AppRouteRecord[] = [
isHide: true,
keepAlive: false
}
},
// 批量订购
{
path: 'bulk-purchase',
name: 'BulkPurchase',
component: RoutesAlias.BulkPurchase,
meta: {
title: '批量订购套餐',
permissions: [BULK_PURCHASE_PERMISSIONS.page],
keepAlive: true
}
}
]
},

View File

@@ -68,6 +68,7 @@ export enum RoutesAlias {
// 订单管理
OrderList = '/order-management/order-list', // 订单列表
OrderDetail = '/order-management/order-list/detail', // 订单详情
BulkPurchase = '/order-management/bulk-purchase', // 批量订购
// 财务管理
AgentRecharge = '/finance/agent-recharge', // 代理充值

View File

@@ -0,0 +1,67 @@
import type { BaseResponse } from './common'
export type BulkPurchasePaymentMethod = 'wallet' | 'offline'
export enum BulkPurchaseTaskStatus {
PENDING = 1,
PROCESSING = 2,
COMPLETED = 3,
FAILED = 4,
CANCELED = 5
}
export interface BulkPurchaseCreateResponse {
task_id: number
task_no?: string
status?: BulkPurchaseTaskStatus
status_name?: string
message?: string
}
export interface BulkPurchaseTask {
id?: number
task_id: number
task_no?: string
status: BulkPurchaseTaskStatus
status_name?: string
total_count?: number
success_count?: number
failed_count?: number
total_amount?: number
success_amount?: number
failed_amount?: number
amount_summary?: Record<string, number | string>
error_code?: string
error_summary?: string
created_at?: string
updated_at?: string
started_at?: string
completed_at?: string
}
export interface BulkPurchaseItem {
id?: number
row_number?: number
line?: number
asset_identifier?: string
iccid?: string
virtual_no?: string
package_code?: string
status?: string | number
status_name?: string
error_code?: string
error_reason?: string
error_summary?: string
}
export interface BulkPurchaseItemsResponse {
items: BulkPurchaseItem[]
page?: number
size?: number
page_size?: number
total?: number
}
export type BulkPurchaseCreateApiResponse = BaseResponse<BulkPurchaseCreateResponse>
export type BulkPurchaseTaskApiResponse = BaseResponse<BulkPurchaseTask>
export type BulkPurchaseItemsApiResponse = BaseResponse<BulkPurchaseItemsResponse>

View File

@@ -116,3 +116,6 @@ export * from './asyncTask'
// 订单套餐批量作废任务相关
export * from './orderPackageInvalidateTask'
// 批量订购任务相关
export * from './bulkPurchase'

View File

@@ -0,0 +1,495 @@
<template>
<ArtTableFullScreen>
<div class="bulk-purchase-page" id="table-full-screen">
<ElCard shadow="never" class="art-table-card">
<template #header>
<div class="page-header">
<div>
<h2>批量订购套餐</h2>
<p>同一批次只能选择一种支付方式系统按任务结果逐行处理订单</p>
</div>
<ElButton
v-if="hasAuth(BULK_PURCHASE_PERMISSIONS.template)"
tag="a"
href="/templates/bulk-purchase-template.xlsx"
download="批量订购模板.xlsx"
>
下载模板
</ElButton>
</div>
</template>
<ElForm ref="formRef" :model="form" :rules="rules" label-width="110px" class="create-form">
<ElFormItem label="代理商" prop="shop_id">
<ElSelect
v-model="form.shop_id"
filterable
remote
reserve-keyword
clearable
placeholder="请选择代理商"
:remote-method="searchShops"
:loading="shopLoading"
style="width: 100%"
>
<ElOption
v-for="shop in shopOptions"
:key="shop.id"
:label="`${shop.shop_name} (${shop.shop_code})`"
:value="shop.id"
/>
</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"
accept=".xlsx,.xls,.csv"
tip="支持 .xlsx、.xls 或 .csv 文件"
@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="submitting || voucherUploading || orderFileUploading"
type="info"
:closable="false"
show-icon
:title="
voucherUploading
? '支付凭证上传中,请稍候'
: orderFileUploading
? '订单文件上传中,请稍候'
: '批量订购文件上传及任务创建中,请勿重复提交'
"
/>
<div class="form-actions">
<ElButton
type="primary"
:loading="submitting || voucherUploading || orderFileUploading"
:disabled="
voucherUploading || orderFileUploading || !hasAuth(BULK_PURCHASE_PERMISSIONS.create)
"
@click="submitTask"
>
创建批量订购任务
</ElButton>
<ElButton v-if="taskDetail" @click="resetTask">重新创建</ElButton>
</div>
</ElForm>
</ElCard>
<ElCard
v-if="taskDetail && hasAuth(BULK_PURCHASE_PERMISSIONS.detail)"
shadow="never"
class="art-table-card task-card"
>
<template #header>
<div class="task-header">
<span>任务结果</span>
<ElTag :type="getStatusType(taskDetail.status)">
{{ taskDetail.status_name || getStatusName(taskDetail.status) }}
</ElTag>
</div>
</template>
<ElDescriptions :column="4" border>
<ElDescriptionsItem label="任务号">{{
taskDetail.task_no || taskDetail.task_id
}}</ElDescriptionsItem>
<ElDescriptionsItem label="总数">{{ taskDetail.total_count ?? 0 }}</ElDescriptionsItem>
<ElDescriptionsItem label="成功数">{{
taskDetail.success_count ?? 0
}}</ElDescriptionsItem>
<ElDescriptionsItem label="失败数">{{ taskDetail.failed_count ?? 0 }}</ElDescriptionsItem>
<ElDescriptionsItem label="总金额">{{
formatAmount(taskDetail.total_amount)
}}</ElDescriptionsItem>
<ElDescriptionsItem label="成功金额">{{
formatAmount(taskDetail.success_amount)
}}</ElDescriptionsItem>
<ElDescriptionsItem label="失败金额">{{
formatAmount(taskDetail.failed_amount)
}}</ElDescriptionsItem>
<ElDescriptionsItem label="更新时间">{{
formatDateTime(taskDetail.updated_at)
}}</ElDescriptionsItem>
</ElDescriptions>
<ElAlert
v-if="taskDetail.error_summary"
class="task-error"
type="error"
:closable="false"
:title="taskDetail.error_summary"
/>
<div v-if="hasAuth(BULK_PURCHASE_PERMISSIONS.items)" class="items-toolbar">
<ElSelect v-model="itemStatus" clearable placeholder="筛选行状态" @change="reloadItems">
<ElOption label="成功" value="success" />
<ElOption label="失败" value="failed" />
<ElOption label="处理中" value="processing" />
</ElSelect>
<ElButton @click="reloadItems">刷新结果</ElButton>
</div>
<ElTable
v-if="hasAuth(BULK_PURCHASE_PERMISSIONS.items)"
v-loading="itemsLoading"
:data="items"
border
>
<ElTableColumn label="行号" width="90">
<template #default="scope">{{
scope.row.row_number ?? scope.row.line ?? '-'
}}</template>
</ElTableColumn>
<ElTableColumn label="资产" min-width="180">
<template #default="scope">{{ getAssetIdentifier(scope.row) }}</template>
</ElTableColumn>
<ElTableColumn prop="package_code" label="套餐编码" min-width="150" />
<ElTableColumn label="状态" width="110">
<template #default="scope">{{
scope.row.status_name || scope.row.status || '-'
}}</template>
</ElTableColumn>
<ElTableColumn label="错误原因" min-width="240" show-overflow-tooltip>
<template #default="scope">
{{ scope.row.error_summary || scope.row.error_reason || scope.row.error_code || '-' }}
</template>
</ElTableColumn>
</ElTable>
<div v-if="hasAuth(BULK_PURCHASE_PERMISSIONS.items)" class="pagination-wrapper">
<ElPagination
v-model:current-page="itemsPage"
v-model:page-size="itemsSize"
layout="total, sizes, prev, pager, next"
:total="itemsTotal"
@current-change="loadItems"
@size-change="reloadItems"
/>
</div>
</ElCard>
</div>
</ArtTableFullScreen>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import {
ElAlert,
ElButton,
ElCard,
ElDescriptions,
ElDescriptionsItem,
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, ShopService } from '@/api/modules'
import type {
BulkPurchaseItem,
BulkPurchasePaymentMethod,
BulkPurchaseTask,
ShopResponse
} 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: 'BulkPurchase' })
const route = useRoute()
const router = useRouter()
const { hasAuth } = useAuth()
const formRef = ref<FormInstance>()
const orderUploadRef = ref<InstanceType<typeof VoucherUpload>>()
const voucherUploadRef = ref<InstanceType<typeof VoucherUpload>>()
const shopOptions = ref<ShopResponse[]>([])
const shopLoading = ref(false)
const submitting = ref(false)
const orderFileKeys = ref<string[]>([])
const orderFileUploading = ref(false)
const voucherFileKeys = ref<string[]>([])
const voucherUploading = ref(false)
const itemStatus = ref<string | undefined>()
const items = ref<BulkPurchaseItem[]>([])
const itemsLoading = ref(false)
const itemsPage = ref(1)
const itemsSize = ref(20)
const itemsTotal = ref(0)
const requestId = ref('')
const form = reactive<{
shop_id?: number
payment_method: BulkPurchasePaymentMethod
}>({
shop_id: undefined,
payment_method: 'wallet'
})
const rules = computed<FormRules>(() => ({
shop_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 polling = useAsyncTaskPolling<BulkPurchaseTask>({
storageKey: 'bulk-purchase-active-task',
fetchTask: async (taskId) => {
const res = await BulkPurchaseService.getTask(taskId)
if (res.code !== 0 || !res.data) throw new Error(res.msg || '获取批量订购任务失败')
return res.data
}
})
const taskDetail = polling.task
const getRequestId = () => {
if (requestId.value) return requestId.value
requestId.value =
typeof crypto !== 'undefined' && crypto.randomUUID
? crypto.randomUUID()
: `${Date.now()}-${Math.random().toString(16).slice(2)}`
return requestId.value
}
const searchShops = async (query: string) => {
shopLoading.value = true
try {
const res = await ShopService.getShops({
page: 1,
page_size: 20,
shop_name: query || undefined
})
if (res.code === 0) shopOptions.value = res.data.items || []
} finally {
shopLoading.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.shop_id || orderFileKeys.value.length === 0) return
submitting.value = true
try {
const data = new FormData()
data.append('shop_id', String(form.shop_id))
data.append('payment_method', form.payment_method)
data.append('file', orderFileKeys.value[0])
data.append('request_id', getRequestId())
if (form.payment_method === 'offline' && voucherFileKeys.value.length > 0) {
data.append('voucher_file', voucherFileKeys.value[0])
}
const res = await BulkPurchaseService.createTask(data)
if (res.code !== 0 || !res.data?.task_id) {
ElMessage.error(res.msg || '创建批量订购任务失败')
return
}
ElMessage.success(res.data.message || '批量订购任务已创建')
requestId.value = ''
await router.replace({
path: route.path,
query: { task_id: String(res.data.task_id) }
})
await polling.start(res.data.task_id)
await loadItems()
} catch (error: any) {
ElMessage.error(error?.message || '创建批量订购任务失败')
} finally {
submitting.value = false
}
}
const loadItems = async () => {
const taskId = taskDetail.value?.task_id
if (!taskId || !hasAuth(BULK_PURCHASE_PERMISSIONS.items)) return
itemsLoading.value = true
try {
const res = await BulkPurchaseService.getItems(taskId, {
page: itemsPage.value,
size: itemsSize.value,
status: itemStatus.value
})
if (res.code === 0 && res.data) {
items.value = res.data.items || []
itemsTotal.value = res.data.total || 0
} else {
ElMessage.error(res.msg || '获取批量订购明细失败')
}
} catch (error: any) {
ElMessage.error(error?.message || '获取批量订购明细失败')
} finally {
itemsLoading.value = false
}
}
const reloadItems = () => {
itemsPage.value = 1
void loadItems()
}
const resetTask = () => {
polling.clear()
requestId.value = ''
items.value = []
itemsTotal.value = 0
itemsPage.value = 1
router.replace({ path: route.path })
}
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 formatAmount = (amount?: number) => (amount === undefined ? '-' : formatMoney(amount))
const getAssetIdentifier = (item: BulkPurchaseItem) =>
item.asset_identifier || item.iccid || item.virtual_no || '-'
watch(taskDetail, () => {
itemsPage.value = 1
void loadItems()
})
onMounted(() => {
if (hasAuth(BULK_PURCHASE_PERMISSIONS.create)) void searchShops('')
const routeTaskId = Number(route.query.task_id)
if (routeTaskId && routeTaskId !== polling.taskId.value) void polling.start(routeTaskId)
})
</script>
<style scoped lang="scss">
.bulk-purchase-page {
.page-header,
.task-header,
.form-actions,
.items-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.page-header h2 {
margin: 0;
font-size: 20px;
}
.page-header p {
margin: 8px 0 0;
color: var(--el-text-color-secondary);
}
.create-form {
max-width: 760px;
}
.task-card {
margin-top: 16px;
}
.task-error {
margin-top: 16px;
}
.items-toolbar {
justify-content: flex-start;
margin: 20px 0 12px;
}
.pagination-wrapper {
display: flex;
justify-content: flex-end;
margin-top: 16px;
}
}
</style>

View File

@@ -22,6 +22,13 @@
<ElButton @click="showCreateDialog" v-permission="'orders:add'">{{
'创建订单'
}}</ElButton>
<ElButton
v-if="hasAuth(BULK_PURCHASE_PERMISSIONS.create)"
type="primary"
@click="router.push(RoutesAlias.BulkPurchase)"
>
批量订购
</ElButton>
<ElButton v-if="hasAuth('orders:export')" @click="exportDialogVisible = true">{{
'导出'
}}</ElButton>
@@ -278,6 +285,7 @@
import PaymentVoucherDialog from '@/components/business/PaymentVoucherDialog.vue'
import ExportTaskCreateDialog from '@/components/business/ExportTaskCreateDialog.vue'
import VoucherUpload from '@/components/business/VoucherUpload.vue'
import { BULK_PURCHASE_PERMISSIONS } from '@/config/constants/bulkPurchase'
defineOptions({ name: 'OrderList' })