feat(editor): 添加 ECS UI 系统和编辑器更新优化 (#238)
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 }
|
||||
]
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
223
packages/editor-app/src/components/StartupLogo.tsx
Normal file
223
packages/editor-app/src/components/StartupLogo.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
22
packages/editor-app/src/styles/StartupLogo.css
Normal file
22
packages/editor-app/src/styles/StartupLogo.css
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user