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:
1 parent
b377618ff9
commit
a9ee5e6383
40 files changed
+7299
-13
No files matched your search
@@ -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 }),
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user