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:
2026-06-18 19:22:28 +08:00
parent e1c8eff946
commit e4c9ae8fc6
42 changed files with 2640 additions and 126 deletions

View File

@@ -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 }}>

View File

@@ -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>
);

View File

@@ -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>
);
}

View 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) {
// 关闭开关 → 表单值置为 undefinedisValueEmpty = 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>
);
}

View File

@@ -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,
};

View 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;
};
}