diff --git a/apps/ai-game-creator-shell/src-tauri/capabilities/main.json b/apps/ai-game-creator-shell/src-tauri/capabilities/main.json index d4e2ade6a..43257763a 100644 --- a/apps/ai-game-creator-shell/src-tauri/capabilities/main.json +++ b/apps/ai-game-creator-shell/src-tauri/capabilities/main.json @@ -1,7 +1,7 @@ { "$schema": "../gen/schemas/desktop-schema.json", "identifier": "main", - "description": "AI 游戏创作主窗口允许读取系统剪贴板图片,用于粘贴素材附件。", + "description": "AI 游戏创作主窗口允许读取系统剪贴板图片,用于粘贴素材附件;允许弹出原生打开/保存对话框用于素材上传与导出。", "windows": ["client"], "permissions": [ "clipboard-manager:allow-read-image", @@ -21,6 +21,7 @@ ] }, "opener:default", - "dialog:allow-open" + "dialog:allow-open", + "dialog:allow-save" ] } 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 2831a0cd7..4ccce0aa5 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -4176,6 +4176,12 @@ pub(crate) fn read_local_project_file( read_local_project_file_at(root, &normalized_path) } +#[tauri::command] +pub(crate) fn save_local_project_asset_file( + input: SaveLocalProjectAssetFileInput, +) -> Result { + save_local_project_asset_file_at(input) +} #[tauri::command] pub(crate) async fn read_local_project_image_preview( preview_manager: tauri::State<'_, ProjectResourcePreviewReadManager>, 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 7a8b93001..b20844c90 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -2647,6 +2647,7 @@ fn main() { import_local_project_image_assets, read_local_project_file, read_local_project_image_preview, + save_local_project_asset_file, read_local_project_text_preview, read_local_project_media_preview, cancel_local_project_resource_preview_scope, diff --git a/apps/ai-game-creator-shell/src-tauri/src/project.rs b/apps/ai-game-creator-shell/src-tauri/src/project.rs index 7baff5dbe..9f5f87a63 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project.rs @@ -4,6 +4,7 @@ use similar::TextDiff; use std::io::{Seek, SeekFrom}; mod agent_db; +mod asset_export; mod asset_usage; mod checkpoint; mod conversation; @@ -18,6 +19,7 @@ mod resource_layout; mod verification; pub(crate) use agent_db::*; +pub(crate) use asset_export::*; pub(crate) use asset_usage::*; pub(crate) use checkpoint::*; pub(crate) use conversation::*; diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/asset_export.rs b/apps/ai-game-creator-shell/src-tauri/src/project/asset_export.rs new file mode 100644 index 000000000..7adaa60bb --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/project/asset_export.rs @@ -0,0 +1,255 @@ +use super::*; +use std::fs::File; +use std::io::{Read, Write}; + +/// 显式保存:把项目内已登记的素材文件复制到用户选定的目标路径。 +/// +/// 这里刻意不做"浏览器下载"——AGC 是 Tauri/WebView2 宿主,未注册 `on_download` +/// 时 `` 能否落盘不可靠,所以保存路径由原生对话框给出,复制由 Rust 完成。 +const ASSET_EXPORT_COPY_CHUNK_BYTES: usize = 64 * 1024; + +#[derive(Clone, Debug, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub(crate) struct SaveLocalProjectAssetFileInput { + pub(crate) project_path: String, + pub(crate) relative_path: String, + pub(crate) destination_path: String, +} + +#[derive(Clone, Debug, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct SaveLocalProjectAssetFileResult { + pub(crate) destination_path: String, + pub(crate) byte_len: u64, +} + +fn resolve_export_source_file( + root: &Path, + relative_path: &str, +) -> Result { + let normalized = normalize_relative_path(relative_path.trim())?; + if normalized.is_empty() { + return Err("待保存的素材路径不能为空".to_string()); + } + let source = root.join(&normalized); + // 只允许保存项目根内真实存在的普通文件:符号链接与目录都要拒绝。 + let metadata = std::fs::symlink_metadata(&source) + .map_err(|error| format!("素材文件不存在或不可读:{error}"))?; + if metadata.file_type().is_symlink() { + return Err("素材路径不能是符号链接".to_string()); + } + if !metadata.is_file() { + return Err("素材路径必须是普通文件".to_string()); + } + Ok(source) +} + +fn resolve_export_destination_file(destination_path: &str) -> Result { + let trimmed = destination_path.trim(); + if trimmed.is_empty() { + return Err("保存目标路径不能为空".to_string()); + } + let destination = PathBuf::from(trimmed); + if destination.file_name().is_none() { + return Err("保存目标必须是文件路径".to_string()); + } + let parent = destination + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .ok_or_else(|| "保存目标缺少父目录".to_string())?; + if !parent.is_dir() { + return Err("保存目标的父目录不存在".to_string()); + } + if destination.is_dir() { + return Err("保存目标不能是目录".to_string()); + } + Ok(destination) +} + +pub(crate) fn save_local_project_asset_file_at( + input: SaveLocalProjectAssetFileInput, +) -> Result { + let root = Path::new(input.project_path.trim()); + validate_project_root(root)?; + enforce_project_auto_permission_policy(root, "file.read")?; + + let source = resolve_export_source_file(root, &input.relative_path)?; + let destination = resolve_export_destination_file(&input.destination_path)?; + + let mut reader = + File::open(&source).map_err(|error| format!("打开素材文件失败:{error}"))?; + let mut writer = File::create(&destination) + .map_err(|error| format!("创建保存目标失败:{error}"))?; + let mut buffer = vec![0_u8; ASSET_EXPORT_COPY_CHUNK_BYTES]; + let mut byte_len = 0_u64; + loop { + let read = reader + .read(&mut buffer) + .map_err(|error| format!("读取素材文件失败:{error}"))?; + if read == 0 { + break; + } + writer + .write_all(&buffer[..read]) + .map_err(|error| format!("写入保存目标失败:{error}"))?; + byte_len += read as u64; + } + writer + .flush() + .map_err(|error| format!("刷新保存目标失败:{error}"))?; + + Ok(SaveLocalProjectAssetFileResult { + destination_path: destination.to_string_lossy().into_owned(), + byte_len, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::{AtomicU64, Ordering}; + + static NEXT_ASSET_EXPORT_TEST_ID: AtomicU64 = AtomicU64::new(0); + + fn unique_asset_export_directory(prefix: &str) -> PathBuf { + std::env::temp_dir().join(format!( + "genarrative-asset-export-{prefix}-{}-{}", + std::process::id(), + NEXT_ASSET_EXPORT_TEST_ID.fetch_add(1, Ordering::Relaxed) + )) + } + + fn create_export_project() -> (PathBuf, PathBuf) { + let root = unique_asset_export_directory("project"); + let assets = root.join("assets"); + std::fs::create_dir_all(&assets).expect("create project assets directory"); + (root, assets) + } + + #[test] + fn copies_registered_asset_to_chosen_destination() { + let (root, assets) = create_export_project(); + let source_bytes = b"genarrative-asset-export-payload".to_vec(); + std::fs::write(assets.join("hero.png"), &source_bytes).expect("write source asset"); + let destination_directory = unique_asset_export_directory("destination"); + std::fs::create_dir_all(&destination_directory).expect("create destination directory"); + let destination = destination_directory.join("hero-copy.png"); + + let result = save_local_project_asset_file_at(SaveLocalProjectAssetFileInput { + project_path: root.to_string_lossy().into_owned(), + relative_path: "assets/hero.png".to_string(), + destination_path: destination.to_string_lossy().into_owned(), + }) + .expect("save asset file"); + + assert_eq!(result.byte_len, source_bytes.len() as u64); + assert_eq!(result.destination_path, destination.to_string_lossy()); + assert_eq!( + std::fs::read(&destination).expect("read copied asset"), + source_bytes + ); + // 源素材保持不变:保存只是复制,不改动项目内文件。 + assert_eq!( + std::fs::read(assets.join("hero.png")).expect("read source asset"), + source_bytes + ); + + let _ = std::fs::remove_dir_all(&root); + let _ = std::fs::remove_dir_all(&destination_directory); + } + + #[test] + fn rejects_missing_source_asset() { + let (root, _assets) = create_export_project(); + let destination_directory = unique_asset_export_directory("destination"); + std::fs::create_dir_all(&destination_directory).expect("create destination directory"); + + let error = save_local_project_asset_file_at(SaveLocalProjectAssetFileInput { + project_path: root.to_string_lossy().into_owned(), + relative_path: "assets/missing.png".to_string(), + destination_path: destination_directory + .join("missing-copy.png") + .to_string_lossy() + .into_owned(), + }) + .expect_err("missing source must fail"); + + assert!(error.contains("素材文件不存在或不可读"), "{error}"); + let _ = std::fs::remove_dir_all(&root); + let _ = std::fs::remove_dir_all(&destination_directory); + } + + #[test] + fn rejects_directory_as_source_asset() { + let (root, assets) = create_export_project(); + std::fs::create_dir_all(assets.join("nested")).expect("create nested directory"); + let destination_directory = unique_asset_export_directory("destination"); + std::fs::create_dir_all(&destination_directory).expect("create destination directory"); + + let error = save_local_project_asset_file_at(SaveLocalProjectAssetFileInput { + project_path: root.to_string_lossy().into_owned(), + relative_path: "assets/nested".to_string(), + destination_path: destination_directory + .join("nested-copy") + .to_string_lossy() + .into_owned(), + }) + .expect_err("directory source must fail"); + + assert!(error.contains("必须是普通文件"), "{error}"); + let _ = std::fs::remove_dir_all(&root); + let _ = std::fs::remove_dir_all(&destination_directory); + } + + #[test] + fn rejects_blank_destination_and_missing_parent_directory() { + let (root, assets) = create_export_project(); + std::fs::write(assets.join("hero.png"), b"payload").expect("write source asset"); + + let blank = save_local_project_asset_file_at(SaveLocalProjectAssetFileInput { + project_path: root.to_string_lossy().into_owned(), + relative_path: "assets/hero.png".to_string(), + destination_path: " ".to_string(), + }) + .expect_err("blank destination must fail"); + assert!(blank.contains("保存目标路径不能为空"), "{blank}"); + + let missing_parent_directory = unique_asset_export_directory("absent"); + let missing_parent = save_local_project_asset_file_at( + SaveLocalProjectAssetFileInput { + project_path: root.to_string_lossy().into_owned(), + relative_path: "assets/hero.png".to_string(), + destination_path: missing_parent_directory + .join("hero-copy.png") + .to_string_lossy() + .into_owned(), + }, + ) + .expect_err("missing parent directory must fail"); + assert!( + missing_parent.contains("保存目标的父目录不存在"), + "{missing_parent}" + ); + + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn rejects_destination_that_is_an_existing_directory() { + let (root, assets) = create_export_project(); + std::fs::write(assets.join("hero.png"), b"payload").expect("write source asset"); + let destination_directory = unique_asset_export_directory("destination"); + std::fs::create_dir_all(&destination_directory).expect("create destination directory"); + + let error = save_local_project_asset_file_at(SaveLocalProjectAssetFileInput { + project_path: root.to_string_lossy().into_owned(), + relative_path: "assets/hero.png".to_string(), + destination_path: destination_directory.to_string_lossy().into_owned(), + }) + .expect_err("directory destination must fail"); + + assert!(error.contains("保存目标不能是目录"), "{error}"); + let _ = std::fs::remove_dir_all(&root); + let _ = std::fs::remove_dir_all(&destination_directory); + } +} diff --git a/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasPanelView.tsx b/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasPanelView.tsx new file mode 100644 index 000000000..d9400857d --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasPanelView.tsx @@ -0,0 +1,187 @@ +import { Download, Loader2, Upload, X } from 'lucide-react'; +import { useEffect, useId, useRef } from 'react'; + +import type { ResourceCanvasPanelEntry } from './resourceCanvasAssetTransferModel'; + +export type ResourceCanvasPanelViewProps = { + entries: readonly ResourceCanvasPanelEntry[]; + selectedResourceIds: readonly string[]; + onToggleEntry: (resourceId: string) => void; + onSelectAll: () => void; + onClearSelection: () => void; + onUploadFiles: (files: FileList) => void; + onDownloadSelection: () => void; + isUploading: boolean; + notice: string; + onClose: () => void; +}; + +/** + * 资源面板:独立的浮层面板(不挂在其它面板下面),承载预览 / 上传 / 下载 / 多选。 + * + * 选中状态直接读写画布那一份 `selectedResourceIds`,面板不维护第二套选择状态。 + */ +export function ResourceCanvasPanelView({ + entries, + selectedResourceIds, + onToggleEntry, + onSelectAll, + onClearSelection, + onUploadFiles, + onDownloadSelection, + isUploading, + notice, + onClose, +}: ResourceCanvasPanelViewProps) { + const titleId = useId(); + const closeButtonRef = useRef(null); + const selected = new Set(selectedResourceIds); + const selectedCount = entries.filter((entry) => + selected.has(entry.resourceId), + ).length; + + useEffect(() => { + closeButtonRef.current?.focus(); + }, []); + + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + event.preventDefault(); + onClose(); + } + }; + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [onClose]); + + return ( +
{ + if (event.target === event.currentTarget) { + onClose(); + } + }} + > +
+
+
+

