From a9ee5e638385ce4c163752754f5a4e854f167602 Mon Sep 17 00:00:00 2001 From: Stev_Wang <304865932@qq.com> Date: Thu, 5 Nov 2026 11:40:41 +0800 Subject: [PATCH] =?UTF-8?q?feat(web):=20A6=20=E4=BB=BB=E5=8A=A1=E4=B8=AD?= =?UTF-8?q?=E5=BF=83=E9=A1=B5=E9=9D=A2=E4=B8=8E=E5=8F=AF=E5=A4=8D=E7=94=A8?= =?UTF-8?q?=E8=BF=9B=E5=BA=A6=E7=BB=84=E4=BB=B6=EF=BC=88=E6=96=B9=E6=A1=88?= =?UTF-8?q?=20A=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 背景: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。 --- apps/web/src/api/client.ts | 43 + apps/web/src/api/endpoints.ts | 9 + apps/web/src/api/media.spec.ts | 198 +++ apps/web/src/api/media.ts | 126 ++ apps/web/src/api/query-client.ts | 22 +- apps/web/src/api/query-keys.ts | 5 + apps/web/src/components/tasks/TaskBadge.tsx | 191 +++ .../components/tasks/TaskItemStatusTag.tsx | 20 + .../web/src/components/tasks/TaskProgress.tsx | 127 ++ .../src/components/tasks/TaskSseIndicator.tsx | 53 + .../src/components/tasks/TaskStatusTag.tsx | 65 + apps/web/src/components/tasks/TaskTypeTag.tsx | 43 + apps/web/src/components/tasks/index.ts | 13 + apps/web/src/components/tasks/task-display.ts | 163 ++ .../web/src/components/tasks/task-sse.spec.ts | 51 + apps/web/src/components/tasks/task-sse.ts | 152 ++ apps/web/src/components/tasks/tasks.spec.ts | 160 ++ .../src/components/tasks/use-task-stream.ts | 136 ++ apps/web/src/hooks/queries/use-client.ts | 2 + apps/web/src/hooks/queries/use-media-tasks.ts | 241 +++ apps/web/src/layout/AppLayout.tsx | 4 +- apps/web/src/layout/PageBreadcrumbs.tsx | 2 + apps/web/src/layout/TopBar.tsx | 8 +- apps/web/src/main.tsx | 2 + apps/web/src/navigation/breadcrumbs.spec.ts | 14 +- apps/web/src/navigation/breadcrumbs.ts | 16 + apps/web/src/navigation/nav-config.ts | 4 +- .../pages/material/tasks/TaskConfirmModal.tsx | 172 ++ .../pages/material/tasks/TaskDetailPage.tsx | 856 ++++++++++ .../src/pages/material/tasks/TaskFilters.tsx | 256 +++ .../pages/material/tasks/TaskSubmitModal.tsx | 244 +++ .../src/pages/material/tasks/TaskTable.tsx | 531 ++++++ .../src/pages/material/tasks/TasksPage.tsx | 276 ++++ .../material/tasks/task-filter-utils.spec.ts | 386 +++++ .../pages/material/tasks/task-filter-utils.ts | 532 ++++++ apps/web/src/router.tsx | 26 + apps/web/src/routes.spec.ts | 9 + apps/web/src/routes.ts | 13 + apps/web/src/styles/task-detail.css | 678 ++++++++ apps/web/src/styles/tasks.css | 1463 +++++++++++++++++ 40 files changed, 7299 insertions(+), 13 deletions(-) create mode 100644 apps/web/src/api/media.spec.ts create mode 100644 apps/web/src/api/media.ts create mode 100644 apps/web/src/components/tasks/TaskBadge.tsx create mode 100644 apps/web/src/components/tasks/TaskItemStatusTag.tsx create mode 100644 apps/web/src/components/tasks/TaskProgress.tsx create mode 100644 apps/web/src/components/tasks/TaskSseIndicator.tsx create mode 100644 apps/web/src/components/tasks/TaskStatusTag.tsx create mode 100644 apps/web/src/components/tasks/TaskTypeTag.tsx create mode 100644 apps/web/src/components/tasks/index.ts create mode 100644 apps/web/src/components/tasks/task-display.ts create mode 100644 apps/web/src/components/tasks/task-sse.spec.ts create mode 100644 apps/web/src/components/tasks/task-sse.ts create mode 100644 apps/web/src/components/tasks/tasks.spec.ts create mode 100644 apps/web/src/components/tasks/use-task-stream.ts create mode 100644 apps/web/src/hooks/queries/use-media-tasks.ts create mode 100644 apps/web/src/pages/material/tasks/TaskConfirmModal.tsx create mode 100644 apps/web/src/pages/material/tasks/TaskDetailPage.tsx create mode 100644 apps/web/src/pages/material/tasks/TaskFilters.tsx create mode 100644 apps/web/src/pages/material/tasks/TaskSubmitModal.tsx create mode 100644 apps/web/src/pages/material/tasks/TaskTable.tsx create mode 100644 apps/web/src/pages/material/tasks/TasksPage.tsx create mode 100644 apps/web/src/pages/material/tasks/task-filter-utils.spec.ts create mode 100644 apps/web/src/pages/material/tasks/task-filter-utils.ts create mode 100644 apps/web/src/styles/task-detail.css create mode 100644 apps/web/src/styles/tasks.css diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts index 867c3d5..8721900 100644 --- a/apps/web/src/api/client.ts +++ b/apps/web/src/api/client.ts @@ -205,3 +205,46 @@ async function send(path: string, options: RequestOptions, allowRefresh: bool export function request(path: string, options: RequestOptions = {}): Promise { return send(path, options, true); } + +export interface StreamRequestOptions { + headers?: Record; + signal?: AbortSignal; +} + +/** + * 流式请求入口(A6 SSE 进度流):与 `request` 相同的鉴权与 401 单飞刷新语义, + * 但返回原始 `Response`,由调用方读取 `body` reader(不解析 JSON、不缓存)。 + */ +export async function streamRequest( + path: string, + options: StreamRequestOptions = {}, + allowRefresh = true, +): Promise { + 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; +} diff --git a/apps/web/src/api/endpoints.ts b/apps/web/src/api/endpoints.ts index 84b2c20..d8c3511 100644 --- a/apps/web/src/api/endpoints.ts +++ b/apps/web/src/api/endpoints.ts @@ -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; diff --git a/apps/web/src/api/media.spec.ts b/apps/web/src/api/media.spec.ts new file mode 100644 index 0000000..57cb390 --- /dev/null +++ b/apps/web/src/api/media.spec.ts @@ -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); + }); +}); diff --git a/apps/web/src/api/media.ts b/apps/web/src/api/media.ts new file mode 100644 index 0000000..68d11bf --- /dev/null +++ b/apps/web/src/api/media.ts @@ -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 { + 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 = {}, +): Promise { + 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 = {}, +): Promise { + 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 { + 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 { + const payload: unknown = await request(ENDPOINTS.mediaTaskCancel(taskId), { method: 'POST' }); + return mediaTaskCancelResponseSchema.parse(payload); +} + +/** 仅重试失败项:成功项不重复执行、不重复计费 */ +export async function retryMediaTask(taskId: string): Promise { + 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 { + 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 }), + }); +} diff --git a/apps/web/src/api/query-client.ts b/apps/web/src/api/query-client.ts index 8fee4a6..9dbc5a1 100644 --- a/apps/web/src/api/query-client.ts +++ b/apps/web/src/api/query-client.ts @@ -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; + +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, diff --git a/apps/web/src/api/query-keys.ts b/apps/web/src/api/query-keys.ts index 3d6583f..b753599 100644 --- a/apps/web/src/api/query-keys.ts +++ b/apps/web/src/api/query-keys.ts @@ -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, }; diff --git a/apps/web/src/components/tasks/TaskBadge.tsx b/apps/web/src/components/tasks/TaskBadge.tsx new file mode 100644 index 0000000..7e32163 --- /dev/null +++ b/apps/web/src/components/tasks/TaskBadge.tsx @@ -0,0 +1,191 @@ +/* eslint-disable max-lines-per-function -- 顶栏角标同时承载轮询、完成通知、下拉列表与导航,集中便于追踪数据流 */ +/** + * A6 全局任务角标(A6-FE-02):顶栏图标 + 进行中数量 + 下拉任务列表 + 完成 / 失败通知。 + * + * - 数据源为「排队 + 生成中」两组任务(15s 轮询,背景失败不弹全局 toast); + * - 任务从进行中列表消失时,读取一次详情判断终态并提示(完成 / 部分失败 / 失败 / 取消); + * - 下拉面板是顶栏角标的唯一展开层,避免与页面 toast 抢位置。 + */ +import { Badge, Popover } from '@douyinfe/semi-ui-19'; +import { notifyError, notifySuccess, notifyWarning } from '@lingxi/ui'; +import { IconChevronRight, IconError, IconTask } from '@lingxi/ui/icons'; +import type { ReactNode } from 'react'; +import { useEffect, useRef, useState } from 'react'; +import { useNavigate } from 'react-router'; + +import { fetchMediaTaskDetail } from '@/api/media'; +import { useActiveMediaTasksQuery, useTaskAttentionQuery } from '@/hooks/queries/use-media-tasks'; +import { ROUTES, taskDetailPath } from '@/routes'; + +import { displayTaskTitle, formatTaskNo } from './task-display'; +import { TaskMiniProgress } from './TaskProgress'; +import { TaskStatusTag } from './TaskStatusTag'; +import { TaskTypeTag } from './TaskTypeTag'; + +const MAX_NOTICE_PER_CYCLE = 5; + +function noticeFor( + status: string, + title: string, + failedCount: number, +): { kind: 'success' | 'warning' | 'error'; text: string } | null { + if (status === 'pending_review') return { kind: 'success', text: `任务已完成:${title}` }; + if (status === 'partial_failed') { + return { + kind: 'warning', + text: `任务部分失败:${title}(${String(failedCount)} 项失败,可重试)`, + }; + } + if (status === 'failed') return { kind: 'error', text: `任务失败:${title}` }; + if (status === 'canceled') return { kind: 'warning', text: `任务已取消:${title}` }; + return null; +} + +export function TaskBadge(): ReactNode { + const navigate = useNavigate(); + const [open, setOpen] = useState(false); + const active = useActiveMediaTasksQuery(); + const attention = useTaskAttentionQuery(); + const tasks = active.data ?? []; + const failedCount = attention.data?.failedCount ?? 0; + const previousIdsRef = useRef | null>(null); + const notifiedRef = useRef>(new Set()); + + useEffect(() => { + if (active.data === undefined) return; + const ids = new Set(active.data.map((task) => task.id)); + const previous = previousIdsRef.current; + previousIdsRef.current = ids; + if (previous === null) return; + const removed = [...previous] + .filter((id) => !ids.has(id) && !notifiedRef.current.has(id)) + .slice(0, MAX_NOTICE_PER_CYCLE); + for (const id of removed) { + notifiedRef.current.add(id); + void fetchMediaTaskDetail(id) + .then((detail) => { + const notice = noticeFor(detail.status, detail.title, detail.itemFailed); + if (notice === null) return; + if (notice.kind === 'success') notifySuccess(notice.text); + else if (notice.kind === 'warning') notifyWarning(notice.text); + else notifyError(notice.text); + }) + .catch(() => { + // 任务可能已被范围 / 删除策略过滤:角标只需安静失败 + }); + } + }, [active.data]); + + const count = tasks.length; + const overflow = count >= 100 ? '99+' : String(count); + + const content = ( +
+
+ 进行中的任务 + {count} 个 + +
+ {failedCount > 0 ? ( + + ) : null} + {active.isLoading ? ( +
正在加载进行中任务…
+ ) : tasks.length === 0 ? ( +
当前没有进行中的任务
+ ) : ( +
    + {tasks.map((task) => ( +
  • + +
  • + ))} +
+ )} +
进度会自动更新,网络恢复后继续同步
+
+ ); + + return ( + { + setOpen(visible); + if (visible) void active.refetch(); + }} + showArrow={false} + className="lx-task-badge-popover" + > + 0 ? 'lx-task-badge-trigger has-failures' : 'lx-task-badge-trigger'} + > + {count > 0 ? ( + + + + ) : ( + + )} + + + ); +} diff --git a/apps/web/src/components/tasks/TaskItemStatusTag.tsx b/apps/web/src/components/tasks/TaskItemStatusTag.tsx new file mode 100644 index 0000000..7284087 --- /dev/null +++ b/apps/web/src/components/tasks/TaskItemStatusTag.tsx @@ -0,0 +1,20 @@ +/** + * A6 任务项状态:小圆点 + 文字;失败 / 部分失败必须同时展示原因与行号(由调用页面负责)。 + */ +import type { TaskItemStatus } from '@lingxi/shared'; +import type { ReactNode } from 'react'; + +import { TASK_ITEM_STATUS_TONE, taskItemStatusName } from './task-display'; + +export interface TaskItemStatusTagProps { + status: TaskItemStatus; +} + +export function TaskItemStatusTag({ status }: TaskItemStatusTagProps): ReactNode { + return ( + +