Files
esengine/packages/editor-app/src/components/AssetPickerDialog.tsx
YHH 009f8af4e1 Feature/ecs behavior tree (#188)
* feat(behavior-tree): 完全 ECS 化的行为树系统

* feat(editor-app): 添加行为树可视化编辑器

* chore: 移除 Cocos Creator 扩展目录

* feat(editor-app): 行为树编辑器功能增强

* fix(editor-app): 修复 TypeScript 类型错误

* feat(editor-app): 使用 FlexLayout 重构面板系统并优化资产浏览器

* feat(editor-app): 改进编辑器UI样式并修复行为树执行顺序

* feat(behavior-tree,editor-app): 添加装饰器系统并优化编辑器性能

* feat(behavior-tree,editor-app): 添加属性绑定系统

* feat(editor-app,behavior-tree): 优化编辑器UI并改进行为树功能

* feat(editor-app,behavior-tree): 添加全局黑板系统并增强资产浏览器功能

* feat(behavior-tree,editor-app): 添加运行时资产导出系统

* feat(behavior-tree,editor-app): 添加SubTree系统和资产选择器

* feat(behavior-tree,editor-app): 优化系统架构并改进编辑器文件管理

* fix(behavior-tree,editor-app): 修复SubTree节点错误显示空节点警告

* fix(editor-app): 修复局部黑板类型定义文件扩展名错误
2025-10-27 09:29:11 +08:00

340 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useEffect } from 'react';
import { X, Folder, File, Search, ArrowLeft, Grid, List, FileCode } from 'lucide-react';
import { TauriAPI, DirectoryEntry } from '../api/tauri';
import '../styles/AssetPickerDialog.css';
interface AssetPickerDialogProps {
projectPath: string;
fileExtension: string;
onSelect: (assetId: string) => void;
onClose: () => void;
locale: string;
/** 资产基础路径(相对于项目根目录),用于计算 assetId */
assetBasePath?: string;
}
interface AssetItem {
name: string;
path: string;
isDir: boolean;
extension?: string;
size?: number;
modified?: number;
}
type ViewMode = 'list' | 'grid';
export function AssetPickerDialog({ projectPath, fileExtension, onSelect, onClose, locale, assetBasePath }: AssetPickerDialogProps) {
// 计算实际的资产目录路径
const actualAssetPath = assetBasePath
? `${projectPath}/${assetBasePath}`.replace(/\\/g, '/').replace(/\/+/g, '/')
: projectPath;
const [currentPath, setCurrentPath] = useState(actualAssetPath);
const [assets, setAssets] = useState<AssetItem[]>([]);
const [selectedPath, setSelectedPath] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [viewMode, setViewMode] = useState<ViewMode>('list');
const translations = {
en: {
title: 'Select Asset',
loading: 'Loading...',
empty: 'No assets found',
select: 'Select',
cancel: 'Cancel',
search: 'Search...',
back: 'Back',
listView: 'List View',
gridView: 'Grid View'
},
zh: {
title: '选择资产',
loading: '加载中...',
empty: '没有找到资产',
select: '选择',
cancel: '取消',
search: '搜索...',
back: '返回上级',
listView: '列表视图',
gridView: '网格视图'
}
};
const t = translations[locale as keyof typeof translations] || translations.en;
useEffect(() => {
loadAssets(currentPath);
}, [currentPath]);
const loadAssets = async (path: string) => {
setLoading(true);
try {
const entries = await TauriAPI.listDirectory(path);
const assetItems: AssetItem[] = entries
.map((entry: DirectoryEntry) => {
const extension = entry.is_dir ? undefined :
(entry.name.includes('.') ? entry.name.split('.').pop() : undefined);
return {
name: entry.name,
path: entry.path,
isDir: entry.is_dir,
extension,
size: entry.size,
modified: entry.modified
};
})
.filter(item => item.isDir || item.extension === fileExtension)
.sort((a, b) => {
if (a.isDir === b.isDir) return a.name.localeCompare(b.name);
return a.isDir ? -1 : 1;
});
setAssets(assetItems);
} catch (error) {
console.error('Failed to load assets:', error);
setAssets([]);
} finally {
setLoading(false);
}
};
// 过滤搜索结果
const filteredAssets = assets.filter(item =>
item.name.toLowerCase().includes(searchQuery.toLowerCase())
);
// 格式化文件大小
const formatFileSize = (bytes?: number): string => {
if (!bytes) return '';
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
};
// 格式化修改时间
const formatDate = (timestamp?: number): string => {
if (!timestamp) return '';
const date = new Date(timestamp * 1000);
return date.toLocaleDateString(locale === 'zh' ? 'zh-CN' : 'en-US', {
year: 'numeric',
month: 'short',
day: 'numeric'
});
};
// 返回上级目录
const handleGoBack = () => {
const parentPath = currentPath.split(/[/\\]/).slice(0, -1).join('/');
const minPath = actualAssetPath.replace(/[/\\]$/, '');
if (parentPath && parentPath !== minPath) {
setCurrentPath(parentPath);
} else if (currentPath !== actualAssetPath) {
setCurrentPath(actualAssetPath);
}
};
// 只能返回到资产基础目录,不能再往上
const canGoBack = currentPath !== actualAssetPath;
const handleItemClick = (item: AssetItem) => {
if (item.isDir) {
setCurrentPath(item.path);
} else {
setSelectedPath(item.path);
}
};
const handleItemDoubleClick = (item: AssetItem) => {
if (!item.isDir) {
const assetId = calculateAssetId(item.path);
onSelect(assetId);
}
};
const handleSelect = () => {
if (selectedPath) {
const assetId = calculateAssetId(selectedPath);
onSelect(assetId);
}
};
/**
* 计算资产ID
* 将绝对路径转换为相对于资产基础目录的assetId不含扩展名
*/
const calculateAssetId = (absolutePath: string): string => {
const normalized = absolutePath.replace(/\\/g, '/');
const baseNormalized = actualAssetPath.replace(/\\/g, '/');
// 获取相对于资产基础目录的路径
let relativePath = normalized;
if (normalized.startsWith(baseNormalized)) {
relativePath = normalized.substring(baseNormalized.length);
}
// 移除开头的斜杠
relativePath = relativePath.replace(/^\/+/, '');
// 移除文件扩展名
const assetId = relativePath.replace(new RegExp(`\\.${fileExtension}$`), '');
return assetId;
};
const getBreadcrumbs = () => {
const basePathNormalized = actualAssetPath.replace(/\\/g, '/');
const currentPathNormalized = currentPath.replace(/\\/g, '/');
const relative = currentPathNormalized.replace(basePathNormalized, '');
const parts = relative.split('/').filter(p => p);
// 根路径名称(显示"行为树"或"Assets"
const rootName = assetBasePath
? assetBasePath.split('/').pop() || 'Assets'
: 'Content';
const crumbs = [{ name: rootName, path: actualAssetPath }];
let accPath = actualAssetPath;
for (const part of parts) {
accPath = `${accPath}/${part}`;
crumbs.push({ name: part, path: accPath });
}
return crumbs;
};
const breadcrumbs = getBreadcrumbs();
return (
<div className="asset-picker-overlay" onClick={onClose}>
<div className="asset-picker-dialog" onClick={(e) => e.stopPropagation()}>
<div className="asset-picker-header">
<h3>{t.title}</h3>
<button className="asset-picker-close" onClick={onClose}>
<X size={18} />
</button>
</div>
<div className="asset-picker-toolbar">
<button
className="toolbar-button"
onClick={handleGoBack}
disabled={!canGoBack}
title={t.back}
>
<ArrowLeft size={16} />
</button>
<div className="asset-picker-breadcrumb">
{breadcrumbs.map((crumb, index) => (
<span key={crumb.path}>
<span
className="breadcrumb-item"
onClick={() => setCurrentPath(crumb.path)}
>
{crumb.name}
</span>
{index < breadcrumbs.length - 1 && <span className="breadcrumb-separator"> / </span>}
</span>
))}
</div>
<div className="view-mode-buttons">
<button
className={`toolbar-button ${viewMode === 'list' ? 'active' : ''}`}
onClick={() => setViewMode('list')}
title={t.listView}
>
<List size={16} />
</button>
<button
className={`toolbar-button ${viewMode === 'grid' ? 'active' : ''}`}
onClick={() => setViewMode('grid')}
title={t.gridView}
>
<Grid size={16} />
</button>
</div>
</div>
<div className="asset-picker-search">
<Search size={16} className="search-icon" />
<input
type="text"
placeholder={t.search}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="search-input"
/>
{searchQuery && (
<button
className="search-clear"
onClick={() => setSearchQuery('')}
>
<X size={14} />
</button>
)}
</div>
<div className="asset-picker-content">
{loading ? (
<div className="asset-picker-loading">{t.loading}</div>
) : filteredAssets.length === 0 ? (
<div className="asset-picker-empty">{t.empty}</div>
) : (
<div className={`asset-picker-list ${viewMode}`}>
{filteredAssets.map((item, index) => (
<div
key={index}
className={`asset-picker-item ${selectedPath === item.path ? 'selected' : ''}`}
onClick={() => handleItemClick(item)}
onDoubleClick={() => handleItemDoubleClick(item)}
>
<div className="asset-icon">
{item.isDir ? (
<Folder size={viewMode === 'grid' ? 32 : 18} style={{ color: '#ffa726' }} />
) : (
<FileCode size={viewMode === 'grid' ? 32 : 18} style={{ color: '#66bb6a' }} />
)}
</div>
<div className="asset-info">
<span className="asset-name">{item.name}</span>
{viewMode === 'list' && !item.isDir && (
<div className="asset-meta">
{item.size && <span className="asset-size">{formatFileSize(item.size)}</span>}
{item.modified && <span className="asset-date">{formatDate(item.modified)}</span>}
</div>
)}
</div>
</div>
))}
</div>
)}
</div>
<div className="asset-picker-footer">
<div className="footer-info">
{filteredAssets.length} {locale === 'zh' ? '项' : 'items'}
</div>
<div className="footer-buttons">
<button className="asset-picker-cancel" onClick={onClose}>
{t.cancel}
</button>
<button
className="asset-picker-select"
onClick={handleSelect}
disabled={!selectedPath}
>
{t.select}
</button>
</div>
</div>
</div>
</div>
);
}