新增素材导入功能
支持从本地或云端导入图片素材,包含预览, 限制检查。
This commit is contained in:
@@ -34,10 +34,12 @@
|
||||
"typecheck": "node ../../node_modules/typescript/bin/tsc -p tsconfig.json --noEmit && node scripts/check-config.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@cubone/react-file-manager": "^1.35.0",
|
||||
"@lexical/react": "^0.47.0",
|
||||
"@lexical/utils": "^0.47.0",
|
||||
"@tauri-apps/api": "^2.11.1",
|
||||
"@tauri-apps/plugin-clipboard-manager": "2.3.2",
|
||||
"@tauri-apps/plugin-dialog": "^2.7.2",
|
||||
"@tauri-apps/plugin-http": "^2.5.9",
|
||||
"@tauri-apps/plugin-opener": "~2",
|
||||
"@vitejs/plugin-react": "^5.0.4",
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "developer",
|
||||
"description": "开发窗口允许打开本地素材选择对话框。",
|
||||
"windows": ["developer"],
|
||||
"permissions": ["dialog:allow-open"]
|
||||
}
|
||||
@@ -13,6 +13,7 @@
|
||||
"identifier": "http:default",
|
||||
"allow": [{ "url": "https://dev.genarrative.world/api/*" }]
|
||||
},
|
||||
"opener:default"
|
||||
"opener:default",
|
||||
"dialog:allow-open"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1377,6 +1377,137 @@ pub(crate) async fn sync_canvas_project_assets(
|
||||
sync_canvas_project_assets_at(root, canvas_project_id.trim(), api_base_url, api_key).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn import_ui_editor_local_files(
|
||||
project_path: String,
|
||||
source_paths: Vec<String>,
|
||||
max_file_size: u64,
|
||||
) -> Result<LocalImportResult, String> {
|
||||
let root = Path::new(project_path.trim());
|
||||
enforce_project_permission_policy(root, "asset.upload")?;
|
||||
let _lock = acquire_project_write_lock(root, "asset.upload")?;
|
||||
advance_agent_runtime_project_revision_locked(root)?;
|
||||
let mut inputs = Vec::new();
|
||||
for source in source_paths {
|
||||
let path = Path::new(source.trim());
|
||||
let metadata = fs::symlink_metadata(path).map_err(|e| format!("读取本地图片失败:{e}"))?;
|
||||
if metadata.file_type().is_symlink() || !metadata.is_file() {
|
||||
return Err("只能导入普通图片文件".to_string());
|
||||
}
|
||||
if metadata.len() > max_file_size {
|
||||
return Err(format!("图片超过 {} 字节限制", max_file_size));
|
||||
}
|
||||
let bytes = fs::read(path).map_err(|e| format!("读取本地图片失败:{e}"))?;
|
||||
let media_type = if bytes.starts_with(b"\x89PNG\r\n\x1a\n") {
|
||||
"image/png"
|
||||
} else if bytes.starts_with(&[0xff, 0xd8, 0xff]) {
|
||||
"image/jpeg"
|
||||
} else if bytes.starts_with(b"RIFF") && bytes.len() > 12 && &bytes[8..12] == b"WEBP" {
|
||||
"image/webp"
|
||||
} else {
|
||||
return Err("本地文件不是受支持的 PNG/JPEG/WEBP 图片".to_string());
|
||||
};
|
||||
let name = path
|
||||
.file_name()
|
||||
.and_then(|v| v.to_str())
|
||||
.unwrap_or("image")
|
||||
.to_string();
|
||||
inputs.push((name, media_type.to_string(), bytes));
|
||||
}
|
||||
let assets = inputs
|
||||
.into_iter()
|
||||
.map(|(name, media, bytes)| upload_local_asset_at(root, &name, &media, &bytes))
|
||||
.collect::<Result<Vec<_>, _>>()?
|
||||
.into_iter()
|
||||
.map(|asset| ImportedAsset {
|
||||
id: asset.id,
|
||||
local_path: asset.local_path,
|
||||
asset_kind: None,
|
||||
})
|
||||
.collect();
|
||||
Ok(LocalImportResult { assets })
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn import_ui_editor_remote_assets(
|
||||
project_path: String,
|
||||
assets: Vec<serde_json::Value>,
|
||||
max_file_size: u64,
|
||||
) -> Result<RemoteImportResult, String> {
|
||||
let root = Path::new(project_path.trim());
|
||||
enforce_project_permission_policy(root, "canvas.asset_import")?;
|
||||
let _lock = acquire_project_write_lock(root, "canvas.asset_import")?;
|
||||
advance_agent_runtime_project_revision_locked(root)?;
|
||||
let mut downloads = Vec::new();
|
||||
for asset in assets {
|
||||
let asset_id = json_string_field(&asset, "assetId")
|
||||
.or_else(|| json_string_field(&asset, "objectKey"))
|
||||
.ok_or_else(|| "平台素材缺少稳定 assetId/objectKey,拒绝导入".to_string())?;
|
||||
let bytes = asset
|
||||
.get("bytes")
|
||||
.cloned()
|
||||
.ok_or_else(|| format!("平台素材 {asset_id} 缺少图片内容"))
|
||||
.and_then(|value| {
|
||||
serde_json::from_value::<Vec<u8>>(value)
|
||||
.map_err(|_| format!("平台素材 {asset_id} 图片内容无效"))
|
||||
})?;
|
||||
if bytes.len() as u64 > max_file_size {
|
||||
return Err(format!("图片超过 {} 字节限制", max_file_size));
|
||||
}
|
||||
let media_type = if bytes.starts_with(b"\x89PNG\r\n\x1a\n") {
|
||||
"image/png"
|
||||
} else if bytes.starts_with(&[0xff, 0xd8, 0xff]) {
|
||||
"image/jpeg"
|
||||
} else if bytes.starts_with(b"RIFF") && bytes.len() > 12 && &bytes[8..12] == b"WEBP" {
|
||||
"image/webp"
|
||||
} else {
|
||||
return Err("平台素材不是受支持的 PNG/JPEG/WEBP 图片".to_string());
|
||||
};
|
||||
downloads.push((asset, asset_id, media_type.to_string(), bytes));
|
||||
}
|
||||
let mut imported = Vec::new();
|
||||
for (asset, asset_id, media_type, bytes) in downloads {
|
||||
let extension = infer_file_extension(
|
||||
json_string_field(&asset, "objectKey")
|
||||
.or_else(|| json_string_field(&asset, "imageSrc"))
|
||||
.as_deref(),
|
||||
&media_type,
|
||||
);
|
||||
let local_name = sanitize_file_name(&asset_id);
|
||||
let local_path = format!("assets/imported/remote-{local_name}.{extension}");
|
||||
let target = resolve_local_project_path(root, &local_path)?;
|
||||
if let Some(parent) = target.parent() {
|
||||
fs::create_dir_all(parent).map_err(|e| format!("创建导入目录失败:{e}"))?;
|
||||
}
|
||||
fs::write(&target, &bytes).map_err(|e| format!("写入平台素材失败:{e}"))?;
|
||||
let registered = register_local_asset_entry(
|
||||
root,
|
||||
&local_path,
|
||||
"ui",
|
||||
&media_type,
|
||||
"canvas",
|
||||
GameCreationAppAssetSource {
|
||||
kind: GameCreationAppAssetSourceKind::Canvas,
|
||||
canvas_project_id: None,
|
||||
resource_id: json_string_field(&asset, "assetId"),
|
||||
asset_object_id: json_string_field(&asset, "assetObjectId"),
|
||||
task_id: None,
|
||||
prompt: None,
|
||||
model: None,
|
||||
generation_route: Some("editor.asset-library.import".to_string()),
|
||||
generation_kind: None,
|
||||
reference_resource_ids: Vec::new(),
|
||||
},
|
||||
)?;
|
||||
imported.push(ImportedAsset {
|
||||
id: registered.id,
|
||||
local_path: registered.local_path,
|
||||
asset_kind: json_string_field(&asset, "assetKind"),
|
||||
});
|
||||
}
|
||||
Ok(RemoteImportResult { assets: imported })
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn generate_platform_art_asset(
|
||||
project_path: String,
|
||||
|
||||
@@ -921,6 +921,22 @@ struct SyncCanvasProjectAssetsResult {
|
||||
assets: Vec<UploadLocalAssetResult>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Eq, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct LocalImportResult {
|
||||
assets: Vec<ImportedAsset>,
|
||||
}
|
||||
|
||||
type RemoteImportResult = LocalImportResult;
|
||||
|
||||
#[derive(Debug, Eq, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ImportedAsset {
|
||||
id: String,
|
||||
local_path: String,
|
||||
asset_kind: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Eq, PartialEq)]
|
||||
struct GeneratedPlatformArtAssetSlice {
|
||||
name: String,
|
||||
@@ -2226,6 +2242,8 @@ fn main() {
|
||||
import_canvas_asset,
|
||||
import_canvas_export,
|
||||
sync_canvas_project_assets,
|
||||
import_ui_editor_local_files,
|
||||
import_ui_editor_remote_assets,
|
||||
generate_platform_art_asset,
|
||||
open_canvas_project,
|
||||
get_game_creation_agent_capabilities,
|
||||
|
||||
@@ -0,0 +1,438 @@
|
||||
import '@cubone/react-file-manager/dist/style.css';
|
||||
|
||||
import { FileManager } from '@cubone/react-file-manager';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { open as openNativeFileDialog } from '@tauri-apps/plugin-dialog';
|
||||
import { X } from 'lucide-react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import {
|
||||
loadEditorAssetLibrary,
|
||||
readClientAssetBytes,
|
||||
resolveClientAssetReadUrl,
|
||||
} from '../../services/clientApi';
|
||||
import { ThemedModal } from '../modal/ThemedModal';
|
||||
import {
|
||||
buildProjectFiles,
|
||||
buildRemoteFolderFiles,
|
||||
buildRootFiles,
|
||||
type ImportedAsset,
|
||||
type ManagerFile,
|
||||
type ManifestAsset,
|
||||
PROJECT_ASSETS_PATH,
|
||||
REMOTE_ASSETS_PATH,
|
||||
} from './utils';
|
||||
|
||||
export type { ImportedAsset } from './utils';
|
||||
|
||||
const ROOT_PATH = '/';
|
||||
|
||||
export type UiEditorImageImporterProps = {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onImport: (assets: ImportedAsset[]) => void;
|
||||
projectPath: string;
|
||||
maxItems: number;
|
||||
maxFileSize: number;
|
||||
acceptedMediaTypes: readonly string[];
|
||||
};
|
||||
|
||||
type LocalProjectFile = { path: string };
|
||||
|
||||
type LocalManifestResponse = {
|
||||
assets?: ManifestAsset[];
|
||||
manifest?: { assets?: ManifestAsset[] };
|
||||
};
|
||||
|
||||
type LocalProjectFilesResponse = { files?: LocalProjectFile[] };
|
||||
|
||||
type ImagePreviewResponse = { dataUrl: string };
|
||||
|
||||
type ImportAssetResponse = {
|
||||
id: string;
|
||||
localPath: string;
|
||||
assetKind?: string | null;
|
||||
};
|
||||
|
||||
export function AssetImporter({
|
||||
open,
|
||||
onClose,
|
||||
onImport,
|
||||
projectPath,
|
||||
maxItems,
|
||||
maxFileSize,
|
||||
acceptedMediaTypes,
|
||||
}: UiEditorImageImporterProps) {
|
||||
const [files, setFiles] = useState<ManagerFile[]>([]);
|
||||
const [selected, setSelected] = useState<ManagerFile[]>([]);
|
||||
const [preview, setPreview] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [currentPath, setCurrentPath] = useState(ROOT_PATH);
|
||||
const currentPathRef = useRef(ROOT_PATH);
|
||||
const remoteLoadedRef = useRef(false);
|
||||
|
||||
const loadLocal = useCallback(async () => {
|
||||
setError(null);
|
||||
await invoke('init_local_game_project', {
|
||||
projectPath,
|
||||
projectId: 'ui-editor-test',
|
||||
name: 'UI Editor Test',
|
||||
});
|
||||
const [manifest, listed] = await Promise.all([
|
||||
invoke<LocalManifestResponse>('get_local_game_manifest', {
|
||||
projectPath,
|
||||
commandId: 'asset.list',
|
||||
}),
|
||||
invoke<LocalProjectFilesResponse>('list_local_project_files', {
|
||||
projectPath,
|
||||
}),
|
||||
]);
|
||||
const registered = (manifest.assets ??
|
||||
manifest.manifest?.assets ??
|
||||
[]) as ManifestAsset[];
|
||||
const registeredPaths = new Set(registered.map((asset) => asset.localPath));
|
||||
const filesFromDisk = (listed.files ?? []).filter((file) =>
|
||||
registeredPaths.has(file.path),
|
||||
);
|
||||
const byPath = new Map(registered.map((asset) => [asset.localPath, asset]));
|
||||
return buildProjectFiles(
|
||||
filesFromDisk
|
||||
.map((file) => byPath.get(file.path))
|
||||
.filter(Boolean) as ManifestAsset[],
|
||||
);
|
||||
}, [projectPath]);
|
||||
|
||||
const replaceProjectFiles = useCallback((projectFiles: ManagerFile[]) => {
|
||||
setFiles((current) => [
|
||||
...buildRootFiles(),
|
||||
...projectFiles,
|
||||
...current.filter((file) => file.source === 'remote'),
|
||||
]);
|
||||
}, []);
|
||||
|
||||
const replaceRemoteFiles = useCallback((remoteFiles: ManagerFile[]) => {
|
||||
setFiles((current) => [
|
||||
...buildRootFiles(),
|
||||
...current.filter((file) => file.source === 'local'),
|
||||
...remoteFiles,
|
||||
]);
|
||||
remoteLoadedRef.current = true;
|
||||
}, []);
|
||||
|
||||
const loadRemote = useCallback(async () => {
|
||||
const library = await loadEditorAssetLibrary();
|
||||
const remoteFiles = buildRemoteFolderFiles(library);
|
||||
replaceRemoteFiles(remoteFiles);
|
||||
return remoteFiles;
|
||||
}, [replaceRemoteFiles]);
|
||||
|
||||
const refresh = useCallback(
|
||||
async (path = currentPathRef.current) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const shouldRefreshProject =
|
||||
path === ROOT_PATH || path.startsWith(PROJECT_ASSETS_PATH);
|
||||
const shouldRefreshRemote =
|
||||
path === ROOT_PATH || path.startsWith(REMOTE_ASSETS_PATH);
|
||||
const projectResult = shouldRefreshProject
|
||||
? await Promise.allSettled([loadLocal()]).then(([result]) => result)
|
||||
: null;
|
||||
if (projectResult?.status === 'fulfilled' && projectResult.value) {
|
||||
replaceProjectFiles(projectResult.value);
|
||||
}
|
||||
let remoteResult: PromiseSettledResult<ManagerFile[]> | null = null;
|
||||
if (
|
||||
shouldRefreshRemote &&
|
||||
(path !== ROOT_PATH || remoteLoadedRef.current)
|
||||
) {
|
||||
remoteResult = await Promise.allSettled([loadRemote()]).then(
|
||||
([result]) => result,
|
||||
);
|
||||
}
|
||||
if (
|
||||
projectResult?.status === 'rejected' ||
|
||||
remoteResult?.status === 'rejected'
|
||||
) {
|
||||
const cause =
|
||||
projectResult?.status === 'rejected'
|
||||
? projectResult.reason
|
||||
: remoteResult?.status === 'rejected'
|
||||
? remoteResult.reason
|
||||
: null;
|
||||
setError(
|
||||
cause instanceof Error
|
||||
? cause.message
|
||||
: '部分素材来源加载失败,仍可继续使用其他来源。',
|
||||
);
|
||||
}
|
||||
} catch (cause) {
|
||||
setError(cause instanceof Error ? cause.message : String(cause));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[loadLocal, loadRemote, replaceProjectFiles],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
currentPathRef.current = ROOT_PATH;
|
||||
remoteLoadedRef.current = false;
|
||||
setCurrentPath(ROOT_PATH);
|
||||
setFiles(buildRootFiles());
|
||||
setSelected([]);
|
||||
setPreview(null);
|
||||
void refresh(ROOT_PATH);
|
||||
}, [open, refresh]);
|
||||
|
||||
useEffect(() => {
|
||||
const item = selected.at(-1);
|
||||
if (!item || item.isDirectory) {
|
||||
setPreview(null);
|
||||
return;
|
||||
}
|
||||
if (item.previewUrl) {
|
||||
setPreview(item.previewUrl);
|
||||
return;
|
||||
}
|
||||
if (item.source === 'remote' && item.remoteObjectKey) {
|
||||
void resolveClientAssetReadUrl(item.remoteObjectKey)
|
||||
.then(setPreview)
|
||||
.catch(() => setPreview(null));
|
||||
return;
|
||||
}
|
||||
if (
|
||||
item.source === 'remote' &&
|
||||
/^https?:\/\//iu.test(item.asset?.localPath ?? '')
|
||||
) {
|
||||
setPreview(item.asset?.localPath ?? null);
|
||||
return;
|
||||
}
|
||||
void invoke<ImagePreviewResponse>('read_local_project_image_preview', {
|
||||
projectPath,
|
||||
relativePath: item.asset?.localPath,
|
||||
})
|
||||
.then((value) => setPreview(value.dataUrl))
|
||||
.catch(() => setPreview(null));
|
||||
}, [projectPath, selected]);
|
||||
|
||||
const pickLocal = async () => {
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
try {
|
||||
const paths = await openNativeFileDialog({
|
||||
title: '选择图片素材',
|
||||
multiple: true,
|
||||
directory: false,
|
||||
filters: [{ name: 'Images', extensions: ['png', 'jpg', 'jpeg', 'webp'] }],
|
||||
});
|
||||
const sourcePaths = Array.isArray(paths) ? paths : paths ? [paths] : [];
|
||||
if (!sourcePaths.length) return;
|
||||
if (sourcePaths.length > maxItems)
|
||||
throw new Error(`最多选择 ${maxItems} 张图片`);
|
||||
const result = await invoke<{ assets: ImportAssetResponse[] }>(
|
||||
'import_ui_editor_local_files',
|
||||
{ projectPath, sourcePaths, maxFileSize },
|
||||
);
|
||||
onImport(
|
||||
result.assets.map((asset) => ({
|
||||
id: asset.id,
|
||||
localPath: asset.localPath,
|
||||
assetKind: asset.assetKind ?? null,
|
||||
})),
|
||||
);
|
||||
onClose();
|
||||
} catch (cause) {
|
||||
setError(cause instanceof Error ? cause.message : String(cause));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
const confirm = async () => {
|
||||
const chosen = selected.filter((item) => !item.isDirectory && item.asset);
|
||||
if (!chosen.length || chosen.length > maxItems) return;
|
||||
try {
|
||||
setLoading(true);
|
||||
const remoteChosen = chosen.filter((entry) => entry.source === 'remote');
|
||||
const localChosen = chosen.filter((entry) => entry.source !== 'remote');
|
||||
const imported: ImportedAsset[] = localChosen.map(
|
||||
(entry) => entry.asset!,
|
||||
);
|
||||
if (remoteChosen.length) {
|
||||
const remoteResult = await invoke<{ assets: ImportAssetResponse[] }>(
|
||||
'import_ui_editor_remote_assets',
|
||||
{
|
||||
projectPath,
|
||||
assets: await Promise.all(
|
||||
remoteChosen.map(async (item) => {
|
||||
const asset = item.asset!;
|
||||
const response = asset.localPath.startsWith('http')
|
||||
? await fetch(asset.localPath)
|
||||
: await readClientAssetBytes(asset.localPath);
|
||||
if (!response.ok)
|
||||
throw new Error(
|
||||
`读取平台素材失败(HTTP ${response.status})`,
|
||||
);
|
||||
const bytes = new Uint8Array(await response.arrayBuffer());
|
||||
if (bytes.byteLength > maxFileSize)
|
||||
throw new Error(`图片超过 ${maxFileSize} 字节限制`);
|
||||
return {
|
||||
assetId: asset.id,
|
||||
objectKey: asset.localPath,
|
||||
bytes: Array.from(bytes),
|
||||
};
|
||||
}),
|
||||
),
|
||||
maxFileSize,
|
||||
},
|
||||
);
|
||||
imported.push(
|
||||
...remoteResult.assets.map((asset, index) => ({
|
||||
id: asset.id,
|
||||
localPath: asset.localPath,
|
||||
assetKind:
|
||||
asset.assetKind ?? remoteChosen[index]?.asset?.assetKind ?? null,
|
||||
})),
|
||||
);
|
||||
}
|
||||
onImport(imported);
|
||||
onClose();
|
||||
} catch (cause) {
|
||||
setError(cause instanceof Error ? cause.message : String(cause));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
const handleComputerImport = () => {
|
||||
void pickLocal();
|
||||
};
|
||||
const handleFolderChange = (path: string) => {
|
||||
const nextPath = path || ROOT_PATH;
|
||||
currentPathRef.current = nextPath;
|
||||
setCurrentPath(nextPath);
|
||||
if (nextPath.startsWith(REMOTE_ASSETS_PATH) && !remoteLoadedRef.current) {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
void loadRemote()
|
||||
.catch((cause) =>
|
||||
setError(cause instanceof Error ? cause.message : String(cause)),
|
||||
)
|
||||
.finally(() => setLoading(false));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ThemedModal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
ariaLabel="导入图片素材"
|
||||
panelClassName="flex h-[min(760px,92dvh)] w-[min(1100px,96vw)] min-w-0 flex-col overflow-hidden rounded-2xl shadow-2xl"
|
||||
>
|
||||
<header className="relative flex h-14 shrink-0 items-center justify-center border-b border-(--platform-subpanel-border) px-14">
|
||||
<h2 className="m-0 text-center text-sm font-semibold leading-5">
|
||||
导入图片素材
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-4 top-1/2 -translate-y-1/2 rounded-lg p-2 hover:bg-black/5"
|
||||
onClick={onClose}
|
||||
aria-label="关闭"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</header>
|
||||
<div className="flex min-h-0 flex-1 flex-row">
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
|
||||
{error ? (
|
||||
<div
|
||||
className="mx-4 mt-3 rounded-lg bg-red-50 px-3 py-2 text-xs text-red-700"
|
||||
role="alert"
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="min-h-0 flex-1 p-3">
|
||||
<FileManager
|
||||
style={{ minHeight: 0, height: '100%', width: '100%' }}
|
||||
files={files}
|
||||
layout="grid"
|
||||
initialPath={currentPath}
|
||||
enableFilePreview={false}
|
||||
permissions={{
|
||||
create: false,
|
||||
upload: false,
|
||||
move: false,
|
||||
copy: false,
|
||||
rename: false,
|
||||
download: false,
|
||||
delete: false,
|
||||
}}
|
||||
acceptedFileTypes={acceptedMediaTypes
|
||||
.map((value) => `.${value.split('/').at(-1)}`)
|
||||
.join(',')}
|
||||
onSelectionChange={(items: ManagerFile[]) => {
|
||||
const assetItems = items
|
||||
.filter((item) => !item.isDirectory)
|
||||
.slice(-maxItems);
|
||||
setSelected(assetItems);
|
||||
}}
|
||||
onFolderChange={handleFolderChange}
|
||||
onRefresh={() => void refresh()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<aside className="flex w-72 shrink-0 flex-col border-l border-(--platform-subpanel-border) p-4">
|
||||
<h3 className="text-xs font-semibold">预览</h3>
|
||||
<div className="mt-3 grid min-h-48 place-items-center overflow-hidden rounded-xl border border-(--platform-subpanel-border) bg-black/3">
|
||||
{preview ? (
|
||||
<img
|
||||
src={preview}
|
||||
alt="选中图片预览"
|
||||
className="max-h-72 max-w-full object-contain"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-xs text-(--platform-text-soft)">
|
||||
选择一张图片
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-auto pt-4">
|
||||
<p className="m-0 text-xs leading-5 text-(--platform-text-soft)">
|
||||
{selected.length}/{maxItems} 已选择 · 单张上限{' '}
|
||||
{Math.round(maxFileSize / 1024 / 1024)} MiB
|
||||
</p>
|
||||
<div className="mt-3 flex flex-col gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="w-full rounded-lg border border-orange-200 bg-orange-50 px-3 py-2 text-xs text-orange-900 hover:bg-orange-100 disabled:opacity-50"
|
||||
disabled={loading}
|
||||
onClick={handleComputerImport}
|
||||
>
|
||||
从电脑导入
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="w-full rounded-lg bg-orange-500 px-3 py-2 text-xs font-semibold text-white disabled:opacity-50"
|
||||
disabled={
|
||||
!selected.length || selected.length > maxItems || loading
|
||||
}
|
||||
onClick={() => void confirm()}
|
||||
>
|
||||
导入
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="w-full rounded-lg border border-(--platform-subpanel-border) px-3 py-2 text-xs hover:bg-black/5"
|
||||
onClick={onClose}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</ThemedModal>
|
||||
);
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
declare module '@cubone/react-file-manager' {
|
||||
import type { CSSProperties, ReactElement, ReactNode } from 'react';
|
||||
|
||||
export type FileManagerFile = {
|
||||
name: string;
|
||||
isDirectory: boolean;
|
||||
path: string;
|
||||
updatedAt?: string;
|
||||
size?: number;
|
||||
};
|
||||
|
||||
export type FileManagerProps<
|
||||
TFile extends FileManagerFile = FileManagerFile,
|
||||
> = {
|
||||
files: TFile[];
|
||||
className?: string;
|
||||
layout?: 'list' | 'grid';
|
||||
initialPath?: string;
|
||||
enableFilePreview?: boolean;
|
||||
acceptedFileTypes?: string;
|
||||
permissions?: Record<string, boolean>;
|
||||
onSelectionChange?: (files: TFile[]) => void;
|
||||
filePreviewComponent?: (file: TFile) => ReactNode;
|
||||
onFileOpen?: (file: TFile) => void;
|
||||
onFolderChange?: (path: string) => void;
|
||||
onRefresh?: () => void;
|
||||
height?: string | number;
|
||||
width?: string | number;
|
||||
style?: CSSProperties;
|
||||
};
|
||||
|
||||
export function FileManager<TFile extends FileManagerFile = FileManagerFile>(
|
||||
props: FileManagerProps<TFile>,
|
||||
): ReactElement;
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import type { FileManagerFile } from '@cubone/react-file-manager';
|
||||
|
||||
export type ImportedAsset = {
|
||||
id: string;
|
||||
localPath: string;
|
||||
assetKind: string | null;
|
||||
};
|
||||
|
||||
export type ManagerFile = FileManagerFile & {
|
||||
previewUrl?: string;
|
||||
source?: 'local' | 'remote';
|
||||
remoteObjectKey?: string;
|
||||
asset?: ImportedAsset;
|
||||
};
|
||||
|
||||
export type ManifestAsset = {
|
||||
id: string;
|
||||
localPath: string;
|
||||
mediaType: string;
|
||||
source?: { kind?: string };
|
||||
};
|
||||
|
||||
type RemoteFolder = { folderId?: string; label?: unknown };
|
||||
type RemoteAsset = {
|
||||
folderId?: string;
|
||||
assetKind?: string | null;
|
||||
label?: unknown;
|
||||
assetId?: string;
|
||||
objectKey?: string;
|
||||
imageSrc?: string;
|
||||
size?: number;
|
||||
previewUrl?: string;
|
||||
};
|
||||
type RemoteLibrary = { folders?: RemoteFolder[]; assets?: RemoteAsset[] };
|
||||
|
||||
export const PROJECT_ASSETS_PATH = '/本地项目素材';
|
||||
export const REMOTE_ASSETS_PATH = '/云端素材库';
|
||||
|
||||
function isImageAssetKind(assetKind: unknown) {
|
||||
const kind = typeof assetKind === 'string' ? assetKind.trim().toLowerCase() : '';
|
||||
if (!kind) return true;
|
||||
return !(
|
||||
kind === 'video' || kind === 'audio' || kind === 'sound-effect' ||
|
||||
kind === 'background-music' || kind === 'character-animation' ||
|
||||
kind.startsWith('video-') || kind.startsWith('audio-') ||
|
||||
kind.startsWith('character-animation')
|
||||
);
|
||||
}
|
||||
|
||||
function imagePath(path: string) {
|
||||
return path.replace(/^\/+/, '').replaceAll('\\', '/');
|
||||
}
|
||||
|
||||
function managerPathFromLocalPath(localPath: string) {
|
||||
const path = imagePath(localPath);
|
||||
return `${PROJECT_ASSETS_PATH}/${path.split('/').slice(1).join('/')}`;
|
||||
}
|
||||
|
||||
function safeManagerName(value: unknown, fallback: string) {
|
||||
const normalized = typeof value === 'string' ? value.trim() : '';
|
||||
return (normalized || fallback).replaceAll('/', '/').replaceAll('\\', '\');
|
||||
}
|
||||
|
||||
export function buildProjectFiles(assets: ManifestAsset[]): ManagerFile[] {
|
||||
const result: ManagerFile[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const asset of assets) {
|
||||
const path = imagePath(asset.localPath);
|
||||
if (!path.startsWith('assets/') || !/^assets\/(.*\.(png|jpe?g|webp))$/i.test(path)) continue;
|
||||
const parts = path.split('/');
|
||||
for (let index = 1; index < parts.length - 1; index += 1) {
|
||||
const folderPath = `${PROJECT_ASSETS_PATH}/${parts.slice(1, index + 1).join('/')}`;
|
||||
if (seen.has(folderPath)) continue;
|
||||
seen.add(folderPath);
|
||||
result.push({ name: parts[index] ?? folderPath, isDirectory: true, path: folderPath, source: 'local' });
|
||||
}
|
||||
result.push({
|
||||
name: parts.at(-1) ?? path,
|
||||
isDirectory: false,
|
||||
path: managerPathFromLocalPath(asset.localPath),
|
||||
source: 'local',
|
||||
asset: { id: asset.id, localPath: asset.localPath, assetKind: null },
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function buildRemoteFolderFiles(payload: unknown): ManagerFile[] {
|
||||
const root = payload && typeof payload === 'object' ? (payload as Record<string, unknown>) : {};
|
||||
const candidate = root.library ?? root.data ?? payload;
|
||||
const library: RemoteLibrary = candidate && typeof candidate === 'object' ? (candidate as RemoteLibrary) : {};
|
||||
const folders = Array.isArray(library.folders) ? library.folders : [];
|
||||
const assets = Array.isArray(library.assets) ? library.assets : [];
|
||||
const result: ManagerFile[] = [];
|
||||
const folderIds = new Set(folders.map((folder) => folder.folderId));
|
||||
const normalizedFolders = [...folders];
|
||||
if (assets.some((asset) => !folderIds.has(asset.folderId))) normalizedFolders.push({ folderId: '__uncategorized__', label: '未分类' });
|
||||
const usedFolderNames = new Set<string>();
|
||||
for (const folder of normalizedFolders) {
|
||||
const baseFolderName = safeManagerName(folder.label, folder.folderId || '未分类');
|
||||
const folderName = usedFolderNames.has(baseFolderName)
|
||||
? `${baseFolderName} (${safeManagerName(folder.folderId, String(usedFolderNames.size + 1))})`
|
||||
: baseFolderName;
|
||||
usedFolderNames.add(folderName);
|
||||
const folderPath = `${REMOTE_ASSETS_PATH}/${folderName}`;
|
||||
result.push({ name: folderName, isDirectory: true, path: folderPath, source: 'remote' });
|
||||
const folderAssets = assets.filter((item) => folder.folderId === '__uncategorized__' ? !folderIds.has(item.folderId) : item.folderId === folder.folderId);
|
||||
const usedNames = new Set<string>();
|
||||
for (const asset of folderAssets) {
|
||||
if (!isImageAssetKind(asset.assetKind)) continue;
|
||||
const baseName = safeManagerName(asset.label, asset.assetId || '图片素材');
|
||||
const fileName = usedNames.has(baseName)
|
||||
? `${baseName} (${safeManagerName(asset.assetId, String(usedNames.size + 1))})`
|
||||
: baseName;
|
||||
usedNames.add(fileName);
|
||||
const localPath = asset.objectKey || asset.imageSrc || asset.assetId || fileName;
|
||||
result.push({
|
||||
name: fileName,
|
||||
isDirectory: false,
|
||||
path: `${folderPath}/${fileName}`,
|
||||
size: asset.size,
|
||||
previewUrl: asset.previewUrl,
|
||||
source: 'remote',
|
||||
remoteObjectKey: asset.objectKey,
|
||||
asset: {
|
||||
id: asset.assetId || asset.objectKey || asset.imageSrc || 'platform-image',
|
||||
localPath,
|
||||
assetKind: asset.assetKind ?? null,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function buildRootFiles(): ManagerFile[] {
|
||||
return [
|
||||
{ name: '本地项目素材', isDirectory: true, path: PROJECT_ASSETS_PATH },
|
||||
{ name: '云端素材库', isDirectory: true, path: REMOTE_ASSETS_PATH },
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import {
|
||||
type CSSProperties,
|
||||
type ReactNode,
|
||||
useEffect,
|
||||
useRef,
|
||||
} from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
type ThemedModalTheme = 'light' | 'dark';
|
||||
|
||||
export type ThemedModalProps = {
|
||||
open: boolean;
|
||||
ariaLabel: string;
|
||||
children: ReactNode;
|
||||
onClose: () => void;
|
||||
theme?: ThemedModalTheme;
|
||||
closeOnBackdrop?: boolean;
|
||||
closeOnEscape?: boolean;
|
||||
overlayClassName?: string;
|
||||
panelClassName?: string;
|
||||
panelStyle?: CSSProperties;
|
||||
};
|
||||
|
||||
function joinClassNames(
|
||||
...classNames: Array<string | false | null | undefined>
|
||||
) {
|
||||
return classNames.filter(Boolean).join(' ');
|
||||
}
|
||||
|
||||
export function ThemedModal({
|
||||
open,
|
||||
ariaLabel,
|
||||
children,
|
||||
onClose,
|
||||
theme = 'light',
|
||||
closeOnBackdrop = true,
|
||||
closeOnEscape = true,
|
||||
overlayClassName,
|
||||
panelClassName,
|
||||
panelStyle,
|
||||
}: ThemedModalProps) {
|
||||
const backdropPointerSequenceRef = useRef<boolean | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !closeOnEscape) return;
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') onClose();
|
||||
};
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [closeOnEscape, onClose, open]);
|
||||
|
||||
if (!open || typeof document === 'undefined') return null;
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className={joinClassNames(
|
||||
'platform-theme',
|
||||
`platform-theme--${theme}`,
|
||||
'fixed inset-0 z-50 grid place-items-center bg-black/35 p-4',
|
||||
overlayClassName,
|
||||
)}
|
||||
onPointerDownCapture={(event) => {
|
||||
backdropPointerSequenceRef.current = event.target === event.currentTarget;
|
||||
}}
|
||||
onPointerUpCapture={(event) => {
|
||||
backdropPointerSequenceRef.current =
|
||||
backdropPointerSequenceRef.current === true &&
|
||||
event.target === event.currentTarget;
|
||||
}}
|
||||
onPointerCancelCapture={() => {
|
||||
backdropPointerSequenceRef.current = false;
|
||||
}}
|
||||
onClick={(event) => {
|
||||
const stayedOnBackdrop = backdropPointerSequenceRef.current !== false;
|
||||
backdropPointerSequenceRef.current = null;
|
||||
if (
|
||||
closeOnBackdrop &&
|
||||
stayedOnBackdrop &&
|
||||
event.target === event.currentTarget
|
||||
) {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<section
|
||||
className={panelClassName}
|
||||
style={{
|
||||
background: 'var(--platform-subpanel-fill)',
|
||||
color: 'var(--platform-text-strong)',
|
||||
...panelStyle,
|
||||
}}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={ariaLabel}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
{children}
|
||||
</section>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -93,6 +93,104 @@ export async function requestClientApi<T>(
|
||||
return text ? unwrapApiResponse<T>(JSON.parse(text) as T) : (null as T);
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticated binary request for internal APIs (for example asset bytes).
|
||||
* This intentionally shares the same token and URL resolution as requestClientApi
|
||||
* instead of using the external editor API key flow.
|
||||
*/
|
||||
export async function requestClientApiBytes(
|
||||
url: string,
|
||||
fallbackMessage: string,
|
||||
init: RequestInit = {},
|
||||
) {
|
||||
const headers = new Headers(init.headers);
|
||||
headers.set(API_RESPONSE_ENVELOPE_HEADER, API_RESPONSE_ENVELOPE_VERSION);
|
||||
const token = getStoredAuthAccessToken();
|
||||
if (token) {
|
||||
headers.set('Authorization', `Bearer ${token}`);
|
||||
}
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(resolveClientApiUrl(url), {
|
||||
...init,
|
||||
credentials: 'same-origin',
|
||||
headers,
|
||||
});
|
||||
} catch {
|
||||
throw new ClientAuthRequestError(
|
||||
'无法连接登录服务,请确认配套后端或 API 代理已启动后重试',
|
||||
{ networkError: true },
|
||||
);
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new ClientAuthRequestError(
|
||||
await readApiErrorMessage(response, fallbackMessage),
|
||||
{ status: response.status },
|
||||
);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
export type ClientAssetReadUrlResponse = {
|
||||
read?: { signedUrl?: string; objectKey?: string; expiresAt?: string };
|
||||
signedUrl?: string;
|
||||
objectKey?: string;
|
||||
expiresAt?: string;
|
||||
};
|
||||
|
||||
export type ClientEditorAssetLibrary = {
|
||||
folders: Array<{
|
||||
folderId: string;
|
||||
label: string;
|
||||
sortOrder?: number;
|
||||
collapsed?: boolean;
|
||||
systemDefault?: boolean;
|
||||
}>;
|
||||
assets: Array<{
|
||||
assetId: string;
|
||||
folderId: string;
|
||||
label: string;
|
||||
imageSrc?: string;
|
||||
thumbnailSrc?: string | null;
|
||||
objectKey?: string | null;
|
||||
assetObjectId?: string | null;
|
||||
width?: number;
|
||||
height?: number;
|
||||
assetKind?: string | null;
|
||||
[key: string]: unknown;
|
||||
}>;
|
||||
};
|
||||
|
||||
export function loadEditorAssetLibrary() {
|
||||
return requestClientApi<{ library: ClientEditorAssetLibrary }>(
|
||||
'/api/editor/assets/library',
|
||||
{ method: 'GET' },
|
||||
'读取平台素材库失败',
|
||||
).then((response) => response.library);
|
||||
}
|
||||
|
||||
export async function resolveClientAssetReadUrl(objectKey: string) {
|
||||
const params = new URLSearchParams({ objectKey: objectKey.replace(/^\/+/, '') });
|
||||
const payload = await requestClientApi<ClientAssetReadUrlResponse>(
|
||||
`/api/assets/read-url?${params.toString()}`,
|
||||
{ method: 'GET' },
|
||||
'读取平台素材预览地址失败',
|
||||
);
|
||||
const signedUrl = payload?.read?.signedUrl ?? payload?.signedUrl;
|
||||
if (!signedUrl?.trim()) {
|
||||
throw new Error('平台素材预览地址缺失');
|
||||
}
|
||||
return signedUrl;
|
||||
}
|
||||
|
||||
export function readClientAssetBytes(objectKey: string) {
|
||||
const params = new URLSearchParams({ objectKey: objectKey.replace(/^\/+/, '') });
|
||||
return requestClientApiBytes(
|
||||
`/api/assets/read-bytes?${params.toString()}`,
|
||||
'读取平台素材内容失败',
|
||||
);
|
||||
}
|
||||
|
||||
export function getClientProfileDashboard() {
|
||||
return requestClientApi<ProfileDashboardSummary>(
|
||||
'/api/profile/dashboard',
|
||||
|
||||
Reference in New Issue
Block a user