* 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): 修复局部黑板类型定义文件扩展名错误
340 lines
13 KiB
TypeScript
340 lines
13 KiB
TypeScript
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>
|
||
);
|
||
}
|