资源面板

+

{`${entries.length} 项资源,已选 ${selectedCount} 项`}

+
+ +
+ +
+ + + + +
+ + {notice ? ( +

+ {notice} +

+ ) : null} + + {entries.length === 0 ? ( +

当前筛选下没有资源

+ ) : ( +
    + {entries.map((entry) => { + const isSelected = selected.has(entry.resourceId); + return ( +
  • + +
    + {entry.label} + {`${entry.categoryLabel} · ${entry.typeLabel}`} +
    +
  • + ); + })} +
+ )} +
+
+ ); +} diff --git a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasAssetTransferModel.ts b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasAssetTransferModel.ts new file mode 100644 index 000000000..96e1e6216 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasAssetTransferModel.ts @@ -0,0 +1,96 @@ +import type { ProjectResource } from '../../view/project-development/resourceProjectionModel'; + +/** 上传只接受浏览器/客户端都能直读字节的图片与音频,其它类型走 Agent 侧登记。 */ +const UPLOADABLE_MEDIA_TYPE_PREFIXES = ['image/', 'audio/', 'video/']; + +export type ResourceCanvasPanelEntry = { + resourceId: string; + label: string; + categoryLabel: string; + typeLabel: string; + path: string; + mediaType: string; + selectable: boolean; + downloadable: boolean; + previewIdentity: string | null; + previewSourceUrl: string | null; + previewStatus: 'idle' | 'loading' | 'loaded' | 'failed'; + previewError: string | null; +}; + +export type ResourceCanvasPanelEntryInput = { + resource: ProjectResource; + categoryLabel: string; + typeLabel: string; + previewIdentity: string | null; + previewStatus: 'idle' | 'loading' | 'loaded' | 'failed'; + previewSourceUrl: string | null; + previewError: string | null; +}; + +export function resolveResourceCanvasPanelEntries( + inputs: readonly ResourceCanvasPanelEntryInput[], +): ResourceCanvasPanelEntry[] { + return inputs.map((input) => ({ + resourceId: input.resource.id, + label: input.resource.label, + categoryLabel: input.categoryLabel, + typeLabel: input.typeLabel, + path: input.resource.path, + mediaType: input.resource.mediaType, + selectable: true, + // 保存走本地文件复制,因此只要求有可读路径;虚拟版本条目没有文件,不能保存。 + downloadable: + input.resource.version === undefined && input.resource.path !== '', + previewIdentity: input.previewIdentity, + previewSourceUrl: input.previewSourceUrl, + previewStatus: input.previewStatus, + previewError: input.previewError, + })); +} + +export function resolveSelectedPanelEntries( + entries: readonly ResourceCanvasPanelEntry[], + selectedResourceIds: readonly string[], +) { + const selected = new Set(selectedResourceIds); + return entries.filter((entry) => selected.has(entry.resourceId)); +} + +export function resolveDownloadablePanelEntries( + entries: readonly ResourceCanvasPanelEntry[], +) { + return entries.filter((entry) => entry.downloadable); +} + +export function resourceFileNameFromPath(path: string) { + const segments = path.split(/[\\/]/u).filter(Boolean); + return segments.at(-1) ?? path; +} + +export function defaultResourceExportFileName( + entry: Pick, +) { + const fromPath = resourceFileNameFromPath(entry.path); + if (fromPath && fromPath !== entry.path) { + return fromPath; + } + return entry.label; +} + +export function isUploadableResourceFile(file: { + type?: string; + name: string; +}) { + const mediaType = (file.type ?? '').toLowerCase(); + if ( + UPLOADABLE_MEDIA_TYPE_PREFIXES.some((prefix) => + mediaType.startsWith(prefix), + ) + ) { + return true; + } + return /\.(png|jpe?g|webp|gif|bmp|avif|mp3|wav|ogg|m4a|mp4|webm|mov)$/iu.test( + file.name, + ); +} diff --git a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasChrome.css b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasChrome.css index 9490e5615..de56e491d 100644 --- a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasChrome.css +++ b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasChrome.css @@ -239,3 +239,138 @@ width: 94vw; } } + +/* 资源面板:独立浮层面板(预览 / 上传 / 下载 / 多选)。 */ +.game-resource-panel { + width: min(880px, calc(100% - 32px)); + max-height: calc(100% - 32px); + display: flex; + flex-direction: column; + gap: 0.6rem; +} + +.game-resource-panel-actions { + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; +} + +.game-resource-panel-actions > button, +.game-resource-panel-upload { + display: inline-flex; + align-items: center; + gap: 0.35rem; + height: 32px; + padding: 0 0.7rem; + border: 1px solid #ecdcd4; + border-radius: 8px; + background: #fffdfa; + color: #6b4a3c; + font-size: 0.78rem; + font-weight: 700; + cursor: pointer; +} + +.game-resource-panel-actions > button:disabled, +.game-resource-panel-upload:has(input:disabled) { + opacity: 0.55; + cursor: not-allowed; +} + +.game-resource-panel-upload { + position: relative; + overflow: hidden; +} + +.game-resource-panel-upload input { + position: absolute; + inset: 0; + opacity: 0; + cursor: pointer; +} + +.game-resource-panel-download { + margin-left: auto; +} + +.game-resource-panel-notice { + margin: 0; + color: #8c6252; + font-size: 0.78rem; +} + +.game-resource-panel-empty { + margin: 0; + color: #8c6252; + font-size: 0.82rem; +} + +.game-resource-panel-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); + gap: 0.6rem; + margin: 0; + padding: 0; + overflow-y: auto; + list-style: none; +} + +.game-resource-panel-card { + display: flex; + min-width: 0; + flex-direction: column; + gap: 0.3rem; + padding: 0.4rem; + border: 1px solid transparent; + border-radius: 10px; + background: #fffaf6; +} + +.game-resource-panel-card.is-selected { + border-color: #d57b51; + box-shadow: 0 0 0 2px rgb(216 115 66 / 16%); +} + +.game-resource-panel-preview { + display: grid; + width: 100%; + aspect-ratio: 4 / 3; + overflow: hidden; + padding: 0; + border: 0; + border-radius: 8px; + background: linear-gradient(145deg, #fffaf6, #f6ece6); + color: #c46a40; + cursor: pointer; + place-items: center; +} + +.game-resource-panel-preview img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.game-resource-panel-preview-placeholder { + font-size: 0.72rem; + color: #a97f6c; +} + +.game-resource-panel-meta { + display: flex; + min-width: 0; + flex-direction: column; +} + +.game-resource-panel-meta strong { + overflow: hidden; + font-size: 0.76rem; + text-overflow: ellipsis; + white-space: nowrap; +} + +.game-resource-panel-meta small { + color: #8c6252; + font-size: 0.68rem; +} \ No newline at end of file diff --git a/apps/ai-game-creator-shell/src/view/project-development/index.tsx b/apps/ai-game-creator-shell/src/view/project-development/index.tsx index 0b53f3800..1d9ca7f20 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/index.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/index.tsx @@ -13,6 +13,7 @@ import { CanvasChromeButton, SelectionOverlay, } from '@genarrative/image-canvas-react'; +import { save as saveNativeFileDialog } from '@tauri-apps/plugin-dialog'; import { AtSign, Code2, @@ -82,6 +83,12 @@ import { RESOURCE_REFERENCE_FILTERS, type ResourceReferenceFilter, } from '../../features/project-workspace/resourceReferences'; +import { + defaultResourceExportFileName, + isUploadableResourceFile, + resolveResourceCanvasPanelEntries, + resolveSelectedPanelEntries, +} from '../../features/resource-canvas/resourceCanvasAssetTransferModel'; import { canRedoResourceCanvasHistory, canUndoResourceCanvasHistory, @@ -94,6 +101,7 @@ import { type ResourceCanvasLayoutSnapshot, undoResourceCanvasHistory, } from '../../features/resource-canvas/resourceCanvasHistoryModel'; +import { ResourceCanvasPanelView } from '../../features/resource-canvas/ResourceCanvasPanelView'; import { createResourceQuickEditPanelDraft } from '../../features/resource-canvas/resourceCanvasQuickEditModel'; import { canNormalizeResourceIntoManifestAsset, @@ -1213,6 +1221,9 @@ export default function ProjectDevelopmentView({ const [resourceCanvasHistory, setResourceCanvasHistory] = useState( createResourceCanvasHistory, ); + const [resourcePanelOpen, setResourcePanelOpen] = useState(false); + const [resourcePanelNotice, setResourcePanelNotice] = useState(''); + const [resourcePanelUploading, setResourcePanelUploading] = useState(false); const [uiEditorRoute, setUiEditorRoute] = useState( null, ); @@ -3265,6 +3276,161 @@ export default function ProjectDevelopmentView({ } }, [resourceCardPreviews.previews, stopActiveCardMedia]); + const resourcePanelEntries = useMemo( + () => + resolveResourceCanvasPanelEntries( + visibleResources.map((resource) => { + const previewIdentity = + resourceCardPreviews.identityByResourceId.get(resource.id) ?? null; + const preview = previewIdentity + ? resourceCardPreviews.previews.get(previewIdentity) + : undefined; + return { + resource, + categoryLabel: categoryLabels[resource.category], + typeLabel: projectResourceTypeLabel(resource), + previewIdentity, + previewStatus: preview?.status ?? 'idle', + previewSourceUrl: + preview?.status === 'loaded' + ? (preview.preview.sourceUrl ?? null) + : null, + previewError: + preview?.status === 'failed' ? (preview.error ?? null) : null, + }; + }), + ), + [resourceCardPreviews, visibleResources], + ); + const selectedResourcePanelEntries = resolveSelectedPanelEntries( + resourcePanelEntries, + selectedResourceIds, + ); + + useEffect(() => { + if (!resourcePanelOpen) { + return; + } + // 面板打开时补一次预览请求;卡片自身只按可见性请求。 + resourcePanelEntries.slice(0, 24).forEach((entry) => { + const resource = canvasResources.find( + (item) => item.id === entry.resourceId, + ); + if (resource && entry.previewIdentity) { + resourceCardPreviews.requestPreview( + resource, + entry.previewIdentity, + 'detail', + ); + } + }); + }, [ + canvasResources, + resourceCardPreviews, + resourcePanelEntries, + resourcePanelOpen, + ]); + + const downloadResourcePanelEntries = useCallback( + async (targets: typeof resourcePanelEntries) => { + const invoke = window.__TAURI__?.core?.invoke; + if (!invoke || targets.length === 0) { + if (!invoke) { + setResourcePanelNotice('保存素材需要在客户端内打开'); + } + return; + } + setResourcePanelNotice(''); + let savedCount = 0; + for (const [index, entry] of targets.entries()) { + const destination = await saveNativeFileDialog({ + defaultPath: defaultResourceExportFileName(entry), + title: + targets.length > 1 + ? `保存素材(${index + 1}/${targets.length})` + : '保存素材', + }); + if (!destination) { + break; + } + try { + const result = await invoke<{ + destinationPath: string; + byteLen: number; + }>('save_local_project_asset_file', { + input: { + projectPath, + relativePath: entry.path, + destinationPath: destination, + }, + }); + savedCount += 1; + setResourcePanelNotice(`已保存 ${result.destinationPath}`); + } catch (error) { + setResourcePanelNotice( + error instanceof Error ? error.message : String(error), + ); + break; + } + } + if (savedCount > 1) { + setResourcePanelNotice(`已保存 ${savedCount} 个素材`); + } + }, + [projectPath], + ); + + const uploadResourcePanelFiles = useCallback( + async (files: FileList) => { + const invoke = window.__TAURI__?.core?.invoke; + if (!invoke) { + setResourcePanelNotice('上传素材需要在客户端内打开'); + return; + } + const uploadable = Array.from(files).filter(isUploadableResourceFile); + if (uploadable.length === 0) { + setResourcePanelNotice('只能上传图片、音频或视频素材'); + return; + } + setResourcePanelUploading(true); + setResourcePanelNotice(''); + try { + let expectedProjectRevision: number | null = null; + for (const file of uploadable) { + const status = await invoke<{ revision: number }>( + 'get_local_game_project_revision', + { projectPath }, + ); + if (!Number.isSafeInteger(status.revision) || status.revision < 0) { + throw new Error('项目 revision 无效'); + } + expectedProjectRevision = status.revision; + const bytes = Array.from(new Uint8Array(await file.arrayBuffer())); + await invoke('upload_local_asset', { + projectPath, + fileName: file.name, + mediaType: file.type || 'application/octet-stream', + bytes, + }); + } + setResourcePanelNotice(`已上传 ${uploadable.length} 个素材`); + if (expectedProjectRevision !== null) { + await reloadManifestAfterAssetCommand( + expectedProjectRevision, + `asset-upload:${Date.now()}`, + ); + } + } catch (error) { + setResourcePanelNotice( + error instanceof Error ? error.message : String(error), + ); + } finally { + setResourcePanelUploading(false); + } + }, + [projectPath, reloadManifestAfterAssetCommand], + ); + const applyResourceCanvasLayoutSnapshot = useCallback( (snapshot: ResourceCanvasLayoutSnapshot) => { resolveResourceCanvasRestoreEntries( @@ -4926,6 +5092,19 @@ export default function ProjectDevelopmentView({ ) : null} {mode === 'resources' && !uiEditorRoute ? ( <> + {pendingResourceEdits.length > 0 || pendingResourceEditsLoadState === 'failed' ? (