新增素材导入功能

支持从本地或云端导入图片素材,包含预览, 限制检查。
This commit is contained in:
2026-08-12 17:47:05 +08:00
parent f30f1e5829
commit c5eaebdfdd
10 changed files with 975 additions and 1 deletions
+2
View File
@@ -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>
);
}
@@ -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',