diff --git a/.cursor/rules/frontend-design-spec.mdc b/.cursor/rules/frontend-design-spec.mdc
index 1a91476..519a957 100644
--- a/.cursor/rules/frontend-design-spec.mdc
+++ b/.cursor/rules/frontend-design-spec.mdc
@@ -6,7 +6,7 @@ alwaysApply: true
# 中后台前端设计规范(itfx-front)
-开发新页面或修改现有页面时,必须遵循 `docs/中后台前端页面设计规范.md` 中的约定。
+开发新页面或修改现有页面时,必须遵循 `docs/前端页面设计规范.md` 中的约定。
## 关键规则速查
@@ -28,7 +28,10 @@ alwaysApply: true
### 表格
- TablePro:api 返回 totalCount + items
+- HandsontablePro 列定义用 `key` + `label`,勿用 `data` + `title`
+- 列表页高度:`flex: 1; min-height: 0`,`.list-wrap` 勿写死 min-height
- 分页 10/30/50/100,默认 30
+- 新页面 path 与 API 前缀冲突时须配置 nginx SPA 回退(见规范 §十五)
### 权限
- `hasPermission('Module.Resource.Action')` 控制按钮/路由
@@ -39,4 +42,4 @@ alwaysApply: true
- 路由 path:camelCase
- 变量:camelCase
-完整规范见 `docs/中后台前端页面设计规范.md`。
+完整规范见 `docs/前端页面设计规范.md`。
diff --git a/docs/前端页面设计规范.md b/docs/前端页面设计规范.md
index 009b57a..32cd552 100644
--- a/docs/前端页面设计规范.md
+++ b/docs/前端页面设计规范.md
@@ -311,6 +311,36 @@ itemList: [
- 删除使用 `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
- [ ] 表单使用 FormPro 或统一 label/span
- [ ] 表格优先 TablePro / CustomList / HandsontablePro
+- [ ] **HandsontablePro** 列定义使用 **`key` + `label`**,勿用 `data` + `title`(**§7.5**)
- [ ] 分页组件置于**列表上方工具栏右侧**(列表右上角),**§7.3**;分页 10/30/50/100,默认 30
- [ ] 操作按钮按权限使用 `hasPermission`
- [ ] 路由 meta 配置 `permission`
+- [ ] **新增前端页面路径**若落在 `/order/`、`/channel/`、`/system/` 等与 Admin API 同源前缀下,须在 **`nginx-default.conf` / `nginx-docker.conf`** 增加 `^~` 的 `try_files /index.html =200` 规则(**§十五**),避免刷新/直链 404
- [ ] 文案通过 i18n,不写死中文
- [ ] 命名符合本规范(文件、路由、变量、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();
```
+### HandsontablePro 列表页(列 + 高度)
+
+```vue
+
+
+
+
+
+```
+
+布局样式见 **§2.5**(`flex: 1; min-height: 0`,勿给 `.list-wrap` 写死 `min-height: 480px` 导致列表区塌缩或撑破)。
+
### FormPro 查询表单
```vue
@@ -575,4 +666,4 @@ const { t } = useI18n();
---
-*文档版本:1.1 | 更新日期:2026-03-23*
+*文档版本:1.2 | 更新日期:2026-03-23*
diff --git a/generate-menu-excel.js b/generate-menu-excel.js
index 1c887c8..f7462f2 100644
--- a/generate-menu-excel.js
+++ b/generate-menu-excel.js
@@ -337,6 +337,17 @@ const menuData = [
hideInMenu: false,
parentRoute: '/order',
},
+ {
+ level: 2,
+ name: '批量任务',
+ permissionCode: 'Order.BatchJobs',
+ route: '/order/batchJobs',
+ component: 'views/order/batchJobs/index',
+ icon: null,
+ order: 4,
+ hideInMenu: false,
+ parentRoute: '/order',
+ },
// 一级菜单 - 渠道
{
diff --git a/nginx-default.conf b/nginx-default.conf
index 55bb063..4b1c0fe 100644
--- a/nginx-default.conf
+++ b/nginx-default.conf
@@ -103,6 +103,20 @@ server {
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 正则代理更优先)============
# 页面路径为 camelCase;API 为 kebab(如 /system/dict/list、/system/batch-email/…),勿用宽泛前缀匹配 dict
location = /system/dict {
diff --git a/nginx-docker.conf b/nginx-docker.conf
index 3fff413..555a096 100644
--- a/nginx-docker.conf
+++ b/nginx-docker.conf
@@ -40,6 +40,12 @@ server {
location ^~ /operation/outboundScan { 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/serviceChannels { try_files /index.html =200; }
location ^~ /channel/priceArea { try_files /index.html =200; }
diff --git a/src/api/order/index.ts b/src/api/order/index.ts
index 14b5c99..205f8e4 100644
--- a/src/api/order/index.ts
+++ b/src/api/order/index.ts
@@ -272,6 +272,308 @@ export interface OrderBatchActionResult {
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 = {
+ 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
+ payloadJson?: string
+ requestId?: string
+}): Promise> {
+ 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>; 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 {
+ 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 {
+ 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 {
+ 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((resolve) => {
+ setTimeout(resolve, ms)
+ })
+}
+
+/** POST /order/batch/track-upsert */
+export async function postOrderBatchTrackUpsert(body: {
+ orderIds: Array
+ requestId?: string
+}): Promise> {
+ 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
+ requestId?: string
+}): Promise> {
+ 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> {
+ 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>> {
+ 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
+ items: Array>
+}> {
+ 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 {
orderIds: Array
issueTemplateId?: number | string
@@ -539,7 +841,10 @@ export async function downloadOrderTemplate(templateId: number, fileName: string
}
/** 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()
form.append('file', file)
form.append('templateId', String(templateId))
@@ -547,7 +852,13 @@ export async function postOrderUpload(file: File, templateId: number): Promise<{
assertBusinessOk(res.data)
const d = res.data?.data ?? res.data?.Data ?? {}
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 */
@@ -584,6 +895,84 @@ export async function postOrderParcelCreate(body: Record): Promise<
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>
+): Promise> {
+ const res = await axiosInstance.post(orderUrl('/order/parcel/batch-create'), bodies)
+ assertBusinessOk(res.data)
+ return parseOrderParcelBatchCreateResult(res.data)
+}
+
/** POST /order/update */
export async function postOrderUpdate(body: Record): Promise {
const res = await axiosInstance.post(orderUrl('/order/update'), body)
diff --git a/src/constants/orderBatchAction.ts b/src/constants/orderBatchAction.ts
new file mode 100644
index 0000000..f91989b
--- /dev/null
+++ b/src/constants/orderBatchAction.ts
@@ -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'
+}
diff --git a/src/constants/pageButtonCatalog.ts b/src/constants/pageButtonCatalog.ts
index d3abb19..beaa888 100644
--- a/src/constants/pageButtonCatalog.ts
+++ b/src/constants/pageButtonCatalog.ts
@@ -50,7 +50,9 @@ export const PERMISSION_ACTION_LABELS: Record = {
Upload: '上传',
Sync: '同步',
Retry: '重试',
+ RetryFailed: '重试失败',
Cancel: '取消',
+ CancelPending: '取消待处理',
Enable: '启用',
Disable: '禁用',
View: '查看',
@@ -251,6 +253,7 @@ export const PAGE_BUTTON_PARENT_ALIASES: Record = {
'Api.ApiTest': 'Api.ApiTest',
'Api.Test.Query': 'Api.ApiTest',
'Channel.LabelDesign': 'Channel.LabelDesign',
+ 'Order.BatchJobs.Query': 'Order.BatchJobs',
};
type BtnInput = { code: string; name: string };
@@ -472,6 +475,12 @@ const CORE_GROUPS: PageButtonGroup[] = [
{ code: 'RecalculatePayable', name: '重算应付' },
{ code: 'AddDeclarationRow', name: '添加申报行' },
]),
+ B('Order.BatchJobs', [
+ { code: 'Query', name: '查询' },
+ { code: 'View', name: '查看' },
+ { code: 'Retry', name: '重试失败' },
+ { code: 'Cancel', name: '取消待处理' },
+ ]),
// ── 渠道 ──
B('Channel.SalesProducts', [
diff --git a/src/locale/en-US.ts b/src/locale/en-US.ts
index 5c000be..938700e 100644
--- a/src/locale/en-US.ts
+++ b/src/locale/en-US.ts
@@ -80,6 +80,7 @@ export default {
'menu.order.priceCheck': 'Price Check',
'menu.order.placeOrder': 'Place Order',
'menu.order.myOrders': 'My Orders',
+ 'menu.order.batchJobs': 'Batch Jobs',
'menu.channel': 'Channel',
'menu.channel.salesProducts': 'Sales Products',
'menu.channel.serviceChannels': 'Service Channels',
@@ -183,6 +184,65 @@ export default {
'system.dataPerm.templates.field.bindRole': 'Linked 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',
+
+ // 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.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.',
diff --git a/src/locale/zh-CN.ts b/src/locale/zh-CN.ts
index 32452e6..d5dd0b7 100644
--- a/src/locale/zh-CN.ts
+++ b/src/locale/zh-CN.ts
@@ -80,6 +80,7 @@ export default {
'menu.order.priceCheck': '我要查价',
'menu.order.placeOrder': '我要下单',
'menu.order.myOrders': '我的订单',
+ 'menu.order.batchJobs': '批量任务',
'menu.channel': '渠道',
'menu.channel.salesProducts': '销售产品',
'menu.channel.serviceChannels': '服务渠道',
@@ -184,6 +185,64 @@ export default {
'nav.balance.rechargeRecord': '充值记录',
'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.draft': '草稿',
diff --git a/src/router/routes/newModules/order.ts b/src/router/routes/newModules/order.ts
index 9c57755..4aa1b21 100644
--- a/src/router/routes/newModules/order.ts
+++ b/src/router/routes/newModules/order.ts
@@ -39,6 +39,16 @@ const ORDER: AppRouteRecordRaw = {
requiresAuth: true,
},
},
+ {
+ path: 'batchJobs',
+ name: 'OrderBatchJobs',
+ component: () => import('@/views/order/batchJobs/index.vue'),
+ meta: {
+ locale: 'menu.order.batchJobs',
+ requiresAuth: true,
+ permission: 'Order.BatchJobs.Query',
+ },
+ },
],
};
diff --git a/src/utils/menu-icon.ts b/src/utils/menu-icon.ts
index 8ca448a..8226f8f 100644
--- a/src/utils/menu-icon.ts
+++ b/src/utils/menu-icon.ts
@@ -27,6 +27,7 @@ export const SUB_MENU_DEFAULT_ICONS: Record = {
'menu.order.priceCheck': 'IconSearch',
'menu.order.placeOrder': 'IconPlus',
'menu.order.myOrders': 'IconOrderedList',
+ 'menu.order.batchJobs': 'IconSchedule',
'menu.channel.salesProducts': 'IconTag',
'menu.channel.serviceChannels': 'IconSend',
'menu.channel.priceArea': 'IconLocation',
diff --git a/src/utils/route-name-mapper.ts b/src/utils/route-name-mapper.ts
index 2a49683..01a9cd9 100644
--- a/src/utils/route-name-mapper.ts
+++ b/src/utils/route-name-mapper.ts
@@ -10,6 +10,8 @@ const routeNameMap: Record = {
'/order/priceCheck': 'PriceCheck',
'order_my_orders': 'MyOrders',
'/order/myOrders': 'MyOrders',
+ 'order_batch_jobs': 'OrderBatchJobs',
+ '/order/batchJobs': 'OrderBatchJobs',
'order': 'Order',
'/order': 'Order',
diff --git a/src/views/order/batchJobs/components/BatchJobDetailDrawer.vue b/src/views/order/batchJobs/components/BatchJobDetailDrawer.vue
new file mode 100644
index 0000000..0619772
--- /dev/null
+++ b/src/views/order/batchJobs/components/BatchJobDetailDrawer.vue
@@ -0,0 +1,285 @@
+
+
+
+
+
+
+
+
+ {{ t(getBatchActionLabelKey(job.action)) }}
+
+
+ {{ t(getBatchJobStatusLabelKey(job.status)) }}
+
+
+ {{ job.successCount }}/{{ job.total }}
+
+ ({{ t('order.batchJobs.table.failCount', { count: job.failCount }) }})
+
+
+
+ {{ formatTime(job.createTime) }}
+
+
+ {{ formatTime(job.startedAt) }}
+
+
+ {{ formatTime(job.finishedAt) }}
+
+
+
+
+
+
+
+
+
+
+ {{ record.serviceNo || '-' }}
+
+
+
+
+
+ {{ t(getBatchItemStatusLabelKey(record.status)) }}
+
+
+
+
+
+ {{ record.failReason || '-' }}
+
+
+
+
+
+
+
+
+
+
diff --git a/src/views/order/batchJobs/index.vue b/src/views/order/batchJobs/index.vue
new file mode 100644
index 0000000..8ed5dcd
--- /dev/null
+++ b/src/views/order/batchJobs/index.vue
@@ -0,0 +1,411 @@
+
+
+
+
+
+
+ {{
+ t('order.batchJobs.dlx.alert', {
+ count: dlxStats.deadLetterMessageCount,
+ threshold: dlxStats.dlxAlertThreshold,
+ })
+ }}
+ {{ t('order.batchJobs.actions.refresh') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/vite.config.ts b/vite.config.ts
index aee59c3..6009681 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -262,6 +262,7 @@ export default defineConfig(({ mode }) => {
'/order/priceCheck',
'/order/placeOrder',
'/order/myOrders',
+ '/order/batchJobs',
// Xhs 相关前端路由(勿用 /xhs 前缀,否则 API 会被误判为 SPA)
'/xhs/startService',
'/xhs/analysis',