From e356ddb9cf835fc0537320fc34549cd4e945df5b Mon Sep 17 00:00:00 2001 From: zzh <1368735060@qq.com> Date: Wed, 8 Jul 2026 12:42:28 +0800 Subject: [PATCH] =?UTF-8?q?feat(order):=20=E8=AE=A2=E5=8D=95=E6=89=B9?= =?UTF-8?q?=E9=87=8F=E4=BB=BB=E5=8A=A1=E7=AE=A1=E7=90=86=E9=A1=B5=E4=B8=8E?= =?UTF-8?q?=20SPA=20=E8=B7=AF=E7=94=B1=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增批量任务列表/详情治理界面,补齐 API、权限菜单与 nginx/vite 前端路由白名单。 Co-authored-by: Cursor --- .cursor/rules/frontend-design-spec.mdc | 7 +- docs/前端页面设计规范.md | 93 +++- generate-menu-excel.js | 11 + nginx-default.conf | 14 + nginx-docker.conf | 6 + src/api/order/index.ts | 393 ++++++++++++++++- src/constants/orderBatchAction.ts | 48 ++ src/constants/pageButtonCatalog.ts | 9 + src/locale/en-US.ts | 60 +++ src/locale/zh-CN.ts | 59 +++ src/router/routes/newModules/order.ts | 10 + src/utils/menu-icon.ts | 1 + src/utils/route-name-mapper.ts | 2 + .../components/BatchJobDetailDrawer.vue | 285 ++++++++++++ src/views/order/batchJobs/index.vue | 411 ++++++++++++++++++ vite.config.ts | 1 + 16 files changed, 1405 insertions(+), 5 deletions(-) create mode 100644 src/constants/orderBatchAction.ts create mode 100644 src/views/order/batchJobs/components/BatchJobDetailDrawer.vue create mode 100644 src/views/order/batchJobs/index.vue 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 @@ + + + + + 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 @@ + + + + + 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',