feat: 实现可扩展的字段编辑器系统与专业资产选择器 (#227)

This commit is contained in:
YHH
2025-11-19 14:54:03 +08:00
committed by GitHub
parent caed5428d5
commit ecfef727c8
18 changed files with 1330 additions and 11 deletions

View File

@@ -674,6 +674,29 @@ export function AssetBrowser({ projectPath, locale, onOpenScene }: AssetBrowserP
onClick={() => handleAssetClick(asset)}
onDoubleClick={() => handleAssetDoubleClick(asset)}
onContextMenu={(e) => handleContextMenu(e, asset)}
draggable={asset.type === 'file'}
onDragStart={(e) => {
if (asset.type === 'file') {
e.dataTransfer.effectAllowed = 'copy';
// 设置拖拽的数据
e.dataTransfer.setData('asset-path', asset.path);
e.dataTransfer.setData('asset-name', asset.name);
e.dataTransfer.setData('asset-extension', asset.extension || '');
e.dataTransfer.setData('text/plain', asset.path);
// 设置拖拽时的视觉效果
const dragImage = e.currentTarget.cloneNode(true) as HTMLElement;
dragImage.style.position = 'absolute';
dragImage.style.top = '-9999px';
dragImage.style.opacity = '0.8';
document.body.appendChild(dragImage);
e.dataTransfer.setDragImage(dragImage, 0, 0);
setTimeout(() => document.body.removeChild(dragImage), 0);
}
}}
style={{
cursor: asset.type === 'file' ? 'grab' : 'pointer'
}}
>
{getFileIcon(asset)}
<div className="asset-info">

View File

@@ -630,10 +630,29 @@ export const FileTree = forwardRef<FileTreeHandle, FileTreeProps>(({ rootPath, o
<div key={node.path}>
<div
className={`tree-node ${isSelected ? 'selected' : ''}`}
style={{ paddingLeft: `${indent}px` }}
style={{ paddingLeft: `${indent}px`, cursor: node.type === 'file' ? 'grab' : 'pointer' }}
onClick={() => !isRenaming && handleNodeClick(node)}
onDoubleClick={() => !isRenaming && handleNodeDoubleClick(node)}
onContextMenu={(e) => handleContextMenu(e, node)}
draggable={node.type === 'file' && !isRenaming}
onDragStart={(e) => {
if (node.type === 'file' && !isRenaming) {
e.dataTransfer.effectAllowed = 'copy';
// 设置拖拽的数据
e.dataTransfer.setData('asset-path', node.path);
e.dataTransfer.setData('asset-name', node.name);
const ext = node.name.includes('.') ? node.name.split('.').pop() : '';
e.dataTransfer.setData('asset-extension', ext || '');
e.dataTransfer.setData('text/plain', node.path);
// 添加视觉反馈
e.currentTarget.style.opacity = '0.5';
}
}}
onDragEnd={(e) => {
// 恢复透明度
e.currentTarget.style.opacity = '1';
}}
>
<span className="tree-arrow">
{node.type === 'folder' ? (

View File

@@ -0,0 +1,180 @@
/* 虚幻引擎风格的资产选择框 */
.asset-field {
margin-bottom: 6px;
}
.asset-field__label {
display: block;
font-size: 11px;
color: #999;
margin-bottom: 4px;
}
.asset-field__container {
display: flex;
align-items: center;
background: #1a1a1a;
border: 1px solid #333;
border-radius: 4px;
transition: all 0.15s ease;
position: relative;
}
.asset-field__container.hovered {
border-color: #444;
}
.asset-field__container.dragging {
border-color: #4ade80;
background: #1a2a1a;
box-shadow: 0 0 0 1px rgba(74, 222, 128, 0.2);
}
/* 资产图标区域 */
.asset-field__icon {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 26px;
background: #262626;
border-right: 1px solid #333;
color: #888;
}
.asset-field__container.hovered .asset-field__icon {
color: #aaa;
}
/* 资产输入区域 */
.asset-field__input {
flex: 1;
padding: 0 8px;
height: 26px;
display: flex;
align-items: center;
cursor: pointer;
user-select: none;
min-width: 0;
}
.asset-field__input:hover {
background: rgba(255, 255, 255, 0.02);
}
.asset-field__value {
font-size: 11px;
color: #e0e0e0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.asset-field__input.empty .asset-field__value {
color: #666;
font-style: italic;
}
/* 操作按钮组 */
.asset-field__actions {
display: flex;
align-items: center;
gap: 1px;
padding: 0 1px;
}
.asset-field__button {
display: flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
background: transparent;
border: none;
border-radius: 2px;
color: #888;
cursor: pointer;
transition: all 0.15s ease;
padding: 0;
}
.asset-field__button:hover {
background: #333;
color: #e0e0e0;
}
.asset-field__button:active {
background: #444;
}
/* 清除按钮特殊样式 */
.asset-field__button--clear:hover {
background: #4a2020;
color: #f87171;
}
/* 禁用状态 */
.asset-field__container[disabled] {
opacity: 0.6;
pointer-events: none;
}
/* 下拉菜单样式(如果需要) */
.asset-field__dropdown {
position: absolute;
top: 100%;
left: 0;
right: 0;
margin-top: 2px;
background: #1a1a1a;
border: 1px solid #333;
border-radius: 4px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
max-height: 200px;
overflow-y: auto;
z-index: 1000;
}
.asset-field__dropdown-item {
padding: 6px 8px;
font-size: 11px;
color: #e0e0e0;
cursor: pointer;
display: flex;
align-items: center;
gap: 8px;
}
.asset-field__dropdown-item:hover {
background: #262626;
}
.asset-field__dropdown-item-icon {
color: #888;
}
/* 动画效果 */
@keyframes highlight {
0% {
box-shadow: 0 0 0 0 rgba(74, 222, 128, 0.4);
}
100% {
box-shadow: 0 0 0 4px rgba(74, 222, 128, 0);
}
}
.asset-field__container.dragging {
animation: highlight 0.5s ease;
}
/* 响应式调整 */
@media (max-width: 768px) {
.asset-field__button {
width: 20px;
height: 20px;
}
.asset-field__icon {
width: 26px;
}
}

View File

@@ -0,0 +1,194 @@
import React, { useState, useRef, useCallback } from 'react';
import { FileText, Search, X, FolderOpen, ArrowRight, Package } from 'lucide-react';
import { open } from '@tauri-apps/plugin-dialog';
import './AssetField.css';
interface AssetFieldProps {
label: string;
value: string | null;
onChange: (value: string | null) => void;
fileExtension?: string; // 例如: '.btree'
placeholder?: string;
readonly?: boolean;
onNavigate?: (path: string) => void; // 导航到资产
}
export function AssetField({
label,
value,
onChange,
fileExtension = '',
placeholder = 'None',
readonly = false,
onNavigate
}: AssetFieldProps) {
const [isDragging, setIsDragging] = useState(false);
const [isHovered, setIsHovered] = useState(false);
const inputRef = useRef<HTMLDivElement>(null);
const handleDragEnter = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
if (!readonly) {
setIsDragging(true);
}
}, [readonly]);
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
}, []);
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
}, []);
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
if (readonly) return;
// 处理从文件系统拖入的文件
const files = Array.from(e.dataTransfer.files);
const file = files.find(f =>
!fileExtension || f.name.endsWith(fileExtension)
);
if (file) {
// Web File API 没有 path 属性,使用 name
onChange(file.name);
return;
}
// 处理从资产面板拖入的文件路径
const assetPath = e.dataTransfer.getData('asset-path');
if (assetPath && (!fileExtension || assetPath.endsWith(fileExtension))) {
onChange(assetPath);
return;
}
// 兼容纯文本拖拽
const text = e.dataTransfer.getData('text/plain');
if (text && (!fileExtension || text.endsWith(fileExtension))) {
onChange(text);
}
}, [onChange, fileExtension, readonly]);
const handleBrowse = useCallback(async () => {
if (readonly) return;
try {
const selected = await open({
multiple: false,
filters: fileExtension ? [{
name: `${fileExtension} Files`,
extensions: [fileExtension.replace('.', '')]
}] : []
});
if (selected) {
onChange(selected as string);
}
} catch (error) {
console.error('Failed to open file dialog:', error);
}
}, [onChange, fileExtension, readonly]);
const handleClear = useCallback(() => {
if (!readonly) {
onChange(null);
}
}, [onChange, readonly]);
const getFileName = (path: string) => {
const parts = path.split(/[\\/]/);
return parts[parts.length - 1];
};
return (
<div className="asset-field">
<label className="asset-field__label">{label}</label>
<div
className={`asset-field__container ${isDragging ? 'dragging' : ''} ${isHovered ? 'hovered' : ''}`}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
{/* 资产图标 */}
<div className="asset-field__icon">
{value ? (
fileExtension === '.btree' ?
<FileText size={14} /> :
<Package size={14} />
) : (
<Package size={14} style={{ opacity: 0.5 }} />
)}
</div>
{/* 资产选择框 */}
<div
ref={inputRef}
className={`asset-field__input ${value ? 'has-value' : 'empty'}`}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}
onClick={!readonly ? handleBrowse : undefined}
title={value || placeholder}
>
<span className="asset-field__value">
{value ? getFileName(value) : placeholder}
</span>
</div>
{/* 操作按钮组 */}
<div className="asset-field__actions">
{/* 浏览按钮 */}
{!readonly && (
<button
className="asset-field__button"
onClick={(e) => {
e.stopPropagation();
handleBrowse();
}}
title="浏览..."
>
<Search size={12} />
</button>
)}
{/* 导航按钮 */}
{value && onNavigate && (
<button
className="asset-field__button"
onClick={(e) => {
e.stopPropagation();
onNavigate(value);
}}
title="在资产浏览器中显示"
>
<ArrowRight size={12} />
</button>
)}
{/* 清除按钮 */}
{value && !readonly && (
<button
className="asset-field__button asset-field__button--clear"
onClick={(e) => {
e.stopPropagation();
handleClear();
}}
title="清除"
>
<X size={12} />
</button>
)}
</div>
</div>
</div>
);
}