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
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 }),
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
Reference in new issue
Block a user