合并 WP4 资源面板与撤销重做:保留 C7 版本入口与资源面板接线

- project.rs 冲突:同时保留 mod asset_export(WS-B 下载命令)与 mod asset_rename(WS-D 重命名命令),丢弃已在 WS-A 退役的 asset_usage
- index.tsx 冲突:同时保留 GameRunVersionPicker(C7 版本入口)与资源面板/历史模型导入(WP4)
This commit is contained in:
2026-09-10 15:57:42 +08:00
11 changed files with 1397 additions and 2 deletions
@@ -1,7 +1,7 @@
{ {
"$schema": "../gen/schemas/desktop-schema.json", "$schema": "../gen/schemas/desktop-schema.json",
"identifier": "main", "identifier": "main",
"description": "AI 游戏创作主窗口允许读取系统剪贴板图片,用于粘贴素材附件。", "description": "AI 游戏创作主窗口允许读取系统剪贴板图片,用于粘贴素材附件;允许弹出原生打开/保存对话框用于素材上传与导出。",
"windows": ["client"], "windows": ["client"],
"permissions": [ "permissions": [
"clipboard-manager:allow-read-image", "clipboard-manager:allow-read-image",
@@ -21,6 +21,7 @@
] ]
}, },
"opener:default", "opener:default",
"dialog:allow-open" "dialog:allow-open",
"dialog:allow-save"
] ]
} }
@@ -4332,6 +4332,12 @@ pub(crate) fn read_local_project_file(
read_local_project_file_at(root, &normalized_path) 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] #[tauri::command]
pub(crate) async fn read_local_project_image_preview( pub(crate) async fn read_local_project_image_preview(
preview_manager: tauri::State<'_, ProjectResourcePreviewReadManager>, preview_manager: tauri::State<'_, ProjectResourcePreviewReadManager>,
@@ -2648,6 +2648,7 @@ fn main() {
import_local_project_image_assets, import_local_project_image_assets,
read_local_project_file, read_local_project_file,
read_local_project_image_preview, read_local_project_image_preview,
save_local_project_asset_file,
read_local_project_text_preview, read_local_project_text_preview,
read_local_project_media_preview, read_local_project_media_preview,
cancel_local_project_resource_preview_scope, cancel_local_project_resource_preview_scope,
@@ -4,6 +4,7 @@ use similar::TextDiff;
use std::io::{Seek, SeekFrom}; use std::io::{Seek, SeekFrom};
mod agent_db; mod agent_db;
mod asset_export;
mod asset_rename; mod asset_rename;
mod checkpoint; mod checkpoint;
mod conversation; mod conversation;
@@ -18,6 +19,7 @@ mod resource_layout;
mod verification; mod verification;
pub(crate) use agent_db::*; pub(crate) use agent_db::*;
pub(crate) use asset_export::*;
pub(crate) use asset_rename::*; pub(crate) use asset_rename::*;
pub(crate) use checkpoint::*; pub(crate) use checkpoint::*;
pub(crate) use conversation::*; 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; 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;
}
@@ -0,0 +1,208 @@
/**
* 资源画布的组织操作历史:只记录资源卡的布局坐标,**不记录、也不回滚素材内容**。
*
* 素材不可变是硬约束,所以撤销/重做只作用于「画布上卡片怎么排」,绝不反向修改
* manifest 条目或素材文件。持久化仍走现役的手动 CAS 写链(`commitPosition`),
* 本模块只做纯函数的快照栈。
*/
import type { ProjectResourceCanvasSection } from '../../../../../packages/shared/src/contracts/gameCreationApp';
export const MAX_RESOURCE_CANVAS_HISTORY_STEPS = 40;
export type ResourceCanvasLayoutSnapshotEntry = {
resourceId: string;
section: ProjectResourceCanvasSection;
x: number;
y: number;
manuallyPlaced: boolean;
};
export type ResourceCanvasLayoutSnapshot = {
entries: ResourceCanvasLayoutSnapshotEntry[];
};
export type ResourceCanvasHistoryEntry = {
id: number;
label: string;
snapshot: ResourceCanvasLayoutSnapshot;
createdAt: number;
};
export type ResourceCanvasHistory = {
undoStack: ResourceCanvasHistoryEntry[];
redoStack: ResourceCanvasHistoryEntry[];
nextEntryId: number;
};
export function createResourceCanvasHistory(): ResourceCanvasHistory {
return { undoStack: [], redoStack: [], nextEntryId: 1 };
}
/**
* 按 resourceId 排序后落快照,保证同一组坐标无论 map 顺序如何都比较得出相等。
*/
export function captureResourceCanvasSnapshot(
positions: readonly ResourceCanvasLayoutSnapshotEntry[],
): ResourceCanvasLayoutSnapshot {
return {
entries: positions
.map((position) => ({
resourceId: position.resourceId,
section: position.section,
x: position.x,
y: position.y,
manuallyPlaced: position.manuallyPlaced,
}))
.sort((left, right) => left.resourceId.localeCompare(right.resourceId)),
};
}
export function resourceCanvasSnapshotsEqual(
left: ResourceCanvasLayoutSnapshot,
right: ResourceCanvasLayoutSnapshot,
) {
if (left.entries.length !== right.entries.length) {
return false;
}
return left.entries.every((entry, index) => {
const other = right.entries[index];
return (
other !== undefined &&
entry.resourceId === other.resourceId &&
entry.section === other.section &&
entry.x === other.x &&
entry.y === other.y &&
entry.manuallyPlaced === other.manuallyPlaced
);
});
}
export function pushResourceCanvasHistory(
history: ResourceCanvasHistory,
{
label,
snapshot,
createdAt = 0,
}: {
label: string;
snapshot: ResourceCanvasLayoutSnapshot;
createdAt?: number;
},
): ResourceCanvasHistory {
const previous = history.undoStack.at(-1);
if (previous && resourceCanvasSnapshotsEqual(previous.snapshot, snapshot)) {
return history;
}
const entry: ResourceCanvasHistoryEntry = {
id: history.nextEntryId,
label,
snapshot,
createdAt,
};
return {
undoStack: [
...history.undoStack.slice(-(MAX_RESOURCE_CANVAS_HISTORY_STEPS - 1)),
entry,
],
// 新的操作会让原来的重做分支失效。
redoStack: [],
nextEntryId: history.nextEntryId + 1,
};
}
export type ResourceCanvasHistoryStep = {
history: ResourceCanvasHistory;
label: string;
snapshot: ResourceCanvasLayoutSnapshot;
};
export function undoResourceCanvasHistory(
history: ResourceCanvasHistory,
currentSnapshot: ResourceCanvasLayoutSnapshot,
): ResourceCanvasHistoryStep | null {
const entry = history.undoStack.at(-1);
if (!entry) {
return null;
}
return {
history: {
undoStack: history.undoStack.slice(0, -1),
redoStack: [
...history.redoStack.slice(-(MAX_RESOURCE_CANVAS_HISTORY_STEPS - 1)),
{
...entry,
snapshot: currentSnapshot,
},
],
nextEntryId: history.nextEntryId,
},
label: entry.label,
snapshot: entry.snapshot,
};
}
export function redoResourceCanvasHistory(
history: ResourceCanvasHistory,
currentSnapshot: ResourceCanvasLayoutSnapshot,
): ResourceCanvasHistoryStep | null {
const entry = history.redoStack.at(-1);
if (!entry) {
return null;
}
return {
history: {
undoStack: [
...history.undoStack.slice(-(MAX_RESOURCE_CANVAS_HISTORY_STEPS - 1)),
{
...entry,
snapshot: currentSnapshot,
},
],
redoStack: history.redoStack.slice(0, -1),
nextEntryId: history.nextEntryId,
},
label: entry.label,
snapshot: entry.snapshot,
};
}
export function clearResourceCanvasHistory(
history: ResourceCanvasHistory,
): ResourceCanvasHistory {
if (history.undoStack.length === 0 && history.redoStack.length === 0) {
return history;
}
return { ...history, undoStack: [], redoStack: [] };
}
export function canUndoResourceCanvasHistory(history: ResourceCanvasHistory) {
return history.undoStack.length > 0;
}
export function canRedoResourceCanvasHistory(history: ResourceCanvasHistory) {
return history.redoStack.length > 0;
}
/**
* 只回写和快照不一致的位置,避免撤销时把没动过的卡片也当成一次手动摆放写回后端。
*/
export function resolveResourceCanvasRestoreEntries(
snapshot: ResourceCanvasLayoutSnapshot,
current: readonly ResourceCanvasLayoutSnapshotEntry[],
): ResourceCanvasLayoutSnapshotEntry[] {
const currentById = new Map(
current.map((entry) => [entry.resourceId, entry] as const),
);
return snapshot.entries.filter((entry) => {
const existing = currentById.get(entry.resourceId);
if (!existing) {
return false;
}
return (
existing.section !== entry.section ||
existing.x !== entry.x ||
existing.y !== entry.y
);
});
}
@@ -13,6 +13,7 @@ import {
CanvasChromeButton, CanvasChromeButton,
SelectionOverlay, SelectionOverlay,
} from '@genarrative/image-canvas-react'; } from '@genarrative/image-canvas-react';
import { save as saveNativeFileDialog } from '@tauri-apps/plugin-dialog';
import { import {
AtSign, AtSign,
Code2, Code2,
@@ -30,11 +31,13 @@ import {
Pencil, Pencil,
Play, Play,
Plus, Plus,
Redo2,
RotateCcw, RotateCcw,
Search, Search,
Settings2, Settings2,
SlidersHorizontal, SlidersHorizontal,
Sparkles, Sparkles,
Undo2,
X, X,
ZoomOut, ZoomOut,
} from 'lucide-react'; } from 'lucide-react';
@@ -86,6 +89,25 @@ import {
type ResourceReferenceFilter, type ResourceReferenceFilter,
} from '../../features/project-workspace/resourceReferences'; } from '../../features/project-workspace/resourceReferences';
import { GameRunVersionPicker } from '../../features/resource-canvas/GameRunVersionPicker'; import { GameRunVersionPicker } from '../../features/resource-canvas/GameRunVersionPicker';
import {
defaultResourceExportFileName,
isUploadableResourceFile,
resolveResourceCanvasPanelEntries,
resolveSelectedPanelEntries,
} from '../../features/resource-canvas/resourceCanvasAssetTransferModel';
import {
canRedoResourceCanvasHistory,
canUndoResourceCanvasHistory,
captureResourceCanvasSnapshot,
clearResourceCanvasHistory,
createResourceCanvasHistory,
pushResourceCanvasHistory,
redoResourceCanvasHistory,
resolveResourceCanvasRestoreEntries,
type ResourceCanvasLayoutSnapshot,
undoResourceCanvasHistory,
} from '../../features/resource-canvas/resourceCanvasHistoryModel';
import { ResourceCanvasPanelView } from '../../features/resource-canvas/ResourceCanvasPanelView';
import { createResourceQuickEditPanelDraft } from '../../features/resource-canvas/resourceCanvasQuickEditModel'; import { createResourceQuickEditPanelDraft } from '../../features/resource-canvas/resourceCanvasQuickEditModel';
import { import {
canNormalizeResourceIntoManifestAsset, canNormalizeResourceIntoManifestAsset,
@@ -1220,6 +1242,13 @@ export default function ProjectDevelopmentView({
useState<QuickEditPanelState | null>(null); useState<QuickEditPanelState | null>(null);
const [resourceCanvasMarquee, setResourceCanvasMarquee] = const [resourceCanvasMarquee, setResourceCanvasMarquee] =
useState<CanvasMarqueeState | null>(null); useState<CanvasMarqueeState | null>(null);
/** 资源卡组织操作历史:只回滚布局坐标,不回滚素材。 */
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>( const [uiEditorRoute, setUiEditorRoute] = useState<UiEditorRoute | null>(
null, null,
); );
@@ -3074,6 +3103,7 @@ export default function ProjectDevelopmentView({
setSelectedResourceIds([]); setSelectedResourceIds([]);
setQuickEditPanel(null); setQuickEditPanel(null);
setQuickEditSourceLayer(null); setQuickEditSourceLayer(null);
setResourceCanvasHistory(clearResourceCanvasHistory);
setActiveResourceCategory(null); setActiveResourceCategory(null);
const defaultViewports = defaultResourceCanvasViewports(); const defaultViewports = defaultResourceCanvasViewports();
resourceCanvasViewportTargetsRef.current = defaultViewports; resourceCanvasViewportTargetsRef.current = defaultViewports;
@@ -3354,6 +3384,246 @@ export default function ProjectDevelopmentView({
} }
}, [resourceCardPreviews.previews, stopActiveCardMedia]); }, [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(
snapshot,
activeResourceLayout.layout.positions,
).forEach((entry) => {
activeResourceLayout.commitPosition(
entry.resourceId,
entry.section,
entry.x,
entry.y,
);
});
},
[activeResourceLayout],
);
const undoSaveResourceCanvasLayout = useCallback(() => {
const step = undoResourceCanvasHistory(
resourceCanvasHistory,
captureResourceCanvasSnapshot(activeResourceLayout.layout.positions),
);
if (!step) {
return;
}
setResourceCanvasHistory(step.history);
applyResourceCanvasLayoutSnapshot(step.snapshot);
}, [
activeResourceLayout.layout.positions,
applyResourceCanvasLayoutSnapshot,
resourceCanvasHistory,
]);
const redoResourceCanvasLayout = useCallback(() => {
const step = redoResourceCanvasHistory(
resourceCanvasHistory,
captureResourceCanvasSnapshot(activeResourceLayout.layout.positions),
);
if (!step) {
return;
}
setResourceCanvasHistory(step.history);
applyResourceCanvasLayoutSnapshot(step.snapshot);
}, [
activeResourceLayout.layout.positions,
applyResourceCanvasLayoutSnapshot,
resourceCanvasHistory,
]);
const canUndoResourceCanvas = canUndoResourceCanvasHistory(
resourceCanvasHistory,
);
const canRedoResourceCanvas = canRedoResourceCanvasHistory(
resourceCanvasHistory,
);
useEffect(() => {
const handleHistoryShortcut = (event: KeyboardEvent) => {
if (!(event.ctrlKey || event.metaKey) || event.altKey) {
return;
}
const key = event.key.toLowerCase();
const isUndo = key === 'z' && !event.shiftKey;
const isRedo = (key === 'z' && event.shiftKey) || key === 'y';
if (!isUndo && !isRedo) {
return;
}
const target = event.target;
if (
target instanceof HTMLElement &&
target.closest('input, textarea, [contenteditable="true"]')
) {
return;
}
event.preventDefault();
if (isUndo) {
undoSaveResourceCanvasLayout();
} else {
redoResourceCanvasLayout();
}
};
window.addEventListener('keydown', handleHistoryShortcut);
return () => window.removeEventListener('keydown', handleHistoryShortcut);
}, [redoResourceCanvasLayout, undoSaveResourceCanvasLayout]);
const handleResourceSelect = useCallback( const handleResourceSelect = useCallback(
(resourceId: string, options: { append?: boolean } = {}) => { (resourceId: string, options: { append?: boolean } = {}) => {
if (skipNextResourceCardClickRef.current === resourceId) { if (skipNextResourceCardClickRef.current === resourceId) {
@@ -3699,6 +3969,15 @@ export default function ProjectDevelopmentView({
: null; : null;
setResourceCardDragPreview(null); setResourceCardDragPreview(null);
if (commit && drag.changed && preview?.resourceId === drag.resourceId) { if (commit && drag.changed && preview?.resourceId === drag.resourceId) {
// 先落"拖动前"的快照,撤销才有回退点;它只含布局坐标。
setResourceCanvasHistory((history) =>
pushResourceCanvasHistory(history, {
label: '移动资源卡',
snapshot: captureResourceCanvasSnapshot(
activeResourceLayout.layout.positions,
),
}),
);
activeResourceLayout.commitPosition( activeResourceLayout.commitPosition(
drag.resourceId, drag.resourceId,
drag.section, drag.section,
@@ -4937,6 +5216,19 @@ export default function ProjectDevelopmentView({
) : null} ) : null}
{mode === 'resources' && !uiEditorRoute ? ( {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 || {pendingResourceEdits.length > 0 ||
pendingResourceEditsLoadState === 'failed' ? ( pendingResourceEditsLoadState === 'failed' ? (
<button <button
@@ -5150,6 +5442,26 @@ export default function ProjectDevelopmentView({
onWheel={handleResourceBookWheel} onWheel={handleResourceBookWheel}
/> />
<div className="game-resource-book-zoom"> <div className="game-resource-book-zoom">
<button
type="button"
className="game-resource-book-zoom-button"
aria-label="撤销画布操作"
title="撤销(Ctrl/Cmd+Z"
disabled={!canUndoResourceCanvas}
onClick={undoSaveResourceCanvasLayout}
>
<Undo2 size={16} aria-hidden="true" />
</button>
<button
type="button"
className="game-resource-book-zoom-button"
aria-label="重做画布操作"
title="重做(Ctrl/Cmd+Shift+Z"
disabled={!canRedoResourceCanvas}
onClick={redoResourceCanvasLayout}
>
<Redo2 size={16} aria-hidden="true" />
</button>
<button <button
type="button" type="button"
className="game-resource-book-zoom-button" className="game-resource-book-zoom-button"
@@ -5653,6 +5965,32 @@ export default function ProjectDevelopmentView({
</footer> </footer>
) : null} ) : 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 ? ( {resourceClassificationAsset ? (
<ResourceClassificationPanel <ResourceClassificationPanel
key={resourceClassificationAsset.id} key={resourceClassificationAsset.id}
@@ -0,0 +1,166 @@
import { describe, expect, it } from 'vitest';
import {
canRedoResourceCanvasHistory,
canUndoResourceCanvasHistory,
captureResourceCanvasSnapshot,
clearResourceCanvasHistory,
createResourceCanvasHistory,
MAX_RESOURCE_CANVAS_HISTORY_STEPS,
pushResourceCanvasHistory,
redoResourceCanvasHistory,
resolveResourceCanvasRestoreEntries,
undoResourceCanvasHistory,
} from '../src/features/resource-canvas/resourceCanvasHistoryModel';
function positions(
entries: Array<[string, number, number, boolean?]>,
): ReturnType<typeof captureResourceCanvasSnapshot>['entries'] {
return entries.map(([resourceId, x, y, manuallyPlaced = true]) => ({
resourceId,
section: 'document',
x,
y,
manuallyPlaced,
}));
}
describe('resource canvas history model', () => {
it('按 resourceId 排序落快照,map 顺序不影响相等判定', () => {
const left = captureResourceCanvasSnapshot(
positions([
['asset:b', 2, 2],
['asset:a', 1, 1],
]),
);
const right = captureResourceCanvasSnapshot(
positions([
['asset:a', 1, 1],
['asset:b', 2, 2],
]),
);
expect(left.entries.map((entry) => entry.resourceId)).toEqual([
'asset:a',
'asset:b',
]);
expect(left).toEqual(right);
});
it('push 连续相同快照不产生新历史条目', () => {
const snapshot = captureResourceCanvasSnapshot(
positions([['asset:a', 1, 1]]),
);
const first = pushResourceCanvasHistory(createResourceCanvasHistory(), {
label: '移动卡片',
snapshot,
});
const second = pushResourceCanvasHistory(first, {
label: '移动卡片',
snapshot: captureResourceCanvasSnapshot(positions([['asset:a', 1, 1]])),
});
expect(first.undoStack).toHaveLength(1);
expect(second).toBe(first);
});
it('push 后重做分支失效,撤回栈有界', () => {
let history = createResourceCanvasHistory();
for (
let index = 0;
index < MAX_RESOURCE_CANVAS_HISTORY_STEPS + 5;
index += 1
) {
history = pushResourceCanvasHistory(history, {
label: `移动 ${index}`,
snapshot: captureResourceCanvasSnapshot(
positions([['asset:a', index, index]]),
),
});
}
expect(history.undoStack).toHaveLength(MAX_RESOURCE_CANVAS_HISTORY_STEPS);
expect(history.undoStack[0]?.label).toBe('移动 5');
const undone = undoResourceCanvasHistory(
history,
captureResourceCanvasSnapshot(positions([['asset:a', 99, 99]])),
)!;
expect(canRedoResourceCanvasHistory(undone.history)).toBe(true);
const pushed = pushResourceCanvasHistory(undone.history, {
label: '新的移动',
snapshot: captureResourceCanvasSnapshot(
positions([['asset:a', 120, 120]]),
),
});
expect(canRedoResourceCanvasHistory(pushed)).toBe(false);
});
it('撤销与重做互为逆操作,并回填当前快照', () => {
const moved = captureResourceCanvasSnapshot(
positions([['asset:a', 10, 20]]),
);
let history = pushResourceCanvasHistory(createResourceCanvasHistory(), {
label: '移动卡片',
snapshot: moved,
});
const current = captureResourceCanvasSnapshot(
positions([['asset:a', 60, 70]]),
);
const undone = undoResourceCanvasHistory(history, current)!;
expect(undone.snapshot).toEqual(moved);
expect(undone.label).toBe('移动卡片');
expect(canUndoResourceCanvasHistory(undone.history)).toBe(false);
const redone = redoResourceCanvasHistory(undone.history, moved)!;
expect(redone.snapshot).toEqual(current);
expect(canRedoResourceCanvasHistory(redone.history)).toBe(false);
expect(canUndoResourceCanvasHistory(redone.history)).toBe(true);
history = redone.history;
expect(history.redoStack).toHaveLength(0);
});
it('空历史撤销/重做返回 null,清空后两个栈都为空', () => {
const empty = createResourceCanvasHistory();
expect(undoResourceCanvasHistory(empty, { entries: [] })).toBeNull();
expect(redoResourceCanvasHistory(empty, { entries: [] })).toBeNull();
const history = pushResourceCanvasHistory(empty, {
label: '移动卡片',
snapshot: captureResourceCanvasSnapshot(positions([['asset:a', 1, 1]])),
});
const cleared = clearResourceCanvasHistory(history);
expect(cleared.undoStack).toHaveLength(0);
expect(cleared.redoStack).toHaveLength(0);
expect(clearResourceCanvasHistory(cleared)).toBe(cleared);
});
it('只把与当前不一致的位置写回后端', () => {
const snapshot = captureResourceCanvasSnapshot(
positions([
['asset:a', 10, 20],
['asset:b', 30, 40],
]),
);
const restore = resolveResourceCanvasRestoreEntries(
snapshot,
positions([
['asset:a', 10, 20],
['asset:b', 99, 98],
['asset:c', 1, 1],
]),
);
expect(restore.map((entry) => entry.resourceId)).toEqual(['asset:b']);
});
it('快照里不存在的资源不会在撤销时被新建出来', () => {
const snapshot = captureResourceCanvasSnapshot(
positions([['asset:deleted', 10, 20]]),
);
expect(
resolveResourceCanvasRestoreEntries(snapshot, positions([])),
).toEqual([]);
});
});