feat(order): 订单批量任务管理页与 SPA 路由配置

新增批量任务列表/详情治理界面,补齐 API、权限菜单与 nginx/vite 前端路由白名单。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zzh 2026-07-08 12:42:28 +08:00
parent 7585e0c7b7
commit e356ddb9cf
16 changed files with 1405 additions and 5 deletions

View File

@ -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`。

View File

@ -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*

View File

@ -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',
},
// 一级菜单 - 渠道 // 一级菜单 - 渠道
{ {

View File

@ -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 {

View File

@ -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; }

View File

@ -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)

View 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'
}

View File

@ -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', [

View File

@ -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.',

View File

@ -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': '草稿',

View File

@ -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',
},
},
], ],
}; };

View File

@ -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',

View File

@ -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',

View 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>

View 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>

View File

@ -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',