资源画布新增独立资源面板:预览/上传/下载/多选(WP4)

新增 features/resource-canvas/resourceCanvasAssetTransferModel:面板网格模型、选中集合解析、默认导出文件名、上传白名单(纯函数)

新增 features/resource-canvas/ResourceCanvasPanelView:独立浮层面板承载预览网格/上传/下载/全选/清空,选中状态直接读写画布同一份 selectedResourceIds,不造第二套选择

下载改为显式保存链路:前端 @tauri-apps/plugin-dialog save() 取目标路径,再调新命令 save_local_project_asset_file 复制素材文件,避免依赖不可靠的 <a download>

Rust 新增 project/asset_export.rs 与命令 save_local_project_asset_file:校验项目根与相对路径、源必须是真实普通文件(拒绝符号链接与目录)、目标路径非空且父目录存在、分块流式复制不进整文件内存,返回目标路径与字节数

Rust 单测覆盖成功复制(内容一致 + byteLen + 源不变)、缺失源拒绝、目录源拒绝、空目标与父目录缺失拒绝、目标为目录拒绝

上传复用现役 upload_local_asset(落 assets/uploads 并登记 manifest 条目)后回读 manifest 刷新,只接受图片/音频/视频

capabilities/main.json 增加 dialog:allow-save;资源画布 chrome 样式补资源面板一套;index.tsx 只做导入/一处按钮/一处渲染/状态透传
This commit is contained in:
2026-09-10 15:56:10 +08:00
parent 9121e89798
commit 422a71d321
9 changed files with 890 additions and 2 deletions
@@ -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"
]
}
@@ -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<SaveLocalProjectAssetFileResult, String> {
save_local_project_asset_file_at(input)
}
#[tauri::command]
pub(crate) async fn read_local_project_image_preview(
preview_manager: tauri::State<'_, ProjectResourcePreviewReadManager>,
@@ -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,
@@ -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::*;
@@ -0,0 +1,255 @@
use super::*;
use std::fs::File;
use std::io::{Read, Write};
/// 显式保存:把项目内已登记的素材文件复制到用户选定的目标路径。
///
/// 这里刻意不做"浏览器下载"——AGC 是 Tauri/WebView2 宿主,未注册 `on_download`
/// 时 `<a 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<PathBuf, String> {
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<PathBuf, String> {
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<SaveLocalProjectAssetFileResult, String> {
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);
}
}
@@ -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<HTMLButtonElement>(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 (
<div
className="game-approval-backdrop"
role="presentation"
onMouseDown={(event) => {
if (event.target === event.currentTarget) {
onClose();
}
}}
>
<section
className="game-approval-dialog game-resource-panel"
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
>
<header>
<div>
<h2 id={titleId}></h2>
<p role="status">{`${entries.length} 项资源,已选 ${selectedCount}`}</p>
</div>
<button
ref={closeButtonRef}
type="button"
aria-label="关闭资源面板"
onClick={onClose}
>
<X size={16} aria-hidden="true" />
</button>
</header>
<div className="game-resource-panel-actions">
<label className="game-resource-panel-upload">
<Upload size={15} aria-hidden="true" />
{isUploading ? '上传中…' : '上传素材'}
<input
type="file"
multiple
accept="image/*,audio/*,video/*"
disabled={isUploading}
aria-label="选择要上传到项目的素材"
onChange={(event) => {
const files = event.currentTarget.files;
if (files && files.length > 0) {
onUploadFiles(files);
}
event.currentTarget.value = '';
}}
/>
</label>
<button
type="button"
onClick={onSelectAll}
disabled={entries.length === 0 || selectedCount === entries.length}
>
</button>
<button
type="button"
onClick={onClearSelection}
disabled={selectedCount === 0}
>
</button>
<button
type="button"
className="game-resource-panel-download"
onClick={onDownloadSelection}
disabled={selectedCount === 0 || isUploading}
>
{isUploading ? (
<Loader2 size={15} className="animate-spin" aria-hidden="true" />
) : (
<Download size={15} aria-hidden="true" />
)}
{selectedCount > 1 ? `下载选中(${selectedCount}` : '下载'}
</button>
</div>
{notice ? (
<p className="game-resource-panel-notice" role="status">
{notice}
</p>
) : null}
{entries.length === 0 ? (
<p className="game-resource-panel-empty"></p>
) : (
<ul className="game-resource-panel-grid">
{entries.map((entry) => {
const isSelected = selected.has(entry.resourceId);
return (
<li
key={entry.resourceId}
className={`game-resource-panel-card${isSelected ? ' is-selected' : ''}`}
>
<button
type="button"
className="game-resource-panel-preview"
aria-label={`${isSelected ? '取消选择' : '选择'} ${entry.label}`}
aria-pressed={isSelected}
disabled={!entry.selectable}
onClick={() => onToggleEntry(entry.resourceId)}
>
{entry.previewStatus === 'loaded' &&
entry.previewSourceUrl ? (
<img src={entry.previewSourceUrl} alt="" />
) : (
<span className="game-resource-panel-preview-placeholder">
{entry.previewStatus === 'failed'
? '预览失败'
: entry.previewStatus === 'loading'
? '载入中…'
: entry.typeLabel}
</span>
)}
</button>
<div className="game-resource-panel-meta">
<strong title={entry.label}>{entry.label}</strong>
<small>{`${entry.categoryLabel} · ${entry.typeLabel}`}</small>
</div>
</li>
);
})}
</ul>
)}
</section>
</div>
);
}
@@ -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<ResourceCanvasPanelEntry, 'label' | 'path'>,
) {
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,
);
}
@@ -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;
}
@@ -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<UiEditorRoute | null>(
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 ? (
<>
<button
type="button"
className="game-workbench-resource-panel-button"
aria-expanded={resourcePanelOpen}
disabled={canvasResources.length === 0}
onClick={() => {
setResourcePanelNotice('');
setResourcePanelOpen((current) => !current);
}}
>
<FolderTree size={15} aria-hidden="true" />
</button>
{pendingResourceEdits.length > 0 ||
pendingResourceEditsLoadState === 'failed' ? (
<button
@@ -5636,6 +5815,32 @@ export default function ProjectDevelopmentView({
</footer>
) : null}
{resourcePanelOpen ? (
<ResourceCanvasPanelView
entries={resourcePanelEntries}
selectedResourceIds={selectedResourceIds}
onToggleEntry={(resourceId) =>
handleResourceSelect(resourceId, { append: true })
}
onSelectAll={() =>
setSelectedResourceIds(
resourcePanelEntries.map((entry) => entry.resourceId),
)
}
onClearSelection={() => setSelectedResourceIds([])}
onUploadFiles={(files) => void uploadResourcePanelFiles(files)}
onDownloadSelection={() =>
void downloadResourcePanelEntries(selectedResourcePanelEntries)
}
isUploading={resourcePanelUploading}
notice={resourcePanelNotice}
onClose={() => {
setResourcePanelOpen(false);
setResourcePanelNotice('');
}}
/>
) : null}
{resourceClassificationAsset ? (
<ResourceClassificationPanel
key={resourceClassificationAsset.id}