Files
lingxi/apps/web/src/api/media.ts
T
Stev_Wang a9ee5e6383 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。
2026-11-05 11:40:41 +08:00

127 lines
4.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 }),
});
}