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,
|
||||
};
|
||||
@@ -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<Set<string> | null>(null);
|
||||
const notifiedRef = useRef<Set<string>>(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 = (
|
||||
<div className="lx-task-badge-panel">
|
||||
<header className="lx-task-badge-panel__head">
|
||||
<b>进行中的任务</b>
|
||||
<span>{count} 个</span>
|
||||
<button
|
||||
type="button"
|
||||
className="lx-task-badge-panel__link"
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
void navigate(ROUTES.material.tasks);
|
||||
}}
|
||||
>
|
||||
查看全部
|
||||
<IconChevronRight size={12} />
|
||||
</button>
|
||||
</header>
|
||||
{failedCount > 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
className="lx-task-badge-panel__alert"
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
void navigate(`${ROUTES.material.tasks}?status=partial_failed`);
|
||||
}}
|
||||
>
|
||||
<IconError size={14} />
|
||||
待处理失败 {failedCount} 项,去处理
|
||||
<IconChevronRight size={12} />
|
||||
</button>
|
||||
) : null}
|
||||
{active.isLoading ? (
|
||||
<div className="lx-task-badge-panel__state">正在加载进行中任务…</div>
|
||||
) : tasks.length === 0 ? (
|
||||
<div className="lx-task-badge-panel__state">当前没有进行中的任务</div>
|
||||
) : (
|
||||
<ul className="lx-task-badge-panel__list">
|
||||
{tasks.map((task) => (
|
||||
<li key={task.id}>
|
||||
<button
|
||||
type="button"
|
||||
className="lx-task-badge-row"
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
void navigate(taskDetailPath(task.id));
|
||||
}}
|
||||
>
|
||||
<TaskTypeTag type={task.type} />
|
||||
<span className="lx-task-badge-row__main">
|
||||
<b>{displayTaskTitle(task)}</b>
|
||||
<span>
|
||||
{task.customerName} · {formatTaskNo(task.id)} · {task.itemSucceeded}/
|
||||
{task.itemTotal} 项
|
||||
</span>
|
||||
<TaskMiniProgress percent={task.progress} status={task.status} />
|
||||
</span>
|
||||
<span className="lx-task-badge-row__side">
|
||||
<TaskStatusTag status={task.status} />
|
||||
<em>{task.progress}%</em>
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<footer className="lx-task-badge-panel__foot">进度会自动更新,网络恢复后继续同步</footer>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover
|
||||
content={content}
|
||||
trigger="click"
|
||||
position="bottomRight"
|
||||
visible={open}
|
||||
onVisibleChange={(visible: boolean) => {
|
||||
setOpen(visible);
|
||||
if (visible) void active.refetch();
|
||||
}}
|
||||
showArrow={false}
|
||||
className="lx-task-badge-popover"
|
||||
>
|
||||
<span
|
||||
className={failedCount > 0 ? 'lx-task-badge-trigger has-failures' : 'lx-task-badge-trigger'}
|
||||
>
|
||||
{count > 0 ? (
|
||||
<Badge count={count} overflowCount={99} type="danger">
|
||||
<button
|
||||
type="button"
|
||||
className="lx-icon-button"
|
||||
aria-label={`进行中的任务 ${overflow} 个`}
|
||||
title="进行中的任务"
|
||||
>
|
||||
<IconTask size={18} />
|
||||
</button>
|
||||
</Badge>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="lx-icon-button"
|
||||
aria-label="没有进行中的任务"
|
||||
title="进行中的任务"
|
||||
>
|
||||
<IconTask size={18} />
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<span className={`lx-task-item-status lx-task-item-status--${TASK_ITEM_STATUS_TONE[status]}`}>
|
||||
<i aria-hidden="true" />
|
||||
{taskItemStatusName(status)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* A6 可复用进度组件(A6-FE-02):线性进度条 / 迷你条 / 进度环。
|
||||
*
|
||||
* - 颜色只通过 CSS 变量(packages/ui tokens)表达,不在业务代码写颜色字面量;
|
||||
* - 进行中可加扫光 / 脉冲,`prefers-reduced-motion` 下由 CSS 关闭;
|
||||
* - 进度环复用 Semi Progress(SVG 由组件库提供,业务代码不内联 SVG)。
|
||||
*/
|
||||
import { Progress } from '@douyinfe/semi-ui-19';
|
||||
import type { TaskStatus } from '@lingxi/shared';
|
||||
import { IconLoading } from '@lingxi/ui/icons';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { TASK_STATUS_META, type TaskTone } from './task-display';
|
||||
|
||||
function clampPercent(value: number): number {
|
||||
if (!Number.isFinite(value)) return 0;
|
||||
return Math.max(0, Math.min(100, Math.round(value)));
|
||||
}
|
||||
|
||||
function toneOf(status: TaskStatus): TaskTone {
|
||||
return TASK_STATUS_META[status].tone;
|
||||
}
|
||||
|
||||
const TONE_STROKE: Record<TaskTone, string> = {
|
||||
blue: 'var(--lx-color-brand)',
|
||||
green: 'var(--lx-color-success)',
|
||||
orange: 'var(--lx-color-warning)',
|
||||
red: 'var(--lx-color-danger)',
|
||||
grey: 'var(--lx-color-border-strong)',
|
||||
teal: 'var(--lx-color-chart-2)',
|
||||
violet: 'var(--lx-color-chart-4)',
|
||||
};
|
||||
|
||||
export interface TaskProgressBarProps {
|
||||
percent: number;
|
||||
status: TaskStatus;
|
||||
/** `sm` 用于表格行内,`md` 用于卡片,`lg` 用于详情主进度 */
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
/** 是否展示右侧百分比数字(默认展示) */
|
||||
showInfo?: boolean;
|
||||
/** 表格 / 卡片内为 true 时加扫光,表示正在推进 */
|
||||
animated?: boolean;
|
||||
}
|
||||
|
||||
export function TaskProgressBar({
|
||||
percent,
|
||||
status,
|
||||
size = 'md',
|
||||
showInfo = true,
|
||||
animated = status === 'running',
|
||||
}: TaskProgressBarProps): ReactNode {
|
||||
const value = clampPercent(percent);
|
||||
return (
|
||||
<span
|
||||
className={`lx-task-progress lx-task-progress--${size} lx-task-progress--${toneOf(status)}${animated ? ' is-animated' : ''}`}
|
||||
role="progressbar"
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-valuenow={value}
|
||||
aria-label={`任务进度 ${String(value)}%`}
|
||||
>
|
||||
<span className="lx-task-progress__track">
|
||||
<span className="lx-task-progress__fill" style={{ width: `${String(value)}%` }} />
|
||||
</span>
|
||||
{showInfo ? <b className="lx-task-progress__num">{value}%</b> : null}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export interface TaskMiniProgressProps {
|
||||
percent: number;
|
||||
status: TaskStatus;
|
||||
}
|
||||
|
||||
/** 迷你进度条:列表行 / 角标面板使用,不展示数字 */
|
||||
export function TaskMiniProgress({ percent, status }: TaskMiniProgressProps): ReactNode {
|
||||
const value = clampPercent(percent);
|
||||
return (
|
||||
<span
|
||||
className={`lx-task-mini lx-task-mini--${toneOf(status)}`}
|
||||
role="img"
|
||||
aria-label={`进度 ${String(value)}%`}
|
||||
>
|
||||
<span style={{ width: `${String(value)}%` }} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export interface TaskProgressRingProps {
|
||||
percent: number;
|
||||
status: TaskStatus;
|
||||
size?: number;
|
||||
strokeWidth?: number;
|
||||
/** 环中心的百分比文字(默认展示) */
|
||||
showInfo?: boolean;
|
||||
}
|
||||
|
||||
/** 进度环:Semi Progress circle(详情摘要 / 看板卡片) */
|
||||
export function TaskProgressRing({
|
||||
percent,
|
||||
status,
|
||||
size = 64,
|
||||
strokeWidth = 6,
|
||||
showInfo = true,
|
||||
}: TaskProgressRingProps): ReactNode {
|
||||
const value = clampPercent(percent);
|
||||
return (
|
||||
<span
|
||||
className={`lx-task-ring lx-task-ring--${toneOf(status)}`}
|
||||
role="img"
|
||||
aria-label={`任务进度 ${String(value)}%`}
|
||||
>
|
||||
<Progress
|
||||
type="circle"
|
||||
percent={value}
|
||||
width={size}
|
||||
strokeWidth={strokeWidth}
|
||||
stroke={TONE_STROKE[toneOf(status)]}
|
||||
showInfo={showInfo}
|
||||
format={() => `${String(value)}%`}
|
||||
/>
|
||||
{status === 'running' ? (
|
||||
<IconLoading size={12} className="lx-task-ring__badge" aria-hidden="true" />
|
||||
) : null}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* A6 SSE 进度通道状态:实时 / 重连中 / 已降级轮询 / 已结束。
|
||||
* 断线时不清空已有进度;文案必须明确当前数据来源,不能只靠颜色表达。
|
||||
*/
|
||||
import { IconCheck, IconClock, IconClose, IconLoading, IconRefresh } from '@lingxi/ui/icons';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import type { TaskStreamMode } from './use-task-stream';
|
||||
|
||||
export interface TaskSseIndicatorProps {
|
||||
mode: TaskStreamMode;
|
||||
}
|
||||
|
||||
export function TaskSseIndicator({ mode }: TaskSseIndicatorProps): ReactNode {
|
||||
if (mode === 'live') {
|
||||
return (
|
||||
<span className="lx-task-sse lx-task-sse--ok" role="status">
|
||||
<IconCheck size={13} />
|
||||
进度实时更新
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (mode === 'connecting') {
|
||||
return (
|
||||
<span className="lx-task-sse lx-task-sse--info" role="status">
|
||||
<IconLoading size={13} className="lx-task-sse__spin" />
|
||||
正在连接进度…
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (mode === 'reconnecting') {
|
||||
return (
|
||||
<span className="lx-task-sse lx-task-sse--warn" role="status">
|
||||
<IconRefresh size={13} className="lx-task-sse__spin" />
|
||||
网络中断,正在重新连接
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (mode === 'polling') {
|
||||
return (
|
||||
<span className="lx-task-sse lx-task-sse--warn" role="status">
|
||||
<IconClock size={13} />
|
||||
已切换为定时刷新
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className="lx-task-sse lx-task-sse--grey" role="status">
|
||||
<IconClose size={13} />
|
||||
进度已更新
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* A6 任务状态标签:九态统一由 shared 枚举驱动,生成中带脉冲点。
|
||||
* 颜色不单独表达状态:文字 + 形状(点 / 脉冲 / 图标)同时存在。
|
||||
*/
|
||||
import { Tag } from '@douyinfe/semi-ui-19';
|
||||
import type { TaskStatus } from '@lingxi/shared';
|
||||
import { IconCheck, IconClock, IconClose, IconError, IconLoading } from '@lingxi/ui/icons';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { TASK_STATUS_META } from './task-display';
|
||||
|
||||
export interface TaskStatusTagProps {
|
||||
status: TaskStatus;
|
||||
size?: 'small' | 'default' | 'large';
|
||||
}
|
||||
|
||||
export function TaskStatusTag({ status, size = 'small' }: TaskStatusTagProps): ReactNode {
|
||||
const meta = TASK_STATUS_META[status];
|
||||
const color = meta.tone;
|
||||
if (status === 'running') {
|
||||
return (
|
||||
<Tag
|
||||
size={size}
|
||||
type="light"
|
||||
color={color}
|
||||
prefixIcon={<IconLoading size={13} className="lx-task-status__spin" />}
|
||||
>
|
||||
{meta.label}
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
if (status === 'failed' || status === 'partial_failed') {
|
||||
return (
|
||||
<Tag size={size} type="light" color={color} prefixIcon={<IconError size={13} />}>
|
||||
{meta.label}
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
if (status === 'pending_review') {
|
||||
return (
|
||||
<Tag size={size} type="light" color={color} prefixIcon={<IconClock size={13} />}>
|
||||
{meta.label}
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
if (status === 'exported' || status === 'archived') {
|
||||
return (
|
||||
<Tag size={size} type="light" color={color} prefixIcon={<IconCheck size={13} />}>
|
||||
{meta.label}
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
if (status === 'canceled') {
|
||||
return (
|
||||
<Tag size={size} type="light" color={color} prefixIcon={<IconClose size={13} />}>
|
||||
{meta.label}
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Tag size={size} type="light" color={color}>
|
||||
{meta.label}
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* A6 任务类型徽标:Semi Tag + Remix 图标统一出口。
|
||||
* 色系与设计稿 / shared 枚举一致,后续 B3 / M1 接入真实类型时无需改组件。
|
||||
*/
|
||||
import { Tag } from '@douyinfe/semi-ui-19';
|
||||
import type { TaskType } from '@lingxi/shared';
|
||||
import {
|
||||
IconDownload,
|
||||
IconMaterial,
|
||||
IconScript,
|
||||
IconShield,
|
||||
IconTask,
|
||||
IconVideo,
|
||||
} from '@lingxi/ui/icons';
|
||||
import type { ComponentType, ReactNode } from 'react';
|
||||
|
||||
import { TASK_TYPE_META, type TaskTypeIcon } from './task-display';
|
||||
|
||||
type IconComponent = ComponentType<{ size?: number | string; className?: string }>;
|
||||
|
||||
const TYPE_ICONS: Record<TaskTypeIcon, IconComponent> = {
|
||||
task: IconTask,
|
||||
script: IconScript,
|
||||
image: IconMaterial,
|
||||
video: IconVideo,
|
||||
export: IconDownload,
|
||||
verify: IconShield,
|
||||
};
|
||||
|
||||
export interface TaskTypeTagProps {
|
||||
type: TaskType;
|
||||
size?: 'small' | 'default';
|
||||
}
|
||||
|
||||
export function TaskTypeTag({ type, size = 'small' }: TaskTypeTagProps): ReactNode {
|
||||
const meta = TASK_TYPE_META[type];
|
||||
const Icon = TYPE_ICONS[meta.icon];
|
||||
return (
|
||||
<Tag size={size} type="light" color={meta.tone} prefixIcon={<Icon size={13} />}>
|
||||
{meta.label}
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* A6 任务中心共享组件出口(页面与后续批次统一从这里导入)。
|
||||
*/
|
||||
export * from './task-display';
|
||||
export * from './task-sse';
|
||||
export { TaskBadge } from './TaskBadge';
|
||||
export { TaskItemStatusTag } from './TaskItemStatusTag';
|
||||
export { TaskMiniProgress, TaskProgressBar, TaskProgressRing } from './TaskProgress';
|
||||
export { TaskSseIndicator } from './TaskSseIndicator';
|
||||
export { TaskStatusTag } from './TaskStatusTag';
|
||||
export { TaskTypeTag } from './TaskTypeTag';
|
||||
export type { TaskStreamMode, UseTaskStreamOptions, UseTaskStreamResult } from './use-task-stream';
|
||||
export { useTaskStream } from './use-task-stream';
|
||||
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* A6 任务中心展示映射与格式化(纯函数,便于单测)。
|
||||
*
|
||||
* - 中文名复用 shared 的 TASK_*_LABELS,前端不维护第二套枚举;
|
||||
* - 失败原因码来自字典(M0 仅演练任务),未知 code 回退原值,便于后续字典扩展;
|
||||
* - 金额 / 时长 / 时间统一格式,避免各页各写一套。
|
||||
*/
|
||||
import type { TaskItemStatus, TaskStatus, TaskType } from '@lingxi/shared';
|
||||
import {
|
||||
taskItemStatusLabel as sharedItemStatusLabel,
|
||||
taskStatusLabel as sharedStatusLabel,
|
||||
taskTypeLabel as sharedTypeLabel,
|
||||
} from '@lingxi/shared';
|
||||
|
||||
export type TaskTone = 'blue' | 'green' | 'orange' | 'red' | 'grey' | 'teal' | 'violet';
|
||||
export type TaskTypeIcon = 'task' | 'script' | 'image' | 'video' | 'export' | 'verify';
|
||||
|
||||
export interface TaskTypeMeta {
|
||||
label: string;
|
||||
tone: TaskTone;
|
||||
icon: TaskTypeIcon;
|
||||
}
|
||||
|
||||
/** 任务类型 → 文案 / 色系 / 图标语义名(Semi Tag 色板 + Remix 图标统一出口) */
|
||||
export const TASK_TYPE_META: Record<TaskType, TaskTypeMeta> = {
|
||||
demo: { label: sharedTypeLabel('demo'), tone: 'teal', icon: 'task' },
|
||||
script: { label: sharedTypeLabel('script'), tone: 'green', icon: 'script' },
|
||||
image: { label: sharedTypeLabel('image'), tone: 'blue', icon: 'image' },
|
||||
video: { label: sharedTypeLabel('video'), tone: 'violet', icon: 'video' },
|
||||
export: { label: sharedTypeLabel('export'), tone: 'orange', icon: 'export' },
|
||||
verify: { label: sharedTypeLabel('verify'), tone: 'grey', icon: 'verify' },
|
||||
};
|
||||
|
||||
export interface TaskStatusMeta {
|
||||
label: string;
|
||||
tone: TaskTone;
|
||||
/** M0 是否点亮(已勾选 / 已导出 / 已入库由 B3 / M1 点亮) */
|
||||
lit: boolean;
|
||||
}
|
||||
|
||||
export const TASK_STATUS_META: Record<TaskStatus, TaskStatusMeta> = {
|
||||
queued: { label: sharedStatusLabel('queued'), tone: 'grey', lit: true },
|
||||
running: { label: sharedStatusLabel('running'), tone: 'blue', lit: true },
|
||||
partial_failed: { label: sharedStatusLabel('partial_failed'), tone: 'orange', lit: true },
|
||||
pending_review: { label: sharedStatusLabel('pending_review'), tone: 'blue', lit: true },
|
||||
selected: { label: sharedStatusLabel('selected'), tone: 'blue', lit: false },
|
||||
exported: { label: sharedStatusLabel('exported'), tone: 'green', lit: false },
|
||||
archived: { label: sharedStatusLabel('archived'), tone: 'green', lit: false },
|
||||
failed: { label: sharedStatusLabel('failed'), tone: 'red', lit: true },
|
||||
canceled: { label: sharedStatusLabel('canceled'), tone: 'grey', lit: true },
|
||||
};
|
||||
|
||||
export const TASK_ITEM_STATUS_TONE: Record<TaskItemStatus, TaskTone> = {
|
||||
queued: 'grey',
|
||||
running: 'blue',
|
||||
pending_review: 'blue',
|
||||
failed: 'red',
|
||||
skipped: 'grey',
|
||||
};
|
||||
|
||||
export function taskItemStatusName(status: TaskItemStatus): string {
|
||||
return sharedItemStatusLabel(status);
|
||||
}
|
||||
|
||||
/** 失败原因码 → 中文名(M0 演练 / 图片 / 视频常见 code;未知 code 回退原文) */
|
||||
export const TASK_FAILURE_LABELS: Readonly<Record<string, string>> = {
|
||||
demo_simulated_failure: '演练模拟失败',
|
||||
template_layer_missing: '模板图层缺失',
|
||||
asset_missing: '素材缺失',
|
||||
render_timeout: '合成超时',
|
||||
compose_failed: '拼接失败',
|
||||
rate_limited: '供应商限流',
|
||||
vendor_timeout: '供应商超时',
|
||||
};
|
||||
|
||||
export function failureLabel(code: string | undefined, message?: string): string {
|
||||
if (message !== undefined && message.trim() !== '') return message;
|
||||
if (code === undefined || code === '') return '未知原因';
|
||||
return TASK_FAILURE_LABELS[code] ?? code;
|
||||
}
|
||||
|
||||
/** 列表 / 角标展示标题:后端标题为「类型 · 客户 · N 项」,去掉类型前缀避免与类型列重复 */
|
||||
export function displayTaskTitle(task: { type: TaskType; title: string }): string {
|
||||
const prefix = `${TASK_TYPE_META[task.type].label} · `;
|
||||
return task.title.startsWith(prefix) ? task.title.slice(prefix.length) : task.title;
|
||||
}
|
||||
|
||||
/** 任务号展示:`T-` + ULID 末 6 位(完整 ID 在详情可复制;后端无独立任务编号字段) */
|
||||
export function formatTaskNo(taskId: string): string {
|
||||
return `T-${taskId.slice(-6).toUpperCase()}`;
|
||||
}
|
||||
|
||||
/** 金额(元):固定 2 位小数,与后端 estimatedCost 出参一致 */
|
||||
export function formatMoney(value: number): string {
|
||||
return `¥${value.toFixed(2)}`;
|
||||
}
|
||||
|
||||
/** 时长:秒 → 「24 秒 / 1 分 30 秒 / 1 分 0 秒」 */
|
||||
export function formatSeconds(value: number): string {
|
||||
const total = Math.max(0, Math.round(value));
|
||||
if (total < 60) return `${total} 秒`;
|
||||
const minutes = Math.floor(total / 60);
|
||||
const seconds = total % 60;
|
||||
return seconds === 0 ? `${minutes} 分钟` : `${minutes} 分 ${seconds} 秒`;
|
||||
}
|
||||
|
||||
function pad(value: number): string {
|
||||
return String(value).padStart(2, '0');
|
||||
}
|
||||
|
||||
function toDate(iso: string | undefined | null): Date | null {
|
||||
if (iso === undefined || iso === null || iso === '') return null;
|
||||
const date = new Date(iso);
|
||||
return Number.isNaN(date.getTime()) ? null : date;
|
||||
}
|
||||
|
||||
/** 本地时区日期时间:`2026-11-04 20:21`;空值返回 `—` */
|
||||
export function formatDateTime(iso: string | undefined | null): string {
|
||||
const date = toDate(iso);
|
||||
if (date === null) return '—';
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
|
||||
}
|
||||
|
||||
/** 仅时间:`20:21` */
|
||||
export function formatTime(iso: string | undefined | null): string {
|
||||
const date = toDate(iso);
|
||||
if (date === null) return '—';
|
||||
return `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
|
||||
}
|
||||
|
||||
/** 实际耗时:startedAt → finishedAt(缺 finishedAt 时按当前时刻算「已执行」) */
|
||||
export function formatElapsed(
|
||||
startedAt: string | undefined | null,
|
||||
finishedAt: string | undefined | null,
|
||||
now: Date = new Date(),
|
||||
): string | null {
|
||||
const start = toDate(startedAt);
|
||||
if (start === null) return null;
|
||||
const end = toDate(finishedAt) ?? now;
|
||||
const seconds = Math.max(0, Math.round((end.getTime() - start.getTime()) / 1000));
|
||||
return formatSeconds(seconds);
|
||||
}
|
||||
|
||||
/** 任务项百分比(用于进度与计数一致性展示) */
|
||||
export function progressOfCounters(task: {
|
||||
itemTotal: number;
|
||||
itemSucceeded: number;
|
||||
itemFailed: number;
|
||||
itemSkipped: number;
|
||||
}): number {
|
||||
if (task.itemTotal <= 0) return 0;
|
||||
const done = task.itemSucceeded + task.itemFailed + task.itemSkipped;
|
||||
return Math.max(0, Math.min(100, Math.round((done / task.itemTotal) * 100)));
|
||||
}
|
||||
|
||||
/** 时间范围选项(A6 列表筛选;与原型一致) */
|
||||
export type TaskTimeRange = 'today' | '7d' | '30d' | 'custom';
|
||||
|
||||
export interface TaskTimeFilter {
|
||||
/** ISO 起止(自定义时使用;预置范围由 created_from/to 直接传给后端) */
|
||||
createdFrom?: string;
|
||||
createdTo?: string;
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { parseSseChunk, parseSseEvent } from './task-sse';
|
||||
|
||||
const TASK_ID = '01JCNB2M8K4Q7T3F9V6W1X0Y5Z';
|
||||
|
||||
describe('SSE 帧解析', () => {
|
||||
it('按空行切分多帧,处理心跳注释与 retry 字段', () => {
|
||||
const chunk = [
|
||||
': ping',
|
||||
'',
|
||||
`id: 7\nevent: progress\ndata: {"taskId":"${TASK_ID}","status":"running","progress":25,"itemTotal":8,"itemSucceeded":2,"itemFailed":0,"itemSkipped":0}`,
|
||||
'',
|
||||
'',
|
||||
].join('\n');
|
||||
const parsed = parseSseChunk(chunk);
|
||||
expect(parsed.frames).toHaveLength(1);
|
||||
expect(parsed.frames[0]?.id).toBe('7');
|
||||
expect(parsed.frames[0]?.event).toBe('progress');
|
||||
expect(parsed.rest).toBe('');
|
||||
});
|
||||
|
||||
it('未收完的尾帧留在 rest,下一 chunk 拼接后解析', () => {
|
||||
const first = parseSseChunk('id: 1\nevent: progress\ndata: {"a":');
|
||||
expect(first.frames).toHaveLength(0);
|
||||
expect(first.rest).toContain('"a":');
|
||||
const second = parseSseChunk(`${first.rest}1}\n\n`);
|
||||
expect(second.frames).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('多行 data 用换行拼接,未知字段忽略,兼容 CRLF', () => {
|
||||
const chunk = 'id: 2\r\nevent: item\r\ndata: {"taskId":"a",\r\ndata: "seq":1}\r\n\r\n';
|
||||
const parsed = parseSseChunk(chunk);
|
||||
expect(parsed.frames[0]?.data).toBe(`{"taskId":"a",\n"seq":1}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('SSE 事件契约解析', () => {
|
||||
it('合法帧 → 契约事件;非法 JSON / 未知 event / 缺 id 返回 null', () => {
|
||||
const valid = parseSseEvent({
|
||||
id: '3',
|
||||
event: 'done',
|
||||
data: `{"taskId":"${TASK_ID}","status":"pending_review","progress":100}`,
|
||||
});
|
||||
expect(valid?.event).toBe('done');
|
||||
|
||||
expect(parseSseEvent({ id: '4', event: 'progress', data: '{oops' })).toBeNull();
|
||||
expect(parseSseEvent({ id: '5', event: 'unknown', data: '{}' })).toBeNull();
|
||||
expect(parseSseEvent({ event: 'done', data: '{}' })).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* A6 SSE 进度流解析(纯函数,便于单测)。
|
||||
*
|
||||
* 后端事件格式(技术架构 §6.4):
|
||||
* ```
|
||||
* id: <task_events.seq>
|
||||
* event: item | progress | done | error
|
||||
* data: <json>
|
||||
* ```
|
||||
* 心跳是注释行 `: ping`,不产生事件;多行 data 按 SSE 规范用 `\n` 拼接。
|
||||
*/
|
||||
import {
|
||||
type MediaTaskDetail,
|
||||
type MediaTaskItem,
|
||||
taskDoneEventPayloadSchema,
|
||||
taskEventTypeSchema,
|
||||
taskItemEventPayloadSchema,
|
||||
taskProgressEventPayloadSchema,
|
||||
type TaskSseEvent,
|
||||
} from '@lingxi/shared';
|
||||
|
||||
export interface RawSseFrame {
|
||||
id?: string;
|
||||
event?: string;
|
||||
data: string;
|
||||
}
|
||||
|
||||
export interface ParsedSseChunk {
|
||||
frames: RawSseFrame[];
|
||||
/** 未以空行结束的残余片段,留到下一次 chunk 拼接 */
|
||||
rest: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从缓冲区切出完整事件帧(以空行分隔),返回帧与残余。
|
||||
* 兼容 \r\n;忽略注释行(`:` 开头)与 `retry:` 等无关字段。
|
||||
*/
|
||||
export function parseSseChunk(buffer: string): ParsedSseChunk {
|
||||
const normalized = buffer.replace(/\r\n/g, '\n');
|
||||
const parts = normalized.split('\n\n');
|
||||
const rest = parts.pop() ?? '';
|
||||
const frames: RawSseFrame[] = [];
|
||||
for (const part of parts) {
|
||||
if (part.trim() === '') continue;
|
||||
const frame: RawSseFrame = { data: '' };
|
||||
const dataLines: string[] = [];
|
||||
for (const line of part.split('\n')) {
|
||||
if (line.startsWith(':')) continue;
|
||||
const index = line.indexOf(':');
|
||||
const field = index < 0 ? line : line.slice(0, index);
|
||||
let value = index < 0 ? '' : line.slice(index + 1);
|
||||
if (value.startsWith(' ')) value = value.slice(1);
|
||||
if (field === 'id') frame.id = value;
|
||||
else if (field === 'event') frame.event = value;
|
||||
else if (field === 'data') dataLines.push(value);
|
||||
}
|
||||
if (dataLines.length === 0 && frame.event === undefined) continue;
|
||||
frame.data = dataLines.join('\n');
|
||||
frames.push(frame);
|
||||
}
|
||||
return { frames, rest };
|
||||
}
|
||||
|
||||
/** 解析单帧为契约事件;未知 event 或非法 JSON 返回 null(不中断流) */
|
||||
export function parseSseEvent(frame: RawSseFrame): TaskSseEvent | null {
|
||||
const eventParsed = taskEventTypeSchema.safeParse(frame.event);
|
||||
if (!eventParsed.success) return null;
|
||||
if (frame.id === undefined || frame.id === '') return null;
|
||||
let data: unknown;
|
||||
try {
|
||||
data = JSON.parse(frame.data) as unknown;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return { id: frame.id, event: eventParsed.data, data };
|
||||
}
|
||||
|
||||
/** `progress` / `done` 事件里可安全合并进详情的任务头字段 */
|
||||
export interface TaskHeadPatch {
|
||||
status?: MediaTaskDetail['status'];
|
||||
progress?: number;
|
||||
itemTotal?: number;
|
||||
itemSucceeded?: number;
|
||||
itemFailed?: number;
|
||||
itemSkipped?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 SSE 事件中提取任务头补丁;非进度事件返回 null。
|
||||
* `done` 载荷只含 taskId / status / progress(计数以最后一次 progress 为准)。
|
||||
*/
|
||||
export function taskHeadPatchOf(event: TaskSseEvent): TaskHeadPatch | null {
|
||||
if (event.event === 'progress') {
|
||||
const parsed = taskProgressEventPayloadSchema.safeParse(event.data);
|
||||
if (!parsed.success) return null;
|
||||
return {
|
||||
status: parsed.data.status,
|
||||
progress: parsed.data.progress,
|
||||
itemTotal: parsed.data.itemTotal,
|
||||
itemSucceeded: parsed.data.itemSucceeded,
|
||||
itemFailed: parsed.data.itemFailed,
|
||||
itemSkipped: parsed.data.itemSkipped,
|
||||
};
|
||||
}
|
||||
if (event.event === 'done') {
|
||||
const parsed = taskDoneEventPayloadSchema.safeParse(event.data);
|
||||
if (!parsed.success) return null;
|
||||
return { status: parsed.data.status, progress: parsed.data.progress };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 从 `item` 事件提取任务项补丁 */
|
||||
export function taskItemPatchOf(event: TaskSseEvent): MediaTaskItem | null {
|
||||
if (event.event !== 'item') return null;
|
||||
const parsed = taskItemEventPayloadSchema.safeParse(event.data);
|
||||
if (!parsed.success) return null;
|
||||
return {
|
||||
id: parsed.data.itemId,
|
||||
seq: parsed.data.seq,
|
||||
status: parsed.data.status,
|
||||
...(parsed.data.failureCode === undefined ? {} : { failureCode: parsed.data.failureCode }),
|
||||
...(parsed.data.failureMessage === undefined
|
||||
? {}
|
||||
: { failureMessage: parsed.data.failureMessage }),
|
||||
attempts: 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 把 SSE 事件合并进任务详情(详情页用于实时刷新,避免每个事件都重新请求)。
|
||||
* 只更新已有任务项(超过首屏 100 条的项由「加载更多」补齐),保持数组顺序。
|
||||
*/
|
||||
export function applyTaskEventToDetail(
|
||||
detail: MediaTaskDetail,
|
||||
event: TaskSseEvent,
|
||||
): MediaTaskDetail {
|
||||
const head = taskHeadPatchOf(event);
|
||||
let next: MediaTaskDetail = head === null ? detail : { ...detail, ...head };
|
||||
if (event.event === 'item') {
|
||||
const patch = taskItemPatchOf(event);
|
||||
if (patch !== null && next.items.some((item) => item.id === patch.id)) {
|
||||
const items = next.items.map((item) =>
|
||||
item.id === patch.id
|
||||
? { ...item, ...patch, attempts: Math.max(item.attempts, patch.attempts) }
|
||||
: item,
|
||||
);
|
||||
next = { ...next, items };
|
||||
}
|
||||
}
|
||||
return next;
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import type { MediaTaskDetail, TaskSseEvent } from '@lingxi/shared';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
failureLabel,
|
||||
formatDateTime,
|
||||
formatElapsed,
|
||||
formatMoney,
|
||||
formatSeconds,
|
||||
formatTaskNo,
|
||||
formatTime,
|
||||
progressOfCounters,
|
||||
TASK_STATUS_META,
|
||||
TASK_TYPE_META,
|
||||
} from './task-display';
|
||||
import { applyTaskEventToDetail } from './task-sse';
|
||||
|
||||
const TASK_ID = '01JCNB2M8K4Q7T3F9V6W1X0Y5Z';
|
||||
|
||||
const detail: MediaTaskDetail = {
|
||||
id: TASK_ID,
|
||||
type: 'demo',
|
||||
status: 'queued',
|
||||
title: '演练 8 项',
|
||||
customerId: '01JCP0CST00000000000000001',
|
||||
customerName: '花间集',
|
||||
itemTotal: 8,
|
||||
itemSucceeded: 0,
|
||||
itemFailed: 0,
|
||||
itemSkipped: 0,
|
||||
progress: 0,
|
||||
estimatedItems: 8,
|
||||
estimatedCost: 0.16,
|
||||
estimatedSeconds: 24,
|
||||
createdBy: '01JCP0SER00000000000000001',
|
||||
createdByName: '江岚',
|
||||
createdAt: '2026-11-04T12:21:00.000Z',
|
||||
payload: { mode: 'mixed' },
|
||||
items: [
|
||||
{
|
||||
id: '01JCN1TEM00000000000000001',
|
||||
seq: 1,
|
||||
status: 'queued',
|
||||
attempts: 1,
|
||||
},
|
||||
],
|
||||
itemsNextCursor: null,
|
||||
};
|
||||
|
||||
describe('任务展示映射', () => {
|
||||
it('类型 / 状态中文名与色系来自 shared 枚举', () => {
|
||||
expect(TASK_TYPE_META.demo.label).toBe('演练任务');
|
||||
expect(TASK_TYPE_META.verify.tone).toBe('grey');
|
||||
expect(TASK_STATUS_META.running.label).toBe('生成中');
|
||||
expect(TASK_STATUS_META.partial_failed.tone).toBe('orange');
|
||||
expect(TASK_STATUS_META.selected.lit).toBe(false);
|
||||
});
|
||||
|
||||
it('任务号取 ULID 末 6 位并大写', () => {
|
||||
expect(formatTaskNo(TASK_ID)).toBe('T-1X0Y5Z');
|
||||
});
|
||||
|
||||
it('金额固定 2 位小数,时长按分 / 秒可读', () => {
|
||||
expect(formatMoney(0.16)).toBe('¥0.16');
|
||||
expect(formatMoney(2)).toBe('¥2.00');
|
||||
expect(formatSeconds(24)).toBe('24 秒');
|
||||
expect(formatSeconds(90)).toBe('1 分 30 秒');
|
||||
expect(formatSeconds(120)).toBe('2 分钟');
|
||||
});
|
||||
|
||||
it('时间与耗时格式化:空值展示占位', () => {
|
||||
expect(formatDateTime(undefined)).toBe('—');
|
||||
expect(formatTime(null)).toBe('—');
|
||||
const elapsed = formatElapsed('2026-11-04T12:00:00.000Z', '2026-11-04T12:01:30.000Z');
|
||||
expect(elapsed).toBe('1 分 30 秒');
|
||||
});
|
||||
|
||||
it('失败原因优先展示后端 message,缺省回退字典中文名,再回退 code', () => {
|
||||
expect(failureLabel('demo_simulated_failure')).toBe('演练模拟失败');
|
||||
expect(failureLabel('unknown_code')).toBe('unknown_code');
|
||||
expect(failureLabel(undefined, '后端说明')).toBe('后端说明');
|
||||
});
|
||||
|
||||
it('进度按已完成项(成功 + 失败 + 跳过)计算', () => {
|
||||
expect(
|
||||
progressOfCounters({ itemTotal: 10, itemSucceeded: 3, itemFailed: 2, itemSkipped: 0 }),
|
||||
).toBe(50);
|
||||
expect(
|
||||
progressOfCounters({ itemTotal: 0, itemSucceeded: 0, itemFailed: 0, itemSkipped: 0 }),
|
||||
).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('SSE 事件合并', () => {
|
||||
const progressEvent: TaskSseEvent = {
|
||||
id: '11',
|
||||
event: 'progress',
|
||||
data: {
|
||||
taskId: TASK_ID,
|
||||
status: 'running',
|
||||
progress: 25,
|
||||
itemTotal: 8,
|
||||
itemSucceeded: 2,
|
||||
itemFailed: 0,
|
||||
itemSkipped: 0,
|
||||
},
|
||||
};
|
||||
|
||||
it('progress 事件合并任务头计数', () => {
|
||||
const next = applyTaskEventToDetail(detail, progressEvent);
|
||||
expect(next.status).toBe('running');
|
||||
expect(next.progress).toBe(25);
|
||||
expect(next.itemSucceeded).toBe(2);
|
||||
});
|
||||
|
||||
it('item 事件只更新已加载的任务项', () => {
|
||||
const next = applyTaskEventToDetail(detail, {
|
||||
id: '12',
|
||||
event: 'item',
|
||||
data: {
|
||||
taskId: TASK_ID,
|
||||
itemId: '01JCN1TEM00000000000000001',
|
||||
seq: 1,
|
||||
status: 'failed',
|
||||
failureCode: 'demo_simulated_failure',
|
||||
failureMessage: '演练模拟失败',
|
||||
},
|
||||
});
|
||||
expect(next.items[0]?.status).toBe('failed');
|
||||
expect(next.items[0]?.failureCode).toBe('demo_simulated_failure');
|
||||
// 未加载的项不会被凭空追加
|
||||
const unknown = applyTaskEventToDetail(detail, {
|
||||
id: '13',
|
||||
event: 'item',
|
||||
data: {
|
||||
taskId: TASK_ID,
|
||||
itemId: '01JCN1TEM00000000000000099',
|
||||
seq: 99,
|
||||
status: 'failed',
|
||||
},
|
||||
});
|
||||
expect(unknown.items).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('done 事件合并终态,error 事件不改详情', () => {
|
||||
const done = applyTaskEventToDetail(detail, {
|
||||
id: '14',
|
||||
event: 'done',
|
||||
data: { taskId: TASK_ID, status: 'partial_failed', progress: 100 },
|
||||
});
|
||||
expect(done.status).toBe('partial_failed');
|
||||
expect(done.progress).toBe(100);
|
||||
const errored = applyTaskEventToDetail(done, {
|
||||
id: '15',
|
||||
event: 'error',
|
||||
data: { code: 50002, message: '事件流不可用' },
|
||||
});
|
||||
expect(errored).toBe(done);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,136 @@
|
||||
/* eslint-disable max-lines-per-function, complexity -- 事件流生命周期(校验 / 握手 / 心跳续推 / 重连 / 降级)集中一处,拆散会丢失清理时机 */
|
||||
/* eslint-disable @typescript-eslint/no-unnecessary-condition -- cancelled 标志在 cleanup 中置 true,TS 控制流看不到跨闭包赋值 */
|
||||
/**
|
||||
* A6 可复用 SSE 进度 Hook(A6-FE-02)。
|
||||
*
|
||||
* - 用 fetch 流读取(支持 Authorization 头,不依赖原生 EventSource);
|
||||
* - 记录 `Last-Event-ID`,断线重连从断点续推(后端优先读 header);
|
||||
* - 连续重连失败后进入 `polling` 模式,由调用方给详情查询设置 `refetchInterval` 兜底;
|
||||
* - 任务收敛(done)或不存在(404)时关闭;组件卸载时 abort,StrictMode 双执行安全。
|
||||
*/
|
||||
import type { TaskSseEvent } from '@lingxi/shared';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { ApiError } from '@/api/client';
|
||||
import { openMediaTaskEvents } from '@/api/media';
|
||||
import { parseSseChunk, parseSseEvent } from '@/components/tasks/task-sse';
|
||||
|
||||
export type TaskStreamMode = 'connecting' | 'live' | 'reconnecting' | 'polling' | 'closed';
|
||||
|
||||
/** 连续重连失败次数上限;超过后降级为轮询,避免无限重连打扰后端 */
|
||||
export const TASK_STREAM_MAX_RECONNECT = 2;
|
||||
|
||||
const RECONNECT_DELAYS_MS = [1500, 3000] as const;
|
||||
|
||||
export interface UseTaskStreamOptions {
|
||||
enabled?: boolean;
|
||||
onEvent?: (event: TaskSseEvent) => void;
|
||||
}
|
||||
|
||||
export interface UseTaskStreamResult {
|
||||
mode: TaskStreamMode;
|
||||
lastEventId: string | null;
|
||||
}
|
||||
|
||||
function sleep(ms: number, signal: AbortSignal): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const timer = window.setTimeout(() => {
|
||||
signal.removeEventListener('abort', onAbort);
|
||||
resolve();
|
||||
}, ms);
|
||||
const onAbort = (): void => {
|
||||
window.clearTimeout(timer);
|
||||
resolve();
|
||||
};
|
||||
signal.addEventListener('abort', onAbort, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
export function useTaskStream(
|
||||
taskId: string | null,
|
||||
options: UseTaskStreamOptions = {},
|
||||
): UseTaskStreamResult {
|
||||
const enabled = options.enabled ?? true;
|
||||
const [mode, setMode] = useState<TaskStreamMode>('connecting');
|
||||
const [lastEventId, setLastEventId] = useState<string | null>(null);
|
||||
const onEventRef = useRef<UseTaskStreamOptions['onEvent']>(options.onEvent);
|
||||
onEventRef.current = options.onEvent;
|
||||
|
||||
const active = enabled && taskId !== null && taskId !== '';
|
||||
|
||||
useEffect(() => {
|
||||
if (taskId === null || taskId === '' || !enabled) {
|
||||
return undefined;
|
||||
}
|
||||
const runState = { cancelled: false };
|
||||
const controller = new AbortController();
|
||||
let lastId: string | null = null;
|
||||
let attempt = 0;
|
||||
|
||||
const run = async (): Promise<void> => {
|
||||
setMode('connecting');
|
||||
setLastEventId(null);
|
||||
while (!runState.cancelled) {
|
||||
try {
|
||||
const response = await openMediaTaskEvents(taskId, {
|
||||
...(lastId === null ? {} : { lastEventId: lastId }),
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (runState.cancelled) {
|
||||
await response.body?.cancel().catch(() => undefined);
|
||||
return;
|
||||
}
|
||||
setMode('live');
|
||||
attempt = 0;
|
||||
const reader = response.body?.getReader();
|
||||
if (reader === undefined) throw new Error('任务事件流不可读');
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (runState.cancelled) return;
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const parsed = parseSseChunk(buffer);
|
||||
buffer = parsed.rest;
|
||||
for (const frame of parsed.frames) {
|
||||
const event = parseSseEvent(frame);
|
||||
if (event === null) continue;
|
||||
lastId = event.id;
|
||||
setLastEventId(event.id);
|
||||
onEventRef.current?.(event);
|
||||
if (event.event === 'done' || event.event === 'error') {
|
||||
setMode(event.event === 'done' ? 'closed' : 'polling');
|
||||
await reader.cancel().catch(() => undefined);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
// 服务端关闭但任务未必收敛(网络中断 / 进程重启):按断线处理
|
||||
throw new Error('任务事件流已中断');
|
||||
} catch (error) {
|
||||
if (runState.cancelled || controller.signal.aborted) return;
|
||||
if (error instanceof ApiError && (error.code === 40401 || error.code === 40302)) {
|
||||
setMode('closed');
|
||||
return;
|
||||
}
|
||||
attempt += 1;
|
||||
if (attempt > TASK_STREAM_MAX_RECONNECT) {
|
||||
setMode('polling');
|
||||
return;
|
||||
}
|
||||
setMode('reconnecting');
|
||||
await sleep(RECONNECT_DELAYS_MS[attempt - 1] ?? 3000, controller.signal);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
void run();
|
||||
return () => {
|
||||
runState.cancelled = true;
|
||||
controller.abort();
|
||||
};
|
||||
}, [taskId, enabled]);
|
||||
|
||||
return { mode: active ? mode : 'closed', lastEventId };
|
||||
}
|
||||
@@ -80,10 +80,12 @@ export function useIndustryDetailQuery(id: string | null): UseQueryResult<Indust
|
||||
|
||||
export function useCustomersQuery(
|
||||
query: Partial<CustomerListQuery> = {},
|
||||
options: { enabled?: boolean } = {},
|
||||
): UseQueryResult<{ items: CustomerSummary[]; next_cursor: string | null }> {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.customerList(query),
|
||||
queryFn: () => fetchCustomerList(query),
|
||||
enabled: options.enabled ?? true,
|
||||
placeholderData: keepPreviousData,
|
||||
staleTime: 10_000,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
/**
|
||||
* A6 任务中心查询与写操作(TanStack Query 唯一入口)。
|
||||
*
|
||||
* - 列表用 `useInfiniteQuery` 做真实游标累加(「加载更多」追加下一页,不做页码);
|
||||
* - 详情在 SSE 断开 / 降级时由调用方传 `refetchInterval` 走轮询兜底;
|
||||
* - 任务项超过详情首屏 100 条时用 `items_cursor` 续拉并合并;
|
||||
* - 顶栏角标读取「排队 + 生成中」两组任务(15s 轻量轮询,失败静默不打扰用户);
|
||||
* - 提交 / 取消 / 重试成功后失效列表、详情与角标缓存。
|
||||
*/
|
||||
import {
|
||||
type MediaTaskDetail,
|
||||
type MediaTaskListQuery,
|
||||
type MediaTaskListResponse,
|
||||
type MediaTaskSubmitInput,
|
||||
type MediaTaskSubmitResponse,
|
||||
type MediaTaskSummary,
|
||||
systemConfigDefinition,
|
||||
TASK_CONFIG_KEYS,
|
||||
} from '@lingxi/shared';
|
||||
import {
|
||||
type InfiniteData,
|
||||
keepPreviousData,
|
||||
useInfiniteQuery,
|
||||
type UseInfiniteQueryResult,
|
||||
useMutation,
|
||||
type UseMutationResult,
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
type UseQueryResult,
|
||||
} from '@tanstack/react-query';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { ApiError } from '@/api/client';
|
||||
import {
|
||||
cancelMediaTask,
|
||||
createTaskIdempotencyKey,
|
||||
fetchMediaTaskDetail,
|
||||
fetchMediaTasks,
|
||||
retryMediaTask,
|
||||
submitMediaTask,
|
||||
} from '@/api/media';
|
||||
import { queryKeys } from '@/api/query-keys';
|
||||
import { useEffectiveConfigsQuery } from '@/hooks/queries/use-configs';
|
||||
|
||||
/** 任务列表每页条数(后端上限 100,取 20 兼顾首屏与扫读) */
|
||||
export const MEDIA_TASK_PAGE_SIZE = 20;
|
||||
/** 顶栏角标:拉取进行中任务的上限,避免无限列表拖慢全局轮询 */
|
||||
export const ACTIVE_TASK_LIMIT = 50;
|
||||
/** 顶栏角标轮询间隔(SSE 只在任务页开启;全局角标用轮询即可) */
|
||||
const ACTIVE_TASK_POLL_MS = 15_000;
|
||||
|
||||
export type MediaTaskListFilter = Partial<
|
||||
Pick<
|
||||
MediaTaskListQuery,
|
||||
'type' | 'status' | 'customer_id' | 'created_from' | 'created_to' | 'mine' | 'keyword'
|
||||
>
|
||||
>;
|
||||
|
||||
/** 任务列表:游标分页(下一页靠后端 next_cursor,不猜页码) */
|
||||
export function useMediaTaskListInfiniteQuery(
|
||||
filter: MediaTaskListFilter,
|
||||
): UseInfiniteQueryResult<InfiniteData<MediaTaskListResponse>> {
|
||||
return useInfiniteQuery({
|
||||
queryKey: queryKeys.mediaTaskList({ ...filter, limit: MEDIA_TASK_PAGE_SIZE }),
|
||||
queryFn: ({ pageParam }) =>
|
||||
fetchMediaTasks({
|
||||
...filter,
|
||||
limit: MEDIA_TASK_PAGE_SIZE,
|
||||
...(pageParam === '' ? {} : { cursor: pageParam }),
|
||||
}),
|
||||
initialPageParam: '',
|
||||
getNextPageParam: (lastPage) => lastPage.next_cursor,
|
||||
placeholderData: keepPreviousData,
|
||||
staleTime: 10_000,
|
||||
});
|
||||
}
|
||||
|
||||
/** 任务详情:40401 / 40302 不做无意义重试;SSE 降级时由调用方设置 refetchInterval */
|
||||
export function useMediaTaskDetailQuery(
|
||||
taskId: string | null,
|
||||
options: { refetchInterval?: number | false } = {},
|
||||
): UseQueryResult<MediaTaskDetail> {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.mediaTask(taskId ?? ''),
|
||||
queryFn: () => fetchMediaTaskDetail(taskId ?? ''),
|
||||
enabled: taskId !== null && taskId !== '',
|
||||
placeholderData: keepPreviousData,
|
||||
staleTime: 5_000,
|
||||
retry: (failureCount, error) => {
|
||||
if (error instanceof ApiError && (error.code === 40401 || error.code === 40302)) return false;
|
||||
return failureCount < 2;
|
||||
},
|
||||
...(options.refetchInterval === undefined ? {} : { refetchInterval: options.refetchInterval }),
|
||||
});
|
||||
}
|
||||
|
||||
/** 任务项续拉:详情首屏 100 条之后按 items_cursor 追加 */
|
||||
export function useMediaTaskItemsQuery(
|
||||
taskId: string | null,
|
||||
cursor: string | null,
|
||||
): UseQueryResult<MediaTaskDetail> {
|
||||
return useQuery({
|
||||
queryKey: [...queryKeys.mediaTask(taskId ?? ''), 'items', cursor ?? ''] as const,
|
||||
queryFn: () => fetchMediaTaskDetail(taskId ?? '', { items_cursor: cursor ?? undefined }),
|
||||
enabled: taskId !== null && cursor !== null,
|
||||
staleTime: 30_000,
|
||||
retry: 1,
|
||||
});
|
||||
}
|
||||
|
||||
/** 顶栏角标:排队 + 生成中两项合并(按提交时间倒序) */
|
||||
export function useActiveMediaTasksQuery(): UseQueryResult<MediaTaskSummary[]> {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.mediaTaskActive,
|
||||
queryFn: async () => {
|
||||
const [running, queued] = await Promise.all([
|
||||
fetchMediaTasks({ status: 'running', limit: ACTIVE_TASK_LIMIT }),
|
||||
fetchMediaTasks({ status: 'queued', limit: ACTIVE_TASK_LIMIT }),
|
||||
]);
|
||||
return [...running.items, ...queued.items].sort((left, right) =>
|
||||
left.createdAt < right.createdAt ? 1 : left.createdAt > right.createdAt ? -1 : 0,
|
||||
);
|
||||
},
|
||||
refetchInterval: ACTIVE_TASK_POLL_MS,
|
||||
staleTime: 5_000,
|
||||
retry: 1,
|
||||
// 顶栏背景轮询失败不弹全局 toast,避免断网时反复打扰
|
||||
meta: { suppressToast: true },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 待处理失败数(角标面板 / 1024 页头):部分失败 + 失败两组任务的小范围统计。
|
||||
* 后台轮询、失败静默,不阻塞主流程;数据量与 100 人规模匹配(上限 50)。
|
||||
*/
|
||||
export function useTaskAttentionQuery(): UseQueryResult<{ failedCount: number }> {
|
||||
return useQuery({
|
||||
queryKey: [...queryKeys.mediaTaskActive, 'attention'] as const,
|
||||
queryFn: async () => {
|
||||
const [partial, failed] = await Promise.all([
|
||||
fetchMediaTasks({ status: 'partial_failed', limit: ACTIVE_TASK_LIMIT }),
|
||||
fetchMediaTasks({ status: 'failed', limit: ACTIVE_TASK_LIMIT }),
|
||||
]);
|
||||
return { failedCount: partial.items.length + failed.items.length };
|
||||
},
|
||||
refetchInterval: ACTIVE_TASK_POLL_MS,
|
||||
staleTime: 5_000,
|
||||
retry: 1,
|
||||
meta: { suppressToast: true },
|
||||
});
|
||||
}
|
||||
|
||||
/** 提交演练任务:幂等键由调用方生成并复用,重复提交返回同一任务 */
|
||||
export interface SubmitMediaTaskVariables {
|
||||
input: MediaTaskSubmitInput;
|
||||
/** 不传时自动生成;用户重试同一表单时应传入首次生成的值 */
|
||||
idempotencyKey?: string;
|
||||
}
|
||||
|
||||
export function useSubmitMediaTaskMutation(): UseMutationResult<
|
||||
MediaTaskSubmitResponse,
|
||||
unknown,
|
||||
SubmitMediaTaskVariables
|
||||
> {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ input, idempotencyKey }: SubmitMediaTaskVariables) =>
|
||||
submitMediaTask(input, idempotencyKey ?? createTaskIdempotencyKey()),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.mediaTasks });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** 取消任务:只影响未开始项;成功后刷新详情、列表与角标 */
|
||||
export function useCancelMediaTaskMutation(): UseMutationResult<unknown, unknown, string> {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (taskId: string) => cancelMediaTask(taskId),
|
||||
onSuccess: (_result, taskId) => {
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.mediaTasks });
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.mediaTask(taskId) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** 仅重试失败项:成功后刷新详情、列表与角标 */
|
||||
export function useRetryMediaTaskMutation(): UseMutationResult<unknown, unknown, string> {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (taskId: string) => retryMediaTask(taskId),
|
||||
onSuccess: (_result, taskId) => {
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.mediaTasks });
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.mediaTask(taskId) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export interface TaskEstimateConfig {
|
||||
/** 单次演练任务项上限(系统配置 task.demo.max_items) */
|
||||
maxItems: number;
|
||||
/** 单项预估成本(元,task.estimate.demo_cost_per_item) */
|
||||
costPerItem: number;
|
||||
/** 单项预估耗时(秒,task.estimate.demo_seconds_per_item) */
|
||||
secondsPerItem: number;
|
||||
}
|
||||
|
||||
function configNumber(key: string, fallback: number): number {
|
||||
const definition = systemConfigDefinition(key);
|
||||
return typeof definition?.defaultValue === 'number' ? definition.defaultValue : fallback;
|
||||
}
|
||||
|
||||
/** 提交前预估(PRD R-06):读生效配置,缺失时回退 shared 定义默认值做展示兜底,后端仍最终校验 */
|
||||
export function useTaskEstimateConfig(): {
|
||||
config: TaskEstimateConfig;
|
||||
isLoading: boolean;
|
||||
} {
|
||||
const effective = useEffectiveConfigsQuery();
|
||||
const config = useMemo<TaskEstimateConfig>(() => {
|
||||
const values = effective.data ?? {};
|
||||
const read = (key: string, fallback: number): number => {
|
||||
const value = values[key];
|
||||
return typeof value === 'number' ? value : fallback;
|
||||
};
|
||||
return {
|
||||
maxItems: read(
|
||||
TASK_CONFIG_KEYS.demoMaxItems,
|
||||
configNumber(TASK_CONFIG_KEYS.demoMaxItems, 20),
|
||||
),
|
||||
costPerItem: read(
|
||||
TASK_CONFIG_KEYS.demoCostPerItem,
|
||||
configNumber(TASK_CONFIG_KEYS.demoCostPerItem, 0.02),
|
||||
),
|
||||
secondsPerItem: read(
|
||||
TASK_CONFIG_KEYS.demoSecPerItem,
|
||||
configNumber(TASK_CONFIG_KEYS.demoSecPerItem, 3),
|
||||
),
|
||||
};
|
||||
}, [effective.data]);
|
||||
return { config, isLoading: effective.isLoading };
|
||||
}
|
||||
@@ -22,7 +22,9 @@ function isFlushRoute(pathname: string): boolean {
|
||||
pathname === ROUTES.system.settings ||
|
||||
pathname === ROUTES.system.auditLogs ||
|
||||
pathname.startsWith(`${ROUTES.system.auditLogs}/`) ||
|
||||
pathname === ROUTES.system.runLogs
|
||||
pathname === ROUTES.system.runLogs ||
|
||||
pathname === ROUTES.material.tasks ||
|
||||
pathname.startsWith(`${ROUTES.material.tasks}/`)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Link } from 'react-router';
|
||||
import {
|
||||
getAuditSectionTrail,
|
||||
getRoleSectionTrail,
|
||||
getTaskSectionTrail,
|
||||
getUserSectionTrail,
|
||||
} from '@/navigation/breadcrumbs';
|
||||
import { getBreadcrumbs, NAV_MODULES } from '@/navigation/nav-config';
|
||||
@@ -17,6 +18,7 @@ export function PageBreadcrumbs({ pathname }: PageBreadcrumbsProps): ReactNode {
|
||||
getUserSectionTrail(pathname) ??
|
||||
getRoleSectionTrail(pathname) ??
|
||||
getAuditSectionTrail(pathname) ??
|
||||
getTaskSectionTrail(pathname) ??
|
||||
getBreadcrumbs(NAV_MODULES, pathname);
|
||||
return (
|
||||
<nav className="lx-breadcrumbs" aria-label="面包屑">
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { IconChevronDown, IconLogout, IconNotification, IconSearch } from '@lingxi/ui/icons';
|
||||
import { IconChevronDown, IconLogout, IconSearch } from '@lingxi/ui/icons';
|
||||
import { type ReactNode, useEffect, useRef, useState } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router';
|
||||
|
||||
import { TaskBadge } from '@/components/tasks/TaskBadge';
|
||||
import { useLogout } from '@/hooks/use-logout';
|
||||
import type { NavModule } from '@/navigation/nav-config';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
@@ -124,10 +125,7 @@ export function TopBar({ modules, activeModuleKey }: TopBarProps): ReactNode {
|
||||
<button type="button" className="lx-icon-button" aria-label="全局搜索" title="全局搜索">
|
||||
<IconSearch size={18} />
|
||||
</button>
|
||||
<button type="button" className="lx-icon-button" aria-label="任务通知" title="任务通知">
|
||||
<IconNotification size={18} />
|
||||
<span className="lx-icon-button__dot" aria-hidden="true" />
|
||||
</button>
|
||||
<TaskBadge />
|
||||
<UserMenu />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -14,6 +14,8 @@ import '@/styles/dicts.css';
|
||||
import '@/styles/settings.css';
|
||||
import '@/styles/audit.css';
|
||||
import '@/styles/run-logs.css';
|
||||
import '@/styles/tasks.css';
|
||||
import '@/styles/task-detail.css';
|
||||
|
||||
import { LocaleProvider } from '@douyinfe/semi-ui-19';
|
||||
import { localeZhCN } from '@lingxi/ui';
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { getAuditSectionTrail, getRoleSectionTrail, getUserSectionTrail } from './breadcrumbs';
|
||||
import {
|
||||
getAuditSectionTrail,
|
||||
getRoleSectionTrail,
|
||||
getTaskSectionTrail,
|
||||
getUserSectionTrail,
|
||||
} from './breadcrumbs';
|
||||
|
||||
describe('面包屑子路由补全', () => {
|
||||
it('角色新建页:工作台 / 管理中心 / 角色管理 / 新建角色', () => {
|
||||
@@ -41,6 +46,13 @@ describe('面包屑子路由补全', () => {
|
||||
expect(getAuditSectionTrail('/system/logs')).toBeNull();
|
||||
});
|
||||
|
||||
it('任务详情:工作台 / 素材 / 任务中心 / 任务详情;列表页不进入补全', () => {
|
||||
const trail = getTaskSectionTrail('/material/tasks/01JCNB2M8K4Q7T3F9V6W1X0Y5Z');
|
||||
expect(trail?.map((entry) => entry.label)).toEqual(['工作台', '素材', '任务中心', '任务详情']);
|
||||
expect(trail?.[2]?.path).toBe('/material/tasks');
|
||||
expect(getTaskSectionTrail('/material/tasks')).toBeNull();
|
||||
});
|
||||
|
||||
it('用户子路由补全不受影响', () => {
|
||||
expect(getUserSectionTrail('/system/users/new')?.map((entry) => entry.label)).toEqual([
|
||||
'工作台',
|
||||
|
||||
@@ -47,6 +47,22 @@ export function getUserSectionTrail(pathname: string): BreadcrumbEntry[] | null
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 任务详情不在 nav-config 菜单项里,这里补齐:工作台 / 素材 / 任务中心 / 任务详情
|
||||
* (列表页由导航配置派生,不进入该分支)
|
||||
*/
|
||||
export function getTaskSectionTrail(pathname: string): BreadcrumbEntry[] | null {
|
||||
if (/^\/material\/tasks\/[^/]+$/.test(pathname)) {
|
||||
return [
|
||||
{ key: 'home', label: '工作台', path: ROUTES.home },
|
||||
{ key: 'material', label: '素材' },
|
||||
{ key: 'tasks', label: '任务中心', path: ROUTES.material.tasks },
|
||||
{ key: 'task-detail', label: '任务详情' },
|
||||
];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 操作日志详情不在 nav-config 菜单项里,这里补齐:管理中心 / 操作日志 / 日志详情 */
|
||||
export function getAuditSectionTrail(pathname: string): BreadcrumbEntry[] | null {
|
||||
if (/^\/system\/audit-logs\/[^/]+$/.test(pathname)) {
|
||||
|
||||
@@ -143,11 +143,11 @@ export const NAV_MODULES: readonly NavModule[] = [
|
||||
},
|
||||
{
|
||||
key: 'tasks',
|
||||
label: '生产任务',
|
||||
label: '任务中心',
|
||||
path: '/material/tasks',
|
||||
icon: IconTask,
|
||||
permission: Permission.MATERIAL_READ,
|
||||
description: '任务进度、失败原因与重试',
|
||||
description: '跨类型任务进度、失败明细与取消 / 重试',
|
||||
},
|
||||
{
|
||||
key: 'quick-review',
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
/* eslint-disable max-lines-per-function -- 取消 / 重试二次确认含两个分支与任务摘要,集中便于对照后果文案 */
|
||||
/**
|
||||
* A6 取消 / 重试确认弹窗(方案 A:居中 Modal 480)。
|
||||
*
|
||||
* - 取消:说明只影响未开始项,已执行与已成功项保留结果;确认按钮文案含后果;
|
||||
* - 重试:只重跑失败项,成功项不重复执行、不重复计费;展示失败行号与失败原因;
|
||||
* - 危险操作二次确认,确认中禁用按钮,失败保留弹窗并 toast 错误码 / trace_id。
|
||||
*/
|
||||
import { Button, Modal } from '@douyinfe/semi-ui-19';
|
||||
import type { MediaTaskItem, MediaTaskSummary } from '@lingxi/shared';
|
||||
import { notifyError, notifySuccess } from '@lingxi/ui';
|
||||
import { IconCheck, IconClose, IconError, IconRefresh } from '@lingxi/ui/icons';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { failureLabel, formatTaskNo, TaskStatusTag, TaskTypeTag } from '@/components/tasks';
|
||||
import {
|
||||
useCancelMediaTaskMutation,
|
||||
useRetryMediaTaskMutation,
|
||||
} from '@/hooks/queries/use-media-tasks';
|
||||
|
||||
import { describeRequestError } from './task-filter-utils';
|
||||
|
||||
export type TaskConfirmKind = 'cancel' | 'retry';
|
||||
|
||||
export interface TaskConfirmModalProps {
|
||||
kind: TaskConfirmKind;
|
||||
task: MediaTaskSummary;
|
||||
/** 重试场景:详情页已加载的失败项(列表页可能为空,仅展示失败数量) */
|
||||
failedItems: readonly MediaTaskItem[];
|
||||
onClose: () => void;
|
||||
/** 操作成功后回调(父级刷新详情 / 列表) */
|
||||
onConfirmed: () => void;
|
||||
}
|
||||
|
||||
/** 取消 / 重试失败项二次确认 */
|
||||
export function TaskConfirmModal({
|
||||
kind,
|
||||
task,
|
||||
failedItems,
|
||||
onClose,
|
||||
onConfirmed,
|
||||
}: TaskConfirmModalProps): ReactNode {
|
||||
const cancel = useCancelMediaTaskMutation();
|
||||
const retry = useRetryMediaTaskMutation();
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const pending = submitting || cancel.isPending || retry.isPending;
|
||||
const queuedCount = Math.max(0, task.itemTotal - task.itemSucceeded - task.itemFailed);
|
||||
|
||||
const handleConfirm = async (): Promise<void> => {
|
||||
setSubmitting(true);
|
||||
try {
|
||||
if (kind === 'cancel') {
|
||||
await cancel.mutateAsync(task.id);
|
||||
notifySuccess('任务已取消 · 未开始项已跳过');
|
||||
} else {
|
||||
await retry.mutateAsync(task.id);
|
||||
notifySuccess('已提交重试 · 只重跑失败项');
|
||||
}
|
||||
onConfirmed();
|
||||
onClose();
|
||||
} catch (error) {
|
||||
const info = describeRequestError(
|
||||
error,
|
||||
kind === 'cancel' ? '取消失败,请稍后重试' : '重试失败,请稍后重试',
|
||||
);
|
||||
notifyError(info.message, info.traceId);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const title = kind === 'cancel' ? '取消任务' : '重试失败项';
|
||||
const confirmText = kind === 'cancel' ? '取消任务' : `重试 ${String(task.itemFailed)} 项`;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible
|
||||
title={title}
|
||||
width={480}
|
||||
maskClosable={false}
|
||||
closeOnEsc={!pending}
|
||||
onCancel={() => {
|
||||
if (!pending) onClose();
|
||||
}}
|
||||
footer={
|
||||
<div className="lx-task-modal__footer">
|
||||
<Button onClick={onClose} disabled={pending}>
|
||||
{kind === 'cancel' ? '再想想' : '取消'}
|
||||
</Button>
|
||||
<Button
|
||||
theme="solid"
|
||||
type={kind === 'cancel' ? 'danger' : 'primary'}
|
||||
icon={kind === 'cancel' ? <IconClose size={14} /> : <IconRefresh size={14} />}
|
||||
loading={pending}
|
||||
onClick={() => {
|
||||
void handleConfirm();
|
||||
}}
|
||||
>
|
||||
{confirmText}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="lx-task-confirm">
|
||||
{kind === 'cancel' ? (
|
||||
<div className="lx-task-confirm__box lx-task-confirm__box--warn">
|
||||
<IconError size={18} />
|
||||
<div>
|
||||
<b>取消后不可恢复</b>
|
||||
<p>
|
||||
取消只影响未开始项({queuedCount} 项),已执行与已成功的 {task.itemSucceeded}{' '}
|
||||
项保留结果。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="lx-task-confirm__box">
|
||||
<IconError size={18} />
|
||||
<div>
|
||||
<b>只重跑失败项</b>
|
||||
<p>
|
||||
本次将重试 {task.itemFailed} 个失败项,已成功的 {task.itemSucceeded}{' '}
|
||||
项不会重复执行,也不会重复计费。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="lx-task-confirm__task">
|
||||
<TaskTypeTag type={task.type} />
|
||||
<span className="lx-task-confirm__main">
|
||||
<b>{task.title}</b>
|
||||
<span>
|
||||
{formatTaskNo(task.id)} · {task.customerName} · 当前进度 {task.progress}%
|
||||
</span>
|
||||
</span>
|
||||
<TaskStatusTag status={task.status} />
|
||||
</div>
|
||||
|
||||
{kind === 'cancel' ? (
|
||||
<div className="lx-task-confirm__list">
|
||||
<span className="lx-task-confirm__item">
|
||||
<IconClose size={14} />
|
||||
未开始项将标记为「已跳过」
|
||||
</span>
|
||||
<span className="lx-task-confirm__item">
|
||||
<IconCheck size={14} />
|
||||
执行中的项会继续完成,结果仍会保留
|
||||
</span>
|
||||
</div>
|
||||
) : failedItems.length === 0 ? (
|
||||
<p className="lx-task-confirm__note">
|
||||
失败行号以任务详情为准;本次只重跑失败的 {task.itemFailed} 项,成功项不重复执行。
|
||||
</p>
|
||||
) : (
|
||||
<div className="lx-task-confirm__list">
|
||||
{failedItems.map((item) => (
|
||||
<span key={item.id} className="lx-task-confirm__item lx-task-confirm__item--fail">
|
||||
<b>#{item.seq}</b>
|
||||
{failureLabel(item.failureCode)}
|
||||
{item.failureMessage === undefined || item.failureMessage.trim() === ''
|
||||
? null
|
||||
: `:${item.failureMessage}`}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,856 @@
|
||||
/* eslint-disable max-lines-per-function, complexity -- 详情页编排 SSE 合并 / 进度 / 逐项表 / 时间线 / 确认弹窗,集中便于追踪数据流 */
|
||||
/**
|
||||
* A6 任务详情页(方案 A · 经典管理台,`/material/tasks/:id`)。
|
||||
*
|
||||
* - 数据:详情查询 + SSE(`useTaskStream`)实时合并;降级轮询时 5 秒 refetch;
|
||||
* - 布局:任务头卡 + 左 2/3(进度卡 / 失败明细 / 任务项表)+ 右 1/3(任务信息 / 时间线 / 进度通道 / 操作约束);
|
||||
* - 任务项按 `itemsNextCursor` 续拉并合并去重;失败行高亮并给出行号与原因;
|
||||
* - 40401 / 40302 显示「任务不存在 / 不在数据范围」,其它错误保留筛选与错误码 + trace_id。
|
||||
*/
|
||||
import { Button, Table } from '@douyinfe/semi-ui-19';
|
||||
import type { ColumnProps } from '@douyinfe/semi-ui-19/lib/es/table/interface';
|
||||
import {
|
||||
canCancelTask,
|
||||
canRetryTask,
|
||||
type MediaTaskDetail,
|
||||
type MediaTaskItem,
|
||||
Permission,
|
||||
type TaskItemStatus,
|
||||
taskItemStatusLabel,
|
||||
} from '@lingxi/shared';
|
||||
import { ErrorState, notifyError, notifySuccess, PageEmpty, PageSkeleton } from '@lingxi/ui';
|
||||
import {
|
||||
IconAlert,
|
||||
IconClock,
|
||||
IconClose,
|
||||
IconCopy,
|
||||
IconError,
|
||||
IconFilter,
|
||||
IconGlobal,
|
||||
IconLoading,
|
||||
IconLock,
|
||||
IconRefresh,
|
||||
IconShield,
|
||||
} from '@lingxi/ui/icons';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router';
|
||||
|
||||
import { ApiError } from '@/api/client';
|
||||
import { queryKeys } from '@/api/query-keys';
|
||||
import {
|
||||
applyTaskEventToDetail,
|
||||
displayTaskTitle,
|
||||
failureLabel,
|
||||
formatDateTime,
|
||||
formatElapsed,
|
||||
formatMoney,
|
||||
formatSeconds,
|
||||
formatTaskNo,
|
||||
formatTime,
|
||||
TASK_TYPE_META,
|
||||
TaskItemStatusTag,
|
||||
TaskProgressBar,
|
||||
TaskSseIndicator,
|
||||
TaskStatusTag,
|
||||
type TaskStreamMode,
|
||||
useTaskStream,
|
||||
} from '@/components/tasks';
|
||||
import { useMediaTaskDetailQuery, useMediaTaskItemsQuery } from '@/hooks/queries/use-media-tasks';
|
||||
import { usePermission } from '@/hooks/use-permission';
|
||||
import { ROUTES } from '@/routes';
|
||||
|
||||
import {
|
||||
buildTaskTimeline,
|
||||
failedSeqsOf,
|
||||
mergeTaskItems,
|
||||
readTaskDemoMode,
|
||||
readTaskPayloadText,
|
||||
TASK_DEMO_MODE_LABELS,
|
||||
TASK_TYPE_ICONS,
|
||||
} from './task-filter-utils';
|
||||
import { type TaskConfirmKind, TaskConfirmModal } from './TaskConfirmModal';
|
||||
|
||||
const ITEM_FILTERS: readonly { value: TaskItemStatus | 'all'; label: string }[] = [
|
||||
{ value: 'all', label: '全部' },
|
||||
{ value: 'queued', label: taskItemStatusLabel('queued') },
|
||||
{ value: 'running', label: taskItemStatusLabel('running') },
|
||||
{ value: 'pending_review', label: taskItemStatusLabel('pending_review') },
|
||||
{ value: 'failed', label: taskItemStatusLabel('failed') },
|
||||
{ value: 'skipped', label: taskItemStatusLabel('skipped') },
|
||||
];
|
||||
|
||||
function itemCountOf(items: readonly MediaTaskItem[], status: TaskItemStatus | 'all'): number {
|
||||
if (status === 'all') return items.length;
|
||||
return items.filter((item) => item.status === status).length;
|
||||
}
|
||||
|
||||
function latestActivityAtOf(detail: MediaTaskDetail): string {
|
||||
let latest = detail.finishedAt ?? detail.startedAt ?? detail.createdAt;
|
||||
for (const item of detail.items) {
|
||||
if (item.finishedAt !== undefined && item.finishedAt > latest) latest = item.finishedAt;
|
||||
}
|
||||
return latest;
|
||||
}
|
||||
|
||||
function itemDurationOf(item: MediaTaskItem): string {
|
||||
return formatElapsed(item.startedAt, item.finishedAt) ?? '—';
|
||||
}
|
||||
|
||||
async function copyText(value: string, label: string): Promise<void> {
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
notifySuccess(`${label}已复制`);
|
||||
} catch {
|
||||
notifyError('复制失败,请手动选择文本');
|
||||
}
|
||||
}
|
||||
|
||||
function TypeHeadIcon({ type }: { type: MediaTaskDetail['type'] }): ReactNode {
|
||||
const meta = TASK_TYPE_META[type];
|
||||
const Icon = TASK_TYPE_ICONS[meta.icon];
|
||||
return (
|
||||
<span className={`lx-task-head__icon lx-task-head__icon--${meta.tone}`} aria-hidden="true">
|
||||
<Icon size={22} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function TaskHead({
|
||||
detail,
|
||||
streamMode,
|
||||
canRender,
|
||||
refreshing,
|
||||
onRefresh,
|
||||
onCancel,
|
||||
onRetryFailures,
|
||||
}: {
|
||||
detail: MediaTaskDetail;
|
||||
streamMode: TaskStreamMode;
|
||||
canRender: boolean;
|
||||
refreshing: boolean;
|
||||
onRefresh: () => void;
|
||||
onCancel: () => void;
|
||||
onRetryFailures: () => void;
|
||||
}): ReactNode {
|
||||
return (
|
||||
<section className="lx-task-head" aria-label="任务摘要">
|
||||
<div className="lx-task-head__main">
|
||||
<TypeHeadIcon type={detail.type} />
|
||||
<div className="lx-task-head__text">
|
||||
<div className="lx-task-head__title">
|
||||
<h1>{displayTaskTitle(detail)}</h1>
|
||||
<TaskStatusTag status={detail.status} size="large" />
|
||||
</div>
|
||||
<div className="lx-task-head__meta">
|
||||
<span className="lx-task-copyline">
|
||||
<code>{formatTaskNo(detail.id)}</code>
|
||||
<button
|
||||
type="button"
|
||||
className="lx-task-icon-button"
|
||||
aria-label="复制完整任务号"
|
||||
title="复制完整任务号"
|
||||
onClick={() => {
|
||||
void copyText(detail.id, '完整任务号');
|
||||
}}
|
||||
>
|
||||
<IconCopy size={13} />
|
||||
</button>
|
||||
</span>
|
||||
<i aria-hidden="true">·</i>
|
||||
<span>{detail.customerName}</span>
|
||||
<i aria-hidden="true">·</i>
|
||||
<span>
|
||||
{detail.createdByName} 提交于 {formatDateTime(detail.createdAt)}
|
||||
</span>
|
||||
<i aria-hidden="true">·</i>
|
||||
<span>{TASK_TYPE_META[detail.type].label}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="lx-task-head__actions">
|
||||
<TaskSseIndicator mode={streamMode} />
|
||||
{canRender && canRetryTask(detail.status) ? (
|
||||
<Button
|
||||
theme="solid"
|
||||
type="primary"
|
||||
icon={<IconRefresh size={14} />}
|
||||
onClick={onRetryFailures}
|
||||
>
|
||||
重试失败项({detail.itemFailed})
|
||||
</Button>
|
||||
) : null}
|
||||
{canRender && canCancelTask(detail.status) ? (
|
||||
<Button theme="outline" type="danger" icon={<IconClose size={14} />} onClick={onCancel}>
|
||||
取消任务
|
||||
</Button>
|
||||
) : null}
|
||||
<Button icon={<IconRefresh size={14} />} loading={refreshing} onClick={onRefresh}>
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function ProgressCard({ detail }: { detail: MediaTaskDetail }): ReactNode {
|
||||
const failedSeqs = failedSeqsOf(detail.items);
|
||||
const elapsed = formatElapsed(detail.startedAt, detail.finishedAt);
|
||||
return (
|
||||
<section className="lx-task-card" aria-label="执行进度">
|
||||
<header className="lx-task-card__head">
|
||||
<b>执行进度</b>
|
||||
{detail.status === 'running' ? (
|
||||
<span className="lx-task-card__hint">
|
||||
<IconLoading size={12} className="lx-task-status__spin" />
|
||||
进度会自动保存,刷新或重连不丢失
|
||||
</span>
|
||||
) : null}
|
||||
<span className="lx-task-card__hint lx-task-card__hint--right">
|
||||
最后更新 {formatDateTime(latestActivityAtOf(detail))}
|
||||
</span>
|
||||
</header>
|
||||
<div className="lx-task-card__body">
|
||||
<div className="lx-task-bigline">
|
||||
{detail.status === 'partial_failed' || detail.status === 'failed' ? (
|
||||
<b>
|
||||
失败 {detail.itemFailed}
|
||||
<em> / {detail.itemTotal} 项已处理</em>
|
||||
</b>
|
||||
) : (
|
||||
<b>{detail.progress}%</b>
|
||||
)}
|
||||
<TaskStatusTag status={detail.status} size="large" />
|
||||
{failedSeqs.length > 0 ? (
|
||||
<span className="lx-task-hint lx-task-hint--danger">
|
||||
<IconError size={13} />
|
||||
失败行号 {failedSeqs.map((seq) => `#${String(seq)}`).join(' / ')}
|
||||
</span>
|
||||
) : null}
|
||||
{detail.status === 'running' && elapsed !== null ? (
|
||||
<span className="lx-task-hint lx-task-hint--right">已执行 {elapsed}</span>
|
||||
) : null}
|
||||
{detail.status === 'pending_review' ? (
|
||||
<span className="lx-task-hint lx-task-hint--right">产物就绪,等待进入快筛预览</span>
|
||||
) : null}
|
||||
</div>
|
||||
<TaskProgressBar
|
||||
percent={detail.progress}
|
||||
status={detail.status}
|
||||
size="lg"
|
||||
showInfo={false}
|
||||
/>
|
||||
<div className="lx-task-counter">
|
||||
<span className="lx-task-counter__item ok">
|
||||
<i aria-hidden="true" />
|
||||
成功 <b>{detail.itemSucceeded}</b>
|
||||
</span>
|
||||
<span className="lx-task-counter__item bad">
|
||||
<i aria-hidden="true" />
|
||||
失败 <b>{detail.itemFailed}</b>
|
||||
</span>
|
||||
<span className="lx-task-counter__item">
|
||||
<i aria-hidden="true" />
|
||||
已跳过 <b>{detail.itemSkipped}</b>
|
||||
</span>
|
||||
<span className="lx-task-counter__item">
|
||||
<i aria-hidden="true" />
|
||||
总计 <b>{detail.itemTotal}</b> 项
|
||||
</span>
|
||||
</div>
|
||||
<div className="lx-task-kvline">
|
||||
<div>
|
||||
<span>预估成本</span>
|
||||
<b>{formatMoney(detail.estimatedCost)}</b>
|
||||
</div>
|
||||
<div>
|
||||
<span>预估时长</span>
|
||||
<b>{formatSeconds(detail.estimatedSeconds)}</b>
|
||||
</div>
|
||||
<div>
|
||||
<span>{detail.finishedAt === undefined ? '已执行' : '实际耗时'}</span>
|
||||
<b>{elapsed ?? '—'}</b>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function FailureBox({
|
||||
items,
|
||||
totalFailed,
|
||||
}: {
|
||||
items: readonly MediaTaskItem[];
|
||||
totalFailed: number;
|
||||
}): ReactNode | null {
|
||||
const failed = items.filter((item) => item.status === 'failed');
|
||||
if (failed.length === 0) return null;
|
||||
return (
|
||||
<div className="lx-task-failbox" role="note">
|
||||
{failed.map((item) => (
|
||||
<div key={item.id} className="lx-task-failbox__row">
|
||||
<span className="lx-task-failbox__seq">#{item.seq}</span>
|
||||
<span className="lx-task-failbox__text">
|
||||
<b title={item.failureCode}>{failureLabel(item.failureCode)}</b>
|
||||
{item.failureCode === 'demo_simulated_failure' ||
|
||||
item.failureMessage === undefined ? null : (
|
||||
<span>{item.failureMessage}</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{totalFailed > failed.length ? (
|
||||
<p className="lx-task-failbox__more">
|
||||
还有 {totalFailed - failed.length} 项失败明细未加载,点「加载更多任务项」查看。
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function itemReasonOf(item: MediaTaskItem): ReactNode {
|
||||
if (item.status === 'failed') {
|
||||
return (
|
||||
<span className="lx-task-fail-reason">
|
||||
<b>{failureLabel(item.failureCode)}</b>
|
||||
{item.failureCode === 'demo_simulated_failure' ||
|
||||
item.failureMessage === undefined ? null : (
|
||||
<span>{item.failureMessage}</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (item.status === 'queued') return <span className="lx-task-muted">等待开始执行</span>;
|
||||
if (item.status === 'running') return <span className="lx-task-muted">处理中…</span>;
|
||||
return <span className="lx-task-muted">—</span>;
|
||||
}
|
||||
|
||||
function buildItemColumns(showFailure: boolean): ColumnProps<MediaTaskItem>[] {
|
||||
return [
|
||||
{
|
||||
title: '行号',
|
||||
dataIndex: 'seq',
|
||||
width: 72,
|
||||
render: (_value: unknown, record: MediaTaskItem): ReactNode => (
|
||||
<span className="lx-task-mono">#{record.seq}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
width: 108,
|
||||
render: (_value: unknown, record: MediaTaskItem): ReactNode => (
|
||||
<TaskItemStatusTag status={record.status} />
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '尝试',
|
||||
dataIndex: 'attempts',
|
||||
width: 80,
|
||||
render: (_value: unknown, record: MediaTaskItem): ReactNode => (
|
||||
<span className="lx-task-mono">{record.attempts} 次</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '耗时',
|
||||
dataIndex: 'startedAt',
|
||||
width: 96,
|
||||
render: (_value: unknown, record: MediaTaskItem): ReactNode => (
|
||||
<span className="lx-task-mono">{itemDurationOf(record)}</span>
|
||||
),
|
||||
},
|
||||
...(showFailure
|
||||
? [
|
||||
{
|
||||
title: '失败原因 / 说明' as const,
|
||||
dataIndex: 'failureCode' as const,
|
||||
render: (_value: unknown, record: MediaTaskItem): ReactNode => itemReasonOf(record),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
title: '完成时间',
|
||||
dataIndex: 'finishedAt',
|
||||
width: 104,
|
||||
render: (_value: unknown, record: MediaTaskItem): ReactNode => (
|
||||
<span className="lx-task-mono">{formatTime(record.finishedAt)}</span>
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function ItemCard({
|
||||
detail,
|
||||
items,
|
||||
itemFilter,
|
||||
canRender,
|
||||
nextCursor,
|
||||
itemsLoading,
|
||||
itemsError,
|
||||
onItemFilterChange,
|
||||
onLoadMore,
|
||||
}: {
|
||||
detail: MediaTaskDetail;
|
||||
items: readonly MediaTaskItem[];
|
||||
itemFilter: TaskItemStatus | 'all';
|
||||
canRender: boolean;
|
||||
nextCursor: string | null;
|
||||
itemsLoading: boolean;
|
||||
itemsError: string | null;
|
||||
onItemFilterChange: (filter: TaskItemStatus | 'all') => void;
|
||||
onLoadMore: () => void;
|
||||
}): ReactNode {
|
||||
const visibleItems = useMemo(
|
||||
() => (itemFilter === 'all' ? [...items] : items.filter((item) => item.status === itemFilter)),
|
||||
[items, itemFilter],
|
||||
);
|
||||
const itemColumns = useMemo(() => buildItemColumns(detail.itemFailed > 0), [detail.itemFailed]);
|
||||
return (
|
||||
<section className="lx-task-card" aria-label="任务项">
|
||||
<header className="lx-task-card__head">
|
||||
<b>任务项</b>
|
||||
<span className="lx-task-card__hint">
|
||||
共 {detail.itemTotal} 项 · 失败 {detail.itemFailed} 项
|
||||
</span>
|
||||
</header>
|
||||
<div className="lx-task-card__body">
|
||||
<FailureBox items={items} totalFailed={detail.itemFailed} />
|
||||
<div className="lx-task-itemchips" role="group" aria-label="按任务项状态筛选">
|
||||
{ITEM_FILTERS.map((filter) => {
|
||||
const active = itemFilter === filter.value;
|
||||
const count = itemCountOf(items, filter.value);
|
||||
if (filter.value !== 'all' && count === 0) return null;
|
||||
return (
|
||||
<button
|
||||
key={filter.value}
|
||||
type="button"
|
||||
className={`lx-task-itemchip${active ? ' is-active' : ''}`}
|
||||
aria-pressed={active}
|
||||
onClick={() => {
|
||||
onItemFilterChange(filter.value);
|
||||
}}
|
||||
>
|
||||
{filter.label}
|
||||
<span>{count}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<span className="lx-task-card__hint lx-task-card__hint--right">
|
||||
<IconFilter size={12} />
|
||||
{canRender ? '失败项按行号可定位;重试只重跑失败项' : '失败项按行号可定位'}
|
||||
</span>
|
||||
</div>
|
||||
<Table<MediaTaskItem>
|
||||
className="lx-task-items-table"
|
||||
columns={itemColumns}
|
||||
dataSource={visibleItems}
|
||||
rowKey="id"
|
||||
pagination={false}
|
||||
size="small"
|
||||
empty={<div className="lx-task-inline-empty">当前筛选没有任务项</div>}
|
||||
onRow={(record) => (record?.status !== 'failed' ? {} : { className: 'is-failed' })}
|
||||
/>
|
||||
<div className="lx-task-items-more">
|
||||
<span>
|
||||
已加载 {items.length} / {detail.itemTotal} 项
|
||||
{itemsError === null ? null : (
|
||||
<em className="lx-task-items-more__error"> · {itemsError}</em>
|
||||
)}
|
||||
</span>
|
||||
{nextCursor === null ? (
|
||||
<span className="lx-task-muted">已加载全部任务项</span>
|
||||
) : (
|
||||
<Button size="small" loading={itemsLoading} onClick={onLoadMore}>
|
||||
加载更多任务项
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function MetaRow({ label, children }: { label: string; children: ReactNode }): ReactNode {
|
||||
return (
|
||||
<div className="lx-task-meta__row">
|
||||
<span>{label}</span>
|
||||
<b>{children}</b>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TaskInfoCard({ detail }: { detail: MediaTaskDetail }): ReactNode {
|
||||
const demoMode = readTaskDemoMode(detail.payload);
|
||||
const note = readTaskPayloadText(detail.payload, 'note');
|
||||
return (
|
||||
<section className="lx-task-card" aria-label="任务信息">
|
||||
<header className="lx-task-card__head">
|
||||
<b>
|
||||
<IconAlert size={14} />
|
||||
任务信息
|
||||
</b>
|
||||
</header>
|
||||
<div className="lx-task-card__body lx-task-meta">
|
||||
<MetaRow label="任务号">
|
||||
<span className="lx-task-copyline">
|
||||
<code>{detail.id}</code>
|
||||
<button
|
||||
type="button"
|
||||
className="lx-task-icon-button"
|
||||
aria-label="复制任务号"
|
||||
title="复制任务号"
|
||||
onClick={() => {
|
||||
void copyText(detail.id, '任务号');
|
||||
}}
|
||||
>
|
||||
<IconCopy size={12} />
|
||||
</button>
|
||||
</span>
|
||||
</MetaRow>
|
||||
<MetaRow label="任务类型">{TASK_TYPE_META[detail.type].label}</MetaRow>
|
||||
<MetaRow label="客户">{detail.customerName}</MetaRow>
|
||||
<MetaRow label="提交人">{detail.createdByName}</MetaRow>
|
||||
<MetaRow label="提交时间">
|
||||
<span className="lx-task-mono">{formatDateTime(detail.createdAt)}</span>
|
||||
</MetaRow>
|
||||
{detail.startedAt === undefined ? null : (
|
||||
<MetaRow label="开始时间">
|
||||
<span className="lx-task-mono">{formatDateTime(detail.startedAt)}</span>
|
||||
</MetaRow>
|
||||
)}
|
||||
{detail.finishedAt === undefined ? null : (
|
||||
<MetaRow label="结束时间">
|
||||
<span className="lx-task-mono">{formatDateTime(detail.finishedAt)}</span>
|
||||
</MetaRow>
|
||||
)}
|
||||
<MetaRow label="任务模式">
|
||||
{demoMode === null ? '标准任务' : TASK_DEMO_MODE_LABELS[demoMode]}
|
||||
</MetaRow>
|
||||
<MetaRow label="备注">{note ?? '—'}</MetaRow>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function TimelineCard({ detail }: { detail: MediaTaskDetail }): ReactNode {
|
||||
const entries = useMemo(() => buildTaskTimeline(detail), [detail]);
|
||||
return (
|
||||
<section className="lx-task-card" aria-label="任务时间线">
|
||||
<header className="lx-task-card__head">
|
||||
<b>
|
||||
<IconClock size={14} />
|
||||
时间线
|
||||
</b>
|
||||
</header>
|
||||
<div className="lx-task-timeline">
|
||||
{entries.map((entry) => (
|
||||
<div
|
||||
key={entry.key}
|
||||
className={`lx-task-timeline__item lx-task-timeline__item--${entry.tone}`}
|
||||
>
|
||||
<span className="lx-task-timeline__dot" aria-hidden="true" />
|
||||
<div className="lx-task-timeline__main">
|
||||
<b>{entry.title}</b>
|
||||
<span>{entry.description}</span>
|
||||
<em>{entry.at === null ? '进行中' : formatDateTime(entry.at)}</em>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function SseCard({ streamMode }: { streamMode: TaskStreamMode }): ReactNode {
|
||||
return (
|
||||
<section className="lx-task-card" aria-label="进度通道">
|
||||
<header className="lx-task-card__head">
|
||||
<b>
|
||||
<IconGlobal size={14} />
|
||||
进度通道
|
||||
</b>
|
||||
</header>
|
||||
<div className="lx-task-card__body">
|
||||
<p className="lx-task-note">
|
||||
<b>进度会自动更新。</b>
|
||||
网络中断时会自动重新连接;长时间无法连接时改为定时刷新,已加载的进度不会丢失。
|
||||
</p>
|
||||
<div className="lx-task-note__sse">
|
||||
<TaskSseIndicator mode={streamMode} />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function ConstraintCard({
|
||||
canRender,
|
||||
status,
|
||||
}: {
|
||||
canRender: boolean;
|
||||
status: MediaTaskDetail['status'];
|
||||
}): ReactNode {
|
||||
return (
|
||||
<section className="lx-task-card" aria-label="操作约束">
|
||||
<header className="lx-task-card__head">
|
||||
<b>
|
||||
<IconShield size={14} />
|
||||
操作约束
|
||||
</b>
|
||||
</header>
|
||||
<div className="lx-task-card__body">
|
||||
<p className="lx-task-note">
|
||||
{canRender ? (
|
||||
status === 'pending_review' ? (
|
||||
<>
|
||||
产物已就绪,等待进入<b>快筛预览</b>;快筛入口开放后可在素材模块勾选交付。
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<b>取消</b>只影响未开始项;<b>重试</b>只重跑失败项,成功项不会重复执行。
|
||||
</>
|
||||
)
|
||||
) : (
|
||||
<>当前账号为只读权限:可查看任务与进度,不能提交、取消或重试。</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailAside({
|
||||
detail,
|
||||
streamMode,
|
||||
canRender,
|
||||
}: {
|
||||
detail: MediaTaskDetail;
|
||||
streamMode: TaskStreamMode;
|
||||
canRender: boolean;
|
||||
}): ReactNode {
|
||||
return (
|
||||
<aside className="lx-task-detail__aside">
|
||||
<TaskInfoCard detail={detail} />
|
||||
<TimelineCard detail={detail} />
|
||||
<SseCard streamMode={streamMode} />
|
||||
<ConstraintCard canRender={canRender} status={detail.status} />
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function TaskDetailView({ taskId }: { taskId: string }): ReactNode {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const canRender = usePermission(Permission.MATERIAL_RENDER);
|
||||
const stream = useTaskStream(taskId === '' ? null : taskId, {
|
||||
onEvent: (event) => {
|
||||
queryClient.setQueryData<MediaTaskDetail>(queryKeys.mediaTask(taskId), (current) =>
|
||||
current === undefined ? current : applyTaskEventToDetail(current, event),
|
||||
);
|
||||
if (event.event === 'done') {
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.mediaTasks });
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.mediaTaskActive });
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.mediaTask(taskId) });
|
||||
}
|
||||
},
|
||||
});
|
||||
const streamMode = stream.mode;
|
||||
/** 轮询降级:SSE 进入 polling 后详情按 5 秒兜底刷新(已有进度不清空) */
|
||||
const detailQuery = useMediaTaskDetailQuery(taskId === '' ? null : taskId, {
|
||||
refetchInterval: stream.mode === 'polling' ? 5_000 : false,
|
||||
});
|
||||
const detail = detailQuery.data;
|
||||
|
||||
const [extraItems, setExtraItems] = useState<MediaTaskItem[]>([]);
|
||||
const [itemsCursor, setItemsCursor] = useState<string | null>(null);
|
||||
const [itemsNextCursor, setItemsNextCursor] = useState<string | null>(null);
|
||||
const itemsQuery = useMediaTaskItemsQuery(taskId === '' ? null : taskId, itemsCursor);
|
||||
|
||||
useEffect(() => {
|
||||
const data = itemsQuery.data;
|
||||
if (data === undefined) return;
|
||||
/* eslint-disable react-hooks/set-state-in-effect -- 任务项游标分页结果需合并进本地累加状态(等价 useInfiniteQuery 的 pages 追加),基础层只提供单页查询 */
|
||||
setExtraItems((previous) => mergeTaskItems(previous, data.items));
|
||||
setItemsNextCursor(data.itemsNextCursor);
|
||||
/* eslint-enable react-hooks/set-state-in-effect */
|
||||
}, [itemsQuery.data]);
|
||||
|
||||
const [itemFilter, setItemFilter] = useState<TaskItemStatus | 'all'>('all');
|
||||
const [confirmKind, setConfirmKind] = useState<TaskConfirmKind | null>(null);
|
||||
|
||||
const mergedItems = useMemo(
|
||||
() => (detail === undefined ? [] : mergeTaskItems(detail.items, extraItems)),
|
||||
[detail, extraItems],
|
||||
);
|
||||
const nextCursor =
|
||||
itemsCursor === null ? (detail === undefined ? null : detail.itemsNextCursor) : itemsNextCursor;
|
||||
|
||||
const resetItems = (): void => {
|
||||
setExtraItems([]);
|
||||
setItemsCursor(null);
|
||||
setItemsNextCursor(null);
|
||||
setItemFilter('all');
|
||||
};
|
||||
|
||||
if (taskId === '') {
|
||||
return (
|
||||
<div className="lx-task-detail">
|
||||
<section className="lx-task-detail__state">
|
||||
<PageEmpty
|
||||
title="任务不存在或已失效"
|
||||
description="任务号缺失或格式不正确,请返回任务中心重新选择任务。"
|
||||
action={
|
||||
<Button
|
||||
onClick={() => {
|
||||
void navigate(ROUTES.material.tasks);
|
||||
}}
|
||||
>
|
||||
返回任务中心
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (detailQuery.isPending) {
|
||||
return (
|
||||
<div className="lx-task-detail">
|
||||
<PageSkeleton rows={12} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (detailQuery.error !== null || detail === undefined) {
|
||||
const error = detailQuery.error;
|
||||
const notFound = error instanceof ApiError && (error.code === 40401 || error.code === 40302);
|
||||
return (
|
||||
<div className="lx-task-detail">
|
||||
<section
|
||||
className="lx-task-detail__state"
|
||||
aria-label={notFound ? '任务不存在' : '任务详情加载失败'}
|
||||
>
|
||||
{notFound ? (
|
||||
<PageEmpty
|
||||
title="任务不存在或不在当前数据范围"
|
||||
description="任务号可能已过期,或不属于当前账号可查看的客户范围。"
|
||||
action={
|
||||
<Button
|
||||
onClick={() => {
|
||||
void navigate(ROUTES.material.tasks);
|
||||
}}
|
||||
>
|
||||
返回任务中心
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<ErrorState
|
||||
title="任务详情加载失败"
|
||||
message={
|
||||
error instanceof ApiError
|
||||
? `${error.message}(错误码 ${String(error.code)})`
|
||||
: error instanceof Error
|
||||
? error.message
|
||||
: '请稍后重试'
|
||||
}
|
||||
traceId={error instanceof ApiError ? error.traceId : undefined}
|
||||
onRetry={() => {
|
||||
void detailQuery.refetch();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const failedItems = mergedItems.filter((item) => item.status === 'failed');
|
||||
const itemsError = itemsQuery.error === null ? null : itemsQuery.error.message;
|
||||
|
||||
return (
|
||||
<div className="lx-task-detail">
|
||||
<TaskHead
|
||||
detail={detail}
|
||||
streamMode={streamMode}
|
||||
canRender={canRender}
|
||||
refreshing={detailQuery.isFetching}
|
||||
onRefresh={() => {
|
||||
void detailQuery.refetch();
|
||||
}}
|
||||
onCancel={() => {
|
||||
setConfirmKind('cancel');
|
||||
}}
|
||||
onRetryFailures={() => {
|
||||
setConfirmKind('retry');
|
||||
}}
|
||||
/>
|
||||
|
||||
{canRender ? null : (
|
||||
<div className="lx-tasks-note lx-tasks-note--readonly" role="note">
|
||||
<IconLock size={14} />
|
||||
<span>当前账号为只读权限:可查看任务与进度,取消、重试入口已隐藏。</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{streamMode === 'polling' ? (
|
||||
<div className="lx-tasks-note lx-tasks-note--warn" role="note">
|
||||
<IconAlert size={14} />
|
||||
<span>
|
||||
<b>进度更新已中断</b>,已切换为定时刷新;已加载的进度与失败明细保留。
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
{streamMode === 'reconnecting' ? (
|
||||
<div className="lx-tasks-note lx-tasks-note--warn" role="note">
|
||||
<IconAlert size={14} />
|
||||
<span>连接中断,正在重连;已加载的进度与失败明细不清空。</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="lx-task-detail__body">
|
||||
<div className="lx-task-detail__main">
|
||||
<ProgressCard detail={detail} />
|
||||
<ItemCard
|
||||
detail={detail}
|
||||
items={mergedItems}
|
||||
itemFilter={itemFilter}
|
||||
canRender={canRender}
|
||||
nextCursor={nextCursor}
|
||||
itemsLoading={itemsQuery.isFetching}
|
||||
itemsError={itemsError}
|
||||
onItemFilterChange={setItemFilter}
|
||||
onLoadMore={() => {
|
||||
if (nextCursor !== null) setItemsCursor(nextCursor);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<DetailAside detail={detail} streamMode={streamMode} canRender={canRender} />
|
||||
</div>
|
||||
|
||||
{confirmKind === null ? null : (
|
||||
<TaskConfirmModal
|
||||
kind={confirmKind}
|
||||
task={detail}
|
||||
failedItems={failedItems}
|
||||
onClose={() => {
|
||||
setConfirmKind(null);
|
||||
}}
|
||||
onConfirmed={() => {
|
||||
resetItems();
|
||||
void detailQuery.refetch();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 任务详情页:按路由 id 重建页面状态(切换任务时清空已追加项 / 筛选) */
|
||||
export function TaskDetailPage(): ReactNode {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
return <TaskDetailView key={id ?? 'missing'} taskId={id ?? ''} />;
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
/* eslint-disable max-lines-per-function -- 筛选卡两行布局、类型 chips、自定义时间与关键字防抖集中在一处便于追踪 */
|
||||
/**
|
||||
* A6 任务中心筛选卡(方案 A):类型 chips + 状态 / 客户 / 时间 / 关键字 + 重置。
|
||||
*
|
||||
* - 除关键字外即时写入 URL(URL 变化驱动查询);关键字 260ms 防抖,回车立即生效;
|
||||
* - 类型 / 状态计数只统计「当前已加载任务」,不伪造后端统计接口;
|
||||
* - 重置回到默认筛选(近 7 天),由页面统一写回空 query。
|
||||
*/
|
||||
import { Button, DatePicker, Input, Select } from '@douyinfe/semi-ui-19';
|
||||
import { IconFilter, IconGrid, IconRefresh, IconSearch } from '@lingxi/ui/icons';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { TASK_TYPE_META } from '@/components/tasks';
|
||||
import { useDebouncedValue } from '@/hooks/use-debounced-value';
|
||||
|
||||
import {
|
||||
formatDateInput,
|
||||
parseDateInput,
|
||||
TASK_STATUS_FILTERS,
|
||||
TASK_TIME_PRESETS,
|
||||
TASK_TYPE_FILTERS,
|
||||
TASK_TYPE_ICONS,
|
||||
type TaskFilterDraft,
|
||||
type TaskStatusFilter,
|
||||
type TaskTypeFilter,
|
||||
} from './task-filter-utils';
|
||||
|
||||
export interface TaskFilterOption {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface TaskFiltersProps {
|
||||
draft: TaskFilterDraft;
|
||||
typeCounts: Record<TaskTypeFilter, number>;
|
||||
statusCounts: Record<TaskStatusFilter, number>;
|
||||
/** 当前已加载任务数(筛选结果提示) */
|
||||
loadedCount: number;
|
||||
customerOptions: readonly TaskFilterOption[];
|
||||
loadingCustomers?: boolean;
|
||||
searching?: boolean;
|
||||
onChange: (draft: TaskFilterDraft) => void;
|
||||
onReset: () => void;
|
||||
}
|
||||
|
||||
const KEYWORD_DEBOUNCE_MS = 260;
|
||||
|
||||
const TYPE_CHIP_TONES: Record<TaskTypeFilter, string> = {
|
||||
all: 'grey',
|
||||
image: 'blue',
|
||||
video: 'violet',
|
||||
script: 'green',
|
||||
export: 'orange',
|
||||
verify: 'grey',
|
||||
demo: 'teal',
|
||||
};
|
||||
|
||||
function typeChipIcon(value: TaskTypeFilter): ReactNode {
|
||||
if (value === 'all') return <IconGrid size={13} />;
|
||||
const meta = TASK_TYPE_META[value];
|
||||
const Icon = TASK_TYPE_ICONS[meta.icon];
|
||||
return <Icon size={13} />;
|
||||
}
|
||||
|
||||
/** 任务中心筛选卡(方案 A:类型 chips 一行 + 条件筛选一行) */
|
||||
export function TaskFilters({
|
||||
draft,
|
||||
typeCounts,
|
||||
statusCounts,
|
||||
loadedCount,
|
||||
customerOptions,
|
||||
loadingCustomers = false,
|
||||
searching = false,
|
||||
onChange,
|
||||
onReset,
|
||||
}: TaskFiltersProps): ReactNode {
|
||||
/**
|
||||
* 关键字输入:聚焦时显示本地草稿(即时反馈),失焦后回落到 URL 值;
|
||||
* 防抖通过 useDebouncedValue 触发写回 URL,避免每次按键都改 query。
|
||||
*/
|
||||
const [keywordInput, setKeywordInput] = useState(draft.keyword);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const debouncedKeyword = useDebouncedValue(keywordInput, KEYWORD_DEBOUNCE_MS);
|
||||
const displayKeyword = editing ? keywordInput : draft.keyword;
|
||||
|
||||
useEffect(() => {
|
||||
if (!editing) return;
|
||||
if (debouncedKeyword.trim() === draft.keyword.trim()) return;
|
||||
onChange({ ...draft, keyword: debouncedKeyword });
|
||||
}, [debouncedKeyword, draft, editing, onChange]);
|
||||
|
||||
const patch = (updates: Partial<TaskFilterDraft>): void => {
|
||||
onChange({ ...draft, ...updates });
|
||||
};
|
||||
|
||||
const commitKeyword = (value: string): void => {
|
||||
setKeywordInput(value);
|
||||
if (value.trim() === draft.keyword.trim()) return;
|
||||
onChange({ ...draft, keyword: value });
|
||||
};
|
||||
|
||||
const customRange = useMemo<Date[] | undefined>(() => {
|
||||
if (draft.timePreset !== 'custom') return undefined;
|
||||
const from = parseDateInput(draft.customFrom);
|
||||
const to = parseDateInput(draft.customTo);
|
||||
if (from === null || to === null) return undefined;
|
||||
return [from, to];
|
||||
}, [draft.customFrom, draft.customTo, draft.timePreset]);
|
||||
|
||||
const statusOptions = TASK_STATUS_FILTERS.map((option) => ({
|
||||
value: option.value,
|
||||
label:
|
||||
option.value === 'all'
|
||||
? option.label
|
||||
: `${option.label} ${String(statusCounts[option.value])}`,
|
||||
}));
|
||||
const customerSelectOptions = [
|
||||
{ value: '', label: '全部客户' },
|
||||
...customerOptions.map((option) => ({ value: option.value, label: option.label })),
|
||||
];
|
||||
const timeOptions = TASK_TIME_PRESETS.map((preset) => ({
|
||||
value: preset.value,
|
||||
label: preset.label,
|
||||
}));
|
||||
|
||||
return (
|
||||
<form
|
||||
className="lx-tasks-filters"
|
||||
aria-label="任务筛选"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
commitKeyword(keywordInput);
|
||||
}}
|
||||
>
|
||||
<div className="lx-tasks-filters__row">
|
||||
<div className="lx-tasks-typechips" role="group" aria-label="按任务类型筛选">
|
||||
{TASK_TYPE_FILTERS.map((filter) => {
|
||||
const active = draft.type === filter.value;
|
||||
return (
|
||||
<button
|
||||
key={filter.value}
|
||||
type="button"
|
||||
className={`lx-tasks-typechip lx-tasks-typechip--${TYPE_CHIP_TONES[filter.value]}${active ? ' is-active' : ''}`}
|
||||
aria-pressed={active}
|
||||
title={`基于已加载的 ${String(loadedCount)} 条任务统计`}
|
||||
onClick={() => {
|
||||
patch({ type: filter.value });
|
||||
}}
|
||||
>
|
||||
{typeChipIcon(filter.value)}
|
||||
{filter.label}
|
||||
<span className="lx-tasks-typechip__count">{typeCounts[filter.value]}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<span className="lx-tasks-filters__hint">
|
||||
<IconFilter size={13} />
|
||||
任务列表跨类型合并展示,按提交时间倒序
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="lx-tasks-filters__row lx-tasks-filters__row--sub">
|
||||
<Select
|
||||
className="lx-tasks-filters__select"
|
||||
aria-label="按状态筛选"
|
||||
value={draft.status}
|
||||
optionList={statusOptions}
|
||||
onChange={(value) => {
|
||||
if (typeof value !== 'string') return;
|
||||
const option = TASK_STATUS_FILTERS.find((item) => item.value === value);
|
||||
if (option !== undefined) patch({ status: option.value });
|
||||
}}
|
||||
/>
|
||||
<Select
|
||||
className="lx-tasks-filters__customer"
|
||||
aria-label="按客户筛选"
|
||||
value={draft.customerId}
|
||||
optionList={customerSelectOptions}
|
||||
loading={loadingCustomers}
|
||||
filter
|
||||
onChange={(value) => {
|
||||
patch({ customerId: typeof value === 'string' ? value : '' });
|
||||
}}
|
||||
/>
|
||||
<Select
|
||||
className="lx-tasks-filters__select"
|
||||
aria-label="按提交时间筛选"
|
||||
value={draft.timePreset}
|
||||
optionList={timeOptions}
|
||||
onChange={(value) => {
|
||||
if (typeof value !== 'string') return;
|
||||
const preset = TASK_TIME_PRESETS.find((item) => item.value === value);
|
||||
if (preset !== undefined) patch({ timePreset: preset.value });
|
||||
}}
|
||||
/>
|
||||
{draft.timePreset === 'custom' ? (
|
||||
<DatePicker
|
||||
className="lx-tasks-filters__date"
|
||||
type="dateRange"
|
||||
value={customRange}
|
||||
placeholder={['开始日期', '结束日期']}
|
||||
onChange={(value) => {
|
||||
if (!Array.isArray(value)) {
|
||||
patch({ customFrom: '', customTo: '' });
|
||||
return;
|
||||
}
|
||||
const [start, end] = value;
|
||||
if (!(start instanceof Date) || !(end instanceof Date)) return;
|
||||
patch({ customFrom: formatDateInput(start), customTo: formatDateInput(end) });
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<Input
|
||||
className="lx-tasks-search"
|
||||
aria-label="搜索任务号 / 标题 / 客户"
|
||||
value={displayKeyword}
|
||||
prefix={<IconSearch size={14} />}
|
||||
placeholder="搜索任务号 / 标题 / 客户"
|
||||
showClear
|
||||
maxLength={100}
|
||||
onFocus={() => {
|
||||
setEditing(true);
|
||||
setKeywordInput(draft.keyword);
|
||||
}}
|
||||
onBlur={() => {
|
||||
setEditing(false);
|
||||
commitKeyword(keywordInput);
|
||||
}}
|
||||
onChange={(value) => {
|
||||
setEditing(true);
|
||||
setKeywordInput(value);
|
||||
}}
|
||||
onEnterPress={() => {
|
||||
commitKeyword(keywordInput);
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
icon={<IconRefresh size={14} />}
|
||||
onClick={() => {
|
||||
setKeywordInput('');
|
||||
setEditing(false);
|
||||
onReset();
|
||||
}}
|
||||
>
|
||||
重置
|
||||
</Button>
|
||||
<span className="lx-tasks-filters__hint lx-tasks-filters__hint--count">
|
||||
<IconFilter size={13} />
|
||||
已加载 {loadedCount} 条{searching ? ' · 查询中…' : ''}
|
||||
</span>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
/* eslint-disable max-lines-per-function -- 提交表单(客户 / 条数 / 模式 / 备注 + 实时预估 + 幂等提交)集中在一处便于追踪 */
|
||||
/**
|
||||
* A6 提交演练任务弹窗(方案 A:居中 Modal 560)。
|
||||
*
|
||||
* - 预估条数 / 成本 / 时长读系统配置(useTaskEstimateConfig),缺省回退 shared 默认值;
|
||||
* - 同一次打开复用同一 Idempotency-Key(失败重试不会创建重复任务),关闭后销毁;
|
||||
* - 提交中禁用按钮防重复提交;失败保留输入并 toast 错误码 / trace_id。
|
||||
*/
|
||||
import { Button, Input, InputNumber, Modal, Select } from '@douyinfe/semi-ui-19';
|
||||
import type { MediaTaskDemoMode, MediaTaskSubmitResponse } from '@lingxi/shared';
|
||||
import { notifyError, notifySuccess } from '@lingxi/ui';
|
||||
import { IconAlert, IconCheck, IconPlay } from '@lingxi/ui/icons';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { createTaskIdempotencyKey } from '@/api/media';
|
||||
import { formatMoney, formatSeconds, formatTaskNo } from '@/components/tasks';
|
||||
import { useSubmitMediaTaskMutation, useTaskEstimateConfig } from '@/hooks/queries/use-media-tasks';
|
||||
|
||||
import { describeRequestError, TASK_DEMO_MODE_LABELS } from './task-filter-utils';
|
||||
|
||||
export interface TaskSubmitCustomer {
|
||||
id: string;
|
||||
name: string;
|
||||
industryName: string;
|
||||
}
|
||||
|
||||
export interface TaskSubmitModalProps {
|
||||
customers: readonly TaskSubmitCustomer[];
|
||||
loadingCustomers: boolean;
|
||||
onClose: () => void;
|
||||
onSubmitted: (result: MediaTaskSubmitResponse) => void;
|
||||
}
|
||||
|
||||
const DEFAULT_ITEM_COUNT = 8;
|
||||
const NOTE_MAX_LENGTH = 200;
|
||||
|
||||
const DEMO_MODES: readonly { value: MediaTaskDemoMode; description: string }[] = [
|
||||
{ value: 'success', description: '逐项成功,结束后进入待预览' },
|
||||
{ value: 'mixed', description: '按序号模拟失败,可重试失败项' },
|
||||
{ value: 'fail', description: '验证全部失败与失败原因展示' },
|
||||
];
|
||||
|
||||
function clampCount(value: number, max: number): number {
|
||||
if (!Number.isFinite(value)) return DEFAULT_ITEM_COUNT;
|
||||
return Math.max(1, Math.min(max, Math.round(value)));
|
||||
}
|
||||
|
||||
/** 提交演练任务弹窗(仅 material:render 由页面控制挂载) */
|
||||
export function TaskSubmitModal({
|
||||
customers,
|
||||
loadingCustomers,
|
||||
onClose,
|
||||
onSubmitted,
|
||||
}: TaskSubmitModalProps): ReactNode {
|
||||
const estimate = useTaskEstimateConfig();
|
||||
const submit = useSubmitMediaTaskMutation();
|
||||
/** 同一次打开复用同一幂等键;组件随弹窗关闭卸载,下次打开自动换新 */
|
||||
const [idempotencyKey] = useState(createTaskIdempotencyKey);
|
||||
const [customerId, setCustomerId] = useState('');
|
||||
const [itemCount, setItemCount] = useState(() =>
|
||||
clampCount(DEFAULT_ITEM_COUNT, Math.max(1, estimate.config.maxItems)),
|
||||
);
|
||||
const [mode, setMode] = useState<MediaTaskDemoMode>('mixed');
|
||||
const [note, setNote] = useState('');
|
||||
|
||||
const maxItems = Math.max(1, estimate.config.maxItems);
|
||||
const selectedCustomerId = customerId !== '' ? customerId : (customers[0]?.id ?? '');
|
||||
const count = clampCount(itemCount, maxItems);
|
||||
const estimatedCost = Math.round(count * estimate.config.costPerItem * 100) / 100;
|
||||
const estimatedSeconds = count * estimate.config.secondsPerItem;
|
||||
const canSubmit = selectedCustomerId !== '' && !submit.isPending;
|
||||
|
||||
const handleSubmit = async (): Promise<void> => {
|
||||
if (!canSubmit) return;
|
||||
const noteText = note.trim();
|
||||
try {
|
||||
const result = await submit.mutateAsync({
|
||||
input: {
|
||||
type: 'demo',
|
||||
customerId: selectedCustomerId,
|
||||
itemCount: count,
|
||||
mode,
|
||||
...(noteText === '' ? {} : { note: noteText }),
|
||||
},
|
||||
idempotencyKey,
|
||||
});
|
||||
notifySuccess(`已提交,任务号 ${formatTaskNo(result.taskId)}`);
|
||||
onSubmitted(result);
|
||||
} catch (error) {
|
||||
const info = describeRequestError(error, '提交失败,请稍后重试');
|
||||
notifyError(info.message, info.traceId);
|
||||
}
|
||||
};
|
||||
|
||||
const customerOptions = customers.map((customer) => ({
|
||||
value: customer.id,
|
||||
label: `${customer.name} · ${customer.industryName}`,
|
||||
}));
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible
|
||||
title="提交演练任务"
|
||||
width={560}
|
||||
maskClosable={false}
|
||||
closeOnEsc={!submit.isPending}
|
||||
onCancel={() => {
|
||||
if (!submit.isPending) onClose();
|
||||
}}
|
||||
footer={
|
||||
<div className="lx-task-modal__footer">
|
||||
<Button onClick={onClose} disabled={submit.isPending}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
theme="solid"
|
||||
type="primary"
|
||||
icon={<IconPlay size={14} />}
|
||||
loading={submit.isPending}
|
||||
disabled={!canSubmit}
|
||||
onClick={() => {
|
||||
void handleSubmit();
|
||||
}}
|
||||
>
|
||||
提交演练任务
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="lx-task-form">
|
||||
<div className="lx-task-form__row">
|
||||
<label className="lx-task-field">
|
||||
<span className="lx-task-field__label">
|
||||
客户<i className="lx-task-field__required">*</i>
|
||||
</span>
|
||||
<Select
|
||||
value={selectedCustomerId}
|
||||
optionList={customerOptions}
|
||||
loading={loadingCustomers}
|
||||
placeholder={customers.length === 0 ? '暂无可选客户' : '选择客户'}
|
||||
disabled={customers.length === 0}
|
||||
onChange={(value) => {
|
||||
setCustomerId(typeof value === 'string' ? value : '');
|
||||
}}
|
||||
/>
|
||||
{selectedCustomerId === '' ? (
|
||||
<span className="lx-task-field__error">请选择客户</span>
|
||||
) : null}
|
||||
</label>
|
||||
<label className="lx-task-field">
|
||||
<span className="lx-task-field__label">
|
||||
任务条数<i className="lx-task-field__required">*</i>
|
||||
<em className="lx-task-field__hint">1–{maxItems} 项</em>
|
||||
</span>
|
||||
<InputNumber
|
||||
value={count}
|
||||
min={1}
|
||||
max={maxItems}
|
||||
precision={0}
|
||||
disabled={submit.isPending}
|
||||
onChange={(value) => {
|
||||
if (typeof value === 'number') setItemCount(clampCount(value, maxItems));
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="lx-task-field">
|
||||
<span className="lx-task-field__label">
|
||||
演练模式<i className="lx-task-field__required">*</i>
|
||||
<em className="lx-task-field__hint">用于验证成功 / 部分失败 / 失败链路</em>
|
||||
</span>
|
||||
<div className="lx-task-modes" role="group" aria-label="演练模式">
|
||||
{DEMO_MODES.map((option) => {
|
||||
const active = mode === option.value;
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
className={`lx-task-mode${active ? ' is-active' : ''}`}
|
||||
aria-pressed={active}
|
||||
onClick={() => {
|
||||
setMode(option.value);
|
||||
}}
|
||||
>
|
||||
<b>
|
||||
{active ? <IconCheck size={12} /> : null}
|
||||
{TASK_DEMO_MODE_LABELS[option.value]}
|
||||
</b>
|
||||
<span>{option.description}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="lx-task-field">
|
||||
<span className="lx-task-field__label">
|
||||
备注
|
||||
<em className="lx-task-field__hint">
|
||||
选填,最多 {NOTE_MAX_LENGTH} 字({note.length}/{NOTE_MAX_LENGTH})
|
||||
</em>
|
||||
</span>
|
||||
<Input
|
||||
value={note}
|
||||
maxLength={NOTE_MAX_LENGTH}
|
||||
placeholder="例如:验证部分失败与仅重试失败项"
|
||||
disabled={submit.isPending}
|
||||
onChange={(value) => {
|
||||
setNote(value);
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="lx-task-estimate">
|
||||
<b>
|
||||
<IconAlert size={13} />
|
||||
提交预估
|
||||
{estimate.isLoading ? <em className="lx-task-estimate__loading">读取配置中…</em> : null}
|
||||
</b>
|
||||
<div className="lx-task-estimate__grid">
|
||||
<div>
|
||||
<span>任务条数</span>
|
||||
<strong>{count} 项</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>预估成本</span>
|
||||
<strong>{formatMoney(estimatedCost)}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>预估时长</span>
|
||||
<strong>{formatSeconds(estimatedSeconds)}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<p className="lx-task-estimate__hint">
|
||||
成本与时长按平台配置预估({formatMoney(estimate.config.costPerItem)} / 项、
|
||||
{formatSeconds(estimate.config.secondsPerItem)} / 项);重复提交不会重复创建任务。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,531 @@
|
||||
/* eslint-disable max-lines-per-function -- 表格列定义、行内操作与状态提示集中对照接口字段 */
|
||||
/**
|
||||
* A6 任务中心列表表格(方案 A:全宽 Semi Table + 游标「加载更多」)。
|
||||
*
|
||||
* - 1024 / 窄屏隐藏客户 / 提交人 / 预估成本三列,保留任务 / 状态 / 进度 / 时间 / 操作;
|
||||
* - 行点击进详情,行内操作 stopPropagation;无 material:render 时只保留「查看」;
|
||||
* - 进度与计数全部来自任务摘要字段,不额外请求接口。
|
||||
*/
|
||||
import { Button, Table } from '@douyinfe/semi-ui-19';
|
||||
import type { ColumnProps } from '@douyinfe/semi-ui-19/lib/es/table/interface';
|
||||
import type { MediaTaskSummary } from '@lingxi/shared';
|
||||
import { PageEmpty, PageSkeleton } from '@lingxi/ui';
|
||||
import { IconAdd, IconCheck, IconClock, IconClose, IconError, IconRefresh } from '@lingxi/ui/icons';
|
||||
import type { ReactNode, RefObject } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import {
|
||||
displayTaskTitle,
|
||||
formatMoney,
|
||||
formatSeconds,
|
||||
formatTaskNo,
|
||||
TASK_TYPE_META,
|
||||
TaskProgressBar,
|
||||
TaskStatusTag,
|
||||
TaskTypeTag,
|
||||
} from '@/components/tasks';
|
||||
|
||||
import { TASK_TYPE_ICONS, taskTimeParts } from './task-filter-utils';
|
||||
|
||||
export interface TaskTableProps {
|
||||
items: readonly MediaTaskSummary[];
|
||||
loading: boolean;
|
||||
isFetchingNextPage: boolean;
|
||||
hasNextPage: boolean;
|
||||
/** 追加分页失败时的提示(保留已加载数据) */
|
||||
errorMessage: string | null;
|
||||
/** 是否有 material:render(无权限时不渲染写操作) */
|
||||
canRender: boolean;
|
||||
/** 客户 id → 行业名(客户下拉已加载时用于展示) */
|
||||
industryByCustomer: ReadonlyMap<string, string>;
|
||||
/** 是否存在非默认筛选(决定空态文案) */
|
||||
filtersActive: boolean;
|
||||
onOpen: (taskId: string) => void;
|
||||
onCancel: (task: MediaTaskSummary) => void;
|
||||
onRetry: (task: MediaTaskSummary) => void;
|
||||
onSubmit: () => void;
|
||||
onLoadMore: () => void;
|
||||
onRetryMore: () => void;
|
||||
onClearFilters: () => void;
|
||||
}
|
||||
|
||||
/** 1024 / 窄屏:隐藏次要列(客户 / 提交人 / 预估成本),避免横向溢出 */
|
||||
function useNarrowTable(): boolean {
|
||||
const query = '(max-width: 1280px)';
|
||||
const [narrow, setNarrow] = useState(
|
||||
() => typeof window !== 'undefined' && window.matchMedia(query).matches,
|
||||
);
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return undefined;
|
||||
const media = window.matchMedia(query);
|
||||
const onChange = (event: MediaQueryListEvent): void => {
|
||||
setNarrow(event.matches);
|
||||
};
|
||||
media.addEventListener('change', onChange);
|
||||
return () => {
|
||||
media.removeEventListener('change', onChange);
|
||||
};
|
||||
}, []);
|
||||
return narrow;
|
||||
}
|
||||
|
||||
/** 表格滚动高度:随卡片可用高度自适应,避免表格撑破页面 */
|
||||
/**
|
||||
* 计算表格 body 可用高度:以卡片(受页面 flex 约束、高度稳定)为基准,
|
||||
* 扣掉页脚与表头,避免「测量表格自身 → 表格又被高度限制」的循环;
|
||||
* 窗口尺寸变化 / 卡片尺寸变化都会重新计算,保证首屏表格撑满。
|
||||
*/
|
||||
function useTableHeight(enabled: boolean): {
|
||||
cardRef: RefObject<HTMLElement | null>;
|
||||
tableHeight: number;
|
||||
} {
|
||||
const cardRef = useRef<HTMLElement | null>(null);
|
||||
const [tableHeight, setTableHeight] = useState(0);
|
||||
useEffect(() => {
|
||||
if (!enabled) return undefined;
|
||||
const card = cardRef.current;
|
||||
if (card === null || typeof ResizeObserver === 'undefined') return undefined;
|
||||
const measure = (): void => {
|
||||
const foot = card.querySelector<HTMLElement>('.lx-tasks-table-card__foot');
|
||||
const head = card.querySelector<HTMLElement>('.semi-table-thead');
|
||||
const available =
|
||||
card.clientHeight - (foot?.offsetHeight ?? 0) - (head?.offsetHeight ?? 0) - 2;
|
||||
if (available > 0) setTableHeight(Math.max(160, available));
|
||||
};
|
||||
const observer = new ResizeObserver(measure);
|
||||
observer.observe(card);
|
||||
measure();
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
};
|
||||
}, [enabled]);
|
||||
return { cardRef, tableHeight };
|
||||
}
|
||||
|
||||
function TypeGlyph({ type }: { type: MediaTaskSummary['type'] }): ReactNode {
|
||||
const meta = TASK_TYPE_META[type];
|
||||
const Icon = TASK_TYPE_ICONS[meta.icon];
|
||||
return (
|
||||
<span className={`lx-task-cell__icon lx-task-cell__icon--${meta.tone}`} aria-hidden="true">
|
||||
<Icon size={16} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
interface StageHint {
|
||||
icon: ReactNode;
|
||||
text: string;
|
||||
tone: 'default' | 'warn' | 'danger';
|
||||
}
|
||||
|
||||
function stageHintOf(task: MediaTaskSummary, canRender: boolean): StageHint | null {
|
||||
if (task.status === 'queued') {
|
||||
return { icon: <IconClock size={12} />, text: '等待开始执行', tone: 'default' };
|
||||
}
|
||||
if (task.status === 'running') {
|
||||
return null;
|
||||
}
|
||||
if (task.status === 'partial_failed') {
|
||||
return {
|
||||
icon: <IconError size={12} />,
|
||||
text: canRender ? '仅重试失败项' : '存在失败项',
|
||||
tone: 'warn',
|
||||
};
|
||||
}
|
||||
if (task.status === 'failed') {
|
||||
return { icon: <IconError size={12} />, text: '全部失败,需排查', tone: 'danger' };
|
||||
}
|
||||
if (task.status === 'canceled') {
|
||||
return { icon: <IconClose size={12} />, text: '未开始项已跳过', tone: 'default' };
|
||||
}
|
||||
if (task.status === 'pending_review') {
|
||||
return { icon: <IconCheck size={12} />, text: '产物就绪,等待预览', tone: 'default' };
|
||||
}
|
||||
return { icon: <IconCheck size={12} />, text: '已结束', tone: 'default' };
|
||||
}
|
||||
|
||||
/** 进度与计数单元格:进度条 + 成功 / 失败 / 跳过 + 状态提示 */
|
||||
function ProgressCell({
|
||||
task,
|
||||
canRender,
|
||||
}: {
|
||||
task: MediaTaskSummary;
|
||||
canRender: boolean;
|
||||
}): ReactNode {
|
||||
const hint = stageHintOf(task, canRender);
|
||||
const hintClass =
|
||||
hint === null
|
||||
? ''
|
||||
: hint.tone === 'default'
|
||||
? 'lx-task-counter__hint'
|
||||
: hint.tone === 'warn'
|
||||
? 'lx-task-counter__hint lx-task-counter__hint--warn'
|
||||
: 'lx-task-counter__hint lx-task-counter__hint--danger';
|
||||
return (
|
||||
<div className="lx-task-progress-cell">
|
||||
<TaskProgressBar percent={task.progress} status={task.status} size="sm" />
|
||||
<div className="lx-task-counter">
|
||||
<span className="lx-task-counter__item ok">
|
||||
<i aria-hidden="true" />
|
||||
成功 <b>{task.itemSucceeded}</b>
|
||||
</span>
|
||||
{task.itemFailed > 0 ? (
|
||||
<span className="lx-task-counter__item bad">
|
||||
<i aria-hidden="true" />
|
||||
失败 <b>{task.itemFailed}</b>
|
||||
</span>
|
||||
) : null}
|
||||
{task.itemSkipped > 0 ? (
|
||||
<span className="lx-task-counter__item">
|
||||
<i aria-hidden="true" />
|
||||
跳过 <b>{task.itemSkipped}</b>
|
||||
</span>
|
||||
) : null}
|
||||
{hint === null ? null : (
|
||||
<span className={hintClass}>
|
||||
{hint.icon}
|
||||
{hint.text}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 行内操作:排队 / 生成中可取消;部分失败可重试;其余查看;只读只保留查看 */
|
||||
function RowActions({
|
||||
task,
|
||||
canRender,
|
||||
onOpen,
|
||||
onCancel,
|
||||
onRetry,
|
||||
}: {
|
||||
task: MediaTaskSummary;
|
||||
canRender: boolean;
|
||||
onOpen: () => void;
|
||||
onCancel: () => void;
|
||||
onRetry: () => void;
|
||||
}): ReactNode {
|
||||
if (!canRender) {
|
||||
return (
|
||||
<Button
|
||||
size="small"
|
||||
theme="borderless"
|
||||
type="primary"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onOpen();
|
||||
}}
|
||||
>
|
||||
查看
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
if (task.status === 'queued' || task.status === 'running') {
|
||||
return (
|
||||
<Button
|
||||
size="small"
|
||||
theme="outline"
|
||||
type="danger"
|
||||
icon={<IconClose size={13} />}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onCancel();
|
||||
}}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
if (task.status === 'partial_failed') {
|
||||
return (
|
||||
<Button
|
||||
size="small"
|
||||
theme="solid"
|
||||
type="primary"
|
||||
icon={<IconRefresh size={13} />}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onRetry();
|
||||
}}
|
||||
>
|
||||
重试
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Button
|
||||
size="small"
|
||||
theme="borderless"
|
||||
type="primary"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onOpen();
|
||||
}}
|
||||
>
|
||||
查看
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
function emptyState(
|
||||
filtersActive: boolean,
|
||||
canRender: boolean,
|
||||
onClearFilters: () => void,
|
||||
onSubmit: () => void,
|
||||
): ReactNode {
|
||||
const actions = (
|
||||
<span className="lx-tasks-empty-actions">
|
||||
{filtersActive ? (
|
||||
<Button icon={<IconRefresh size={14} />} onClick={onClearFilters}>
|
||||
清除筛选
|
||||
</Button>
|
||||
) : null}
|
||||
{canRender ? (
|
||||
<Button theme="solid" type="primary" icon={<IconAdd size={14} />} onClick={onSubmit}>
|
||||
提交演练任务
|
||||
</Button>
|
||||
) : null}
|
||||
</span>
|
||||
);
|
||||
if (filtersActive) {
|
||||
return (
|
||||
<PageEmpty
|
||||
title="当前筛选没有任务"
|
||||
description="调整类型 / 状态 / 客户或时间范围后重试;也可以提交演练任务查看完整流程。"
|
||||
action={actions}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<PageEmpty
|
||||
title="还没有任务"
|
||||
description="任务提交后会出现在这里,并实时展示进度与失败明细。"
|
||||
action={actions}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function compactColumnWidths(): Readonly<Record<string, number>> {
|
||||
return {
|
||||
type: 96,
|
||||
status: 96,
|
||||
progress: 220,
|
||||
created_at: 100,
|
||||
actions: 88,
|
||||
};
|
||||
}
|
||||
|
||||
/** 任务列表(方案 A:全宽表格 + 游标「加载更多」) */
|
||||
export function TaskTable({
|
||||
items,
|
||||
loading,
|
||||
isFetchingNextPage,
|
||||
hasNextPage,
|
||||
errorMessage,
|
||||
canRender,
|
||||
industryByCustomer,
|
||||
filtersActive,
|
||||
onOpen,
|
||||
onCancel,
|
||||
onRetry,
|
||||
onSubmit,
|
||||
onLoadMore,
|
||||
onRetryMore,
|
||||
onClearFilters,
|
||||
}: TaskTableProps): ReactNode {
|
||||
const narrow = useNarrowTable();
|
||||
const { cardRef, tableHeight } = useTableHeight(items.length > 0);
|
||||
|
||||
if (loading && items.length === 0) {
|
||||
return (
|
||||
<section className="lx-tasks-table-card" aria-label="任务列表加载中">
|
||||
<div className="lx-tasks-table-card__body lx-tasks-table-card__body--loading">
|
||||
<PageSkeleton rows={8} />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const columns: ColumnProps<MediaTaskSummary>[] = [
|
||||
{
|
||||
title: '任务',
|
||||
dataIndex: 'title',
|
||||
render: (_value: unknown, record: MediaTaskSummary): ReactNode => (
|
||||
<span className="lx-task-cell">
|
||||
<TypeGlyph type={record.type} />
|
||||
<span className="lx-task-cell__text">
|
||||
<b className="lx-task-cell__title" title={record.title}>
|
||||
{displayTaskTitle(record)}
|
||||
</b>
|
||||
<span className="lx-task-cell__meta">
|
||||
{formatTaskNo(record.id)} · 预估 {formatSeconds(record.estimatedSeconds)}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '类型',
|
||||
dataIndex: 'type',
|
||||
width: 92,
|
||||
render: (_value: unknown, record: MediaTaskSummary): ReactNode => (
|
||||
<TaskTypeTag type={record.type} />
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '客户',
|
||||
dataIndex: 'customerName',
|
||||
width: 116,
|
||||
className: 'lx-tasks-col-customer',
|
||||
render: (_value: unknown, record: MediaTaskSummary): ReactNode => (
|
||||
<span className="lx-task-customer">
|
||||
<b>{record.customerName}</b>
|
||||
<span>{industryByCustomer.get(record.customerId) ?? ''}</span>
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
width: 92,
|
||||
render: (_value: unknown, record: MediaTaskSummary): ReactNode => (
|
||||
<TaskStatusTag status={record.status} />
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '进度与计数',
|
||||
dataIndex: 'progress',
|
||||
width: 240,
|
||||
render: (_value: unknown, record: MediaTaskSummary): ReactNode => (
|
||||
<ProgressCell task={record} canRender={canRender} />
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '预估成本',
|
||||
dataIndex: 'estimatedCost',
|
||||
width: 96,
|
||||
className: 'lx-tasks-col-cost',
|
||||
render: (_value: unknown, record: MediaTaskSummary): ReactNode => (
|
||||
<span className="lx-task-mono">{formatMoney(record.estimatedCost)}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '提交人',
|
||||
dataIndex: 'createdByName',
|
||||
width: 84,
|
||||
className: 'lx-tasks-col-actor',
|
||||
render: (_value: unknown, record: MediaTaskSummary): ReactNode => (
|
||||
<span className="lx-task-actor">
|
||||
<b>{record.createdByName}</b>
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '提交时间',
|
||||
dataIndex: 'createdAt',
|
||||
width: 88,
|
||||
render: (_value: unknown, record: MediaTaskSummary): ReactNode => {
|
||||
const parts = taskTimeParts(record.createdAt);
|
||||
return (
|
||||
<span className="lx-task-time">
|
||||
<b>{parts.time}</b>
|
||||
<em>{parts.date}</em>
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
dataIndex: 'id',
|
||||
width: 84,
|
||||
align: 'right',
|
||||
render: (_value: unknown, record: MediaTaskSummary): ReactNode => (
|
||||
<span className="lx-task-row-actions">
|
||||
<RowActions
|
||||
task={record}
|
||||
canRender={canRender}
|
||||
onOpen={() => {
|
||||
onOpen(record.id);
|
||||
}}
|
||||
onCancel={() => {
|
||||
onCancel(record);
|
||||
}}
|
||||
onRetry={() => {
|
||||
onRetry(record);
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const hiddenIndexes = new Set(['customerName', 'estimatedCost', 'createdByName']);
|
||||
const compact = compactColumnWidths();
|
||||
const visibleColumns = columns
|
||||
.filter((column) => !narrow || !hiddenIndexes.has(String(column.dataIndex)))
|
||||
.map((column) =>
|
||||
narrow ? { ...column, width: compact[String(column.dataIndex)] ?? column.width } : column,
|
||||
);
|
||||
|
||||
const pagerText = hasNextPage
|
||||
? `已加载 ${String(items.length)} 条 · 还有更多`
|
||||
: `已加载 ${String(items.length)} 条 · 当前筛选结果已全部加载`;
|
||||
|
||||
return (
|
||||
<section className="lx-tasks-table-card" aria-label="任务列表" ref={cardRef}>
|
||||
<div className="lx-tasks-table-card__body">
|
||||
<Table<MediaTaskSummary>
|
||||
className="lx-tasks-table"
|
||||
columns={visibleColumns}
|
||||
dataSource={[...items]}
|
||||
rowKey="id"
|
||||
pagination={false}
|
||||
size="middle"
|
||||
scroll={{ x: '100%', ...(tableHeight > 0 ? { y: tableHeight } : {}) }}
|
||||
empty={emptyState(filtersActive, canRender, onClearFilters, onSubmit)}
|
||||
onRow={(record) =>
|
||||
record === undefined
|
||||
? {}
|
||||
: {
|
||||
className: 'lx-task-row',
|
||||
tabIndex: 0,
|
||||
onKeyDown: (event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
onOpen(record.id);
|
||||
}
|
||||
},
|
||||
onClick: () => {
|
||||
onOpen(record.id);
|
||||
},
|
||||
}
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="lx-tasks-table-card__foot">
|
||||
<span>
|
||||
{pagerText} · 按提交时间倒序
|
||||
{errorMessage === null ? null : (
|
||||
<em className="lx-tasks-table-card__error"> · {errorMessage}</em>
|
||||
)}
|
||||
</span>
|
||||
{hasNextPage ? (
|
||||
<Button
|
||||
size="small"
|
||||
loading={isFetchingNextPage}
|
||||
onClick={errorMessage === null ? onLoadMore : onRetryMore}
|
||||
>
|
||||
加载更多
|
||||
</Button>
|
||||
) : (
|
||||
<Button size="small" disabled icon={<IconCheck size={14} />}>
|
||||
已全部加载
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
/* eslint-disable max-lines-per-function -- 页面编排筛选 URL、游标列表与提交 / 确认弹窗,集中在一处便于追踪数据流 */
|
||||
/**
|
||||
* A6 任务中心列表页(方案 A · 经典管理台,`/material/tasks`)。
|
||||
*
|
||||
* - 页头统计只用当前已加载列表推算(不伪造后端统计接口);
|
||||
* - 筛选条件同步 URL query,URL 变化驱动查询(关键字由筛选卡防抖后写入);
|
||||
* - 游标分页「加载更多」,三态:骨架 / 空态(区分筛选无结果)/ 错误态;
|
||||
* - 无 material:render 隐藏提交 / 取消 / 重试,仅保留查看。
|
||||
*/
|
||||
import { Button } from '@douyinfe/semi-ui-19';
|
||||
import { type MediaTaskSummary, Permission } from '@lingxi/shared';
|
||||
import { ErrorState } from '@lingxi/ui';
|
||||
import { IconAdd, IconLock, IconRefresh } from '@lingxi/ui/icons';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router';
|
||||
|
||||
import { ApiError } from '@/api/client';
|
||||
import { useCustomersQuery } from '@/hooks/queries/use-client';
|
||||
import { useMediaTaskListInfiniteQuery } from '@/hooks/queries/use-media-tasks';
|
||||
import { usePermission } from '@/hooks/use-permission';
|
||||
import { taskDetailPath } from '@/routes';
|
||||
|
||||
import {
|
||||
computeTaskStats,
|
||||
countTasksByStatus,
|
||||
countTasksByType,
|
||||
parseTaskFilters,
|
||||
serializeTaskFilters,
|
||||
type TaskFilterDraft,
|
||||
taskFilterIsActive,
|
||||
type TaskStatusFilter,
|
||||
type TaskTypeFilter,
|
||||
toMediaTaskFilter,
|
||||
} from './task-filter-utils';
|
||||
import { type TaskConfirmKind, TaskConfirmModal } from './TaskConfirmModal';
|
||||
import { TaskFilters } from './TaskFilters';
|
||||
import { type TaskSubmitCustomer, TaskSubmitModal } from './TaskSubmitModal';
|
||||
import { TaskTable } from './TaskTable';
|
||||
|
||||
const CUSTOMER_OPTION_LIMIT = 100;
|
||||
|
||||
interface ConfirmState {
|
||||
kind: TaskConfirmKind;
|
||||
task: MediaTaskSummary;
|
||||
}
|
||||
|
||||
/** 任务中心列表页 */
|
||||
export function TasksPage(): ReactNode {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const canRender = usePermission(Permission.MATERIAL_RENDER);
|
||||
const canReadCustomers = usePermission(Permission.CUSTOMER_READ);
|
||||
const [submitOpen, setSubmitOpen] = useState(false);
|
||||
const [confirmState, setConfirmState] = useState<ConfirmState | null>(null);
|
||||
|
||||
const searchKey = searchParams.toString();
|
||||
const draft = useMemo(() => parseTaskFilters(new URLSearchParams(searchKey)), [searchKey]);
|
||||
const queryFilter = useMemo(() => toMediaTaskFilter(draft, new Date()), [draft]);
|
||||
const list = useMediaTaskListInfiniteQuery(queryFilter);
|
||||
const customers = useCustomersQuery(
|
||||
{ limit: CUSTOMER_OPTION_LIMIT },
|
||||
{ enabled: canReadCustomers },
|
||||
);
|
||||
|
||||
const applyDraft = (next: TaskFilterDraft): void => {
|
||||
const params = serializeTaskFilters(next);
|
||||
if (params.toString() === searchKey) return;
|
||||
setSearchParams(params, { replace: true });
|
||||
};
|
||||
const resetFilters = (): void => {
|
||||
setSearchParams(new URLSearchParams(), { replace: true });
|
||||
};
|
||||
|
||||
const items = useMemo(
|
||||
() => (list.data === undefined ? [] : list.data.pages.flatMap((page) => page.items)),
|
||||
[list.data],
|
||||
);
|
||||
const typeCounts = useMemo<Record<TaskTypeFilter, number>>(
|
||||
() => countTasksByType(items),
|
||||
[items],
|
||||
);
|
||||
const statusCounts = useMemo<Record<TaskStatusFilter, number>>(
|
||||
() => countTasksByStatus(items),
|
||||
[items],
|
||||
);
|
||||
const stats = useMemo(() => computeTaskStats(items), [items]);
|
||||
const filtersActive = taskFilterIsActive(draft);
|
||||
|
||||
const customerItems = useMemo(() => customers.data?.items ?? [], [customers.data]);
|
||||
const customerOptions = useMemo(
|
||||
() => customerItems.map((customer) => ({ value: customer.id, label: customer.name })),
|
||||
[customerItems],
|
||||
);
|
||||
const submitCustomers = useMemo<TaskSubmitCustomer[]>(
|
||||
() =>
|
||||
customerItems.map((customer) => ({
|
||||
id: customer.id,
|
||||
name: customer.name,
|
||||
industryName: customer.industryName,
|
||||
})),
|
||||
[customerItems],
|
||||
);
|
||||
const industryByCustomer = useMemo(() => {
|
||||
const map = new Map<string, string>();
|
||||
for (const customer of customerItems) map.set(customer.id, customer.industryName);
|
||||
return map;
|
||||
}, [customerItems]);
|
||||
|
||||
const openDetail = (taskId: string): void => {
|
||||
void navigate(taskDetailPath(taskId));
|
||||
};
|
||||
const openConfirm = (kind: TaskConfirmKind, task: MediaTaskSummary): void => {
|
||||
setConfirmState({ kind, task });
|
||||
};
|
||||
|
||||
const pageError = list.error;
|
||||
const showError = pageError !== null && items.length === 0;
|
||||
const moreError = list.isFetchNextPageError ? (pageError?.message ?? '加载更多失败') : null;
|
||||
const searching = list.isFetching && !list.isFetchingNextPage;
|
||||
|
||||
return (
|
||||
<div className="lx-tasks-page">
|
||||
<header className="lx-tasks-page__header">
|
||||
<div>
|
||||
<h1>任务中心</h1>
|
||||
<p>查看脚本、图片、视频等任务进度,处理失败项与重试</p>
|
||||
</div>
|
||||
<div className="lx-tasks-page__actions">
|
||||
<div className="lx-tasks-stats" aria-label="当前已加载任务统计">
|
||||
<span className="lx-tasks-stats__item">
|
||||
<span>全部任务</span>
|
||||
<b>{stats.total}</b>
|
||||
</span>
|
||||
<span className="lx-tasks-stats__item">
|
||||
<span>进行中</span>
|
||||
<b>{stats.active}</b>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="lx-tasks-stats__item lx-tasks-stats__item--warn lx-tasks-stats__item--attention"
|
||||
aria-label={`筛选待处理失败任务(${stats.pendingFailed} 个)`}
|
||||
onClick={() => {
|
||||
applyDraft({ ...draft, status: 'partial_failed' });
|
||||
}}
|
||||
>
|
||||
<span>待处理失败</span>
|
||||
<b>{stats.pendingFailed}</b>
|
||||
</button>
|
||||
<span className="lx-tasks-stats__item">
|
||||
<span>待预览</span>
|
||||
<b>{stats.pendingReview}</b>
|
||||
</span>
|
||||
</div>
|
||||
{canRender ? (
|
||||
<Button
|
||||
theme="solid"
|
||||
type="primary"
|
||||
icon={<IconAdd size={14} />}
|
||||
onClick={() => {
|
||||
setSubmitOpen(true);
|
||||
}}
|
||||
>
|
||||
提交演练任务
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
theme="borderless"
|
||||
icon={<IconRefresh size={14} />}
|
||||
loading={list.isFetching}
|
||||
aria-label="刷新任务列表"
|
||||
onClick={() => {
|
||||
void list.refetch();
|
||||
}}
|
||||
>
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{canRender ? null : (
|
||||
<div className="lx-tasks-note lx-tasks-note--readonly" role="note">
|
||||
<IconLock size={14} />
|
||||
<span>当前账号为只读权限:可查看任务与进度,提交、取消、重试入口已隐藏。</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<TaskFilters
|
||||
draft={draft}
|
||||
typeCounts={typeCounts}
|
||||
statusCounts={statusCounts}
|
||||
loadedCount={items.length}
|
||||
customerOptions={customerOptions}
|
||||
loadingCustomers={customers.isPending}
|
||||
searching={searching}
|
||||
onChange={applyDraft}
|
||||
onReset={resetFilters}
|
||||
/>
|
||||
|
||||
{showError ? (
|
||||
<section className="lx-tasks-table-card" aria-label="任务列表加载失败">
|
||||
<div className="lx-tasks-table-card__body lx-tasks-table-card__body--state">
|
||||
<ErrorState
|
||||
title="任务列表加载失败"
|
||||
message={
|
||||
pageError instanceof ApiError
|
||||
? `${pageError.message}(错误码 ${String(pageError.code)})`
|
||||
: pageError.message
|
||||
}
|
||||
traceId={pageError instanceof ApiError ? pageError.traceId : undefined}
|
||||
onRetry={() => {
|
||||
void list.refetch();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
) : (
|
||||
<TaskTable
|
||||
items={items}
|
||||
loading={list.isPending}
|
||||
isFetchingNextPage={list.isFetchingNextPage}
|
||||
hasNextPage={list.hasNextPage}
|
||||
errorMessage={moreError}
|
||||
canRender={canRender}
|
||||
industryByCustomer={industryByCustomer}
|
||||
filtersActive={filtersActive}
|
||||
onOpen={openDetail}
|
||||
onCancel={(task) => {
|
||||
openConfirm('cancel', task);
|
||||
}}
|
||||
onRetry={(task) => {
|
||||
openConfirm('retry', task);
|
||||
}}
|
||||
onSubmit={() => {
|
||||
setSubmitOpen(true);
|
||||
}}
|
||||
onLoadMore={() => {
|
||||
void list.fetchNextPage();
|
||||
}}
|
||||
onRetryMore={() => {
|
||||
void list.fetchNextPage();
|
||||
}}
|
||||
onClearFilters={resetFilters}
|
||||
/>
|
||||
)}
|
||||
|
||||
{submitOpen ? (
|
||||
<TaskSubmitModal
|
||||
customers={submitCustomers}
|
||||
loadingCustomers={customers.isPending}
|
||||
onClose={() => {
|
||||
setSubmitOpen(false);
|
||||
}}
|
||||
onSubmitted={() => {
|
||||
setSubmitOpen(false);
|
||||
void list.refetch();
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{confirmState === null ? null : (
|
||||
<TaskConfirmModal
|
||||
kind={confirmState.kind}
|
||||
task={confirmState.task}
|
||||
failedItems={[]}
|
||||
onClose={() => {
|
||||
setConfirmState(null);
|
||||
}}
|
||||
onConfirmed={() => {
|
||||
void list.refetch();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,386 @@
|
||||
import type {
|
||||
MediaTaskDetail,
|
||||
MediaTaskItem,
|
||||
MediaTaskSummary,
|
||||
TaskItemStatus,
|
||||
} from '@lingxi/shared';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { ApiError } from '@/api/client';
|
||||
|
||||
import {
|
||||
buildTaskTimeline,
|
||||
computeTaskStats,
|
||||
countTasksByStatus,
|
||||
countTasksByType,
|
||||
createDefaultTaskFilter,
|
||||
describeRequestError,
|
||||
describeTaskTimeRange,
|
||||
failedSeqsOf,
|
||||
formatDateInput,
|
||||
mergeTaskItems,
|
||||
parseDateInput,
|
||||
parseTaskFilters,
|
||||
readTaskDemoMode,
|
||||
readTaskPayloadText,
|
||||
resolveTaskTimeRange,
|
||||
serializeTaskFilters,
|
||||
type TaskFilterDraft,
|
||||
taskFilterIsActive,
|
||||
taskTimeParts,
|
||||
toMediaTaskFilter,
|
||||
} from './task-filter-utils';
|
||||
|
||||
const NOW = new Date(2026, 10, 4, 20, 32, 0, 0);
|
||||
|
||||
const CUSTOMER_ID = '01JCNB000000000000000000001';
|
||||
|
||||
function summaryOf(overrides: Partial<MediaTaskSummary> = {}): MediaTaskSummary {
|
||||
return {
|
||||
id: '01JCNB2M8K4Q7T3F9V6W1X0Y5Z',
|
||||
type: 'image',
|
||||
status: 'running',
|
||||
title: '花间集 · 双 11 主图 40 规格',
|
||||
customerId: CUSTOMER_ID,
|
||||
customerName: '花间集',
|
||||
itemTotal: 40,
|
||||
itemSucceeded: 17,
|
||||
itemFailed: 0,
|
||||
itemSkipped: 0,
|
||||
progress: 43,
|
||||
estimatedItems: 40,
|
||||
estimatedCost: 0.8,
|
||||
estimatedSeconds: 120,
|
||||
createdBy: '01JCNB000000000000000000002',
|
||||
createdByName: '陈默',
|
||||
createdAt: new Date(2026, 10, 4, 20, 21).toISOString(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function itemOf(
|
||||
seq: number,
|
||||
status: TaskItemStatus,
|
||||
overrides: Partial<MediaTaskItem> = {},
|
||||
): MediaTaskItem {
|
||||
return {
|
||||
id: `01JCNITEM${String(seq).padStart(20, '0')}`,
|
||||
seq,
|
||||
status,
|
||||
attempts: 1,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function detailOf(overrides: Partial<MediaTaskDetail> = {}): MediaTaskDetail {
|
||||
return {
|
||||
...summaryOf(),
|
||||
payload: {},
|
||||
items: [],
|
||||
itemsNextCursor: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const FULL_DRAFT: TaskFilterDraft = {
|
||||
type: 'image',
|
||||
status: 'partial_failed',
|
||||
customerId: CUSTOMER_ID,
|
||||
timePreset: 'custom',
|
||||
customFrom: '2026-10-01',
|
||||
customTo: '2026-10-31',
|
||||
keyword: ' 双11 ',
|
||||
};
|
||||
|
||||
describe('task-filter-utils · URL 往返', () => {
|
||||
it('序列化会裁剪关键字并只写入非默认值', () => {
|
||||
const params = serializeTaskFilters(FULL_DRAFT);
|
||||
expect(params.get('type')).toBe('image');
|
||||
expect(params.get('status')).toBe('partial_failed');
|
||||
expect(params.get('customer')).toBe(CUSTOMER_ID);
|
||||
expect(params.get('time')).toBe('custom');
|
||||
expect(params.get('from')).toBe('2026-10-01');
|
||||
expect(params.get('to')).toBe('2026-10-31');
|
||||
expect(params.get('keyword')).toBe('双11');
|
||||
});
|
||||
|
||||
it('parse → serialize → parse 保持一致(往返稳定)', () => {
|
||||
const parsed = parseTaskFilters(serializeTaskFilters(FULL_DRAFT));
|
||||
expect(parsed).toEqual({ ...FULL_DRAFT, keyword: '双11' });
|
||||
expect(serializeTaskFilters(parsed).toString()).toBe(
|
||||
serializeTaskFilters(FULL_DRAFT).toString(),
|
||||
);
|
||||
});
|
||||
|
||||
it('空 query 解析为默认筛选且序列化为空', () => {
|
||||
const parsed = parseTaskFilters(new URLSearchParams());
|
||||
expect(parsed).toEqual(createDefaultTaskFilter());
|
||||
expect(serializeTaskFilters(parsed).toString()).toBe('');
|
||||
});
|
||||
|
||||
it('非法枚举值回退默认,不抛错', () => {
|
||||
const parsed = parseTaskFilters(
|
||||
new URLSearchParams({ type: 'unknown', status: 'done', time: 'last-year' }),
|
||||
);
|
||||
expect(parsed.type).toBe('all');
|
||||
expect(parsed.status).toBe('all');
|
||||
expect(parsed.timePreset).toBe('7d');
|
||||
});
|
||||
});
|
||||
|
||||
describe('task-filter-utils · 时间范围', () => {
|
||||
it('今天 = 本地 00:00:00 至 23:59:59.999', () => {
|
||||
const range = resolveTaskTimeRange({ ...createDefaultTaskFilter(), timePreset: 'today' }, NOW);
|
||||
expect(range.from).toBe(new Date(2026, 10, 4, 0, 0, 0, 0).toISOString());
|
||||
expect(range.to).toBe(new Date(2026, 10, 4, 23, 59, 59, 999).toISOString());
|
||||
});
|
||||
|
||||
it('近 7 天包含今天在内的 7 个自然日', () => {
|
||||
const range = resolveTaskTimeRange({ ...createDefaultTaskFilter(), timePreset: '7d' }, NOW);
|
||||
expect(range.from).toBe(new Date(2026, 9, 29, 0, 0, 0, 0).toISOString());
|
||||
expect(range.to).toBe(new Date(2026, 10, 4, 23, 59, 59, 999).toISOString());
|
||||
});
|
||||
|
||||
it('近 30 天从 29 天前开始', () => {
|
||||
const range = resolveTaskTimeRange({ ...createDefaultTaskFilter(), timePreset: '30d' }, NOW);
|
||||
expect(range.from).toBe(new Date(2026, 9, 6, 0, 0, 0, 0).toISOString());
|
||||
});
|
||||
|
||||
it('自定义缺一端时只限制另一端', () => {
|
||||
const range = resolveTaskTimeRange(
|
||||
{ ...createDefaultTaskFilter(), timePreset: 'custom', customFrom: '2026-10-01' },
|
||||
NOW,
|
||||
);
|
||||
expect(range.from).toBe(new Date(2026, 9, 1, 0, 0, 0, 0).toISOString());
|
||||
expect(range.to).toBeNull();
|
||||
});
|
||||
|
||||
it('日期输入解析与格式化互逆', () => {
|
||||
const date = new Date(2026, 10, 4);
|
||||
expect(formatDateInput(date)).toBe('2026-11-04');
|
||||
expect(parseDateInput('2026-11-04')?.getDate()).toBe(4);
|
||||
expect(parseDateInput('2026/11/04')).toBeNull();
|
||||
});
|
||||
|
||||
it('时间范围说明用于筛选提示', () => {
|
||||
const description = describeTaskTimeRange(
|
||||
{ ...createDefaultTaskFilter(), timePreset: '30d' },
|
||||
NOW,
|
||||
);
|
||||
expect(description.label).toBe('近 30 天');
|
||||
expect(description.range).toContain('至');
|
||||
});
|
||||
});
|
||||
|
||||
describe('task-filter-utils · 后端筛选', () => {
|
||||
it('默认筛选只带 created_from / created_to', () => {
|
||||
const filter = toMediaTaskFilter(createDefaultTaskFilter(), NOW);
|
||||
expect(filter.type).toBeUndefined();
|
||||
expect(filter.status).toBeUndefined();
|
||||
expect(filter.customer_id).toBeUndefined();
|
||||
expect(filter.keyword).toBeUndefined();
|
||||
expect(filter.created_from).toBe(new Date(2026, 9, 29, 0, 0, 0, 0).toISOString());
|
||||
});
|
||||
|
||||
it('非默认值映射为 snake_case 且关键字去空格', () => {
|
||||
const filter = toMediaTaskFilter(FULL_DRAFT, NOW);
|
||||
expect(filter.type).toBe('image');
|
||||
expect(filter.status).toBe('partial_failed');
|
||||
expect(filter.customer_id).toBe(CUSTOMER_ID);
|
||||
expect(filter.keyword).toBe('双11');
|
||||
expect(filter.created_from).toBe(new Date(2026, 9, 1, 0, 0, 0, 0).toISOString());
|
||||
expect(filter.created_to).toBe(new Date(2026, 9, 31, 23, 59, 59, 999).toISOString());
|
||||
});
|
||||
|
||||
it('是否偏离默认筛选', () => {
|
||||
expect(taskFilterIsActive(createDefaultTaskFilter())).toBe(false);
|
||||
expect(taskFilterIsActive({ ...createDefaultTaskFilter(), keyword: ' ' })).toBe(false);
|
||||
expect(taskFilterIsActive({ ...createDefaultTaskFilter(), keyword: '双11' })).toBe(true);
|
||||
expect(taskFilterIsActive({ ...createDefaultTaskFilter(), timePreset: '30d' })).toBe(true);
|
||||
expect(taskFilterIsActive({ ...createDefaultTaskFilter(), customFrom: '2026-10-01' })).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('task-filter-utils · 列表统计', () => {
|
||||
const tasks: MediaTaskSummary[] = [
|
||||
summaryOf({ id: 'T1', type: 'image', status: 'running' }),
|
||||
summaryOf({ id: 'T2', type: 'image', status: 'queued' }),
|
||||
summaryOf({ id: 'T3', type: 'image', status: 'partial_failed', itemFailed: 3 }),
|
||||
summaryOf({ id: 'T4', type: 'video', status: 'failed', itemFailed: 6 }),
|
||||
summaryOf({ id: 'T5', type: 'demo', status: 'pending_review' }),
|
||||
summaryOf({ id: 'T6', type: 'script', status: 'canceled' }),
|
||||
];
|
||||
|
||||
it('按类型计数(全部 + 六类)', () => {
|
||||
const counts = countTasksByType(tasks);
|
||||
expect(counts.all).toBe(6);
|
||||
expect(counts.image).toBe(3);
|
||||
expect(counts.video).toBe(1);
|
||||
expect(counts.demo).toBe(1);
|
||||
expect(counts.script).toBe(1);
|
||||
expect(counts.export).toBe(0);
|
||||
expect(counts.verify).toBe(0);
|
||||
});
|
||||
|
||||
it('按状态计数只统计 M0 六态', () => {
|
||||
const counts = countTasksByStatus(tasks);
|
||||
expect(counts.all).toBe(6);
|
||||
expect(counts.queued).toBe(1);
|
||||
expect(counts.running).toBe(1);
|
||||
expect(counts.partial_failed).toBe(1);
|
||||
expect(counts.pending_review).toBe(1);
|
||||
expect(counts.failed).toBe(1);
|
||||
expect(counts.canceled).toBe(1);
|
||||
});
|
||||
|
||||
it('页头统计:进行中 / 待处理失败 / 待预览', () => {
|
||||
expect(computeTaskStats(tasks)).toEqual({
|
||||
total: 6,
|
||||
active: 2,
|
||||
pendingFailed: 2,
|
||||
pendingReview: 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('task-filter-utils · 任务项', () => {
|
||||
it('失败行号升序且只取失败项', () => {
|
||||
const seqs = failedSeqsOf([
|
||||
itemOf(3, 'failed'),
|
||||
itemOf(1, 'pending_review'),
|
||||
itemOf(9, 'failed'),
|
||||
itemOf(5, 'skipped'),
|
||||
]);
|
||||
expect(seqs).toEqual([3, 9]);
|
||||
});
|
||||
|
||||
it('追加合并按 id 去重、取最新状态、保留最大尝试次数并按行号排序', () => {
|
||||
const current = [itemOf(2, 'pending_review'), itemOf(1, 'queued', { attempts: 1 })];
|
||||
const incoming = [
|
||||
itemOf(1, 'running', { attempts: 2 }),
|
||||
itemOf(3, 'failed', { failureCode: 'demo_simulated_failure' }),
|
||||
];
|
||||
const merged = mergeTaskItems(current, incoming);
|
||||
expect(merged.map((item) => item.seq)).toEqual([1, 2, 3]);
|
||||
expect(merged[0]?.status).toBe('running');
|
||||
expect(merged[0]?.attempts).toBe(2);
|
||||
expect(merged[2]?.failureCode).toBe('demo_simulated_failure');
|
||||
// 幂等:重复合并不会产生重复项
|
||||
expect(mergeTaskItems(merged, incoming)).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('列表时间拆分为 MM-DD + HH:mm(本地时区)', () => {
|
||||
const parts = taskTimeParts(new Date(2026, 10, 4, 20, 21).toISOString());
|
||||
expect(parts).toEqual({ date: '11-04', time: '20:21' });
|
||||
expect(taskTimeParts(undefined)).toEqual({ date: '', time: '—' });
|
||||
});
|
||||
|
||||
it('payload 快照安全读取 mode / note', () => {
|
||||
expect(readTaskDemoMode({ mode: 'mixed' })).toBe('mixed');
|
||||
expect(readTaskDemoMode({ mode: 'unknown' })).toBeNull();
|
||||
expect(readTaskPayloadText({ note: ' 验证失败链路 ' }, 'note')).toBe('验证失败链路');
|
||||
expect(readTaskPayloadText({ note: ' ' }, 'note')).toBeNull();
|
||||
expect(readTaskPayloadText({}, 'note')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('task-filter-utils · 时间线', () => {
|
||||
const createdAt = new Date(2026, 10, 4, 18, 42).toISOString();
|
||||
const startedAt = new Date(2026, 10, 4, 18, 42, 1).toISOString();
|
||||
|
||||
it('生成中:提交 → 开始执行 → 进度更新中(无结束时间)', () => {
|
||||
const entries = buildTaskTimeline(
|
||||
detailOf({ status: 'running', createdAt, startedAt, itemTotal: 40, itemSucceeded: 17 }),
|
||||
);
|
||||
expect(entries.map((entry) => entry.key)).toEqual(['created', 'started', 'progress']);
|
||||
expect(entries[1]?.tone).toBe('live');
|
||||
expect(entries[2]?.at).toBeNull();
|
||||
});
|
||||
|
||||
it('部分失败:失败行号与收敛结果都进入时间线', () => {
|
||||
const entries = buildTaskTimeline(
|
||||
detailOf({
|
||||
status: 'partial_failed',
|
||||
createdAt,
|
||||
startedAt,
|
||||
finishedAt: new Date(2026, 10, 4, 18, 48).toISOString(),
|
||||
itemSucceeded: 27,
|
||||
itemFailed: 3,
|
||||
itemTotal: 30,
|
||||
items: [
|
||||
itemOf(9, 'failed', { failureCode: 'render_timeout' }),
|
||||
itemOf(15, 'failed', { failureCode: 'render_timeout' }),
|
||||
itemOf(23, 'failed', { failureCode: 'asset_missing' }),
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(entries.map((entry) => entry.key)).toEqual([
|
||||
'created',
|
||||
'started',
|
||||
'failures',
|
||||
'finished',
|
||||
]);
|
||||
const failure = entries.find((entry) => entry.key === 'failures');
|
||||
expect(failure?.description).toContain('#9');
|
||||
expect(failure?.description).toContain('#23');
|
||||
const finished = entries.find((entry) => entry.key === 'finished');
|
||||
expect(finished?.title).toContain('部分失败');
|
||||
expect(finished?.tone).toBe('warn');
|
||||
});
|
||||
|
||||
it('已取消:请求取消事件在收敛之前', () => {
|
||||
const entries = buildTaskTimeline(
|
||||
detailOf({
|
||||
status: 'canceled',
|
||||
createdAt,
|
||||
startedAt,
|
||||
cancelRequestedAt: new Date(2026, 10, 4, 18, 46).toISOString(),
|
||||
finishedAt: new Date(2026, 10, 4, 18, 47).toISOString(),
|
||||
itemSkipped: 8,
|
||||
}),
|
||||
);
|
||||
expect(entries.map((entry) => entry.key)).toEqual([
|
||||
'created',
|
||||
'started',
|
||||
'cancel-requested',
|
||||
'finished',
|
||||
]);
|
||||
expect(entries[2]?.tone).toBe('warn');
|
||||
expect(entries[3]?.tone).toBe('muted');
|
||||
});
|
||||
|
||||
it('排队且未开始:等待 worker 的条目排在最后且无时间', () => {
|
||||
const entries = buildTaskTimeline(detailOf({ status: 'queued', createdAt }));
|
||||
expect(entries.map((entry) => entry.key)).toEqual(['created', 'queued']);
|
||||
expect(entries[1]?.at).toBeNull();
|
||||
expect(entries[1]?.tone).toBe('live');
|
||||
});
|
||||
});
|
||||
|
||||
describe('task-filter-utils · 错误提示', () => {
|
||||
it('ApiError 保留错误码信息与 trace_id', () => {
|
||||
const error = new ApiError({
|
||||
code: 50003,
|
||||
status: 500,
|
||||
message: '服务暂时不可用',
|
||||
traceId: 'trace-1',
|
||||
});
|
||||
expect(describeRequestError(error, '兜底')).toEqual({
|
||||
message: '服务暂时不可用',
|
||||
traceId: 'trace-1',
|
||||
});
|
||||
});
|
||||
|
||||
it('普通 Error 与未知值回退', () => {
|
||||
expect(describeRequestError(new Error('网络错误'), '兜底')).toEqual({
|
||||
message: '网络错误',
|
||||
traceId: undefined,
|
||||
});
|
||||
expect(describeRequestError('boom', '提交失败')).toEqual({
|
||||
message: '提交失败',
|
||||
traceId: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,532 @@
|
||||
/**
|
||||
* A6 任务中心页面纯函数:筛选 URL 同步 / 时间换算 / 列表统计 / 任务项合并 / 时间线推导。
|
||||
*
|
||||
* 口径:
|
||||
* - 筛选草稿与 URL search params 同构,刷新 / 分享可还原;默认时间范围为近 7 天(与原型一致);
|
||||
* - 时间预设「今天 / 近 7 天 / 近 30 天」在每次查询时按本地时区换算为 created_from / created_to;
|
||||
* - 列表统计只用「当前已加载的任务」推算,不伪造后端不存在的统计接口;
|
||||
* - 任务项追加按 id 去重并按行号排序,SSE 只会更新详情首屏已加载的项。
|
||||
*/
|
||||
import {
|
||||
type MediaTaskDemoMode,
|
||||
type MediaTaskDetail,
|
||||
type MediaTaskItem,
|
||||
type MediaTaskSummary,
|
||||
TASK_STATUSES,
|
||||
TASK_TYPES,
|
||||
type TaskStatus,
|
||||
taskStatusLabel,
|
||||
type TaskType,
|
||||
taskTypeLabel,
|
||||
} from '@lingxi/shared';
|
||||
import {
|
||||
IconDownload,
|
||||
IconMaterial,
|
||||
IconScript,
|
||||
IconShield,
|
||||
IconTask,
|
||||
IconVideo,
|
||||
} from '@lingxi/ui/icons';
|
||||
import type { ComponentType } from 'react';
|
||||
|
||||
import { ApiError } from '@/api/client';
|
||||
import {
|
||||
failureLabel,
|
||||
formatDateTime,
|
||||
formatMoney,
|
||||
formatSeconds,
|
||||
type TaskTypeIcon,
|
||||
} from '@/components/tasks/task-display';
|
||||
|
||||
/** 后端列表筛选(snake_case query,空值剔除) */
|
||||
export interface MediaTaskQueryFilter {
|
||||
type?: TaskType;
|
||||
status?: TaskStatus;
|
||||
customer_id?: string;
|
||||
created_from?: string;
|
||||
created_to?: string;
|
||||
keyword?: string;
|
||||
}
|
||||
|
||||
export type TaskTypeFilter = 'all' | TaskType;
|
||||
export type TaskStatusFilter = 'all' | TaskStatus;
|
||||
export type TaskTimePreset = 'today' | '7d' | '30d' | 'custom';
|
||||
|
||||
export interface TaskFilterDraft {
|
||||
type: TaskTypeFilter;
|
||||
status: TaskStatusFilter;
|
||||
/** 客户 id;空串 = 全部客户 */
|
||||
customerId: string;
|
||||
timePreset: TaskTimePreset;
|
||||
/** 自定义起始日期(YYYY-MM-DD,本地时区) */
|
||||
customFrom: string;
|
||||
/** 自定义结束日期(YYYY-MM-DD,本地时区) */
|
||||
customTo: string;
|
||||
keyword: string;
|
||||
}
|
||||
|
||||
export interface TaskListStats {
|
||||
total: number;
|
||||
/** 排队 + 生成中 */
|
||||
active: number;
|
||||
/** 部分失败 + 失败 */
|
||||
pendingFailed: number;
|
||||
pendingReview: number;
|
||||
}
|
||||
|
||||
export interface TaskTimeRange {
|
||||
from: string | null;
|
||||
to: string | null;
|
||||
}
|
||||
|
||||
export interface TaskTimelineEntry {
|
||||
key: string;
|
||||
title: string;
|
||||
description: string;
|
||||
/** 事件时间 ISO;null 表示「进行中 / 无明确时间」 */
|
||||
at: string | null;
|
||||
tone: 'done' | 'live' | 'warn' | 'bad' | 'muted';
|
||||
}
|
||||
|
||||
export interface RequestErrorInfo {
|
||||
message: string;
|
||||
traceId: string | undefined;
|
||||
}
|
||||
|
||||
export interface TaskTimeParts {
|
||||
/** `MM-DD`(本地时区) */
|
||||
date: string;
|
||||
/** `HH:mm`(本地时区) */
|
||||
time: string;
|
||||
}
|
||||
|
||||
type TaskTypeIconComponent = ComponentType<{ size?: number | string; className?: string }>;
|
||||
|
||||
export const DEFAULT_TASK_TIME_PRESET: TaskTimePreset = '7d';
|
||||
|
||||
export const TASK_TIME_PRESETS: readonly { value: TaskTimePreset; label: string }[] = [
|
||||
{ value: 'today', label: '今天' },
|
||||
{ value: '7d', label: '近 7 天' },
|
||||
{ value: '30d', label: '近 30 天' },
|
||||
{ value: 'custom', label: '自定义时间' },
|
||||
];
|
||||
|
||||
/** 类型 chips(顺序与原型一致:全部 / 图片 / 视频 / 脚本 / 导出 / 校验 / 演练) */
|
||||
export const TASK_TYPE_FILTERS: readonly { value: TaskTypeFilter; label: string }[] = [
|
||||
{ value: 'all', label: '全部' },
|
||||
{ value: 'image', label: '图片' },
|
||||
{ value: 'video', label: '视频' },
|
||||
{ value: 'script', label: '脚本' },
|
||||
{ value: 'export', label: '导出' },
|
||||
{ value: 'verify', label: '校验' },
|
||||
{ value: 'demo', label: '演练' },
|
||||
];
|
||||
|
||||
/** 状态筛选(M0 点亮的六态;已勾选 / 已导出 / 已入库由 B3 / M1 点亮) */
|
||||
export const TASK_STATUS_FILTERS: readonly { value: TaskStatusFilter; label: string }[] = [
|
||||
{ value: 'all', label: '全部状态' },
|
||||
{ value: 'queued', label: taskStatusLabel('queued') },
|
||||
{ value: 'running', label: taskStatusLabel('running') },
|
||||
{ value: 'partial_failed', label: taskStatusLabel('partial_failed') },
|
||||
{ value: 'pending_review', label: taskStatusLabel('pending_review') },
|
||||
{ value: 'failed', label: taskStatusLabel('failed') },
|
||||
{ value: 'canceled', label: taskStatusLabel('canceled') },
|
||||
];
|
||||
|
||||
export const TASK_DEMO_MODE_LABELS: Record<MediaTaskDemoMode, string> = {
|
||||
success: '全部成功',
|
||||
mixed: '混合成功 / 失败',
|
||||
fail: '全部失败',
|
||||
};
|
||||
|
||||
/** 任务类型图标:表格首列 / 详情任务头共用,避免在两处各写一套映射 */
|
||||
export const TASK_TYPE_ICONS: Record<TaskTypeIcon, TaskTypeIconComponent> = {
|
||||
task: IconTask,
|
||||
script: IconScript,
|
||||
image: IconMaterial,
|
||||
video: IconVideo,
|
||||
export: IconDownload,
|
||||
verify: IconShield,
|
||||
};
|
||||
|
||||
export function createDefaultTaskFilter(): TaskFilterDraft {
|
||||
return {
|
||||
type: 'all',
|
||||
status: 'all',
|
||||
customerId: '',
|
||||
timePreset: DEFAULT_TASK_TIME_PRESET,
|
||||
customFrom: '',
|
||||
customTo: '',
|
||||
keyword: '',
|
||||
};
|
||||
}
|
||||
|
||||
export function isTaskTypeFilter(value: string): value is TaskTypeFilter {
|
||||
return value === 'all' || TASK_TYPES.some((type) => type === value);
|
||||
}
|
||||
|
||||
export function isTaskStatusFilter(value: string): value is TaskStatusFilter {
|
||||
return value === 'all' || TASK_STATUSES.some((status) => status === value);
|
||||
}
|
||||
|
||||
export function isTaskTimePreset(value: string): value is TaskTimePreset {
|
||||
return value === 'today' || value === '7d' || value === '30d' || value === 'custom';
|
||||
}
|
||||
|
||||
/** 草稿 → URLSearchParams(仅序列化非默认值,便于分享 / 刷新还原) */
|
||||
export function serializeTaskFilters(draft: TaskFilterDraft): URLSearchParams {
|
||||
const params = new URLSearchParams();
|
||||
const keyword = draft.keyword.trim();
|
||||
if (draft.type !== 'all') params.set('type', draft.type);
|
||||
if (draft.status !== 'all') params.set('status', draft.status);
|
||||
if (draft.customerId !== '') params.set('customer', draft.customerId);
|
||||
if (draft.timePreset !== DEFAULT_TASK_TIME_PRESET) params.set('time', draft.timePreset);
|
||||
if (draft.timePreset === 'custom') {
|
||||
if (draft.customFrom !== '') params.set('from', draft.customFrom);
|
||||
if (draft.customTo !== '') params.set('to', draft.customTo);
|
||||
}
|
||||
if (keyword !== '') params.set('keyword', keyword);
|
||||
return params;
|
||||
}
|
||||
|
||||
/** URLSearchParams → 草稿(非法值回退默认,缺省时间范围为近 7 天) */
|
||||
export function parseTaskFilters(params: URLSearchParams): TaskFilterDraft {
|
||||
const typeRaw = params.get('type') ?? '';
|
||||
const statusRaw = params.get('status') ?? '';
|
||||
const timeRaw = params.get('time') ?? '';
|
||||
return {
|
||||
type: isTaskTypeFilter(typeRaw) ? typeRaw : 'all',
|
||||
status: isTaskStatusFilter(statusRaw) ? statusRaw : 'all',
|
||||
customerId: params.get('customer') ?? '',
|
||||
timePreset: isTaskTimePreset(timeRaw) ? timeRaw : DEFAULT_TASK_TIME_PRESET,
|
||||
customFrom: params.get('from') ?? '',
|
||||
customTo: params.get('to') ?? '',
|
||||
keyword: params.get('keyword') ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
function startOfDay(date: Date): Date {
|
||||
return new Date(date.getFullYear(), date.getMonth(), date.getDate(), 0, 0, 0, 0);
|
||||
}
|
||||
|
||||
function endOfDay(date: Date): Date {
|
||||
return new Date(date.getFullYear(), date.getMonth(), date.getDate(), 23, 59, 59, 999);
|
||||
}
|
||||
|
||||
function shiftDays(date: Date, days: number): Date {
|
||||
const next = new Date(date);
|
||||
next.setDate(next.getDate() + days);
|
||||
return next;
|
||||
}
|
||||
|
||||
/** `YYYY-MM-DD` → 本地当天 00:00;非法输入返回 null */
|
||||
export function parseDateInput(value: string): Date | null {
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return null;
|
||||
const date = new Date(`${value}T00:00:00`);
|
||||
return Number.isNaN(date.getTime()) ? null : date;
|
||||
}
|
||||
|
||||
/** Date → `YYYY-MM-DD`(本地时区) */
|
||||
export function formatDateInput(date: Date): string {
|
||||
const year = String(date.getFullYear());
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
/** 时间预设 / 自定义区间 → created_from / created_to ISO(自定义缺任意一端时不限制该端) */
|
||||
export function resolveTaskTimeRange(draft: TaskFilterDraft, now: Date): TaskTimeRange {
|
||||
switch (draft.timePreset) {
|
||||
case 'today':
|
||||
return { from: startOfDay(now).toISOString(), to: endOfDay(now).toISOString() };
|
||||
case '7d':
|
||||
return {
|
||||
from: startOfDay(shiftDays(now, -6)).toISOString(),
|
||||
to: endOfDay(now).toISOString(),
|
||||
};
|
||||
case '30d':
|
||||
return {
|
||||
from: startOfDay(shiftDays(now, -29)).toISOString(),
|
||||
to: endOfDay(now).toISOString(),
|
||||
};
|
||||
case 'custom': {
|
||||
const from = parseDateInput(draft.customFrom);
|
||||
const to = parseDateInput(draft.customTo);
|
||||
return {
|
||||
from: from === null ? null : startOfDay(from).toISOString(),
|
||||
to: to === null ? null : endOfDay(to).toISOString(),
|
||||
};
|
||||
}
|
||||
default:
|
||||
return { from: null, to: null };
|
||||
}
|
||||
}
|
||||
|
||||
/** 草稿 → 后端列表筛选(snake_case,空值 / 全部值剔除) */
|
||||
export function toMediaTaskFilter(draft: TaskFilterDraft, now: Date): MediaTaskQueryFilter {
|
||||
const filter: MediaTaskQueryFilter = {};
|
||||
const keyword = draft.keyword.trim();
|
||||
const range = resolveTaskTimeRange(draft, now);
|
||||
if (draft.type !== 'all') filter.type = draft.type;
|
||||
if (draft.status !== 'all') filter.status = draft.status;
|
||||
if (draft.customerId !== '') filter.customer_id = draft.customerId;
|
||||
if (range.from !== null) filter.created_from = range.from;
|
||||
if (range.to !== null) filter.created_to = range.to;
|
||||
if (keyword !== '') filter.keyword = keyword;
|
||||
return filter;
|
||||
}
|
||||
|
||||
/** 是否偏离默认筛选(用于区分「筛选无结果」与「还没有任务」) */
|
||||
export function taskFilterIsActive(draft: TaskFilterDraft): boolean {
|
||||
const defaults = createDefaultTaskFilter();
|
||||
return (
|
||||
draft.type !== defaults.type ||
|
||||
draft.status !== defaults.status ||
|
||||
draft.customerId !== defaults.customerId ||
|
||||
draft.timePreset !== defaults.timePreset ||
|
||||
draft.customFrom !== '' ||
|
||||
draft.customTo !== '' ||
|
||||
draft.keyword.trim() !== ''
|
||||
);
|
||||
}
|
||||
|
||||
/** 时间范围说明:筛选卡右侧提示 */
|
||||
export function describeTaskTimeRange(
|
||||
draft: TaskFilterDraft,
|
||||
now: Date,
|
||||
): { label: string; range: string } {
|
||||
const preset = TASK_TIME_PRESETS.find((item) => item.value === draft.timePreset);
|
||||
const label = preset?.label ?? '近 7 天';
|
||||
const resolved = resolveTaskTimeRange(draft, now);
|
||||
if (resolved.from === null && resolved.to === null) {
|
||||
return { label, range: draft.timePreset === 'custom' ? '未选择起止日期' : '不限时间' };
|
||||
}
|
||||
const from = resolved.from === null ? '不限' : formatDateInput(new Date(resolved.from));
|
||||
const to = resolved.to === null ? '不限' : formatDateInput(new Date(resolved.to));
|
||||
return { label, range: `${from} 至 ${to}` };
|
||||
}
|
||||
|
||||
/** 类型 chips 计数(只统计当前已加载任务) */
|
||||
export function countTasksByType(
|
||||
tasks: readonly MediaTaskSummary[],
|
||||
): Record<TaskTypeFilter, number> {
|
||||
const counts: Record<TaskTypeFilter, number> = {
|
||||
all: tasks.length,
|
||||
demo: 0,
|
||||
script: 0,
|
||||
image: 0,
|
||||
video: 0,
|
||||
export: 0,
|
||||
verify: 0,
|
||||
};
|
||||
for (const task of tasks) counts[task.type] += 1;
|
||||
return counts;
|
||||
}
|
||||
|
||||
/** 状态筛选计数(只统计当前已加载任务) */
|
||||
export function countTasksByStatus(
|
||||
tasks: readonly MediaTaskSummary[],
|
||||
): Record<TaskStatusFilter, number> {
|
||||
const counts: Record<TaskStatusFilter, number> = {
|
||||
all: tasks.length,
|
||||
queued: 0,
|
||||
running: 0,
|
||||
partial_failed: 0,
|
||||
pending_review: 0,
|
||||
selected: 0,
|
||||
exported: 0,
|
||||
archived: 0,
|
||||
failed: 0,
|
||||
canceled: 0,
|
||||
};
|
||||
for (const task of tasks) counts[task.status] += 1;
|
||||
return counts;
|
||||
}
|
||||
|
||||
/** 页头统计:全部 / 进行中 / 待处理失败 / 待预览(当前已加载列表推算) */
|
||||
export function computeTaskStats(tasks: readonly MediaTaskSummary[]): TaskListStats {
|
||||
let active = 0;
|
||||
let pendingFailed = 0;
|
||||
let pendingReview = 0;
|
||||
for (const task of tasks) {
|
||||
if (task.status === 'queued' || task.status === 'running') active += 1;
|
||||
else if (task.status === 'partial_failed' || task.status === 'failed') pendingFailed += 1;
|
||||
else if (task.status === 'pending_review') pendingReview += 1;
|
||||
}
|
||||
return { total: tasks.length, active, pendingFailed, pendingReview };
|
||||
}
|
||||
|
||||
/** 失败行号(升序去重);用于进度卡与重试确认 */
|
||||
export function failedSeqsOf(items: readonly MediaTaskItem[]): number[] {
|
||||
const seqs = new Set<number>();
|
||||
for (const item of items) {
|
||||
if (item.status === 'failed') seqs.add(item.seq);
|
||||
}
|
||||
return [...seqs].sort((left, right) => left - right);
|
||||
}
|
||||
|
||||
/**
|
||||
* 任务项追加:按 id 去重(后端游标分页不保证跨页不重叠)、保留最新状态并按行号升序。
|
||||
* SSE 只更新首屏已加载项,追加项以本次拉取结果为准。
|
||||
*/
|
||||
export function mergeTaskItems(
|
||||
current: readonly MediaTaskItem[],
|
||||
incoming: readonly MediaTaskItem[],
|
||||
): MediaTaskItem[] {
|
||||
const byId = new Map<string, MediaTaskItem>();
|
||||
for (const item of current) byId.set(item.id, item);
|
||||
for (const item of incoming) {
|
||||
const previous = byId.get(item.id);
|
||||
byId.set(
|
||||
item.id,
|
||||
previous === undefined
|
||||
? item
|
||||
: { ...item, attempts: Math.max(previous.attempts, item.attempts) },
|
||||
);
|
||||
}
|
||||
return [...byId.values()].sort((left, right) => left.seq - right.seq);
|
||||
}
|
||||
|
||||
/** 列表提交时间拆成 `MM-DD` + `HH:mm`(本地时区) */
|
||||
export function taskTimeParts(iso: string | undefined | null): TaskTimeParts {
|
||||
const text = formatDateTime(iso);
|
||||
if (text === '—') return { date: '', time: '—' };
|
||||
return { date: text.slice(5, 10), time: text.slice(11) };
|
||||
}
|
||||
|
||||
/** `payload` 快照里读取字符串字段(`Record<string, unknown>` 安全收窄) */
|
||||
export function readTaskPayloadText(payload: Record<string, unknown>, key: string): string | null {
|
||||
const value = payload[key];
|
||||
if (typeof value !== 'string') return null;
|
||||
const trimmed = value.trim();
|
||||
return trimmed === '' ? null : trimmed;
|
||||
}
|
||||
|
||||
/** `payload.mode`:仅演练任务有,非法值返回 null */
|
||||
export function readTaskDemoMode(payload: Record<string, unknown>): MediaTaskDemoMode | null {
|
||||
const value = payload.mode;
|
||||
if (value === 'success' || value === 'mixed' || value === 'fail') return value;
|
||||
return null;
|
||||
}
|
||||
|
||||
function timelineToneOfStatus(status: TaskStatus): TaskTimelineEntry['tone'] {
|
||||
if (status === 'partial_failed') return 'warn';
|
||||
if (status === 'failed') return 'bad';
|
||||
if (status === 'canceled') return 'muted';
|
||||
if (status === 'queued' || status === 'running') return 'live';
|
||||
return 'done';
|
||||
}
|
||||
|
||||
function lastFinishedAtOf(items: readonly MediaTaskItem[]): string | null {
|
||||
let latest: string | null = null;
|
||||
for (const item of items) {
|
||||
if (item.finishedAt === undefined) continue;
|
||||
if (latest === null || item.finishedAt > latest) latest = item.finishedAt;
|
||||
}
|
||||
return latest;
|
||||
}
|
||||
|
||||
function failureSummaryOf(items: readonly MediaTaskItem[]): string {
|
||||
const failed = items.filter((item) => item.status === 'failed');
|
||||
const first = failed[0];
|
||||
if (first === undefined) return '仅失败项可重试';
|
||||
const label = failureLabel(first.failureCode);
|
||||
return failed.length > 1
|
||||
? `${label} 等 ${failed.length} 项失败,仅失败项可重试`
|
||||
: `${label},仅失败项可重试`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 详情时间线:由任务状态时间字段推导(不新增后端字段):
|
||||
* 提交 → 开始执行 → 失败项 → 请求取消 → 任务收敛 / 进度更新中。
|
||||
*/
|
||||
// eslint-disable-next-line max-lines-per-function -- 时间线覆盖提交 / 执行 / 失败 / 取消 / 收敛五类事件与排序
|
||||
export function buildTaskTimeline(task: MediaTaskDetail): TaskTimelineEntry[] {
|
||||
const entries: TaskTimelineEntry[] = [
|
||||
{
|
||||
key: 'created',
|
||||
title: '提交任务',
|
||||
description: `${task.createdByName} 提交${taskTypeLabel(task.type)} ${String(task.itemTotal)} 项,预估 ${formatMoney(task.estimatedCost)} / ${formatSeconds(task.estimatedSeconds)}`,
|
||||
at: task.createdAt,
|
||||
tone: 'done',
|
||||
},
|
||||
];
|
||||
|
||||
if (task.status === 'queued' && task.startedAt === undefined) {
|
||||
entries.push({
|
||||
key: 'queued',
|
||||
title: '等待开始执行',
|
||||
description: '任务已提交,将按顺序开始执行;进度会自动保存,刷新不丢失。',
|
||||
at: null,
|
||||
tone: 'live',
|
||||
});
|
||||
}
|
||||
|
||||
if (task.startedAt !== undefined) {
|
||||
entries.push({
|
||||
key: 'started',
|
||||
title: '开始执行',
|
||||
description: '任务开始执行,正在逐项处理。',
|
||||
at: task.startedAt,
|
||||
tone: task.status === 'running' || task.status === 'queued' ? 'live' : 'done',
|
||||
});
|
||||
}
|
||||
|
||||
if (task.cancelRequestedAt !== undefined) {
|
||||
entries.push({
|
||||
key: 'cancel-requested',
|
||||
title: '请求取消',
|
||||
description: '取消只影响未开始项,已执行与已成功的任务项保留结果。',
|
||||
at: task.cancelRequestedAt,
|
||||
tone: 'warn',
|
||||
});
|
||||
}
|
||||
|
||||
if (task.itemFailed > 0) {
|
||||
const seqs = failedSeqsOf(task.items);
|
||||
entries.push({
|
||||
key: 'failures',
|
||||
title: `出现失败项(${String(task.itemFailed)} 项)`,
|
||||
description:
|
||||
seqs.length > 0
|
||||
? `失败行号 ${seqs.map((seq) => `#${String(seq)}`).join(' / ')};重试只重跑失败项,成功项不重复执行。`
|
||||
: failureSummaryOf(task.items),
|
||||
at: task.finishedAt ?? lastFinishedAtOf(task.items),
|
||||
tone: task.status === 'failed' ? 'bad' : 'warn',
|
||||
});
|
||||
}
|
||||
|
||||
if (task.finishedAt !== undefined) {
|
||||
entries.push({
|
||||
key: 'finished',
|
||||
title: `任务结束:${taskStatusLabel(task.status)}`,
|
||||
description: `成功 ${String(task.itemSucceeded)} 项 / 失败 ${String(task.itemFailed)} 项 / 跳过 ${String(task.itemSkipped)} 项。`,
|
||||
at: task.finishedAt,
|
||||
tone: timelineToneOfStatus(task.status),
|
||||
});
|
||||
} else if (task.status === 'running') {
|
||||
entries.push({
|
||||
key: 'progress',
|
||||
title: '进度更新中',
|
||||
description: `已成功 ${String(task.itemSucceeded)} / ${String(task.itemTotal)} 项,进度会自动更新。`,
|
||||
at: null,
|
||||
tone: 'live',
|
||||
});
|
||||
}
|
||||
|
||||
return entries.sort((left, right) => {
|
||||
if (left.at === null) return 1;
|
||||
if (right.at === null) return -1;
|
||||
if (left.at < right.at) return -1;
|
||||
return left.at > right.at ? 1 : 0;
|
||||
});
|
||||
}
|
||||
|
||||
/** 统一把接口异常转成提示文案(ApiError 保留 trace_id) */
|
||||
export function describeRequestError(error: unknown, fallback: string): RequestErrorInfo {
|
||||
if (error instanceof ApiError) return { message: error.message, traceId: error.traceId };
|
||||
if (error instanceof Error && error.message.trim() !== '') {
|
||||
return { message: error.message, traceId: undefined };
|
||||
}
|
||||
return { message: fallback, traceId: undefined };
|
||||
}
|
||||
@@ -99,6 +99,16 @@ const RunLogsPage = lazy(async () => {
|
||||
return { default: module.RunLogsPage };
|
||||
});
|
||||
|
||||
const TasksPage = lazy(async () => {
|
||||
const module = await import('@/pages/material/tasks/TasksPage');
|
||||
return { default: module.TasksPage };
|
||||
});
|
||||
|
||||
const TaskDetailPage = lazy(async () => {
|
||||
const module = await import('@/pages/material/tasks/TaskDetailPage');
|
||||
return { default: module.TaskDetailPage };
|
||||
});
|
||||
|
||||
function withSkeleton(node: ReactNode): ReactNode {
|
||||
return <Suspense fallback={<PageSkeleton rows={8} />}>{node}</Suspense>;
|
||||
}
|
||||
@@ -285,6 +295,22 @@ export const router = createBrowserRouter([
|
||||
</RequirePermission>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'material/tasks',
|
||||
element: (
|
||||
<RequirePermission permission={Permission.MATERIAL_READ}>
|
||||
{withSkeleton(<TasksPage />)}
|
||||
</RequirePermission>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'material/tasks/:id',
|
||||
element: (
|
||||
<RequirePermission permission={Permission.MATERIAL_READ}>
|
||||
{withSkeleton(<TaskDetailPage />)}
|
||||
</RequirePermission>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: ':moduleKey/:itemKey?',
|
||||
lazy: async () => ({
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
roleNewPath,
|
||||
ROUTES,
|
||||
sanitizeRedirect,
|
||||
taskDetailPath,
|
||||
userDetailPath,
|
||||
} from './routes';
|
||||
|
||||
@@ -41,6 +42,14 @@ describe('路由常量与登录回跳', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('A6 任务中心路由:列表 / 详情深链', () => {
|
||||
expect(ROUTES.material.tasks).toBe('/material/tasks');
|
||||
expect(ROUTES.material.taskDetail).toBe('/material/tasks/:id');
|
||||
expect(taskDetailPath('01HZZZZZZZZZZZZZZZZZZZZZZZ')).toBe(
|
||||
'/material/tasks/01HZZZZZZZZZZZZZZZZZZZZZZZ',
|
||||
);
|
||||
});
|
||||
|
||||
it('部门管理路由挂在管理中心下', () => {
|
||||
expect(ROUTES.system.departments).toBe('/system/departments');
|
||||
});
|
||||
|
||||
@@ -29,6 +29,11 @@ export const ROUTES = {
|
||||
runLogs: '/system/logs',
|
||||
settings: '/system/settings',
|
||||
},
|
||||
material: {
|
||||
root: '/material',
|
||||
tasks: '/material/tasks',
|
||||
taskDetail: '/material/tasks/:id',
|
||||
},
|
||||
unauthorized: '/401',
|
||||
forbidden: '/403',
|
||||
notFound: '/404',
|
||||
@@ -58,6 +63,9 @@ export type RoutePath =
|
||||
| typeof ROUTES.system.auditLogDetail
|
||||
| typeof ROUTES.system.runLogs
|
||||
| typeof ROUTES.system.settings
|
||||
| typeof ROUTES.material.root
|
||||
| typeof ROUTES.material.tasks
|
||||
| typeof ROUTES.material.taskDetail
|
||||
| typeof ROUTES.unauthorized
|
||||
| typeof ROUTES.forbidden
|
||||
| typeof ROUTES.notFound
|
||||
@@ -88,6 +96,11 @@ export function auditLogDetailPath(logId: string): string {
|
||||
return `${ROUTES.system.auditLogs}/${encodeURIComponent(logId)}`;
|
||||
}
|
||||
|
||||
/** 任务详情深链:`/material/tasks/{id}` */
|
||||
export function taskDetailPath(taskId: string): string {
|
||||
return `${ROUTES.material.tasks}/${encodeURIComponent(taskId)}`;
|
||||
}
|
||||
|
||||
/** 新建客户(四步向导) */
|
||||
export function customerNewPath(): string {
|
||||
return ROUTES.system.customerNew;
|
||||
|
||||
@@ -0,0 +1,678 @@
|
||||
/* A6 任务中心 · 详情页样式(任务头 / 进度卡 / 失败明细 / 任务项表 / 右栏信息 / 时间线)
|
||||
颜色只引用 packages/ui tokens(--lx-*);软色通过 color-mix 混入 token,不写颜色字面量。 */
|
||||
|
||||
/* ---------- 页面骨架 ---------- */
|
||||
|
||||
.lx-task-detail {
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: var(--lx-space-4);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.lx-task-detail__state {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 32px;
|
||||
border: 1px solid var(--lx-color-border);
|
||||
border-radius: var(--lx-radius-lg);
|
||||
background: var(--lx-color-bg-panel);
|
||||
box-shadow: var(--lx-shadow-panel);
|
||||
}
|
||||
|
||||
.lx-task-detail__body {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--lx-space-4);
|
||||
}
|
||||
|
||||
.lx-task-detail__main {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex: 1 1 66%;
|
||||
flex-direction: column;
|
||||
gap: var(--lx-space-4);
|
||||
}
|
||||
|
||||
.lx-task-detail__aside {
|
||||
display: flex;
|
||||
width: 320px;
|
||||
flex: 0 0 320px;
|
||||
flex-direction: column;
|
||||
gap: var(--lx-space-4);
|
||||
}
|
||||
|
||||
/* ---------- 任务头卡 ---------- */
|
||||
|
||||
.lx-task-head {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
gap: var(--lx-space-4);
|
||||
padding: 16px 18px;
|
||||
border: 1px solid var(--lx-color-border);
|
||||
border-radius: var(--lx-radius-lg);
|
||||
background: var(--lx-color-bg-panel);
|
||||
box-shadow: var(--lx-shadow-panel);
|
||||
}
|
||||
|
||||
.lx-task-head__main {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.lx-task-head__icon {
|
||||
display: inline-flex;
|
||||
width: 46px;
|
||||
height: 46px;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--lx-radius-lg);
|
||||
background: var(--lx-color-brand-soft);
|
||||
color: var(--lx-color-brand);
|
||||
}
|
||||
|
||||
.lx-task-head__icon--green {
|
||||
background: color-mix(in srgb, var(--lx-color-success) 12%, var(--lx-color-bg-panel));
|
||||
color: var(--lx-color-success);
|
||||
}
|
||||
|
||||
.lx-task-head__icon--violet {
|
||||
background: color-mix(in srgb, var(--lx-color-chart-4) 12%, var(--lx-color-bg-panel));
|
||||
color: var(--lx-color-chart-4);
|
||||
}
|
||||
|
||||
.lx-task-head__icon--orange {
|
||||
background: color-mix(in srgb, var(--lx-color-chart-3) 12%, var(--lx-color-bg-panel));
|
||||
color: var(--lx-color-chart-3);
|
||||
}
|
||||
|
||||
.lx-task-head__icon--grey {
|
||||
background: var(--lx-color-bg-hover);
|
||||
color: var(--lx-color-text-secondary);
|
||||
}
|
||||
|
||||
.lx-task-head__icon--teal {
|
||||
background: color-mix(in srgb, var(--lx-color-chart-2) 12%, var(--lx-color-bg-panel));
|
||||
color: var(--lx-color-chart-2);
|
||||
}
|
||||
|
||||
.lx-task-head__text {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.lx-task-head__title {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.lx-task-head__title h1 {
|
||||
overflow: hidden;
|
||||
color: var(--lx-color-text-primary);
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
line-height: 26px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.lx-task-head__meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--lx-space-2);
|
||||
flex-wrap: wrap;
|
||||
row-gap: 2px;
|
||||
margin-top: 3px;
|
||||
color: var(--lx-color-text-secondary);
|
||||
font-size: var(--lx-font-size-xs);
|
||||
}
|
||||
|
||||
.lx-task-head__meta i {
|
||||
color: var(--lx-color-border-strong);
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.lx-task-head__actions {
|
||||
display: flex;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
gap: var(--lx-space-2);
|
||||
}
|
||||
|
||||
/* 任务号复制行 */
|
||||
.lx-task-copyline {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.lx-task-copyline code {
|
||||
overflow: hidden;
|
||||
padding: 1px 5px;
|
||||
border-radius: var(--lx-radius-sm);
|
||||
background: var(--lx-color-bg-subtle);
|
||||
color: var(--lx-color-text-secondary);
|
||||
font-family: var(--lx-font-mono);
|
||||
font-size: var(--lx-font-size-xs);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.lx-task-icon-button {
|
||||
display: inline-flex;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: var(--lx-radius-sm);
|
||||
background: transparent;
|
||||
color: var(--lx-color-text-tertiary);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
color var(--lx-motion-fast) var(--lx-ease-out),
|
||||
background var(--lx-motion-fast) var(--lx-ease-out);
|
||||
}
|
||||
|
||||
.lx-task-icon-button:hover {
|
||||
background: var(--lx-color-bg-hover);
|
||||
color: var(--lx-color-brand);
|
||||
}
|
||||
|
||||
/* ---------- 通用卡片 ---------- */
|
||||
|
||||
.lx-task-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--lx-color-border);
|
||||
border-radius: var(--lx-radius-lg);
|
||||
background: var(--lx-color-bg-panel);
|
||||
box-shadow: var(--lx-shadow-panel);
|
||||
}
|
||||
|
||||
.lx-task-card__head {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
gap: var(--lx-space-2);
|
||||
min-height: 44px;
|
||||
padding: 0 16px;
|
||||
border-bottom: 1px solid var(--lx-color-border);
|
||||
}
|
||||
|
||||
.lx-task-card__head b {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--lx-color-text-primary);
|
||||
font-size: var(--lx-font-size-sm);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.lx-task-card__head b .semi-icon {
|
||||
color: var(--lx-color-text-secondary);
|
||||
}
|
||||
|
||||
.lx-task-card__hint {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
color: var(--lx-color-text-secondary);
|
||||
font-size: var(--lx-font-size-xs);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.lx-task-card__hint--right {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.lx-task-card__action {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.lx-task-card__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--lx-space-3);
|
||||
padding: 14px 16px;
|
||||
}
|
||||
|
||||
/* ---------- 进度卡 ---------- */
|
||||
|
||||
.lx-task-bigline {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.lx-task-bigline > b {
|
||||
color: var(--lx-color-text-primary);
|
||||
font-size: 26px;
|
||||
font-weight: 600;
|
||||
line-height: 32px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.lx-task-hint {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
color: var(--lx-color-text-secondary);
|
||||
font-size: var(--lx-font-size-xs);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.lx-task-hint--danger {
|
||||
color: var(--lx-color-danger);
|
||||
}
|
||||
|
||||
.lx-task-hint--right {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.lx-task-kvline {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: var(--lx-space-3);
|
||||
padding-top: var(--lx-space-3);
|
||||
border-top: 1px dashed var(--lx-color-border);
|
||||
}
|
||||
|
||||
.lx-task-kvline > div {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.lx-task-kvline span {
|
||||
color: var(--lx-color-text-tertiary);
|
||||
font-size: 11px;
|
||||
line-height: 16px;
|
||||
}
|
||||
|
||||
.lx-task-kvline b {
|
||||
color: var(--lx-color-text-primary);
|
||||
font-size: var(--lx-font-size-sm);
|
||||
font-weight: 500;
|
||||
line-height: 19px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* ---------- 失败明细 ---------- */
|
||||
|
||||
.lx-task-failbox {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid color-mix(in srgb, var(--lx-color-danger) 30%, var(--lx-color-border));
|
||||
border-radius: var(--lx-radius-md);
|
||||
background: color-mix(in srgb, var(--lx-color-danger) 5%, var(--lx-color-bg-panel));
|
||||
}
|
||||
|
||||
.lx-task-failbox__row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--lx-space-2);
|
||||
padding: 6px 0;
|
||||
border-bottom: 1px dashed color-mix(in srgb, var(--lx-color-danger) 18%, var(--lx-color-border));
|
||||
font-size: var(--lx-font-size-xs);
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.lx-task-failbox__row:last-of-type {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.lx-task-failbox__seq {
|
||||
flex: none;
|
||||
color: var(--lx-color-danger);
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.lx-task-failbox__text {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.lx-task-failbox__text b {
|
||||
color: var(--lx-color-text-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.lx-task-failbox__text span {
|
||||
color: var(--lx-color-text-secondary);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.lx-task-failbox__more {
|
||||
margin: 6px 0 0;
|
||||
color: var(--lx-color-text-secondary);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
/* ---------- 任务项表 ---------- */
|
||||
|
||||
.lx-task-itemchips {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.lx-task-itemchip {
|
||||
display: inline-flex;
|
||||
height: 26px;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid var(--lx-color-border);
|
||||
border-radius: var(--lx-radius-pill);
|
||||
background: var(--lx-color-bg-panel);
|
||||
color: var(--lx-color-text-secondary);
|
||||
font-size: var(--lx-font-size-xs);
|
||||
white-space: nowrap;
|
||||
transition:
|
||||
color var(--lx-motion-fast) var(--lx-ease-out),
|
||||
background var(--lx-motion-fast) var(--lx-ease-out),
|
||||
border-color var(--lx-motion-fast) var(--lx-ease-out);
|
||||
}
|
||||
|
||||
.lx-task-itemchip:hover {
|
||||
border-color: var(--lx-color-border-strong);
|
||||
background: var(--lx-color-bg-hover);
|
||||
color: var(--lx-color-text-primary);
|
||||
}
|
||||
|
||||
.lx-task-itemchip.is-active {
|
||||
border-color: var(--lx-color-focus);
|
||||
background: var(--lx-color-brand-soft);
|
||||
color: var(--lx-color-brand);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.lx-task-itemchip span {
|
||||
color: var(--lx-color-text-tertiary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.lx-task-itemchip.is-active span {
|
||||
color: var(--lx-color-brand);
|
||||
}
|
||||
|
||||
.lx-task-items-table {
|
||||
border: 1px solid var(--lx-color-border);
|
||||
border-radius: var(--lx-radius-md);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.lx-task-items-table .semi-table-thead .semi-table-row-head {
|
||||
background: var(--lx-color-bg-subtle);
|
||||
}
|
||||
|
||||
.lx-task-items-table tr.is-failed td {
|
||||
background: color-mix(in srgb, var(--lx-color-danger) 5%, var(--lx-color-bg-panel));
|
||||
}
|
||||
|
||||
.lx-task-fail-reason {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.lx-task-fail-reason b {
|
||||
color: var(--lx-color-danger);
|
||||
font-size: var(--lx-font-size-xs);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.lx-task-fail-reason span {
|
||||
overflow: hidden;
|
||||
color: var(--lx-color-text-secondary);
|
||||
font-size: 11px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.lx-task-inline-empty {
|
||||
padding: 26px 16px;
|
||||
color: var(--lx-color-text-tertiary);
|
||||
font-size: var(--lx-font-size-xs);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.lx-task-items-more {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--lx-space-3);
|
||||
color: var(--lx-color-text-secondary);
|
||||
font-size: var(--lx-font-size-xs);
|
||||
}
|
||||
|
||||
.lx-task-items-more__error {
|
||||
color: var(--lx-color-danger);
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
/* ---------- 右栏:任务信息 / 时间线 / 通道说明 ---------- */
|
||||
|
||||
.lx-task-meta {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.lx-task-meta__row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
font-size: var(--lx-font-size-xs);
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.lx-task-meta__row > span {
|
||||
width: 68px;
|
||||
flex: none;
|
||||
color: var(--lx-color-text-tertiary);
|
||||
}
|
||||
|
||||
.lx-task-meta__row > b {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
color: var(--lx-color-text-primary);
|
||||
font-weight: 500;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.lx-task-meta__row > b .lx-task-copyline code {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.lx-task-timeline {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 10px 16px 14px;
|
||||
}
|
||||
|
||||
.lx-task-timeline__item {
|
||||
position: relative;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.lx-task-timeline__item:not(:last-child)::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 22px;
|
||||
bottom: -6px;
|
||||
left: 5px;
|
||||
width: 1px;
|
||||
background: var(--lx-color-border);
|
||||
}
|
||||
|
||||
.lx-task-timeline__dot {
|
||||
z-index: 1;
|
||||
width: 11px;
|
||||
height: 11px;
|
||||
flex: none;
|
||||
margin-top: 3px;
|
||||
border: 2px solid var(--lx-color-border);
|
||||
border-radius: 50%;
|
||||
background: var(--lx-color-bg-panel);
|
||||
}
|
||||
|
||||
.lx-task-timeline__item--done .lx-task-timeline__dot {
|
||||
border-color: var(--lx-color-success);
|
||||
background: var(--lx-color-success);
|
||||
}
|
||||
|
||||
.lx-task-timeline__item--live .lx-task-timeline__dot {
|
||||
border-color: var(--lx-color-brand);
|
||||
background: var(--lx-color-brand);
|
||||
box-shadow: 0 0 0 4px var(--lx-color-brand-soft);
|
||||
}
|
||||
|
||||
.lx-task-timeline__item--warn .lx-task-timeline__dot {
|
||||
border-color: var(--lx-color-warning);
|
||||
background: var(--lx-color-warning);
|
||||
}
|
||||
|
||||
.lx-task-timeline__item--bad .lx-task-timeline__dot {
|
||||
border-color: var(--lx-color-danger);
|
||||
background: var(--lx-color-danger);
|
||||
}
|
||||
|
||||
.lx-task-timeline__item--muted .lx-task-timeline__dot {
|
||||
border-color: var(--lx-color-border-strong);
|
||||
background: var(--lx-color-border-strong);
|
||||
}
|
||||
|
||||
.lx-task-timeline__main {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.lx-task-timeline__main b {
|
||||
color: var(--lx-color-text-primary);
|
||||
font-size: var(--lx-font-size-xs);
|
||||
font-weight: 500;
|
||||
line-height: 17px;
|
||||
}
|
||||
|
||||
.lx-task-timeline__main span {
|
||||
color: var(--lx-color-text-secondary);
|
||||
font-size: 11px;
|
||||
line-height: 16px;
|
||||
}
|
||||
|
||||
.lx-task-timeline__main em {
|
||||
color: var(--lx-color-text-tertiary);
|
||||
font-size: 11px;
|
||||
font-style: normal;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.lx-task-note {
|
||||
margin: 0;
|
||||
color: var(--lx-color-text-secondary);
|
||||
font-size: var(--lx-font-size-xs);
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.lx-task-note b {
|
||||
color: var(--lx-color-text-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.lx-task-note__sse {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--lx-space-2);
|
||||
}
|
||||
|
||||
.lx-task-mono {
|
||||
font-family: var(--lx-font-mono);
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.lx-task-muted {
|
||||
color: var(--lx-color-text-tertiary);
|
||||
}
|
||||
|
||||
/* ---------- 1024 / 窄屏适配 ---------- */
|
||||
|
||||
@media (width <= 1280px) {
|
||||
.lx-task-kvline {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (width <= 1200px) {
|
||||
.lx-task-detail__body {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.lx-task-detail__main {
|
||||
flex: none;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.lx-task-detail__aside {
|
||||
width: 100%;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.lx-task-head {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.lx-task-head__actions {
|
||||
width: 100%;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.lx-task-head__title h1 {
|
||||
white-space: normal;
|
||||
}
|
||||
}
|
||||
|
||||
@media (width <= 1024px) {
|
||||
.lx-task-head__meta {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.lx-task-meta__row > span {
|
||||
width: 62px;
|
||||
}
|
||||
|
||||
.lx-task-items-more {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user