重构图片与字体通用导入器
抽取按来源配置的 AssetImporter settings、候选过滤和提交约束 统一本地与远端 UI Editor Tauri 导入请求并保留字体本地限制 更新 UI Editor 技术方案中的 importer 契约
This commit is contained in:
@@ -5,6 +5,190 @@ const UI_EDITOR_FONT_MAX_FILE_SIZE: u64 = 8 * 1024 * 1024;
|
||||
const UI_EDITOR_FONT_MAX_TOTAL_SIZE: u64 = 32 * 1024 * 1024;
|
||||
const UI_EDITOR_FONT_MAX_COUNT: usize = 64;
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct AssetImportRequirements {
|
||||
max_items: usize,
|
||||
max_file_size_bytes: Option<u64>,
|
||||
max_total_size_bytes: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct AssetImportPolicy {
|
||||
destination: String,
|
||||
accepted_media_types: Vec<String>,
|
||||
accepted_extensions: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(tag = "source", rename_all = "camelCase")]
|
||||
pub enum UiEditorAssetImportRequest {
|
||||
Local {
|
||||
project_path: String,
|
||||
local_source_paths: Vec<String>,
|
||||
local_policy: AssetImportPolicy,
|
||||
local_requirements: AssetImportRequirements,
|
||||
},
|
||||
Remote {
|
||||
project_path: String,
|
||||
remote_assets: Vec<serde_json::Value>,
|
||||
remote_policy: AssetImportPolicy,
|
||||
remote_requirements: AssetImportRequirements,
|
||||
},
|
||||
}
|
||||
|
||||
fn validate_asset_import_policy(policy: &AssetImportPolicy) -> Result<bool, String> {
|
||||
let normalized_destination = policy.destination.trim();
|
||||
let is_font = normalized_destination == "assets/fonts";
|
||||
let is_image = normalized_destination == "assets/uploads";
|
||||
if !is_font && !is_image {
|
||||
return Err("不支持的资产导入目录".to_string());
|
||||
}
|
||||
let allowed_media_types = if is_font {
|
||||
["font/ttf", "font/otf", "font/woff", "font/woff2"]
|
||||
.into_iter()
|
||||
.collect::<std::collections::BTreeSet<_>>()
|
||||
} else {
|
||||
["image/png", "image/jpeg", "image/webp"]
|
||||
.into_iter()
|
||||
.collect::<std::collections::BTreeSet<_>>()
|
||||
};
|
||||
if policy
|
||||
.accepted_media_types
|
||||
.iter()
|
||||
.any(|value| !allowed_media_types.contains(value.trim().to_ascii_lowercase().as_str()))
|
||||
{
|
||||
return Err("导入策略包含不支持的媒体类型".to_string());
|
||||
}
|
||||
let allowed_extensions = if is_font {
|
||||
["ttf", "otf", "woff", "woff2"]
|
||||
.into_iter()
|
||||
.collect::<std::collections::BTreeSet<_>>()
|
||||
} else {
|
||||
["png", "jpg", "jpeg", "webp"]
|
||||
.into_iter()
|
||||
.collect::<std::collections::BTreeSet<_>>()
|
||||
};
|
||||
if policy.accepted_extensions.iter().any(|value| {
|
||||
!allowed_extensions.contains(
|
||||
value
|
||||
.trim()
|
||||
.trim_start_matches('.')
|
||||
.to_ascii_lowercase()
|
||||
.as_str(),
|
||||
)
|
||||
}) {
|
||||
return Err("导入策略包含不支持的文件扩展名".to_string());
|
||||
}
|
||||
Ok(is_font)
|
||||
}
|
||||
|
||||
fn validate_local_asset_import_requirements(
|
||||
source_paths: &[String],
|
||||
requirements: &AssetImportRequirements,
|
||||
) -> Result<u64, String> {
|
||||
if source_paths.len() > requirements.max_items {
|
||||
return Err(format!("最多导入 {} 个文件", requirements.max_items));
|
||||
}
|
||||
let mut total_size = 0u64;
|
||||
for source in source_paths {
|
||||
let metadata =
|
||||
fs::symlink_metadata(source.trim()).map_err(|_| "读取本地文件失败".to_string())?;
|
||||
if metadata.file_type().is_symlink() || !metadata.is_file() {
|
||||
return Err("只能导入普通文件".to_string());
|
||||
}
|
||||
if let Some(max_size) = requirements.max_file_size_bytes {
|
||||
if metadata.len() > max_size {
|
||||
return Err(format!("文件超过 {} 字节限制", max_size));
|
||||
}
|
||||
}
|
||||
total_size = total_size.saturating_add(metadata.len());
|
||||
}
|
||||
if let Some(max_total_size) = requirements.max_total_size_bytes {
|
||||
if total_size > max_total_size {
|
||||
return Err(format!("文件总量超过 {} 字节限制", max_total_size));
|
||||
}
|
||||
}
|
||||
Ok(total_size)
|
||||
}
|
||||
|
||||
fn validate_remote_asset_import_requirements(
|
||||
assets: &[serde_json::Value],
|
||||
requirements: &AssetImportRequirements,
|
||||
) -> Result<(), String> {
|
||||
if assets.len() > requirements.max_items {
|
||||
return Err(format!("最多导入 {} 个文件", requirements.max_items));
|
||||
}
|
||||
let mut total_size = 0u64;
|
||||
for asset in assets {
|
||||
let bytes = asset
|
||||
.get("bytes")
|
||||
.cloned()
|
||||
.ok_or_else(|| "远端素材缺少文件内容".to_string())
|
||||
.and_then(|value| {
|
||||
serde_json::from_value::<Vec<u8>>(value).map_err(|_| "远端素材内容无效".to_string())
|
||||
})?;
|
||||
let size = bytes.len() as u64;
|
||||
if let Some(max_size) = requirements.max_file_size_bytes {
|
||||
if size > max_size {
|
||||
return Err(format!("文件超过 {} 字节限制", max_size));
|
||||
}
|
||||
}
|
||||
total_size = total_size.saturating_add(size);
|
||||
}
|
||||
if let Some(max_total_size) = requirements.max_total_size_bytes {
|
||||
if total_size > max_total_size {
|
||||
return Err(format!("文件总量超过 {} 字节限制", max_total_size));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn import_ui_editor_assets(
|
||||
request: UiEditorAssetImportRequest,
|
||||
) -> Result<LocalImportResult, String> {
|
||||
match request {
|
||||
UiEditorAssetImportRequest::Local {
|
||||
project_path,
|
||||
local_source_paths,
|
||||
local_policy,
|
||||
local_requirements,
|
||||
} => {
|
||||
let is_font = validate_asset_import_policy(&local_policy)?;
|
||||
validate_local_asset_import_requirements(&local_source_paths, &local_requirements)?;
|
||||
if is_font {
|
||||
import_ui_editor_local_fonts(project_path, local_source_paths)
|
||||
} else {
|
||||
import_ui_editor_local_files(
|
||||
project_path,
|
||||
local_source_paths,
|
||||
local_requirements.max_file_size_bytes.unwrap_or(u64::MAX),
|
||||
)
|
||||
}
|
||||
}
|
||||
UiEditorAssetImportRequest::Remote {
|
||||
project_path,
|
||||
remote_assets,
|
||||
remote_policy,
|
||||
remote_requirements,
|
||||
} => {
|
||||
let is_font = validate_asset_import_policy(&remote_policy)?;
|
||||
if is_font {
|
||||
return Err("字体导入不支持云端素材".to_string());
|
||||
}
|
||||
validate_remote_asset_import_requirements(&remote_assets, &remote_requirements)?;
|
||||
import_ui_editor_remote_assets(
|
||||
project_path,
|
||||
remote_assets,
|
||||
remote_requirements.max_file_size_bytes.unwrap_or(u64::MAX),
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn init_local_game_project(
|
||||
project_path: String,
|
||||
@@ -1485,7 +1669,6 @@ 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>,
|
||||
@@ -1618,7 +1801,6 @@ pub(crate) fn prepare_ui_editor_project_fonts(
|
||||
Ok(fonts)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn import_ui_editor_local_fonts(
|
||||
project_path: String,
|
||||
source_paths: Vec<String>,
|
||||
@@ -1952,7 +2134,6 @@ mod ui_editor_font_tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn import_ui_editor_remote_assets(
|
||||
project_path: String,
|
||||
assets: Vec<serde_json::Value>,
|
||||
|
||||
@@ -2298,12 +2298,10 @@ fn main() {
|
||||
import_canvas_asset,
|
||||
import_canvas_export,
|
||||
sync_canvas_project_assets,
|
||||
import_ui_editor_local_files,
|
||||
import_ui_editor_local_fonts,
|
||||
import_ui_editor_assets,
|
||||
prepare_ui_editor_project_fonts,
|
||||
read_ui_editor_font_bytes,
|
||||
check_ui_editor_font_glyph_coverage,
|
||||
import_ui_editor_remote_assets,
|
||||
suggest_ui_design_semantic,
|
||||
recognize_ui,
|
||||
merge_ui,
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
} from '../../services/projectResourcePreviewTransport';
|
||||
import { ThemedModal } from '../modal/ThemedModal';
|
||||
import {
|
||||
type AssetImporterMode,
|
||||
type AssetImporterSettings,
|
||||
buildProjectFiles,
|
||||
buildRemoteFolderFiles,
|
||||
buildRootFiles,
|
||||
@@ -30,18 +30,22 @@ import {
|
||||
} from './utils';
|
||||
|
||||
export type { ImportedAsset } from './utils';
|
||||
export type {
|
||||
AssetImporterSettings,
|
||||
ImportPolicy,
|
||||
ImportRequirements,
|
||||
LocalAssetCandidate,
|
||||
RemoteAssetCandidate,
|
||||
} from './utils';
|
||||
|
||||
const ROOT_PATH = '/';
|
||||
|
||||
export type UiEditorImageImporterProps = {
|
||||
export type AssetImporterProps = {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onImport: (assets: ImportedAsset[]) => void;
|
||||
projectPath: string;
|
||||
maxItems: number;
|
||||
maxFileSize: number;
|
||||
acceptedMediaTypes: readonly string[];
|
||||
mode?: AssetImporterMode;
|
||||
settings: AssetImporterSettings;
|
||||
};
|
||||
|
||||
type LocalProjectFile = { path: string };
|
||||
@@ -61,16 +65,17 @@ type ImportAssetResponse = {
|
||||
assetKind?: string | null;
|
||||
};
|
||||
|
||||
type GenericImportResponse = { assets: ImportAssetResponse[] };
|
||||
|
||||
export function AssetImporter({
|
||||
open,
|
||||
onClose,
|
||||
onImport,
|
||||
projectPath,
|
||||
maxItems,
|
||||
maxFileSize,
|
||||
acceptedMediaTypes,
|
||||
mode = 'image',
|
||||
}: UiEditorImageImporterProps) {
|
||||
settings,
|
||||
}: AssetImporterProps) {
|
||||
const maxItems = settings.local.requirements.maxItems;
|
||||
const maxFileSize = settings.local.requirements.maxFileSizeBytes ?? 0;
|
||||
const [files, setFiles] = useState<ManagerFile[]>([]);
|
||||
const [selected, setSelected] = useState<ManagerFile[]>([]);
|
||||
const [preview, setPreview] = useState<string | null>(null);
|
||||
@@ -118,41 +123,45 @@ export function AssetImporter({
|
||||
filesFromDisk
|
||||
.map((file) => byPath.get(file.path))
|
||||
.filter(Boolean) as ManifestAsset[],
|
||||
mode,
|
||||
settings.local.typeFilter,
|
||||
);
|
||||
}, [mode, projectPath]);
|
||||
}, [projectPath, settings.local.typeFilter]);
|
||||
|
||||
const replaceProjectFiles = useCallback(
|
||||
(projectFiles: ManagerFile[]) => {
|
||||
setFiles((current) => [
|
||||
...buildRootFiles(mode),
|
||||
...buildRootFiles(settings),
|
||||
...projectFiles,
|
||||
...(mode === 'image'
|
||||
...(settings.remote
|
||||
? current.filter((file) => file.source === 'remote')
|
||||
: []),
|
||||
]);
|
||||
},
|
||||
[mode],
|
||||
[settings],
|
||||
);
|
||||
|
||||
const replaceRemoteFiles = useCallback(
|
||||
(remoteFiles: ManagerFile[]) => {
|
||||
setFiles((current) => [
|
||||
...buildRootFiles(mode),
|
||||
...buildRootFiles(settings),
|
||||
...current.filter((file) => file.source === 'local'),
|
||||
...remoteFiles,
|
||||
]);
|
||||
remoteLoadedRef.current = true;
|
||||
},
|
||||
[mode],
|
||||
[settings],
|
||||
);
|
||||
|
||||
const loadRemote = useCallback(async () => {
|
||||
const library = await loadEditorAssetLibrary();
|
||||
const remoteFiles = buildRemoteFolderFiles(library);
|
||||
if (!settings.remote) return [];
|
||||
const remoteFiles = buildRemoteFolderFiles(
|
||||
library,
|
||||
settings.remote.typeFilter,
|
||||
);
|
||||
replaceRemoteFiles(remoteFiles);
|
||||
return remoteFiles;
|
||||
}, [replaceRemoteFiles]);
|
||||
}, [replaceRemoteFiles, settings.remote]);
|
||||
|
||||
const refresh = useCallback(
|
||||
async (path = currentPathRef.current) => {
|
||||
@@ -162,7 +171,7 @@ export function AssetImporter({
|
||||
const shouldRefreshProject =
|
||||
path === ROOT_PATH || path.startsWith(PROJECT_ASSETS_PATH);
|
||||
const shouldRefreshRemote =
|
||||
mode === 'image' &&
|
||||
settings.remote &&
|
||||
(path === ROOT_PATH || path.startsWith(REMOTE_ASSETS_PATH));
|
||||
const projectResult = shouldRefreshProject
|
||||
? await Promise.allSettled([loadLocal()]).then(([result]) => result)
|
||||
@@ -201,7 +210,7 @@ export function AssetImporter({
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[loadLocal, loadRemote, mode, replaceProjectFiles],
|
||||
[loadLocal, loadRemote, replaceProjectFiles, settings.remote],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -209,18 +218,14 @@ export function AssetImporter({
|
||||
currentPathRef.current = ROOT_PATH;
|
||||
remoteLoadedRef.current = false;
|
||||
setCurrentPath(ROOT_PATH);
|
||||
setFiles(buildRootFiles(mode));
|
||||
setFiles(buildRootFiles(settings));
|
||||
setSelected([]);
|
||||
setPreview(null);
|
||||
void refresh(ROOT_PATH);
|
||||
}, [mode, open, refresh]);
|
||||
}, [open, refresh, settings]);
|
||||
|
||||
useEffect(() => {
|
||||
const item = selected.at(-1);
|
||||
if (mode === 'font') {
|
||||
setPreview(null);
|
||||
return;
|
||||
}
|
||||
if (!item || item.isDirectory) {
|
||||
setPreview(null);
|
||||
return;
|
||||
@@ -250,49 +255,37 @@ export function AssetImporter({
|
||||
})
|
||||
.then((value) => setPreview(value.dataUrl))
|
||||
.catch(() => setPreview(null));
|
||||
}, [mode, projectPath, selected]);
|
||||
}, [projectPath, selected]);
|
||||
|
||||
const pickLocal = async () => {
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
try {
|
||||
const paths = await openNativeFileDialog({
|
||||
title: mode === 'font' ? '选择字体文件' : '选择图片素材',
|
||||
title: settings.local.fileDialog.title,
|
||||
multiple: true,
|
||||
directory: false,
|
||||
filters:
|
||||
mode === 'font'
|
||||
? [
|
||||
{
|
||||
name: 'Fonts',
|
||||
extensions: ['ttf', 'otf', 'woff', 'woff2'],
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
name: 'Images',
|
||||
extensions: ['png', 'jpg', 'jpeg', 'webp'],
|
||||
},
|
||||
],
|
||||
filters: settings.local.fileDialog.filters.map((filter) => ({
|
||||
name: filter.name,
|
||||
extensions: [...filter.extensions],
|
||||
})),
|
||||
});
|
||||
const sourcePaths = Array.isArray(paths) ? paths : paths ? [paths] : [];
|
||||
if (!sourcePaths.length) return;
|
||||
if (sourcePaths.length > maxItems)
|
||||
throw new Error(
|
||||
mode === 'font'
|
||||
? `最多选择 ${maxItems} 个字体面`
|
||||
: `最多选择 ${maxItems} 张图片`,
|
||||
);
|
||||
const result =
|
||||
mode === 'font'
|
||||
? await invoke<{ assets: ImportAssetResponse[] }>(
|
||||
'import_ui_editor_local_fonts',
|
||||
{ projectPath, sourcePaths },
|
||||
)
|
||||
: await invoke<{ assets: ImportAssetResponse[] }>(
|
||||
'import_ui_editor_local_files',
|
||||
{ projectPath, sourcePaths, maxFileSize },
|
||||
);
|
||||
throw new Error(`最多选择 ${maxItems} 个文件`);
|
||||
const result = await invoke<GenericImportResponse>(
|
||||
'import_ui_editor_assets',
|
||||
{
|
||||
request: {
|
||||
projectPath,
|
||||
source: 'local',
|
||||
localSourcePaths: sourcePaths,
|
||||
localPolicy: settings.local.localPolicy,
|
||||
localRequirements: settings.local.requirements,
|
||||
},
|
||||
},
|
||||
);
|
||||
onImport(
|
||||
result.assets.map((asset) => ({
|
||||
id: asset.id,
|
||||
@@ -318,31 +311,33 @@ export function AssetImporter({
|
||||
(entry) => entry.asset!,
|
||||
);
|
||||
if (remoteChosen.length) {
|
||||
const remoteResult = await invoke<{ assets: ImportAssetResponse[] }>(
|
||||
'import_ui_editor_remote_assets',
|
||||
const remoteResult = await invoke<GenericImportResponse>(
|
||||
'import_ui_editor_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,
|
||||
request: {
|
||||
projectPath,
|
||||
source: 'remote',
|
||||
remoteAssets: 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());
|
||||
return {
|
||||
assetId: asset.id,
|
||||
objectKey: asset.localPath,
|
||||
bytes: Array.from(bytes),
|
||||
};
|
||||
}),
|
||||
),
|
||||
remotePolicy: settings.remote!.remotePolicy,
|
||||
remoteRequirements: settings.remote!.requirements,
|
||||
},
|
||||
},
|
||||
);
|
||||
imported.push(
|
||||
@@ -370,7 +365,7 @@ export function AssetImporter({
|
||||
currentPathRef.current = nextPath;
|
||||
setCurrentPath(nextPath);
|
||||
if (
|
||||
mode === 'image' &&
|
||||
settings.remote &&
|
||||
nextPath.startsWith(REMOTE_ASSETS_PATH) &&
|
||||
!remoteLoadedRef.current
|
||||
) {
|
||||
@@ -388,12 +383,13 @@ export function AssetImporter({
|
||||
<ThemedModal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
ariaLabel={mode === 'font' ? '导入字体' : '导入图片素材'}
|
||||
ariaLabel={settings.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">
|
||||
{mode === 'font' ? '导入字体' : '导入图片素材'}
|
||||
{settings.icon}
|
||||
{settings.title}
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
@@ -430,8 +426,9 @@ export function AssetImporter({
|
||||
download: false,
|
||||
delete: false,
|
||||
}}
|
||||
acceptedFileTypes={acceptedMediaTypes
|
||||
.map((value) => `.${value.split('/').at(-1)}`)
|
||||
acceptedFileTypes={settings.local.fileDialog.filters
|
||||
.flatMap((filter) => filter.extensions)
|
||||
.map((value) => `.${value}`)
|
||||
.join(',')}
|
||||
onSelectionChange={(items: ManagerFile[]) => {
|
||||
const assetItems = items
|
||||
@@ -445,15 +442,9 @@ export function AssetImporter({
|
||||
</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">
|
||||
{mode === 'font' ? '字体文件' : '预览'}
|
||||
</h3>
|
||||
<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">
|
||||
{mode === 'font' ? (
|
||||
<span className="px-4 text-center text-xs text-(--platform-text-soft)">
|
||||
{selected.at(-1)?.name ?? '选择一个字体文件'}
|
||||
</span>
|
||||
) : preview ? (
|
||||
{preview ? (
|
||||
<img
|
||||
src={preview}
|
||||
alt="选中图片预览"
|
||||
@@ -461,14 +452,16 @@ export function AssetImporter({
|
||||
/>
|
||||
) : (
|
||||
<span className="text-xs text-(--platform-text-soft)">
|
||||
选择一张图片
|
||||
{selected.at(-1)?.name ?? '选择一个文件'}
|
||||
</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
|
||||
{selected.length}/{maxItems} 已选择
|
||||
{maxFileSize > 0
|
||||
? ` · 单文件上限 ${Math.round(maxFileSize / 1024 / 1024)} MiB`
|
||||
: null}
|
||||
</p>
|
||||
<div className="mt-3 flex flex-col gap-2">
|
||||
<button
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import { Image as ImageIcon, Type } from 'lucide-react';
|
||||
import { createElement } from 'react';
|
||||
|
||||
import type {
|
||||
AssetImporterSettings,
|
||||
ImportRequirements,
|
||||
LocalAssetCandidate,
|
||||
RemoteAssetCandidate,
|
||||
} from './utils';
|
||||
|
||||
const IMAGE_MEDIA_TYPES = ['image/png', 'image/jpeg', 'image/webp'] as const;
|
||||
const IMAGE_EXTENSIONS = ['png', 'jpg', 'jpeg', 'webp'] as const;
|
||||
const FONT_MEDIA_TYPES = [
|
||||
'font/ttf',
|
||||
'font/otf',
|
||||
'font/woff',
|
||||
'font/woff2',
|
||||
] as const;
|
||||
const FONT_EXTENSIONS = ['ttf', 'otf', 'woff', 'woff2'] as const;
|
||||
|
||||
function imageLocalTypeFilter(asset: LocalAssetCandidate) {
|
||||
return (
|
||||
IMAGE_MEDIA_TYPES.includes(
|
||||
asset.mediaType.toLowerCase() as (typeof IMAGE_MEDIA_TYPES)[number],
|
||||
) || /\.(png|jpe?g|webp)$/iu.test(asset.localPath)
|
||||
);
|
||||
}
|
||||
|
||||
function imageRemoteTypeFilter(asset: RemoteAssetCandidate) {
|
||||
return (
|
||||
asset.assetKind === null ||
|
||||
!/^(video|audio|sound-effect|background-music|character-animation)/iu.test(
|
||||
asset.assetKind,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function fontLocalTypeFilter(asset: LocalAssetCandidate) {
|
||||
return (
|
||||
FONT_MEDIA_TYPES.includes(
|
||||
asset.mediaType.toLowerCase() as (typeof FONT_MEDIA_TYPES)[number],
|
||||
) || /\.(ttf|otf|woff2?)$/iu.test(asset.localPath)
|
||||
);
|
||||
}
|
||||
|
||||
const imageRequirements: ImportRequirements = {
|
||||
maxItems: 100,
|
||||
maxFileSizeBytes: 20 * 1024 * 1024,
|
||||
};
|
||||
|
||||
const fontRequirements: ImportRequirements = {
|
||||
maxItems: 64,
|
||||
maxFileSizeBytes: 8 * 1024 * 1024,
|
||||
maxTotalSizeBytes: 32 * 1024 * 1024,
|
||||
};
|
||||
|
||||
function imageImporterSettings(
|
||||
requirements: ImportRequirements,
|
||||
): AssetImporterSettings {
|
||||
return {
|
||||
title: '导入图片素材',
|
||||
ariaLabel: '导入图片素材',
|
||||
icon: createElement(ImageIcon, { size: 16 }),
|
||||
local: {
|
||||
label: '本地项目素材',
|
||||
typeFilter: imageLocalTypeFilter,
|
||||
fileDialog: {
|
||||
title: '选择图片素材',
|
||||
filters: [{ name: 'Images', extensions: [...IMAGE_EXTENSIONS] }],
|
||||
},
|
||||
requirements,
|
||||
localPolicy: {
|
||||
destination: 'assets/uploads',
|
||||
acceptedMediaTypes: IMAGE_MEDIA_TYPES,
|
||||
acceptedExtensions: IMAGE_EXTENSIONS,
|
||||
},
|
||||
},
|
||||
remote: {
|
||||
label: '云端素材库',
|
||||
typeFilter: imageRemoteTypeFilter,
|
||||
requirements,
|
||||
remotePolicy: {
|
||||
destination: 'assets/uploads',
|
||||
acceptedMediaTypes: IMAGE_MEDIA_TYPES,
|
||||
acceptedExtensions: IMAGE_EXTENSIONS,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const DESIGN_IMAGE_IMPORTER_SETTINGS: AssetImporterSettings =
|
||||
imageImporterSettings({ ...imageRequirements, maxItems: 4 });
|
||||
|
||||
export const SPRITE_IMPORTER_SETTINGS: AssetImporterSettings =
|
||||
imageImporterSettings(imageRequirements);
|
||||
|
||||
export const FONT_IMPORTER_SETTINGS: AssetImporterSettings = {
|
||||
title: '导入字体',
|
||||
ariaLabel: '导入字体',
|
||||
icon: createElement(Type, { size: 16 }),
|
||||
local: {
|
||||
label: '本地项目字体',
|
||||
typeFilter: fontLocalTypeFilter,
|
||||
fileDialog: {
|
||||
title: '选择字体文件',
|
||||
filters: [{ name: 'Fonts', extensions: [...FONT_EXTENSIONS] }],
|
||||
},
|
||||
requirements: fontRequirements,
|
||||
localPolicy: {
|
||||
destination: 'assets/fonts',
|
||||
acceptedMediaTypes: FONT_MEDIA_TYPES,
|
||||
acceptedExtensions: FONT_EXTENSIONS,
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { FileManagerFile } from '@cubone/react-file-manager';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
export type ImportedAsset = {
|
||||
id: string;
|
||||
@@ -6,6 +7,67 @@ export type ImportedAsset = {
|
||||
assetKind: string | null;
|
||||
};
|
||||
|
||||
export type AssetSource = 'local' | 'remote';
|
||||
|
||||
export type LocalAssetCandidate = {
|
||||
id: string;
|
||||
name: string;
|
||||
localPath: string;
|
||||
mediaType: string;
|
||||
};
|
||||
|
||||
export type RemoteAssetCandidate = {
|
||||
assetId: string;
|
||||
name: string;
|
||||
objectKey: string;
|
||||
assetKind: string | null;
|
||||
sizeBytes: number;
|
||||
previewUrl?: string;
|
||||
};
|
||||
|
||||
export type ImportRequirements = {
|
||||
maxItems: number;
|
||||
maxFileSizeBytes?: number;
|
||||
maxTotalSizeBytes?: number;
|
||||
};
|
||||
|
||||
export type ImportPolicy = {
|
||||
destination: string;
|
||||
acceptedMediaTypes: readonly string[];
|
||||
acceptedExtensions: readonly string[];
|
||||
};
|
||||
|
||||
export type NativeDialogSettings = {
|
||||
title: string;
|
||||
filters: ReadonlyArray<{
|
||||
name: string;
|
||||
extensions: readonly string[];
|
||||
}>;
|
||||
};
|
||||
|
||||
export type LocalImporterSettings = {
|
||||
label: string;
|
||||
typeFilter: (asset: LocalAssetCandidate) => boolean;
|
||||
fileDialog: NativeDialogSettings;
|
||||
requirements: ImportRequirements;
|
||||
localPolicy: ImportPolicy;
|
||||
};
|
||||
|
||||
export type RemoteImporterSettings = {
|
||||
label: string;
|
||||
typeFilter: (asset: RemoteAssetCandidate) => boolean;
|
||||
requirements: ImportRequirements;
|
||||
remotePolicy: ImportPolicy;
|
||||
};
|
||||
|
||||
export type AssetImporterSettings = {
|
||||
title: string;
|
||||
ariaLabel: string;
|
||||
icon?: ReactNode;
|
||||
local: LocalImporterSettings;
|
||||
remote?: RemoteImporterSettings;
|
||||
};
|
||||
|
||||
export type ManagerFile = FileManagerFile & {
|
||||
previewUrl?: string;
|
||||
source?: 'local' | 'remote';
|
||||
@@ -35,24 +97,6 @@ type RemoteLibrary = { folders?: RemoteFolder[]; assets?: RemoteAsset[] };
|
||||
|
||||
export const PROJECT_ASSETS_PATH = '/本地项目素材';
|
||||
export const REMOTE_ASSETS_PATH = '/云端素材库';
|
||||
export type AssetImporterMode = 'font' | 'image';
|
||||
|
||||
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('\\', '/');
|
||||
}
|
||||
@@ -67,28 +111,22 @@ function safeManagerName(value: unknown, fallback: string) {
|
||||
return (normalized || fallback).replaceAll('/', '/').replaceAll('\\', '\');
|
||||
}
|
||||
|
||||
function isFontManifestAsset(asset: ManifestAsset) {
|
||||
const mediaType = asset.mediaType.trim().toLowerCase();
|
||||
const path = imagePath(asset.localPath).toLowerCase();
|
||||
return (
|
||||
['font/ttf', 'font/otf', 'font/woff', 'font/woff2'].includes(mediaType) ||
|
||||
/\.(ttf|otf|woff2?)$/u.test(path)
|
||||
);
|
||||
}
|
||||
|
||||
export function buildProjectFiles(
|
||||
assets: ManifestAsset[],
|
||||
mode: AssetImporterMode = 'image',
|
||||
typeFilter: (asset: LocalAssetCandidate) => boolean,
|
||||
): ManagerFile[] {
|
||||
const result: ManagerFile[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const asset of assets) {
|
||||
const path = imagePath(asset.localPath);
|
||||
const supported =
|
||||
mode === 'font'
|
||||
? isFontManifestAsset(asset)
|
||||
: /^assets\/(.*\.(png|jpe?g|webp))$/iu.test(path);
|
||||
if (!path.startsWith('assets/') || !supported) continue;
|
||||
if (!path.startsWith('assets/')) continue;
|
||||
const candidate: LocalAssetCandidate = {
|
||||
id: asset.id,
|
||||
name: path.split('/').at(-1) ?? path,
|
||||
localPath: asset.localPath,
|
||||
mediaType: asset.mediaType,
|
||||
};
|
||||
if (!typeFilter(candidate)) 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('/')}`;
|
||||
@@ -112,7 +150,10 @@ export function buildProjectFiles(
|
||||
return result;
|
||||
}
|
||||
|
||||
export function buildRemoteFolderFiles(payload: unknown): ManagerFile[] {
|
||||
export function buildRemoteFolderFiles(
|
||||
payload: unknown,
|
||||
typeFilter: (asset: RemoteAssetCandidate) => boolean,
|
||||
): ManagerFile[] {
|
||||
const root =
|
||||
payload && typeof payload === 'object'
|
||||
? (payload as Record<string, unknown>)
|
||||
@@ -153,7 +194,6 @@ export function buildRemoteFolderFiles(payload: unknown): ManagerFile[] {
|
||||
);
|
||||
const usedNames = new Set<string>();
|
||||
for (const asset of folderAssets) {
|
||||
if (!isImageAssetKind(asset.assetKind)) continue;
|
||||
const baseName = safeManagerName(
|
||||
asset.label,
|
||||
asset.assetId || '图片素材',
|
||||
@@ -164,6 +204,15 @@ export function buildRemoteFolderFiles(payload: unknown): ManagerFile[] {
|
||||
usedNames.add(fileName);
|
||||
const localPath =
|
||||
asset.objectKey || asset.imageSrc || asset.assetId || fileName;
|
||||
const candidate: RemoteAssetCandidate = {
|
||||
assetId: asset.assetId || asset.objectKey || asset.imageSrc || fileName,
|
||||
name: baseName,
|
||||
objectKey: asset.objectKey || asset.imageSrc || fileName,
|
||||
assetKind: asset.assetKind ?? null,
|
||||
sizeBytes: asset.size ?? 0,
|
||||
previewUrl: asset.previewUrl,
|
||||
};
|
||||
if (!typeFilter(candidate)) continue;
|
||||
result.push({
|
||||
name: fileName,
|
||||
isDirectory: false,
|
||||
@@ -187,19 +236,17 @@ export function buildRemoteFolderFiles(payload: unknown): ManagerFile[] {
|
||||
return result;
|
||||
}
|
||||
|
||||
export function buildRootFiles(
|
||||
mode: AssetImporterMode = 'image',
|
||||
): ManagerFile[] {
|
||||
export function buildRootFiles(settings: AssetImporterSettings): ManagerFile[] {
|
||||
const roots: ManagerFile[] = [
|
||||
{
|
||||
name: mode === 'font' ? '本地项目字体' : '本地项目素材',
|
||||
name: settings.local.label,
|
||||
isDirectory: true,
|
||||
path: PROJECT_ASSETS_PATH,
|
||||
},
|
||||
];
|
||||
if (mode === 'image') {
|
||||
if (settings.remote) {
|
||||
roots.push({
|
||||
name: '云端素材库',
|
||||
name: settings.remote.label,
|
||||
isDirectory: true,
|
||||
path: REMOTE_ASSETS_PATH,
|
||||
});
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { AssetImporter } from '../../../components/AssetImporter';
|
||||
import {
|
||||
DESIGN_IMAGE_IMPORTER_SETTINGS,
|
||||
FONT_IMPORTER_SETTINGS,
|
||||
SPRITE_IMPORTER_SETTINGS,
|
||||
} from '../../../components/AssetImporter/settings';
|
||||
import { ThemedModal } from '../../../components/modal/ThemedModal';
|
||||
import type { UiEditorPageController } from '../useUiEditorPage';
|
||||
|
||||
@@ -8,6 +13,12 @@ export function EditorDialogs({
|
||||
controller: UiEditorPageController;
|
||||
}) {
|
||||
const impact = controller.pendingRemoval?.impact;
|
||||
const importerSettings =
|
||||
controller.importKind === 'font'
|
||||
? FONT_IMPORTER_SETTINGS
|
||||
: controller.importKind === 'design-image'
|
||||
? DESIGN_IMAGE_IMPORTER_SETTINGS
|
||||
: SPRITE_IMPORTER_SETTINGS;
|
||||
return (
|
||||
<>
|
||||
<AssetImporter
|
||||
@@ -18,22 +29,7 @@ export function EditorDialogs({
|
||||
void controller.importAssets(assets);
|
||||
controller.closeImporter();
|
||||
}}
|
||||
maxItems={
|
||||
controller.importKind === 'design-image'
|
||||
? 4
|
||||
: controller.importKind === 'font'
|
||||
? 64
|
||||
: 100
|
||||
}
|
||||
maxFileSize={
|
||||
controller.importKind === 'font' ? 8 * 1024 * 1024 : 20 * 1024 * 1024
|
||||
}
|
||||
acceptedMediaTypes={
|
||||
controller.importKind === 'font'
|
||||
? ['font/ttf', 'font/otf', 'font/woff', 'font/woff2']
|
||||
: ['image/png', 'image/jpeg', 'image/webp']
|
||||
}
|
||||
mode={controller.importKind === 'font' ? 'font' : 'image'}
|
||||
settings={importerSettings}
|
||||
/>
|
||||
|
||||
<ThemedModal
|
||||
|
||||
@@ -14,6 +14,12 @@ UI Editor 的 `State.font_assets` 正式承载项目字体面资源;`FontAsset
|
||||
|
||||
候选格式为 TTF、OTF、WOFF 和 WOFF2,但 Rust 安全解析是导入硬门;当前解析依赖不能完整解析的压缩 Web Font 必须拒绝,不能把浏览器可能加载当作验证成功。已登记字体字节只能经字体专用 Tauri 命令读取;命令重新核对 manifest 的 asset ID / 相对路径、普通文件、大小、字体结构与摘要。前端以 `FontAssetId` 派生私有 CSS family,创建 Blob URL 和 `FontFace`,加载成功后加入当前 `document.fonts`,资源变更或卸载时删除 FontFace 并回收 Blob URL。同名 family 不共享浏览器注册名。WebView 加载失败或字体缺少当前文本字形时不阻塞后续阶段,Inspector 显示非阻断提示并回退系统字体;悬空字体 ID 继续由 prerequisite 阻止。`BestFit` 保持现有取最大字号的近似,不在本次字体闭环中扩展为测量算法。
|
||||
|
||||
## 2026-08-18 UI Editor 图片 / 字体通用 AssetImporter 契约
|
||||
|
||||
图片和字体共用同一个 `AssetImporter`,组件不再接收 `mode` / `kind`,也不暴露候选项或结果项的 React 渲染回调。调用方只传入 image/font 两份 settings:标题、图标、本地 / 远端来源分支、来源专属 `typeFilter`、系统文件选择器规则、数量 / 大小 requirements 和导入 policy。`typeFilter` 只决定候选是否展示;requirements 在提交前限制数量、单文件大小和总大小;文件管理器、预览、加载态、错误态和导入队列由 importer 内部统一处理。
|
||||
|
||||
本地和远端候选项保持来源专属字段,不用可选字段猜测来源。项目树已有登记资源直接交付统一的 `ImportedAsset`;从电脑或云端导入时,importer 调用唯一 Tauri `import_ui_editor_assets` 命令。命令请求是按 `source` 分支的联合结构,分别使用 `localSourcePaths` / `localPolicy` / `localRequirements` 或 `remoteAssets` / `remotePolicy` / `remoteRequirements`。Rust 对目标目录、实际文件签名、媒体类型、扩展名、数量和大小做最终白名单校验;字体只允许本地源,图片允许本地与远端源。旧的三个 UI Editor 导入 command 不再注册为 Tauri command。
|
||||
|
||||
## 2026-08-12 Issue #163:子 Agent 澄清回执中转
|
||||
|
||||
正式用户对话 Agent 仍固定为 `project-supervisor`;委派专业 Agent 和隔离 child 不得直接调用 `user.input_request`。当 child 缺少会实质改变结果的用户事实时,child 以短小的 `AGC_NEEDS_USER_INPUT_V1` + 结构化 JSON 终态回执交付问题,Runtime 将其作为 `needs-user-input` delivery,而不是 `needs-repair`。
|
||||
|
||||
Reference in New Issue
Block a user