feat(editor): 添加 ECS UI 系统和编辑器更新优化 (#238)

This commit is contained in:
YHH
2025-11-26 11:08:10 +08:00
committed by GitHub
parent 3fb6f919f8
commit 7b14fa2da4
62 changed files with 8745 additions and 235 deletions

View File

@@ -4,6 +4,7 @@ import { ProfilerPlugin } from '../../plugins/ProfilerPlugin';
import { EditorAppearancePlugin } from '../../plugins/EditorAppearancePlugin';
import { GizmoPlugin } from '../../plugins/GizmoPlugin';
import { TilemapEditorPlugin } from '@esengine/tilemap-editor';
import { UIEditorPlugin } from '@esengine/ui-editor';
export class PluginInstaller {
async installBuiltinPlugins(pluginManager: EditorPluginManager): Promise<void> {
@@ -12,7 +13,8 @@ export class PluginInstaller {
new SceneInspectorPlugin(),
new ProfilerPlugin(),
new EditorAppearancePlugin(),
new TilemapEditorPlugin()
new TilemapEditorPlugin(),
new UIEditorPlugin()
];
for (const plugin of plugins) {

View File

@@ -20,6 +20,7 @@ import {
PropertyRendererRegistry,
FieldEditorRegistry,
ComponentActionRegistry,
ComponentInspectorRegistry,
IDialogService,
IFileSystemService,
CompilerRegistry,
@@ -138,6 +139,7 @@ export class ServiceRegistry {
const fileActionRegistry = new FileActionRegistry();
const entityCreationRegistry = new EntityCreationRegistry();
const componentActionRegistry = new ComponentActionRegistry();
const componentInspectorRegistry = new ComponentInspectorRegistry();
Core.services.registerInstance(UIRegistry, uiRegistry);
Core.services.registerInstance(MessageHub, messageHub);
@@ -154,6 +156,7 @@ export class ServiceRegistry {
Core.services.registerInstance(FileActionRegistry, fileActionRegistry);
Core.services.registerInstance(EntityCreationRegistry, entityCreationRegistry);
Core.services.registerInstance(ComponentActionRegistry, componentActionRegistry);
Core.services.registerInstance(ComponentInspectorRegistry, componentInspectorRegistry);
const pluginManager = new EditorPluginManager();
pluginManager.initialize(coreInstance, Core.services);

View File

@@ -1,6 +1,6 @@
import { useState, useEffect } from 'react';
import { X, RefreshCw, Check, AlertCircle, Download } from 'lucide-react';
import { checkForUpdates } from '../utils/updater';
import { X, RefreshCw, Check, AlertCircle, Download, Loader2 } from 'lucide-react';
import { checkForUpdates, installUpdate } from '../utils/updater';
import { getVersion } from '@tauri-apps/api/app';
import { open } from '@tauri-apps/plugin-shell';
import '../styles/AboutDialog.css';
@@ -12,7 +12,8 @@ interface AboutDialogProps {
export function AboutDialog({ onClose, locale = 'en' }: AboutDialogProps) {
const [checking, setChecking] = useState(false);
const [updateStatus, setUpdateStatus] = useState<'idle' | 'checking' | 'available' | 'latest' | 'error'>('idle');
const [installing, setInstalling] = useState(false);
const [updateStatus, setUpdateStatus] = useState<'idle' | 'checking' | 'available' | 'latest' | 'error' | 'installing'>('idle');
const [version, setVersion] = useState<string>('1.0.0');
const [newVersion, setNewVersion] = useState<string>('');
@@ -40,7 +41,8 @@ export function AboutDialog({ onClose, locale = 'en' }: AboutDialogProps) {
updateAvailable: 'New version available',
latest: 'You are using the latest version',
error: 'Failed to check for updates',
download: 'Download Update',
download: 'Download & Install',
installing: 'Installing...',
close: 'Close',
copyright: '© 2025 ESEngine. All rights reserved.',
website: 'Website',
@@ -55,7 +57,8 @@ export function AboutDialog({ onClose, locale = 'en' }: AboutDialogProps) {
updateAvailable: '发现新版本',
latest: '您正在使用最新版本',
error: '检查更新失败',
download: '下载更新',
download: '下载并安装',
installing: '正在安装...',
close: '关闭',
copyright: '© 2025 ESEngine. 保留所有权利。',
website: '官网',
@@ -73,8 +76,8 @@ export function AboutDialog({ onClose, locale = 'en' }: AboutDialogProps) {
const currentVersion = await getVersion();
setVersion(currentVersion);
// 使用我们的 updater 工具检查更新
const result = await checkForUpdates(false);
// 使用我们的 updater 工具检查更新(仅检查,不自动安装)
const result = await checkForUpdates();
if (result.error) {
setUpdateStatus('error');
@@ -94,12 +97,32 @@ export function AboutDialog({ onClose, locale = 'en' }: AboutDialogProps) {
}
};
const handleInstallUpdate = async () => {
setInstalling(true);
setUpdateStatus('installing');
try {
const success = await installUpdate();
if (!success) {
setUpdateStatus('error');
setInstalling(false);
}
// 如果成功,应用会重启,不需要处理
} catch (error) {
console.error('Install update failed:', error);
setUpdateStatus('error');
setInstalling(false);
}
};
const getStatusIcon = () => {
switch (updateStatus) {
case 'checking':
return <RefreshCw size={16} className="animate-spin" />;
case 'available':
return <Download size={16} className="status-available" />;
case 'installing':
return <Loader2 size={16} className="animate-spin" />;
case 'latest':
return <Check size={16} className="status-latest" />;
case 'error':
@@ -115,6 +138,8 @@ export function AboutDialog({ onClose, locale = 'en' }: AboutDialogProps) {
return t('checking');
case 'available':
return `${t('updateAvailable')} (v${newVersion})`;
case 'installing':
return t('installing');
case 'latest':
return t('latest');
case 'error':
@@ -161,7 +186,7 @@ export function AboutDialog({ onClose, locale = 'en' }: AboutDialogProps) {
<button
className="update-btn"
onClick={handleCheckUpdate}
disabled={checking}
disabled={checking || installing}
>
{checking ? (
<>
@@ -182,6 +207,26 @@ export function AboutDialog({ onClose, locale = 'en' }: AboutDialogProps) {
<span>{getStatusText()}</span>
</div>
)}
{updateStatus === 'available' && (
<button
className="update-btn install-btn"
onClick={handleInstallUpdate}
disabled={installing}
>
{installing ? (
<>
<Loader2 size={16} className="animate-spin" />
<span>{t('installing')}</span>
</>
) : (
<>
<Download size={16} />
<span>{t('download')}</span>
</>
)}
</button>
)}
</div>
<div className="about-links">

View File

@@ -239,7 +239,6 @@ export function MenuBar({
help: [
{ label: t('documentation'), disabled: true },
{ separator: true },
{ label: t('checkForUpdates'), onClick: onOpenAbout },
{ label: t('about'), onClick: onOpenAbout }
]
};

View File

@@ -1,5 +1,5 @@
import { useState, useEffect, useRef } from 'react';
import { Component, Core } from '@esengine/ecs-framework';
import { Component, Core, getComponentInstanceTypeName } from '@esengine/ecs-framework';
import { PropertyMetadataService, PropertyMetadata, PropertyAction, MessageHub, IFileSystemService } from '@esengine/editor-core';
import type { IFileSystem } from '@esengine/editor-core';
import { ChevronRight, ChevronDown, ArrowRight, Lock } from 'lucide-react';
@@ -31,7 +31,7 @@ export function PropertyInspector({ component, entity, version, onChange, onActi
const propertyMetadataService = Core.services.resolve(PropertyMetadataService);
if (!propertyMetadataService) return;
const componentName = component.constructor.name;
const componentName = getComponentInstanceTypeName(component);
const controlled = new Map<string, string>();
// Check all components on this entity
@@ -39,7 +39,7 @@ export function PropertyInspector({ component, entity, version, onChange, onActi
if (otherComponent === component) continue;
const otherMetadata = propertyMetadataService.getEditableProperties(otherComponent);
const otherComponentName = otherComponent.constructor.name;
const otherComponentName = getComponentInstanceTypeName(otherComponent);
// Check if any property has controls declaration
for (const [, propMeta] of Object.entries(otherMetadata)) {
@@ -140,16 +140,26 @@ export function PropertyInspector({ component, entity, version, onChange, onActi
/>
);
case 'color':
case 'color': {
// Convert numeric color (0xRRGGBB) to hex string (#RRGGBB)
let colorValue = value ?? '#ffffff';
if (typeof colorValue === 'number') {
colorValue = '#' + colorValue.toString(16).padStart(6, '0');
}
return (
<ColorField
key={propertyName}
label={label}
value={value ?? '#ffffff'}
value={colorValue}
readOnly={metadata.readOnly}
onChange={(newValue) => handleChange(propertyName, newValue)}
onChange={(newValue) => {
// Convert hex string back to number for storage
const numericValue = parseInt(newValue.slice(1), 16);
handleChange(propertyName, numericValue);
}}
/>
);
}
case 'vector2':
return (
@@ -187,12 +197,14 @@ export function PropertyInspector({ component, entity, version, onChange, onActi
case 'asset': {
const controlledBy = getControlledBy(propertyName);
const assetMeta = metadata as { assetType?: string; extensions?: string[] };
return (
<AssetDropField
key={propertyName}
label={label}
value={value ?? ''}
fileExtension={metadata.fileExtension}
assetType={assetMeta.assetType}
extensions={assetMeta.extensions}
readOnly={metadata.readOnly || !!controlledBy}
controlledBy={controlledBy}
entityId={entity?.id?.toString()}
@@ -800,20 +812,31 @@ function Vector3Field({ label, value, readOnly, onChange }: Vector3FieldProps) {
);
}
type EnumOptionInput = string | { label: string; value: any };
interface EnumFieldProps {
label: string;
value: any;
options: Array<{ label: string; value: any }>;
options: EnumOptionInput[];
readOnly?: boolean;
onChange: (value: any) => void;
}
function normalizeEnumOption(opt: EnumOptionInput): { label: string; value: any } {
if (typeof opt === 'string') {
return { label: opt, value: opt };
}
return opt;
}
function EnumField({ label, value, options, readOnly, onChange }: EnumFieldProps) {
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
const selectedOption = options.find((opt) => opt.value === value);
const displayLabel = selectedOption?.label || (options.length === 0 ? 'No options' : '');
// Ensure options is always an array and normalize them
const safeOptions = Array.isArray(options) ? options.map(normalizeEnumOption) : [];
const selectedOption = safeOptions.find((opt) => opt.value === value);
const displayLabel = selectedOption?.label || (safeOptions.length === 0 ? 'No options' : '');
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
@@ -842,7 +865,7 @@ function EnumField({ label, value, options, readOnly, onChange }: EnumFieldProps
</button>
{isOpen && (
<div className="property-dropdown-menu">
{options.map((option, index) => (
{safeOptions.map((option, index) => (
<button
key={index}
className={`property-dropdown-item ${option.value === value ? 'selected' : ''}`}
@@ -865,18 +888,22 @@ function EnumField({ label, value, options, readOnly, onChange }: EnumFieldProps
interface AssetDropFieldProps {
label: string;
value: string;
fileExtension?: string;
assetType?: string;
extensions?: string[];
readOnly?: boolean;
controlledBy?: string;
entityId?: string;
onChange: (value: string) => void;
}
function AssetDropField({ label, value, fileExtension, readOnly, controlledBy, entityId, onChange }: AssetDropFieldProps) {
function AssetDropField({ label, value, assetType, extensions, readOnly, controlledBy, entityId, onChange }: AssetDropFieldProps) {
const [isDragging, setIsDragging] = useState(false);
const [showSaveDialog, setShowSaveDialog] = useState(false);
const canCreate = fileExtension && ['.tilemap.json', '.btree'].includes(fileExtension);
// Determine if this asset type can be created
const creatableExtensions = ['.tilemap.json', '.btree'];
const canCreate = extensions?.some(ext => creatableExtensions.includes(ext));
const fileExtension = extensions?.[0];
const handleCreate = () => {
setShowSaveDialog(true);

View File

@@ -1,8 +1,8 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useRef } from 'react';
import { Entity, Core } from '@esengine/ecs-framework';
import { EntityStoreService, MessageHub, SceneManagerService, CommandManager, EntityCreationRegistry, EntityCreationTemplate } from '@esengine/editor-core';
import { useLocale } from '../hooks/useLocale';
import { Box, Layers, Wifi, Search, Plus, Trash2, Monitor, Globe, Image, Camera, Film } from 'lucide-react';
import { Box, Layers, Wifi, Search, Plus, Trash2, Monitor, Globe, Image, Camera, Film, ChevronRight } from 'lucide-react';
import { ProfilerService, RemoteEntity } from '../services/ProfilerService';
import { confirm } from '@tauri-apps/plugin-dialog';
import { CreateEntityCommand, CreateSpriteEntityCommand, CreateAnimatedSpriteEntityCommand, CreateCameraEntityCommand, DeleteEntityCommand } from '../application/commands/entity';
@@ -532,58 +532,163 @@ export function SceneHierarchy({ entityStore, messageHub, commandManager, isProf
</div>
{contextMenu && !isShowingRemote && (
<div
className="context-menu"
style={{
position: 'fixed',
left: contextMenu.x,
top: contextMenu.y,
zIndex: 1000
<ContextMenuWithSubmenu
x={contextMenu.x}
y={contextMenu.y}
locale={locale}
entityId={contextMenu.entityId}
pluginTemplates={pluginTemplates}
onCreateEmpty={() => { handleCreateEntity(); closeContextMenu(); }}
onCreateSprite={() => { handleCreateSpriteEntity(); closeContextMenu(); }}
onCreateAnimatedSprite={() => { handleCreateAnimatedSpriteEntity(); closeContextMenu(); }}
onCreateCamera={() => { handleCreateCameraEntity(); closeContextMenu(); }}
onCreateFromTemplate={async (template) => {
await template.create(contextMenu.entityId ?? undefined);
closeContextMenu();
}}
>
<button onClick={() => { handleCreateEntity(); closeContextMenu(); }}>
<Plus size={12} />
<span>{locale === 'zh' ? '创建空实体' : 'Create Empty Entity'}</span>
</button>
<button onClick={() => { handleCreateSpriteEntity(); closeContextMenu(); }}>
<Image size={12} />
<span>{locale === 'zh' ? '创建 Sprite' : 'Create Sprite'}</span>
</button>
<button onClick={() => { handleCreateAnimatedSpriteEntity(); closeContextMenu(); }}>
<Film size={12} />
<span>{locale === 'zh' ? '创建动画 Sprite' : 'Create Animated Sprite'}</span>
</button>
<button onClick={() => { handleCreateCameraEntity(); closeContextMenu(); }}>
<Camera size={12} />
<span>{locale === 'zh' ? '创建相机' : 'Create Camera'}</span>
</button>
{pluginTemplates.length > 0 && (
<>
<div className="context-menu-divider" />
{pluginTemplates.map((template) => (
<button
key={template.id}
onClick={async () => {
await template.create(contextMenu.entityId ?? undefined);
closeContextMenu();
}}
>
{template.icon || <Plus size={12} />}
<span>{template.label}</span>
</button>
))}
</>
)}
{contextMenu.entityId && (
<>
<div className="context-menu-divider" />
<button onClick={() => { handleDeleteEntity(); closeContextMenu(); }}>
<Trash2 size={12} />
<span>{locale === 'zh' ? '删除实体' : 'Delete Entity'}</span>
</button>
</>
)}
</div>
onDelete={() => { handleDeleteEntity(); closeContextMenu(); }}
onClose={closeContextMenu}
/>
)}
</div>
);
}
interface ContextMenuWithSubmenuProps {
x: number;
y: number;
locale: string;
entityId: number | null;
pluginTemplates: EntityCreationTemplate[];
onCreateEmpty: () => void;
onCreateSprite: () => void;
onCreateAnimatedSprite: () => void;
onCreateCamera: () => void;
onCreateFromTemplate: (template: EntityCreationTemplate) => void;
onDelete: () => void;
onClose: () => void;
}
function ContextMenuWithSubmenu({
x, y, locale, entityId, pluginTemplates,
onCreateEmpty, onCreateSprite, onCreateAnimatedSprite, onCreateCamera,
onCreateFromTemplate, onDelete
}: ContextMenuWithSubmenuProps) {
const [activeSubmenu, setActiveSubmenu] = useState<string | null>(null);
const [submenuPosition, setSubmenuPosition] = useState<{ x: number; y: number }>({ x: 0, y: 0 });
const menuRef = useRef<HTMLDivElement>(null);
const categoryLabels: Record<string, { zh: string; en: string }> = {
'basic': { zh: '基础', en: 'Basic' },
'rendering': { zh: '渲染', en: 'Rendering' },
'ui': { zh: 'UI', en: 'UI' },
'physics': { zh: '物理', en: 'Physics' },
'audio': { zh: '音频', en: 'Audio' },
'other': { zh: '其他', en: 'Other' },
};
const getCategoryLabel = (category: string) => {
const labels = categoryLabels[category];
return labels ? (locale === 'zh' ? labels.zh : labels.en) : category;
};
const templatesByCategory = pluginTemplates.reduce((acc, template) => {
const cat = template.category || 'other';
if (!acc[cat]) acc[cat] = [];
acc[cat].push(template);
return acc;
}, {} as Record<string, EntityCreationTemplate[]>);
const hasPluginCategories = Object.keys(templatesByCategory).length > 0;
const handleSubmenuEnter = (category: string, e: React.MouseEvent) => {
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
setSubmenuPosition({ x: rect.right - 4, y: rect.top });
setActiveSubmenu(category);
};
return (
<div
ref={menuRef}
className="context-menu"
style={{ position: 'fixed', left: x, top: y, zIndex: 1000 }}
>
<button onClick={onCreateEmpty}>
<Plus size={12} />
<span>{locale === 'zh' ? '创建空实体' : 'Create Empty Entity'}</span>
</button>
<div className="context-menu-divider" />
<div
className="context-menu-item-with-submenu"
onMouseEnter={(e) => handleSubmenuEnter('rendering', e)}
onMouseLeave={() => setActiveSubmenu(null)}
>
<button>
<Image size={12} />
<span>{locale === 'zh' ? '2D 对象' : '2D Objects'}</span>
<ChevronRight size={12} className="submenu-arrow" />
</button>
{activeSubmenu === 'rendering' && (
<div
className="context-submenu"
style={{ left: submenuPosition.x, top: submenuPosition.y }}
onMouseEnter={() => setActiveSubmenu('rendering')}
>
<button onClick={onCreateSprite}>
<Image size={12} />
<span>Sprite</span>
</button>
<button onClick={onCreateAnimatedSprite}>
<Film size={12} />
<span>{locale === 'zh' ? '动画 Sprite' : 'Animated Sprite'}</span>
</button>
<button onClick={onCreateCamera}>
<Camera size={12} />
<span>{locale === 'zh' ? '相机' : 'Camera'}</span>
</button>
</div>
)}
</div>
{hasPluginCategories && Object.entries(templatesByCategory).map(([category, templates]) => (
<div
key={category}
className="context-menu-item-with-submenu"
onMouseEnter={(e) => handleSubmenuEnter(category, e)}
onMouseLeave={() => setActiveSubmenu(null)}
>
<button>
{templates[0]?.icon || <Plus size={12} />}
<span>{getCategoryLabel(category)}</span>
<ChevronRight size={12} className="submenu-arrow" />
</button>
{activeSubmenu === category && (
<div
className="context-submenu"
style={{ left: submenuPosition.x, top: submenuPosition.y }}
onMouseEnter={() => setActiveSubmenu(category)}
>
{templates.map((template) => (
<button key={template.id} onClick={() => onCreateFromTemplate(template)}>
{template.icon || <Plus size={12} />}
<span>{template.label}</span>
</button>
))}
</div>
)}
</div>
))}
{entityId && (
<>
<div className="context-menu-divider" />
<button onClick={onDelete} className="context-menu-danger">
<Trash2 size={12} />
<span>{locale === 'zh' ? '删除实体' : 'Delete Entity'}</span>
</button>
</>
)}
</div>
);

View File

@@ -0,0 +1,223 @@
import { useEffect, useRef, useState, useCallback } from 'react';
import '../styles/StartupLogo.css';
interface Particle {
x: number;
y: number;
targetX: number;
targetY: number;
size: number;
alpha: number;
color: string;
}
interface StartupLogoProps {
onAnimationComplete: () => void;
}
// 在组件外部创建粒子数据,确保只初始化一次
let particlesCache: Particle[] | null = null;
let cacheKey: string | null = null;
function createParticles(width: number, height: number, text: string, fontSize: number): Particle[] {
const key = `${width}-${height}-${fontSize}`;
if (particlesCache && cacheKey === key) {
// 重置粒子位置
for (const p of particlesCache) {
const angle = Math.random() * Math.PI * 2;
const distance = Math.random() * Math.max(width, height);
p.x = width / 2 + Math.cos(angle) * distance;
p.y = height / 2 + Math.sin(angle) * distance;
}
return particlesCache;
}
const tempCanvas = document.createElement('canvas');
const tempCtx = tempCanvas.getContext('2d');
if (!tempCtx) return [];
tempCtx.font = `bold ${fontSize}px "Segoe UI", Arial, sans-serif`;
const textMetrics = tempCtx.measureText(text);
const textWidth = textMetrics.width;
const textHeight = fontSize;
tempCanvas.width = textWidth + 40;
tempCanvas.height = textHeight + 40;
tempCtx.font = `bold ${fontSize}px "Segoe UI", Arial, sans-serif`;
tempCtx.textAlign = 'center';
tempCtx.textBaseline = 'middle';
tempCtx.fillStyle = '#ffffff';
tempCtx.fillText(text, tempCanvas.width / 2, tempCanvas.height / 2);
const imageData = tempCtx.getImageData(0, 0, tempCanvas.width, tempCanvas.height);
const pixels = imageData.data;
const particles: Particle[] = [];
const gap = 4;
const offsetX = (width - tempCanvas.width) / 2;
const offsetY = (height - tempCanvas.height) / 2;
const colors = ['#569CD6', '#4EC9B0', '#9CDCFE', '#C586C0', '#DCDCAA'];
for (let y = 0; y < tempCanvas.height; y += gap) {
for (let x = 0; x < tempCanvas.width; x += gap) {
const index = (y * tempCanvas.width + x) * 4;
const alpha = pixels[index + 3] ?? 0;
if (alpha > 128) {
const angle = Math.random() * Math.PI * 2;
const distance = Math.random() * Math.max(width, height);
particles.push({
x: width / 2 + Math.cos(angle) * distance,
y: height / 2 + Math.sin(angle) * distance,
targetX: offsetX + x,
targetY: offsetY + y,
size: Math.random() * 2 + 1.5,
alpha: Math.random() * 0.5 + 0.5,
color: colors[Math.floor(Math.random() * colors.length)] ?? '#569CD6'
});
}
}
}
particlesCache = particles;
cacheKey = key;
return particles;
}
export function StartupLogo({ onAnimationComplete }: StartupLogoProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const [fadeOut, setFadeOut] = useState(false);
const onCompleteRef = useRef(onAnimationComplete);
onCompleteRef.current = onAnimationComplete;
const startAnimation = useCallback(() => {
const canvas = canvasRef.current;
if (!canvas) return () => {};
const ctx = canvas.getContext('2d');
if (!ctx) return () => {};
const dpr = window.devicePixelRatio || 1;
const width = window.innerWidth;
const height = window.innerHeight;
canvas.width = width * dpr;
canvas.height = height * dpr;
canvas.style.width = `${width}px`;
canvas.style.height = `${height}px`;
ctx.scale(dpr, dpr);
const text = 'ESEngine';
const fontSize = Math.min(width / 6, 120);
const particles = createParticles(width, height, text, fontSize);
const startTime = performance.now();
const duration = 2000;
const glowDuration = 500; // 发光过渡时长
const holdDuration = 800;
let animationId: number | null = null;
let glowStartTime: number | null = null;
let isCancelled = false;
let timeoutId1: ReturnType<typeof setTimeout> | null = null;
let timeoutId2: ReturnType<typeof setTimeout> | null = null;
const easeOutQuart = (t: number) => 1 - Math.pow(1 - t, 4);
const easeOutCubic = (t: number) => 1 - Math.pow(1 - t, 3);
const animate = (currentTime: number) => {
if (isCancelled) return;
const elapsed = currentTime - startTime;
const progress = Math.min(elapsed / duration, 1);
const easedProgress = easeOutQuart(progress);
ctx.fillStyle = '#1e1e1e';
ctx.fillRect(0, 0, width, height);
// 计算发光进度
let glowProgress = 0;
if (progress >= 1) {
if (glowStartTime === null) {
glowStartTime = currentTime;
}
glowProgress = Math.min((currentTime - glowStartTime) / glowDuration, 1);
glowProgress = easeOutCubic(glowProgress);
}
for (const particle of particles) {
// 使用线性插值移动
const moveProgress = Math.min(easedProgress * 1.2, 1);
const currentX = particle.x + (particle.targetX - particle.x) * moveProgress;
const currentY = particle.y + (particle.targetY - particle.y) * moveProgress;
ctx.beginPath();
ctx.arc(currentX, currentY, particle.size, 0, Math.PI * 2);
ctx.fillStyle = particle.color;
ctx.globalAlpha = particle.alpha * (1 - glowProgress * 0.3); // 粒子逐渐变淡
ctx.fill();
}
ctx.globalAlpha = 1;
// 发光文字渐变显示
if (glowProgress > 0) {
ctx.save();
ctx.shadowColor = '#4EC9B0';
ctx.shadowBlur = 20 * glowProgress;
ctx.fillStyle = `rgba(255, 255, 255, ${glowProgress})`;
ctx.font = `bold ${fontSize}px "Segoe UI", Arial, sans-serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(text, width / 2, height / 2);
ctx.restore();
}
// 发光完成后开始淡出
if (glowProgress >= 1) {
if (!timeoutId1) {
timeoutId1 = setTimeout(() => {
if (isCancelled) return;
setFadeOut(true);
timeoutId2 = setTimeout(() => {
if (isCancelled) return;
onCompleteRef.current();
}, 500);
}, holdDuration);
}
}
if (!isCancelled && (!timeoutId1 || glowProgress < 1)) {
animationId = requestAnimationFrame(animate);
}
};
animationId = requestAnimationFrame(animate);
// 返回 cleanup 函数
return () => {
isCancelled = true;
if (animationId !== null) {
cancelAnimationFrame(animationId);
}
if (timeoutId1 !== null) {
clearTimeout(timeoutId1);
}
if (timeoutId2 !== null) {
clearTimeout(timeoutId2);
}
};
}, []);
useEffect(() => {
const cleanup = startAnimation();
return cleanup;
}, [startAnimation]);
return (
<div className={`startup-logo-container ${fadeOut ? 'fade-out' : ''}`}>
<canvas ref={canvasRef} className="startup-logo-canvas" />
</div>
);
}

View File

@@ -1,6 +1,8 @@
import { useState, useEffect, useRef } from 'react';
import { getVersion } from '@tauri-apps/api/app';
import { Globe, ChevronDown } from 'lucide-react';
import { Globe, ChevronDown, Download, X, Loader2 } from 'lucide-react';
import { checkForUpdatesOnStartup, installUpdate, type UpdateCheckResult } from '../utils/updater';
import { StartupLogo } from './StartupLogo';
import '../styles/StartupPage.css';
type Locale = 'en' | 'zh';
@@ -21,9 +23,13 @@ const LANGUAGES = [
];
export function StartupPage({ onOpenProject, onCreateProject, onOpenRecentProject, onProfilerMode, onLocaleChange, recentProjects = [], locale }: StartupPageProps) {
const [showLogo, setShowLogo] = useState(true);
const [hoveredProject, setHoveredProject] = useState<string | null>(null);
const [appVersion, setAppVersion] = useState<string>('');
const [showLangMenu, setShowLangMenu] = useState(false);
const [updateInfo, setUpdateInfo] = useState<UpdateCheckResult | null>(null);
const [showUpdateBanner, setShowUpdateBanner] = useState(false);
const [isInstalling, setIsInstalling] = useState(false);
const langMenuRef = useRef<HTMLDivElement>(null);
useEffect(() => {
@@ -40,6 +46,16 @@ export function StartupPage({ onOpenProject, onCreateProject, onOpenRecentProjec
getVersion().then(setAppVersion).catch(() => setAppVersion('1.0.0'));
}, []);
// 启动时检查更新
useEffect(() => {
checkForUpdatesOnStartup().then((result) => {
if (result.available) {
setUpdateInfo(result);
setShowUpdateBanner(true);
}
});
}, []);
const translations = {
en: {
title: 'ECS Framework Editor',
@@ -49,7 +65,11 @@ export function StartupPage({ onOpenProject, onCreateProject, onOpenRecentProjec
profilerMode: 'Profiler Mode',
recentProjects: 'Recent Projects',
noRecentProjects: 'No recent projects',
comingSoon: 'Coming Soon'
comingSoon: 'Coming Soon',
updateAvailable: 'New version available',
updateNow: 'Update Now',
installing: 'Installing...',
later: 'Later'
},
zh: {
title: 'ECS 框架编辑器',
@@ -59,15 +79,33 @@ export function StartupPage({ onOpenProject, onCreateProject, onOpenRecentProjec
profilerMode: '性能分析模式',
recentProjects: '最近的项目',
noRecentProjects: '没有最近的项目',
comingSoon: '即将推出'
comingSoon: '即将推出',
updateAvailable: '发现新版本',
updateNow: '立即更新',
installing: '正在安装...',
later: '稍后'
}
};
const handleInstallUpdate = async () => {
setIsInstalling(true);
const success = await installUpdate();
if (!success) {
setIsInstalling(false);
}
// 如果成功,应用会重启,不需要处理
};
const t = translations[locale as keyof typeof translations] || translations.en;
const versionText = locale === 'zh' ? `版本 ${appVersion}` : `Version ${appVersion}`;
const handleLogoComplete = () => {
setShowLogo(false);
};
return (
<div className="startup-page">
{showLogo && <StartupLogo onAnimationComplete={handleLogoComplete} />}
<div className="startup-header">
<h1 className="startup-title">{t.title}</h1>
<p className="startup-subtitle">{t.subtitle}</p>
@@ -126,6 +164,40 @@ export function StartupPage({ onOpenProject, onCreateProject, onOpenRecentProjec
</div>
</div>
{/* 更新提示条 */}
{showUpdateBanner && updateInfo?.available && (
<div className="startup-update-banner">
<div className="update-banner-content">
<Download size={16} />
<span className="update-banner-text">
{t.updateAvailable}: v{updateInfo.version}
</span>
<button
className="update-banner-btn primary"
onClick={handleInstallUpdate}
disabled={isInstalling}
>
{isInstalling ? (
<>
<Loader2 size={14} className="animate-spin" />
{t.installing}
</>
) : (
t.updateNow
)}
</button>
<button
className="update-banner-close"
onClick={() => setShowUpdateBanner(false)}
disabled={isInstalling}
title={t.later}
>
<X size={14} />
</button>
</div>
</div>
)}
<div className="startup-footer">
<span className="startup-version">{versionText}</span>
{onLocaleChange && (

View File

@@ -6,6 +6,7 @@ import { EngineService } from '../services/EngineService';
import { Core, Entity, SceneSerializer } from '@esengine/ecs-framework';
import { MessageHub } from '@esengine/editor-core';
import { TransformComponent, CameraComponent } from '@esengine/ecs-components';
import { UITransformComponent } from '@esengine/ui';
import { TauriAPI } from '../api/tauri';
import { open } from '@tauri-apps/plugin-shell';
import { RuntimeResolver } from '../services/RuntimeResolver';
@@ -258,9 +259,6 @@ export function Viewport({ locale = 'en', messageHub }: ViewportProps) {
const entity = selectedEntityRef.current;
if (!entity) return;
const transform = entity.getComponent(TransformComponent);
if (!transform) return;
const worldStart = screenToWorld(lastMousePosRef.current.x, lastMousePosRef.current.y);
const worldEnd = screenToWorld(e.clientX, e.clientY);
const worldDelta = {
@@ -269,37 +267,71 @@ export function Viewport({ locale = 'en', messageHub }: ViewportProps) {
};
const mode = transformModeRef.current;
if (mode === 'move') {
// Update position
transform.position.x += worldDelta.x;
transform.position.y += worldDelta.y;
} else if (mode === 'rotate') {
// Horizontal mouse movement controls rotation (in radians)
const rotationSpeed = 0.01; // radians per pixel
transform.rotation.z += deltaX * rotationSpeed;
} else if (mode === 'scale') {
// Scale based on distance from center
const centerX = transform.position.x;
const centerY = transform.position.y;
const startDist = Math.sqrt((worldStart.x - centerX) ** 2 + (worldStart.y - centerY) ** 2);
const endDist = Math.sqrt((worldEnd.x - centerX) ** 2 + (worldEnd.y - centerY) ** 2);
if (startDist > 0) {
const scaleFactor = endDist / startDist;
transform.scale.x *= scaleFactor;
transform.scale.y *= scaleFactor;
// Try standard TransformComponent first
const transform = entity.getComponent(TransformComponent);
if (transform) {
if (mode === 'move') {
transform.position.x += worldDelta.x;
transform.position.y += worldDelta.y;
} else if (mode === 'rotate') {
const rotationSpeed = 0.01;
transform.rotation.z += deltaX * rotationSpeed;
} else if (mode === 'scale') {
const centerX = transform.position.x;
const centerY = transform.position.y;
const startDist = Math.sqrt((worldStart.x - centerX) ** 2 + (worldStart.y - centerY) ** 2);
const endDist = Math.sqrt((worldEnd.x - centerX) ** 2 + (worldEnd.y - centerY) ** 2);
if (startDist > 0) {
const scaleFactor = endDist / startDist;
transform.scale.x *= scaleFactor;
transform.scale.y *= scaleFactor;
}
}
if (messageHubRef.current) {
const propertyName = mode === 'move' ? 'position' : mode === 'rotate' ? 'rotation' : 'scale';
messageHubRef.current.publish('component:property:changed', {
entity,
component: transform,
propertyName,
value: transform[propertyName]
});
}
}
// Notify system of transform change for real-time update
// 通知系统变换更改,用于实时更新
if (messageHubRef.current) {
const propertyName = mode === 'move' ? 'position' : mode === 'rotate' ? 'rotation' : 'scale';
messageHubRef.current.publish('component:property:changed', {
entity,
component: transform,
propertyName,
value: transform[propertyName]
});
// Try UITransformComponent
const uiTransform = entity.getComponent(UITransformComponent);
if (uiTransform) {
if (mode === 'move') {
uiTransform.x += worldDelta.x;
uiTransform.y += worldDelta.y;
} else if (mode === 'rotate') {
const rotationSpeed = 0.01;
uiTransform.rotation += deltaX * rotationSpeed;
} else if (mode === 'scale') {
const width = uiTransform.width * uiTransform.scaleX;
const height = uiTransform.height * uiTransform.scaleY;
const centerX = uiTransform.x + width * uiTransform.pivotX;
const centerY = uiTransform.y + height * uiTransform.pivotY;
const startDist = Math.sqrt((worldStart.x - centerX) ** 2 + (worldStart.y - centerY) ** 2);
const endDist = Math.sqrt((worldEnd.x - centerX) ** 2 + (worldEnd.y - centerY) ** 2);
if (startDist > 0) {
const scaleFactor = endDist / startDist;
uiTransform.scaleX *= scaleFactor;
uiTransform.scaleY *= scaleFactor;
}
}
if (messageHubRef.current) {
const propertyName = mode === 'move' ? 'x' : mode === 'rotate' ? 'rotation' : 'scaleX';
messageHubRef.current.publish('component:property:changed', {
entity,
component: uiTransform,
propertyName,
value: uiTransform[propertyName]
});
}
}
} else {
return;

View File

@@ -1,13 +1,21 @@
import { useState } from 'react';
import { Settings, ChevronDown, ChevronRight, X, Plus, Box } from 'lucide-react';
import { useState, useRef, useEffect, useMemo } from 'react';
import { Settings, ChevronDown, ChevronRight, X, Plus, Box, Search } from 'lucide-react';
import { Entity, Component, Core, getComponentDependencies, getComponentTypeName, getComponentInstanceTypeName } from '@esengine/ecs-framework';
import { MessageHub, CommandManager, ComponentRegistry, ComponentActionRegistry } from '@esengine/editor-core';
import { MessageHub, CommandManager, ComponentRegistry, ComponentActionRegistry, ComponentInspectorRegistry } from '@esengine/editor-core';
import { PropertyInspector } from '../../PropertyInspector';
import { NotificationService } from '../../../services/NotificationService';
import { RemoveComponentCommand, UpdateComponentCommand, AddComponentCommand } from '../../../application/commands/component';
import '../../../styles/EntityInspector.css';
import * as LucideIcons from 'lucide-react';
interface ComponentInfo {
name: string;
type?: new () => Component;
category?: string;
description?: string;
icon?: string;
}
interface EntityInspectorProps {
entity: Entity;
messageHub: MessageHub;
@@ -19,10 +27,66 @@ export function EntityInspector({ entity, messageHub, commandManager, componentV
const [expandedComponents, setExpandedComponents] = useState<Set<number>>(new Set());
const [showComponentMenu, setShowComponentMenu] = useState(false);
const [localVersion, setLocalVersion] = useState(0);
const [dropdownPosition, setDropdownPosition] = useState<{ top: number; right: number } | null>(null);
const [searchQuery, setSearchQuery] = useState('');
const [collapsedCategories, setCollapsedCategories] = useState<Set<string>>(new Set());
const addButtonRef = useRef<HTMLButtonElement>(null);
const searchInputRef = useRef<HTMLInputElement>(null);
const componentRegistry = Core.services.resolve(ComponentRegistry);
const componentActionRegistry = Core.services.resolve(ComponentActionRegistry);
const availableComponents = componentRegistry?.getAllComponents() || [];
const componentInspectorRegistry = Core.services.resolve(ComponentInspectorRegistry);
const availableComponents = (componentRegistry?.getAllComponents() || []) as ComponentInfo[];
useEffect(() => {
if (showComponentMenu && addButtonRef.current) {
const rect = addButtonRef.current.getBoundingClientRect();
setDropdownPosition({
top: rect.bottom + 4,
right: window.innerWidth - rect.right
});
setSearchQuery('');
setTimeout(() => searchInputRef.current?.focus(), 50);
}
}, [showComponentMenu]);
const categoryLabels: Record<string, string> = {
'components.category.core': '核心',
'components.category.rendering': '渲染',
'components.category.physics': '物理',
'components.category.audio': '音频',
'components.category.ui': 'UI',
'components.category.ui.core': 'UI 核心',
'components.category.ui.widgets': 'UI 控件',
'components.category.other': '其他',
};
const filteredAndGroupedComponents = useMemo(() => {
const query = searchQuery.toLowerCase().trim();
const filtered = query
? availableComponents.filter(c =>
c.name.toLowerCase().includes(query) ||
(c.description && c.description.toLowerCase().includes(query))
)
: availableComponents;
const grouped = new Map<string, ComponentInfo[]>();
filtered.forEach((info) => {
const cat = info.category || 'components.category.other';
if (!grouped.has(cat)) grouped.set(cat, []);
grouped.get(cat)!.push(info);
});
return grouped;
}, [availableComponents, searchQuery]);
const toggleCategory = (category: string) => {
setCollapsedCategories(prev => {
const next = new Set(prev);
if (next.has(category)) next.delete(category);
else next.add(category);
return next;
});
};
const toggleComponentExpanded = (index: number) => {
setExpandedComponents((prev) => {
@@ -146,49 +210,65 @@ export function EntityInspector({ entity, messageHub, commandManager, componentV
<span></span>
<div className="component-menu-container">
<button
ref={addButtonRef}
className="add-component-trigger"
onClick={() => setShowComponentMenu(!showComponentMenu)}
>
<Plus size={12} />
</button>
{showComponentMenu && (
{showComponentMenu && dropdownPosition && (
<>
<div className="component-dropdown-overlay" onClick={() => setShowComponentMenu(false)} />
<div className="component-dropdown">
<div className="component-dropdown-header"></div>
{availableComponents.length === 0 ? (
<div
className="component-dropdown"
style={{ top: dropdownPosition.top, right: dropdownPosition.right }}
>
<div className="component-dropdown-search">
<Search size={14} />
<input
ref={searchInputRef}
type="text"
placeholder="搜索组件..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
{filteredAndGroupedComponents.size === 0 ? (
<div className="component-dropdown-empty">
{searchQuery ? '未找到匹配的组件' : '没有可用组件'}
</div>
) : (
<div className="component-dropdown-list">
{/* 按分类分组显示 */}
{(() => {
const categories = new Map<string, typeof availableComponents>();
availableComponents.forEach((info) => {
const cat = info.category || 'components.category.other';
if (!categories.has(cat)) {
categories.set(cat, []);
}
categories.get(cat)!.push(info);
});
return Array.from(categories.entries()).map(([category, components]) => (
{Array.from(filteredAndGroupedComponents.entries()).map(([category, components]) => {
const isCollapsed = collapsedCategories.has(category) && !searchQuery;
const label = categoryLabels[category] || category;
return (
<div key={category} className="component-category-group">
<div className="component-category-label">{category}</div>
{components.map((info) => (
<button
key={info.name}
className="component-dropdown-item"
onClick={() => info.type && handleAddComponent(info.type)}
>
<span className="component-dropdown-item-name">{info.name}</span>
</button>
))}
<button
className="component-category-header"
onClick={() => toggleCategory(category)}
>
{isCollapsed ? <ChevronRight size={12} /> : <ChevronDown size={12} />}
<span>{label}</span>
<span className="component-category-count">{components.length}</span>
</button>
{!isCollapsed && components.map((info) => {
const IconComp = info.icon && (LucideIcons as any)[info.icon];
return (
<button
key={info.name}
className="component-dropdown-item"
onClick={() => info.type && handleAddComponent(info.type)}
>
{IconComp ? <IconComp size={14} /> : <Box size={14} />}
<span className="component-dropdown-item-name">{info.name}</span>
</button>
);
})}
</div>
));
})()}
);
})}
</div>
)}
</div>
@@ -244,15 +324,25 @@ export function EntityInspector({ entity, messageHub, commandManager, componentV
{isExpanded && (
<div className="component-item-content">
<PropertyInspector
component={component}
entity={entity}
version={componentVersion + localVersion}
onChange={(propName: string, value: unknown) =>
handlePropertyChange(component, propName, value)
}
onAction={handlePropertyAction}
/>
{componentInspectorRegistry?.hasInspector(component)
? componentInspectorRegistry.render({
component,
entity,
version: componentVersion + localVersion,
onChange: (propName: string, value: unknown) =>
handlePropertyChange(component, propName, value),
onAction: handlePropertyAction
})
: <PropertyInspector
component={component}
entity={entity}
version={componentVersion + localVersion}
onChange={(propName: string, value: unknown) =>
handlePropertyChange(component, propName, value)
}
onAction={handlePropertyAction}
/>
}
{/* Dynamic component actions from plugins */}
{componentActionRegistry?.getActionsForComponent(componentName).map((action) => (
<button

View File

@@ -8,6 +8,7 @@ import { GizmoRegistry } from '@esengine/editor-core';
import { Core, Scene, Entity, SceneSerializer } from '@esengine/ecs-framework';
import { TransformComponent, SpriteComponent, SpriteAnimatorSystem, SpriteAnimatorComponent } from '@esengine/ecs-components';
import { TilemapComponent, TilemapRenderingSystem } from '@esengine/tilemap';
import { UIRenderDataProvider } from '@esengine/ui';
import { EntityStoreService, MessageHub, SceneManagerService, ProjectService } from '@esengine/editor-core';
import * as esEngine from '@esengine/engine';
import {
@@ -33,6 +34,7 @@ export class EngineService {
private renderSystem: EngineRenderSystem | null = null;
private animatorSystem: SpriteAnimatorSystem | null = null;
private tilemapSystem: TilemapRenderingSystem | null = null;
private uiRenderProvider: UIRenderDataProvider | null = null;
private initialized = false;
private running = false;
private animationFrameId: number | null = null;
@@ -121,6 +123,17 @@ export class EngineService {
// 将瓦片地图系统注册为渲染数据提供者
this.renderSystem.addRenderDataProvider(this.tilemapSystem);
// Register UI render data provider
// 注册 UI 渲染数据提供者
this.uiRenderProvider = new UIRenderDataProvider();
this.renderSystem.addRenderDataProvider(this.uiRenderProvider);
// Set up texture callback for UI text rendering
// 设置 UI 文本渲染的纹理回调
this.uiRenderProvider.setTextureCallback((id: number, dataUrl: string) => {
this.bridge!.loadTexture(id, dataUrl);
});
// Inject GizmoRegistry into render system
// 将 GizmoRegistry 注入渲染系统
this.renderSystem.setGizmoRegistry(
@@ -702,6 +715,13 @@ export class EngineService {
this.tilemapSystem.clearCache();
}
// Clear UI text cache before restoring
// 恢复前清除 UI 文本缓存
if (this.uiRenderProvider) {
console.log('[EngineService] Clearing UI text cache before restore');
this.uiRenderProvider.clearTextCache();
}
// Use SceneSerializer from core library
console.log('[EngineService] Deserializing scene snapshot');
SceneSerializer.deserialize(this.scene, this.sceneSnapshot, {

View File

@@ -150,6 +150,17 @@
cursor: not-allowed;
}
.update-btn.install-btn {
background: #22c55e;
border-color: #22c55e;
color: white;
}
.update-btn.install-btn:hover:not(:disabled) {
background: #16a34a;
border-color: #16a34a;
}
.update-status {
display: flex;
align-items: center;
@@ -179,6 +190,11 @@
color: #CE9178;
}
.update-status.status-installing {
background: rgba(34, 197, 94, 0.1);
color: #22c55e;
}
.update-status .status-available {
color: #4EC9B0;
}

View File

@@ -515,9 +515,7 @@
}
.component-dropdown {
position: absolute;
top: calc(100% + 4px);
right: 0;
position: fixed;
min-width: 220px;
max-height: 320px;
background: var(--color-bg-elevated);
@@ -540,17 +538,33 @@
}
}
.component-dropdown-header {
padding: 10px 12px;
font-size: 11px;
font-weight: 600;
color: var(--color-text-secondary);
text-transform: uppercase;
letter-spacing: 0.05em;
.component-dropdown-search {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border-bottom: 1px solid var(--color-border-default);
background: var(--color-bg-base);
}
.component-dropdown-search svg {
color: var(--color-text-tertiary);
flex-shrink: 0;
}
.component-dropdown-search input {
flex: 1;
background: transparent;
border: none;
outline: none;
color: var(--color-text-primary);
font-size: 12px;
}
.component-dropdown-search input::placeholder {
color: var(--color-text-tertiary);
}
.component-dropdown-empty {
padding: 16px 12px;
text-align: center;
@@ -589,6 +603,41 @@
margin-bottom: 0;
}
.component-category-header {
display: flex;
align-items: center;
gap: 6px;
width: 100%;
padding: 8px 12px;
background: var(--color-bg-base);
border: none;
color: var(--color-text-secondary);
font-size: 11px;
font-weight: 600;
cursor: pointer;
text-align: left;
transition: all var(--transition-fast);
}
.component-category-header:hover {
background: var(--color-bg-hover);
color: var(--color-text-primary);
}
.component-category-header svg {
color: var(--color-text-tertiary);
}
.component-category-count {
margin-left: auto;
padding: 1px 6px;
background: var(--color-bg-inset);
border-radius: 10px;
font-size: 10px;
font-weight: 500;
color: var(--color-text-tertiary);
}
.component-category-label {
padding: 6px 12px 4px;
font-size: 10px;
@@ -600,10 +649,11 @@
.component-dropdown-item {
display: flex;
flex-direction: column;
align-items: flex-start;
flex-direction: row;
align-items: center;
gap: 8px;
width: 100%;
padding: 8px 12px;
padding: 6px 12px 6px 28px;
background: transparent;
border: none;
color: var(--color-text-primary);
@@ -612,6 +662,11 @@
transition: all 0.1s ease;
}
.component-dropdown-item svg {
color: var(--color-text-tertiary);
flex-shrink: 0;
}
.component-dropdown-item:hover {
background: var(--color-bg-hover);
}
@@ -637,7 +692,7 @@
background: #2a2a2f;
border: none;
border-radius: 0;
overflow: hidden;
overflow: visible;
transition: none;
border-left: 3px solid #4a4a50;
}
@@ -729,7 +784,7 @@
padding: 6px 8px 8px 8px;
border-top: 1px solid rgba(255, 255, 255, 0.05);
background: #1e1e23;
overflow: hidden;
overflow: visible;
min-width: 0;
}

View File

@@ -16,7 +16,7 @@
border-bottom: 1px solid var(--color-border-subtle);
gap: 8px;
transition: background-color var(--transition-fast);
overflow: hidden;
overflow: visible;
}
.property-field:hover {

View File

@@ -466,3 +466,69 @@
background-color: var(--color-border-default);
margin: var(--spacing-xs) 0;
}
.context-menu-item-with-submenu {
position: relative;
}
.context-menu-item-with-submenu > button {
display: flex;
align-items: center;
gap: var(--spacing-sm);
width: 100%;
padding: var(--spacing-sm) var(--spacing-md);
background: transparent;
border: none;
border-radius: var(--radius-sm);
color: var(--color-text-primary);
font-size: var(--font-size-sm);
cursor: pointer;
text-align: left;
}
.context-menu-item-with-submenu > button:hover {
background-color: var(--color-bg-hover);
}
.context-menu-item-with-submenu .submenu-arrow {
margin-left: auto;
color: var(--color-text-tertiary);
}
.context-submenu {
position: fixed;
background-color: var(--color-bg-elevated);
border: 1px solid var(--color-border-default);
border-radius: var(--radius-md);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
padding: var(--spacing-xs);
min-width: 150px;
z-index: 1001;
}
.context-submenu button {
display: flex;
align-items: center;
gap: var(--spacing-sm);
width: 100%;
padding: var(--spacing-sm) var(--spacing-md);
background: transparent;
border: none;
border-radius: var(--radius-sm);
color: var(--color-text-primary);
font-size: var(--font-size-sm);
cursor: pointer;
text-align: left;
}
.context-submenu button:hover {
background-color: var(--color-bg-hover);
}
.context-menu-danger {
color: var(--color-error) !important;
}
.context-menu-danger:hover {
background-color: rgba(244, 135, 113, 0.1) !important;
}

View File

@@ -0,0 +1,22 @@
.startup-logo-container {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100vh;
background-color: #1e1e1e;
z-index: 10000;
display: flex;
align-items: center;
justify-content: center;
transition: opacity 0.5s ease-out;
}
.startup-logo-container.fade-out {
opacity: 0;
pointer-events: none;
}
.startup-logo-canvas {
display: block;
}

View File

@@ -250,3 +250,99 @@
.startup-locale-item.active:hover {
color: #fff;
}
/* 更新提示条样式 */
.startup-update-banner {
position: fixed;
bottom: 60px;
left: 50%;
transform: translateX(-50%);
z-index: 1000;
}
.update-banner-content {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 16px;
background: linear-gradient(135deg, #1e5a2f 0%, #1a4a28 100%);
border: 1px solid #2e8b4a;
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
color: #ffffff;
font-size: 13px;
animation: slideUp 0.3s ease-out;
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.update-banner-content svg {
color: #4ade80;
flex-shrink: 0;
}
.update-banner-text {
white-space: nowrap;
}
.update-banner-btn {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 14px;
border: none;
border-radius: 4px;
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s;
white-space: nowrap;
}
.update-banner-btn.primary {
background: #22c55e;
color: #ffffff;
}
.update-banner-btn.primary:hover:not(:disabled) {
background: #16a34a;
}
.update-banner-btn:disabled {
opacity: 0.7;
cursor: not-allowed;
}
.update-banner-close {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
padding: 0;
background: transparent;
border: none;
border-radius: 4px;
color: rgba(255, 255, 255, 0.6);
cursor: pointer;
transition: all 0.15s;
}
.update-banner-close:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.1);
color: #ffffff;
}
.update-banner-close:disabled {
opacity: 0.5;
cursor: not-allowed;
}

View File

@@ -1,4 +1,4 @@
import { check } from '@tauri-apps/plugin-updater';
import { check, Update } from '@tauri-apps/plugin-updater';
export interface UpdateCheckResult {
available: boolean;
@@ -7,33 +7,33 @@ export interface UpdateCheckResult {
error?: string;
}
// 全局存储更新对象,以便后续安装
let pendingUpdate: Update | null = null;
/**
* 检查应用更新
* 检查应用更新(仅检查,不安装)
*
* 自动检查 GitHub Releases 是否有新版本
* 如果有更新,提示用户并可选择安装
* 返回检查结果,由调用者决定是否安装
*/
export async function checkForUpdates(silent: boolean = false): Promise<UpdateCheckResult> {
export async function checkForUpdates(): Promise<UpdateCheckResult> {
try {
const update = await check();
if (update?.available) {
if (!silent) {
// Tauri 会自动显示更新对话框(因为配置了 dialog: true
// 用户点击确认后会自动下载并安装,安装完成后会自动重启
await update.downloadAndInstall();
}
pendingUpdate = update;
return {
available: true,
version: update.version,
currentVersion: update.currentVersion
};
} else {
pendingUpdate = null;
return { available: false };
}
} catch (error) {
console.error('检查更新失败:', error);
pendingUpdate = null;
return {
available: false,
error: error instanceof Error ? error.message : '检查更新失败'
@@ -42,11 +42,34 @@ export async function checkForUpdates(silent: boolean = false): Promise<UpdateCh
}
/**
* 应用启动时静默检查更新
* 安装待处理的更新
* 需要先调用 checkForUpdates 检测到更新
*/
export async function checkForUpdatesOnStartup(): Promise<void> {
// 延迟 3 秒后检查,避免影响启动速度
setTimeout(() => {
checkForUpdates(true);
}, 3000);
export async function installUpdate(): Promise<boolean> {
if (!pendingUpdate) {
console.error('没有待安装的更新');
return false;
}
try {
await pendingUpdate.downloadAndInstall();
return true;
} catch (error) {
console.error('安装更新失败:', error);
return false;
}
}
/**
* 应用启动时静默检查更新
* 返回 Promise 以便调用者可以获取结果
*/
export async function checkForUpdatesOnStartup(): Promise<UpdateCheckResult> {
// 延迟 2 秒后检查,避免影响启动速度
return new Promise((resolve) => {
setTimeout(async () => {
const result = await checkForUpdates();
resolve(result);
}, 2000);
});
}