feat(order): 订单批量任务管理页与 SPA 路由配置
新增批量任务列表/详情治理界面,补齐 API、权限菜单与 nginx/vite 前端路由白名单。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
7585e0c7b7
commit
e356ddb9cf
@ -6,7 +6,7 @@ alwaysApply: true
|
|||||||
|
|
||||||
# 中后台前端设计规范(itfx-front)
|
# 中后台前端设计规范(itfx-front)
|
||||||
|
|
||||||
开发新页面或修改现有页面时,必须遵循 `docs/中后台前端页面设计规范.md` 中的约定。
|
开发新页面或修改现有页面时,必须遵循 `docs/前端页面设计规范.md` 中的约定。
|
||||||
|
|
||||||
## 关键规则速查
|
## 关键规则速查
|
||||||
|
|
||||||
@ -28,7 +28,10 @@ alwaysApply: true
|
|||||||
|
|
||||||
### 表格
|
### 表格
|
||||||
- TablePro:api 返回 totalCount + items
|
- TablePro:api 返回 totalCount + items
|
||||||
|
- HandsontablePro 列定义用 `key` + `label`,勿用 `data` + `title`
|
||||||
|
- 列表页高度:`flex: 1; min-height: 0`,`.list-wrap` 勿写死 min-height
|
||||||
- 分页 10/30/50/100,默认 30
|
- 分页 10/30/50/100,默认 30
|
||||||
|
- 新页面 path 与 API 前缀冲突时须配置 nginx SPA 回退(见规范 §十五)
|
||||||
|
|
||||||
### 权限
|
### 权限
|
||||||
- `hasPermission('Module.Resource.Action')` 控制按钮/路由
|
- `hasPermission('Module.Resource.Action')` 控制按钮/路由
|
||||||
@ -39,4 +42,4 @@ alwaysApply: true
|
|||||||
- 路由 path:camelCase
|
- 路由 path:camelCase
|
||||||
- 变量:camelCase
|
- 变量:camelCase
|
||||||
|
|
||||||
完整规范见 `docs/中后台前端页面设计规范.md`。
|
完整规范见 `docs/前端页面设计规范.md`。
|
||||||
|
|||||||
@ -311,6 +311,36 @@ itemList: [
|
|||||||
- 删除使用 `a-popconfirm`
|
- 删除使用 `a-popconfirm`
|
||||||
- 操作按钮需权限控制(见「权限」)
|
- 操作按钮需权限控制(见「权限」)
|
||||||
|
|
||||||
|
### 7.5 HandsontablePro 列定义(必读)
|
||||||
|
|
||||||
|
**HandsontablePro 的 `columns` 与原生 Handsontable 不同**,必须使用项目约定的 `ColumnDef` 结构:
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `key` | `string` | 对应行数据字段名(如 `jobId`、`statusLabel`) |
|
||||||
|
| `label` | `string` | 表头文案(支持 `t('...')`) |
|
||||||
|
| `width` | `number` | 列宽(像素) |
|
||||||
|
|
||||||
|
**正确示例:**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const hotColumns = computed(() => [
|
||||||
|
{ key: 'jobId', label: t('order.batchJobs.table.jobId'), width: 90 },
|
||||||
|
{ key: 'statusLabel', label: t('order.batchJobs.table.status'), width: 100 },
|
||||||
|
])
|
||||||
|
```
|
||||||
|
|
||||||
|
**错误示例(会导致表头、单元格均为空):**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// ❌ 勿用 Handsontable 原生 data/title
|
||||||
|
{ data: 'jobId', title: '任务 ID', width: 90 }
|
||||||
|
```
|
||||||
|
|
||||||
|
**操作列渲染器**签名固定为 `(rowData, rowIndex) => string`,勿写成 `(row, col, rowData)`。
|
||||||
|
|
||||||
|
行数据字段名须与 `key` 一致;展示用衍生字段(如 `actionLabel`)在 `mapRow` 中提前组装。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 八、交互规范
|
## 八、交互规范
|
||||||
@ -503,14 +533,39 @@ const { t } = useI18n();
|
|||||||
- [ ] 弹窗 / 抽屉使用 ModalPro / DrawerPro + useModal
|
- [ ] 弹窗 / 抽屉使用 ModalPro / DrawerPro + useModal
|
||||||
- [ ] 表单使用 FormPro 或统一 label/span
|
- [ ] 表单使用 FormPro 或统一 label/span
|
||||||
- [ ] 表格优先 TablePro / CustomList / HandsontablePro
|
- [ ] 表格优先 TablePro / CustomList / HandsontablePro
|
||||||
|
- [ ] **HandsontablePro** 列定义使用 **`key` + `label`**,勿用 `data` + `title`(**§7.5**)
|
||||||
- [ ] 分页组件置于**列表上方工具栏右侧**(列表右上角),**§7.3**;分页 10/30/50/100,默认 30
|
- [ ] 分页组件置于**列表上方工具栏右侧**(列表右上角),**§7.3**;分页 10/30/50/100,默认 30
|
||||||
- [ ] 操作按钮按权限使用 `hasPermission`
|
- [ ] 操作按钮按权限使用 `hasPermission`
|
||||||
- [ ] 路由 meta 配置 `permission`
|
- [ ] 路由 meta 配置 `permission`
|
||||||
|
- [ ] **新增前端页面路径**若落在 `/order/`、`/channel/`、`/system/` 等与 Admin API 同源前缀下,须在 **`nginx-default.conf` / `nginx-docker.conf`** 增加 `^~` 的 `try_files /index.html =200` 规则(**§十五**),避免刷新/直链 404
|
||||||
- [ ] 文案通过 i18n,不写死中文
|
- [ ] 文案通过 i18n,不写死中文
|
||||||
- [ ] 命名符合本规范(文件、路由、变量、i18n key)
|
- [ ] 命名符合本规范(文件、路由、变量、i18n key)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 十五、部署与 nginx(SPA 路由)
|
||||||
|
|
||||||
|
生产环境 nginx 将 `/order/`、`/channel/`、`/system/` 等前缀**同时**用于 Admin API 反代。浏览器刷新或直接打开前端 URL(如 `/order/batchJobs`)时,若未单独配置 SPA 回退,请求会被转发到后端 API 并返回 **404**。
|
||||||
|
|
||||||
|
**约定:**
|
||||||
|
|
||||||
|
1. 每新增一个**前端页面路由**,若其 path 与 API 前缀冲突,必须在 `nginx-default.conf` 与 `nginx-docker.conf` 中、**API 正则代理块之前**增加更高优先级的 `^~` 规则,例如:
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
# 订单前端路由(^~ 须比下方 /order/ API 正则代理更优先)
|
||||||
|
location ^~ /order/batchJobs {
|
||||||
|
try_files /index.html =200;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
2. 修改后执行 `nginx -t && nginx -s reload`(或重建前端 Docker 镜像使 `nginx-docker.conf` 生效)。
|
||||||
|
|
||||||
|
3. 本地 Vite 开发不受此限制;**仅在部署环境**需遵守本节前缀规则。
|
||||||
|
|
||||||
|
当前订单模块已配置的前端路径示例:`/order/priceCheck`、`/order/placeOrder`、`/order/myOrders`、`/order/batchJobs`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 附录:常用代码片段
|
## 附录:常用代码片段
|
||||||
|
|
||||||
### 页面头部 + 权限按钮
|
### 页面头部 + 权限按钮
|
||||||
@ -561,6 +616,42 @@ const { t } = useI18n();
|
|||||||
</template>
|
</template>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### HandsontablePro 列表页(列 + 高度)
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<script setup lang="ts">
|
||||||
|
import HandsontablePro from '@/components/HandsontablePro/index.vue'
|
||||||
|
import { useTableContainerHeight } from '@/composables/useTableContainerHeight'
|
||||||
|
|
||||||
|
const { tableContainerRef, tableHeight: handsontableHeight } = useTableContainerHeight()
|
||||||
|
|
||||||
|
const hotColumns = computed(() => [
|
||||||
|
{ key: 'jobId', label: t('module.table.jobId'), width: 90 },
|
||||||
|
{ key: 'statusLabel', label: t('module.table.status'), width: 100 },
|
||||||
|
])
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="module-page">
|
||||||
|
<div class="module-page-card">
|
||||||
|
<div class="table-toolbar">...</div>
|
||||||
|
<a-spin class="list-spin" :loading="listLoading">
|
||||||
|
<div ref="tableContainerRef" class="list-wrap">
|
||||||
|
<HandsontablePro
|
||||||
|
:columns="hotColumns"
|
||||||
|
:data="tableRows"
|
||||||
|
:height="handsontableHeight"
|
||||||
|
:stretch-columns="true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</a-spin>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
```
|
||||||
|
|
||||||
|
布局样式见 **§2.5**(`flex: 1; min-height: 0`,勿给 `.list-wrap` 写死 `min-height: 480px` 导致列表区塌缩或撑破)。
|
||||||
|
|
||||||
### FormPro 查询表单
|
### FormPro 查询表单
|
||||||
|
|
||||||
```vue
|
```vue
|
||||||
@ -575,4 +666,4 @@ const { t } = useI18n();
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
*文档版本:1.1 | 更新日期:2026-03-23*
|
*文档版本:1.2 | 更新日期:2026-03-23*
|
||||||
|
|||||||
@ -337,6 +337,17 @@ const menuData = [
|
|||||||
hideInMenu: false,
|
hideInMenu: false,
|
||||||
parentRoute: '/order',
|
parentRoute: '/order',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
level: 2,
|
||||||
|
name: '批量任务',
|
||||||
|
permissionCode: 'Order.BatchJobs',
|
||||||
|
route: '/order/batchJobs',
|
||||||
|
component: 'views/order/batchJobs/index',
|
||||||
|
icon: null,
|
||||||
|
order: 4,
|
||||||
|
hideInMenu: false,
|
||||||
|
parentRoute: '/order',
|
||||||
|
},
|
||||||
|
|
||||||
// 一级菜单 - 渠道
|
// 一级菜单 - 渠道
|
||||||
{
|
{
|
||||||
|
|||||||
@ -103,6 +103,20 @@ server {
|
|||||||
try_files /index.html =200;
|
try_files /index.html =200;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# ============ 订单前端路由(^~ 须比下方 /order/ API 正则代理更优先)============
|
||||||
|
location ^~ /order/priceCheck {
|
||||||
|
try_files /index.html =200;
|
||||||
|
}
|
||||||
|
location ^~ /order/placeOrder {
|
||||||
|
try_files /index.html =200;
|
||||||
|
}
|
||||||
|
location ^~ /order/myOrders {
|
||||||
|
try_files /index.html =200;
|
||||||
|
}
|
||||||
|
location ^~ /order/batchJobs {
|
||||||
|
try_files /index.html =200;
|
||||||
|
}
|
||||||
|
|
||||||
# ============ 系统管理前端路由(^~ 须比下方 /system/ API 正则代理更优先)============
|
# ============ 系统管理前端路由(^~ 须比下方 /system/ API 正则代理更优先)============
|
||||||
# 页面路径为 camelCase;API 为 kebab(如 /system/dict/list、/system/batch-email/…),勿用宽泛前缀匹配 dict
|
# 页面路径为 camelCase;API 为 kebab(如 /system/dict/list、/system/batch-email/…),勿用宽泛前缀匹配 dict
|
||||||
location = /system/dict {
|
location = /system/dict {
|
||||||
|
|||||||
@ -40,6 +40,12 @@ server {
|
|||||||
location ^~ /operation/outboundScan { try_files /index.html =200; }
|
location ^~ /operation/outboundScan { try_files /index.html =200; }
|
||||||
location ^~ /operation/printLabel { try_files /index.html =200; }
|
location ^~ /operation/printLabel { try_files /index.html =200; }
|
||||||
|
|
||||||
|
# 订单前端路由(^~ 须比下方 /order/ API 正则代理更优先)
|
||||||
|
location ^~ /order/priceCheck { try_files /index.html =200; }
|
||||||
|
location ^~ /order/placeOrder { try_files /index.html =200; }
|
||||||
|
location ^~ /order/myOrders { try_files /index.html =200; }
|
||||||
|
location ^~ /order/batchJobs { try_files /index.html =200; }
|
||||||
|
|
||||||
location ^~ /channel/salesProducts { try_files /index.html =200; }
|
location ^~ /channel/salesProducts { try_files /index.html =200; }
|
||||||
location ^~ /channel/serviceChannels { try_files /index.html =200; }
|
location ^~ /channel/serviceChannels { try_files /index.html =200; }
|
||||||
location ^~ /channel/priceArea { try_files /index.html =200; }
|
location ^~ /channel/priceArea { try_files /index.html =200; }
|
||||||
|
|||||||
@ -272,6 +272,308 @@ export interface OrderBatchActionResult {
|
|||||||
Failures?: OrderBatchActionFailure[]
|
Failures?: OrderBatchActionFailure[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface OrderBatchJobOutput {
|
||||||
|
jobId?: number
|
||||||
|
JobId?: number
|
||||||
|
action?: string
|
||||||
|
Action?: string
|
||||||
|
status?: string
|
||||||
|
Status?: string
|
||||||
|
total?: number
|
||||||
|
Total?: number
|
||||||
|
successCount?: number
|
||||||
|
SuccessCount?: number
|
||||||
|
failCount?: number
|
||||||
|
FailCount?: number
|
||||||
|
startedAt?: string
|
||||||
|
StartedAt?: string
|
||||||
|
finishedAt?: string
|
||||||
|
FinishedAt?: string
|
||||||
|
createTime?: string
|
||||||
|
CreateTime?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OrderBatchJobItemOutput {
|
||||||
|
id?: number
|
||||||
|
Id?: number
|
||||||
|
orderId?: number
|
||||||
|
OrderId?: number
|
||||||
|
serviceNo?: string
|
||||||
|
ServiceNo?: string
|
||||||
|
status?: string
|
||||||
|
Status?: string
|
||||||
|
failReason?: string
|
||||||
|
FailReason?: string
|
||||||
|
startedAt?: string
|
||||||
|
StartedAt?: string
|
||||||
|
finishedAt?: string
|
||||||
|
FinishedAt?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseOrderBatchJob(raw: any): {
|
||||||
|
jobId: number
|
||||||
|
action: string
|
||||||
|
status: string
|
||||||
|
total: number
|
||||||
|
successCount: number
|
||||||
|
failCount: number
|
||||||
|
startedAt?: string
|
||||||
|
finishedAt?: string
|
||||||
|
createTime?: string
|
||||||
|
} {
|
||||||
|
const d = raw?.data ?? raw?.Data ?? raw ?? {}
|
||||||
|
return {
|
||||||
|
jobId: Number(d.jobId ?? d.JobId ?? 0),
|
||||||
|
action: String(d.action ?? d.Action ?? ''),
|
||||||
|
status: String(d.status ?? d.Status ?? ''),
|
||||||
|
total: Number(d.total ?? d.Total ?? 0),
|
||||||
|
successCount: Number(d.successCount ?? d.SuccessCount ?? 0),
|
||||||
|
failCount: Number(d.failCount ?? d.FailCount ?? 0),
|
||||||
|
startedAt: d.startedAt ?? d.StartedAt,
|
||||||
|
finishedAt: d.finishedAt ?? d.FinishedAt,
|
||||||
|
createTime: d.createTime ?? d.CreateTime,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseOrderBatchJobItem(raw: any): {
|
||||||
|
id: number
|
||||||
|
orderId: number
|
||||||
|
serviceNo?: string
|
||||||
|
status: string
|
||||||
|
failReason?: string
|
||||||
|
resultJson?: string
|
||||||
|
} {
|
||||||
|
return {
|
||||||
|
id: Number(raw.id ?? raw.Id ?? 0),
|
||||||
|
orderId: Number(raw.orderId ?? raw.OrderId ?? 0),
|
||||||
|
serviceNo: raw.serviceNo ?? raw.ServiceNo,
|
||||||
|
status: String(raw.status ?? raw.Status ?? ''),
|
||||||
|
failReason: raw.failReason ?? raw.FailReason,
|
||||||
|
resultJson: raw.resultJson ?? raw.ResultJson,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseBatchJobPrintUrl(item: { resultJson?: string }): string {
|
||||||
|
if (!item.resultJson) return ''
|
||||||
|
try {
|
||||||
|
const obj = JSON.parse(item.resultJson)
|
||||||
|
return String(obj.printUrl ?? obj.PrintUrl ?? '').trim()
|
||||||
|
} catch {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const ORDER_BATCH_ACTION_ROUTES: Record<string, string> = {
|
||||||
|
pay: '/order/batch/pay',
|
||||||
|
'pre-declare': '/order/batch/pre-declare',
|
||||||
|
intercept: '/order/batch/intercept',
|
||||||
|
'cancel-intercept': '/order/batch/cancel-intercept',
|
||||||
|
delete: '/order/batch/delete',
|
||||||
|
print: '/order/batch/print',
|
||||||
|
'mark-problematic': '/order/batch/mark-problematic',
|
||||||
|
'mark-claimed': '/order/batch/mark-claimed',
|
||||||
|
'handle-status-update': '/order/batch/handle-status-update',
|
||||||
|
'track-upsert-small-bag': '/order/batch/track-upsert',
|
||||||
|
'downstream-forward-create-one': '/order/batch/downstream-forward',
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST /order/batch/jobs 或按 action 路由的批量任务 */
|
||||||
|
export async function postOrderBatchJob(body: {
|
||||||
|
action: string
|
||||||
|
orderIds: Array<number | string>
|
||||||
|
payloadJson?: string
|
||||||
|
requestId?: string
|
||||||
|
}): Promise<ReturnType<typeof parseOrderBatchJob>> {
|
||||||
|
const route = ORDER_BATCH_ACTION_ROUTES[body.action] ?? '/order/batch/jobs'
|
||||||
|
const payload = {
|
||||||
|
action: body.action,
|
||||||
|
orderIds: body.orderIds.map((id) => Number(id)),
|
||||||
|
payloadJson: body.payloadJson,
|
||||||
|
requestId: body.requestId,
|
||||||
|
}
|
||||||
|
const res = await axiosInstance.post(orderUrl(route), payload)
|
||||||
|
assertBusinessOk(res.data)
|
||||||
|
return parseOrderBatchJob(res.data)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatBatchJobFailureMessage(
|
||||||
|
job: { successCount: number; failCount: number },
|
||||||
|
items: Array<{ orderId: number; serviceNo?: string; status: string; failReason?: string }>,
|
||||||
|
rows?: any[],
|
||||||
|
getRowLabel?: (row: any, item: { orderId: number; serviceNo?: string }) => string
|
||||||
|
): string {
|
||||||
|
const failed = items.filter((item) => item.status === 'failed')
|
||||||
|
if (!failed.length) {
|
||||||
|
return `完成:成功 ${job.successCount} 条`
|
||||||
|
}
|
||||||
|
const lines = failed.slice(0, 8).map((item) => {
|
||||||
|
const row = rows?.find((r) => Number(r?.id ?? r?.Id) === item.orderId)
|
||||||
|
const label = getRowLabel?.(row, item) ?? item.serviceNo ?? String(item.orderId)
|
||||||
|
return `【${label}】${item.failReason || '失败'}`
|
||||||
|
})
|
||||||
|
return [`完成:成功 ${job.successCount} 条,失败 ${job.failCount} 条`, ...lines].join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
const BATCH_JOB_TERMINAL_STATUSES = new Set(['completed', 'failed', 'cancelled'])
|
||||||
|
|
||||||
|
export interface OrderBatchDlxStats {
|
||||||
|
enabled: boolean
|
||||||
|
deadLetterMessageCount: number
|
||||||
|
dlxAlertThreshold: number
|
||||||
|
alert: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OrderBatchJobManageResult {
|
||||||
|
jobId: number
|
||||||
|
retriedCount: number
|
||||||
|
cancelledCount: number
|
||||||
|
status: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseOrderBatchDlxStats(raw: any): OrderBatchDlxStats {
|
||||||
|
const d = raw?.data ?? raw?.Data ?? raw ?? {}
|
||||||
|
return {
|
||||||
|
enabled: Boolean(d.enabled ?? d.Enabled),
|
||||||
|
deadLetterMessageCount: Number(d.deadLetterMessageCount ?? d.DeadLetterMessageCount ?? 0),
|
||||||
|
dlxAlertThreshold: Number(d.dlxAlertThreshold ?? d.DlxAlertThreshold ?? 0),
|
||||||
|
alert: Boolean(d.alert ?? d.Alert),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseOrderBatchJobManage(raw: any): OrderBatchJobManageResult {
|
||||||
|
const d = raw?.data ?? raw?.Data ?? raw ?? {}
|
||||||
|
return {
|
||||||
|
jobId: Number(d.jobId ?? d.JobId ?? 0),
|
||||||
|
retriedCount: Number(d.retriedCount ?? d.RetriedCount ?? 0),
|
||||||
|
cancelledCount: Number(d.cancelledCount ?? d.CancelledCount ?? 0),
|
||||||
|
status: String(d.status ?? d.Status ?? ''),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET /order/batch/jobs */
|
||||||
|
export async function getOrderBatchJobList(params: {
|
||||||
|
page?: number
|
||||||
|
pageSize?: number
|
||||||
|
status?: string
|
||||||
|
action?: string
|
||||||
|
jobId?: number | string
|
||||||
|
}): Promise<{ items: Array<ReturnType<typeof parseOrderBatchJob>>; total: number }> {
|
||||||
|
const res = await axiosInstance.get(orderUrl('/order/batch/jobs'), {
|
||||||
|
params: {
|
||||||
|
Page: params.page ?? 1,
|
||||||
|
PageSize: params.pageSize ?? 30,
|
||||||
|
Status: params.status || undefined,
|
||||||
|
Action: params.action || undefined,
|
||||||
|
JobId: params.jobId ? Number(params.jobId) : undefined,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const parsed = parseOrderListPayload(res.data)
|
||||||
|
return {
|
||||||
|
items: (parsed.items || []).map(parseOrderBatchJob),
|
||||||
|
total: parsed.total,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET /order/batch/dlx/stats */
|
||||||
|
export async function getOrderBatchDlxStats(): Promise<OrderBatchDlxStats> {
|
||||||
|
const res = await axiosInstance.get(orderUrl('/order/batch/dlx/stats'))
|
||||||
|
assertBusinessOk(res.data)
|
||||||
|
return parseOrderBatchDlxStats(res.data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST /order/batch/jobs/{id}/retry-failed */
|
||||||
|
export async function postOrderBatchJobRetryFailed(
|
||||||
|
jobId: number | string,
|
||||||
|
body?: { itemIds?: number[] }
|
||||||
|
): Promise<OrderBatchJobManageResult> {
|
||||||
|
const res = await axiosInstance.post(orderUrl(`/order/batch/jobs/${jobId}/retry-failed`), body ?? {})
|
||||||
|
assertBusinessOk(res.data)
|
||||||
|
return parseOrderBatchJobManage(res.data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST /order/batch/jobs/{id}/cancel */
|
||||||
|
export async function postOrderBatchJobCancel(jobId: number | string): Promise<OrderBatchJobManageResult> {
|
||||||
|
const res = await axiosInstance.post(orderUrl(`/order/batch/jobs/${jobId}/cancel`))
|
||||||
|
assertBusinessOk(res.data)
|
||||||
|
return parseOrderBatchJobManage(res.data)
|
||||||
|
}
|
||||||
|
|
||||||
|
function sleep(ms: number) {
|
||||||
|
return new Promise<void>((resolve) => {
|
||||||
|
setTimeout(resolve, ms)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST /order/batch/track-upsert */
|
||||||
|
export async function postOrderBatchTrackUpsert(body: {
|
||||||
|
orderIds: Array<number | string>
|
||||||
|
requestId?: string
|
||||||
|
}): Promise<ReturnType<typeof parseOrderBatchJob>> {
|
||||||
|
const res = await axiosInstance.post(orderUrl('/order/batch/track-upsert'), {
|
||||||
|
orderIds: body.orderIds.map((id) => Number(id)),
|
||||||
|
requestId: body.requestId,
|
||||||
|
})
|
||||||
|
assertBusinessOk(res.data)
|
||||||
|
return parseOrderBatchJob(res.data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST /order/batch/downstream-forward */
|
||||||
|
export async function postOrderBatchDownstreamForward(body: {
|
||||||
|
orderIds: Array<number | string>
|
||||||
|
requestId?: string
|
||||||
|
}): Promise<ReturnType<typeof parseOrderBatchJob>> {
|
||||||
|
const res = await axiosInstance.post(orderUrl('/order/batch/downstream-forward'), {
|
||||||
|
orderIds: body.orderIds.map((id) => Number(id)),
|
||||||
|
requestId: body.requestId,
|
||||||
|
})
|
||||||
|
assertBusinessOk(res.data)
|
||||||
|
return parseOrderBatchJob(res.data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET /order/batch/jobs/{id} */
|
||||||
|
export async function getOrderBatchJob(jobId: number | string): Promise<ReturnType<typeof parseOrderBatchJob>> {
|
||||||
|
const res = await axiosInstance.get(orderUrl(`/order/batch/jobs/${jobId}`))
|
||||||
|
assertBusinessOk(res.data)
|
||||||
|
return parseOrderBatchJob(res.data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET /order/batch/jobs/{id}/items */
|
||||||
|
export async function getOrderBatchJobItems(
|
||||||
|
jobId: number | string
|
||||||
|
): Promise<Array<ReturnType<typeof parseOrderBatchJobItem>>> {
|
||||||
|
const res = await axiosInstance.get(orderUrl(`/order/batch/jobs/${jobId}/items`))
|
||||||
|
assertBusinessOk(res.data)
|
||||||
|
const list = res.data?.data ?? res.data?.Data ?? res.data ?? []
|
||||||
|
return (Array.isArray(list) ? list : []).map(parseOrderBatchJobItem)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 轮询批量任务直至结束或超时 */
|
||||||
|
export async function waitOrderBatchJob(
|
||||||
|
jobId: number | string,
|
||||||
|
options?: { intervalMs?: number; timeoutMs?: number }
|
||||||
|
): Promise<{
|
||||||
|
job: ReturnType<typeof parseOrderBatchJob>
|
||||||
|
items: Array<ReturnType<typeof parseOrderBatchJobItem>>
|
||||||
|
}> {
|
||||||
|
const intervalMs = options?.intervalMs ?? 1500
|
||||||
|
const timeoutMs = options?.timeoutMs ?? 300_000
|
||||||
|
const started = Date.now()
|
||||||
|
while (Date.now() - started < timeoutMs) {
|
||||||
|
const job = await getOrderBatchJob(jobId)
|
||||||
|
if (BATCH_JOB_TERMINAL_STATUSES.has(job.status.toLowerCase())) {
|
||||||
|
const items = await getOrderBatchJobItems(jobId)
|
||||||
|
return { job, items }
|
||||||
|
}
|
||||||
|
await sleep(intervalMs)
|
||||||
|
}
|
||||||
|
throw new Error('批量任务执行超时,请稍后在任务列表中查看进度')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isOrderBatchQueueDisabledError(error: unknown): boolean {
|
||||||
|
const msg = String((error as any)?.message ?? (error as any)?.msg ?? '')
|
||||||
|
return msg.includes('未启用') || msg.toLowerCase().includes('rabbitmq')
|
||||||
|
}
|
||||||
|
|
||||||
export interface OrderMarkProblematicRequest {
|
export interface OrderMarkProblematicRequest {
|
||||||
orderIds: Array<number | string>
|
orderIds: Array<number | string>
|
||||||
issueTemplateId?: number | string
|
issueTemplateId?: number | string
|
||||||
@ -539,7 +841,10 @@ export async function downloadOrderTemplate(templateId: number, fileName: string
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** POST /order/upload multipart: file + templateId */
|
/** POST /order/upload multipart: file + templateId */
|
||||||
export async function postOrderUpload(file: File, templateId: number): Promise<{ taskId?: number }> {
|
export async function postOrderUpload(
|
||||||
|
file: File,
|
||||||
|
templateId: number
|
||||||
|
): Promise<{ taskId?: number; jobId?: number; async?: boolean }> {
|
||||||
const form = new FormData()
|
const form = new FormData()
|
||||||
form.append('file', file)
|
form.append('file', file)
|
||||||
form.append('templateId', String(templateId))
|
form.append('templateId', String(templateId))
|
||||||
@ -547,7 +852,13 @@ export async function postOrderUpload(file: File, templateId: number): Promise<{
|
|||||||
assertBusinessOk(res.data)
|
assertBusinessOk(res.data)
|
||||||
const d = res.data?.data ?? res.data?.Data ?? {}
|
const d = res.data?.data ?? res.data?.Data ?? {}
|
||||||
const taskId = d.taskId ?? d.TaskId
|
const taskId = d.taskId ?? d.TaskId
|
||||||
return { taskId: taskId != null ? Number(taskId) : undefined }
|
const jobId = d.jobId ?? d.JobId
|
||||||
|
const asyncFlag = d.async ?? d.Async
|
||||||
|
return {
|
||||||
|
taskId: taskId != null ? Number(taskId) : undefined,
|
||||||
|
jobId: jobId != null ? Number(jobId) : undefined,
|
||||||
|
async: asyncFlag === true || asyncFlag === 'true',
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** GET /order/task/list */
|
/** GET /order/task/list */
|
||||||
@ -584,6 +895,84 @@ export async function postOrderParcelCreate(body: Record<string, any>): Promise<
|
|||||||
return res.data?.data ?? res.data?.Data ?? {}
|
return res.data?.data ?? res.data?.Data ?? {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface OrderParcelBatchCreateResult {
|
||||||
|
batchNo?: string
|
||||||
|
BatchNo?: string
|
||||||
|
jobId?: number
|
||||||
|
JobId?: number
|
||||||
|
async?: boolean
|
||||||
|
Async?: boolean
|
||||||
|
total?: number
|
||||||
|
Total?: number
|
||||||
|
successCount?: number
|
||||||
|
SuccessCount?: number
|
||||||
|
failCount?: number
|
||||||
|
FailCount?: number
|
||||||
|
items?: Array<{
|
||||||
|
index?: number
|
||||||
|
Index?: number
|
||||||
|
requestId?: string
|
||||||
|
RequestId?: string
|
||||||
|
clientNo?: string
|
||||||
|
ClientNo?: string
|
||||||
|
serviceNo?: string
|
||||||
|
ServiceNo?: string
|
||||||
|
trackNo?: string
|
||||||
|
TrackNo?: string
|
||||||
|
labelUrl?: string
|
||||||
|
LabelUrl?: string
|
||||||
|
success?: boolean
|
||||||
|
Success?: boolean
|
||||||
|
message?: string
|
||||||
|
Message?: string
|
||||||
|
}>
|
||||||
|
Items?: OrderParcelBatchCreateResult['items']
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseOrderParcelBatchCreateResult(raw: any): {
|
||||||
|
batchNo: string
|
||||||
|
jobId?: number
|
||||||
|
async: boolean
|
||||||
|
total: number
|
||||||
|
successCount: number
|
||||||
|
failCount: number
|
||||||
|
items: Array<{
|
||||||
|
index: number
|
||||||
|
clientNo?: string
|
||||||
|
serviceNo?: string
|
||||||
|
success: boolean
|
||||||
|
message?: string
|
||||||
|
}>
|
||||||
|
} {
|
||||||
|
const d = raw?.data ?? raw?.Data ?? raw ?? {}
|
||||||
|
const itemsRaw = d.items ?? d.Items ?? []
|
||||||
|
const items = (Array.isArray(itemsRaw) ? itemsRaw : []).map((item: any, idx: number) => ({
|
||||||
|
index: Number(item.index ?? item.Index ?? idx + 1),
|
||||||
|
clientNo: item.clientNo ?? item.ClientNo,
|
||||||
|
serviceNo: item.serviceNo ?? item.ServiceNo,
|
||||||
|
success: item.success ?? item.Success ?? false,
|
||||||
|
message: item.message ?? item.Message,
|
||||||
|
}))
|
||||||
|
return {
|
||||||
|
batchNo: String(d.batchNo ?? d.BatchNo ?? ''),
|
||||||
|
jobId: d.jobId != null || d.JobId != null ? Number(d.jobId ?? d.JobId) : undefined,
|
||||||
|
async: d.async === true || d.Async === true,
|
||||||
|
total: Number(d.total ?? d.Total ?? items.length),
|
||||||
|
successCount: Number(d.successCount ?? d.SuccessCount ?? 0),
|
||||||
|
failCount: Number(d.failCount ?? d.FailCount ?? 0),
|
||||||
|
items,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST /order/parcel/batch-create */
|
||||||
|
export async function postOrderParcelBatchCreate(
|
||||||
|
bodies: Array<Record<string, any>>
|
||||||
|
): Promise<ReturnType<typeof parseOrderParcelBatchCreateResult>> {
|
||||||
|
const res = await axiosInstance.post(orderUrl('/order/parcel/batch-create'), bodies)
|
||||||
|
assertBusinessOk(res.data)
|
||||||
|
return parseOrderParcelBatchCreateResult(res.data)
|
||||||
|
}
|
||||||
|
|
||||||
/** POST /order/update */
|
/** POST /order/update */
|
||||||
export async function postOrderUpdate(body: Record<string, any>): Promise<any> {
|
export async function postOrderUpdate(body: Record<string, any>): Promise<any> {
|
||||||
const res = await axiosInstance.post(orderUrl('/order/update'), body)
|
const res = await axiosInstance.post(orderUrl('/order/update'), body)
|
||||||
|
|||||||
48
src/constants/orderBatchAction.ts
Normal file
48
src/constants/orderBatchAction.ts
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
export const ORDER_BATCH_JOB_STATUS_OPTIONS = [
|
||||||
|
{ value: 'pending', labelKey: 'order.batchJobs.status.pending' },
|
||||||
|
{ value: 'running', labelKey: 'order.batchJobs.status.running' },
|
||||||
|
{ value: 'completed', labelKey: 'order.batchJobs.status.completed' },
|
||||||
|
{ value: 'failed', labelKey: 'order.batchJobs.status.failed' },
|
||||||
|
{ value: 'cancelled', labelKey: 'order.batchJobs.status.cancelled' },
|
||||||
|
] as const
|
||||||
|
|
||||||
|
export const ORDER_BATCH_ITEM_STATUS_OPTIONS = [
|
||||||
|
{ value: 'pending', labelKey: 'order.batchJobs.itemStatus.pending' },
|
||||||
|
{ value: 'running', labelKey: 'order.batchJobs.itemStatus.running' },
|
||||||
|
{ value: 'success', labelKey: 'order.batchJobs.itemStatus.success' },
|
||||||
|
{ value: 'failed', labelKey: 'order.batchJobs.itemStatus.failed' },
|
||||||
|
{ value: 'cancelled', labelKey: 'order.batchJobs.itemStatus.cancelled' },
|
||||||
|
] as const
|
||||||
|
|
||||||
|
export const ORDER_BATCH_ACTION_OPTIONS = [
|
||||||
|
{ value: 'pay', labelKey: 'order.batchJobs.action.pay' },
|
||||||
|
{ value: 'pre-declare', labelKey: 'order.batchJobs.action.preDeclare' },
|
||||||
|
{ value: 'intercept', labelKey: 'order.batchJobs.action.intercept' },
|
||||||
|
{ value: 'cancel-intercept', labelKey: 'order.batchJobs.action.cancelIntercept' },
|
||||||
|
{ value: 'delete', labelKey: 'order.batchJobs.action.delete' },
|
||||||
|
{ value: 'print', labelKey: 'order.batchJobs.action.print' },
|
||||||
|
{ value: 'track-upsert-small-bag', labelKey: 'order.batchJobs.action.trackUpsert' },
|
||||||
|
{ value: 'downstream-forward-create-one', labelKey: 'order.batchJobs.action.downstreamForward' },
|
||||||
|
{ value: 'mark-problematic', labelKey: 'order.batchJobs.action.markProblematic' },
|
||||||
|
{ value: 'mark-claimed', labelKey: 'order.batchJobs.action.markClaimed' },
|
||||||
|
{ value: 'handle-status-update', labelKey: 'order.batchJobs.action.handleStatusUpdate' },
|
||||||
|
{ value: 'parcel-create', labelKey: 'order.batchJobs.action.parcelCreate' },
|
||||||
|
{ value: 'import-row', labelKey: 'order.batchJobs.action.importRow' },
|
||||||
|
] as const
|
||||||
|
|
||||||
|
export const BATCH_JOB_TERMINAL_STATUSES = new Set(['completed', 'failed', 'cancelled'])
|
||||||
|
|
||||||
|
export function getBatchActionLabelKey(action: string): string {
|
||||||
|
const found = ORDER_BATCH_ACTION_OPTIONS.find((item) => item.value === action)
|
||||||
|
return found?.labelKey ?? 'order.batchJobs.action.unknown'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getBatchJobStatusLabelKey(status: string): string {
|
||||||
|
const found = ORDER_BATCH_JOB_STATUS_OPTIONS.find((item) => item.value === status)
|
||||||
|
return found?.labelKey ?? 'order.batchJobs.status.unknown'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getBatchItemStatusLabelKey(status: string): string {
|
||||||
|
const found = ORDER_BATCH_ITEM_STATUS_OPTIONS.find((item) => item.value === status)
|
||||||
|
return found?.labelKey ?? 'order.batchJobs.itemStatus.unknown'
|
||||||
|
}
|
||||||
@ -50,7 +50,9 @@ export const PERMISSION_ACTION_LABELS: Record<string, string> = {
|
|||||||
Upload: '上传',
|
Upload: '上传',
|
||||||
Sync: '同步',
|
Sync: '同步',
|
||||||
Retry: '重试',
|
Retry: '重试',
|
||||||
|
RetryFailed: '重试失败',
|
||||||
Cancel: '取消',
|
Cancel: '取消',
|
||||||
|
CancelPending: '取消待处理',
|
||||||
Enable: '启用',
|
Enable: '启用',
|
||||||
Disable: '禁用',
|
Disable: '禁用',
|
||||||
View: '查看',
|
View: '查看',
|
||||||
@ -251,6 +253,7 @@ export const PAGE_BUTTON_PARENT_ALIASES: Record<string, string> = {
|
|||||||
'Api.ApiTest': 'Api.ApiTest',
|
'Api.ApiTest': 'Api.ApiTest',
|
||||||
'Api.Test.Query': 'Api.ApiTest',
|
'Api.Test.Query': 'Api.ApiTest',
|
||||||
'Channel.LabelDesign': 'Channel.LabelDesign',
|
'Channel.LabelDesign': 'Channel.LabelDesign',
|
||||||
|
'Order.BatchJobs.Query': 'Order.BatchJobs',
|
||||||
};
|
};
|
||||||
|
|
||||||
type BtnInput = { code: string; name: string };
|
type BtnInput = { code: string; name: string };
|
||||||
@ -472,6 +475,12 @@ const CORE_GROUPS: PageButtonGroup[] = [
|
|||||||
{ code: 'RecalculatePayable', name: '重算应付' },
|
{ code: 'RecalculatePayable', name: '重算应付' },
|
||||||
{ code: 'AddDeclarationRow', name: '添加申报行' },
|
{ code: 'AddDeclarationRow', name: '添加申报行' },
|
||||||
]),
|
]),
|
||||||
|
B('Order.BatchJobs', [
|
||||||
|
{ code: 'Query', name: '查询' },
|
||||||
|
{ code: 'View', name: '查看' },
|
||||||
|
{ code: 'Retry', name: '重试失败' },
|
||||||
|
{ code: 'Cancel', name: '取消待处理' },
|
||||||
|
]),
|
||||||
|
|
||||||
// ── 渠道 ──
|
// ── 渠道 ──
|
||||||
B('Channel.SalesProducts', [
|
B('Channel.SalesProducts', [
|
||||||
|
|||||||
@ -80,6 +80,7 @@ export default {
|
|||||||
'menu.order.priceCheck': 'Price Check',
|
'menu.order.priceCheck': 'Price Check',
|
||||||
'menu.order.placeOrder': 'Place Order',
|
'menu.order.placeOrder': 'Place Order',
|
||||||
'menu.order.myOrders': 'My Orders',
|
'menu.order.myOrders': 'My Orders',
|
||||||
|
'menu.order.batchJobs': 'Batch Jobs',
|
||||||
'menu.channel': 'Channel',
|
'menu.channel': 'Channel',
|
||||||
'menu.channel.salesProducts': 'Sales Products',
|
'menu.channel.salesProducts': 'Sales Products',
|
||||||
'menu.channel.serviceChannels': 'Service Channels',
|
'menu.channel.serviceChannels': 'Service Channels',
|
||||||
@ -183,6 +184,65 @@ export default {
|
|||||||
'system.dataPerm.templates.field.bindRole': 'Linked role',
|
'system.dataPerm.templates.field.bindRole': 'Linked role',
|
||||||
'system.dataPerm.templates.field.bindRolePlaceholder': 'Select an RBAC role',
|
'system.dataPerm.templates.field.bindRolePlaceholder': 'Select an RBAC role',
|
||||||
'system.dataPerm.templates.field.bindRoleExtra': 'Saved as roleCode — identifies which job role this template belongs to',
|
'system.dataPerm.templates.field.bindRoleExtra': 'Saved as roleCode — identifies which job role this template belongs to',
|
||||||
|
|
||||||
|
// Order - Batch Jobs
|
||||||
|
'order.batchJobs.filter.jobId': 'Job ID',
|
||||||
|
'order.batchJobs.filter.status': 'Status',
|
||||||
|
'order.batchJobs.filter.action': 'Action',
|
||||||
|
'order.batchJobs.table.jobId': 'Job ID',
|
||||||
|
'order.batchJobs.table.action': 'Action',
|
||||||
|
'order.batchJobs.table.status': 'Status',
|
||||||
|
'order.batchJobs.table.progress': 'Progress',
|
||||||
|
'order.batchJobs.table.failCount': '{count} failed',
|
||||||
|
'order.batchJobs.table.failCountShort': 'Failed',
|
||||||
|
'order.batchJobs.table.createTime': 'Created',
|
||||||
|
'order.batchJobs.table.startedAt': 'Started',
|
||||||
|
'order.batchJobs.table.finishedAt': 'Finished',
|
||||||
|
'order.batchJobs.actions.search': 'Search',
|
||||||
|
'order.batchJobs.actions.reset': 'Reset',
|
||||||
|
'order.batchJobs.actions.refresh': 'Refresh',
|
||||||
|
'order.batchJobs.actions.view': 'View',
|
||||||
|
'order.batchJobs.actions.retryFailed': 'Retry Failed',
|
||||||
|
'order.batchJobs.actions.cancelPending': 'Cancel Pending',
|
||||||
|
'order.batchJobs.drawer.title': 'Batch Job #{id}',
|
||||||
|
'order.batchJobs.items.orderId': 'Order ID',
|
||||||
|
'order.batchJobs.items.serviceNo': 'Tracking No.',
|
||||||
|
'order.batchJobs.items.status': 'Item Status',
|
||||||
|
'order.batchJobs.items.failReason': 'Fail Reason',
|
||||||
|
'order.batchJobs.message.loadFailed': 'Failed to load batch jobs',
|
||||||
|
'order.batchJobs.message.retrySuccess': 'Retried {count} failed item(s)',
|
||||||
|
'order.batchJobs.message.retryFailed': 'Failed to retry items',
|
||||||
|
'order.batchJobs.message.cancelConfirm': 'Cancel {count} pending item(s)?',
|
||||||
|
'order.batchJobs.message.cancelSuccess': 'Cancelled {count} pending item(s)',
|
||||||
|
'order.batchJobs.message.cancelFailed': 'Failed to cancel pending items',
|
||||||
|
'order.batchJobs.dlx.alert': 'DLX queue has {count} message(s), above threshold {threshold}',
|
||||||
|
'order.batchJobs.status.pending': 'Pending',
|
||||||
|
'order.batchJobs.status.running': 'Running',
|
||||||
|
'order.batchJobs.status.completed': 'Completed',
|
||||||
|
'order.batchJobs.status.failed': 'Failed',
|
||||||
|
'order.batchJobs.status.cancelled': 'Cancelled',
|
||||||
|
'order.batchJobs.status.unknown': 'Unknown',
|
||||||
|
'order.batchJobs.itemStatus.pending': 'Pending',
|
||||||
|
'order.batchJobs.itemStatus.running': 'Running',
|
||||||
|
'order.batchJobs.itemStatus.success': 'Success',
|
||||||
|
'order.batchJobs.itemStatus.failed': 'Failed',
|
||||||
|
'order.batchJobs.itemStatus.cancelled': 'Cancelled',
|
||||||
|
'order.batchJobs.itemStatus.unknown': 'Unknown',
|
||||||
|
'order.batchJobs.action.pay': 'Batch Pay',
|
||||||
|
'order.batchJobs.action.preDeclare': 'Batch Pre-declare',
|
||||||
|
'order.batchJobs.action.intercept': 'Batch Intercept',
|
||||||
|
'order.batchJobs.action.cancelIntercept': 'Batch Cancel Intercept',
|
||||||
|
'order.batchJobs.action.delete': 'Batch Delete',
|
||||||
|
'order.batchJobs.action.print': 'Batch Print',
|
||||||
|
'order.batchJobs.action.trackUpsert': 'Batch Track Upsert',
|
||||||
|
'order.batchJobs.action.downstreamForward': 'Batch Downstream Forward',
|
||||||
|
'order.batchJobs.action.markProblematic': 'Batch Mark Problematic',
|
||||||
|
'order.batchJobs.action.markClaimed': 'Batch Mark Claimed',
|
||||||
|
'order.batchJobs.action.handleStatusUpdate': 'Batch Handle Status',
|
||||||
|
'order.batchJobs.action.parcelCreate': 'Batch Parcel Create',
|
||||||
|
'order.batchJobs.action.importRow': 'Order Import',
|
||||||
|
'order.batchJobs.action.unknown': 'Other',
|
||||||
|
|
||||||
'system.dataPerm.templates.section.bindApply': 'Role link & apply',
|
'system.dataPerm.templates.section.bindApply': 'Role link & apply',
|
||||||
'system.dataPerm.templates.section.applyNow': 'Apply now',
|
'system.dataPerm.templates.section.applyNow': 'Apply now',
|
||||||
'system.dataPerm.templates.bindApplyHint': 'Linking a role is metadata only. Use "Apply now" below to write template items into role or user permissions. Linking does not apply by itself.',
|
'system.dataPerm.templates.bindApplyHint': 'Linking a role is metadata only. Use "Apply now" below to write template items into role or user permissions. Linking does not apply by itself.',
|
||||||
|
|||||||
@ -80,6 +80,7 @@ export default {
|
|||||||
'menu.order.priceCheck': '我要查价',
|
'menu.order.priceCheck': '我要查价',
|
||||||
'menu.order.placeOrder': '我要下单',
|
'menu.order.placeOrder': '我要下单',
|
||||||
'menu.order.myOrders': '我的订单',
|
'menu.order.myOrders': '我的订单',
|
||||||
|
'menu.order.batchJobs': '批量任务',
|
||||||
'menu.channel': '渠道',
|
'menu.channel': '渠道',
|
||||||
'menu.channel.salesProducts': '销售产品',
|
'menu.channel.salesProducts': '销售产品',
|
||||||
'menu.channel.serviceChannels': '服务渠道',
|
'menu.channel.serviceChannels': '服务渠道',
|
||||||
@ -184,6 +185,64 @@ export default {
|
|||||||
'nav.balance.rechargeRecord': '充值记录',
|
'nav.balance.rechargeRecord': '充值记录',
|
||||||
'nav.balance.accountFlow': '资金流水',
|
'nav.balance.accountFlow': '资金流水',
|
||||||
|
|
||||||
|
// 订单 - 批量任务
|
||||||
|
'order.batchJobs.filter.jobId': '任务 ID',
|
||||||
|
'order.batchJobs.filter.status': '任务状态',
|
||||||
|
'order.batchJobs.filter.action': '操作类型',
|
||||||
|
'order.batchJobs.table.jobId': '任务 ID',
|
||||||
|
'order.batchJobs.table.action': '操作类型',
|
||||||
|
'order.batchJobs.table.status': '状态',
|
||||||
|
'order.batchJobs.table.progress': '进度',
|
||||||
|
'order.batchJobs.table.failCount': '失败 {count} 条',
|
||||||
|
'order.batchJobs.table.failCountShort': '失败数',
|
||||||
|
'order.batchJobs.table.createTime': '创建时间',
|
||||||
|
'order.batchJobs.table.startedAt': '开始时间',
|
||||||
|
'order.batchJobs.table.finishedAt': '完成时间',
|
||||||
|
'order.batchJobs.actions.search': '查询',
|
||||||
|
'order.batchJobs.actions.reset': '重置',
|
||||||
|
'order.batchJobs.actions.refresh': '刷新',
|
||||||
|
'order.batchJobs.actions.view': '查看',
|
||||||
|
'order.batchJobs.actions.retryFailed': '重试失败',
|
||||||
|
'order.batchJobs.actions.cancelPending': '取消待处理',
|
||||||
|
'order.batchJobs.drawer.title': '批量任务 #{id}',
|
||||||
|
'order.batchJobs.items.orderId': '订单 ID',
|
||||||
|
'order.batchJobs.items.serviceNo': '运单号',
|
||||||
|
'order.batchJobs.items.status': '子项状态',
|
||||||
|
'order.batchJobs.items.failReason': '失败原因',
|
||||||
|
'order.batchJobs.message.loadFailed': '加载批量任务失败',
|
||||||
|
'order.batchJobs.message.retrySuccess': '已重新投递 {count} 条失败子项',
|
||||||
|
'order.batchJobs.message.retryFailed': '重试失败子项失败',
|
||||||
|
'order.batchJobs.message.cancelConfirm': '确定取消 {count} 条待处理子项?',
|
||||||
|
'order.batchJobs.message.cancelSuccess': '已取消 {count} 条待处理子项',
|
||||||
|
'order.batchJobs.message.cancelFailed': '取消待处理子项失败',
|
||||||
|
'order.batchJobs.dlx.alert': 'DLX 死信队列堆积 {count} 条,已超过阈值 {threshold}',
|
||||||
|
'order.batchJobs.status.pending': '待处理',
|
||||||
|
'order.batchJobs.status.running': '执行中',
|
||||||
|
'order.batchJobs.status.completed': '已完成',
|
||||||
|
'order.batchJobs.status.failed': '失败',
|
||||||
|
'order.batchJobs.status.cancelled': '已取消',
|
||||||
|
'order.batchJobs.status.unknown': '未知',
|
||||||
|
'order.batchJobs.itemStatus.pending': '待处理',
|
||||||
|
'order.batchJobs.itemStatus.running': '执行中',
|
||||||
|
'order.batchJobs.itemStatus.success': '成功',
|
||||||
|
'order.batchJobs.itemStatus.failed': '失败',
|
||||||
|
'order.batchJobs.itemStatus.cancelled': '已取消',
|
||||||
|
'order.batchJobs.itemStatus.unknown': '未知',
|
||||||
|
'order.batchJobs.action.pay': '批量支付',
|
||||||
|
'order.batchJobs.action.preDeclare': '批量预报',
|
||||||
|
'order.batchJobs.action.intercept': '批量拦截',
|
||||||
|
'order.batchJobs.action.cancelIntercept': '批量取消拦截',
|
||||||
|
'order.batchJobs.action.delete': '批量删除',
|
||||||
|
'order.batchJobs.action.print': '批量打印',
|
||||||
|
'order.batchJobs.action.trackUpsert': '批量创建轨迹',
|
||||||
|
'order.batchJobs.action.downstreamForward': '批量预报下游',
|
||||||
|
'order.batchJobs.action.markProblematic': '批量创建问题件',
|
||||||
|
'order.batchJobs.action.markClaimed': '批量创建理赔件',
|
||||||
|
'order.batchJobs.action.handleStatusUpdate': '批量更新处理状态',
|
||||||
|
'order.batchJobs.action.parcelCreate': '批量小包创建',
|
||||||
|
'order.batchJobs.action.importRow': '订单导入',
|
||||||
|
'order.batchJobs.action.unknown': '其他',
|
||||||
|
|
||||||
// 订单 - 我的订单
|
// 订单 - 我的订单
|
||||||
'order.myOrders.tabs.all': '全部',
|
'order.myOrders.tabs.all': '全部',
|
||||||
'order.myOrders.tabs.draft': '草稿',
|
'order.myOrders.tabs.draft': '草稿',
|
||||||
|
|||||||
@ -39,6 +39,16 @@ const ORDER: AppRouteRecordRaw = {
|
|||||||
requiresAuth: true,
|
requiresAuth: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'batchJobs',
|
||||||
|
name: 'OrderBatchJobs',
|
||||||
|
component: () => import('@/views/order/batchJobs/index.vue'),
|
||||||
|
meta: {
|
||||||
|
locale: 'menu.order.batchJobs',
|
||||||
|
requiresAuth: true,
|
||||||
|
permission: 'Order.BatchJobs.Query',
|
||||||
|
},
|
||||||
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -27,6 +27,7 @@ export const SUB_MENU_DEFAULT_ICONS: Record<string, string> = {
|
|||||||
'menu.order.priceCheck': 'IconSearch',
|
'menu.order.priceCheck': 'IconSearch',
|
||||||
'menu.order.placeOrder': 'IconPlus',
|
'menu.order.placeOrder': 'IconPlus',
|
||||||
'menu.order.myOrders': 'IconOrderedList',
|
'menu.order.myOrders': 'IconOrderedList',
|
||||||
|
'menu.order.batchJobs': 'IconSchedule',
|
||||||
'menu.channel.salesProducts': 'IconTag',
|
'menu.channel.salesProducts': 'IconTag',
|
||||||
'menu.channel.serviceChannels': 'IconSend',
|
'menu.channel.serviceChannels': 'IconSend',
|
||||||
'menu.channel.priceArea': 'IconLocation',
|
'menu.channel.priceArea': 'IconLocation',
|
||||||
|
|||||||
@ -10,6 +10,8 @@ const routeNameMap: Record<string, string> = {
|
|||||||
'/order/priceCheck': 'PriceCheck',
|
'/order/priceCheck': 'PriceCheck',
|
||||||
'order_my_orders': 'MyOrders',
|
'order_my_orders': 'MyOrders',
|
||||||
'/order/myOrders': 'MyOrders',
|
'/order/myOrders': 'MyOrders',
|
||||||
|
'order_batch_jobs': 'OrderBatchJobs',
|
||||||
|
'/order/batchJobs': 'OrderBatchJobs',
|
||||||
'order': 'Order',
|
'order': 'Order',
|
||||||
'/order': 'Order',
|
'/order': 'Order',
|
||||||
|
|
||||||
|
|||||||
285
src/views/order/batchJobs/components/BatchJobDetailDrawer.vue
Normal file
285
src/views/order/batchJobs/components/BatchJobDetailDrawer.vue
Normal file
@ -0,0 +1,285 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref, watch } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { Modal } from '@arco-design/web-vue'
|
||||||
|
import dayjs from 'dayjs'
|
||||||
|
import {
|
||||||
|
getOrderBatchJob,
|
||||||
|
getOrderBatchJobItems,
|
||||||
|
postOrderBatchJobCancel,
|
||||||
|
postOrderBatchJobRetryFailed,
|
||||||
|
} from '@/api/order'
|
||||||
|
import { $baseMessage } from '@/utils'
|
||||||
|
import { hasPermission } from '@/utils/rbac-permission'
|
||||||
|
import {
|
||||||
|
BATCH_JOB_TERMINAL_STATUSES,
|
||||||
|
getBatchActionLabelKey,
|
||||||
|
getBatchItemStatusLabelKey,
|
||||||
|
getBatchJobStatusLabelKey,
|
||||||
|
} from '@/constants/orderBatchAction'
|
||||||
|
|
||||||
|
defineOptions({ name: 'BatchJobDetailDrawer' })
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
visible: boolean
|
||||||
|
jobId: number | null
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:visible': [value: boolean]
|
||||||
|
refreshed: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const actionLoading = ref(false)
|
||||||
|
const job = ref<{
|
||||||
|
jobId: number
|
||||||
|
action: string
|
||||||
|
status: string
|
||||||
|
total: number
|
||||||
|
successCount: number
|
||||||
|
failCount: number
|
||||||
|
startedAt?: string
|
||||||
|
finishedAt?: string
|
||||||
|
createTime?: string
|
||||||
|
} | null>(null)
|
||||||
|
const items = ref<
|
||||||
|
Array<{
|
||||||
|
id: number
|
||||||
|
orderId: number
|
||||||
|
serviceNo?: string
|
||||||
|
status: string
|
||||||
|
failReason?: string
|
||||||
|
}>
|
||||||
|
>([])
|
||||||
|
|
||||||
|
let pollTimer: ReturnType<typeof setInterval> | null = null
|
||||||
|
|
||||||
|
const drawerVisible = computed({
|
||||||
|
get: () => props.visible,
|
||||||
|
set: (value: boolean) => emit('update:visible', value),
|
||||||
|
})
|
||||||
|
|
||||||
|
const pendingCount = computed(() => items.value.filter((x) => x.status === 'pending').length)
|
||||||
|
const failedCount = computed(() => items.value.filter((x) => x.status === 'failed').length)
|
||||||
|
const canRetry = computed(
|
||||||
|
() => hasPermission('Order.BatchJobs.Retry') && failedCount.value > 0 && job.value?.status !== 'cancelled',
|
||||||
|
)
|
||||||
|
const canCancel = computed(
|
||||||
|
() => hasPermission('Order.BatchJobs.Cancel') && pendingCount.value > 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
function formatTime(value?: string) {
|
||||||
|
if (!value) return '-'
|
||||||
|
const parsed = dayjs(value)
|
||||||
|
return parsed.isValid() ? parsed.format('YYYY-MM-DD HH:mm:ss') : value
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearPoll() {
|
||||||
|
if (pollTimer) {
|
||||||
|
clearInterval(pollTimer)
|
||||||
|
pollTimer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadDetail() {
|
||||||
|
if (!props.jobId) return
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const [jobRes, itemRes] = await Promise.all([
|
||||||
|
getOrderBatchJob(props.jobId),
|
||||||
|
getOrderBatchJobItems(props.jobId),
|
||||||
|
])
|
||||||
|
job.value = jobRes
|
||||||
|
items.value = itemRes
|
||||||
|
} catch (e: any) {
|
||||||
|
$baseMessage(e?.message || t('order.batchJobs.message.loadFailed'), 'error')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupPoll() {
|
||||||
|
clearPoll()
|
||||||
|
if (!props.visible || !props.jobId || !job.value) return
|
||||||
|
if (BATCH_JOB_TERMINAL_STATUSES.has(job.value.status.toLowerCase())) return
|
||||||
|
pollTimer = setInterval(async () => {
|
||||||
|
if (!props.visible || !props.jobId) return
|
||||||
|
try {
|
||||||
|
const [jobRes, itemRes] = await Promise.all([
|
||||||
|
getOrderBatchJob(props.jobId),
|
||||||
|
getOrderBatchJobItems(props.jobId),
|
||||||
|
])
|
||||||
|
job.value = jobRes
|
||||||
|
items.value = itemRes
|
||||||
|
if (BATCH_JOB_TERMINAL_STATUSES.has(jobRes.status.toLowerCase())) {
|
||||||
|
clearPoll()
|
||||||
|
emit('refreshed')
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* ignore poll errors */
|
||||||
|
}
|
||||||
|
}, 2000)
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [props.visible, props.jobId] as const,
|
||||||
|
async ([visible, jobId]) => {
|
||||||
|
clearPoll()
|
||||||
|
if (!visible || !jobId) {
|
||||||
|
job.value = null
|
||||||
|
items.value = []
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await loadDetail()
|
||||||
|
setupPoll()
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
function handleClose() {
|
||||||
|
drawerVisible.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRetryFailed() {
|
||||||
|
if (!props.jobId) return
|
||||||
|
actionLoading.value = true
|
||||||
|
try {
|
||||||
|
const result = await postOrderBatchJobRetryFailed(props.jobId)
|
||||||
|
$baseMessage(t('order.batchJobs.message.retrySuccess', { count: result.retriedCount }), 'success')
|
||||||
|
await loadDetail()
|
||||||
|
setupPoll()
|
||||||
|
emit('refreshed')
|
||||||
|
} catch (e: any) {
|
||||||
|
$baseMessage(e?.message || t('order.batchJobs.message.retryFailed'), 'error')
|
||||||
|
} finally {
|
||||||
|
actionLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCancelPending() {
|
||||||
|
if (!props.jobId) return
|
||||||
|
Modal.confirm({
|
||||||
|
title: t('order.batchJobs.actions.cancelPending'),
|
||||||
|
content: t('order.batchJobs.message.cancelConfirm', { count: pendingCount.value }),
|
||||||
|
onOk: async () => {
|
||||||
|
actionLoading.value = true
|
||||||
|
try {
|
||||||
|
const result = await postOrderBatchJobCancel(props.jobId!)
|
||||||
|
$baseMessage(
|
||||||
|
t('order.batchJobs.message.cancelSuccess', { count: result.cancelledCount }),
|
||||||
|
'success',
|
||||||
|
)
|
||||||
|
await loadDetail()
|
||||||
|
emit('refreshed')
|
||||||
|
} catch (e: any) {
|
||||||
|
$baseMessage(e?.message || t('order.batchJobs.message.cancelFailed'), 'error')
|
||||||
|
} finally {
|
||||||
|
actionLoading.value = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<a-drawer
|
||||||
|
v-model:visible="drawerVisible"
|
||||||
|
:width="920"
|
||||||
|
:title="t('order.batchJobs.drawer.title', { id: jobId ?? '' })"
|
||||||
|
:footer="false"
|
||||||
|
unmount-on-close
|
||||||
|
@cancel="handleClose"
|
||||||
|
>
|
||||||
|
<a-spin :loading="loading" style="width: 100%">
|
||||||
|
<template v-if="job">
|
||||||
|
<a-descriptions :column="3" bordered size="medium" style="margin-bottom: 16px">
|
||||||
|
<a-descriptions-item :label="t('order.batchJobs.table.action')">
|
||||||
|
{{ t(getBatchActionLabelKey(job.action)) }}
|
||||||
|
</a-descriptions-item>
|
||||||
|
<a-descriptions-item :label="t('order.batchJobs.table.status')">
|
||||||
|
{{ t(getBatchJobStatusLabelKey(job.status)) }}
|
||||||
|
</a-descriptions-item>
|
||||||
|
<a-descriptions-item :label="t('order.batchJobs.table.progress')">
|
||||||
|
{{ job.successCount }}/{{ job.total }}
|
||||||
|
<span v-if="job.failCount > 0" class="fail-text">
|
||||||
|
({{ t('order.batchJobs.table.failCount', { count: job.failCount }) }})
|
||||||
|
</span>
|
||||||
|
</a-descriptions-item>
|
||||||
|
<a-descriptions-item :label="t('order.batchJobs.table.createTime')">
|
||||||
|
{{ formatTime(job.createTime) }}
|
||||||
|
</a-descriptions-item>
|
||||||
|
<a-descriptions-item :label="t('order.batchJobs.table.startedAt')">
|
||||||
|
{{ formatTime(job.startedAt) }}
|
||||||
|
</a-descriptions-item>
|
||||||
|
<a-descriptions-item :label="t('order.batchJobs.table.finishedAt')">
|
||||||
|
{{ formatTime(job.finishedAt) }}
|
||||||
|
</a-descriptions-item>
|
||||||
|
</a-descriptions>
|
||||||
|
|
||||||
|
<div class="drawer-toolbar">
|
||||||
|
<a-space>
|
||||||
|
<a-button
|
||||||
|
v-if="canRetry"
|
||||||
|
type="primary"
|
||||||
|
:loading="actionLoading"
|
||||||
|
@click="handleRetryFailed"
|
||||||
|
>
|
||||||
|
{{ t('order.batchJobs.actions.retryFailed') }}
|
||||||
|
</a-button>
|
||||||
|
<a-button
|
||||||
|
v-if="canCancel"
|
||||||
|
status="warning"
|
||||||
|
:loading="actionLoading"
|
||||||
|
@click="handleCancelPending"
|
||||||
|
>
|
||||||
|
{{ t('order.batchJobs.actions.cancelPending') }}
|
||||||
|
</a-button>
|
||||||
|
<a-button @click="loadDetail">{{ t('order.batchJobs.actions.refresh') }}</a-button>
|
||||||
|
</a-space>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a-table
|
||||||
|
:data="items"
|
||||||
|
:pagination="false"
|
||||||
|
row-key="id"
|
||||||
|
size="small"
|
||||||
|
:scroll="{ y: 420 }"
|
||||||
|
>
|
||||||
|
<template #columns>
|
||||||
|
<a-table-column :title="t('order.batchJobs.items.orderId')" data-index="orderId" :width="100" />
|
||||||
|
<a-table-column :title="t('order.batchJobs.items.serviceNo')" data-index="serviceNo" :width="160">
|
||||||
|
<template #cell="{ record }">
|
||||||
|
{{ record.serviceNo || '-' }}
|
||||||
|
</template>
|
||||||
|
</a-table-column>
|
||||||
|
<a-table-column :title="t('order.batchJobs.items.status')" data-index="status" :width="120">
|
||||||
|
<template #cell="{ record }">
|
||||||
|
<a-tag :color="record.status === 'failed' ? 'red' : record.status === 'success' ? 'green' : 'arcoblue'">
|
||||||
|
{{ t(getBatchItemStatusLabelKey(record.status)) }}
|
||||||
|
</a-tag>
|
||||||
|
</template>
|
||||||
|
</a-table-column>
|
||||||
|
<a-table-column :title="t('order.batchJobs.items.failReason')" data-index="failReason">
|
||||||
|
<template #cell="{ record }">
|
||||||
|
{{ record.failReason || '-' }}
|
||||||
|
</template>
|
||||||
|
</a-table-column>
|
||||||
|
</template>
|
||||||
|
</a-table>
|
||||||
|
</template>
|
||||||
|
</a-spin>
|
||||||
|
</a-drawer>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="less">
|
||||||
|
.fail-text {
|
||||||
|
color: rgb(var(--red-6));
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-toolbar {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
411
src/views/order/batchJobs/index.vue
Normal file
411
src/views/order/batchJobs/index.vue
Normal file
@ -0,0 +1,411 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import dayjs from 'dayjs'
|
||||||
|
import HandsontablePro from '@/components/HandsontablePro/index.vue'
|
||||||
|
import PaginationPro from '@/components/PaginationPro/index.vue'
|
||||||
|
import { useTableContainerHeight } from '@/composables/useTableContainerHeight'
|
||||||
|
import {
|
||||||
|
getOrderBatchDlxStats,
|
||||||
|
getOrderBatchJobList,
|
||||||
|
postOrderBatchJobCancel,
|
||||||
|
postOrderBatchJobRetryFailed,
|
||||||
|
} from '@/api/order'
|
||||||
|
import { $baseMessage } from '@/utils'
|
||||||
|
import { hasPermission } from '@/utils/rbac-permission'
|
||||||
|
import {
|
||||||
|
ORDER_BATCH_ACTION_OPTIONS,
|
||||||
|
ORDER_BATCH_JOB_STATUS_OPTIONS,
|
||||||
|
getBatchActionLabelKey,
|
||||||
|
getBatchJobStatusLabelKey,
|
||||||
|
} from '@/constants/orderBatchAction'
|
||||||
|
import BatchJobDetailDrawer from './components/BatchJobDetailDrawer.vue'
|
||||||
|
|
||||||
|
defineOptions({ name: 'OrderBatchJobs' })
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const { tableContainerRef, tableHeight: handsontableHeight } = useTableContainerHeight()
|
||||||
|
|
||||||
|
const listLoading = ref(false)
|
||||||
|
const currentPage = ref(1)
|
||||||
|
const pageSize = ref(30)
|
||||||
|
const totalCount = ref(0)
|
||||||
|
const tableRows = ref<any[]>([])
|
||||||
|
|
||||||
|
const filterStatus = ref<string | undefined>()
|
||||||
|
const filterAction = ref<string | undefined>()
|
||||||
|
const filterJobId = ref<number | undefined>()
|
||||||
|
|
||||||
|
const dlxStats = ref<{ enabled: boolean; deadLetterMessageCount: number; dlxAlertThreshold: number; alert: boolean } | null>(null)
|
||||||
|
const dlxLoading = ref(false)
|
||||||
|
|
||||||
|
const drawerVisible = ref(false)
|
||||||
|
const activeJobId = ref<number | null>(null)
|
||||||
|
|
||||||
|
const statusOptions = computed(() =>
|
||||||
|
ORDER_BATCH_JOB_STATUS_OPTIONS.map((item) => ({
|
||||||
|
label: t(item.labelKey),
|
||||||
|
value: item.value,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
|
||||||
|
const actionOptions = computed(() =>
|
||||||
|
ORDER_BATCH_ACTION_OPTIONS.map((item) => ({
|
||||||
|
label: t(item.labelKey),
|
||||||
|
value: item.value,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
|
||||||
|
function formatTime(value?: string) {
|
||||||
|
if (!value) return '-'
|
||||||
|
const parsed = dayjs(value)
|
||||||
|
return parsed.isValid() ? parsed.format('YYYY-MM-DD HH:mm:ss') : value
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapRow(raw: any) {
|
||||||
|
return {
|
||||||
|
jobId: raw.jobId,
|
||||||
|
action: raw.action,
|
||||||
|
actionLabel: t(getBatchActionLabelKey(raw.action)),
|
||||||
|
status: raw.status,
|
||||||
|
statusLabel: t(getBatchJobStatusLabelKey(raw.status)),
|
||||||
|
total: raw.total,
|
||||||
|
successCount: raw.successCount,
|
||||||
|
failCount: raw.failCount,
|
||||||
|
progressText: `${raw.successCount}/${raw.total}`,
|
||||||
|
createTime: formatTime(raw.createTime),
|
||||||
|
finishedAt: formatTime(raw.finishedAt),
|
||||||
|
_raw: raw,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadList() {
|
||||||
|
listLoading.value = true
|
||||||
|
try {
|
||||||
|
const { items, total } = await getOrderBatchJobList({
|
||||||
|
page: currentPage.value,
|
||||||
|
pageSize: pageSize.value,
|
||||||
|
status: filterStatus.value,
|
||||||
|
action: filterAction.value,
|
||||||
|
jobId: filterJobId.value,
|
||||||
|
})
|
||||||
|
tableRows.value = (items || []).map(mapRow)
|
||||||
|
totalCount.value = Number(total || 0)
|
||||||
|
} catch (e: any) {
|
||||||
|
$baseMessage(e?.message || t('order.batchJobs.message.loadFailed'), 'error')
|
||||||
|
tableRows.value = []
|
||||||
|
totalCount.value = 0
|
||||||
|
} finally {
|
||||||
|
listLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadDlxStats() {
|
||||||
|
dlxLoading.value = true
|
||||||
|
try {
|
||||||
|
dlxStats.value = await getOrderBatchDlxStats()
|
||||||
|
} catch {
|
||||||
|
dlxStats.value = null
|
||||||
|
} finally {
|
||||||
|
dlxLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSearch() {
|
||||||
|
currentPage.value = 1
|
||||||
|
loadList()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleReset() {
|
||||||
|
filterStatus.value = undefined
|
||||||
|
filterAction.value = undefined
|
||||||
|
filterJobId.value = undefined
|
||||||
|
currentPage.value = 1
|
||||||
|
loadList()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePageChange(page: number) {
|
||||||
|
currentPage.value = page
|
||||||
|
loadList()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePageSizeChange(size: number) {
|
||||||
|
pageSize.value = size
|
||||||
|
currentPage.value = 1
|
||||||
|
loadList()
|
||||||
|
}
|
||||||
|
|
||||||
|
function openDetail(jobId: number) {
|
||||||
|
activeJobId.value = jobId
|
||||||
|
drawerVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRetry(row: any) {
|
||||||
|
if (!hasPermission('Order.BatchJobs.Retry')) return
|
||||||
|
try {
|
||||||
|
const result = await postOrderBatchJobRetryFailed(row.jobId)
|
||||||
|
$baseMessage(t('order.batchJobs.message.retrySuccess', { count: result.retriedCount }), 'success')
|
||||||
|
await loadList()
|
||||||
|
} catch (e: any) {
|
||||||
|
$baseMessage(e?.message || t('order.batchJobs.message.retryFailed'), 'error')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCancel(row: any) {
|
||||||
|
if (!hasPermission('Order.BatchJobs.Cancel')) return
|
||||||
|
try {
|
||||||
|
const result = await postOrderBatchJobCancel(row.jobId)
|
||||||
|
$baseMessage(t('order.batchJobs.message.cancelSuccess', { count: result.cancelledCount }), 'success')
|
||||||
|
await loadList()
|
||||||
|
} catch (e: any) {
|
||||||
|
$baseMessage(e?.message || t('order.batchJobs.message.cancelFailed'), 'error')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleActionClick(row: any, _rowIndex: number, action?: string) {
|
||||||
|
if (!row?.jobId || !action) return
|
||||||
|
if (action === 'view') {
|
||||||
|
openDetail(row.jobId)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (action === 'retry') {
|
||||||
|
handleRetry(row)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (action === 'cancel') {
|
||||||
|
handleCancel(row)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const hotColumns = computed(() => [
|
||||||
|
{ key: 'jobId', label: t('order.batchJobs.table.jobId'), width: 90 },
|
||||||
|
{ key: 'actionLabel', label: t('order.batchJobs.table.action'), width: 140 },
|
||||||
|
{ key: 'statusLabel', label: t('order.batchJobs.table.status'), width: 100 },
|
||||||
|
{ key: 'progressText', label: t('order.batchJobs.table.progress'), width: 100 },
|
||||||
|
{ key: 'failCount', label: t('order.batchJobs.table.failCountShort'), width: 80 },
|
||||||
|
{ key: 'createTime', label: t('order.batchJobs.table.createTime'), width: 170 },
|
||||||
|
{ key: 'finishedAt', label: t('order.batchJobs.table.finishedAt'), width: 170 },
|
||||||
|
])
|
||||||
|
|
||||||
|
function actionRenderer(rowData: any, _rowIndex: number) {
|
||||||
|
const actions: Array<{ key: string; label: string; show?: boolean }> = [
|
||||||
|
{
|
||||||
|
key: 'view',
|
||||||
|
label: t('order.batchJobs.actions.view'),
|
||||||
|
show: hasPermission('Order.BatchJobs.View'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'retry',
|
||||||
|
label: t('order.batchJobs.actions.retryFailed'),
|
||||||
|
show: hasPermission('Order.BatchJobs.Retry') && Number(rowData?.failCount || 0) > 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'cancel',
|
||||||
|
label: t('order.batchJobs.actions.cancelPending'),
|
||||||
|
show:
|
||||||
|
hasPermission('Order.BatchJobs.Cancel') &&
|
||||||
|
['pending', 'running'].includes(String(rowData?.status || '').toLowerCase()),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
return actions
|
||||||
|
.filter((item) => item.show)
|
||||||
|
.map((item) => `<button type="button" class="hot-action-btn" data-action="${item.key}">${item.label}</button>`)
|
||||||
|
.join('')
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadList()
|
||||||
|
loadDlxStats()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="batch-jobs-page">
|
||||||
|
<div class="batch-jobs-page-card">
|
||||||
|
<a-alert
|
||||||
|
v-if="dlxStats?.enabled && dlxStats.alert"
|
||||||
|
type="warning"
|
||||||
|
banner
|
||||||
|
closable
|
||||||
|
style="margin-bottom: 12px"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
t('order.batchJobs.dlx.alert', {
|
||||||
|
count: dlxStats.deadLetterMessageCount,
|
||||||
|
threshold: dlxStats.dlxAlertThreshold,
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
<a-link style="margin-left: 8px" @click="loadDlxStats">{{ t('order.batchJobs.actions.refresh') }}</a-link>
|
||||||
|
</a-alert>
|
||||||
|
|
||||||
|
<div class="table-toolbar">
|
||||||
|
<div class="toolbar-left">
|
||||||
|
<a-space wrap :size="8">
|
||||||
|
<a-input-number
|
||||||
|
v-model="filterJobId"
|
||||||
|
:placeholder="t('order.batchJobs.filter.jobId')"
|
||||||
|
:min="1"
|
||||||
|
:precision="0"
|
||||||
|
allow-clear
|
||||||
|
hide-button
|
||||||
|
style="width: 140px"
|
||||||
|
@press-enter="handleSearch"
|
||||||
|
/>
|
||||||
|
<a-select
|
||||||
|
v-model="filterStatus"
|
||||||
|
:placeholder="t('order.batchJobs.filter.status')"
|
||||||
|
allow-clear
|
||||||
|
style="width: 140px"
|
||||||
|
>
|
||||||
|
<a-option v-for="opt in statusOptions" :key="opt.value" :value="opt.value">
|
||||||
|
{{ opt.label }}
|
||||||
|
</a-option>
|
||||||
|
</a-select>
|
||||||
|
<a-select
|
||||||
|
v-model="filterAction"
|
||||||
|
:placeholder="t('order.batchJobs.filter.action')"
|
||||||
|
allow-clear
|
||||||
|
style="width: 180px"
|
||||||
|
>
|
||||||
|
<a-option v-for="opt in actionOptions" :key="opt.value" :value="opt.value">
|
||||||
|
{{ opt.label }}
|
||||||
|
</a-option>
|
||||||
|
</a-select>
|
||||||
|
<a-button type="primary" @click="handleSearch">{{ t('order.batchJobs.actions.search') }}</a-button>
|
||||||
|
<a-button @click="handleReset">{{ t('order.batchJobs.actions.reset') }}</a-button>
|
||||||
|
<a-button :loading="listLoading || dlxLoading" @click="loadList(); loadDlxStats()">
|
||||||
|
{{ t('order.batchJobs.actions.refresh') }}
|
||||||
|
</a-button>
|
||||||
|
</a-space>
|
||||||
|
</div>
|
||||||
|
<div class="toolbar-right">
|
||||||
|
<PaginationPro
|
||||||
|
:total-count="totalCount"
|
||||||
|
v-model:current-page="currentPage"
|
||||||
|
v-model:page-size="pageSize"
|
||||||
|
@page-change="handlePageChange"
|
||||||
|
@page-size-change="handlePageSizeChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a-spin class="list-spin" :loading="listLoading">
|
||||||
|
<div ref="tableContainerRef" class="list-wrap">
|
||||||
|
<HandsontablePro
|
||||||
|
:columns="hotColumns"
|
||||||
|
:data="tableRows"
|
||||||
|
:total="totalCount"
|
||||||
|
:current-page="currentPage"
|
||||||
|
:page-size="pageSize"
|
||||||
|
:show-action-column="true"
|
||||||
|
:action-column-position="'end'"
|
||||||
|
:action-column-width="220"
|
||||||
|
:action-renderer="actionRenderer"
|
||||||
|
:stretch-columns="true"
|
||||||
|
:height="handsontableHeight"
|
||||||
|
:read-only="true"
|
||||||
|
@action-click="handleActionClick"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</a-spin>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<BatchJobDetailDrawer
|
||||||
|
v-model:visible="drawerVisible"
|
||||||
|
:job-id="activeJobId"
|
||||||
|
@refreshed="loadList"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="less">
|
||||||
|
.batch-jobs-page {
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 16px 16px 24px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: #f0f3f4;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-x: hidden;
|
||||||
|
overflow-y: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.batch-jobs-page-card {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||||
|
box-sizing: border-box;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 16px 16px 0;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-left,
|
||||||
|
.toolbar-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-spin {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 0 16px 16px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-spin :deep(.arco-spin-container) {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-wrap {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
touch-action: pan-x pan-y;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-wrap :deep(.handsontable-pro-container) {
|
||||||
|
width: 100%;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.hot-action-btn) {
|
||||||
|
margin-right: 8px;
|
||||||
|
color: var(--app-primary-color, rgb(var(--primary-6)));
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -262,6 +262,7 @@ export default defineConfig(({ mode }) => {
|
|||||||
'/order/priceCheck',
|
'/order/priceCheck',
|
||||||
'/order/placeOrder',
|
'/order/placeOrder',
|
||||||
'/order/myOrders',
|
'/order/myOrders',
|
||||||
|
'/order/batchJobs',
|
||||||
// Xhs 相关前端路由(勿用 /xhs 前缀,否则 API 会被误判为 SPA)
|
// Xhs 相关前端路由(勿用 /xhs 前缀,否则 API 会被误判为 SPA)
|
||||||
'/xhs/startService',
|
'/xhs/startService',
|
||||||
'/xhs/analysis',
|
'/xhs/analysis',
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user