Files
lingxi/apps/web/src/pages/material/tasks/TaskSubmitModal.tsx
T
Stev_Wang a9ee5e6383 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。
2026-11-05 11:40:41 +08:00

245 lines
8.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 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>
);
}