mirror of
https://github.com/dream-pep/koring-launcher.git
synced 2026-09-12 05:45:18 +08:00
Add crash monitor, OOBE flow & factory-reset
Introduce crash monitoring and an OOBE onboarding flow. Adds a crash logger (koring-crash.log), crash-monitor handler/window, preload-crash bridge, crash UI (crash.html, src/crash.tsx, pages/crash) and a debug page. Main process now sets up crash listeners, startup checks (first-launch, .minecraft), and a config reset/factory-reset that removes Koring.yml, koring-auth.json and background cache and can relaunch the app. Exposes config preloading to renderer, adds OOBE pages/layout, confirm dialog store/UI, trims Silk renderer settings, bumps version to 1.0.1 and adds motion dependency, and registers crash.html in Vite. Various related type and config updates included.
This commit is contained in:
+23
-1
@@ -7,6 +7,7 @@ import { syncThemeFromConfig } from "./stores/themeStore";
|
||||
import { syncA11yFromConfig } from "./stores/a11yStore";
|
||||
import { syncBackgroundFromConfig } from "./stores/backgroundStore";
|
||||
import { useAuthStore } from "./stores/authStore";
|
||||
import type { AppConfig } from "./api/config";
|
||||
import { Home } from "./pages/home";
|
||||
import { Store } from "./pages/store";
|
||||
import { Today } from "./pages/today";
|
||||
@@ -19,7 +20,13 @@ import { SplashDebug } from "./pages/debug/splash-debug";
|
||||
import { DisplayDebug } from "./pages/debug/display-debug";
|
||||
import { VersionCardDebug } from "./pages/debug/version-card-debug";
|
||||
import { TaskDebug } from "./pages/debug/task-debug";
|
||||
import { CrashDebug } from "./pages/debug/crash-debug";
|
||||
import { Oobe } from "./pages/oobe";
|
||||
import { OobeLanguage } from "./pages/oobe/step-language";
|
||||
import { OobeAgreement } from "./pages/oobe/step-agreement";
|
||||
import { OobeVersion } from "./pages/oobe/step-version";
|
||||
import { OobeBetaTest } from "./pages/oobe/step-beta-test";
|
||||
import { OobeFinish } from "./pages/oobe/step-finish";
|
||||
import { OobeAboutInfo } from "./pages/oobe/about-info";
|
||||
|
||||
const pageMap = {
|
||||
@@ -31,12 +38,18 @@ const pageMap = {
|
||||
gallery: Gallery,
|
||||
"task-queue": TaskQueue,
|
||||
oobe: Oobe,
|
||||
"oobe/language": OobeLanguage,
|
||||
"oobe/agreement": OobeAgreement,
|
||||
"oobe/version": OobeVersion,
|
||||
"oobe/beta-test": OobeBetaTest,
|
||||
"oobe/finish": OobeFinish,
|
||||
"oobe/about-info": OobeAboutInfo,
|
||||
debug: Debug,
|
||||
"debug-splash": SplashDebug,
|
||||
"debug-display": DisplayDebug,
|
||||
"debug-version-card": VersionCardDebug,
|
||||
"debug-task": TaskDebug,
|
||||
"debug-crash": CrashDebug,
|
||||
} as const;
|
||||
|
||||
function App() {
|
||||
@@ -45,12 +58,21 @@ function App() {
|
||||
const Page = pageMap[current];
|
||||
|
||||
useEffect(() => {
|
||||
useConfigStore.getState().init().then(() => {
|
||||
// Listen for preloaded config from main process
|
||||
const unsub = window.electronAPI?.onConfigPreload((data) => {
|
||||
const { config, isFirstLaunch } = data;
|
||||
useConfigStore.getState().applyPreloaded(config as AppConfig, isFirstLaunch);
|
||||
syncThemeFromConfig();
|
||||
syncA11yFromConfig();
|
||||
syncBackgroundFromConfig();
|
||||
useAuthStore.getState().initFromRegistry();
|
||||
// Navigate to OOBE on first launch or if oobe not completed
|
||||
if (isFirstLaunch || config.oobe) {
|
||||
useRouteStore.getState().navigate("oobe");
|
||||
}
|
||||
});
|
||||
|
||||
return () => { unsub?.(); };
|
||||
}, []);
|
||||
|
||||
return (
|
||||
|
||||
@@ -62,6 +62,7 @@ export interface NetworkConfig {
|
||||
|
||||
export interface AppConfig {
|
||||
version: number;
|
||||
oobe: boolean;
|
||||
theme: ThemeConfig;
|
||||
a11y: A11yConfig;
|
||||
background: BackgroundConfig;
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
import { useEffect } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { BUILD_MODE } from "@/lib/mode";
|
||||
import { VERSION } from "@/lib/version";
|
||||
|
||||
export function BetaWarning() {
|
||||
useEffect(() => {
|
||||
if (BUILD_MODE === "beta" || BUILD_MODE === "dev") {
|
||||
toast.warning(`当前为 v${VERSION} BETA 测试版,不代表最终品质。`, {
|
||||
duration: Infinity,
|
||||
dismissible: true,
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogContent,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogAction,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
|
||||
const enabled = import.meta.env.VITE_START_POP === "true";
|
||||
const title = import.meta.env.VITE_START_POP_TITLE ?? "";
|
||||
const info = import.meta.env.VITE_START_POP_INFO ?? "";
|
||||
const buttonText = import.meta.env.VITE_START_POP_BOUTTON ?? "确定";
|
||||
|
||||
export function StartupPopup() {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (enabled) {
|
||||
setOpen(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
if (!enabled) return null;
|
||||
|
||||
return (
|
||||
<AlertDialog open={open} onOpenChange={setOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<div className="mb-2 inline-flex size-10 items-center justify-center rounded-md bg-amber-500/10 sm:group-data-[size=default]/alert-dialog-content:row-span-2">
|
||||
<AlertTriangle className="size-5 text-amber-500" />
|
||||
</div>
|
||||
<AlertDialogTitle>{title}</AlertDialogTitle>
|
||||
<AlertDialogDescription>{info}</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogAction onClick={() => setOpen(false)}>
|
||||
{buttonText}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
@@ -124,9 +124,10 @@ const Silk = ({ speed = 5, scale = 1, color = "#7B7481", noiseIntensity = 1.5, r
|
||||
|
||||
return (
|
||||
<Canvas
|
||||
dpr={[1, 2]}
|
||||
dpr={[1, 1.5]}
|
||||
frameloop="demand"
|
||||
style={{ position: "absolute", inset: 0, width: "100%", height: "100%", background: "black" }}
|
||||
gl={{ antialias: true, alpha: false }}
|
||||
gl={{ antialias: false, alpha: false }}
|
||||
>
|
||||
<SilkPlane ref={meshRef} uniforms={uniforms} />
|
||||
</Canvas>
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { motion } from "motion/react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const initialProps = {
|
||||
pathLength: 0,
|
||||
opacity: 0,
|
||||
} as const
|
||||
|
||||
const animateProps = {
|
||||
pathLength: 1,
|
||||
opacity: 1,
|
||||
} as const
|
||||
|
||||
type Props = React.ComponentProps<typeof motion.svg> & {
|
||||
speed?: number
|
||||
onAnimationComplete?: () => void
|
||||
}
|
||||
|
||||
function AppleHelloEnglishEffect({ className, speed = 1, onAnimationComplete, ...props }: Props) {
|
||||
const calc = (x: number) => x * speed
|
||||
|
||||
return (
|
||||
<motion.svg
|
||||
className={cn("h-20", className)}
|
||||
exit={{ opacity: 0 }}
|
||||
fill="none"
|
||||
initial={{ opacity: 1 }}
|
||||
stroke="currentColor"
|
||||
strokeWidth="14.8883"
|
||||
transition={{ duration: 0.5 }}
|
||||
viewBox="0 0 638 200"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...(props as any)}
|
||||
>
|
||||
<title>hello</title>
|
||||
|
||||
{/* h1 */}
|
||||
<motion.path
|
||||
animate={animateProps}
|
||||
d="M8.69214 166.553C36.2393 151.239 61.3409 131.548 89.8191 98.0295C109.203 75.1488 119.625 49.0228 120.122 31.0026C120.37 17.6036 113.836 7.43883 101.759 7.43883C88.3598 7.43883 79.9231 17.6036 74.7122 40.9363C69.005 66.5793 64.7866 96.0036 54.1166 190.356"
|
||||
initial={initialProps}
|
||||
style={{ strokeLinecap: "round" }}
|
||||
transition={{
|
||||
duration: calc(0.8),
|
||||
ease: "easeInOut",
|
||||
opacity: { duration: 0.4 },
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* h2, ello */}
|
||||
<motion.path
|
||||
animate={animateProps}
|
||||
d="M55.1624 181.135C60.6251 133.114 81.4118 98.0479 107.963 98.0479C123.844 98.0479 133.937 110.703 131.071 128.817C129.457 139.487 127.587 150.405 125.408 163.06C122.869 178.941 130.128 191.348 152.122 191.348C184.197 191.348 219.189 173.523 237.097 145.915C243.198 136.509 245.68 128.073 245.928 119.884C246.176 104.996 237.739 93.8296 222.851 93.8296C203.992 93.8296 189.6 115.17 189.6 142.465C189.6 171.745 205.481 192.341 239.208 192.341C285.066 192.341 335.86 137.292 359.199 75.8585C365.788 58.513 368.26 42.4065 368.26 31.1512C368.26 17.8057 364.042 7.55823 352.131 7.55823C340.469 7.55823 332.777 16.6141 325.829 30.9129C317.688 47.4967 311.667 71.4162 309.203 98.4549C303 166.301 316.896 191.348 349.936 191.348C390 191.348 434.542 135.534 457.286 75.6686C463.803 58.513 466.275 42.4065 466.275 31.1512C466.275 17.8057 462.057 7.55823 450.146 7.55823C438.484 7.55823 430.792 16.6141 423.844 30.9129C415.703 47.4967 409.682 71.4162 407.218 98.4549C401.015 166.301 414.911 191.348 444.416 191.348C473.874 191.348 489.877 165.67 499.471 138.402C508.955 111.447 520.618 94.8221 544.935 94.8221C565.035 94.8221 580.916 109.71 580.916 137.75C580.916 168.768 560.792 192.093 535.362 192.341C512.984 192.589 498.285 174.475 499.774 147.179C501.511 116.907 519.873 94.8221 543.943 94.8221C557.839 94.8221 569.51 100.999 578.682 107.725C603.549 125.866 622.709 114.656 630.047 96.7186"
|
||||
initial={initialProps}
|
||||
onAnimationComplete={onAnimationComplete}
|
||||
style={{ strokeLinecap: "round" }}
|
||||
transition={{
|
||||
duration: calc(2.8),
|
||||
ease: "easeInOut",
|
||||
delay: calc(0.7),
|
||||
opacity: { duration: 0.7, delay: calc(0.7) },
|
||||
}}
|
||||
/>
|
||||
</motion.svg>
|
||||
)
|
||||
}
|
||||
|
||||
export { AppleHelloEnglishEffect}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useConfirmDialogStore } from "@/stores/confirmDialogStore";
|
||||
|
||||
export function ConfirmDialog() {
|
||||
const { open, title, description, confirmLabel, countdown: initCountdown, showCountdown, onConfirm, closeDialog } =
|
||||
useConfirmDialogStore();
|
||||
const [countdown, setCountdown] = useState(initCountdown);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setCountdown(initCountdown);
|
||||
}, [open, initCountdown]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !showCountdown || countdown <= 0) return;
|
||||
const timer = setTimeout(() => setCountdown((c) => c - 1), 1000);
|
||||
return () => clearTimeout(timer);
|
||||
}, [open, showCountdown, countdown]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const canConfirm = !showCountdown || countdown <= 0;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[200] flex items-center justify-center bg-black/50">
|
||||
<div className="glass-card w-[380px] p-6 space-y-4">
|
||||
<h3 className="text-base font-bold text-foreground">{title}</h3>
|
||||
<p className="text-[13px] text-muted-foreground leading-relaxed">{description}</p>
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<button
|
||||
onClick={closeDialog}
|
||||
className="px-4 py-1.5 rounded-md text-[13px] font-medium bg-foreground/[0.06] hover:bg-foreground/[0.12] text-foreground/60 hover:text-foreground transition-colors"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { closeDialog(); onConfirm(); }}
|
||||
disabled={!canConfirm}
|
||||
className="px-4 py-1.5 rounded-md text-[13px] font-medium bg-red-500/15 text-red-600 dark:text-red-400 hover:bg-red-500/25 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
{showCountdown && countdown > 0 ? `${confirmLabel} (${countdown}s)` : confirmLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { CrashPage } from "./pages/crash";
|
||||
import "./index.css";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
|
||||
<React.StrictMode>
|
||||
<CrashPage />
|
||||
</React.StrictMode>,
|
||||
);
|
||||
@@ -1,8 +1,7 @@
|
||||
import { type ReactNode } from "react";
|
||||
import { BackgroundLayer } from "@/components/background/BackgroundLayer";
|
||||
import { SystemLayer } from "@/components/system/SystemLayer";
|
||||
import { StartupPopup } from "@/components/StartupPopup";
|
||||
import { BetaWarning } from "@/components/BetaWarning";
|
||||
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
|
||||
import { Toaster } from "sonner";
|
||||
import { useA11yStore } from "@/stores/a11yStore";
|
||||
import clsx from "clsx";
|
||||
@@ -52,11 +51,8 @@ export function RootLayout({
|
||||
showClose={showClose}
|
||||
/>
|
||||
|
||||
{/* Startup popup — only when VITE_START_POP=true */}
|
||||
<StartupPopup />
|
||||
|
||||
{/* Beta warning toast */}
|
||||
<BetaWarning />
|
||||
{/* Global confirm dialog */}
|
||||
<ConfirmDialog />
|
||||
|
||||
{/* Sonner toaster */}
|
||||
<Toaster
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { VERSION } from "@/lib/version";
|
||||
|
||||
type CrashInfo = {
|
||||
type: string;
|
||||
message: string;
|
||||
timestamp: string;
|
||||
};
|
||||
|
||||
export function CrashPage() {
|
||||
const [crashInfo, setCrashInfo] = useState<CrashInfo | null>(null);
|
||||
const [copying, setCopying] = useState(false);
|
||||
const [copyDone, setCopyDone] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const unsub = window.electronAPI?.on("crash:show", (...args: unknown[]) => {
|
||||
const info = args[0] as CrashInfo;
|
||||
setCrashInfo(info);
|
||||
});
|
||||
return () => { unsub?.(); };
|
||||
}, []);
|
||||
|
||||
const handleClose = () => {
|
||||
window.electronAPI?.close();
|
||||
};
|
||||
|
||||
const handleCopyLog = async () => {
|
||||
setCopying(true);
|
||||
try {
|
||||
const log = await window.electronAPI?.invoke("crash:readLog") as string;
|
||||
if (log) {
|
||||
await navigator.clipboard.writeText(log);
|
||||
setCopyDone(true);
|
||||
setTimeout(() => setCopyDone(false), 2000);
|
||||
}
|
||||
} catch {
|
||||
} finally {
|
||||
setCopying(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFactoryReset = async () => {
|
||||
await window.electronAPI?.invoke("crash:factoryReset");
|
||||
};
|
||||
|
||||
const handleRestart = async () => {
|
||||
await window.electronAPI?.invoke("crash:restart");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-screen h-screen flex flex-col overflow-hidden bg-background text-foreground select-none">
|
||||
{/* 顶栏 — 只有关闭按钮 */}
|
||||
<div
|
||||
className="h-[40px] flex items-center shrink-0 relative z-10"
|
||||
style={{
|
||||
WebkitAppRegion: "drag" as React.CSSProperties["WebkitAppRegion"],
|
||||
background: "var(--titlebar-bg, rgba(255,255,255,0.03))",
|
||||
backdropFilter: "blur(3px)",
|
||||
WebkitBackdropFilter: "blur(3px)",
|
||||
borderBottom: "1px solid var(--titlebar-border, rgba(255,255,255,0.06))",
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center pl-3 shrink-0" style={{ WebkitAppRegion: "no-drag" as React.CSSProperties["WebkitAppRegion"] }}>
|
||||
<span className="text-sm font-semibold tracking-wide text-foreground/80">
|
||||
Koring Launcher
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center shrink-0 justify-end ml-auto pr-2" style={{ WebkitAppRegion: "no-drag" as React.CSSProperties["WebkitAppRegion"] }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
className="flex items-center justify-center w-[25px] h-[25px] rounded transition-colors cursor-default hover:bg-red-500 text-black/70 dark:text-white/70 hover:text-white"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<line x1="2" y1="2" x2="10" y2="10" />
|
||||
<line x1="10" y1="2" x2="2" y2="10" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 崩溃内容 */}
|
||||
<div className="flex-1 flex items-center justify-center p-8">
|
||||
<div className="w-full max-w-[520px] text-center space-y-6">
|
||||
{/* 图标 */}
|
||||
<div className="flex justify-center">
|
||||
<div className="w-16 h-16 rounded-full bg-red-500/10 flex items-center justify-center">
|
||||
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" className="text-red-500">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="12" y1="8" x2="12" y2="12" />
|
||||
<line x1="12" y1="16" x2="12.01" y2="16" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 标题 */}
|
||||
<div className="space-y-2">
|
||||
<h1 className="text-xl font-bold text-foreground">
|
||||
程序发生崩溃
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
我们已记录此错误,接下来您想做什么?
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
{/* 说明文字 */}
|
||||
<p className="text-sm text-muted-foreground leading-relaxed text-left">
|
||||
您可能是在一些奇怪的地方触发了内容导致崩溃,我们已准备好日志,你可以直接复制将其发送给支持人员,你也可以点击下方的重启按钮再次启动,如果还是不行您可以点击强还原配置按钮进行还原。(注意,还原只会清除启动器数据,并不会影响实例,请放心使用)
|
||||
</p>
|
||||
|
||||
{/* 按钮组 */}
|
||||
<div className="flex items-center justify-center gap-3 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopyLog}
|
||||
disabled={copying}
|
||||
className="px-4 py-2 text-sm font-medium rounded-lg border border-border bg-background hover:bg-muted transition-colors disabled:opacity-50"
|
||||
>
|
||||
{copyDone ? "已复制" : copying ? "复制中..." : "复制日志"}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleFactoryReset}
|
||||
className="px-4 py-2 text-sm font-medium rounded-lg border border-border bg-background hover:bg-muted transition-colors"
|
||||
>
|
||||
强还原配置
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRestart}
|
||||
className="px-4 py-2 text-sm font-medium rounded-lg bg-primary text-primary-foreground hover:bg-primary/80 transition-colors"
|
||||
>
|
||||
重启
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import { useState } from "react";
|
||||
import { AlertTriangle, TestTube, RotateCcw, Copy, Trash2 } from "lucide-react";
|
||||
|
||||
export function CrashDebug() {
|
||||
const [log, setLog] = useState("");
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [status, setStatus] = useState("");
|
||||
|
||||
const handleSimulateCrash = async () => {
|
||||
setStatus("正在模拟崩溃...");
|
||||
await window.electronAPI?.simulateCrash();
|
||||
};
|
||||
|
||||
const handleTestDialog = async () => {
|
||||
setStatus("正在打开崩溃弹窗...");
|
||||
await window.electronAPI?.testCrashDialog();
|
||||
setStatus("崩溃弹窗已打开");
|
||||
};
|
||||
|
||||
const handleReadLog = async () => {
|
||||
const result = await window.electronAPI?.invoke("crash:readLog") as string;
|
||||
setLog(result || "(空)");
|
||||
setStatus("日志已加载");
|
||||
};
|
||||
|
||||
const handleCopyLog = async () => {
|
||||
if (!log) return;
|
||||
await navigator.clipboard.writeText(log);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
const handleFactoryReset = async () => {
|
||||
if (!confirm("确定要执行强还原配置吗?这将删除 Koring.yml、koring-auth.json 和背景缓存,但不会影响实例。")) return;
|
||||
setStatus("正在还原...");
|
||||
await window.electronAPI?.invoke("crash:factoryReset");
|
||||
setStatus("还原完成");
|
||||
};
|
||||
|
||||
const btnBase = "flex items-center gap-2 px-4 py-2.5 rounded-lg text-sm font-medium transition-all duration-150 cursor-pointer active:scale-[0.97]";
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-foreground mb-1">崩溃测试</h2>
|
||||
<p className="text-sm text-muted-foreground mb-6">测试崩溃检测、崩溃弹窗与恢复功能</p>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* 模拟崩溃 */}
|
||||
<section className="glass-card p-5 space-y-3">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<AlertTriangle className="w-4 h-4 text-red-500" />
|
||||
<h3 className="text-sm font-semibold text-foreground">模拟渲染进程崩溃</h3>
|
||||
</div>
|
||||
<p className="text-[13px] text-muted-foreground">
|
||||
调用 <code className="px-1.5 py-0.5 rounded bg-foreground/[0.06] text-foreground/80 text-xs">forcefullyCrashRenderer()</code> 强制销毁渲染进程,触发 <code className="px-1.5 py-0.5 rounded bg-foreground/[0.06] text-foreground/80 text-xs">render-process-gone</code> 事件,崩溃弹窗应自动弹出。
|
||||
</p>
|
||||
<button onClick={handleSimulateCrash} className={`${btnBase} bg-red-500/10 text-red-600 dark:text-red-400 hover:bg-red-500/20`}>
|
||||
<AlertTriangle className="w-4 h-4" />
|
||||
模拟崩溃
|
||||
</button>
|
||||
</section>
|
||||
|
||||
{/* 测试崩溃弹窗 */}
|
||||
<section className="glass-card p-5 space-y-3">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<TestTube className="w-4 h-4 text-amber-500" />
|
||||
<h3 className="text-sm font-semibold text-foreground">测试崩溃弹窗</h3>
|
||||
</div>
|
||||
<p className="text-[13px] text-muted-foreground">
|
||||
不会真正崩溃,直接弹出崩溃弹窗 UI,用于验证窗口样式、按钮功能是否正常。
|
||||
</p>
|
||||
<button onClick={handleTestDialog} className={`${btnBase} bg-amber-500/10 text-amber-600 dark:text-amber-400 hover:bg-amber-500/20`}>
|
||||
<TestTube className="w-4 h-4" />
|
||||
测试崩溃弹窗
|
||||
</button>
|
||||
</section>
|
||||
|
||||
{/* 崩溃日志 */}
|
||||
<section className="glass-card p-5 space-y-3">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<Copy className="w-4 h-4 text-blue-500" />
|
||||
<h3 className="text-sm font-semibold text-foreground">崩溃日志</h3>
|
||||
</div>
|
||||
<p className="text-[13px] text-muted-foreground">
|
||||
读取 <code className="px-1.5 py-0.5 rounded bg-foreground/[0.06] text-foreground/80 text-xs">koring-crash.log</code> 文件内容,可复制发送给开发人员。
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={handleReadLog} className={`${btnBase} bg-blue-500/10 text-blue-600 dark:text-blue-400 hover:bg-blue-500/20`}>
|
||||
<Copy className="w-4 h-4" />
|
||||
读取日志
|
||||
</button>
|
||||
<button onClick={handleCopyLog} disabled={!log} className={`${btnBase} bg-blue-500/10 text-blue-600 dark:text-blue-400 hover:bg-blue-500/20 disabled:opacity-40`}>
|
||||
<Copy className="w-4 h-4" />
|
||||
{copied ? "已复制" : "复制到剪贴板"}
|
||||
</button>
|
||||
</div>
|
||||
{log && (
|
||||
<pre className="mt-2 p-3 rounded-lg bg-foreground/[0.03] border border-border/50 text-xs text-foreground/70 overflow-auto max-h-40 font-mono">
|
||||
{log}
|
||||
</pre>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* 强还原配置 */}
|
||||
<section className="glass-card p-5 space-y-3">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<Trash2 className="w-4 h-4 text-orange-500" />
|
||||
<h3 className="text-sm font-semibold text-foreground">强还原配置</h3>
|
||||
</div>
|
||||
<p className="text-[13px] text-muted-foreground">
|
||||
删除 <code className="px-1.5 py-0.5 rounded bg-foreground/[0.06] text-foreground/80 text-xs">Koring.yml</code>、
|
||||
<code className="px-1.5 py-0.5 rounded bg-foreground/[0.06] text-foreground/80 text-xs">koring-auth.json</code> 和背景缓存。
|
||||
<strong className="text-foreground/80"> 不会影响实例数据。</strong>
|
||||
</p>
|
||||
<button onClick={handleFactoryReset} className={`${btnBase} bg-orange-500/10 text-orange-600 dark:text-orange-400 hover:bg-orange-500/20`}>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
强还原配置
|
||||
</button>
|
||||
</section>
|
||||
|
||||
{/* 状态 */}
|
||||
{status && (
|
||||
<p className="text-xs text-muted-foreground/60">{status}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,15 @@
|
||||
import { useRouteStore } from "@/stores/routeStore";
|
||||
import { Monitor, Paintbrush, CreditCard, ListTodo, ChevronRight, FlaskConical, Rocket } from "lucide-react";
|
||||
import { Monitor, Paintbrush, CreditCard, ListTodo, ChevronRight, FlaskConical, Rocket, AlertTriangle } from "lucide-react";
|
||||
|
||||
const debugPages = [
|
||||
{
|
||||
key: "debug-crash" as const,
|
||||
icon: AlertTriangle,
|
||||
title: "崩溃测试",
|
||||
desc: "模拟崩溃、测试崩溃弹窗、查看崩溃日志与强还原配置",
|
||||
color: "text-red-500",
|
||||
bg: "bg-red-500/10",
|
||||
},
|
||||
{
|
||||
key: "debug-splash" as const,
|
||||
icon: Monitor,
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
Clock,
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
import { VERSION } from "@/lib/version";
|
||||
|
||||
interface InfoItem {
|
||||
icon: typeof Package;
|
||||
@@ -35,7 +36,7 @@ export function OobeAboutInfo() {
|
||||
|
||||
const items: InfoItem[] = systemInfo && localeInfo
|
||||
? [
|
||||
{ icon: Package, label: "App Version", value: `v${systemInfo.app_version}`, color: "text-primary", bg: "bg-primary/10" },
|
||||
{ icon: Package, label: "App Version", value: `v${VERSION}`, color: "text-primary", bg: "bg-primary/10" },
|
||||
{ icon: Cpu, label: "BIOS ID", value: systemInfo.bios_id, color: "text-blue-500", bg: "bg-blue-500/10" },
|
||||
{ icon: Monitor, label: "OS Name", value: `${systemInfo.os_name} (${systemInfo.os_version})`, color: "text-purple-500", bg: "bg-purple-500/10" },
|
||||
{ icon: Globe, label: "Region", value: localeInfo.region, color: "text-green-500", bg: "bg-green-500/10" },
|
||||
|
||||
+53
-18
@@ -1,29 +1,64 @@
|
||||
import { useRouteStore } from "@/stores/routeStore";
|
||||
import { Rocket, ChevronRight } from "lucide-react";
|
||||
import { useEffect, useState, useRef } from "react";
|
||||
|
||||
export function Oobe() {
|
||||
const goBack = useRouteStore((s) => s.goBack);
|
||||
const words = ["Hello.", "你好。", "こんにちは。", "안녕하세요。", "Bonjour."];
|
||||
|
||||
function TypewriterText() {
|
||||
const [wordIndex, setWordIndex] = useState(0);
|
||||
const [displayed, setDisplayed] = useState("");
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout>>();
|
||||
|
||||
useEffect(() => {
|
||||
const word = words[wordIndex];
|
||||
|
||||
if (!isDeleting) {
|
||||
if (displayed.length < word.length) {
|
||||
timerRef.current = setTimeout(() => {
|
||||
setDisplayed(word.slice(0, displayed.length + 1));
|
||||
}, 120);
|
||||
} else {
|
||||
timerRef.current = setTimeout(() => setIsDeleting(true), 1800);
|
||||
}
|
||||
} else {
|
||||
if (displayed.length > 0) {
|
||||
timerRef.current = setTimeout(() => {
|
||||
setDisplayed(displayed.slice(0, -1));
|
||||
}, 60);
|
||||
} else {
|
||||
setIsDeleting(false);
|
||||
setWordIndex((i) => (i + 1) % words.length);
|
||||
}
|
||||
}
|
||||
|
||||
return () => clearTimeout(timerRef.current);
|
||||
}, [displayed, isDeleting, wordIndex]);
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col items-center justify-center px-8">
|
||||
<div className="flex flex-col items-center text-center max-w-md">
|
||||
<div className="p-4 rounded-2xl bg-primary/10 mb-6">
|
||||
<Rocket className="w-10 h-10 text-primary" />
|
||||
</div>
|
||||
<span className="inline-block min-w-[200px] text-center">
|
||||
{displayed}
|
||||
<span className="inline-block w-[2px] h-[1em] bg-foreground/60 ml-0.5 align-middle animate-pulse" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
<h1 className="text-2xl font-bold text-foreground mb-2">
|
||||
欢迎使用 Koring Launcher
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground mb-8 leading-relaxed">
|
||||
这是 OOBE(开箱体验)页面。在这里可以引导用户完成初始设置。
|
||||
</p>
|
||||
export function Oobe() {
|
||||
const navigate = useRouteStore((s) => s.navigate);
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col items-center justify-center relative">
|
||||
{/* 中心动画文字 */}
|
||||
<div className="text-5xl font-bold tracking-tight text-foreground">
|
||||
<TypewriterText />
|
||||
</div>
|
||||
|
||||
{/* 下方按钮 */}
|
||||
<div className="absolute bottom-12">
|
||||
<button
|
||||
onClick={goBack}
|
||||
className="inline-flex items-center gap-1.5 px-4 py-2 rounded-lg bg-foreground/[0.06] hover:bg-foreground/[0.1] text-sm font-medium transition-colors"
|
||||
onClick={() => navigate("oobe/language")}
|
||||
className="w-12 h-12 rounded-full bg-foreground/[0.06] hover:bg-foreground/[0.12] flex items-center justify-center text-foreground/60 hover:text-foreground transition-all duration-200 text-xl"
|
||||
>
|
||||
返回调试
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
→
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { type ReactNode } from "react";
|
||||
|
||||
interface OobeLayoutProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function OobeLayout({ children }: OobeLayoutProps) {
|
||||
return (
|
||||
<div className="h-full flex flex-col items-center justify-center relative">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
interface NextButtonProps {
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function NextButton({ onClick, disabled = false }: NextButtonProps) {
|
||||
return (
|
||||
<div className="absolute bottom-12">
|
||||
<button
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className="w-12 h-12 rounded-full bg-foreground/[0.06] hover:bg-foreground/[0.12] flex items-center justify-center text-foreground/60 hover:text-foreground transition-all duration-200 text-xl disabled:opacity-30 disabled:cursor-not-allowed disabled:hover:bg-foreground/[0.06] disabled:hover:text-foreground/60"
|
||||
>
|
||||
→
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useRouteStore } from "@/stores/routeStore";
|
||||
import { OobeLayout } from "./layout";
|
||||
import { NextButton } from "./next-button";
|
||||
|
||||
export function OobeAgreement() {
|
||||
const navigate = useRouteStore((s) => s.navigate);
|
||||
const [text, setText] = useState("");
|
||||
const [checked, setChecked] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`${import.meta.env.BASE_URL}protocol-user.txt`)
|
||||
.then((r) => r.text())
|
||||
.then(setText)
|
||||
.catch(() => setText("无法加载协议内容"));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<OobeLayout>
|
||||
<div className="w-full max-w-lg flex flex-col items-center gap-4 px-6">
|
||||
{/* 标题 */}
|
||||
<div className="text-center space-y-1">
|
||||
<h2 className="text-lg font-bold text-foreground">Koring Team 产品用户协议</h2>
|
||||
<p className="text-xs text-muted-foreground">您需要同意才可以继续</p>
|
||||
</div>
|
||||
|
||||
{/* 协议内容 */}
|
||||
<div className="w-full h-[300px] rounded-xl bg-foreground/[0.03] border border-border/50 p-4 overflow-y-auto">
|
||||
<pre className="text-xs text-foreground/70 whitespace-pre-wrap font-sans leading-relaxed">
|
||||
{text}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
{/* 勾选框 */}
|
||||
<label className="flex items-start gap-2.5 cursor-pointer select-none group">
|
||||
<div className="relative mt-0.5">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={(e) => setChecked(e.target.checked)}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-4 h-4 rounded border border-border/60 bg-foreground/[0.03] peer-checked:bg-primary peer-checked:border-primary transition-colors flex items-center justify-center">
|
||||
{checked && (
|
||||
<svg width="10" height="10" viewBox="0 0 12 12" fill="none" stroke="white" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="2 6 5 9 10 3" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground leading-relaxed group-hover:text-foreground/70 transition-colors">
|
||||
我已详细阅读此协议,且同意其内容并签署此协议。
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<NextButton onClick={() => navigate("oobe/version")} disabled={!checked} />
|
||||
</OobeLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useRouteStore } from "@/stores/routeStore";
|
||||
import { OobeLayout } from "./layout";
|
||||
import { NextButton } from "./next-button";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
export function OobeBetaTest() {
|
||||
const navigate = useRouteStore((s) => s.navigate);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [text, setText] = useState("");
|
||||
const [checked, setChecked] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
fetch(`${import.meta.env.BASE_URL}protocol-beta.txt`)
|
||||
.then((r) => r.text())
|
||||
.then(setText)
|
||||
.catch(() => setText("无法加载协议内容"))
|
||||
.finally(() => setLoading(false));
|
||||
}, 1500);
|
||||
return () => clearTimeout(timer);
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<OobeLayout>
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-foreground/40" />
|
||||
<span className="text-sm text-muted-foreground">正在确认版本信息,并激活...</span>
|
||||
</div>
|
||||
</OobeLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<OobeLayout>
|
||||
<div className="w-full max-w-lg flex flex-col items-center gap-4 px-6">
|
||||
{/* 标题 */}
|
||||
<div className="text-center space-y-1">
|
||||
<h2 className="text-lg font-bold text-foreground">Koring APP Beta 测试协议</h2>
|
||||
<p className="text-xs text-muted-foreground">您需要同意才可以继续</p>
|
||||
</div>
|
||||
|
||||
{/* 协议内容 */}
|
||||
<div className="w-full h-[300px] rounded-xl bg-foreground/[0.03] border border-border/50 p-4 overflow-y-auto">
|
||||
<pre className="text-xs text-foreground/70 whitespace-pre-wrap font-sans leading-relaxed">
|
||||
{text}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
{/* 勾选框 */}
|
||||
<label className="flex items-start gap-2.5 cursor-pointer select-none group">
|
||||
<div className="relative mt-0.5">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={(e) => setChecked(e.target.checked)}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-4 h-4 rounded border border-border/60 bg-foreground/[0.03] peer-checked:bg-primary peer-checked:border-primary transition-colors flex items-center justify-center">
|
||||
{checked && (
|
||||
<svg width="10" height="10" viewBox="0 0 12 12" fill="none" stroke="white" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="2 6 5 9 10 3" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground leading-relaxed group-hover:text-foreground/70 transition-colors">
|
||||
我已详细阅读并了解此 测试 协议,且 同意其内容 并 签署此协议。
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<NextButton onClick={() => navigate("oobe/finish")} disabled={!checked} />
|
||||
</OobeLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { useRouteStore } from "@/stores/routeStore";
|
||||
import { useConfigStore } from "@/stores/configStore";
|
||||
import { OobeLayout } from "./layout";
|
||||
import { AppleHelloEnglishEffect } from "@/components/ui/apple-hello-effect";
|
||||
|
||||
export function OobeFinish() {
|
||||
const navigate = useRouteStore((s) => s.navigate);
|
||||
const setOobe = useConfigStore((s) => s.setOobe);
|
||||
|
||||
const handleFinish = () => {
|
||||
setOobe(false);
|
||||
navigate("home");
|
||||
};
|
||||
|
||||
return (
|
||||
<OobeLayout>
|
||||
<div className="flex flex-col items-center gap-6">
|
||||
<AppleHelloEnglishEffect className="text-foreground" />
|
||||
</div>
|
||||
|
||||
<div className="absolute bottom-12">
|
||||
<button
|
||||
onClick={handleFinish}
|
||||
className="h-12 px-6 rounded-full bg-foreground/[0.06] hover:bg-foreground/[0.12] flex items-center justify-center text-foreground/60 hover:text-foreground transition-all duration-200 text-sm font-medium"
|
||||
>
|
||||
前往首页
|
||||
</button>
|
||||
</div>
|
||||
</OobeLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { useState } from "react";
|
||||
import { useRouteStore } from "@/stores/routeStore";
|
||||
import { OobeLayout } from "./layout";
|
||||
import { NextButton } from "./next-button";
|
||||
import { Check } from "lucide-react";
|
||||
|
||||
const languages = [
|
||||
{ key: "zh-CN", label: "简体中文", available: true },
|
||||
{ key: "zh-TW", label: "繁体中文", available: false },
|
||||
{ key: "en", label: "English", available: false },
|
||||
{ key: "ja", label: "日本語", available: false },
|
||||
{ key: "ko", label: "한국어", available: false },
|
||||
{ key: "lzh", label: "文言文(中国)", available: false },
|
||||
];
|
||||
|
||||
export function OobeLanguage() {
|
||||
const navigate = useRouteStore((s) => s.navigate);
|
||||
const [selected, setSelected] = useState("zh-CN");
|
||||
|
||||
return (
|
||||
<OobeLayout>
|
||||
<div className="w-full max-w-sm space-y-3">
|
||||
{languages.map((lang) => (
|
||||
<button
|
||||
key={lang.key}
|
||||
disabled={!lang.available}
|
||||
onClick={() => lang.available && setSelected(lang.key)}
|
||||
className={[
|
||||
"w-full flex items-center justify-between px-4 py-3 rounded-xl text-sm font-medium transition-all duration-200",
|
||||
lang.available
|
||||
? selected === lang.key
|
||||
? "bg-foreground/[0.08] text-foreground ring-1 ring-foreground/10"
|
||||
: "bg-foreground/[0.03] text-foreground/60 hover:bg-foreground/[0.06] hover:text-foreground/80"
|
||||
: "bg-foreground/[0.02] text-foreground/25 cursor-not-allowed",
|
||||
].join(" ")}
|
||||
>
|
||||
<span>{lang.label}</span>
|
||||
{!lang.available && (
|
||||
<span className="text-[11px] text-foreground/20">即将推出</span>
|
||||
)}
|
||||
{selected === lang.key && lang.available && (
|
||||
<Check className="w-4 h-4 text-foreground/60" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<NextButton onClick={() => navigate("oobe/agreement")} />
|
||||
</OobeLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useRouteStore } from "@/stores/routeStore";
|
||||
import { OobeLayout } from "./layout";
|
||||
import { NextButton } from "./next-button";
|
||||
import { VERSION } from "@/lib/version";
|
||||
import { BUILD_MODE } from "@/lib/mode";
|
||||
|
||||
export function OobeVersion() {
|
||||
const navigate = useRouteStore((s) => s.navigate);
|
||||
const [changelog, setChangelog] = useState("");
|
||||
|
||||
const isTestBuild = BUILD_MODE === "dev" || BUILD_MODE === "beta";
|
||||
const badgeLabel = BUILD_MODE === "dev" ? "DEV" : BUILD_MODE === "beta" ? "BETA" : null;
|
||||
|
||||
const nextRoute = isTestBuild ? "oobe/beta-test" : "oobe/finish";
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`${import.meta.env.BASE_URL}changelog-${VERSION}.txt`)
|
||||
.then((r) => r.text())
|
||||
.then(setChangelog)
|
||||
.catch(() => setChangelog("暂无更新内容"));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<OobeLayout>
|
||||
<div className="w-full max-w-lg flex flex-col items-center gap-4 px-6">
|
||||
{/* 标题 */}
|
||||
<h2 className="text-lg font-bold text-foreground">核对您的版本信息</h2>
|
||||
|
||||
{/* 版本号 + Badge */}
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span className="text-3xl font-bold tracking-tight text-foreground">
|
||||
v{VERSION}
|
||||
</span>
|
||||
{badgeLabel && (
|
||||
<span
|
||||
className={[
|
||||
"text-[11px] font-bold px-2 py-0.5 rounded-full leading-none",
|
||||
BUILD_MODE === "dev"
|
||||
? "bg-amber-500/15 text-amber-600 dark:text-amber-400"
|
||||
: "bg-emerald-500/15 text-emerald-600 dark:text-emerald-400",
|
||||
].join(" ")}
|
||||
>
|
||||
{badgeLabel}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 测试版警告 */}
|
||||
{isTestBuild && (
|
||||
<p className="text-xs text-muted-foreground text-center max-w-sm leading-relaxed">
|
||||
您正在使用测试版本,它并不稳定,不建议用于正式游戏体验,具体内容请以发行版本为准。
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* 更新内容 */}
|
||||
<div className="w-full h-[260px] rounded-xl bg-foreground/[0.03] border border-border/50 p-4 overflow-y-auto">
|
||||
<pre className="text-xs text-foreground/70 whitespace-pre-wrap font-sans leading-relaxed">
|
||||
{changelog}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<NextButton onClick={() => navigate(nextRoute)} />
|
||||
</OobeLayout>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { VersionCard } from "@/components/VersionCard";
|
||||
import { BUILD_MODE } from "@/lib/mode";
|
||||
import { ExternalLink, GitFork } from "lucide-react";
|
||||
import { ExternalLink, GitFork, RotateCcw } from "lucide-react";
|
||||
import { useConfirmDialogStore } from "@/stores/confirmDialogStore";
|
||||
|
||||
function GlassCard({ children }: { children: React.ReactNode }) {
|
||||
return <div className="glass-card px-5 py-4">{children}</div>;
|
||||
@@ -28,10 +29,22 @@ const GITHUB_URL = "https://github.com/koring-launcher/koring-launcher";
|
||||
const OFFICIAL_URL = "https://koring.app";
|
||||
|
||||
export function AboutSetting() {
|
||||
const openDialog = useConfirmDialogStore((s) => s.openDialog);
|
||||
|
||||
const openLink = (url: string) => {
|
||||
window.electronAPI?.openExternal(url);
|
||||
};
|
||||
|
||||
const handleResetClick = () => {
|
||||
openDialog({
|
||||
title: "您确定要还原所有配置吗?",
|
||||
description: "您还原后,您的实例将会保留,但是所有个性化配置将全部丢失,并且需要重新进行激活",
|
||||
confirmLabel: "确认还原",
|
||||
countdown: 5,
|
||||
onConfirm: () => window.electronAPI?.resetConfig(),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-foreground mb-1">关于</h2>
|
||||
@@ -92,6 +105,22 @@ export function AboutSetting() {
|
||||
</GlassCard>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 危险操作 */}
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-foreground mb-3">危险操作</h3>
|
||||
<GlassCard>
|
||||
<SettingRow label="还原所有设置" desc="删除所有配置文件并重启应用">
|
||||
<button
|
||||
onClick={handleResetClick}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-md text-[13px] font-medium bg-red-500/10 text-red-600 dark:text-red-400 hover:bg-red-500/20 transition-colors"
|
||||
>
|
||||
<RotateCcw className="w-4 h-4" />
|
||||
还原
|
||||
</button>
|
||||
</SettingRow>
|
||||
</GlassCard>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
+58
-27
@@ -14,22 +14,23 @@ import {
|
||||
} from "@/api/config";
|
||||
import { DEFAULT_BG } from "@/lib/mode";
|
||||
|
||||
// TODO: re-enable when ready
|
||||
// let saveTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
// function debouncedSave(config: AppConfig) {
|
||||
// if (saveTimer) clearTimeout(saveTimer);
|
||||
// saveTimer = setTimeout(() => {
|
||||
// saveConfig(config).catch((e) => {
|
||||
// console.error("[config] save failed:", e);
|
||||
// });
|
||||
// }, 300);
|
||||
// }
|
||||
let saveTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
function debouncedSave(config: AppConfig) {
|
||||
if (saveTimer) clearTimeout(saveTimer);
|
||||
saveTimer = setTimeout(() => {
|
||||
saveConfig(config).catch((e) => {
|
||||
console.error("[config] save failed:", e);
|
||||
});
|
||||
}, 300);
|
||||
}
|
||||
|
||||
interface ConfigState {
|
||||
config: AppConfig;
|
||||
loaded: boolean;
|
||||
isFirstLaunch: boolean;
|
||||
|
||||
init: () => Promise<void>;
|
||||
applyPreloaded: (config: AppConfig, isFirstLaunch: boolean) => void;
|
||||
setTheme: (patch: Partial<ThemeConfig>) => void;
|
||||
setA11y: (patch: Partial<A11yConfig>) => void;
|
||||
setBackground: (patch: Partial<BackgroundConfig>) => void;
|
||||
@@ -38,10 +39,12 @@ interface ConfigState {
|
||||
setAdvanced: (patch: Partial<AdvancedConfig>) => void;
|
||||
setDownload: (patch: Partial<DownloadConfig>) => void;
|
||||
setNetwork: (patch: Partial<NetworkConfig>) => void;
|
||||
setOobe: (value: boolean) => void;
|
||||
}
|
||||
|
||||
const DEFAULT_CONFIG: AppConfig = {
|
||||
version: 1,
|
||||
oobe: true,
|
||||
theme: { darkMode: "auto", parallax: true },
|
||||
a11y: { reduceMotion: false, reduceTransparency: false, highContrast: false, contentBlurOpacity: 50 },
|
||||
background: { bgType: "image", image: DEFAULT_BG, blur: 0, opacity: 100 },
|
||||
@@ -55,56 +58,84 @@ const DEFAULT_CONFIG: AppConfig = {
|
||||
export const useConfigStore = create<ConfigState>((set, get) => ({
|
||||
config: DEFAULT_CONFIG,
|
||||
loaded: false,
|
||||
isFirstLaunch: false,
|
||||
|
||||
applyPreloaded: (config, isFirstLaunch) => {
|
||||
set({ config, isFirstLaunch, loaded: true });
|
||||
},
|
||||
|
||||
init: async () => {
|
||||
// TODO: re-enable when ready — load from Rust/Koring.yml
|
||||
// try {
|
||||
// const config = await getConfig();
|
||||
// set({ config, loaded: true });
|
||||
// } catch (e) {
|
||||
// console.error("[config] init failed, using defaults:", e);
|
||||
// set({ loaded: true });
|
||||
// }
|
||||
set({ loaded: true });
|
||||
// If already preloaded, skip IPC call
|
||||
if (get().loaded) return;
|
||||
try {
|
||||
const config = await getConfig();
|
||||
set({ config, loaded: true });
|
||||
} catch (e) {
|
||||
console.error("[config] init failed, using defaults:", e);
|
||||
set({ loaded: true });
|
||||
}
|
||||
},
|
||||
|
||||
setTheme: (patch) => {
|
||||
const { config } = get();
|
||||
set({ config: { ...config, theme: { ...config.theme, ...patch } } });
|
||||
const next = { ...config, theme: { ...config.theme, ...patch } };
|
||||
set({ config: next });
|
||||
debouncedSave(next);
|
||||
},
|
||||
|
||||
setA11y: (patch) => {
|
||||
const { config } = get();
|
||||
set({ config: { ...config, a11y: { ...config.a11y, ...patch } } });
|
||||
const next = { ...config, a11y: { ...config.a11y, ...patch } };
|
||||
set({ config: next });
|
||||
debouncedSave(next);
|
||||
},
|
||||
|
||||
setBackground: (patch) => {
|
||||
const { config } = get();
|
||||
set({ config: { ...config, background: { ...config.background, ...patch } } });
|
||||
const next = { ...config, background: { ...config.background, ...patch } };
|
||||
set({ config: next });
|
||||
debouncedSave(next);
|
||||
},
|
||||
|
||||
setGame: (patch) => {
|
||||
const { config } = get();
|
||||
set({ config: { ...config, game: { ...config.game, ...patch } } });
|
||||
const next = { ...config, game: { ...config.game, ...patch } };
|
||||
set({ config: next });
|
||||
debouncedSave(next);
|
||||
},
|
||||
|
||||
setJava: (patch) => {
|
||||
const { config } = get();
|
||||
set({ config: { ...config, java: { ...config.java, ...patch } } });
|
||||
const next = { ...config, java: { ...config.java, ...patch } };
|
||||
set({ config: next });
|
||||
debouncedSave(next);
|
||||
},
|
||||
|
||||
setAdvanced: (patch) => {
|
||||
const { config } = get();
|
||||
set({ config: { ...config, advanced: { ...config.advanced, ...patch } } });
|
||||
const next = { ...config, advanced: { ...config.advanced, ...patch } };
|
||||
set({ config: next });
|
||||
debouncedSave(next);
|
||||
},
|
||||
|
||||
setDownload: (patch) => {
|
||||
const { config } = get();
|
||||
set({ config: { ...config, download: { ...config.download, ...patch } } });
|
||||
const next = { ...config, download: { ...config.download, ...patch } };
|
||||
set({ config: next });
|
||||
debouncedSave(next);
|
||||
},
|
||||
|
||||
setNetwork: (patch) => {
|
||||
const { config } = get();
|
||||
set({ config: { ...config, network: { ...config.network, ...patch } } });
|
||||
const next = { ...config, network: { ...config.network, ...patch } };
|
||||
set({ config: next });
|
||||
debouncedSave(next);
|
||||
},
|
||||
|
||||
setOobe: (value) => {
|
||||
const { config } = get();
|
||||
const next = { ...config, oobe: value };
|
||||
set({ config: next });
|
||||
debouncedSave(next);
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { create } from "zustand";
|
||||
|
||||
interface ConfirmDialogState {
|
||||
open: boolean;
|
||||
title: string;
|
||||
description: string;
|
||||
confirmLabel: string;
|
||||
countdown: number;
|
||||
showCountdown: boolean;
|
||||
onConfirm: () => void;
|
||||
openDialog: (opts: {
|
||||
title: string;
|
||||
description: string;
|
||||
confirmLabel?: string;
|
||||
countdown?: number;
|
||||
onConfirm: () => void;
|
||||
}) => void;
|
||||
closeDialog: () => void;
|
||||
}
|
||||
|
||||
export const useConfirmDialogStore = create<ConfirmDialogState>((set) => ({
|
||||
open: false,
|
||||
title: "",
|
||||
description: "",
|
||||
confirmLabel: "确认",
|
||||
countdown: 0,
|
||||
showCountdown: false,
|
||||
onConfirm: () => {},
|
||||
openDialog: (opts) =>
|
||||
set({
|
||||
open: true,
|
||||
title: opts.title,
|
||||
description: opts.description,
|
||||
confirmLabel: opts.confirmLabel ?? "确认",
|
||||
countdown: opts.countdown ?? 0,
|
||||
showCountdown: (opts.countdown ?? 0) > 0,
|
||||
onConfirm: opts.onConfirm,
|
||||
}),
|
||||
closeDialog: () => set({ open: false }),
|
||||
}));
|
||||
@@ -9,12 +9,18 @@ export type RouteKey =
|
||||
| "gallery"
|
||||
| "task-queue"
|
||||
| "oobe"
|
||||
| "oobe/language"
|
||||
| "oobe/agreement"
|
||||
| "oobe/version"
|
||||
| "oobe/beta-test"
|
||||
| "oobe/finish"
|
||||
| "oobe/about-info"
|
||||
| "debug"
|
||||
| "debug-splash"
|
||||
| "debug-display"
|
||||
| "debug-version-card"
|
||||
| "debug-task";
|
||||
| "debug-task"
|
||||
| "debug-crash";
|
||||
|
||||
export type TitleBarMode = "default" | "sub" | "window" | "oobe";
|
||||
|
||||
@@ -41,6 +47,11 @@ export const allRoutes: RouteItem[] = [
|
||||
...routes,
|
||||
{ key: "task-queue", label: "任务队列", path: "/task-queue", hidden: true },
|
||||
{ key: "oobe", label: "OOBE", path: "/oobe", hidden: true },
|
||||
{ key: "oobe/language", label: "语言设置", path: "/oobe/language", hidden: true },
|
||||
{ key: "oobe/agreement", label: "同意协议", path: "/oobe/agreement", hidden: true },
|
||||
{ key: "oobe/version", label: "当前版本", path: "/oobe/version", hidden: true },
|
||||
{ key: "oobe/beta-test", label: "测试协议", path: "/oobe/beta-test", hidden: true },
|
||||
{ key: "oobe/finish", label: "完成", path: "/oobe/finish", hidden: true },
|
||||
{ key: "oobe/about-info", label: "关于信息", path: "/oobe/about-info", hidden: true, backable: true },
|
||||
{ key: "debug", label: "调试", path: "/debug", hidden: true },
|
||||
{ key: "debug-splash", label: "启动动画调试", path: "/debug/splash", hidden: true },
|
||||
@@ -52,7 +63,7 @@ export const allRoutes: RouteItem[] = [
|
||||
const topLevelKeys = new Set(routes.map((r) => r.key));
|
||||
|
||||
function getRouteTitleBarMode(key: RouteKey): TitleBarMode {
|
||||
if (key === "oobe") return "oobe";
|
||||
if (key === "oobe" || key.startsWith("oobe/")) return "oobe";
|
||||
return topLevelKeys.has(key) ? "default" : "sub";
|
||||
}
|
||||
|
||||
|
||||
Vendored
+9
@@ -11,7 +11,16 @@ interface ElectronAPI {
|
||||
|
||||
getTheme: () => Promise<'light' | 'dark' | 'system' | null>;
|
||||
|
||||
onConfigPreload: (callback: (data: { config: unknown; isFirstLaunch: boolean }) => void) => () => void;
|
||||
|
||||
openExternal: (url: string) => Promise<void>;
|
||||
|
||||
// Crash monitoring
|
||||
simulateCrash: () => Promise<void>;
|
||||
testCrashDialog: () => Promise<void>;
|
||||
|
||||
// Config reset
|
||||
resetConfig: () => Promise<void>;
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
||||
Vendored
+1
-6
@@ -1,11 +1,6 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_START_POP: string;
|
||||
readonly VITE_START_POP_TITLE: string;
|
||||
readonly VITE_START_POP_INFO: string;
|
||||
readonly VITE_START_POP_BOUTTON: string;
|
||||
}
|
||||
interface ImportMetaEnv {}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
|
||||
Reference in New Issue
Block a user