feat(web): A6 任务中心页面与可复用进度组件(方案 A)

背景:A6-FE-01/02,用户选定方案 A · 经典管理台;后端任务协议 / SSE / 取消 / 仅重试失败项已就绪,需要可用页面与可复用进度体验。
改动:
- 路由 /material/tasks、/material/tasks/:id(material:read;写操作按 material:render 渲染);侧栏「生产任务」更名「任务中心」;AppLayout 满高布局;详情面包屑与标题对齐。
- 页面:任务中心列表(统计、类型 chips、状态/客户/时间/关键字筛选 + URL 同步、游标加载更多、三态、行内取消/重试)、详情(任务头、失败优先进度、失败明细与行号、逐项表 + items 续拉、任务信息/时间线/进度更新/操作说明)、提交演练任务弹层(实时预估 + 复用 Idempotency-Key + 提交中禁用)、取消/重试二次确认。
- 组件:TaskTypeTag / TaskStatusTag / TaskItemStatusTag / TaskProgressBar / TaskMiniProgress / TaskProgressRing / TaskSseIndicator / TaskBadge + useTaskStream(fetch 流 + Last-Event-ID + 重连 + 5s 轮询兜底);顶栏角标含待处理失败直达,完成/失败通知。
- API/hooks:media.ts(提交 202 + Idempotency-Key、列表、详情、取消、重试、SSE 流)、use-media-tasks(游标 / 详情 / 角标 / 待处理失败 / 提交取消重试 mutation / 预估配置);client 增加流式请求与 401 刷新;后台轮询失败不弹全局 toast。
- 样式:tasks.css / task-detail.css 全部使用 --lx-* token;行高 56、表格撑满、1024 保留失败统计;按独立视觉评审完成两轮修正(失败数前置、去工程化文案、状态色统一、去重复入口与计数、去掉禁用入口)。
影响面:新增 2 个受权限保护的路由与 1 个顶栏角标入口;未改其他页面行为。
回滚:撤销提交即可,后端接口与旧页面不受影响。
验证:web typecheck / lint / test(37 文件 238 用例,覆盖率 91.7%)/ build 全绿;A6 浏览器 e2e 6/6;真实 API 截图见 design/a6-ui-v1/shots/impl/real。
This commit is contained in:
Stev_Wang committed 2026-11-05 11:40:41 +08:00
1 parent b377618ff9
commit a9ee5e6383
40 files changed
+7299 -13

No files matched your search

