feat: 任务轮询 + Token刷新容错 + 提交防抖 + 文件上传Hook (0.0.20)
### 任务状态轮询 - 新增 useTaskPolling hook:批量轮询非终态任务,递归 setTimeout + next_poll_ms 自适应间隔 - TaskHistory 集成批量轮询,OutputPreview 移除独立轮询改为状态转换监听 - batchTaskStatus 修复数组参数序列化(GET + 逗号拼接,避免 bracket 格式) - 统一 POLLING_STATUSES 常量管理非终态状态 ### Token 刷新容错 - 双轨刷新:主动续期(JWT exp 75% 时间点 setTimeout)+ 被动兜底(handle401) - 刷新失败分级:永久失败(throw RefreshTokenExpiredError)→清token弹登录;临时失败(return null)→仅拒绝当前请求 - AppProvider 登录/自动登录/退出 启停主动刷新定时器 ### 提交按钮防抖 - 2s 节流防抖("你点击的太快了")+ 二次确认弹窗("您已提交,确认要再次提交吗?") - 修复 useAsyncMutation.execute 返回值判空(原 try/catch 死代码) ### 文件上传 Hook - 新增 useFileUpload hook:封装 request.upload() 为 antd Upload 兼容 customRequest - ImageInput/ImageListInput/AudioListInput 集成 customRequest - uploadValidation beforeUpload 返回值修正(false→true) ### 其他 - AnnouncementDrawer 接入公告 API + EnumResolver - billing/announcement/banner 服务模块完善 - CLAUDE.md 补充架构约定文档
This commit is contained in:
@@ -10,7 +10,7 @@
|
||||
// - 本组件只负责:布局、Form 容器、提交逻辑、依赖禁用
|
||||
// ============================================================
|
||||
|
||||
import { useState, useLayoutEffect, useMemo, useCallback } from 'react';
|
||||
import { useState, useLayoutEffect, useMemo, useCallback, useRef } from 'react';
|
||||
import {
|
||||
Form,
|
||||
Input,
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
Space,
|
||||
theme as antTheme,
|
||||
message,
|
||||
Modal,
|
||||
} from 'antd';
|
||||
import type { FormInstance } from 'antd';
|
||||
import { SendOutlined, ClockCircleOutlined, ReloadOutlined } from '@ant-design/icons';
|
||||
@@ -147,6 +148,16 @@ export function ModelInputForm() {
|
||||
}, []),
|
||||
});
|
||||
|
||||
// -------- 提交防抖 & 二次确认 Ref --------
|
||||
|
||||
/** 上次点击提交按钮的时间戳(毫秒),用于 2s 防抖节流 */
|
||||
const lastClickTimeRef = useRef(0);
|
||||
/** 已成功提交次数,用于判断是否需要"再次提交确认"弹窗 */
|
||||
const submitCountRef = useRef(0);
|
||||
/** 最新错误信息的 ref(避免 doSubmit useCallback 闭包过期) */
|
||||
const errorMessageRef = useRef(errorMessage);
|
||||
errorMessageRef.current = errorMessage;
|
||||
|
||||
// ======== useModelUI:param_schema → UIFieldConfig[] ========
|
||||
|
||||
const fields: UIFieldConfig[] = useModelUI(
|
||||
@@ -190,54 +201,96 @@ export function ModelInputForm() {
|
||||
|
||||
// ======== 提交 ========
|
||||
|
||||
const handleSubmit = async () => {
|
||||
/**
|
||||
* 实际提交逻辑(在防抖检查和二次确认之后执行)。
|
||||
* 用 useCallback 稳定化,避免 handleSubmit 因闭包过期而读到旧 fields/values。
|
||||
*/
|
||||
const doSubmit = useCallback(async () => {
|
||||
if (!selectedModel) return;
|
||||
|
||||
let values: Record<string, unknown>;
|
||||
try {
|
||||
values = await form.validateFields();
|
||||
} catch {
|
||||
// 表单校验失败 → antd 自动展示字段错误,静默返回
|
||||
return;
|
||||
}
|
||||
|
||||
const params: Record<string, string> = {};
|
||||
|
||||
for (const field of fields) {
|
||||
const value = values[field.name];
|
||||
if (value === undefined || value === null) continue;
|
||||
|
||||
if (field.isFileWidget) {
|
||||
const fileList = value as UploadFile[];
|
||||
const urls = fileList
|
||||
.filter((f) => f.status === 'done')
|
||||
.map((f) => f.response?.url || f.url || f.name)
|
||||
.filter(Boolean) as string[];
|
||||
if (urls.length > 0) {
|
||||
params[field.name] = urls.length === 1 ? urls[0] : JSON.stringify(urls);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (typeof value === 'boolean') {
|
||||
params[field.name] = String(value);
|
||||
continue;
|
||||
}
|
||||
|
||||
params[field.name] = String(value);
|
||||
}
|
||||
|
||||
// useAsyncMutation.execute 内部 catch 所有错误,返回 undefined 表示失败
|
||||
const result = await submitTask({
|
||||
model_id: selectedModel.id,
|
||||
params,
|
||||
...(taskTag.trim() ? { tags: [taskTag.trim()] } : {}),
|
||||
});
|
||||
|
||||
if (result !== undefined) {
|
||||
submitCountRef.current += 1;
|
||||
} else {
|
||||
// 使用 ref 读取最新错误信息(避免闭包过期)
|
||||
message.error(errorMessageRef.current || '任务提交失败,请重试');
|
||||
}
|
||||
}, [selectedModel, form, fields, taskTag, submitTask]);
|
||||
|
||||
/** 提交按钮点击入口:登录校验 → 防抖节流 → 二次确认 → 执行提交 */
|
||||
const handleSubmit = useCallback(() => {
|
||||
if (!isLoggedIn) {
|
||||
message.warning('请先登录后再提交任务');
|
||||
return;
|
||||
}
|
||||
if (!selectedModel) return;
|
||||
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
|
||||
const params: Record<string, string> = {};
|
||||
|
||||
for (const field of fields) {
|
||||
const value = values[field.name];
|
||||
if (value === undefined || value === null) continue;
|
||||
|
||||
if (field.isFileWidget) {
|
||||
const fileList = value as UploadFile[];
|
||||
const urls = fileList
|
||||
.filter((f) => f.status === 'done')
|
||||
.map((f) => f.response?.url || f.url || f.name)
|
||||
.filter(Boolean) as string[];
|
||||
if (urls.length > 0) {
|
||||
params[field.name] = urls.length === 1 ? urls[0] : JSON.stringify(urls);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (typeof value === 'boolean') {
|
||||
params[field.name] = String(value);
|
||||
continue;
|
||||
}
|
||||
|
||||
params[field.name] = String(value);
|
||||
}
|
||||
|
||||
await submitTask({
|
||||
model_id: selectedModel.id,
|
||||
params,
|
||||
...(taskTag.trim() ? { tags: [taskTag.trim()] } : {}),
|
||||
});
|
||||
} catch (err) {
|
||||
if (err && typeof err === 'object' && 'errorFields' in err) {
|
||||
return;
|
||||
}
|
||||
message.error(errorMessage || '任务提交失败,请重试');
|
||||
// ---- 防抖节流:2s 内重复点击 ----
|
||||
const now = Date.now();
|
||||
if (now - lastClickTimeRef.current < 2000) {
|
||||
message.warning('你点击的太快了');
|
||||
return;
|
||||
}
|
||||
};
|
||||
lastClickTimeRef.current = now;
|
||||
|
||||
// ---- 已提交过 → 二次确认弹窗 ----
|
||||
if (submitCountRef.current > 0) {
|
||||
Modal.confirm({
|
||||
title: '确认再次提交',
|
||||
content: '您已提交,确认要再次提交吗?',
|
||||
okText: '确认提交',
|
||||
cancelText: '取消',
|
||||
onOk: () => {
|
||||
// 弹窗确认后重置节流时间戳,确保确认提交不被防抖拦截
|
||||
lastClickTimeRef.current = 0;
|
||||
void doSubmit();
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
void doSubmit();
|
||||
}, [isLoggedIn, selectedModel, doSubmit]);
|
||||
|
||||
// ======== 动态预估消费金额(必须在所有提前返回之前) ========
|
||||
|
||||
|
||||
@@ -8,7 +8,8 @@ import { Tabs, Spin, Empty, Tag, Typography, Button, Result, message, theme as a
|
||||
import { AppstoreOutlined } from '@ant-design/icons';
|
||||
|
||||
import { useHomeContext } from '@/pages/home/HomeContent';
|
||||
import { resolveCategoryLabel, MODEL_CATEGORIES, type ModelsListResponseBody, type ModelCategoryStringEnum } from '@/services/modules/models';
|
||||
import { MODEL_CATEGORIES, type ModelsListResponseBody, type ModelCategoryStringEnum } from '@/services/modules/models';
|
||||
import { EnumResolver } from '@/utils/enum-resolver';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
@@ -132,7 +133,7 @@ export function ModelSelector({ models, loading, error, groupedModels, refetch }
|
||||
seenCategories.add(cat);
|
||||
items.push({
|
||||
key: cat,
|
||||
label: `${resolveCategoryLabel(cat)} (${groupModels.length})`,
|
||||
label: `${EnumResolver.model.category.label(cat)} (${groupModels.length})`,
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -142,7 +143,7 @@ export function ModelSelector({ models, loading, error, groupedModels, refetch }
|
||||
if (!seenCategories.has(category) && groupModels.length > 0) {
|
||||
items.push({
|
||||
key: category,
|
||||
label: `${resolveCategoryLabel(category)} (${groupModels.length})`,
|
||||
label: `${EnumResolver.model.category.label(category as ModelCategoryStringEnum)} (${groupModels.length})`,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// 使用 useTaskDetail() Hook 获取任务详情,组件仅负责渲染
|
||||
// ============================================================
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { useMemo, useRef, useEffect } from 'react';
|
||||
import { Image, Empty, Spin, Typography, Button, Tag, theme as antTheme } from 'antd';
|
||||
import {
|
||||
EyeOutlined,
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
|
||||
import { useHomeContext } from '@/pages/home/HomeContent';
|
||||
import { useTaskDetail } from '@/hooks/use-api';
|
||||
import type { TaskStatusString } from '@/services/modules/task';
|
||||
import { POLLING_STATUSES, type TaskStatusString } from '@/services/modules/task';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
@@ -50,7 +50,30 @@ export function OutputPreview() {
|
||||
const { selectedTask } = useHomeContext();
|
||||
|
||||
// 数据获取(Hook 自动处理:taskId 为 null 时不请求、竞态、loading/error)
|
||||
const { data: detail, loading } = useTaskDetail(selectedTask?.id);
|
||||
const { data: detail, loading, refetch } = useTaskDetail(selectedTask?.id);
|
||||
|
||||
// ======== 状态转换监听:非终态→终态时触发详情刷新 ========
|
||||
// 不再独立轮询 getTaskStatus(避免与 TaskHistory 的批量轮询重复请求),
|
||||
// 而是依赖批量轮询通过 Context 同步过来的 selectedTask.status 变化。
|
||||
|
||||
const prevStatusRef = useRef<TaskStatusString | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
const status = selectedTask?.status as TaskStatusString | undefined;
|
||||
const prev = prevStatusRef.current;
|
||||
|
||||
// 从非终态变为终态 → 刷新详情获取完整数据(output_assets 等)
|
||||
if (
|
||||
prev &&
|
||||
POLLING_STATUSES.includes(prev) &&
|
||||
status &&
|
||||
!POLLING_STATUSES.includes(status)
|
||||
) {
|
||||
refetch();
|
||||
}
|
||||
|
||||
prevStatusRef.current = status;
|
||||
}, [selectedTask?.status, refetch]);
|
||||
|
||||
// 从 output_assets 提取图片/视频
|
||||
const { images, videos } = useMemo(() => {
|
||||
@@ -75,7 +98,7 @@ export function OutputPreview() {
|
||||
}
|
||||
|
||||
// ---- 处理中 ----
|
||||
if (status === 'pending' || status === 'submitted' || status === 'processing') {
|
||||
if (status && POLLING_STATUSES.includes(status)) {
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '100%', gap: 16 }}>
|
||||
<Spin size="large" />
|
||||
|
||||
@@ -17,7 +17,7 @@ import type { ColumnsType, TableProps } from 'antd/es/table';
|
||||
import type { FilterValue, SorterResult } from 'antd/es/table/interface';
|
||||
import { HistoryOutlined, SearchOutlined, SettingOutlined } from '@ant-design/icons';
|
||||
|
||||
import { useTaskList } from '@/hooks/use-api';
|
||||
import { useTaskList, useTaskPolling } from '@/hooks/use-api';
|
||||
import type { ModelsListResponseBody } from '@/services/modules/models';
|
||||
import { useAppContext } from '@/components/AppProvider';
|
||||
import { useHomeContext } from '@/pages/home/HomeContent';
|
||||
@@ -286,6 +286,22 @@ export function TaskHistory({ allModels }: TaskHistoryProps) {
|
||||
});
|
||||
|
||||
const tasks = taskData?.items ?? [];
|
||||
|
||||
// 批量轮询非终态任务状态
|
||||
const handleTaskTerminal = useCallback(() => {
|
||||
setRefreshTick((t) => t + 1);
|
||||
}, []);
|
||||
const polledTasks = useTaskPolling(tasks, handleTaskTerminal);
|
||||
|
||||
// 轮询更新后同步选中的任务到 Context
|
||||
useEffect(() => {
|
||||
if (!selectedTask || polledTasks.length === 0) return;
|
||||
const updated = polledTasks.find(t => t.id === selectedTask.id);
|
||||
if (updated && updated.status !== selectedTask.status) {
|
||||
setSelectedTask(updated);
|
||||
}
|
||||
}, [polledTasks, selectedTask, setSelectedTask]);
|
||||
|
||||
const total = taskData?.total ?? 0;
|
||||
|
||||
// 从任务数据中提取用户列表(企业版筛选用)
|
||||
@@ -719,7 +735,7 @@ export function TaskHistory({ allModels }: TaskHistoryProps) {
|
||||
<Table<TaskInfoItemResponseBody>
|
||||
className="task-table"
|
||||
columns={columns}
|
||||
dataSource={tasks}
|
||||
dataSource={polledTasks}
|
||||
rowKey="id"
|
||||
size="small"
|
||||
loading={loading}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Upload } from 'antd';
|
||||
import { InboxOutlined } from '@ant-design/icons';
|
||||
import type { UploadFile } from 'antd/es/upload';
|
||||
import type { WidgetProps } from './types';
|
||||
import { useFileUpload } from '@/hooks/use-api';
|
||||
|
||||
const { Dragger } = Upload;
|
||||
|
||||
@@ -16,6 +17,8 @@ export function AudioListInput({ value, onChange, disabled, config }: WidgetProp
|
||||
const maxCount = (config.maxFiles as number) || 10;
|
||||
const filters = config.fileFilter as string[] | undefined;
|
||||
|
||||
const { customRequest } = useFileUpload();
|
||||
|
||||
return (
|
||||
<Dragger
|
||||
multiple
|
||||
@@ -25,9 +28,10 @@ export function AudioListInput({ value, onChange, disabled, config }: WidgetProp
|
||||
: '.wav,.mp3,.m4a,.flac'
|
||||
}
|
||||
fileList={fileList}
|
||||
customRequest={customRequest}
|
||||
onChange={({ fileList: newList }) => onChange?.(newList)}
|
||||
disabled={disabled}
|
||||
beforeUpload={() => false}
|
||||
beforeUpload={() => true}
|
||||
maxCount={maxCount}
|
||||
>
|
||||
<p className="ant-upload-drag-icon">
|
||||
|
||||
@@ -19,6 +19,7 @@ import { UploadOutlined } from '@ant-design/icons';
|
||||
import type { UploadFile } from 'antd/es/upload';
|
||||
import type { WidgetProps } from './types';
|
||||
import { createImageBeforeUpload } from './uploadValidation';
|
||||
import { useFileUpload } from '@/hooks/use-api';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
@@ -36,6 +37,9 @@ export function ImageInput({ value, onChange, disabled, config }: WidgetProps) {
|
||||
const maxSizeMb = (config.maxFileSizeMb as number) || 10;
|
||||
const hasEnableSwitch = (config.enableSwitch as boolean) || false;
|
||||
|
||||
// 文件上传 Hook(提供 customRequest → antd Upload 集成)
|
||||
const { customRequest } = useFileUpload();
|
||||
|
||||
// 有 enable_switch 时默认禁用,需手动开启
|
||||
const [switchOn, setSwitchOn] = useState(!hasEnableSwitch);
|
||||
const isUploadDisabled = disabled || !switchOn;
|
||||
@@ -75,6 +79,7 @@ export function ImageInput({ value, onChange, disabled, config }: WidgetProps) {
|
||||
maxCount={1}
|
||||
accept={buildAccept(config.fileFilter as string[] | undefined)}
|
||||
fileList={fileList}
|
||||
customRequest={customRequest}
|
||||
onChange={({ fileList: newList }) => {
|
||||
// 仅当开关开启时上报文件变化
|
||||
if (switchOn || !hasEnableSwitch) {
|
||||
|
||||
@@ -8,6 +8,7 @@ import { InboxOutlined } from '@ant-design/icons';
|
||||
import type { UploadFile } from 'antd/es/upload';
|
||||
import type { WidgetProps } from './types';
|
||||
import { createImageBeforeUpload } from './uploadValidation';
|
||||
import { useFileUpload } from '@/hooks/use-api';
|
||||
|
||||
const { Dragger } = Upload;
|
||||
|
||||
@@ -17,6 +18,8 @@ export function ImageListInput({ value, onChange, disabled, config }: WidgetProp
|
||||
const maxCount = (config.maxFiles as number) || 10;
|
||||
const filters = config.fileFilter as string[] | undefined;
|
||||
|
||||
const { customRequest } = useFileUpload();
|
||||
|
||||
return (
|
||||
<Dragger
|
||||
multiple
|
||||
@@ -27,6 +30,7 @@ export function ImageListInput({ value, onChange, disabled, config }: WidgetProp
|
||||
: 'image/png,image/jpg,image/jpeg,image/webp'
|
||||
}
|
||||
fileList={fileList}
|
||||
customRequest={customRequest}
|
||||
onChange={({ fileList: newList }) => onChange?.(newList)}
|
||||
disabled={disabled}
|
||||
beforeUpload={createImageBeforeUpload(maxSizeMb)}
|
||||
|
||||
@@ -21,6 +21,6 @@ export function createImageBeforeUpload(maxSizeMb: number = 10) {
|
||||
message.error(`图片大小不能超过 ${maxSizeMb}MB`);
|
||||
return Upload.LIST_IGNORE;
|
||||
}
|
||||
return false;
|
||||
return true; // 校验通过,交由 customRequest 执行实际上传
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user