重构图片与字体通用导入器

抽取按来源配置的 AssetImporter settings、候选过滤和提交约束

统一本地与远端 UI Editor Tauri 导入请求并保留字体本地限制

更新 UI Editor 技术方案中的 importer 契约
This commit is contained in:
2026-08-18 16:45:05 +08:00
parent cc4b89c0f4
commit e423c410eb
7 changed files with 498 additions and 162 deletions
@@ -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`