资源画布新增独立资源面板:预览/上传/下载/多选(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:
@@ -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>
|
||||
);
|
||||
}
|
||||
+96
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user