feat: 三栏比例缩放 + SeedanceContent视频上传 + 本地存储基建 (0.0.22)
三栏布局 - ResizeObserver 等比例分配替代固定默认值恢复 - 修复拖拽+窗口调整竞态卡死(needsRecalcRef 延迟补算) - containerRef.current 动态读取 DOM 替代闭包 el Seedance2Content - 从占位文本重构为内容编排控件(文本+图片/视频/音频上传) - 根据 spec.image_files/video_files/audio_files 动态渲染上传区域 - 值 JSON 序列化 → 提交时转 ContentItem[](text/image_url/video_url/audio_url) 图片上传兼容视频文件 - buildAccept 根据 file_filter 区分 image/video 前缀 - createMediaBeforeUpload 动态放行 video/* MIME - 公共模块 media-upload-utils.ts 本地存储基建 - sql.js (WASM SQLite) + safeStorage 加密持久化 - 8个IPC文件操作通道 + resolveSafe 路径穿越防护 - <userData>/heixiu-data/ 沙箱 + JWT用户隔离 - 7表DDL + 游标分页 + LRU媒体淘汰 其他 - 修复 errorMessageRef 渲染期写入警告 - 媒体预览回退直接URL(<img>无CORS限制)+ output_type类型补充 - CSP wasm-unsafe-eval + https: CDN媒体源
This commit is contained in:
@@ -14,45 +14,22 @@
|
||||
// ============================================================
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import { Upload, Switch, Space, Typography, message } from 'antd';
|
||||
import { Upload, Switch, Space, Typography } from 'antd';
|
||||
import { UploadOutlined } from '@ant-design/icons';
|
||||
import type { UploadFile, RcFile } from 'antd/es/upload';
|
||||
import type { UploadFile } from 'antd/es/upload';
|
||||
import type { WidgetProps } from './types';
|
||||
import { useFileUpload } from '@/hooks/use-api';
|
||||
import { buildAccept, createMediaBeforeUpload } from './media-upload-utils';
|
||||
|
||||
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 filters = config.fileFilter as string[] | undefined;
|
||||
const hasEnableSwitch = (config.enableSwitch as boolean) || false;
|
||||
|
||||
// 文件上传 Hook(提供 customRequest → antd Upload 集成)
|
||||
@@ -95,7 +72,7 @@ export function ImageInput({ value, onChange, disabled, config }: WidgetProps) {
|
||||
<Upload
|
||||
listType="picture-card"
|
||||
maxCount={1}
|
||||
accept={buildAccept(config.fileFilter as string[] | undefined)}
|
||||
accept={buildAccept(filters)}
|
||||
fileList={fileList}
|
||||
customRequest={customRequest}
|
||||
onChange={({ fileList: newList }) => {
|
||||
@@ -105,7 +82,7 @@ export function ImageInput({ value, onChange, disabled, config }: WidgetProps) {
|
||||
}
|
||||
}}
|
||||
disabled={isUploadDisabled}
|
||||
beforeUpload={createImageBeforeUpload(maxSizeMb)}
|
||||
beforeUpload={createMediaBeforeUpload(maxSizeMb, filters)}
|
||||
>
|
||||
{fileList.length < 1 && (
|
||||
<div style={{ opacity: isUploadDisabled ? 0.4 : 1 }}>
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
// ============================================================
|
||||
// ImageListInput — imageList → Dragger(多张图片拖拽上传)
|
||||
// 用于图生图模型的多图输入场景
|
||||
// ImageListInput — imageList → Dragger(多张图片/视频拖拽上传)
|
||||
// 用于图生图/图生视频模型的多图输入场景
|
||||
// 当 file_filter 包含视频扩展名时,自动支持视频文件上传
|
||||
// ============================================================
|
||||
|
||||
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';
|
||||
import { buildAccept, createMediaBeforeUpload, VIDEO_EXTENSIONS } from './media-upload-utils';
|
||||
|
||||
const { Dragger } = Upload;
|
||||
|
||||
@@ -17,32 +18,31 @@ export function ImageListInput({ value, onChange, disabled, config }: WidgetProp
|
||||
const maxSizeMb = (config.maxFileSizeMb as number) || 10;
|
||||
const maxCount = (config.maxFiles as number) || 10;
|
||||
const filters = config.fileFilter as string[] | undefined;
|
||||
const hasVideo = filters?.some(f => VIDEO_EXTENSIONS.has(f.toLowerCase()));
|
||||
|
||||
const { customRequest } = useFileUpload();
|
||||
|
||||
const mediaLabel = hasVideo ? '图片/视频' : '图片';
|
||||
|
||||
return (
|
||||
<Dragger
|
||||
multiple
|
||||
listType="picture"
|
||||
accept={
|
||||
filters
|
||||
? filters.map((ext) => `image/${ext}`).join(',')
|
||||
: 'image/png,image/jpg,image/jpeg,image/webp'
|
||||
}
|
||||
accept={buildAccept(filters)}
|
||||
fileList={fileList}
|
||||
customRequest={customRequest}
|
||||
onChange={({ fileList: newList }) => onChange?.(newList)}
|
||||
disabled={disabled}
|
||||
beforeUpload={createImageBeforeUpload(maxSizeMb)}
|
||||
beforeUpload={createMediaBeforeUpload(maxSizeMb, filters)}
|
||||
maxCount={maxCount}
|
||||
>
|
||||
<p className="ant-upload-drag-icon">
|
||||
<InboxOutlined />
|
||||
</p>
|
||||
<p className="ant-upload-text">点击或拖拽图片到此区域上传</p>
|
||||
<p className="ant-upload-text">点击或拖拽{mediaLabel}到此区域上传</p>
|
||||
<p className="ant-upload-hint">
|
||||
支持 {filters?.join(' / ').toUpperCase() || 'PNG / JPG / WebP'}
|
||||
,单张不超过 {maxSizeMb}MB
|
||||
,单文件不超过 {maxSizeMb}MB
|
||||
</p>
|
||||
</Dragger>
|
||||
);
|
||||
|
||||
@@ -1,55 +1,248 @@
|
||||
// ============================================================
|
||||
// SeedanceContent — seedance2Content → 自定义内容编排控件
|
||||
// SeedanceContent — seedance2Content → 内容编排控件
|
||||
//
|
||||
// 对应 Seedance 2.0 视频生成模型的 content 参数(json_array)
|
||||
// 最终形态:文本 + 参考图 + 参考视频自由组合编排
|
||||
// 对应 Seedance 2.0 视频生成模型的 content 参数(json_array)。
|
||||
// 内部值结构(JSON 字符串 ⟷ 表单字段值):
|
||||
// { text: string; imageUrls: string[]; videoUrls: string[]; audioUrls: string[] }
|
||||
//
|
||||
// TODO: 当前为占位实现,后续需完善拖拽编排能力
|
||||
// 从 param_schema.spec 读取 image_files / video_files / audio_files
|
||||
// 决定各媒体类型的上传数量上限和是否显示对应上传区域。
|
||||
// ============================================================
|
||||
|
||||
import { Input, Typography, theme as antTheme } from 'antd';
|
||||
import { PictureOutlined, VideoCameraOutlined } from '@ant-design/icons';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { Upload, Input, Typography, message, theme as antTheme } from 'antd';
|
||||
import {
|
||||
PictureOutlined,
|
||||
VideoCameraOutlined,
|
||||
AudioOutlined,
|
||||
InboxOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import type { UploadFile, RcFile } from 'antd/es/upload';
|
||||
import type { WidgetProps } from './types';
|
||||
import { useFileUpload } from '@/hooks/use-api';
|
||||
|
||||
const { TextArea } = Input;
|
||||
const { Dragger } = Upload;
|
||||
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>
|
||||
);
|
||||
interface ContentValue {
|
||||
text: string;
|
||||
imageUrls: string[];
|
||||
videoUrls: string[];
|
||||
audioUrls: string[];
|
||||
}
|
||||
|
||||
// ---------- 辅助函数 ----------
|
||||
|
||||
function parseValue(raw: unknown): ContentValue {
|
||||
if (typeof raw === 'string' && raw.startsWith('{')) {
|
||||
try {
|
||||
const obj = JSON.parse(raw) as Partial<ContentValue>;
|
||||
return {
|
||||
text: obj.text || '',
|
||||
imageUrls: Array.isArray(obj.imageUrls) ? obj.imageUrls : [],
|
||||
videoUrls: Array.isArray(obj.videoUrls) ? obj.videoUrls : [],
|
||||
audioUrls: Array.isArray(obj.audioUrls) ? obj.audioUrls : [],
|
||||
};
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
return { text: typeof raw === 'string' ? raw : '', imageUrls: [], videoUrls: [], audioUrls: [] };
|
||||
}
|
||||
|
||||
function serializeValue(cv: ContentValue): string {
|
||||
return JSON.stringify(cv);
|
||||
}
|
||||
|
||||
/** URL 数组 → UploadFile 数组(全部标记为 done) */
|
||||
function urlsToFileList(urls: string[], type: string): UploadFile[] {
|
||||
return urls.map((url, i) => ({
|
||||
uid: `${type}-${i}-${url.slice(-20)}`,
|
||||
name: url.split('/').pop() || `${type}_${i + 1}`,
|
||||
status: 'done' as const,
|
||||
url,
|
||||
}));
|
||||
}
|
||||
|
||||
/** 从 UploadFile 数组中提取已上传成功的 URL */
|
||||
function extractUrls(fileList: UploadFile[]): string[] {
|
||||
return fileList
|
||||
.filter(f => f.status === 'done')
|
||||
.map(f => (f.response as { url?: string })?.url || f.url || '')
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
// ---------- 上传区域子组件 ----------
|
||||
|
||||
interface UploadSectionProps {
|
||||
type: 'image' | 'video' | 'audio';
|
||||
urls: string[];
|
||||
maxCount: number;
|
||||
maxSizeMb: number;
|
||||
disabled: boolean;
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
customRequest: ReturnType<typeof useFileUpload>['customRequest'];
|
||||
onChange: (urls: string[]) => void;
|
||||
}
|
||||
|
||||
function UploadSection({
|
||||
type, urls, maxCount, maxSizeMb, disabled, icon, label, customRequest, onChange,
|
||||
}: UploadSectionProps) {
|
||||
const fileList = useMemo(() => urlsToFileList(urls, type), [urls, type]);
|
||||
|
||||
const beforeUpload = useCallback((file: RcFile) => {
|
||||
const mimePrefix = type === 'image' ? 'image/' : type === 'video' ? 'video/' : 'audio/';
|
||||
if (!file.type.startsWith(mimePrefix)) {
|
||||
message.error(`只能上传${label}文件`);
|
||||
return Upload.LIST_IGNORE;
|
||||
}
|
||||
if (file.size / 1024 / 1024 >= maxSizeMb) {
|
||||
message.error(`${label}大小不能超过 ${maxSizeMb}MB`);
|
||||
return Upload.LIST_IGNORE;
|
||||
}
|
||||
return true;
|
||||
}, [type, label, maxSizeMb]);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(info: { fileList: UploadFile[] }) => {
|
||||
onChange(extractUrls(info.fileList));
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
if (maxCount <= 0) return null;
|
||||
|
||||
return (
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<Text type="secondary" style={{ fontSize: 12, display: 'block', marginBottom: 4 }}>
|
||||
{icon} {label}(最多 {maxCount} 个)
|
||||
</Text>
|
||||
<Dragger
|
||||
multiple={maxCount > 1}
|
||||
listType="picture"
|
||||
accept={type === 'image' ? 'image/*' : type === 'video' ? 'video/*' : 'audio/*'}
|
||||
fileList={fileList}
|
||||
customRequest={customRequest}
|
||||
onChange={handleChange}
|
||||
disabled={disabled}
|
||||
beforeUpload={beforeUpload}
|
||||
maxCount={maxCount}
|
||||
>
|
||||
<p className="ant-upload-drag-icon">
|
||||
<InboxOutlined />
|
||||
</p>
|
||||
<p className="ant-upload-text">点击或拖拽{label}到此区域上传</p>
|
||||
<p className="ant-upload-hint">单文件不超过 {maxSizeMb}MB</p>
|
||||
</Dragger>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- 主组件 ----------
|
||||
|
||||
export function SeedanceContent({ value, onChange, disabled, config }: WidgetProps) {
|
||||
const { token } = antTheme.useToken();
|
||||
const { customRequest } = useFileUpload();
|
||||
|
||||
const cv = useMemo(() => parseValue(value), [value]);
|
||||
|
||||
const imageFiles = (config.imageFiles as number) || 0;
|
||||
const videoFiles = (config.videoFiles as number) || 0;
|
||||
const audioFiles = (config.audioFiles as number) || 0;
|
||||
const maxSizeMb = (config.maxFileSizeMb as number) || 50;
|
||||
|
||||
const emit = useCallback(
|
||||
(patch: Partial<ContentValue>) => {
|
||||
const next = { ...cv, ...patch };
|
||||
onChange?.(serializeValue(next));
|
||||
},
|
||||
[cv, onChange],
|
||||
);
|
||||
|
||||
const handleTextChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
emit({ text: e.target.value });
|
||||
},
|
||||
[emit],
|
||||
);
|
||||
|
||||
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 }}>
|
||||
{(imageFiles > 0) && <PictureOutlined style={{ color: token.colorPrimary, fontSize: 14 }} />}
|
||||
{(videoFiles > 0) && <VideoCameraOutlined style={{ color: token.colorPrimary, fontSize: 14 }} />}
|
||||
{(audioFiles > 0) && <AudioOutlined style={{ color: token.colorPrimary, fontSize: 14 }} />}
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
内容编排 — 描述动作 + 参考素材
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<TextArea
|
||||
rows={4}
|
||||
value={cv.text}
|
||||
onChange={handleTextChange}
|
||||
disabled={disabled}
|
||||
placeholder={
|
||||
(config.placeholder as string) ||
|
||||
'输入视频内容的文字描述'
|
||||
}
|
||||
maxLength={(config.maxLength as number) || 5000}
|
||||
showCount
|
||||
/>
|
||||
|
||||
{/* 参考图片上传 */}
|
||||
{imageFiles > 0 && (
|
||||
<UploadSection
|
||||
type="image"
|
||||
urls={cv.imageUrls}
|
||||
maxCount={imageFiles}
|
||||
maxSizeMb={maxSizeMb}
|
||||
disabled={disabled ?? false}
|
||||
icon={<PictureOutlined />}
|
||||
label="参考图片"
|
||||
customRequest={customRequest}
|
||||
onChange={(urls) => emit({ imageUrls: urls })}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 参考视频上传 */}
|
||||
{videoFiles > 0 && (
|
||||
<UploadSection
|
||||
type="video"
|
||||
urls={cv.videoUrls}
|
||||
maxCount={videoFiles}
|
||||
maxSizeMb={Math.max(maxSizeMb, 100)}
|
||||
disabled={disabled ?? false}
|
||||
icon={<VideoCameraOutlined />}
|
||||
label="参考视频"
|
||||
customRequest={customRequest}
|
||||
onChange={(urls) => emit({ videoUrls: urls })}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 参考音频上传 */}
|
||||
{audioFiles > 0 && (
|
||||
<UploadSection
|
||||
type="audio"
|
||||
urls={cv.audioUrls}
|
||||
maxCount={audioFiles}
|
||||
maxSizeMb={maxSizeMb}
|
||||
disabled={disabled ?? false}
|
||||
icon={<AudioOutlined />}
|
||||
label="参考音频"
|
||||
customRequest={customRequest}
|
||||
onChange={(urls) => emit({ audioUrls: urls })}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
119
src/pages/home/controls/VideoInput.tsx
Normal file
119
src/pages/home/controls/VideoInput.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
// ============================================================
|
||||
// VideoInput — videoInput → 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 { VideoCameraOutlined } 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;
|
||||
|
||||
/**
|
||||
* 创建视频上传前的类型 & 大小校验函数。
|
||||
* @param maxSizeMb 最大文件大小(MB),默认 50
|
||||
*/
|
||||
export function createVideoBeforeUpload(maxSizeMb: number = 50) {
|
||||
return (file: RcFile) => {
|
||||
const isVideo = file.type.startsWith('video/');
|
||||
if (!isVideo) {
|
||||
message.error('只能上传视频文件');
|
||||
return Upload.LIST_IGNORE;
|
||||
}
|
||||
if (file.size / 1024 / 1024 >= maxSizeMb) {
|
||||
message.error(`视频大小不能超过 ${maxSizeMb}MB`);
|
||||
return Upload.LIST_IGNORE;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
}
|
||||
|
||||
/** 构建视频 accept 字符串 */
|
||||
function buildVideoAccept(filters?: string[]): string {
|
||||
if (!filters || filters.length === 0) return 'video/mp4,video/webm,video/quicktime,video/x-msvideo';
|
||||
return filters.map((ext) => `video/${ext}`).join(',');
|
||||
}
|
||||
|
||||
/** 标记"已启用但空"的空数组,避免 react 渲染时引用变化 */
|
||||
const ENABLED_EMPTY: UploadFile[] = [];
|
||||
|
||||
export function VideoInput({ value, onChange, disabled, config }: WidgetProps) {
|
||||
const fileList = (value as UploadFile[]) || [];
|
||||
const maxSizeMb = (config.maxFileSizeMb as number) || 50;
|
||||
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={buildVideoAccept(config.fileFilter as string[] | undefined)}
|
||||
fileList={fileList}
|
||||
customRequest={customRequest}
|
||||
onChange={({ fileList: newList }) => {
|
||||
// 仅当开关开启时上报文件变化
|
||||
if (switchOn || !hasEnableSwitch) {
|
||||
onChange?.(newList as unknown as UploadFile[]);
|
||||
}
|
||||
}}
|
||||
disabled={isUploadDisabled}
|
||||
beforeUpload={createVideoBeforeUpload(maxSizeMb)}
|
||||
>
|
||||
{fileList.length < 1 && (
|
||||
<div style={{ opacity: isUploadDisabled ? 0.4 : 1 }}>
|
||||
<VideoCameraOutlined />
|
||||
<div style={{ marginTop: 4, fontSize: 12 }}>上传视频</div>
|
||||
</div>
|
||||
)}
|
||||
</Upload>
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
@@ -23,6 +23,7 @@ import { DoubleSpinBox } from './DoubleSpinBox';
|
||||
import { ImageInput } from './ImageInput';
|
||||
import { ImageListInput } from './ImageListInput';
|
||||
import { AudioListInput } from './AudioListInput';
|
||||
import { VideoInput } from './VideoInput';
|
||||
import { SeedanceContent } from './SeedanceContent';
|
||||
|
||||
export type { WidgetProps } from './types';
|
||||
@@ -45,6 +46,7 @@ export const WIDGET_MAP: Record<string, ComponentType<WidgetProps>> = {
|
||||
imageInput: ImageInput,
|
||||
imageList: ImageListInput,
|
||||
audioList: AudioListInput,
|
||||
videoInput: VideoInput,
|
||||
seedance2Content: SeedanceContent,
|
||||
};
|
||||
|
||||
|
||||
62
src/pages/home/controls/media-upload-utils.ts
Normal file
62
src/pages/home/controls/media-upload-utils.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
// ============================================================
|
||||
// media-upload-utils — ImageInput / ImageListInput / VideoInput 共享工具
|
||||
// ============================================================
|
||||
|
||||
import { message, Upload } from 'antd';
|
||||
import type { RcFile } from 'antd/es/upload';
|
||||
|
||||
/** 常见视频扩展名,用于 buildAccept / createMediaBeforeUpload 区分图片/视频 */
|
||||
export const VIDEO_EXTENSIONS = new Set(['mp4', 'webm', 'mov', 'avi', 'mkv', 'flv', 'wmv']);
|
||||
|
||||
/**
|
||||
* 构建媒体文件 accept 字符串,自动区分 image/ video/ 前缀。
|
||||
*
|
||||
* @example
|
||||
* buildAccept(['png', 'jpg']) → "image/png,image/jpg"
|
||||
* buildAccept(['mp4', 'webm']) → "video/mp4,video/webm"
|
||||
* buildAccept([]) → "image/png,image/jpg,image/jpeg,image/webp"
|
||||
*/
|
||||
export function buildAccept(filters?: string[]): string {
|
||||
if (!filters || filters.length === 0) return 'image/png,image/jpg,image/jpeg,image/webp';
|
||||
return filters.map((ext) => {
|
||||
if (VIDEO_EXTENSIONS.has(ext.toLowerCase())) return `video/${ext}`;
|
||||
return `image/${ext}`;
|
||||
}).join(',');
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建上传前的类型 & 大小校验函数(ImageInput / ImageListInput 共用)。
|
||||
*
|
||||
* 当 file_filter 包含视频扩展名时,自动放行 video/* MIME 文件。
|
||||
* 不传入 filters 时仅接受 image/*(向后兼容)。
|
||||
*
|
||||
* @param maxSizeMb 最大文件大小(MB),默认 10
|
||||
* @param filters API spec.file_filter 数组
|
||||
*/
|
||||
export function createMediaBeforeUpload(maxSizeMb: number = 10, filters?: string[]) {
|
||||
const hasVideoFilters = filters?.some(f => VIDEO_EXTENSIONS.has(f.toLowerCase()));
|
||||
|
||||
return (file: RcFile) => {
|
||||
const isImage = file.type.startsWith('image/');
|
||||
const isVideo = file.type.startsWith('video/');
|
||||
|
||||
if (isVideo && hasVideoFilters) {
|
||||
if (file.size / 1024 / 1024 >= maxSizeMb) {
|
||||
message.error(`视频大小不能超过 ${maxSizeMb}MB`);
|
||||
return Upload.LIST_IGNORE;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!isImage) {
|
||||
message.error(hasVideoFilters ? '只能上传图片或视频文件' : '只能上传图片文件');
|
||||
return Upload.LIST_IGNORE;
|
||||
}
|
||||
|
||||
if (file.size / 1024 / 1024 >= maxSizeMb) {
|
||||
message.error(`图片大小不能超过 ${maxSizeMb}MB`);
|
||||
return Upload.LIST_IGNORE;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user