refactor: 重组 components/ 和 pages/ 文件结构,按职责分层
components/ 拆为三层:
- providers/ — 状态管理(App/Theme/SettingsProvider)
- shell/ — 应用外壳(Layout/NavBar/PageDispatcher)
- ui/ — 通用 UI 原子(FloatingPanel/LegalTextModal)
pages/ 重命名 + 重组:
- headers/ → panels/ NavBar 触发的面板页
- login/ → auth/ 认证模块(含注册/忘记密码)
- settings/blocks/ → sections/
- home/components/ → panels/ features/ controls/ hooks/
按职责分:panels(布局壳) + features(功能面板) + controls(表单控件) + hooks
其他清理:
- 删除空占位 Test3.tsx
- HomeContext 从 HomeContent 拆出独立文件
- uploadValidation 内联到 ImageInput
- 修复过时注释(useUI → controls)
This commit is contained in:
47
src/pages/home/controls/AudioListInput.tsx
Normal file
47
src/pages/home/controls/AudioListInput.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
// ============================================================
|
||||
// AudioListInput — audioList → Dragger(音频文件拖拽上传)
|
||||
// 用于声纹克隆的音频样本上传场景
|
||||
// ============================================================
|
||||
|
||||
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;
|
||||
|
||||
export function AudioListInput({ value, onChange, disabled, config }: WidgetProps) {
|
||||
const fileList = (value as UploadFile[]) || [];
|
||||
const maxSizeMb = (config.maxFileSizeMb as number) || 10;
|
||||
const maxCount = (config.maxFiles as number) || 10;
|
||||
const filters = config.fileFilter as string[] | undefined;
|
||||
|
||||
const { customRequest } = useFileUpload();
|
||||
|
||||
return (
|
||||
<Dragger
|
||||
multiple
|
||||
accept={
|
||||
filters
|
||||
? filters.map((ext) => `.${ext}`).join(',')
|
||||
: '.wav,.mp3,.m4a,.flac'
|
||||
}
|
||||
fileList={fileList}
|
||||
customRequest={customRequest}
|
||||
onChange={({ fileList: newList }) => onChange?.(newList)}
|
||||
disabled={disabled}
|
||||
beforeUpload={() => true}
|
||||
maxCount={maxCount}
|
||||
>
|
||||
<p className="ant-upload-drag-icon">
|
||||
<InboxOutlined />
|
||||
</p>
|
||||
<p className="ant-upload-text">点击或拖拽音频文件到此区域上传</p>
|
||||
<p className="ant-upload-hint">
|
||||
支持 {filters?.join(' / ').toUpperCase() || 'WAV / MP3 / M4A / FLAC'}
|
||||
,单文件不超过 {maxSizeMb}MB
|
||||
</p>
|
||||
</Dragger>
|
||||
);
|
||||
}
|
||||
17
src/pages/home/controls/Checkbox.tsx
Normal file
17
src/pages/home/controls/Checkbox.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
// ============================================================
|
||||
// Checkbox — checkbox → Switch
|
||||
// 用于布尔开关:原始模式、平铺、Base64 输出等
|
||||
// ============================================================
|
||||
|
||||
import { Switch } from 'antd';
|
||||
import type { WidgetProps } from './types';
|
||||
|
||||
export function Checkbox({ value, onChange, disabled }: WidgetProps) {
|
||||
return (
|
||||
<Switch
|
||||
checked={value as boolean}
|
||||
onChange={(v) => onChange?.(v)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
}
|
||||
24
src/pages/home/controls/ComboBox.tsx
Normal file
24
src/pages/home/controls/ComboBox.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
// ============================================================
|
||||
// ComboBox — comboBox → Select
|
||||
// 用于枚举选择:宽高比、音色、分辨率、模型等
|
||||
// ============================================================
|
||||
|
||||
import { Select } from 'antd';
|
||||
import type { WidgetProps } from './types';
|
||||
|
||||
export function ComboBox({ value, onChange, disabled, config }: WidgetProps) {
|
||||
const enumValues = config.enumValues as string[] | undefined;
|
||||
const options = enumValues?.map((v) => ({ label: v, value: v })) || [];
|
||||
|
||||
return (
|
||||
<Select
|
||||
value={value as string}
|
||||
onChange={(v) => onChange?.(v)}
|
||||
disabled={disabled}
|
||||
placeholder={(config.placeholder as string) || '请选择'}
|
||||
options={options}
|
||||
allowClear={!config.required}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
23
src/pages/home/controls/DoubleSpinBox.tsx
Normal file
23
src/pages/home/controls/DoubleSpinBox.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
// ============================================================
|
||||
// DoubleSpinBox — doubleSpinBox → InputNumber (step=config.step)
|
||||
// 用于小数输入:语速、音量、相似度等
|
||||
// ============================================================
|
||||
|
||||
import { InputNumber } from 'antd';
|
||||
import type { WidgetProps } from './types';
|
||||
|
||||
export function DoubleSpinBox({ value, onChange, disabled, config }: WidgetProps) {
|
||||
return (
|
||||
<InputNumber
|
||||
value={value as number}
|
||||
onChange={(v) => onChange?.(v != null ? v : undefined)}
|
||||
disabled={disabled}
|
||||
min={config.minValue as number | undefined}
|
||||
max={config.maxValue as number | undefined}
|
||||
step={config.step as number || 0.1}
|
||||
style={{ width: '100%', minWidth: 140 }}
|
||||
placeholder={(config.placeholder as string) || undefined}
|
||||
controls
|
||||
/>
|
||||
);
|
||||
}
|
||||
119
src/pages/home/controls/ImageInput.tsx
Normal file
119
src/pages/home/controls/ImageInput.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
// ============================================================
|
||||
// ImageInput — imageInput → Upload(单张图片)
|
||||
// 用于垫图、参考图等单图上传场景
|
||||
//
|
||||
// enable_switch 支持:
|
||||
// 当 API ui.enable_switch === true 时,默认禁用(灰色),
|
||||
// 需点击 Switch 开关后才能上传——匹配原 Qt 客户端行为。
|
||||
// 开关关闭时清除已上传文件。
|
||||
//
|
||||
// 表单值语义(配合 constraints_config.requires 依赖禁用):
|
||||
// undefined → 开关关闭 / 未启用 → isValueEmpty = true → 依赖字段禁用
|
||||
// [] → 开关开启但未上传文件 → isValueEmpty = false → 依赖字段启用
|
||||
// [{...}] → 已上传文件 → isValueEmpty = false → 依赖字段启用
|
||||
// ============================================================
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import { Upload, Switch, Space, Typography, message } from 'antd';
|
||||
import { UploadOutlined } from '@ant-design/icons';
|
||||
import type { UploadFile, RcFile } from 'antd/es/upload';
|
||||
import type { WidgetProps } from './types';
|
||||
import { useFileUpload } from '@/hooks/use-api';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
/**
|
||||
* 创建图片上传前的类型 & 大小校验函数(ImageInput / ImageListInput 共用)
|
||||
* @param maxSizeMb 最大文件大小(MB),默认 10
|
||||
*/
|
||||
export function createImageBeforeUpload(maxSizeMb: number = 10) {
|
||||
return (file: RcFile) => {
|
||||
const isImage = file.type.startsWith('image/');
|
||||
if (!isImage) {
|
||||
message.error('只能上传图片文件');
|
||||
return Upload.LIST_IGNORE;
|
||||
}
|
||||
if (file.size / 1024 / 1024 >= maxSizeMb) {
|
||||
message.error(`图片大小不能超过 ${maxSizeMb}MB`);
|
||||
return Upload.LIST_IGNORE;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
}
|
||||
|
||||
/** 构建图片 accept 字符串 */
|
||||
function buildAccept(filters?: string[]): string {
|
||||
if (!filters || filters.length === 0) return 'image/png,image/jpg,image/jpeg,image/webp';
|
||||
return filters.map((ext) => `image/${ext}`).join(',');
|
||||
}
|
||||
|
||||
/** 标记"已启用但空"的空数组,避免 react 渲染时引用变化 */
|
||||
const ENABLED_EMPTY: UploadFile[] = [];
|
||||
|
||||
export function ImageInput({ value, onChange, disabled, config }: WidgetProps) {
|
||||
const fileList = (value as UploadFile[]) || [];
|
||||
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;
|
||||
|
||||
const handleSwitchChange = useCallback(
|
||||
(on: boolean) => {
|
||||
setSwitchOn(on);
|
||||
if (!on) {
|
||||
// 关闭开关 → 表单值置为 undefined(isValueEmpty = true,依赖字段禁用)
|
||||
onChange?.(undefined);
|
||||
} else {
|
||||
// 开启开关 → 空数组 = "已启用但未上传"(isValueEmpty = false,依赖字段启用)
|
||||
onChange?.(ENABLED_EMPTY as unknown as UploadFile[]);
|
||||
}
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
return (
|
||||
<Space orientation="vertical" style={{ width: '100%' }} size={8}>
|
||||
{hasEnableSwitch && (
|
||||
<Space size={8}>
|
||||
<Switch
|
||||
size="small"
|
||||
checked={switchOn}
|
||||
onChange={handleSwitchChange}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Text type={switchOn ? 'success' : 'secondary'} style={{ fontSize: 12 }}>
|
||||
{switchOn ? '已启用' : '点击开关启用上传'}
|
||||
</Text>
|
||||
</Space>
|
||||
)}
|
||||
|
||||
<Upload
|
||||
listType="picture-card"
|
||||
maxCount={1}
|
||||
accept={buildAccept(config.fileFilter as string[] | undefined)}
|
||||
fileList={fileList}
|
||||
customRequest={customRequest}
|
||||
onChange={({ fileList: newList }) => {
|
||||
// 仅当开关开启时上报文件变化
|
||||
if (switchOn || !hasEnableSwitch) {
|
||||
onChange?.(newList as unknown as UploadFile[]);
|
||||
}
|
||||
}}
|
||||
disabled={isUploadDisabled}
|
||||
beforeUpload={createImageBeforeUpload(maxSizeMb)}
|
||||
>
|
||||
{fileList.length < 1 && (
|
||||
<div style={{ opacity: isUploadDisabled ? 0.4 : 1 }}>
|
||||
<UploadOutlined />
|
||||
<div style={{ marginTop: 4, fontSize: 12 }}>上传</div>
|
||||
</div>
|
||||
)}
|
||||
</Upload>
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
49
src/pages/home/controls/ImageListInput.tsx
Normal file
49
src/pages/home/controls/ImageListInput.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
// ============================================================
|
||||
// ImageListInput — imageList → Dragger(多张图片拖拽上传)
|
||||
// 用于图生图模型的多图输入场景
|
||||
// ============================================================
|
||||
|
||||
import { Upload } from 'antd';
|
||||
import { InboxOutlined } from '@ant-design/icons';
|
||||
import type { UploadFile } from 'antd/es/upload';
|
||||
import type { WidgetProps } from './types';
|
||||
import { createImageBeforeUpload } from './ImageInput';
|
||||
import { useFileUpload } from '@/hooks/use-api';
|
||||
|
||||
const { Dragger } = Upload;
|
||||
|
||||
export function ImageListInput({ value, onChange, disabled, config }: WidgetProps) {
|
||||
const fileList = (value as UploadFile[]) || [];
|
||||
const maxSizeMb = (config.maxFileSizeMb as number) || 10;
|
||||
const maxCount = (config.maxFiles as number) || 10;
|
||||
const filters = config.fileFilter as string[] | undefined;
|
||||
|
||||
const { customRequest } = useFileUpload();
|
||||
|
||||
return (
|
||||
<Dragger
|
||||
multiple
|
||||
listType="picture"
|
||||
accept={
|
||||
filters
|
||||
? filters.map((ext) => `image/${ext}`).join(',')
|
||||
: 'image/png,image/jpg,image/jpeg,image/webp'
|
||||
}
|
||||
fileList={fileList}
|
||||
customRequest={customRequest}
|
||||
onChange={({ fileList: newList }) => onChange?.(newList)}
|
||||
disabled={disabled}
|
||||
beforeUpload={createImageBeforeUpload(maxSizeMb)}
|
||||
maxCount={maxCount}
|
||||
>
|
||||
<p className="ant-upload-drag-icon">
|
||||
<InboxOutlined />
|
||||
</p>
|
||||
<p className="ant-upload-text">点击或拖拽图片到此区域上传</p>
|
||||
<p className="ant-upload-hint">
|
||||
支持 {filters?.join(' / ').toUpperCase() || 'PNG / JPG / WebP'}
|
||||
,单张不超过 {maxSizeMb}MB
|
||||
</p>
|
||||
</Dragger>
|
||||
);
|
||||
}
|
||||
21
src/pages/home/controls/LineInput.tsx
Normal file
21
src/pages/home/controls/LineInput.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
// ============================================================
|
||||
// LineInput — lineEdit → Input
|
||||
// 用于单行文本输入(如 custom_voice_id)
|
||||
// 同时作为未知 widget 类型的兜底控件
|
||||
// ============================================================
|
||||
|
||||
import { Input } from 'antd';
|
||||
import type { WidgetProps } from './types';
|
||||
|
||||
export function LineInput({ value, onChange, disabled, config }: WidgetProps) {
|
||||
return (
|
||||
<Input
|
||||
value={value as string}
|
||||
onChange={(e) => onChange?.(e.target.value)}
|
||||
disabled={disabled}
|
||||
placeholder={(config.placeholder as string) || '请输入'}
|
||||
maxLength={config.maxLength as number | undefined}
|
||||
allowClear
|
||||
/>
|
||||
);
|
||||
}
|
||||
55
src/pages/home/controls/SeedanceContent.tsx
Normal file
55
src/pages/home/controls/SeedanceContent.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
// ============================================================
|
||||
// SeedanceContent — seedance2Content → 自定义内容编排控件
|
||||
//
|
||||
// 对应 Seedance 2.0 视频生成模型的 content 参数(json_array)
|
||||
// 最终形态:文本 + 参考图 + 参考视频自由组合编排
|
||||
//
|
||||
// TODO: 当前为占位实现,后续需完善拖拽编排能力
|
||||
// ============================================================
|
||||
|
||||
import { Input, Typography, theme as antTheme } from 'antd';
|
||||
import { PictureOutlined, VideoCameraOutlined } from '@ant-design/icons';
|
||||
import type { WidgetProps } from './types';
|
||||
|
||||
const { TextArea } = Input;
|
||||
const { Text } = Typography;
|
||||
|
||||
export function SeedanceContent({ value, onChange, disabled, config }: WidgetProps) {
|
||||
const { token } = antTheme.useToken();
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
border: `1px dashed ${token.colorBorder}`,
|
||||
borderRadius: 6,
|
||||
padding: 12,
|
||||
background: token.colorFillQuaternary,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
|
||||
<PictureOutlined style={{ color: token.colorPrimary, fontSize: 14 }} />
|
||||
<VideoCameraOutlined style={{ color: token.colorPrimary, fontSize: 14 }} />
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
内容编排 — 描述动作 + 参考素材
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<TextArea
|
||||
rows={4}
|
||||
value={value as string}
|
||||
onChange={(e) => onChange?.(e.target.value)}
|
||||
disabled={disabled}
|
||||
placeholder={
|
||||
(config.placeholder as string) ||
|
||||
'输入视频内容的文字描述(后续版本将支持拖拽编排图片/视频参考素材)'
|
||||
}
|
||||
maxLength={(config.maxLength as number) || 5000}
|
||||
showCount
|
||||
/>
|
||||
|
||||
<Text type="secondary" style={{ display: 'block', marginTop: 6, fontSize: 11 }}>
|
||||
💡 更多内容编排能力即将上线:支持文本 + 参考图 + 参考视频自由组合
|
||||
</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
23
src/pages/home/controls/SpinBox.tsx
Normal file
23
src/pages/home/controls/SpinBox.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
// ============================================================
|
||||
// SpinBox — spinBox → InputNumber (step=1)
|
||||
// 用于整数输入:混沌、风格化、怪异、时长、音调等
|
||||
// ============================================================
|
||||
|
||||
import { InputNumber } from 'antd';
|
||||
import type { WidgetProps } from './types';
|
||||
|
||||
export function SpinBox({ value, onChange, disabled, config }: WidgetProps) {
|
||||
return (
|
||||
<InputNumber
|
||||
value={value as number}
|
||||
onChange={(v) => onChange?.(v != null ? v : undefined)}
|
||||
disabled={disabled}
|
||||
min={config.minValue as number | undefined}
|
||||
max={config.maxValue as number | undefined}
|
||||
step={1}
|
||||
style={{ width: '100%', minWidth: 140 }}
|
||||
placeholder={(config.placeholder as string) || undefined}
|
||||
controls
|
||||
/>
|
||||
);
|
||||
}
|
||||
23
src/pages/home/controls/TextInput.tsx
Normal file
23
src/pages/home/controls/TextInput.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
// ============================================================
|
||||
// TextInput — textEdit → Input.TextArea
|
||||
// 用于提示词、文本合成等长文本输入场景
|
||||
// ============================================================
|
||||
|
||||
import { Input } from 'antd';
|
||||
import type { WidgetProps } from './types';
|
||||
|
||||
const { TextArea } = Input;
|
||||
|
||||
export function TextInput({ value, onChange, disabled, config }: WidgetProps) {
|
||||
return (
|
||||
<TextArea
|
||||
rows={4}
|
||||
value={value as string}
|
||||
onChange={(e) => onChange?.(e.target.value)}
|
||||
disabled={disabled}
|
||||
placeholder={(config.placeholder as string) || '请输入'}
|
||||
maxLength={config.maxLength as number | undefined}
|
||||
showCount={config.maxLength !== undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
55
src/pages/home/controls/index.ts
Normal file
55
src/pages/home/controls/index.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
// ============================================================
|
||||
// controls/index.ts — Widget 注册表
|
||||
//
|
||||
// 职责:
|
||||
// 1. 定义 WidgetProps 统一接口
|
||||
// 2. 维护 Qt 控件名 → React 组件的 WIDGET_MAP 映射
|
||||
// 3. 导出 WidgetType 联合类型供外部使用
|
||||
//
|
||||
// 新增控件类型只需两步:
|
||||
// ① 在 controls/ 下创建组件文件
|
||||
// ② 在 WIDGET_MAP 中加一行
|
||||
// ============================================================
|
||||
|
||||
import type { ComponentType } from 'react';
|
||||
import type { WidgetProps } from './types';
|
||||
|
||||
import { TextInput } from './TextInput';
|
||||
import { LineInput } from './LineInput';
|
||||
import { ComboBox } from './ComboBox';
|
||||
import { Checkbox } from './Checkbox';
|
||||
import { SpinBox } from './SpinBox';
|
||||
import { DoubleSpinBox } from './DoubleSpinBox';
|
||||
import { ImageInput } from './ImageInput';
|
||||
import { ImageListInput } from './ImageListInput';
|
||||
import { AudioListInput } from './AudioListInput';
|
||||
import { SeedanceContent } from './SeedanceContent';
|
||||
|
||||
export type { WidgetProps } from './types';
|
||||
|
||||
// ---------- 注册表 ----------
|
||||
|
||||
/**
|
||||
* Qt 控件名 → React 组件的映射表。
|
||||
*
|
||||
* 键来自 API param_schema[].ui.widget 字段(原 Python Qt 客户端的组件类名),
|
||||
* 值为 useUI/ 下的对应 React 组件。
|
||||
*/
|
||||
export const WIDGET_MAP: Record<string, ComponentType<WidgetProps>> = {
|
||||
textEdit: TextInput,
|
||||
lineEdit: LineInput,
|
||||
comboBox: ComboBox,
|
||||
checkbox: Checkbox,
|
||||
spinBox: SpinBox,
|
||||
doubleSpinBox: DoubleSpinBox,
|
||||
imageInput: ImageInput,
|
||||
imageList: ImageListInput,
|
||||
audioList: AudioListInput,
|
||||
seedance2Content: SeedanceContent,
|
||||
};
|
||||
|
||||
/** 已知的 widget 类型字面量联合 */
|
||||
export type WidgetType = keyof typeof WIDGET_MAP;
|
||||
|
||||
/** 未知控件类型的兜底组件(渲染为普通 Input) */
|
||||
export const FALLBACK_WIDGET: ComponentType<WidgetProps> = LineInput;
|
||||
19
src/pages/home/controls/types.ts
Normal file
19
src/pages/home/controls/types.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
// ============================================================
|
||||
// controls/types.ts — Widget 类型定义(与 index.ts 分离以避免循环引用)
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* 所有 widget 组件的统一 Props(非泛型)。
|
||||
*
|
||||
* 不设类型参数的原因:WIDGET_MAP 需要统一存储不同值类型的组件,
|
||||
* 带泛型的 WidgetProps<string> 和 WidgetProps<number> 因函数参数逆变
|
||||
* 而互不兼容(ComponentType 协变检查失败),改为统一 unknown 接口,
|
||||
* 各组件内部通过类型断言收窄到已知类型。
|
||||
*/
|
||||
export interface WidgetProps {
|
||||
value?: unknown;
|
||||
onChange?: (value: unknown) => void;
|
||||
disabled?: boolean;
|
||||
/** 从 param_schema 提取的控件专属配置(placeholder / options / min / max 等) */
|
||||
config: Record<string, unknown>;
|
||||
}
|
||||
Reference in New Issue
Block a user