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