2025-11-23 21:45:10 +08:00
|
|
|
import { useState, useEffect, useRef } from 'react';
|
2025-11-23 14:49:37 +08:00
|
|
|
import { getVersion } from '@tauri-apps/api/app';
|
2025-12-04 14:04:39 +08:00
|
|
|
import { Globe, ChevronDown, Download, X, Loader2, Trash2 } from 'lucide-react';
|
2025-11-26 11:08:10 +08:00
|
|
|
import { checkForUpdatesOnStartup, installUpdate, type UpdateCheckResult } from '../utils/updater';
|
|
|
|
|
import { StartupLogo } from './StartupLogo';
|
2025-10-15 09:34:44 +08:00
|
|
|
import '../styles/StartupPage.css';
|
|
|
|
|
|
2025-11-23 21:45:10 +08:00
|
|
|
type Locale = 'en' | 'zh';
|
|
|
|
|
|
2025-10-15 09:34:44 +08:00
|
|
|
interface StartupPageProps {
|
|
|
|
|
onOpenProject: () => void;
|
|
|
|
|
onCreateProject: () => void;
|
2025-10-16 17:10:22 +08:00
|
|
|
onOpenRecentProject?: (projectPath: string) => void;
|
2025-12-04 14:04:39 +08:00
|
|
|
onRemoveRecentProject?: (projectPath: string) => void;
|
|
|
|
|
onDeleteProject?: (projectPath: string) => Promise<void>;
|
2025-11-23 21:45:10 +08:00
|
|
|
onLocaleChange?: (locale: Locale) => void;
|
2025-10-15 09:34:44 +08:00
|
|
|
recentProjects?: string[];
|
|
|
|
|
locale: string;
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-23 21:45:10 +08:00
|
|
|
const LANGUAGES = [
|
|
|
|
|
{ code: 'en', name: 'English' },
|
|
|
|
|
{ code: 'zh', name: '中文' }
|
|
|
|
|
];
|
|
|
|
|
|
2025-12-04 14:04:39 +08:00
|
|
|
export function StartupPage({ onOpenProject, onCreateProject, onOpenRecentProject, onRemoveRecentProject, onDeleteProject, onLocaleChange, recentProjects = [], locale }: StartupPageProps) {
|
2025-11-26 11:08:10 +08:00
|
|
|
const [showLogo, setShowLogo] = useState(true);
|
2025-11-02 23:50:41 +08:00
|
|
|
const [hoveredProject, setHoveredProject] = useState<string | null>(null);
|
2025-11-23 14:49:37 +08:00
|
|
|
const [appVersion, setAppVersion] = useState<string>('');
|
2025-11-23 21:45:10 +08:00
|
|
|
const [showLangMenu, setShowLangMenu] = useState(false);
|
2025-12-04 14:04:39 +08:00
|
|
|
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; project: string } | null>(null);
|
|
|
|
|
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null);
|
2025-11-26 11:08:10 +08:00
|
|
|
const [updateInfo, setUpdateInfo] = useState<UpdateCheckResult | null>(null);
|
|
|
|
|
const [showUpdateBanner, setShowUpdateBanner] = useState(false);
|
|
|
|
|
const [isInstalling, setIsInstalling] = useState(false);
|
2025-11-23 21:45:10 +08:00
|
|
|
const langMenuRef = useRef<HTMLDivElement>(null);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
const handleClickOutside = (e: MouseEvent) => {
|
|
|
|
|
if (langMenuRef.current && !langMenuRef.current.contains(e.target as Node)) {
|
|
|
|
|
setShowLangMenu(false);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
document.addEventListener('mousedown', handleClickOutside);
|
|
|
|
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
|
|
|
|
}, []);
|
2025-11-23 14:49:37 +08:00
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
getVersion().then(setAppVersion).catch(() => setAppVersion('1.0.0'));
|
|
|
|
|
}, []);
|
2025-10-15 09:34:44 +08:00
|
|
|
|
2025-11-26 11:08:10 +08:00
|
|
|
// 启动时检查更新
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
checkForUpdatesOnStartup().then((result) => {
|
|
|
|
|
if (result.available) {
|
|
|
|
|
setUpdateInfo(result);
|
|
|
|
|
setShowUpdateBanner(true);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}, []);
|
|
|
|
|
|
2025-11-02 23:50:41 +08:00
|
|
|
const translations = {
|
|
|
|
|
en: {
|
2025-11-27 20:42:46 +08:00
|
|
|
title: 'ESEngine Editor',
|
2025-11-02 23:50:41 +08:00
|
|
|
subtitle: 'Professional Game Development Tool',
|
|
|
|
|
openProject: 'Open Project',
|
|
|
|
|
createProject: 'Create Project',
|
|
|
|
|
recentProjects: 'Recent Projects',
|
|
|
|
|
noRecentProjects: 'No recent projects',
|
2025-11-26 11:08:10 +08:00
|
|
|
updateAvailable: 'New version available',
|
|
|
|
|
updateNow: 'Update Now',
|
|
|
|
|
installing: 'Installing...',
|
2025-12-04 14:04:39 +08:00
|
|
|
later: 'Later',
|
|
|
|
|
removeFromList: 'Remove from List',
|
|
|
|
|
deleteProject: 'Delete Project',
|
|
|
|
|
deleteConfirmTitle: 'Delete Project',
|
|
|
|
|
deleteConfirmMessage: 'Are you sure you want to permanently delete this project? This action cannot be undone.',
|
|
|
|
|
cancel: 'Cancel',
|
|
|
|
|
delete: 'Delete'
|
2025-11-02 23:50:41 +08:00
|
|
|
},
|
|
|
|
|
zh: {
|
2025-11-27 20:42:46 +08:00
|
|
|
title: 'ESEngine 编辑器',
|
2025-11-02 23:50:41 +08:00
|
|
|
subtitle: '专业游戏开发工具',
|
|
|
|
|
openProject: '打开项目',
|
|
|
|
|
createProject: '创建新项目',
|
|
|
|
|
recentProjects: '最近的项目',
|
|
|
|
|
noRecentProjects: '没有最近的项目',
|
2025-11-26 11:08:10 +08:00
|
|
|
updateAvailable: '发现新版本',
|
|
|
|
|
updateNow: '立即更新',
|
|
|
|
|
installing: '正在安装...',
|
2025-12-04 14:04:39 +08:00
|
|
|
later: '稍后',
|
|
|
|
|
removeFromList: '从列表中移除',
|
|
|
|
|
deleteProject: '删除项目',
|
|
|
|
|
deleteConfirmTitle: '删除项目',
|
|
|
|
|
deleteConfirmMessage: '确定要永久删除此项目吗?此操作无法撤销。',
|
|
|
|
|
cancel: '取消',
|
|
|
|
|
delete: '删除'
|
2025-11-02 23:50:41 +08:00
|
|
|
}
|
|
|
|
|
};
|
2025-10-15 09:34:44 +08:00
|
|
|
|
2025-11-26 11:08:10 +08:00
|
|
|
const handleInstallUpdate = async () => {
|
|
|
|
|
setIsInstalling(true);
|
|
|
|
|
const success = await installUpdate();
|
|
|
|
|
if (!success) {
|
|
|
|
|
setIsInstalling(false);
|
|
|
|
|
}
|
|
|
|
|
// 如果成功,应用会重启,不需要处理
|
|
|
|
|
};
|
|
|
|
|
|
2025-11-02 23:50:41 +08:00
|
|
|
const t = translations[locale as keyof typeof translations] || translations.en;
|
2025-11-23 14:49:37 +08:00
|
|
|
const versionText = locale === 'zh' ? `版本 ${appVersion}` : `Version ${appVersion}`;
|
2025-10-15 09:34:44 +08:00
|
|
|
|
2025-11-26 11:08:10 +08:00
|
|
|
const handleLogoComplete = () => {
|
|
|
|
|
setShowLogo(false);
|
|
|
|
|
};
|
|
|
|
|
|
2025-11-02 23:50:41 +08:00
|
|
|
return (
|
|
|
|
|
<div className="startup-page">
|
2025-11-26 11:08:10 +08:00
|
|
|
{showLogo && <StartupLogo onAnimationComplete={handleLogoComplete} />}
|
2025-11-02 23:50:41 +08:00
|
|
|
<div className="startup-header">
|
|
|
|
|
<h1 className="startup-title">{t.title}</h1>
|
|
|
|
|
<p className="startup-subtitle">{t.subtitle}</p>
|
|
|
|
|
</div>
|
2025-10-15 09:34:44 +08:00
|
|
|
|
2025-11-02 23:50:41 +08:00
|
|
|
<div className="startup-content">
|
|
|
|
|
<div className="startup-actions">
|
|
|
|
|
<button className="startup-action-btn primary" onClick={onOpenProject}>
|
|
|
|
|
<svg className="btn-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
|
|
|
|
<path d="M3 7V17C3 18.1046 3.89543 19 5 19H19C20.1046 19 21 18.1046 21 17V9C21 7.89543 20.1046 7 19 7H13L11 5H5C3.89543 5 3 5.89543 3 7Z" strokeWidth="2"/>
|
|
|
|
|
</svg>
|
|
|
|
|
<span>{t.openProject}</span>
|
|
|
|
|
</button>
|
2025-10-15 09:34:44 +08:00
|
|
|
|
2025-11-02 23:50:41 +08:00
|
|
|
<button className="startup-action-btn" onClick={onCreateProject}>
|
|
|
|
|
<svg className="btn-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
|
|
|
|
<path d="M12 5V19M5 12H19" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
|
|
|
|
</svg>
|
|
|
|
|
<span>{t.createProject}</span>
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
2025-10-15 09:34:44 +08:00
|
|
|
|
2025-11-02 23:50:41 +08:00
|
|
|
<div className="startup-recent">
|
|
|
|
|
<h2 className="recent-title">{t.recentProjects}</h2>
|
|
|
|
|
{recentProjects.length === 0 ? (
|
|
|
|
|
<p className="recent-empty">{t.noRecentProjects}</p>
|
|
|
|
|
) : (
|
|
|
|
|
<ul className="recent-list">
|
|
|
|
|
{recentProjects.map((project, index) => (
|
|
|
|
|
<li
|
|
|
|
|
key={index}
|
|
|
|
|
className={`recent-item ${hoveredProject === project ? 'hovered' : ''}`}
|
|
|
|
|
onMouseEnter={() => setHoveredProject(project)}
|
|
|
|
|
onMouseLeave={() => setHoveredProject(null)}
|
|
|
|
|
onClick={() => onOpenRecentProject?.(project)}
|
2025-12-04 14:04:39 +08:00
|
|
|
onContextMenu={(e) => {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
setContextMenu({ x: e.clientX, y: e.clientY, project });
|
|
|
|
|
}}
|
2025-11-02 23:50:41 +08:00
|
|
|
style={{ cursor: onOpenRecentProject ? 'pointer' : 'default' }}
|
|
|
|
|
>
|
|
|
|
|
<svg className="recent-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
|
|
|
|
<path d="M3 7V17C3 18.1046 3.89543 19 5 19H19C20.1046 19 21 18.1046 21 17V9C21 7.89543 20.1046 7 19 7H13L11 5H5C3.89543 5 3 5.89543 3 7Z" strokeWidth="2"/>
|
|
|
|
|
</svg>
|
|
|
|
|
<div className="recent-info">
|
|
|
|
|
<div className="recent-name">{project.split(/[\\/]/).pop()}</div>
|
|
|
|
|
<div className="recent-path">{project}</div>
|
|
|
|
|
</div>
|
2025-12-04 14:04:39 +08:00
|
|
|
{onRemoveRecentProject && (
|
|
|
|
|
<button
|
|
|
|
|
className="recent-remove-btn"
|
|
|
|
|
onClick={(e) => {
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
onRemoveRecentProject(project);
|
|
|
|
|
}}
|
|
|
|
|
title={t.removeFromList}
|
|
|
|
|
>
|
|
|
|
|
<Trash2 size={14} />
|
|
|
|
|
</button>
|
|
|
|
|
)}
|
2025-11-02 23:50:41 +08:00
|
|
|
</li>
|
|
|
|
|
))}
|
|
|
|
|
</ul>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
2025-10-15 09:34:44 +08:00
|
|
|
|
2025-11-26 11:08:10 +08:00
|
|
|
{/* 更新提示条 */}
|
|
|
|
|
{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>
|
|
|
|
|
)}
|
|
|
|
|
|
2025-11-02 23:50:41 +08:00
|
|
|
<div className="startup-footer">
|
2025-11-23 14:49:37 +08:00
|
|
|
<span className="startup-version">{versionText}</span>
|
2025-11-23 21:45:10 +08:00
|
|
|
{onLocaleChange && (
|
|
|
|
|
<div className="startup-locale-dropdown" ref={langMenuRef}>
|
|
|
|
|
<button
|
|
|
|
|
className="startup-locale-btn"
|
|
|
|
|
onClick={() => setShowLangMenu(!showLangMenu)}
|
|
|
|
|
>
|
|
|
|
|
<Globe size={14} />
|
|
|
|
|
<span>{LANGUAGES.find(l => l.code === locale)?.name || 'English'}</span>
|
|
|
|
|
<ChevronDown size={12} />
|
|
|
|
|
</button>
|
|
|
|
|
{showLangMenu && (
|
|
|
|
|
<div className="startup-locale-menu">
|
|
|
|
|
{LANGUAGES.map(lang => (
|
|
|
|
|
<button
|
|
|
|
|
key={lang.code}
|
|
|
|
|
className={`startup-locale-item ${locale === lang.code ? 'active' : ''}`}
|
|
|
|
|
onClick={() => {
|
|
|
|
|
onLocaleChange(lang.code as Locale);
|
|
|
|
|
setShowLangMenu(false);
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
{lang.name}
|
|
|
|
|
</button>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
2025-11-02 23:50:41 +08:00
|
|
|
</div>
|
2025-12-04 14:04:39 +08:00
|
|
|
|
|
|
|
|
{/* 右键菜单 | Context Menu */}
|
|
|
|
|
{contextMenu && (
|
|
|
|
|
<div
|
|
|
|
|
className="startup-context-menu-overlay"
|
|
|
|
|
onClick={() => setContextMenu(null)}
|
|
|
|
|
>
|
|
|
|
|
<div
|
|
|
|
|
className="startup-context-menu"
|
|
|
|
|
style={{ left: contextMenu.x, top: contextMenu.y }}
|
|
|
|
|
onClick={(e) => e.stopPropagation()}
|
|
|
|
|
>
|
|
|
|
|
<button
|
|
|
|
|
className="startup-context-menu-item"
|
|
|
|
|
onClick={() => {
|
|
|
|
|
onRemoveRecentProject?.(contextMenu.project);
|
|
|
|
|
setContextMenu(null);
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<X size={14} />
|
|
|
|
|
<span>{t.removeFromList}</span>
|
|
|
|
|
</button>
|
|
|
|
|
{onDeleteProject && (
|
|
|
|
|
<button
|
|
|
|
|
className="startup-context-menu-item danger"
|
|
|
|
|
onClick={() => {
|
|
|
|
|
setDeleteConfirm(contextMenu.project);
|
|
|
|
|
setContextMenu(null);
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<Trash2 size={14} />
|
|
|
|
|
<span>{t.deleteProject}</span>
|
|
|
|
|
</button>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* 删除确认对话框 | Delete Confirmation Dialog */}
|
|
|
|
|
{deleteConfirm && (
|
|
|
|
|
<div className="startup-dialog-overlay">
|
|
|
|
|
<div className="startup-dialog">
|
|
|
|
|
<div className="startup-dialog-header">
|
|
|
|
|
<Trash2 size={20} className="dialog-icon-danger" />
|
|
|
|
|
<h3>{t.deleteConfirmTitle}</h3>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="startup-dialog-body">
|
|
|
|
|
<p>{t.deleteConfirmMessage}</p>
|
|
|
|
|
<p className="startup-dialog-path">{deleteConfirm}</p>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="startup-dialog-footer">
|
|
|
|
|
<button
|
|
|
|
|
className="startup-dialog-btn"
|
|
|
|
|
onClick={() => setDeleteConfirm(null)}
|
|
|
|
|
>
|
|
|
|
|
{t.cancel}
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
className="startup-dialog-btn danger"
|
|
|
|
|
onClick={async () => {
|
|
|
|
|
if (deleteConfirm && onDeleteProject) {
|
|
|
|
|
await onDeleteProject(deleteConfirm);
|
|
|
|
|
}
|
|
|
|
|
setDeleteConfirm(null);
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
{t.delete}
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
2025-11-02 23:50:41 +08:00
|
|
|
</div>
|
|
|
|
|
);
|
2025-10-15 09:34:44 +08:00
|
|
|
}
|