feat :修改文件结构
This commit is contained in:
74
src/pages/home/components/BannerCarousel.tsx
Normal file
74
src/pages/home/components/BannerCarousel.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
// ============================================================
|
||||
// BannerCarousel — 首页顶部轮播图
|
||||
// 使用 useBannerList() Hook 获取数据,组件仅负责渲染
|
||||
// ============================================================
|
||||
|
||||
import { useRef, useMemo } from 'react';
|
||||
import { Carousel, Skeleton } from 'antd';
|
||||
import type { CarouselRef } from 'antd/es/carousel';
|
||||
|
||||
import { useBannerList } from '@/hooks/use-api';
|
||||
import type { Banner } from '@/services/modules';
|
||||
|
||||
// ---------- 组件 Props ----------
|
||||
|
||||
interface BannerCarouselProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// ---------- 组件 ----------
|
||||
|
||||
export function BannerCarousel({ className }: BannerCarouselProps) {
|
||||
const carouselRef = useRef<CarouselRef>(null);
|
||||
const { data: rawBanners, loading } = useBannerList();
|
||||
|
||||
// 过滤 + 排序(纯计算,无副作用)
|
||||
const banners = useMemo<Banner[]>(() => {
|
||||
if (!rawBanners) return [];
|
||||
return rawBanners
|
||||
.filter((b) => b.is_active)
|
||||
.filter((b) => b.category === 'banner')
|
||||
.sort((a, b) => a.sort_order - b.sort_order);
|
||||
}, [rawBanners]);
|
||||
|
||||
// 无数据时不渲染
|
||||
if (!loading && banners.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 加载态 — 适配长条比例(2280×90)
|
||||
if (loading) {
|
||||
return (
|
||||
<div className={className} style={{ width: '100%' }}>
|
||||
<Skeleton.Input active block style={{ height: 90 }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`banner-carousel ${className ?? ''}`}
|
||||
style={{ position: 'relative', width: '100%', overflow: 'hidden', lineHeight: 0 }}
|
||||
>
|
||||
<Carousel
|
||||
ref={carouselRef}
|
||||
autoplay
|
||||
autoplaySpeed={5000}
|
||||
effect="fade"
|
||||
pauseOnHover
|
||||
adaptiveHeight={true}
|
||||
dots={banners.length > 1}
|
||||
>
|
||||
{banners.map((banner) => (
|
||||
<div key={banner.id}>
|
||||
<img
|
||||
src={banner.image_url}
|
||||
alt={banner.title}
|
||||
style={{ width: '100%', height: 'auto', display: 'block' }}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</Carousel>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
23
src/pages/home/components/CenterPanel.tsx
Normal file
23
src/pages/home/components/CenterPanel.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
// ============================================================
|
||||
// CenterPanel — 中列容器(模型输入表单)
|
||||
// ============================================================
|
||||
|
||||
import { theme as antTheme } from 'antd';
|
||||
import { ModelInputForm } from './ModelInputForm';
|
||||
|
||||
export function CenterPanel() {
|
||||
const { token } = antTheme.useToken();
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
height: '100%',
|
||||
overflow: 'hidden',
|
||||
borderRadius: 8,
|
||||
border: `1px solid ${token.colorBorderSecondary}`,
|
||||
}}
|
||||
>
|
||||
<ModelInputForm />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
179
src/pages/home/components/LeftPanel.tsx
Normal file
179
src/pages/home/components/LeftPanel.tsx
Normal file
@@ -0,0 +1,179 @@
|
||||
// ============================================================
|
||||
// LeftPanel — 左列容器(模型选择 + 可拖拽分割线 + 任务搜索 + 任务记录)
|
||||
// 纵向拖拽分隔线支持鼠标调整上下区域高度比例
|
||||
//
|
||||
// 职责:布局 + edition 分发,子组件自行管理数据和搜索状态
|
||||
// ============================================================
|
||||
|
||||
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { theme as antTheme } from 'antd';
|
||||
import { ModelSelector } from './ModelSelector';
|
||||
import { TaskHistory } from './TaskHistory';
|
||||
|
||||
// ---------- 常量 ----------
|
||||
|
||||
/** 模型选择区最小高度 */
|
||||
const TOP_MIN = 120;
|
||||
/** 任务列表区最小高度 */
|
||||
const BOTTOM_MIN = 200;
|
||||
/** 默认模型选择区百分比 */
|
||||
const TOP_DEFAULT_PCT = 0.40;
|
||||
/** 拖拽分隔条高度 */
|
||||
const DIVIDER_HEIGHT = 6;
|
||||
|
||||
// ---------- 组件 ----------
|
||||
|
||||
export function LeftPanel() {
|
||||
const { token } = antTheme.useToken();
|
||||
|
||||
// 面板容器引用
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// 模型选择区高度(像素)
|
||||
const [topHeight, setTopHeight] = useState<number | null>(null);
|
||||
// 拖拽状态
|
||||
const [dragging, setDragging] = useState(false);
|
||||
// hover 状态
|
||||
const [hovered, setHovered] = useState(false);
|
||||
|
||||
const dragState = useRef<{
|
||||
startY: number;
|
||||
startHeight: number;
|
||||
}>({ startY: 0, startHeight: 0 });
|
||||
|
||||
// 初始化:按百分比计算顶部高度
|
||||
const initHeight = useCallback(() => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
const total = el.clientHeight;
|
||||
setTopHeight(Math.max(TOP_MIN, Math.round(total * TOP_DEFAULT_PCT)));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
initHeight();
|
||||
}, [initHeight]);
|
||||
|
||||
// 响应容器 resize(窗口大小变化时重新按比例计算)
|
||||
useEffect(() => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
const observer = new ResizeObserver(() => {
|
||||
if (!dragging) {
|
||||
const total = el.clientHeight;
|
||||
setTopHeight((prev) => {
|
||||
if (prev === null) return Math.max(TOP_MIN, Math.round(total * TOP_DEFAULT_PCT));
|
||||
// 保持原有比例
|
||||
const ratio = prev / total;
|
||||
return Math.max(TOP_MIN, Math.round(total * Math.min(ratio, 0.8)));
|
||||
});
|
||||
}
|
||||
});
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, [dragging]);
|
||||
|
||||
// 拖拽事件
|
||||
const handleDividerMouseDown = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
dragState.current = { startY: e.clientY, startHeight: topHeight ?? 200 };
|
||||
setDragging(true);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (!dragging) return;
|
||||
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
|
||||
const total = el.clientHeight;
|
||||
const ds = dragState.current;
|
||||
const delta = e.clientY - ds.startY;
|
||||
const newTop = Math.max(TOP_MIN, Math.min(total - BOTTOM_MIN, ds.startHeight + delta));
|
||||
setTopHeight(newTop);
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setDragging(false);
|
||||
};
|
||||
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
}, [dragging]);
|
||||
|
||||
// 分隔条样式
|
||||
const isActive = dragging;
|
||||
const isHovered = hovered;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
height: '100%',
|
||||
overflow: 'hidden',
|
||||
borderRadius: 8,
|
||||
border: `1px solid ${token.colorBorderSecondary}`,
|
||||
}}
|
||||
>
|
||||
{/* 上方:模型选择(高度由拖拽控制) */}
|
||||
<div
|
||||
style={{
|
||||
height: topHeight ?? '40%',
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<ModelSelector />
|
||||
</div>
|
||||
|
||||
{/* 纵向拖拽分隔条 */}
|
||||
<div
|
||||
onMouseDown={handleDividerMouseDown}
|
||||
style={{
|
||||
height: DIVIDER_HEIGHT,
|
||||
flexShrink: 0,
|
||||
cursor: 'row-resize',
|
||||
background: isActive
|
||||
? token.colorPrimary
|
||||
: isHovered
|
||||
? `${token.colorPrimary}60`
|
||||
: token.colorBorderSecondary,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
transition: 'background 0.2s ease',
|
||||
zIndex: 10,
|
||||
}}
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
>
|
||||
{/* 拖拽手柄横线 */}
|
||||
<div style={{
|
||||
height: 2,
|
||||
width: 32,
|
||||
borderRadius: 1,
|
||||
background: isActive
|
||||
? '#fff'
|
||||
: isHovered
|
||||
? token.colorPrimary
|
||||
: token.colorBorder,
|
||||
transition: 'background 0.2s ease',
|
||||
}} />
|
||||
</div>
|
||||
|
||||
{/* 下方:任务记录(剩余高度) */}
|
||||
<div style={{ flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column', minHeight: 0 }}>
|
||||
<TaskHistory />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
463
src/pages/home/components/ModelInputForm.tsx
Normal file
463
src/pages/home/components/ModelInputForm.tsx
Normal file
@@ -0,0 +1,463 @@
|
||||
// ============================================================
|
||||
// ModelInputForm — 动态模型输入表单
|
||||
// 根据选中模型的 param_schema + ui_config 动态渲染表单控件
|
||||
// ============================================================
|
||||
|
||||
import { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import {
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
Select,
|
||||
Slider,
|
||||
Button,
|
||||
Typography,
|
||||
Empty,
|
||||
Upload,
|
||||
Switch,
|
||||
theme as antTheme,
|
||||
message,
|
||||
} from 'antd';
|
||||
import {
|
||||
SendOutlined,
|
||||
UploadOutlined,
|
||||
InboxOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import type { UploadFile } from 'antd/es/upload';
|
||||
|
||||
import { useHomeContext } from '@/contexts/home-context';
|
||||
import { useAppContext } from '@/contexts/app-context';
|
||||
import { useSubmitTask } from '@/hooks/use-api';
|
||||
import { emit, EVENTS } from '@/utils/event-bus';
|
||||
import type { TaskInfoItemResponseBody } from '@/services/modules';
|
||||
|
||||
const { Text, Title } = Typography;
|
||||
const { TextArea } = Input;
|
||||
const { Dragger } = Upload;
|
||||
|
||||
// ---------- 参数类型映射 ----------
|
||||
|
||||
/** 从 ui_config 中解析的参数描述 */
|
||||
interface ParamDescriptor {
|
||||
key: string;
|
||||
label: string;
|
||||
/** 控件类型 */
|
||||
controlType: 'text' | 'number' | 'textarea' | 'select' | 'slider' | 'switch' | 'image-upload';
|
||||
defaultValue?: unknown;
|
||||
required?: boolean;
|
||||
placeholder?: string;
|
||||
/** 数值型:最小值 */
|
||||
min?: number;
|
||||
/** 数值型:最大值 */
|
||||
max?: number;
|
||||
/** 数值型:步长 */
|
||||
step?: number;
|
||||
/** select 型:选项列表 */
|
||||
options?: Array<{ label: string; value: string | number }>;
|
||||
}
|
||||
|
||||
/** 从 ui_config 对象中解析控件类型 */
|
||||
function inferControlType(config: Record<string, unknown>): ParamDescriptor['controlType'] {
|
||||
const type = config.type as string | undefined;
|
||||
if (!type) return 'text';
|
||||
|
||||
switch (type) {
|
||||
case 'number':
|
||||
case 'integer':
|
||||
case 'float':
|
||||
return 'number';
|
||||
case 'textarea':
|
||||
case 'text_area':
|
||||
case 'prompt':
|
||||
return 'textarea';
|
||||
case 'select':
|
||||
case 'dropdown':
|
||||
case 'enum':
|
||||
return 'select';
|
||||
case 'slider':
|
||||
case 'range':
|
||||
return 'slider';
|
||||
case 'switch':
|
||||
case 'boolean':
|
||||
case 'bool':
|
||||
return 'switch';
|
||||
case 'image':
|
||||
case 'image-upload':
|
||||
case 'file':
|
||||
return 'image-upload';
|
||||
default:
|
||||
return 'text';
|
||||
}
|
||||
}
|
||||
|
||||
/** 从 param_schema + ui_config 构建参数描述符列表 */
|
||||
function buildParamDescriptors(
|
||||
paramSchema: string[],
|
||||
uiConfig: Record<string, unknown>,
|
||||
): ParamDescriptor[] {
|
||||
return paramSchema.map((key) => {
|
||||
const config = (uiConfig[key] || {}) as Record<string, unknown>;
|
||||
const controlType = inferControlType(config);
|
||||
return {
|
||||
key,
|
||||
label: (config.label as string) || key,
|
||||
controlType,
|
||||
defaultValue: config.default ?? config.defaultValue,
|
||||
required: (config.required as boolean) || false,
|
||||
placeholder: (config.placeholder as string) || `请输入${config.label || key}`,
|
||||
min: config.min as number | undefined,
|
||||
max: config.max as number | undefined,
|
||||
step: config.step as number | undefined,
|
||||
options: (config.options as ParamDescriptor['options']) || undefined,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- 组件 ----------
|
||||
|
||||
export function ModelInputForm() {
|
||||
const { token } = antTheme.useToken();
|
||||
const { isLoggedIn } = useAppContext();
|
||||
const { selectedModel } = useHomeContext();
|
||||
const [form] = Form.useForm();
|
||||
const [referenceFiles, setReferenceFiles] = useState<UploadFile[]>([]);
|
||||
|
||||
// 提交突变 Hook(自动处理 loading / error / 日志)
|
||||
const { execute: submitTask, loading: submitting, errorMessage } = useSubmitTask({
|
||||
onSuccess: useCallback(
|
||||
(_data: TaskInfoItemResponseBody) => {
|
||||
message.success('任务已提交');
|
||||
// 通过事件总线通知 TaskHistory 刷新(替代 Context 中的 refreshTaskSignal)
|
||||
emit(EVENTS.TASK_SUBMITTED);
|
||||
},
|
||||
[],
|
||||
),
|
||||
});
|
||||
|
||||
// 选中模型时重置表单(仅当有选中模型时操作,避免 form 未连接警告)
|
||||
useEffect(() => {
|
||||
if (selectedModel) {
|
||||
form.resetFields();
|
||||
}
|
||||
setReferenceFiles([]);
|
||||
}, [selectedModel?.id]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// 参数描述符
|
||||
const paramDescriptors = useMemo(() => {
|
||||
if (!selectedModel) return [];
|
||||
return buildParamDescriptors(
|
||||
selectedModel.param_schema || [],
|
||||
selectedModel.ui_config || {},
|
||||
);
|
||||
}, [selectedModel]);
|
||||
|
||||
// 提交
|
||||
const handleSubmit = async () => {
|
||||
if (!isLoggedIn) {
|
||||
message.warning('请先登录后再提交任务');
|
||||
return;
|
||||
}
|
||||
if (!selectedModel) return;
|
||||
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
|
||||
// 分离固定字段和模型动态参数
|
||||
const { prompt, negative_prompt, num_outputs, ...dynamicParams } = values;
|
||||
|
||||
// 构建提交参数(全部转为字符串值)
|
||||
const params: Record<string, string> = {
|
||||
prompt: String(prompt ?? ''),
|
||||
negative_prompt: String(negative_prompt ?? ''),
|
||||
num_outputs: String(num_outputs ?? 1),
|
||||
};
|
||||
|
||||
// 动态参数统一转字符串
|
||||
for (const [key, value] of Object.entries(dynamicParams)) {
|
||||
if (value !== undefined && value !== null) {
|
||||
params[key] = String(value);
|
||||
}
|
||||
}
|
||||
|
||||
// 参考图
|
||||
if (referenceFiles.length > 0) {
|
||||
const refUrls = referenceFiles
|
||||
.filter((f) => f.status === 'done')
|
||||
.map((f) => f.response?.url || f.url || f.name)
|
||||
.filter(Boolean) as string[];
|
||||
if (refUrls.length > 0) {
|
||||
params.reference_images = JSON.stringify(refUrls);
|
||||
}
|
||||
}
|
||||
|
||||
// 使用 mutation Hook 提交(自动处理 loading / error / 日志)
|
||||
await submitTask({ model_id: selectedModel.id, params });
|
||||
} catch (err) {
|
||||
if (err && typeof err === 'object' && 'errorFields' in err) {
|
||||
// 表单验证错误,antd 会自动提示
|
||||
return;
|
||||
}
|
||||
// 错误消息由 Hook 自动通过 errorMessage 提供
|
||||
message.error(errorMessage || '任务提交失败,请重试');
|
||||
}
|
||||
};
|
||||
|
||||
// 渲染单个参数控件
|
||||
const renderParamControl = (desc: ParamDescriptor) => {
|
||||
const commonProps = {
|
||||
placeholder: desc.placeholder,
|
||||
style: { width: '100%' },
|
||||
};
|
||||
|
||||
switch (desc.controlType) {
|
||||
case 'number':
|
||||
return (
|
||||
<InputNumber
|
||||
{...commonProps}
|
||||
min={desc.min}
|
||||
max={desc.max}
|
||||
step={desc.step}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'textarea':
|
||||
return <TextArea rows={3} {...commonProps} />;
|
||||
|
||||
case 'select':
|
||||
return (
|
||||
<Select
|
||||
{...commonProps}
|
||||
options={desc.options}
|
||||
allowClear
|
||||
/>
|
||||
);
|
||||
|
||||
case 'slider':
|
||||
return (
|
||||
<Slider
|
||||
min={desc.min ?? 1}
|
||||
max={desc.max ?? 100}
|
||||
step={desc.step ?? 1}
|
||||
marks={
|
||||
desc.min !== undefined && desc.max !== undefined
|
||||
? { [desc.min]: `${desc.min}`, [desc.max]: `${desc.max}` }
|
||||
: undefined
|
||||
}
|
||||
tooltip={{ formatter: (v) => v }}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'switch':
|
||||
return <Switch />;
|
||||
|
||||
case 'image-upload':
|
||||
return (
|
||||
<Upload
|
||||
listType="picture-card"
|
||||
maxCount={1}
|
||||
accept="image/png,image/jpg,image/jpeg,image/webp"
|
||||
beforeUpload={(file) => {
|
||||
const isImage = file.type.startsWith('image/');
|
||||
const isLt10M = file.size / 1024 / 1024 < 10;
|
||||
if (!isImage) {
|
||||
message.error('只能上传图片文件');
|
||||
return Upload.LIST_IGNORE;
|
||||
}
|
||||
if (!isLt10M) {
|
||||
message.error('图片大小不能超过 10MB');
|
||||
return Upload.LIST_IGNORE;
|
||||
}
|
||||
return false; // 手动上传
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<UploadOutlined />
|
||||
<div style={{ marginTop: 4, fontSize: 12 }}>上传</div>
|
||||
</div>
|
||||
</Upload>
|
||||
);
|
||||
|
||||
default:
|
||||
return <Input {...commonProps} />;
|
||||
}
|
||||
};
|
||||
|
||||
// ---- 未选择模型 ----
|
||||
if (!selectedModel) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '100%',
|
||||
}}
|
||||
>
|
||||
<Empty description="请在左侧选择一个模型" image={Empty.PRESENTED_IMAGE_SIMPLE} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---- 表单 ----
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{/* 模型标题 */}
|
||||
<div
|
||||
style={{
|
||||
padding: '16px 20px',
|
||||
borderBottom: `1px solid ${token.colorBorderSecondary}`,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Title level={4} style={{ margin: 0, fontSize: 18 }}>
|
||||
{selectedModel.name}
|
||||
</Title>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{selectedModel.provider_name} · {selectedModel.category_name}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{/* 可滚动表单区 */}
|
||||
<div style={{ flex: 1, overflow: 'auto', padding: '16px 20px' }}>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
size="small"
|
||||
initialValues={{ num_outputs: 1 }}
|
||||
>
|
||||
{/* ======== 固定字段 ======== */}
|
||||
|
||||
{/* 提示词 */}
|
||||
<Form.Item
|
||||
name="prompt"
|
||||
label={<Text strong>提示词</Text>}
|
||||
rules={[{ required: true, message: '请输入提示词' }]}
|
||||
>
|
||||
<TextArea
|
||||
rows={4}
|
||||
placeholder="描述你想要的画面、视频或音频内容..."
|
||||
showCount
|
||||
maxLength={2000}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
{/* 负面提示词 */}
|
||||
<Form.Item
|
||||
name="negative_prompt"
|
||||
label={<Text type="secondary">负面提示词</Text>}
|
||||
>
|
||||
<TextArea
|
||||
rows={2}
|
||||
placeholder="描述你不希望在结果中出现的内容(可选)"
|
||||
maxLength={1000}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
{/* 参考图 */}
|
||||
<Form.Item label={<Text strong>参考图</Text>}>
|
||||
<Dragger
|
||||
multiple
|
||||
listType="picture"
|
||||
fileList={referenceFiles}
|
||||
onChange={({ fileList }) => setReferenceFiles(fileList)}
|
||||
accept="image/png,image/jpg,image/jpeg,image/webp"
|
||||
beforeUpload={(file) => {
|
||||
const isImage = file.type.startsWith('image/');
|
||||
const isLt10M = file.size / 1024 / 1024 < 10;
|
||||
if (!isImage) {
|
||||
message.error('只能上传图片文件');
|
||||
return Upload.LIST_IGNORE;
|
||||
}
|
||||
if (!isLt10M) {
|
||||
message.error('图片大小不能超过 10MB');
|
||||
return Upload.LIST_IGNORE;
|
||||
}
|
||||
return false;
|
||||
}}
|
||||
maxCount={5}
|
||||
style={{ marginBottom: 8 }}
|
||||
>
|
||||
<p className="ant-upload-drag-icon">
|
||||
<InboxOutlined />
|
||||
</p>
|
||||
<p className="ant-upload-text">点击或拖拽图片到此区域上传</p>
|
||||
<p className="ant-upload-hint">
|
||||
支持 PNG / JPG / WebP,单张不超过 10MB
|
||||
</p>
|
||||
</Dragger>
|
||||
</Form.Item>
|
||||
|
||||
{/* 生成数量 */}
|
||||
<Form.Item
|
||||
name="num_outputs"
|
||||
label={<Text strong>生成数量</Text>}
|
||||
>
|
||||
<InputNumber min={1} max={10} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
|
||||
{/* ======== 模型动态参数 ======== */}
|
||||
|
||||
{paramDescriptors.length > 0 && (
|
||||
<div
|
||||
style={{
|
||||
marginTop: 16,
|
||||
paddingTop: 16,
|
||||
borderTop: `1px solid ${token.colorBorderSecondary}`,
|
||||
}}
|
||||
>
|
||||
<Text strong style={{ display: 'block', marginBottom: 12 }}>
|
||||
模型参数
|
||||
</Text>
|
||||
{paramDescriptors.map((desc) => (
|
||||
<Form.Item
|
||||
key={desc.key}
|
||||
name={desc.key}
|
||||
label={desc.label}
|
||||
rules={desc.required ? [{ required: true, message: `请输入${desc.label}` }] : undefined}
|
||||
initialValue={desc.defaultValue}
|
||||
valuePropName={desc.controlType === 'switch' ? 'checked' : 'value'}
|
||||
>
|
||||
{renderParamControl(desc)}
|
||||
</Form.Item>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Form>
|
||||
</div>
|
||||
|
||||
{/* 提交按钮 */}
|
||||
<div
|
||||
style={{
|
||||
padding: '12px 20px',
|
||||
borderTop: `1px solid ${token.colorBorderSecondary}`,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<SendOutlined />}
|
||||
onClick={handleSubmit}
|
||||
loading={submitting}
|
||||
disabled={!isLoggedIn || !selectedModel}
|
||||
block
|
||||
size="large"
|
||||
style={{
|
||||
background: token.colorPrimary,
|
||||
height: 44,
|
||||
fontSize: 16,
|
||||
}}
|
||||
>
|
||||
开始生成
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
122
src/pages/home/components/ModelSelector.tsx
Normal file
122
src/pages/home/components/ModelSelector.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
// ============================================================
|
||||
// ModelSelector — 模型选择器(垂直单选列表,按类别分组)
|
||||
// 使用 useModelList() Hook 获取数据,组件仅负责渲染
|
||||
// ============================================================
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { Menu, Spin, Empty, Tag, Typography, theme as antTheme } from 'antd';
|
||||
import { AppstoreOutlined } from '@ant-design/icons';
|
||||
|
||||
import { useModelList, MODEL_CATEGORY_LABELS } from '@/hooks/use-api';
|
||||
import { useHomeContext } from '@/contexts/home-context';
|
||||
import type { ModelCategory } from '@/services/modules';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
// ---------- 组件 ----------
|
||||
|
||||
export function ModelSelector() {
|
||||
const { token } = antTheme.useToken();
|
||||
const { selectedModel, setSelectedModel } = useHomeContext();
|
||||
const { data: models, loading, groupedModels } = useModelList();
|
||||
|
||||
// 构建 Menu items:分组标题 + 模型项
|
||||
const menuItems = useMemo(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const items: any[] = [];
|
||||
|
||||
groupedModels.forEach((groupModels, category) => {
|
||||
const categoryLabel = MODEL_CATEGORY_LABELS[category as ModelCategory] || category;
|
||||
items.push({
|
||||
key: `group-${category}`,
|
||||
label: (
|
||||
<Text strong style={{ fontSize: 12, color: token.colorTextSecondary }}>
|
||||
{categoryLabel} ({groupModels.length})
|
||||
</Text>
|
||||
),
|
||||
type: 'group' as const,
|
||||
children: groupModels.map((model) => ({
|
||||
key: model.id,
|
||||
label: (
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
|
||||
<Text
|
||||
style={{
|
||||
flex: 1,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
fontSize: 13,
|
||||
}}
|
||||
title={model.name}
|
||||
>
|
||||
{model.name}
|
||||
</Text>
|
||||
{model.status !== 'active' && (
|
||||
<Tag
|
||||
color={model.status === 'inactive' ? 'default' : 'orange'}
|
||||
style={{ fontSize: 10, lineHeight: '16px', padding: '0 4px' }}
|
||||
>
|
||||
{model.status === 'maintenance' ? '维护' : '停用'}
|
||||
</Tag>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
})),
|
||||
});
|
||||
});
|
||||
|
||||
return items;
|
||||
}, [groupedModels, token.colorTextSecondary]);
|
||||
|
||||
// 选中菜单项
|
||||
const handleSelect = ({ key }: { key: string }) => {
|
||||
const model = models?.find((m) => m.id === key);
|
||||
if (model) {
|
||||
setSelectedModel(model);
|
||||
}
|
||||
};
|
||||
|
||||
// 加载态
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: 24 }}>
|
||||
<Spin size="small" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 空态
|
||||
if (!models || models.length === 0) {
|
||||
return <Empty description="暂无可用模型" image={Empty.PRESENTED_IMAGE_SIMPLE} />;
|
||||
}
|
||||
|
||||
// 选中 key
|
||||
const selectedKeys = selectedModel ? [selectedModel.id] : [];
|
||||
|
||||
return (
|
||||
<div style={{ flex: 1, overflow: 'auto', minHeight: 0 }}>
|
||||
<div
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
borderBottom: `1px solid ${token.colorBorderSecondary}`,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
}}
|
||||
>
|
||||
<AppstoreOutlined style={{ color: token.colorPrimary, fontSize: 14 }} />
|
||||
<Text strong style={{ fontSize: 13 }}>选择模型</Text>
|
||||
</div>
|
||||
<Menu
|
||||
mode="inline"
|
||||
selectedKeys={selectedKeys}
|
||||
onSelect={handleSelect}
|
||||
items={menuItems}
|
||||
style={{
|
||||
borderInlineEnd: 'none',
|
||||
background: 'transparent',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
165
src/pages/home/components/OutputPreview.tsx
Normal file
165
src/pages/home/components/OutputPreview.tsx
Normal file
@@ -0,0 +1,165 @@
|
||||
// ============================================================
|
||||
// OutputPreview — 任务输出预览(图片 / 视频)
|
||||
// 使用 useTaskDetail() Hook 获取任务详情,组件仅负责渲染
|
||||
// ============================================================
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { Image, Empty, Spin, Typography, Button, Tag, theme as antTheme } from 'antd';
|
||||
import {
|
||||
EyeOutlined,
|
||||
DownloadOutlined,
|
||||
CloseCircleOutlined,
|
||||
} from '@ant-design/icons';
|
||||
|
||||
import { useHomeContext } from '@/contexts/home-context';
|
||||
import { useTaskDetail } from '@/hooks/use-api';
|
||||
import type { TaskStatusString } from '@/services/modules';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
// ---------- 状态标签配置 ----------
|
||||
|
||||
const STATUS_CONFIG: Record<TaskStatusString, { color: string; label: string }> = {
|
||||
pending: { color: 'blue', label: '排队中' },
|
||||
submitted: { color: 'cyan', label: '已提交' },
|
||||
processing: { color: 'orange', label: '处理中' },
|
||||
success: { color: 'green', label: '已完成' },
|
||||
failed: { color: 'red', label: '失败' },
|
||||
};
|
||||
|
||||
// ---------- 工具函数 ----------
|
||||
|
||||
function isVideoUrl(url: string): boolean {
|
||||
return /\.(mp4|webm|mov|avi|mkv)(\?|$)/i.test(url);
|
||||
}
|
||||
|
||||
function splitAssets(assets: Array<{ url: string }>): { images: string[]; videos: string[] } {
|
||||
const images: string[] = [];
|
||||
const videos: string[] = [];
|
||||
assets.forEach(({ url }) => {
|
||||
if (isVideoUrl(url)) videos.push(url);
|
||||
else images.push(url);
|
||||
});
|
||||
return { images, videos };
|
||||
}
|
||||
|
||||
// ---------- 组件 ----------
|
||||
|
||||
export function OutputPreview() {
|
||||
const { token } = antTheme.useToken();
|
||||
const { selectedTask } = useHomeContext();
|
||||
|
||||
// 数据获取(Hook 自动处理:taskId 为 null 时不请求、竞态、loading/error)
|
||||
const { data: detail, loading } = useTaskDetail(selectedTask?.id);
|
||||
|
||||
// 从 output_assets 提取图片/视频
|
||||
const { images, videos } = useMemo(() => {
|
||||
if (!detail?.output_assets || detail.output_assets.length === 0) {
|
||||
return { images: [], videos: [] };
|
||||
}
|
||||
return splitAssets(detail.output_assets);
|
||||
}, [detail]);
|
||||
|
||||
const status = selectedTask?.status as TaskStatusString | undefined;
|
||||
const statusCfg = status
|
||||
? (STATUS_CONFIG[status] || { color: 'default', label: status })
|
||||
: { color: 'default', label: '未知' };
|
||||
|
||||
// ---- 无选中任务 ----
|
||||
if (!selectedTask) {
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%' }}>
|
||||
<Empty description="选择任务记录查看输出" image={Empty.PRESENTED_IMAGE_SIMPLE} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---- 处理中 ----
|
||||
if (status === 'pending' || status === 'submitted' || status === 'processing') {
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '100%', gap: 16 }}>
|
||||
<Spin size="large" />
|
||||
<Tag color={statusCfg.color}>{statusCfg.label}</Tag>
|
||||
<Text type="secondary">任务正在处理中,请稍候...</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---- 失败 ----
|
||||
if (status === 'failed') {
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '100%', gap: 12 }}>
|
||||
<CloseCircleOutlined style={{ fontSize: 48, color: '#ff4d4f' }} />
|
||||
<Text type="danger" strong>任务执行失败</Text>
|
||||
{detail?.error_message && (
|
||||
<Text type="secondary" style={{ maxWidth: 300, textAlign: 'center', fontSize: 12 }}>
|
||||
{detail.error_message}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---- 加载中 ----
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%' }}>
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---- 无结果 ----
|
||||
if (!detail || images.length + videos.length === 0) {
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%' }}>
|
||||
<Empty description="暂无输出结果" image={Empty.PRESENTED_IMAGE_SIMPLE} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---- 展示 ----
|
||||
return (
|
||||
<div style={{ height: '100%', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
||||
<div style={{
|
||||
padding: '12px 16px',
|
||||
borderBottom: `1px solid ${token.colorBorderSecondary}`,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<EyeOutlined style={{ fontSize: 14 }} />
|
||||
<Text strong style={{ fontSize: 13 }}>输出预览</Text>
|
||||
<Tag color={statusCfg.color} style={{ fontSize: 11 }}>{statusCfg.label}</Tag>
|
||||
</div>
|
||||
{videos.length > 0 && (
|
||||
<Button size="small" type="link" icon={<DownloadOutlined />} href={videos[0]} target="_blank">
|
||||
下载视频
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, overflow: 'auto', padding: 12, display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
{videos.map((url, i) => (
|
||||
<div key={i} style={{ borderRadius: 6, overflow: 'hidden', background: '#000' }}>
|
||||
<video controls style={{ width: '100%', maxHeight: 360, display: 'block' }} src={url}>
|
||||
您的浏览器不支持视频播放
|
||||
</video>
|
||||
</div>
|
||||
))}
|
||||
{images.length === 1 ? (
|
||||
<Image src={images[0]} alt="输出结果" style={{ borderRadius: 6, width: '100%', objectFit: 'contain' }} />
|
||||
) : (
|
||||
<Image.PreviewGroup>
|
||||
{images.map((url, i) => (
|
||||
<Image key={i} src={url} alt={`输出结果 ${i + 1}`}
|
||||
style={{ borderRadius: 6, width: '100%', objectFit: 'contain', marginBottom: 8 }} />
|
||||
))}
|
||||
</Image.PreviewGroup>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
23
src/pages/home/components/RightPanel.tsx
Normal file
23
src/pages/home/components/RightPanel.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
// ============================================================
|
||||
// RightPanel — 右列容器(输出预览)
|
||||
// ============================================================
|
||||
|
||||
import { theme as antTheme } from 'antd';
|
||||
import { OutputPreview } from './OutputPreview';
|
||||
|
||||
export function RightPanel() {
|
||||
const { token } = antTheme.useToken();
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
height: '100%',
|
||||
overflow: 'hidden',
|
||||
borderRadius: 8,
|
||||
border: `1px solid ${token.colorBorderSecondary}`,
|
||||
}}
|
||||
>
|
||||
<OutputPreview />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
538
src/pages/home/components/TaskHistory.tsx
Normal file
538
src/pages/home/components/TaskHistory.tsx
Normal file
@@ -0,0 +1,538 @@
|
||||
// ============================================================
|
||||
// TaskHistory — 任务记录列表(分页 + 列头筛选 + 排序 + 滚动)
|
||||
//
|
||||
// 筛选策略(混合模式):
|
||||
// - 服务端筛选(传 API):status、model_id、user_id(企业版)
|
||||
// - 客户端筛选(antd onFilter):output_type、provider_name
|
||||
// - 服务端不支持日期查询,时间范围筛选暂用 sorter 排序替代
|
||||
// - 企业版才会在"用户"列显示筛选菜单
|
||||
//
|
||||
// 使用 antd Table onChange 统一管理 pagination + filters + sorter,
|
||||
// 参考 table-test.tsx 示例 4 的模式。
|
||||
// ============================================================
|
||||
|
||||
import { useState, useRef, useEffect, useCallback, useMemo } from 'react';
|
||||
import { Table, Tag, Typography, theme as antTheme, Popover, Checkbox, Button, Space } from 'antd';
|
||||
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, useModelList } from '@/hooks/use-api';
|
||||
import { useAppContext } from '@/contexts/app-context';
|
||||
import { useHomeContext } from '@/contexts/home-context';
|
||||
import { useTheme } from '@/hooks/use-theme';
|
||||
import { on, off, EVENTS } from '@/utils/event-bus';
|
||||
import type {
|
||||
TaskInfoItemResponseBody,
|
||||
TaskListRequestParams,
|
||||
TaskStatusString,
|
||||
} from '@/services/modules';
|
||||
import { TaskStatusMap, OutputTypeMap } from '@/services/modules';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
// ---------- 辅助类型 ----------
|
||||
|
||||
/** antd Table onChange 中 filters 的类型 */
|
||||
type TableFilters = Record<string, FilterValue | null>;
|
||||
|
||||
/** antd Table onChange 中 sorter 的类型(单列排序) */
|
||||
interface TableSorter {
|
||||
field?: React.Key | readonly React.Key[];
|
||||
order?: 'ascend' | 'descend';
|
||||
}
|
||||
|
||||
/** 表格参数(缓存 antd onChange 的结果,用于构建 API 请求) */
|
||||
interface TableParams {
|
||||
pagination: { current: number; pageSize: number };
|
||||
filters: TableFilters;
|
||||
sorter: TableSorter;
|
||||
}
|
||||
|
||||
// ---------- 常量 ----------
|
||||
|
||||
/** 状态筛选选项(来自 API 枚举) */
|
||||
const STATUS_FILTERS = Object.entries(TaskStatusMap).map(([value, label]) => ({
|
||||
text: label,
|
||||
value,
|
||||
}));
|
||||
|
||||
/** 输出类型筛选选项 */
|
||||
const OUTPUT_TYPE_FILTERS = [
|
||||
{ text: '图片', value: 'image' },
|
||||
{ text: '视频', value: 'video' },
|
||||
{ text: '音频', value: 'audio' },
|
||||
];
|
||||
|
||||
/** 状态标签配置(渲染用) */
|
||||
const STATUS_CONFIG: Record<TaskStatusString, { color: string; label: string }> = {
|
||||
pending: { color: 'blue', label: '排队中' },
|
||||
submitted: { color: 'cyan', label: '已提交' },
|
||||
processing: { color: 'orange', label: '处理中' },
|
||||
success: { color: 'green', label: '已完成' },
|
||||
failed: { color: 'red', label: '失败' },
|
||||
};
|
||||
|
||||
// ---------- 工具函数 ----------
|
||||
|
||||
/** 计算任务耗时(分钟),无 finished_at 时返回 null */
|
||||
function computeDuration(record: TaskInfoItemResponseBody): number | null {
|
||||
if (!record.submitted_at || !record.finished_at) return null;
|
||||
return Math.round(
|
||||
(new Date(record.finished_at).getTime() - new Date(record.submitted_at).getTime()) / 60000,
|
||||
);
|
||||
}
|
||||
|
||||
/** 从 filters 中提取单值筛选(如 status 单选) */
|
||||
function pickOne(filters: TableFilters, key: string): string | undefined {
|
||||
const v = filters[key];
|
||||
if (Array.isArray(v) && v.length > 0) return v[0] as string;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** 从 filters 中提取多值筛选(如 model_ids、user_ids) */
|
||||
function pickMany(filters: TableFilters, key: string): string[] | undefined {
|
||||
const v = filters[key];
|
||||
if (Array.isArray(v) && v.length > 0) return v as string[];
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// ---------- 组件 ----------
|
||||
|
||||
export function TaskHistory() {
|
||||
const { token } = antTheme.useToken();
|
||||
const { edition } = useAppContext();
|
||||
const {
|
||||
selectedTask,
|
||||
setSelectedTask,
|
||||
taskPage,
|
||||
taskPageSize,
|
||||
setTaskPage,
|
||||
setTaskPageSize,
|
||||
} = useHomeContext();
|
||||
|
||||
const isEnterprise = edition === 'enterprise';
|
||||
|
||||
// -------- antd Table onChange 参数缓存 --------
|
||||
|
||||
const [tableParams, setTableParams] = useState<TableParams>({
|
||||
pagination: { current: taskPage, pageSize: taskPageSize },
|
||||
filters: {},
|
||||
sorter: {},
|
||||
});
|
||||
|
||||
// 同步 context 分页 ↔ tableParams
|
||||
useEffect(() => {
|
||||
setTableParams((prev) => ({
|
||||
...prev,
|
||||
pagination: { current: taskPage, pageSize: taskPageSize },
|
||||
}));
|
||||
}, [taskPage, taskPageSize]);
|
||||
|
||||
// -------- 模型列表(用于模型列筛选选项 + 模型名→ID 映射)--------
|
||||
|
||||
// TODO: 与 ModelSelector 中的 useModelList 重复请求,后续统一缓存层
|
||||
const { data: allModels } = useModelList();
|
||||
const modelFilters = useMemo(() => {
|
||||
if (!allModels) return [];
|
||||
return allModels.map((m) => ({ text: m.name, value: m.id }));
|
||||
}, [allModels]);
|
||||
|
||||
// -------- 供应商列表(从模型数据中提取)--------
|
||||
|
||||
const providerFilters = useMemo(() => {
|
||||
if (!allModels) return [];
|
||||
const seen = new Set<string>();
|
||||
allModels.forEach((m) => {
|
||||
if (m.provider_name) seen.add(m.provider_name);
|
||||
});
|
||||
return Array.from(seen).sort().map((name) => ({ text: name, value: name }));
|
||||
}, [allModels]);
|
||||
|
||||
// -------- 用户列表(从当前任务数据中提取,仅企业版使用)--------
|
||||
// TODO: 替换为独立的用户列表 API
|
||||
|
||||
const [userFilters, setUserFilters] = useState<Array<{ text: string; value: string }>>([]);
|
||||
|
||||
// -------- 构建 API 请求参数 --------
|
||||
|
||||
const requestParams = useMemo<TaskListRequestParams>(() => {
|
||||
const req: TaskListRequestParams = {
|
||||
page: tableParams.pagination.current,
|
||||
page_size: tableParams.pagination.pageSize,
|
||||
};
|
||||
const { filters } = tableParams;
|
||||
|
||||
// 服务端筛选字段
|
||||
const status = pickOne(filters, 'status');
|
||||
if (status) req.status = status as TaskStatusString;
|
||||
|
||||
const modelIds = pickMany(filters, 'model_name');
|
||||
if (modelIds) req.model_ids = modelIds;
|
||||
|
||||
// user_ids:仅企业版传入
|
||||
if (isEnterprise) {
|
||||
const userIds = pickMany(filters, 'user_id');
|
||||
if (userIds) req.user_ids = userIds;
|
||||
}
|
||||
|
||||
return req;
|
||||
}, [tableParams, isEnterprise]);
|
||||
|
||||
// -------- 数据获取 --------
|
||||
|
||||
const [refreshTick, setRefreshTick] = useState(0);
|
||||
const handleTaskSubmitted = useCallback(() => {
|
||||
setRefreshTick((t) => t + 1);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
on(EVENTS.TASK_SUBMITTED, handleTaskSubmitted);
|
||||
return () => { off(EVENTS.TASK_SUBMITTED, handleTaskSubmitted); };
|
||||
}, [handleTaskSubmitted]);
|
||||
|
||||
const {
|
||||
data: taskData,
|
||||
loading,
|
||||
errorMessage,
|
||||
} = useTaskList({
|
||||
requestParams,
|
||||
refreshSignal: refreshTick,
|
||||
});
|
||||
|
||||
const tasks = taskData?.items ?? [];
|
||||
const total = taskData?.total ?? 0;
|
||||
|
||||
// 从任务数据中提取用户列表(企业版筛选用)
|
||||
useEffect(() => {
|
||||
if (!isEnterprise || !taskData?.items) return;
|
||||
const seen = new Set<string>();
|
||||
taskData.items.forEach((t) => {
|
||||
if (t.user_id && t.user_display_name) {
|
||||
seen.add(JSON.stringify({ text: t.user_display_name, value: t.user_id }));
|
||||
}
|
||||
});
|
||||
const newList = Array.from(seen).map((s) => JSON.parse(s) as { text: string; value: string });
|
||||
setUserFilters((prev) => {
|
||||
if (prev.length === newList.length) return prev; // 避免不必要的重渲染
|
||||
return newList;
|
||||
});
|
||||
}, [isEnterprise, taskData]);
|
||||
|
||||
// -------- antd Table onChange --------
|
||||
|
||||
const handleTableChange: TableProps<TaskInfoItemResponseBody>['onChange'] = (
|
||||
pagination,
|
||||
filters,
|
||||
sorter,
|
||||
) => {
|
||||
// 同步分页到 Context
|
||||
if (pagination.current && pagination.current !== taskPage) setTaskPage(pagination.current);
|
||||
if (pagination.pageSize && pagination.pageSize !== taskPageSize) setTaskPageSize(pagination.pageSize);
|
||||
|
||||
// 缓存筛选和排序参数
|
||||
const s = sorter as SorterResult<TaskInfoItemResponseBody>;
|
||||
setTableParams({
|
||||
pagination: {
|
||||
current: pagination.current || taskPage,
|
||||
pageSize: pagination.pageSize || taskPageSize,
|
||||
},
|
||||
filters: filters as TableFilters,
|
||||
sorter: !Array.isArray(s) && s.field
|
||||
? { field: s.field, order: s.order as 'ascend' | 'descend' | undefined }
|
||||
: {},
|
||||
});
|
||||
};
|
||||
|
||||
// -------- 表格列定义(含列头筛选/排序)--------
|
||||
|
||||
const columns = useMemo<ColumnsType<TaskInfoItemResponseBody>>(() => {
|
||||
const cols: ColumnsType<TaskInfoItemResponseBody> = [
|
||||
{
|
||||
title: '任务 ID',
|
||||
dataIndex: 'id',
|
||||
key: 'id',
|
||||
width: 140,
|
||||
ellipsis: true,
|
||||
fixed: 'left',
|
||||
render: (id: string) => (
|
||||
<Text copyable={{ text: id }} style={{ fontSize: 12, fontFamily: 'monospace' }}>
|
||||
{id.slice(0, 10)}...
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '模型',
|
||||
dataIndex: 'model_name',
|
||||
key: 'model_name',
|
||||
width: 130,
|
||||
ellipsis: true,
|
||||
filters: modelFilters,
|
||||
filteredValue: (tableParams.filters.model_name as string[]) || null,
|
||||
// 服务端筛选 → onFilter 恒返回 true(实际过滤在 API 层)
|
||||
onFilter: () => true,
|
||||
filterSearch: true,
|
||||
filterMode: 'menu' as const,
|
||||
},
|
||||
{
|
||||
title: '供应商',
|
||||
dataIndex: 'provider_name',
|
||||
key: 'provider_name',
|
||||
width: 100,
|
||||
ellipsis: true,
|
||||
filters: providerFilters,
|
||||
filteredValue: (tableParams.filters.provider_name as string[]) || null,
|
||||
// 客户端筛选(API 不支持 provider 参数,在当前页内过滤)
|
||||
onFilter: (value, record) => record.provider_name === value,
|
||||
filterSearch: true,
|
||||
},
|
||||
{
|
||||
title: '输出类型',
|
||||
dataIndex: 'output_type',
|
||||
key: 'output_type',
|
||||
width: 100,
|
||||
filters: OUTPUT_TYPE_FILTERS,
|
||||
filteredValue: (tableParams.filters.output_type as string[]) || null,
|
||||
onFilter: (value, record) => record.output_type === value,
|
||||
render: (t: string) => <Tag style={{ fontSize: 10 }}>{t}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
width: 80,
|
||||
filters: STATUS_FILTERS,
|
||||
filteredValue: (tableParams.filters.status as string[]) || null,
|
||||
onFilter: () => true, // 服务端筛选
|
||||
render: (s: TaskStatusString) => {
|
||||
const cfg = STATUS_CONFIG[s] || { color: 'default', label: s };
|
||||
return <Tag color={cfg.color} style={{ fontSize: 10 }}>{cfg.label}</Tag>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '提示词',
|
||||
dataIndex: 'prompt',
|
||||
key: 'prompt',
|
||||
width: 160,
|
||||
ellipsis: true,
|
||||
filterIcon: (filtered: boolean) => (
|
||||
<SearchOutlined style={{ color: filtered ? token.colorPrimary : undefined, fontSize: 12 }} />
|
||||
),
|
||||
filterDropdown: undefined, // TODO: 后续加自定义搜索框
|
||||
render: (p: string | undefined) => (
|
||||
<Text style={{ fontSize: 11 }} title={p}>{p || '-'}</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '提交时间',
|
||||
dataIndex: 'submitted_at',
|
||||
key: 'submitted_at',
|
||||
width: 145,
|
||||
sorter: (a, b) => {
|
||||
const da = a.submitted_at ? new Date(a.submitted_at).getTime() : 0;
|
||||
const db = b.submitted_at ? new Date(b.submitted_at).getTime() : 0;
|
||||
return da - db;
|
||||
},
|
||||
sortOrder: tableParams.sorter.field === 'submitted_at' ? tableParams.sorter.order : null,
|
||||
render: (v: Date) => (
|
||||
<Text style={{ fontSize: 11 }}>
|
||||
{v ? new Date(v).toLocaleString('zh-CN', {
|
||||
month: '2-digit', day: '2-digit',
|
||||
hour: '2-digit', minute: '2-digit', second: '2-digit',
|
||||
}) : '-'}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '耗时',
|
||||
key: 'duration',
|
||||
width: 80,
|
||||
sorter: (a, b) => {
|
||||
const da = computeDuration(a) ?? -1;
|
||||
const db = computeDuration(b) ?? -1;
|
||||
return da - db;
|
||||
},
|
||||
sortOrder: tableParams.sorter.field === 'duration' ? tableParams.sorter.order : null,
|
||||
render: (_: unknown, record: TaskInfoItemResponseBody) => {
|
||||
const min = computeDuration(record);
|
||||
return <Text style={{ fontSize: 11 }}>{min !== null ? `${min} 分钟` : '-'}</Text>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '费用',
|
||||
dataIndex: 'cost_cent',
|
||||
key: 'cost_cent',
|
||||
width: 80,
|
||||
sorter: (a, b) => a.cost_cent - b.cost_cent,
|
||||
sortOrder: tableParams.sorter.field === 'cost_cent' ? tableParams.sorter.order : null,
|
||||
render: (c: number) => (
|
||||
<Text style={{ fontSize: 11 }}>¥{(c / 100).toFixed(2)}</Text>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
// 用户列:仅企业版展示 + 可筛选
|
||||
if (isEnterprise) {
|
||||
cols.push({
|
||||
title: '用户',
|
||||
dataIndex: 'user_display_name',
|
||||
key: 'user_id',
|
||||
width: 100,
|
||||
ellipsis: true,
|
||||
filters: userFilters,
|
||||
filteredValue: (tableParams.filters.user_id as string[]) || null,
|
||||
onFilter: () => true, // 服务端筛选(传 user_ids)
|
||||
render: (n: string | null) => (
|
||||
<Text style={{ fontSize: 11 }}>{n || '-'}</Text>
|
||||
),
|
||||
});
|
||||
} else {
|
||||
cols.push({
|
||||
title: '用户',
|
||||
dataIndex: 'user_display_name',
|
||||
key: 'user_id',
|
||||
width: 100,
|
||||
ellipsis: true,
|
||||
render: (n: string | null) => (
|
||||
<Text style={{ fontSize: 11 }}>{n || '-'}</Text>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
return cols;
|
||||
}, [
|
||||
token.colorPrimary,
|
||||
modelFilters,
|
||||
providerFilters,
|
||||
userFilters,
|
||||
tableParams.filters,
|
||||
tableParams.sorter,
|
||||
isEnterprise,
|
||||
]);
|
||||
|
||||
// -------- 表格高度 --------
|
||||
//
|
||||
// 核心策略:固定预留 pagination 区域高度,确保无论数据加载时机如何,
|
||||
// 分页器始终有空间渲染,不会溢出可视区。
|
||||
//
|
||||
// scroll.y = wrapper高度 - thead高度 - PAGINATION_RESERVE - 水平滚动条高度
|
||||
//
|
||||
// 为什么固定预留 pagination 而不是动态测量?
|
||||
// - 无数据/加载中时 pagination DOM 尚不存在 → offsetHeight=0 → scroll.y 偏大
|
||||
// - 翻页/切换 pageSize 时 pagination 异步渲染 → 测量时机不可控
|
||||
// - 固定预留值(48px 覆盖 small-size pagination + 上下间距)避免上述所有时序问题
|
||||
|
||||
/** 预留 pagination 高度(small-size: ~32px + 上下 padding/margin 8px × 2) */
|
||||
const PAGINATION_RESERVE = 48;
|
||||
|
||||
const tableWrapperRef = useRef<HTMLDivElement>(null);
|
||||
const [tableBodyHeight, setTableBodyHeight] = useState(400);
|
||||
|
||||
useEffect(() => {
|
||||
const wrapper = tableWrapperRef.current;
|
||||
if (!wrapper) return;
|
||||
|
||||
const calcHeight = () => {
|
||||
const thead = wrapper.querySelector('.ant-table-thead') as HTMLElement | null;
|
||||
const body = wrapper.querySelector('.ant-table-body') as HTMLElement | null;
|
||||
const theadH = thead?.offsetHeight ?? 0;
|
||||
|
||||
// 水平滚动条占用高度(antd Table scroll.x 触发时出现)
|
||||
let hScrollH = 0;
|
||||
if (body && body.scrollWidth > body.clientWidth + 1) {
|
||||
hScrollH = body.offsetHeight - body.clientHeight;
|
||||
}
|
||||
|
||||
const available = wrapper.clientHeight - theadH - PAGINATION_RESERVE - hScrollH;
|
||||
setTableBodyHeight(Math.max(120, available));
|
||||
};
|
||||
|
||||
// ResizeObserver:容器尺寸变化(窗口拉伸、面板拖拽)
|
||||
const resizeObserver = new ResizeObserver(calcHeight);
|
||||
resizeObserver.observe(wrapper);
|
||||
|
||||
// MutationObserver:内部 DOM 变化(thead 高度变化如筛选菜单弹出、
|
||||
// 数据加载后水平滚动条出现等)
|
||||
const mutationObserver = new MutationObserver(calcHeight);
|
||||
mutationObserver.observe(wrapper, { childList: true, subtree: true });
|
||||
|
||||
calcHeight();
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
mutationObserver.disconnect();
|
||||
};
|
||||
}, [taskPageSize]);
|
||||
|
||||
// -------- 行样式 --------
|
||||
|
||||
const rowClassName = (record: TaskInfoItemResponseBody) =>
|
||||
selectedTask?.id === record.id ? 'task-row-selected' : '';
|
||||
|
||||
// -------- 是否有活跃筛选 --------
|
||||
|
||||
const hasActiveFilters = Object.values(tableParams.filters).some(
|
||||
(v) => Array.isArray(v) && v.length > 0,
|
||||
);
|
||||
|
||||
// ======== 渲染 ========
|
||||
|
||||
return (
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden', minHeight: 0 }}>
|
||||
{/* 标题栏 */}
|
||||
<div
|
||||
style={{
|
||||
padding: '6px 12px',
|
||||
borderBottom: `1px solid ${token.colorBorderSecondary}`,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<HistoryOutlined style={{ fontSize: 14 }} />
|
||||
<Text strong style={{ fontSize: 13 }}>任务记录</Text>
|
||||
{total > 0 && (
|
||||
<Text type="secondary" style={{ fontSize: 11 }}>共 {total} 条</Text>
|
||||
)}
|
||||
{hasActiveFilters && (
|
||||
<Tag color="blue" style={{ fontSize: 10, lineHeight: '16px', padding: '0 4px' }}>
|
||||
已筛选
|
||||
</Tag>
|
||||
)}
|
||||
{errorMessage && (
|
||||
<Text type="danger" style={{ fontSize: 11, marginLeft: 'auto' }}>{errorMessage}</Text>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 表格 — 横向 + 纵向滚动,筛选/排序通过列头操作 */}
|
||||
<div ref={tableWrapperRef} style={{ flex: 1, overflow: 'hidden', minHeight: 0 }}>
|
||||
<Table<TaskInfoItemResponseBody>
|
||||
columns={columns}
|
||||
dataSource={tasks}
|
||||
rowKey="id"
|
||||
size="small"
|
||||
loading={loading}
|
||||
showHeader={true}
|
||||
scroll={{ x: isEnterprise ? 1250 : 1150, y: tableBodyHeight }}
|
||||
rowClassName={rowClassName}
|
||||
onRow={(record) => ({
|
||||
onClick: () => setSelectedTask(record),
|
||||
style: { cursor: 'pointer' },
|
||||
})}
|
||||
onChange={handleTableChange}
|
||||
pagination={{
|
||||
current: tableParams.pagination.current,
|
||||
pageSize: tableParams.pagination.pageSize,
|
||||
total,
|
||||
size: 'small',
|
||||
showSizeChanger: true,
|
||||
pageSizeOptions: ['10', '20', '50'],
|
||||
placement: ['bottomCenter'],
|
||||
style: { marginBottom: 0 },
|
||||
}}
|
||||
locale={{ emptyText: '暂无任务记录', filterReset: '重置', filterConfirm: '确定' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user