背景: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。
245 lines
8.8 KiB
TypeScript
245 lines
8.8 KiB
TypeScript
/* 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>
|
||
);
|
||
}
|