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

@@ -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>
);
}