+43
View File
@@ -205,3 +205,46 @@ async function send<T>(path: string, options: RequestOptions, allowRefresh: bool
export function request<T>(path: string, options: RequestOptions = {}): Promise<T> { export function request<T>(path: string, options: RequestOptions = {}): Promise<T> {
return send<T>(path, options, true); return send<T>(path, options, true);
} }
export interface StreamRequestOptions {
headers?: Record<string, string>;
signal?: AbortSignal;
}
/**
* 流式请求入口(A6 SSE 进度流):与 `request` 相同的鉴权与 401 单飞刷新语义,
* 但返回原始 `Response`,由调用方读取 `body` reader(不解析 JSON、不缓存)。
*/
export async function streamRequest(
path: string,
options: StreamRequestOptions = {},
allowRefresh = true,
): Promise<Response> {
const { accessToken } = useAuthStore.getState();
const headers = new Headers(options.headers);
if (accessToken !== null) {
headers.set('Authorization', `Bearer ${accessToken}`);
}
let response: Response;
try {
response = await fetch(buildUrl(path), {
method: 'GET',
headers,
signal: options.signal,
});
} catch (error) {
// 用户主动取消(组件卸载)不当作错误上报
if (error instanceof DOMException && error.name === 'AbortError') throw error;
throw new ApiError({ code: 50001, status: 0, message: '网络连接中断,请检查网络后重试' });
}
if (response.status === 401 && allowRefresh) {
const refreshed = await refreshSession();
if (refreshed) {
return streamRequest(path, options, false);
}
}
if (!response.ok) {
throw await toApiError(response);
}
return response;
}
+9
View File
@@ -7,6 +7,7 @@ const USERS = '/users';
const DICTS = '/dicts'; const DICTS = '/dicts';
const SYSTEM_CONFIGS = '/system/configs'; const SYSTEM_CONFIGS = '/system/configs';
const AUDIT_LOGS = '/audit-logs'; const AUDIT_LOGS = '/audit-logs';
const MEDIA_TASKS = '/media-tasks';
export const ENDPOINTS = { export const ENDPOINTS = {
login: '/auth/login', login: '/auth/login',
@@ -75,4 +76,12 @@ export const ENDPOINTS = {
`${AUDIT_LOGS}/exports/${encodeURIComponent(exportId)}`, `${AUDIT_LOGS}/exports/${encodeURIComponent(exportId)}`,
auditExportDownload: (exportId: string): string => auditExportDownload: (exportId: string): string =>
`${AUDIT_LOGS}/exports/${encodeURIComponent(exportId)}/download`, `${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; } as const;
+198
View File
@@ -0,0 +1,198 @@
import type { MediaTaskDetail, MediaTaskListResponse } from '@lingxi/shared';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { useAuthStore } from '@/stores/auth-store';
import { setUnauthorizedHandler } from './client';
import {
cancelMediaTask,
createTaskIdempotencyKey,
fetchMediaTaskDetail,
fetchMediaTasks,
openMediaTaskEvents,
retryMediaTask,
submitMediaTask,
} from './media';
const TASK_ID = '01JCNB2M8K4Q7T3F9V6W1X0Y5Z';
const CUSTOMER_ID = '01JCP0CST00000000000000001';
const NOW = '2026-11-04T12:21:00.000Z';
const jsonResponse = (body: unknown, status = 200): Response =>
new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
});
const summary = {
id: TASK_ID,
type: 'demo' as const,
status: 'running' as const,
title: '花间集 · 任务协议演练 8 项',
customerId: CUSTOMER_ID,
customerName: '花间集',
itemTotal: 8,
itemSucceeded: 3,
itemFailed: 0,
itemSkipped: 0,
progress: 38,
estimatedItems: 8,
estimatedCost: 0.16,
estimatedSeconds: 24,
createdBy: '01JCP0SER00000000000000001',
createdByName: '江岚',
createdAt: NOW,
};
const list: MediaTaskListResponse = { items: [summary], next_cursor: null };
const detail: MediaTaskDetail = {
...summary,
payload: { mode: 'mixed' },
items: [
{
id: '01JCN1TEM00000000000000001',
seq: 1,
status: 'pending_review',
attempts: 1,
startedAt: NOW,
finishedAt: NOW,
},
],
itemsNextCursor: null,
};
function lastCall(): { url: string; init: RequestInit | undefined } {
const fetchMock = vi.mocked(globalThis.fetch);
const call = fetchMock.mock.calls.at(-1);
const input = call?.[0];
const url =
typeof input === 'string' ? input : input instanceof URL ? input.href : (input?.url ?? '');
return { url, init: call?.[1] };
}
function lastHeaders(): Headers {
return new Headers(lastCall().init?.headers);
}
beforeEach(() => {
vi.restoreAllMocks();
window.localStorage.clear();
useAuthStore.getState().clearSession();
setUnauthorizedHandler(null);
useAuthStore.getState().setSession({
accessToken: 'token',
user: { id: '01JCP0SER00000000000000001', name: '江岚', account: 'jiang.lan' },
permissions: ['material:read', 'material:render'],
});
});
describe('任务中心接口封装', () => {
it('列表 query 使用 snake_case,剔除空值并带默认 limit', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(jsonResponse(list));
const response = await fetchMediaTasks({
type: 'demo',
status: 'running',
customer_id: CUSTOMER_ID,
keyword: '演练',
mine: 'true',
limit: 20,
});
expect(response.items).toHaveLength(1);
const url = lastCall().url;
expect(url).toContain('/media-tasks?');
for (const part of ['type=demo', 'status=running', 'customer_id=', 'keyword=', 'mine=true']) {
expect(url).toContain(part);
}
expect(url).not.toContain('undefined');
});
it('列表默认 limit=20,空筛选不会带出空参数', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
jsonResponse({ items: [], next_cursor: null }),
);
await fetchMediaTasks({});
const url = lastCall().url;
expect(url).toContain('limit=20');
expect(url).not.toContain('status=');
});
it('详情带 items_cursor,响应经 Zod parse', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(jsonResponse(detail));
const result = await fetchMediaTaskDetail(TASK_ID, { items_cursor: 'item:101' });
expect(lastCall().url).toContain(`/media-tasks/${TASK_ID}?items_cursor=item%3A101`);
expect(result.items).toHaveLength(1);
expect(result.payload).toEqual({ mode: 'mixed' });
});
it('提交:body camelCase + Idempotency-Key 头,202 响应解析', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
jsonResponse(
{ taskId: TASK_ID, estimatedItems: 8, estimatedCost: 0.16, estimatedSeconds: 24 },
202,
),
);
const result = await submitMediaTask(
{ type: 'demo', customerId: CUSTOMER_ID, itemCount: 8, mode: 'mixed', note: '验证重试' },
'idem-key-0001',
);
expect(result.taskId).toBe(TASK_ID);
expect(lastCall().init?.method).toBe('POST');
expect(lastCall().url).toContain('/media-tasks');
expect(lastHeaders().get('Idempotency-Key')).toBe('idem-key-0001');
const body = lastCall().init?.body;
expect(typeof body).toBe('string');
expect(JSON.parse(body as string)).toEqual({
type: 'demo',
customerId: CUSTOMER_ID,
itemCount: 8,
mode: 'mixed',
note: '验证重试',
});
});
it('提交入参非法(itemCount 超硬上限)在请求层被拒绝', async () => {
const fetchMock = vi.spyOn(globalThis, 'fetch');
await expect(
submitMediaTask(
{ type: 'demo', customerId: CUSTOMER_ID, itemCount: 101, mode: 'success' },
'idem-key-0002',
),
).rejects.toBeTruthy();
expect(fetchMock).not.toHaveBeenCalled();
});
it('取消 / 重试走 POST 动作端点并解析响应', async () => {
vi.spyOn(globalThis, 'fetch')
.mockResolvedValueOnce(jsonResponse({ taskId: TASK_ID, status: 'canceled' }))
.mockResolvedValueOnce(jsonResponse({ taskId: TASK_ID, status: 'queued', retriedItems: 2 }));
const canceled = await cancelMediaTask(TASK_ID);
expect(canceled.status).toBe('canceled');
expect(lastCall().url).toContain(`/media-tasks/${TASK_ID}/cancel`);
const retried = await retryMediaTask(TASK_ID);
expect(retried.retriedItems).toBe(2);
expect(lastCall().url).toContain(`/media-tasks/${TASK_ID}/retry`);
});
it('SSE:带 Authorization 与 Last-Event-ID 头,返回原始流', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
new Response(null, { status: 200, headers: { 'Content-Type': 'text/event-stream' } }),
);
const response = await openMediaTaskEvents(TASK_ID, { lastEventId: '42' });
expect(response.status).toBe(200);
const url = lastCall().url;
expect(url).toContain(`/media-tasks/${TASK_ID}/events`);
expect(url).toContain('last_event_id=42');
expect(lastHeaders().get('Authorization')).toBe('Bearer token');
expect(lastHeaders().get('Accept')).toBe('text/event-stream');
});
it('幂等键满足 8–128 位可见 ASCII 且有随机性', () => {
const first = createTaskIdempotencyKey();
const second = createTaskIdempotencyKey();
expect(first).not.toBe(second);
expect(first.length).toBeGreaterThanOrEqual(8);
expect(first.length).toBeLessThanOrEqual(128);
expect(/^[\x21-\x7E]+$/.test(first)).toBe(true);
});
});
+126
View File
@@ -0,0 +1,126 @@
/**
* A6 任务中心接口(唯一出处)。
*
* 依据:packages/shared/src/schemas/tasks.ts、技术架构 §6.4(异步任务协议)。
* 约定:query 用 snake_case(C-01);提交必须带 `Idempotency-Key`,响应字段固定
* `{ taskId, estimatedItems, estimatedCost, estimatedSeconds }`;列表 `{ items, next_cursor }`。
* 所有响应 / 入参都用 shared Zod schema parse,契约漂移在请求层直接暴露。
*/
import {
type MediaTaskCancelResponse,
mediaTaskCancelResponseSchema,
type MediaTaskDetail,
type MediaTaskDetailQuery,
mediaTaskDetailQuerySchema,
mediaTaskDetailSchema,
type MediaTaskListQuery,
mediaTaskListQuerySchema,
type MediaTaskListResponse,
mediaTaskListResponseSchema,
type MediaTaskRetryResponse,
mediaTaskRetryResponseSchema,
type MediaTaskSubmitInput,
type MediaTaskSubmitParsed,
type MediaTaskSubmitResponse,
mediaTaskSubmitResponseSchema,
mediaTaskSubmitSchema,
} from '@lingxi/shared';
import { request, streamRequest } from './client';
import { ENDPOINTS } from './endpoints';
function searchParams(entries: Record<string, string | number | undefined>): string {
const params = new URLSearchParams();
for (const [key, value] of Object.entries(entries)) {
if (value !== undefined && value !== '') params.set(key, String(value));
}
const search = params.toString();
return search === '' ? '' : `?${search}`;
}
/** 任务提交幂等键:8–128 位可见 ASCII(后端强校验);随机 UUID 足够且不落库敏感信息 */
export function createTaskIdempotencyKey(): string {
if (typeof crypto.randomUUID === 'function') {
return `task-${crypto.randomUUID()}`;
}
const random = Math.random().toString(36).slice(2);
return `task-${Date.now().toString(36)}-${random}`;
}
/** 跨类型任务列表:类型 / 状态 / 客户 / 时间 / 我的 / 关键字 + 游标分页 */
export async function fetchMediaTasks(
query: Partial<MediaTaskListQuery> = {},
): Promise<MediaTaskListResponse> {
const parsed = mediaTaskListQuerySchema.parse(query);
const payload: unknown = await request(
`${ENDPOINTS.mediaTasks}${searchParams({
type: parsed.type,
status: parsed.status,
customer_id: parsed.customer_id,
created_from: parsed.created_from,
created_to: parsed.created_to,
mine: parsed.mine,
keyword: parsed.keyword,
cursor: parsed.cursor,
limit: parsed.limit,
})}`,
);
return mediaTaskListResponseSchema.parse(payload);
}
/** 任务详情:摘要 + payload 快照 + 任务项首页(默认 100 条)+ itemsNextCursor */
export async function fetchMediaTaskDetail(
taskId: string,
query: Partial<MediaTaskDetailQuery> = {},
): Promise<MediaTaskDetail> {
const parsed = mediaTaskDetailQuerySchema.parse(query);
const payload: unknown = await request(
`${ENDPOINTS.mediaTask(taskId)}${searchParams({ items_cursor: parsed.items_cursor })}`,
);
return mediaTaskDetailSchema.parse(payload);
}
/**
* 提交任务(M0 仅演练任务)。幂等键由调用方生成并在重试 / 双击时复用:
* 同 key 同 body 24h 内返回同一任务,不同 body 返回 40902。
*/
export async function submitMediaTask(
input: MediaTaskSubmitInput,
idempotencyKey: string,
): Promise<MediaTaskSubmitResponse> {
const body: MediaTaskSubmitParsed = mediaTaskSubmitSchema.parse(input);
const payload: unknown = await request(ENDPOINTS.mediaTasks, {
method: 'POST',
body,
headers: { 'Idempotency-Key': idempotencyKey },
});
return mediaTaskSubmitResponseSchema.parse(payload);
}
/** 取消任务:只影响未开始项,已执行 / 已成功项保留结果 */
export async function cancelMediaTask(taskId: string): Promise<MediaTaskCancelResponse> {
const payload: unknown = await request(ENDPOINTS.mediaTaskCancel(taskId), { method: 'POST' });
return mediaTaskCancelResponseSchema.parse(payload);
}
/** 仅重试失败项:成功项不重复执行、不重复计费 */
export async function retryMediaTask(taskId: string): Promise<MediaTaskRetryResponse> {
const payload: unknown = await request(ENDPOINTS.mediaTaskRetry(taskId), { method: 'POST' });
return mediaTaskRetryResponseSchema.parse(payload);
}
/**
* 打开任务 SSE 事件流(fetch 流,支持 Authorization 头)。
* 断线续推用 `Last-Event-ID` 头(后端优先)或 `last_event_id` query;由调用方负责重连与降级。
*/
export function openMediaTaskEvents(
taskId: string,
options: { lastEventId?: string; signal?: AbortSignal } = {},
): Promise<Response> {
const query =
options.lastEventId === undefined ? '' : searchParams({ last_event_id: options.lastEventId });
return streamRequest(`${ENDPOINTS.mediaTaskEvents(taskId)}${query}`, {
headers: { Accept: 'text/event-stream' },
...(options.signal === undefined ? {} : { signal: options.signal }),
});
}
+18 -4
View File
@@ -1,9 +1,15 @@
import { notifyError } from '@lingxi/ui'; import { 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'; 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 instanceof ApiError) {
if (error.status === 401) { if (error.status === 401) {
return; return;
@@ -25,8 +31,16 @@ function shouldRetry(failureCount: number, error: unknown): boolean {
} }
export const queryClient = new QueryClient({ export const queryClient = new QueryClient({
queryCache: new QueryCache({ onError: reportError }), queryCache: new QueryCache({
mutationCache: new MutationCache({ onError: reportError }), onError: (error, query) => {
reportError(error, query);
},
}),
mutationCache: new MutationCache({
onError: (error) => {
reportError(error);
},
}),
defaultOptions: { defaultOptions: {
queries: { queries: {
retry: shouldRetry, retry: shouldRetry,
+5
View File
@@ -43,4 +43,9 @@ export const queryKeys = {
auditExports: ['audit-exports'] as const, auditExports: ['audit-exports'] as const,
auditExportList: (query: object) => ['audit-exports', 'list', query] as const, auditExportList: (query: object) => ['audit-exports', 'list', query] as const,
auditExport: (exportId: string) => ['audit-exports', 'detail', exportId] 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,
}; };
+191
View File
@@ -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>
);
}
+13
View File
@@ -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();
});
});
+152
View File
@@ -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;
}
+160
View File
@@ -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 };
}
+2
View File
@@ -80,10 +80,12 @@ export function useIndustryDetailQuery(id: string | null): UseQueryResult<Indust
export function useCustomersQuery( export function useCustomersQuery(
query: Partial<CustomerListQuery> = {}, query: Partial<CustomerListQuery> = {},
options: { enabled?: boolean } = {},
): UseQueryResult<{ items: CustomerSummary[]; next_cursor: string | null }> { ): UseQueryResult<{ items: CustomerSummary[]; next_cursor: string | null }> {
return useQuery({ return useQuery({
queryKey: queryKeys.customerList(query), queryKey: queryKeys.customerList(query),
queryFn: () => fetchCustomerList(query), queryFn: () => fetchCustomerList(query),
enabled: options.enabled ?? true,
placeholderData: keepPreviousData, placeholderData: keepPreviousData,
staleTime: 10_000, 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 };
}
+3 -1
View File
@@ -22,7 +22,9 @@ function isFlushRoute(pathname: string): boolean {
pathname === ROUTES.system.settings || pathname === ROUTES.system.settings ||
pathname === ROUTES.system.auditLogs || pathname === ROUTES.system.auditLogs ||
pathname.startsWith(`${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}/`)
); );
} }
+2
View File
@@ -4,6 +4,7 @@ import { Link } from 'react-router';
import { import {
getAuditSectionTrail, getAuditSectionTrail,
getRoleSectionTrail, getRoleSectionTrail,
getTaskSectionTrail,
getUserSectionTrail, getUserSectionTrail,
} from '@/navigation/breadcrumbs'; } from '@/navigation/breadcrumbs';
import { getBreadcrumbs, NAV_MODULES } from '@/navigation/nav-config'; import { getBreadcrumbs, NAV_MODULES } from '@/navigation/nav-config';
@@ -17,6 +18,7 @@ export function PageBreadcrumbs({ pathname }: PageBreadcrumbsProps): ReactNode {
getUserSectionTrail(pathname) ?? getUserSectionTrail(pathname) ??
getRoleSectionTrail(pathname) ?? getRoleSectionTrail(pathname) ??
getAuditSectionTrail(pathname) ?? getAuditSectionTrail(pathname) ??
getTaskSectionTrail(pathname) ??
getBreadcrumbs(NAV_MODULES, pathname); getBreadcrumbs(NAV_MODULES, pathname);
return ( return (
<nav className="lx-breadcrumbs" aria-label="面包屑"> <nav className="lx-breadcrumbs" aria-label="面包屑">
+3 -5
View File
@@ -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 { type ReactNode, useEffect, useRef, useState } from 'react';
import { useLocation, useNavigate } from 'react-router'; import { useLocation, useNavigate } from 'react-router';
import { TaskBadge } from '@/components/tasks/TaskBadge';
import { useLogout } from '@/hooks/use-logout'; import { useLogout } from '@/hooks/use-logout';
import type { NavModule } from '@/navigation/nav-config'; import type { NavModule } from '@/navigation/nav-config';
import { useAuthStore } from '@/stores/auth-store'; 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="全局搜索"> <button type="button" className="lx-icon-button" aria-label="全局搜索" title="全局搜索">
<IconSearch size={18} /> <IconSearch size={18} />
</button> </button>
<button type="button" className="lx-icon-button" aria-label="任务通知" title="任务通知"> <TaskBadge />
<IconNotification size={18} />
<span className="lx-icon-button__dot" aria-hidden="true" />
</button>
<UserMenu /> <UserMenu />
</div> </div>
</header> </header>
+2
View File
@@ -14,6 +14,8 @@ import '@/styles/dicts.css';
import '@/styles/settings.css'; import '@/styles/settings.css';
import '@/styles/audit.css'; import '@/styles/audit.css';
import '@/styles/run-logs.css'; import '@/styles/run-logs.css';
import '@/styles/tasks.css';
import '@/styles/task-detail.css';
import { LocaleProvider } from '@douyinfe/semi-ui-19'; import { LocaleProvider } from '@douyinfe/semi-ui-19';
import { localeZhCN } from '@lingxi/ui'; import { localeZhCN } from '@lingxi/ui';
+13 -1
View File
@@ -1,6 +1,11 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { getAuditSectionTrail, getRoleSectionTrail, getUserSectionTrail } from './breadcrumbs'; import {
getAuditSectionTrail,
getRoleSectionTrail,
getTaskSectionTrail,
getUserSectionTrail,
} from './breadcrumbs';
describe('面包屑子路由补全', () => { describe('面包屑子路由补全', () => {
it('角色新建页:工作台 / 管理中心 / 角色管理 / 新建角色', () => { it('角色新建页:工作台 / 管理中心 / 角色管理 / 新建角色', () => {
@@ -41,6 +46,13 @@ describe('面包屑子路由补全', () => {
expect(getAuditSectionTrail('/system/logs')).toBeNull(); 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('用户子路由补全不受影响', () => { it('用户子路由补全不受影响', () => {
expect(getUserSectionTrail('/system/users/new')?.map((entry) => entry.label)).toEqual([ expect(getUserSectionTrail('/system/users/new')?.map((entry) => entry.label)).toEqual([
'工作台', '工作台',
+16
View File
@@ -47,6 +47,22 @@ export function getUserSectionTrail(pathname: string): BreadcrumbEntry[] | null
return 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 菜单项里,这里补齐:管理中心 / 操作日志 / 日志详情 */ /** 操作日志详情不在 nav-config 菜单项里,这里补齐:管理中心 / 操作日志 / 日志详情 */
export function getAuditSectionTrail(pathname: string): BreadcrumbEntry[] | null { export function getAuditSectionTrail(pathname: string): BreadcrumbEntry[] | null {
if (/^\/system\/audit-logs\/[^/]+$/.test(pathname)) { if (/^\/system\/audit-logs\/[^/]+$/.test(pathname)) {
+2 -2
View File
@@ -143,11 +143,11 @@ export const NAV_MODULES: readonly NavModule[] = [
}, },
{ {
key: 'tasks', key: 'tasks',
label: '生产任务', label: '任务中心',
path: '/material/tasks', path: '/material/tasks',
icon: IconTask, icon: IconTask,
permission: Permission.MATERIAL_READ, permission: Permission.MATERIAL_READ,
description: '任务进度、失败原因与重试', description: '跨类型任务进度、失败明细与取消 / 重试',
}, },
{ {
key: 'quick-review', 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 };
}
+26
View File
@@ -99,6 +99,16 @@ const RunLogsPage = lazy(async () => {
return { default: module.RunLogsPage }; 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 { function withSkeleton(node: ReactNode): ReactNode {
return <Suspense fallback={<PageSkeleton rows={8} />}>{node}</Suspense>; return <Suspense fallback={<PageSkeleton rows={8} />}>{node}</Suspense>;
} }
@@ -285,6 +295,22 @@ export const router = createBrowserRouter([
</RequirePermission> </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?', path: ':moduleKey/:itemKey?',
lazy: async () => ({ lazy: async () => ({
+9
View File
@@ -7,6 +7,7 @@ import {
roleNewPath, roleNewPath,
ROUTES, ROUTES,
sanitizeRedirect, sanitizeRedirect,
taskDetailPath,
userDetailPath, userDetailPath,
} from './routes'; } 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('部门管理路由挂在管理中心下', () => { it('部门管理路由挂在管理中心下', () => {
expect(ROUTES.system.departments).toBe('/system/departments'); expect(ROUTES.system.departments).toBe('/system/departments');
}); });
+13
View File
@@ -29,6 +29,11 @@ export const ROUTES = {
runLogs: '/system/logs', runLogs: '/system/logs',
settings: '/system/settings', settings: '/system/settings',
}, },
material: {
root: '/material',
tasks: '/material/tasks',
taskDetail: '/material/tasks/:id',
},
unauthorized: '/401', unauthorized: '/401',
forbidden: '/403', forbidden: '/403',
notFound: '/404', notFound: '/404',
@@ -58,6 +63,9 @@ export type RoutePath =
| typeof ROUTES.system.auditLogDetail | typeof ROUTES.system.auditLogDetail
| typeof ROUTES.system.runLogs | typeof ROUTES.system.runLogs
| typeof ROUTES.system.settings | typeof ROUTES.system.settings
| typeof ROUTES.material.root
| typeof ROUTES.material.tasks
| typeof ROUTES.material.taskDetail
| typeof ROUTES.unauthorized | typeof ROUTES.unauthorized
| typeof ROUTES.forbidden | typeof ROUTES.forbidden
| typeof ROUTES.notFound | typeof ROUTES.notFound
@@ -88,6 +96,11 @@ export function auditLogDetailPath(logId: string): string {
return `${ROUTES.system.auditLogs}/${encodeURIComponent(logId)}`; 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 { export function customerNewPath(): string {
return ROUTES.system.customerNew; return ROUTES.system.customerNew;
+678
View File
@@ -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