背景:A6-FE-01/02,用户选定方案 A · 经典管理台;后端任务协议 / SSE / 取消 / 仅重试失败项已就绪,需要可用页面与可复用进度体验。 改动: - 路由 /material/tasks、/material/tasks/:id(material:read;写操作按 material:render 渲染);侧栏「生产任务」更名「任务中心」;AppLayout 满高布局;详情面包屑与标题对齐。 - 页面:任务中心列表(统计、类型 chips、状态/客户/时间/关键字筛选 + URL 同步、游标加载更多、三态、行内取消/重试)、详情(任务头、失败优先进度、失败明细与行号、逐项表 + items 续拉、任务信息/时间线/进度更新/操作说明)、提交演练任务弹层(实时预估 + 复用 Idempotency-Key + 提交中禁用)、取消/重试二次确认。 - 组件:TaskTypeTag / TaskStatusTag / TaskItemStatusTag / TaskProgressBar / TaskMiniProgress / TaskProgressRing / TaskSseIndicator / TaskBadge + useTaskStream(fetch 流 + Last-Event-ID + 重连 + 5s 轮询兜底);顶栏角标含待处理失败直达,完成/失败通知。 - API/hooks:media.ts(提交 202 + Idempotency-Key、列表、详情、取消、重试、SSE 流)、use-media-tasks(游标 / 详情 / 角标 / 待处理失败 / 提交取消重试 mutation / 预估配置);client 增加流式请求与 401 刷新;后台轮询失败不弹全局 toast。 - 样式:tasks.css / task-detail.css 全部使用 --lx-* token;行高 56、表格撑满、1024 保留失败统计;按独立视觉评审完成两轮修正(失败数前置、去工程化文案、状态色统一、去重复入口与计数、去掉禁用入口)。 影响面:新增 2 个受权限保护的路由与 1 个顶栏角标入口;未改其他页面行为。 回滚:撤销提交即可,后端接口与旧页面不受影响。 验证:web typecheck / lint / test(37 文件 238 用例,覆盖率 91.7%)/ build 全绿;A6 浏览器 e2e 6/6;真实 API 截图见 design/a6-ui-v1/shots/impl/real。
127 lines
4.8 KiB
TypeScript
127 lines
4.8 KiB
TypeScript
/**
|
||
* A6 任务中心接口(唯一出处)。
|
||
*
|
||
* 依据:packages/shared/src/schemas/tasks.ts、技术架构 §6.4(异步任务协议)。
|
||
* 约定:query 用 snake_case(C-01);提交必须带 `Idempotency-Key`,响应字段固定
|
||
* `{ taskId, estimatedItems, estimatedCost, estimatedSeconds }`;列表 `{ items, next_cursor }`。
|
||
* 所有响应 / 入参都用 shared Zod schema parse,契约漂移在请求层直接暴露。
|
||
*/
|
||
import {
|
||
type MediaTaskCancelResponse,
|
||
mediaTaskCancelResponseSchema,
|
||
type MediaTaskDetail,
|
||
type MediaTaskDetailQuery,
|
||
mediaTaskDetailQuerySchema,
|
||
mediaTaskDetailSchema,
|
||
type MediaTaskListQuery,
|
||
mediaTaskListQuerySchema,
|
||
type MediaTaskListResponse,
|
||
mediaTaskListResponseSchema,
|
||
type MediaTaskRetryResponse,
|
||
mediaTaskRetryResponseSchema,
|
||
type MediaTaskSubmitInput,
|
||
type MediaTaskSubmitParsed,
|
||
type MediaTaskSubmitResponse,
|
||
mediaTaskSubmitResponseSchema,
|
||
mediaTaskSubmitSchema,
|
||
} from '@lingxi/shared';
|
||
|
||
import { request, streamRequest } from './client';
|
||
import { ENDPOINTS } from './endpoints';
|
||
|
||
function searchParams(entries: Record<string, string | number | undefined>): string {
|
||
const params = new URLSearchParams();
|
||
for (const [key, value] of Object.entries(entries)) {
|
||
if (value !== undefined && value !== '') params.set(key, String(value));
|
||
}
|
||
const search = params.toString();
|
||
return search === '' ? '' : `?${search}`;
|
||
}
|
||
|
||
/** 任务提交幂等键:8–128 位可见 ASCII(后端强校验);随机 UUID 足够且不落库敏感信息 */
|
||
export function createTaskIdempotencyKey(): string {
|
||
if (typeof crypto.randomUUID === 'function') {
|
||
return `task-${crypto.randomUUID()}`;
|
||
}
|
||
const random = Math.random().toString(36).slice(2);
|
||
return `task-${Date.now().toString(36)}-${random}`;
|
||
}
|
||
|
||
/** 跨类型任务列表:类型 / 状态 / 客户 / 时间 / 我的 / 关键字 + 游标分页 */
|
||
export async function fetchMediaTasks(
|
||
query: Partial<MediaTaskListQuery> = {},
|
||
): Promise<MediaTaskListResponse> {
|
||
const parsed = mediaTaskListQuerySchema.parse(query);
|
||
const payload: unknown = await request(
|
||
`${ENDPOINTS.mediaTasks}${searchParams({
|
||
type: parsed.type,
|
||
status: parsed.status,
|
||
customer_id: parsed.customer_id,
|
||
created_from: parsed.created_from,
|
||
created_to: parsed.created_to,
|
||
mine: parsed.mine,
|
||
keyword: parsed.keyword,
|
||
cursor: parsed.cursor,
|
||
limit: parsed.limit,
|
||
})}`,
|
||
);
|
||
return mediaTaskListResponseSchema.parse(payload);
|
||
}
|
||
|
||
/** 任务详情:摘要 + payload 快照 + 任务项首页(默认 100 条)+ itemsNextCursor */
|
||
export async function fetchMediaTaskDetail(
|
||
taskId: string,
|
||
query: Partial<MediaTaskDetailQuery> = {},
|
||
): Promise<MediaTaskDetail> {
|
||
const parsed = mediaTaskDetailQuerySchema.parse(query);
|
||
const payload: unknown = await request(
|
||
`${ENDPOINTS.mediaTask(taskId)}${searchParams({ items_cursor: parsed.items_cursor })}`,
|
||
);
|
||
return mediaTaskDetailSchema.parse(payload);
|
||
}
|
||
|
||
/**
|
||
* 提交任务(M0 仅演练任务)。幂等键由调用方生成并在重试 / 双击时复用:
|
||
* 同 key 同 body 24h 内返回同一任务,不同 body 返回 40902。
|
||
*/
|
||
export async function submitMediaTask(
|
||
input: MediaTaskSubmitInput,
|
||
idempotencyKey: string,
|
||
): Promise<MediaTaskSubmitResponse> {
|
||
const body: MediaTaskSubmitParsed = mediaTaskSubmitSchema.parse(input);
|
||
const payload: unknown = await request(ENDPOINTS.mediaTasks, {
|
||
method: 'POST',
|
||
body,
|
||
headers: { 'Idempotency-Key': idempotencyKey },
|
||
});
|
||
return mediaTaskSubmitResponseSchema.parse(payload);
|
||
}
|
||
|
||
/** 取消任务:只影响未开始项,已执行 / 已成功项保留结果 */
|
||
export async function cancelMediaTask(taskId: string): Promise<MediaTaskCancelResponse> {
|
||
const payload: unknown = await request(ENDPOINTS.mediaTaskCancel(taskId), { method: 'POST' });
|
||
return mediaTaskCancelResponseSchema.parse(payload);
|
||
}
|
||
|
||
/** 仅重试失败项:成功项不重复执行、不重复计费 */
|
||
export async function retryMediaTask(taskId: string): Promise<MediaTaskRetryResponse> {
|
||
const payload: unknown = await request(ENDPOINTS.mediaTaskRetry(taskId), { method: 'POST' });
|
||
return mediaTaskRetryResponseSchema.parse(payload);
|
||
}
|
||
|
||
/**
|
||
* 打开任务 SSE 事件流(fetch 流,支持 Authorization 头)。
|
||
* 断线续推用 `Last-Event-ID` 头(后端优先)或 `last_event_id` query;由调用方负责重连与降级。
|
||
*/
|
||
export function openMediaTaskEvents(
|
||
taskId: string,
|
||
options: { lastEventId?: string; signal?: AbortSignal } = {},
|
||
): Promise<Response> {
|
||
const query =
|
||
options.lastEventId === undefined ? '' : searchParams({ last_event_id: options.lastEventId });
|
||
return streamRequest(`${ENDPOINTS.mediaTaskEvents(taskId)}${query}`, {
|
||
headers: { Accept: 'text/event-stream' },
|
||
...(options.signal === undefined ? {} : { signal: options.signal }),
|
||
});
|
||
}
|