feat(web): A6 任务中心页面与可复用进度组件(方案 A)

背景: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。
This commit is contained in:
Stev_Wang committed 2026-11-05 11:40:41 +08:00
1 parent b377618ff9
commit a9ee5e6383
40 files changed
+7299 -13

No files matched your search

+126
View File
@@ -0,0 +1,126 @@
/**
* 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 }),
});
}