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

+43
View File
@@ -205,3 +205,46 @@ async function send<T>(path: string, options: RequestOptions, allowRefresh: bool
export function request<T>(path: string, options: RequestOptions = {}): Promise<T> {
return send<T>(path, options, true);
}
export interface StreamRequestOptions {
headers?: Record<string, string>;
signal?: AbortSignal;
}
/**
* 流式请求入口(A6 SSE 进度流):与 `request` 相同的鉴权与 401 单飞刷新语义,
* 但返回原始 `Response`,由调用方读取 `body` reader(不解析 JSON、不缓存)。
*/
export async function streamRequest(
path: string,
options: StreamRequestOptions = {},
allowRefresh = true,
): Promise<Response> {
const { accessToken } = useAuthStore.getState();
const headers = new Headers(options.headers);
if (accessToken !== null) {
headers.set('Authorization', `Bearer ${accessToken}`);
}
let response: Response;
try {
response = await fetch(buildUrl(path), {
method: 'GET',
headers,
signal: options.signal,
});
} catch (error) {
// 用户主动取消(组件卸载)不当作错误上报
if (error instanceof DOMException && error.name === 'AbortError') throw error;
throw new ApiError({ code: 50001, status: 0, message: '网络连接中断,请检查网络后重试' });
}
if (response.status === 401 && allowRefresh) {
const refreshed = await refreshSession();
if (refreshed) {
return streamRequest(path, options, false);
}
}
if (!response.ok) {
throw await toApiError(response);
}
return response;
}
+9
View File
@@ -7,6 +7,7 @@ const USERS = '/users';
const DICTS = '/dicts';
const SYSTEM_CONFIGS = '/system/configs';
const AUDIT_LOGS = '/audit-logs';
const MEDIA_TASKS = '/media-tasks';
export const ENDPOINTS = {
login: '/auth/login',
@@ -75,4 +76,12 @@ export const ENDPOINTS = {
`${AUDIT_LOGS}/exports/${encodeURIComponent(exportId)}`,
auditExportDownload: (exportId: string): string =>
`${AUDIT_LOGS}/exports/${encodeURIComponent(exportId)}/download`,
// A6 media tasks:提交 / 列表 / 详情 / SSE / 取消 / 仅重试失败项
mediaTasks: MEDIA_TASKS,
mediaTask: (taskId: string): string => `${MEDIA_TASKS}/${encodeURIComponent(taskId)}`,
mediaTaskEvents: (taskId: string): string =>
`${MEDIA_TASKS}/${encodeURIComponent(taskId)}/events`,
mediaTaskCancel: (taskId: string): string =>
`${MEDIA_TASKS}/${encodeURIComponent(taskId)}/cancel`,
mediaTaskRetry: (taskId: string): string => `${MEDIA_TASKS}/${encodeURIComponent(taskId)}/retry`,
} as const;
+198
View File
@@ -0,0 +1,198 @@
import type { MediaTaskDetail, MediaTaskListResponse } from '@lingxi/shared';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { useAuthStore } from '@/stores/auth-store';
import { setUnauthorizedHandler } from './client';
import {
cancelMediaTask,
createTaskIdempotencyKey,
fetchMediaTaskDetail,
fetchMediaTasks,
openMediaTaskEvents,
retryMediaTask,
submitMediaTask,
} from './media';
const TASK_ID = '01JCNB2M8K4Q7T3F9V6W1X0Y5Z';
const CUSTOMER_ID = '01JCP0CST00000000000000001';
const NOW = '2026-11-04T12:21:00.000Z';
const jsonResponse = (body: unknown, status = 200): Response =>
new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
});
const summary = {
id: TASK_ID,
type: 'demo' as const,
status: 'running' as const,
title: '花间集 · 任务协议演练 8 项',
customerId: CUSTOMER_ID,
customerName: '花间集',
itemTotal: 8,
itemSucceeded: 3,
itemFailed: 0,
itemSkipped: 0,
progress: 38,
estimatedItems: 8,
estimatedCost: 0.16,
estimatedSeconds: 24,
createdBy: '01JCP0SER00000000000000001',
createdByName: '江岚',
createdAt: NOW,
};
const list: MediaTaskListResponse = { items: [summary], next_cursor: null };
const detail: MediaTaskDetail = {
...summary,
payload: { mode: 'mixed' },
items: [
{
id: '01JCN1TEM00000000000000001',
seq: 1,
status: 'pending_review',
attempts: 1,
startedAt: NOW,
finishedAt: NOW,
},
],
itemsNextCursor: null,
};
function lastCall(): { url: string; init: RequestInit | undefined } {
const fetchMock = vi.mocked(globalThis.fetch);
const call = fetchMock.mock.calls.at(-1);
const input = call?.[0];
const url =
typeof input === 'string' ? input : input instanceof URL ? input.href : (input?.url ?? '');
return { url, init: call?.[1] };
}
function lastHeaders(): Headers {
return new Headers(lastCall().init?.headers);
}
beforeEach(() => {
vi.restoreAllMocks();
window.localStorage.clear();
useAuthStore.getState().clearSession();
setUnauthorizedHandler(null);
useAuthStore.getState().setSession({
accessToken: 'token',
user: { id: '01JCP0SER00000000000000001', name: '江岚', account: 'jiang.lan' },
permissions: ['material:read', 'material:render'],
});
});
describe('任务中心接口封装', () => {
it('列表 query 使用 snake_case,剔除空值并带默认 limit', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(jsonResponse(list));
const response = await fetchMediaTasks({
type: 'demo',
status: 'running',
customer_id: CUSTOMER_ID,
keyword: '演练',
mine: 'true',
limit: 20,
});
expect(response.items).toHaveLength(1);
const url = lastCall().url;
expect(url).toContain('/media-tasks?');
for (const part of ['type=demo', 'status=running', 'customer_id=', 'keyword=', 'mine=true']) {
expect(url).toContain(part);
}
expect(url).not.toContain('undefined');
});
it('列表默认 limit=20,空筛选不会带出空参数', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
jsonResponse({ items: [], next_cursor: null }),
);
await fetchMediaTasks({});
const url = lastCall().url;
expect(url).toContain('limit=20');
expect(url).not.toContain('status=');
});
it('详情带 items_cursor,响应经 Zod parse', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(jsonResponse(detail));
const result = await fetchMediaTaskDetail(TASK_ID, { items_cursor: 'item:101' });
expect(lastCall().url).toContain(`/media-tasks/${TASK_ID}?items_cursor=item%3A101`);
expect(result.items).toHaveLength(1);
expect(result.payload).toEqual({ mode: 'mixed' });
});
it('提交:body camelCase + Idempotency-Key 头,202 响应解析', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
jsonResponse(
{ taskId: TASK_ID, estimatedItems: 8, estimatedCost: 0.16, estimatedSeconds: 24 },
202,
),
);
const result = await submitMediaTask(
{ type: 'demo', customerId: CUSTOMER_ID, itemCount: 8, mode: 'mixed', note: '验证重试' },
'idem-key-0001',
);
expect(result.taskId).toBe(TASK_ID);
expect(lastCall().init?.method).toBe('POST');
expect(lastCall().url).toContain('/media-tasks');
expect(lastHeaders().get('Idempotency-Key')).toBe('idem-key-0001');
const body = lastCall().init?.body;
expect(typeof body).toBe('string');
expect(JSON.parse(body as string)).toEqual({
type: 'demo',
customerId: CUSTOMER_ID,
itemCount: 8,
mode: 'mixed',
note: '验证重试',
});
});
it('提交入参非法(itemCount 超硬上限)在请求层被拒绝', async () => {
const fetchMock = vi.spyOn(globalThis, 'fetch');
await expect(
submitMediaTask(
{ type: 'demo', customerId: CUSTOMER_ID, itemCount: 101, mode: 'success' },
'idem-key-0002',
),
).rejects.toBeTruthy();
expect(fetchMock).not.toHaveBeenCalled();
});
it('取消 / 重试走 POST 动作端点并解析响应', async () => {
vi.spyOn(globalThis, 'fetch')
.mockResolvedValueOnce(jsonResponse({ taskId: TASK_ID, status: 'canceled' }))
.mockResolvedValueOnce(jsonResponse({ taskId: TASK_ID, status: 'queued', retriedItems: 2 }));
const canceled = await cancelMediaTask(TASK_ID);
expect(canceled.status).toBe('canceled');
expect(lastCall().url).toContain(`/media-tasks/${TASK_ID}/cancel`);
const retried = await retryMediaTask(TASK_ID);
expect(retried.retriedItems).toBe(2);
expect(lastCall().url).toContain(`/media-tasks/${TASK_ID}/retry`);
});
it('SSE:带 Authorization 与 Last-Event-ID 头,返回原始流', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
new Response(null, { status: 200, headers: { 'Content-Type': 'text/event-stream' } }),
);
const response = await openMediaTaskEvents(TASK_ID, { lastEventId: '42' });
expect(response.status).toBe(200);
const url = lastCall().url;
expect(url).toContain(`/media-tasks/${TASK_ID}/events`);
expect(url).toContain('last_event_id=42');
expect(lastHeaders().get('Authorization')).toBe('Bearer token');
expect(lastHeaders().get('Accept')).toBe('text/event-stream');
});
it('幂等键满足 8–128 位可见 ASCII 且有随机性', () => {
const first = createTaskIdempotencyKey();
const second = createTaskIdempotencyKey();
expect(first).not.toBe(second);
expect(first.length).toBeGreaterThanOrEqual(8);
expect(first.length).toBeLessThanOrEqual(128);
expect(/^[\x21-\x7E]+$/.test(first)).toBe(true);
});
});
+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 }),
});
}
+18 -4
View File
@@ -1,9 +1,15 @@
import { notifyError } from '@lingxi/ui';
import { MutationCache, QueryCache, QueryClient } from '@tanstack/react-query';
import { MutationCache, type Query, QueryCache, QueryClient } from '@tanstack/react-query';
import { ApiError } from './client';
function reportError(error: unknown): void {
type CacheQuery = Query<unknown, unknown, unknown>;
function reportError(error: unknown, query?: CacheQuery): void {
// 背景轮询(如顶栏任务角标)失败不弹全局 toast:页面自身有状态提示,断网时避免反复打扰
if (query !== undefined && query.meta?.suppressToast === true) {
return;
}
if (error instanceof ApiError) {
if (error.status === 401) {
return;
@@ -25,8 +31,16 @@ function shouldRetry(failureCount: number, error: unknown): boolean {
}
export const queryClient = new QueryClient({
queryCache: new QueryCache({ onError: reportError }),
mutationCache: new MutationCache({ onError: reportError }),
queryCache: new QueryCache({
onError: (error, query) => {
reportError(error, query);
},
}),
mutationCache: new MutationCache({
onError: (error) => {
reportError(error);
},
}),
defaultOptions: {
queries: {
retry: shouldRetry,
+5
View File
@@ -43,4 +43,9 @@ export const queryKeys = {
auditExports: ['audit-exports'] as const,
auditExportList: (query: object) => ['audit-exports', 'list', query] as const,
auditExport: (exportId: string) => ['audit-exports', 'detail', exportId] as const,
// A6 media tasks
mediaTasks: ['media-tasks'] as const,
mediaTaskList: (query: object) => ['media-tasks', 'list', query] as const,
mediaTask: (taskId: string) => ['media-tasks', 'detail', taskId] as const,
mediaTaskActive: ['media-tasks', 'active'] as const,
};