diff --git a/apps/ai-game-creator-shell/src-tauri/src/commands.rs b/apps/ai-game-creator-shell/src-tauri/src/commands.rs index 9ecc16c8b..85115930e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -5,6 +5,190 @@ const UI_EDITOR_FONT_MAX_FILE_SIZE: u64 = 8 * 1024 * 1024; const UI_EDITOR_FONT_MAX_TOTAL_SIZE: u64 = 32 * 1024 * 1024; const UI_EDITOR_FONT_MAX_COUNT: usize = 64; +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct AssetImportRequirements { + max_items: usize, + max_file_size_bytes: Option, + max_total_size_bytes: Option, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct AssetImportPolicy { + destination: String, + accepted_media_types: Vec, + accepted_extensions: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(tag = "source", rename_all = "camelCase")] +pub enum UiEditorAssetImportRequest { + Local { + project_path: String, + local_source_paths: Vec, + local_policy: AssetImportPolicy, + local_requirements: AssetImportRequirements, + }, + Remote { + project_path: String, + remote_assets: Vec, + remote_policy: AssetImportPolicy, + remote_requirements: AssetImportRequirements, + }, +} + +fn validate_asset_import_policy(policy: &AssetImportPolicy) -> Result { + let normalized_destination = policy.destination.trim(); + let is_font = normalized_destination == "assets/fonts"; + let is_image = normalized_destination == "assets/uploads"; + if !is_font && !is_image { + return Err("不支持的资产导入目录".to_string()); + } + let allowed_media_types = if is_font { + ["font/ttf", "font/otf", "font/woff", "font/woff2"] + .into_iter() + .collect::>() + } else { + ["image/png", "image/jpeg", "image/webp"] + .into_iter() + .collect::>() + }; + if policy + .accepted_media_types + .iter() + .any(|value| !allowed_media_types.contains(value.trim().to_ascii_lowercase().as_str())) + { + return Err("导入策略包含不支持的媒体类型".to_string()); + } + let allowed_extensions = if is_font { + ["ttf", "otf", "woff", "woff2"] + .into_iter() + .collect::>() + } else { + ["png", "jpg", "jpeg", "webp"] + .into_iter() + .collect::>() + }; + if policy.accepted_extensions.iter().any(|value| { + !allowed_extensions.contains( + value + .trim() + .trim_start_matches('.') + .to_ascii_lowercase() + .as_str(), + ) + }) { + return Err("导入策略包含不支持的文件扩展名".to_string()); + } + Ok(is_font) +} + +fn validate_local_asset_import_requirements( + source_paths: &[String], + requirements: &AssetImportRequirements, +) -> Result { + if source_paths.len() > requirements.max_items { + return Err(format!("最多导入 {} 个文件", requirements.max_items)); + } + let mut total_size = 0u64; + for source in source_paths { + let metadata = + fs::symlink_metadata(source.trim()).map_err(|_| "读取本地文件失败".to_string())?; + if metadata.file_type().is_symlink() || !metadata.is_file() { + return Err("只能导入普通文件".to_string()); + } + if let Some(max_size) = requirements.max_file_size_bytes { + if metadata.len() > max_size { + return Err(format!("文件超过 {} 字节限制", max_size)); + } + } + total_size = total_size.saturating_add(metadata.len()); + } + if let Some(max_total_size) = requirements.max_total_size_bytes { + if total_size > max_total_size { + return Err(format!("文件总量超过 {} 字节限制", max_total_size)); + } + } + Ok(total_size) +} + +fn validate_remote_asset_import_requirements( + assets: &[serde_json::Value], + requirements: &AssetImportRequirements, +) -> Result<(), String> { + if assets.len() > requirements.max_items { + return Err(format!("最多导入 {} 个文件", requirements.max_items)); + } + let mut total_size = 0u64; + for asset in assets { + let bytes = asset + .get("bytes") + .cloned() + .ok_or_else(|| "远端素材缺少文件内容".to_string()) + .and_then(|value| { + serde_json::from_value::>(value).map_err(|_| "远端素材内容无效".to_string()) + })?; + let size = bytes.len() as u64; + if let Some(max_size) = requirements.max_file_size_bytes { + if size > max_size { + return Err(format!("文件超过 {} 字节限制", max_size)); + } + } + total_size = total_size.saturating_add(size); + } + if let Some(max_total_size) = requirements.max_total_size_bytes { + if total_size > max_total_size { + return Err(format!("文件总量超过 {} 字节限制", max_total_size)); + } + } + Ok(()) +} + +#[tauri::command] +pub(crate) async fn import_ui_editor_assets( + request: UiEditorAssetImportRequest, +) -> Result { + match request { + UiEditorAssetImportRequest::Local { + project_path, + local_source_paths, + local_policy, + local_requirements, + } => { + let is_font = validate_asset_import_policy(&local_policy)?; + validate_local_asset_import_requirements(&local_source_paths, &local_requirements)?; + if is_font { + import_ui_editor_local_fonts(project_path, local_source_paths) + } else { + import_ui_editor_local_files( + project_path, + local_source_paths, + local_requirements.max_file_size_bytes.unwrap_or(u64::MAX), + ) + } + } + UiEditorAssetImportRequest::Remote { + project_path, + remote_assets, + remote_policy, + remote_requirements, + } => { + let is_font = validate_asset_import_policy(&remote_policy)?; + if is_font { + return Err("字体导入不支持云端素材".to_string()); + } + validate_remote_asset_import_requirements(&remote_assets, &remote_requirements)?; + import_ui_editor_remote_assets( + project_path, + remote_assets, + remote_requirements.max_file_size_bytes.unwrap_or(u64::MAX), + ) + .await + } + } +} + #[tauri::command] pub(crate) fn init_local_game_project( project_path: String, @@ -1485,7 +1669,6 @@ pub(crate) async fn sync_canvas_project_assets( sync_canvas_project_assets_at(root, canvas_project_id.trim(), api_base_url, api_key).await } -#[tauri::command] pub(crate) fn import_ui_editor_local_files( project_path: String, source_paths: Vec, @@ -1618,7 +1801,6 @@ pub(crate) fn prepare_ui_editor_project_fonts( Ok(fonts) } -#[tauri::command] pub(crate) fn import_ui_editor_local_fonts( project_path: String, source_paths: Vec, @@ -1952,7 +2134,6 @@ mod ui_editor_font_tests { } } -#[tauri::command] pub(crate) async fn import_ui_editor_remote_assets( project_path: String, assets: Vec, diff --git a/apps/ai-game-creator-shell/src-tauri/src/main.rs b/apps/ai-game-creator-shell/src-tauri/src/main.rs index a1aa6485f..0290151e6 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -2298,12 +2298,10 @@ fn main() { import_canvas_asset, import_canvas_export, sync_canvas_project_assets, - import_ui_editor_local_files, - import_ui_editor_local_fonts, + import_ui_editor_assets, prepare_ui_editor_project_fonts, read_ui_editor_font_bytes, check_ui_editor_font_glyph_coverage, - import_ui_editor_remote_assets, suggest_ui_design_semantic, recognize_ui, merge_ui, diff --git a/apps/ai-game-creator-shell/src/components/AssetImporter/index.tsx b/apps/ai-game-creator-shell/src/components/AssetImporter/index.tsx index 7cd725d79..9b6493369 100644 --- a/apps/ai-game-creator-shell/src/components/AssetImporter/index.tsx +++ b/apps/ai-game-creator-shell/src/components/AssetImporter/index.tsx @@ -18,7 +18,7 @@ import { } from '../../services/projectResourcePreviewTransport'; import { ThemedModal } from '../modal/ThemedModal'; import { - type AssetImporterMode, + type AssetImporterSettings, buildProjectFiles, buildRemoteFolderFiles, buildRootFiles, @@ -30,18 +30,22 @@ import { } from './utils'; export type { ImportedAsset } from './utils'; +export type { + AssetImporterSettings, + ImportPolicy, + ImportRequirements, + LocalAssetCandidate, + RemoteAssetCandidate, +} from './utils'; const ROOT_PATH = '/'; -export type UiEditorImageImporterProps = { +export type AssetImporterProps = { open: boolean; onClose: () => void; onImport: (assets: ImportedAsset[]) => void; projectPath: string; - maxItems: number; - maxFileSize: number; - acceptedMediaTypes: readonly string[]; - mode?: AssetImporterMode; + settings: AssetImporterSettings; }; type LocalProjectFile = { path: string }; @@ -61,16 +65,17 @@ type ImportAssetResponse = { assetKind?: string | null; }; +type GenericImportResponse = { assets: ImportAssetResponse[] }; + export function AssetImporter({ open, onClose, onImport, projectPath, - maxItems, - maxFileSize, - acceptedMediaTypes, - mode = 'image', -}: UiEditorImageImporterProps) { + settings, +}: AssetImporterProps) { + const maxItems = settings.local.requirements.maxItems; + const maxFileSize = settings.local.requirements.maxFileSizeBytes ?? 0; const [files, setFiles] = useState([]); const [selected, setSelected] = useState([]); const [preview, setPreview] = useState(null); @@ -118,41 +123,45 @@ export function AssetImporter({ filesFromDisk .map((file) => byPath.get(file.path)) .filter(Boolean) as ManifestAsset[], - mode, + settings.local.typeFilter, ); - }, [mode, projectPath]); + }, [projectPath, settings.local.typeFilter]); const replaceProjectFiles = useCallback( (projectFiles: ManagerFile[]) => { setFiles((current) => [ - ...buildRootFiles(mode), + ...buildRootFiles(settings), ...projectFiles, - ...(mode === 'image' + ...(settings.remote ? current.filter((file) => file.source === 'remote') : []), ]); }, - [mode], + [settings], ); const replaceRemoteFiles = useCallback( (remoteFiles: ManagerFile[]) => { setFiles((current) => [ - ...buildRootFiles(mode), + ...buildRootFiles(settings), ...current.filter((file) => file.source === 'local'), ...remoteFiles, ]); remoteLoadedRef.current = true; }, - [mode], + [settings], ); const loadRemote = useCallback(async () => { const library = await loadEditorAssetLibrary(); - const remoteFiles = buildRemoteFolderFiles(library); + if (!settings.remote) return []; + const remoteFiles = buildRemoteFolderFiles( + library, + settings.remote.typeFilter, + ); replaceRemoteFiles(remoteFiles); return remoteFiles; - }, [replaceRemoteFiles]); + }, [replaceRemoteFiles, settings.remote]); const refresh = useCallback( async (path = currentPathRef.current) => { @@ -162,7 +171,7 @@ export function AssetImporter({ const shouldRefreshProject = path === ROOT_PATH || path.startsWith(PROJECT_ASSETS_PATH); const shouldRefreshRemote = - mode === 'image' && + settings.remote && (path === ROOT_PATH || path.startsWith(REMOTE_ASSETS_PATH)); const projectResult = shouldRefreshProject ? await Promise.allSettled([loadLocal()]).then(([result]) => result) @@ -201,7 +210,7 @@ export function AssetImporter({ setLoading(false); } }, - [loadLocal, loadRemote, mode, replaceProjectFiles], + [loadLocal, loadRemote, replaceProjectFiles, settings.remote], ); useEffect(() => { @@ -209,18 +218,14 @@ export function AssetImporter({ currentPathRef.current = ROOT_PATH; remoteLoadedRef.current = false; setCurrentPath(ROOT_PATH); - setFiles(buildRootFiles(mode)); + setFiles(buildRootFiles(settings)); setSelected([]); setPreview(null); void refresh(ROOT_PATH); - }, [mode, open, refresh]); + }, [open, refresh, settings]); useEffect(() => { const item = selected.at(-1); - if (mode === 'font') { - setPreview(null); - return; - } if (!item || item.isDirectory) { setPreview(null); return; @@ -250,49 +255,37 @@ export function AssetImporter({ }) .then((value) => setPreview(value.dataUrl)) .catch(() => setPreview(null)); - }, [mode, projectPath, selected]); + }, [projectPath, selected]); const pickLocal = async () => { setError(null); setLoading(true); try { const paths = await openNativeFileDialog({ - title: mode === 'font' ? '选择字体文件' : '选择图片素材', + title: settings.local.fileDialog.title, multiple: true, directory: false, - filters: - mode === 'font' - ? [ - { - name: 'Fonts', - extensions: ['ttf', 'otf', 'woff', 'woff2'], - }, - ] - : [ - { - name: 'Images', - extensions: ['png', 'jpg', 'jpeg', 'webp'], - }, - ], + filters: settings.local.fileDialog.filters.map((filter) => ({ + name: filter.name, + extensions: [...filter.extensions], + })), }); const sourcePaths = Array.isArray(paths) ? paths : paths ? [paths] : []; if (!sourcePaths.length) return; if (sourcePaths.length > maxItems) - throw new Error( - mode === 'font' - ? `最多选择 ${maxItems} 个字体面` - : `最多选择 ${maxItems} 张图片`, - ); - const result = - mode === 'font' - ? await invoke<{ assets: ImportAssetResponse[] }>( - 'import_ui_editor_local_fonts', - { projectPath, sourcePaths }, - ) - : await invoke<{ assets: ImportAssetResponse[] }>( - 'import_ui_editor_local_files', - { projectPath, sourcePaths, maxFileSize }, - ); + throw new Error(`最多选择 ${maxItems} 个文件`); + const result = await invoke( + 'import_ui_editor_assets', + { + request: { + projectPath, + source: 'local', + localSourcePaths: sourcePaths, + localPolicy: settings.local.localPolicy, + localRequirements: settings.local.requirements, + }, + }, + ); onImport( result.assets.map((asset) => ({ id: asset.id, @@ -318,31 +311,33 @@ export function AssetImporter({ (entry) => entry.asset!, ); if (remoteChosen.length) { - const remoteResult = await invoke<{ assets: ImportAssetResponse[] }>( - 'import_ui_editor_remote_assets', + const remoteResult = await invoke( + 'import_ui_editor_assets', { - projectPath, - assets: await Promise.all( - remoteChosen.map(async (item) => { - const asset = item.asset!; - const response = asset.localPath.startsWith('http') - ? await fetch(asset.localPath) - : await readClientAssetBytes(asset.localPath); - if (!response.ok) - throw new Error( - `读取平台素材失败(HTTP ${response.status})`, - ); - const bytes = new Uint8Array(await response.arrayBuffer()); - if (bytes.byteLength > maxFileSize) - throw new Error(`图片超过 ${maxFileSize} 字节限制`); - return { - assetId: asset.id, - objectKey: asset.localPath, - bytes: Array.from(bytes), - }; - }), - ), - maxFileSize, + request: { + projectPath, + source: 'remote', + remoteAssets: await Promise.all( + remoteChosen.map(async (item) => { + const asset = item.asset!; + const response = asset.localPath.startsWith('http') + ? await fetch(asset.localPath) + : await readClientAssetBytes(asset.localPath); + if (!response.ok) + throw new Error( + `读取平台素材失败(HTTP ${response.status})`, + ); + const bytes = new Uint8Array(await response.arrayBuffer()); + return { + assetId: asset.id, + objectKey: asset.localPath, + bytes: Array.from(bytes), + }; + }), + ), + remotePolicy: settings.remote!.remotePolicy, + remoteRequirements: settings.remote!.requirements, + }, }, ); imported.push( @@ -370,7 +365,7 @@ export function AssetImporter({ currentPathRef.current = nextPath; setCurrentPath(nextPath); if ( - mode === 'image' && + settings.remote && nextPath.startsWith(REMOTE_ASSETS_PATH) && !remoteLoadedRef.current ) { @@ -388,12 +383,13 @@ export function AssetImporter({

- {mode === 'font' ? '导入字体' : '导入图片素材'} + {settings.icon} + {settings.title}