feat: add auth flow and settings UI refactor

This commit is contained in:
2026-07-31 04:03:18 +08:00
parent 27acc66252
commit 0fca587fef
16 changed files with 766 additions and 153 deletions
+12
View File
@@ -0,0 +1,12 @@
{
"name": "123",
"author": "",
"description": "123",
"runtime": {
"minecraft": "1.21.4"
},
"creationDate": 1785342079436,
"lastAccessDate": 1785342079436,
"lastPlayedDate": 0,
"playtime": 0
}
-1
View File
@@ -1 +0,0 @@
oobe: false
+14
View File
@@ -70,6 +70,18 @@ export interface SecurityIdConfig {
authUrl: string;
}
export interface InstanceMeta {
name: string;
displayName: string;
icon: string;
gameVersion: string;
loader: string;
loaderVersion: string;
createdAt: number;
lastPlayed: number;
playtime: number;
}
export interface NetworkConfig {
securityId: SecurityIdConfig;
}
@@ -85,6 +97,7 @@ export interface AppConfig {
advanced: AdvancedConfig;
download: DownloadConfig;
network: NetworkConfig;
instances: InstanceMeta[];
}
const DEFAULTS: AppConfig = {
@@ -98,6 +111,7 @@ const DEFAULTS: AppConfig = {
advanced: { afterLaunch: 'close', winMode: 'default', customWidth: 854, customHeight: 480, gameArgs: '', preLaunchCmd: '', debugMode: false },
download: { fileSource: 'mirror', versionSource: 'mirror', threads: 16, speedLimit: 0 },
network: { securityId: { enabled: false, authUrl: '' } },
instances: [],
};
function migrate(config: AppConfig): AppConfig {
+13
View File
@@ -60,6 +60,18 @@ export interface NetworkConfig {
securityId: SecurityIdConfig;
}
export interface InstanceMeta {
name: string;
displayName: string;
icon: string;
gameVersion: string;
loader: string;
loaderVersion: string;
createdAt: number;
lastPlayed: number;
playtime: number;
}
export interface AppConfig {
version: number;
oobe: boolean;
@@ -71,6 +83,7 @@ export interface AppConfig {
advanced: AdvancedConfig;
download: DownloadConfig;
network: NetworkConfig;
instances: InstanceMeta[];
}
interface CommandResult {
+52 -81
View File
@@ -3,8 +3,9 @@ import { VERSION } from "@/lib/version";
import { useUpdateStore } from "@/stores/updateStore";
import { relaunchApp } from "@/api/update";
import Silk from "@/components/silk/Silk";
import { Button } from "@/components/ui/button";
import { Button } from "@heroui/react";
import clsx from "clsx";
import { Suspense } from "react";
const modeColors: Record<string, string> = {
dev: "#F59E0B",
@@ -12,6 +13,12 @@ const modeColors: Record<string, string> = {
run: "#3B82F6",
};
const modeGradients: Record<string, string> = {
dev: "linear-gradient(135deg, #F59E0B, #D97706)",
beta: "linear-gradient(135deg, #10B981, #059669)",
run: "linear-gradient(135deg, #3B82F6, #2563EB)",
};
const modeLabels: Record<string, string> = {
dev: "开发版",
beta: "测试版",
@@ -22,124 +29,88 @@ type UpdateState = "latest" | "hasUpdate" | "installed";
interface VersionCardProps {
className?: string;
/** 开发者模式:覆盖颜色 */
overrideMode?: string | null;
/** 开发者模式:覆盖更新状态 */
overrideState?: UpdateState | null;
/** 开发者模式:遮罩透明度 (0-100) */
overlayOpacity?: number;
/** 开发者模式:模糊强度 (px) */
blurAmount?: number;
simple?: boolean;
}
export function VersionCard({
className,
overrideMode,
overrideState,
overlayOpacity = 30,
blurAmount = 12,
simple = false,
}: VersionCardProps) {
const color = modeColors[overrideMode ?? BUILD_MODE] ?? modeColors.run;
const gradient = modeGradients[overrideMode ?? BUILD_MODE] ?? modeGradients.run;
const label = modeLabels[overrideMode ?? BUILD_MODE] ?? modeLabels.run;
const { checking, downloading, installed, update, check, install } = useUpdateStore();
const effectiveState: UpdateState = overrideState ?? (installed ? "installed" : update ? "hasUpdate" : "latest");
return (
<div
className={clsx(
"relative overflow-hidden rounded-xl border border-white/10 min-h-[200px]",
className,
)}
>
{/* Silk 背景 */}
<div className="absolute inset-0 z-0" style={{ background: color }}>
<Silk speed={3} scale={1.2} color={color} noiseIntensity={1.2} rotation={0.3} />
</div>
const Btn = (props: React.ComponentProps<typeof Button>) => (
<Button
size="sm"
variant="ghost"
className="text-white/90 hover:text-white hover:bg-white/10 border border-white/20"
{...props}
/>
);
{/* 磨砂层 */}
return (
<div className={clsx("relative overflow-hidden rounded-xl border border-white/10 min-h-[200px]", className)}>
{/* 背景层 */}
<div className="absolute inset-0" style={{ background: gradient }} />
{/* Silk 动画层 (非 simple 模式) */}
{!simple && (
<Suspense fallback={null}>
<div className="absolute inset-0 opacity-60 mix-blend-soft-light">
<Silk speed={3} scale={1.2} color={color} noiseIntensity={1.2} rotation={0.3} />
</div>
</Suspense>
)}
{/* 磨砂遮罩 */}
<div
className="absolute inset-0 z-[1]"
style={{
background: `rgba(0,0,0,${overlayOpacity / 100})`,
backdropFilter: `blur(${blurAmount}px)`,
}}
className="absolute inset-0"
style={{ background: "rgba(0,0,0,0.25)", backdropFilter: "blur(12px)" }}
/>
{/* 内容 */}
{/* 内容 */}
<div className="relative z-10 flex flex-col items-center justify-center px-6 py-10 gap-4">
{/* 版本类型 */}
<span className="text-[11px] font-bold tracking-wider text-white/70">
{label}
</span>
{/* Logo */}
<img src={LOGO_SVG} alt="Koring" className="w-50 h-20 drop-shadow-lg" style={{ filter: "brightness(0) invert(1)" }} />
{/* 版本号 */}
<span className="text-[11px] font-bold tracking-wider text-white/70">{label}</span>
<img
src={LOGO_SVG}
alt="Koring"
className="w-40 h-auto drop-shadow-lg"
style={{ filter: "brightness(0) invert(1)" }}
/>
<p className="text-sm text-white/70 font-medium">v{VERSION}</p>
{/* 按钮组 */}
<div className="flex items-center gap-2 mt-1">
{effectiveState === "latest" && (
<>
<Button
size="sm"
variant="outline"
className="bg-white/10 border-white/20 text-white hover:bg-white/20 hover:text-white"
onClick={check}
disabled={checking}
>
<Btn onPress={check} isDisabled={checking}>
{checking ? "检查中..." : "检查更新"}
</Button>
<Button
size="sm"
variant="outline"
className="bg-white/10 border-white/20 text-white hover:bg-white/20 hover:text-white"
>
</Button>
</Btn>
<Btn></Btn>
</>
)}
{effectiveState === "hasUpdate" && (
<>
<span className="text-[12px] text-white/80 mr-1">
</span>
<Button
size="sm"
variant="outline"
className="bg-white/10 border-white/20 text-white hover:bg-white/20 hover:text-white"
onClick={install}
disabled={downloading}
>
<span className="text-[12px] text-white/80 mr-1"></span>
<Btn onPress={install} isDisabled={downloading}>
{downloading ? "下载中..." : "下载更新"}
</Button>
<Button
size="sm"
variant="outline"
className="bg-white/10 border-white/20 text-white hover:bg-white/20 hover:text-white"
>
</Button>
</Btn>
</>
)}
{effectiveState === "installed" && (
<>
<span className="text-[12px] text-white/80 mr-1">
</span>
<Button
size="sm"
variant="outline"
className="bg-white/10 border-white/20 text-white hover:bg-white/20 hover:text-white"
onClick={relaunchApp}
>
</Button>
<span className="text-[12px] text-white/80 mr-1"></span>
<Btn onPress={relaunchApp}></Btn>
</>
)}
</div>
+38
View File
@@ -0,0 +1,38 @@
import { Component, type ReactNode } from "react";
import { Button } from "@heroui/react";
import { AlertTriangle } from "lucide-react";
interface Props { children: ReactNode; }
interface State { hasError: boolean; errorMsg: string; }
export class ErrorBoundary extends Component<Props, State> {
state: State = { hasError: false, errorMsg: "" };
static getDerivedStateFromError(error: Error) {
return { hasError: true, errorMsg: error.message };
}
render() {
if (this.state.hasError) {
return (
<div className="flex flex-col items-center justify-center h-full gap-4 p-8">
<AlertTriangle className="w-12 h-12 text-destructive/50" />
<p className="text-sm text-muted-foreground text-center max-w-[320px]">
</p>
<p className="text-[11px] text-muted-foreground/50 font-mono text-center max-w-[400px] break-all">
{this.state.errorMsg}
</p>
<Button
size="sm"
variant="flat"
onPress={() => this.setState({ hasError: false, errorMsg: "" })}
>
</Button>
</div>
);
}
return this.props.children;
}
}
+75
View File
@@ -0,0 +1,75 @@
import { cn } from "@/lib/utils"
interface SurfaceProps extends React.HTMLAttributes<HTMLDivElement> {
variant?: "raised" | "flat" | "bordered"
frost?: "none" | "sm" | "md" | "lg"
padding?: "none" | "sm" | "md" | "lg"
}
const frostMap = {
none: "",
sm: "backdrop-blur-[6px]",
md: "backdrop-blur-[12px]",
lg: "backdrop-blur-[20px]",
}
const paddingMap = {
none: "",
sm: "px-3 py-2.5",
md: "px-5 py-4",
lg: "px-6 py-5",
}
function Surface({
variant = "raised",
frost = "none",
padding = "md",
className,
children,
...props
}: SurfaceProps) {
return (
<div
data-slot="surface"
className={cn(
"rounded-xl transition-colors duration-200",
"bg-white/85 dark:bg-black/45",
"border border-black/[0.06] dark:border-white/[0.07]",
frostMap[frost],
paddingMap[padding],
variant === "raised" && "shadow-sm",
variant === "bordered" && "shadow-none",
variant === "flat" && "shadow-none bg-transparent border-0",
className,
)}
{...props}
>
{children}
</div>
)
}
function SurfaceHeader({ className, children, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
data-slot="surface-header"
className={cn(
"flex items-center gap-2 mb-3 pb-3 border-b border-black/[0.06] dark:border-white/[0.06]",
className,
)}
{...props}
>
{children}
</div>
)
}
function SurfaceContent({ className, children, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return (
<div data-slot="surface-content" className={cn("space-y-3", className)} {...props}>
{children}
</div>
)
}
export { Surface, SurfaceHeader, SurfaceContent }
+2
View File
@@ -1,3 +1,5 @@
export { SettingCard } from "./SettingCard";
export { SettingRow } from "./SettingRow";
export { PageHeader, SectionTitle } from "./SectionTitle";
export { Surface, SurfaceHeader, SurfaceContent } from "./Surface";
export { Skeleton } from "@/components/ui/skeleton";
+2 -2
View File
@@ -126,8 +126,8 @@ const Silk = ({ speed = 5, scale = 1, color = "#7B7481", noiseIntensity = 1.5, r
<Canvas
dpr={[1, 1.5]}
frameloop="demand"
style={{ position: "absolute", inset: 0, width: "100%", height: "100%", background: "black" }}
gl={{ antialias: false, alpha: false }}
style={{ position: "absolute", inset: 0, width: "100%", height: "100%" }}
gl={{ antialias: false, alpha: true, premultipliedAlpha: true }}
>
<SilkPlane ref={meshRef} uniforms={uniforms} />
</Canvas>
+17
View File
@@ -0,0 +1,17 @@
import { cn } from "@/lib/utils"
function Skeleton({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
data-slot="skeleton"
className={cn(
"animate-pulse rounded-md bg-muted",
"dark:bg-muted/50",
className,
)}
{...props}
/>
)
}
export { Skeleton }
+189
View File
@@ -0,0 +1,189 @@
import { useState, useEffect } from "react";
import { Button, Input, RadioGroup, Radio, Slider } from "@heroui/react";
import { X, Loader2, Plus } from "lucide-react";
import { getMinecraftVersionList, getFabricVersionList, getForgeVersionList, getQuiltVersionList, type InstanceRuntime } from "@/api/instance";
interface CreateDialogProps {
isOpen: boolean;
onClose: () => void;
onCreate: (name: string, runtime: InstanceRuntime, displayName?: string) => Promise<void>;
}
const loaderOptions = [
{ value: "vanilla", label: "原版 (Vanilla)", desc: "纯净 Minecraft,无模组加载器" },
{ value: "forge", label: "Forge", desc: "最经典的模组加载器,模组生态最丰富" },
{ value: "fabric", label: "Fabric", desc: "轻量快速,适合性能优化与辅助模组" },
{ value: "quilt", label: "Quilt", desc: "Fabric 的分支,实验性功能更多" },
{ value: "neoforged", label: "NeoForge", desc: "Forge 的现代化分支,1.20+ 推荐" },
];
export function CreateDialog({ isOpen, onClose, onCreate }: CreateDialogProps) {
const [step, setStep] = useState(0);
const [name, setName] = useState("");
const [displayName, setDisplayName] = useState("");
const [loader, setLoader] = useState("vanilla");
const [mcVersion, setMcVersion] = useState("");
const [memory, setMemory] = useState(4);
const [creating, setCreating] = useState(false);
const [mcVersions, setMcVersions] = useState<string[]>([]);
const [loadingVersions, setLoadingVersions] = useState(false);
const [error, setError] = useState("");
useEffect(() => {
if (isOpen) { loadMcVersions(); } else { resetForm(); }
}, [isOpen]);
const resetForm = () => {
setStep(0); setName(""); setDisplayName("");
setLoader("vanilla"); setMcVersion(""); setMemory(4);
setError(""); setCreating(false);
};
const loadMcVersions = async () => {
setLoadingVersions(true);
try {
const list = await getMinecraftVersionList();
const releases = list.filter((v: string) => /^1\.\d+/.test(v)).slice(0, 24);
setMcVersions(releases.length ? releases : ["1.21.4","1.21","1.20.6","1.20.4","1.20.1","1.19.4","1.19.2","1.18.2","1.17.1","1.16.5"]);
if (!mcVersion && releases.length) setMcVersion(releases[0]);
} catch { setMcVersions(["1.21.4","1.21","1.20.1","1.19.2","1.18.2","1.16.5"]); if (!mcVersion) setMcVersion("1.21.4"); }
setLoadingVersions(false);
};
const handleCreate = async () => {
if (!name.trim()) { setError("请输入实例名称"); return; }
setCreating(true);
try {
const runtime: any = { minecraft: mcVersion };
if (loader === "forge") runtime.forge = "latest";
else if (loader === "fabric") runtime.fabricLoader = "latest";
else if (loader === "quilt") runtime.quiltLoader = "latest";
else if (loader === "neoforged") runtime.neoForged = "latest";
await onCreate(name.trim(), runtime, displayName.trim() || undefined);
onClose();
} catch (e: any) { setError(e.message || "创建失败"); }
setCreating(false);
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/40 backdrop-blur-sm" onClick={onClose} />
<div className="relative z-10 w-full max-w-lg mx-4 bg-background border border-border/50 dark:border-white/[0.08] rounded-2xl shadow-2xl overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-border/30 dark:border-white/[0.04]">
<div className="flex items-center gap-2">
<Plus className="w-5 h-5 text-primary" />
<h2 className="text-base font-semibold text-foreground"></h2>
</div>
<button onClick={onClose} className="p-1 rounded-md hover:bg-foreground/[0.06] transition-colors">
<X className="w-4 h-4 text-muted-foreground" />
</button>
</div>
{/* Body */}
<div className="px-6 py-5 space-y-5 max-h-[60vh] overflow-y-auto scroll-area">
{/* 步骤指示器 */}
<div className="flex items-center gap-2">
{["基本信息", "版本选择", "内存设置"].map((label, i) => (
<div key={i} className="flex items-center gap-2">
<div className={`w-6 h-6 rounded-full flex items-center justify-center text-xs font-medium shrink-0 transition-colors ${
i === step ? "bg-primary text-primary-foreground" :
i < step ? "bg-primary/20 text-primary" :
"bg-foreground/[0.06] dark:bg-white/[0.06] text-muted-foreground"
}`}>{i < step ? "✓" : i + 1}</div>
<span className={`text-[12px] ${i === step ? "text-foreground font-medium" : "text-muted-foreground"}`}>{label}</span>
{i < 2 && <div className="w-5 h-px bg-border/40" />}
</div>
))}
</div>
{step === 0 && (
<div className="space-y-4">
<Input label="实例名称 (英文ID)" placeholder="例如: survival-world"
value={name} onChange={(e) => setName(e.target.value.replace(/\s/g, "-").toLowerCase())} autoFocus />
<Input label="显示名称 (可选)" placeholder="例如: 生存世界"
value={displayName} onChange={(e) => setDisplayName(e.target.value)} />
</div>
)}
{step === 1 && (
<div className="space-y-4">
<div>
<p className="text-sm font-medium text-foreground mb-2">Minecraft </p>
{loadingVersions ? (
<div className="flex items-center gap-2 py-4"><Loader2 className="w-4 h-4 animate-spin text-muted-foreground" /><span className="text-sm text-muted-foreground">...</span></div>
) : (
<div className="grid grid-cols-4 gap-2">
{mcVersions.map((v) => (
<button key={v} onClick={() => setMcVersion(v)}
className={`px-3 py-2 rounded-lg text-[13px] border transition-all ${
mcVersion === v
? "border-primary bg-primary/10 text-primary font-medium"
: "border-border/40 dark:border-white/[0.06] text-muted-foreground hover:border-primary/30 hover:text-foreground"
}`}>{v}</button>
))}
</div>
)}
</div>
<div>
<p className="text-sm font-medium text-foreground mb-2"></p>
<RadioGroup value={loader} onValueChange={setLoader}>
{loaderOptions.map((opt) => (
<Radio key={opt.value} value={opt.value} className="py-1.5">
<Radio.Content>
<div><p className="text-sm font-medium">{opt.label}</p><p className="text-[11px] text-muted-foreground">{opt.desc}</p></div>
</Radio.Content>
</Radio>
))}
</RadioGroup>
</div>
</div>
)}
{step === 2 && (
<div className="space-y-4">
<div>
<div className="flex items-center justify-between mb-2">
<p className="text-sm font-medium text-foreground"></p>
<span className="text-sm font-semibold text-foreground">{memory} GB</span>
</div>
<Slider value={memory} onChange={(v) => setMemory(typeof v === "number" ? v : v[0])} minValue={1} maxValue={16} step={1}>
<Slider.Track><Slider.Fill /><Slider.Thumb /></Slider.Track>
</Slider>
</div>
<div className="p-3 rounded-lg bg-foreground/[0.03] dark:bg-white/[0.03] border border-border/30 dark:border-white/[0.04] space-y-1">
<p className="text-[12px] font-medium text-foreground"></p>
<p className="text-[13px] text-muted-foreground">: {displayName || name}</p>
<p className="text-[13px] text-muted-foreground">版本: Minecraft {mcVersion} ({loaderOptions.find(o=>o.value===loader)?.label})</p>
<p className="text-[13px] text-muted-foreground">: {memory} GB</p>
</div>
</div>
)}
{error && <p className="text-[12px] text-destructive">{error}</p>}
</div>
{/* Footer */}
<div className="flex items-center justify-between px-6 py-4 border-t border-border/30 dark:border-white/[0.04]">
<div>
{step > 0 && <Button variant="light" onPress={() => setStep(step - 1)}></Button>}
</div>
<div className="flex items-center gap-2">
<Button variant="light" onPress={onClose}></Button>
{step < 2 ? (
<Button onPress={() => setStep(step + 1)} isDisabled={step === 0 ? !name.trim() : !mcVersion}></Button>
) : (
<Button onPress={handleCreate} isDisabled={creating || !name.trim()}
startContent={creating ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : undefined}>
{creating ? "创建中..." : "创建实例"}
</Button>
)}
</div>
</div>
</div>
</div>
);
}
+154
View File
@@ -0,0 +1,154 @@
import { Button } from "@heroui/react";
import {
Play,
Settings,
Clock,
Gamepad2,
} from "lucide-react";
import clsx from "clsx";
import type { InstanceInfo } from "@/api/instance";
function getLoader(runtime: InstanceInfo["config"]["runtime"]): string {
if (runtime.forge) return "forge";
if (runtime.fabricLoader) return "fabric";
if (runtime.quiltLoader) return "quilt";
if (runtime.neoForged) return "neoforged";
return "vanilla";
}
function getLoaderVersion(runtime: InstanceInfo["config"]["runtime"]): string {
return runtime.forge || runtime.fabricLoader || runtime.quiltLoader || runtime.neoForged || "";
}
const loaderBadgeColors: Record<string, string> = {
vanilla: "bg-green-500/10 text-green-600 dark:bg-green-500/15 dark:text-green-400 border-green-500/20",
forge: "bg-orange-500/10 text-orange-600 dark:bg-orange-500/15 dark:text-orange-400 border-orange-500/20",
fabric: "bg-sky-500/10 text-sky-600 dark:bg-sky-500/15 dark:text-sky-400 border-sky-500/20",
quilt: "bg-indigo-500/10 text-indigo-600 dark:bg-indigo-500/15 dark:text-indigo-400 border-indigo-500/20",
neoforged: "bg-red-500/10 text-red-600 dark:bg-red-500/15 dark:text-red-400 border-red-500/20",
};
const loaderLabels: Record<string, string> = {
vanilla: "原版",
forge: "Forge",
fabric: "Fabric",
quilt: "Quilt",
neoforged: "NeoForge",
};
const loaderColors: Record<string, string> = {
vanilla: "bg-green-400",
forge: "bg-orange-400",
fabric: "bg-sky-400",
quilt: "bg-indigo-400",
neoforged: "bg-red-400",
};
function formatPlaytime(ms: number): string {
if (!ms || ms < 60000) return "";
const hours = Math.floor(ms / 3600000);
const mins = Math.floor((ms % 3600000) / 60000);
if (hours > 0) return `${hours}h ${mins}m`;
return `${mins}m`;
}
function formatLastPlayed(ts: number): string {
if (!ts) return "";
const diff = Date.now() - ts;
if (diff < 3600000) return "刚刚";
if (diff < 86400000) return `${Math.floor(diff / 3600000)}小时前`;
if (diff < 604800000) return `${Math.floor(diff / 86400000)}天前`;
return new Date(ts).toLocaleDateString("zh-CN");
}
interface InstanceCardProps {
instance: InstanceInfo;
displayName?: string;
onPlay: (name: string) => void;
onSettings: (name: string) => void;
isLaunching: boolean;
}
export function InstanceCard({ instance, displayName, onPlay, onSettings, isLaunching }: InstanceCardProps) {
const { name, config, modCount, healthy } = instance;
const loader = getLoader(config.runtime);
const version = config.runtime.minecraft || "未知";
const loaderLabel = loaderLabels[loader] ?? loader;
const badgeCls = loaderBadgeColors[loader] ?? loaderBadgeColors.vanilla;
const topColor = loaderColors[loader] ?? loaderColors.vanilla;
const title = displayName || config.name || name;
return (
<div
className={clsx(
"relative group rounded-xl transition-all duration-200",
"bg-white/85 dark:bg-black/45",
"border border-black/[0.06] dark:border-white/[0.07]",
"hover:shadow-md hover:border-primary/20 dark:hover:border-primary/20",
"overflow-hidden",
)}
>
<div className={clsx("h-1.5 w-full", topColor)} />
<div className="px-5 py-4 space-y-3">
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<p className="text-sm font-semibold text-foreground truncate">{title}</p>
<p className="text-[10.5px] text-muted-foreground/60 mt-0.5 truncate">{name}</p>
</div>
{healthy === false && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-destructive/10 text-destructive font-medium shrink-0">
</span>
)}
<Button
size="sm"
variant="light"
className="min-w-0 w-7 h-7 p-0 opacity-0 group-hover:opacity-100 transition-opacity shrink-0"
onPress={() => onSettings(name)}
>
<Settings className="w-3.5 h-3.5" />
</Button>
</div>
<div className="flex items-center gap-1.5 flex-wrap">
<span className={clsx("text-[10.5px] px-2 py-0.5 rounded-md border font-medium", badgeCls)}>
{loaderLabel}
</span>
<span className="text-[10.5px] px-2 py-0.5 rounded-md bg-foreground/[0.05] dark:bg-white/[0.05] text-muted-foreground border border-border/30 dark:border-white/[0.05]">
{version}
</span>
{modCount > 0 && (
<span className="text-[10.5px] px-2 py-0.5 rounded-md bg-purple-500/10 text-purple-600 dark:bg-purple-500/15 dark:text-purple-400 border border-purple-500/20">
{modCount}
</span>
)}
</div>
<div className="flex items-center gap-3 text-[11px] text-muted-foreground/60">
{config.lastPlayedDate ? (
<span className="flex items-center gap-1">
<Clock className="w-3 h-3" />{formatLastPlayed(config.lastPlayedDate)}
</span>
) : null}
{config.playtime > 0 && (
<span className="flex items-center gap-1">
<Gamepad2 className="w-3 h-3" />{formatPlaytime(config.playtime)}
</span>
)}
</div>
<Button
size="sm"
className="w-full"
variant="flat"
startContent={<Play className="w-3.5 h-3.5" />}
onPress={() => onPlay(name)}
isDisabled={isLaunching}
>
</Button>
</div>
</div>
);
}
+162 -3
View File
@@ -1,8 +1,167 @@
import { useEffect, useState, useCallback } from "react";
import { Button } from "@heroui/react";
import { Plus, Gamepad2, RefreshCw } from "lucide-react";
import { useInstanceStore } from "@/stores/instanceStore";
import { useConfigStore } from "@/stores/configStore";
import { Skeleton } from "@/components/ui/skeleton";
import { Surface } from "@/components/setting/Surface";
import { InstanceCard } from "./InstanceCard";
import { CreateDialog } from "./CreateDialog";
import type { InstanceRuntime } from "@/api/instance";
export function Gallery() {
const { instances, loading, fetchInstances, create, launch } = useInstanceStore();
const gameConfig = useConfigStore((s) => s.config.game);
const configInstances = useConfigStore((s) => s.config.instances);
const setInstances = useConfigStore((s) => s.setInstances);
const [createOpen, setCreateOpen] = useState(false);
const [launching, setLaunching] = useState<string | null>(null);
const loadInstances = useCallback(async () => {
await fetchInstances(gameConfig.gameDir);
}, [fetchInstances, gameConfig.gameDir]);
useEffect(() => {
loadInstances();
}, [loadInstances]);
const handleCreate = async (name: string, runtime: InstanceRuntime, displayName?: string) => {
await create(name, gameConfig.gameDir, runtime, {
description: displayName,
});
// Sync to Koring.yml
const loader = runtime.forge ? "forge"
: runtime.fabricLoader ? "fabric"
: runtime.quiltLoader ? "quilt"
: runtime.neoForged ? "neoforged"
: "vanilla";
const loaderVersion = runtime.forge || runtime.fabricLoader || runtime.quiltLoader || runtime.neoForged || "";
const updated = configInstances.filter((m) => m.name !== name);
updated.push({
name,
displayName: displayName || name,
icon: "",
gameVersion: runtime.minecraft,
loader,
loaderVersion,
createdAt: Date.now(),
lastPlayed: 0,
playtime: 0,
});
setInstances(updated);
setCreateOpen(false);
};
const handlePlay = async (name: string) => {
setLaunching(name);
try {
await launch(name, gameConfig.gameDir);
} catch {
// Launch events handled via IPC listeners
}
// Update lastPlayed in config
const idx = configInstances.findIndex((m) => m.name === name);
if (idx >= 0) {
const updated = [...configInstances];
updated[idx] = { ...updated[idx], lastPlayed: Date.now() };
setInstances(updated);
}
setLaunching(null);
};
const handleSettings = (name: string) => {
// TODO: Navigate to instance settings
console.log("Settings for:", name);
};
const getDisplayName = (name: string) => {
return configInstances.find((m) => m.name === name)?.displayName;
};
return (
<div className="p-6">
<h1 className="text-xl font-bold text-foreground mb-2"></h1>
<p className="text-sm text-muted-foreground"></p>
<div className="p-6 h-full overflow-y-auto scroll-area">
{/* 头部 */}
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-xl font-bold text-foreground"></h1>
<p className="text-[13px] text-muted-foreground mt-1">
Minecraft
</p>
</div>
<div className="flex items-center gap-2">
<Button size="sm" variant="light" onPress={loadInstances} isDisabled={loading}>
<RefreshCw className={`w-4 h-4 ${loading ? "animate-spin" : ""}`} />
</Button>
<Button size="sm" startContent={<Plus className="w-4 h-4" />} onPress={() => setCreateOpen(true)}>
</Button>
</div>
</div>
{/* 加载骨架 */}
{loading && instances.length === 0 && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{Array.from({ length: 6 }).map((_, i) => (
<Surface key={i} padding="md">
<div className="space-y-3">
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-3 w-1/2" />
<div className="flex gap-2">
<Skeleton className="h-5 w-14 rounded-md" />
<Skeleton className="h-5 w-20 rounded-md" />
</div>
<Skeleton className="h-8 w-full rounded-lg" />
</div>
</Surface>
))}
</div>
)}
{/* 空状态 */}
{!loading && instances.length === 0 && (
<Surface variant="flat" padding="lg">
<div className="flex flex-col items-center gap-4 py-16">
<div className="w-16 h-16 rounded-full bg-foreground/[0.04] dark:bg-white/[0.04] flex items-center justify-center">
<Gamepad2 className="w-8 h-8 text-muted-foreground/30" />
</div>
<div className="text-center">
<p className="text-sm text-muted-foreground"></p>
<p className="text-[12px] text-muted-foreground/60 mt-1 max-w-[300px]">
"新建实例" Minecraft
</p>
</div>
<Button size="sm" startContent={<Plus className="w-4 h-4" />} onPress={() => setCreateOpen(true)}>
</Button>
</div>
</Surface>
)}
{/* 实例网格 */}
{instances.length > 0 && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{instances.map((inst) => (
<InstanceCard
key={inst.name}
instance={inst}
displayName={getDisplayName(inst.name)}
onPlay={handlePlay}
onSettings={handleSettings}
isLaunching={launching === inst.name}
/>
))}
</div>
)}
<CreateDialog
isOpen={createOpen}
onClose={() => setCreateOpen(false)}
onCreate={handleCreate}
/>
</div>
);
}
+23 -66
View File
@@ -1,6 +1,3 @@
import { useKoringAuthStore } from "@/stores/koringAuthStore";
import { BUILD_MODE } from "@/lib/mode";
import { useUpdateStore } from "@/stores/updateStore";
import {
UserCircle,
Gamepad2,
@@ -10,10 +7,9 @@ import {
Cpu,
Info,
ChevronRight,
Search,
} from "lucide-react";
import { Avatar } from "@heroui/react";
import { SettingCard, PageHeader, SectionTitle } from "@/components/setting";
import { VersionCard } from "@/components/VersionCard";
import { Surface, PageHeader, SectionTitle } from "@/components/setting";
interface ShortcutItem {
icon: React.ReactNode;
@@ -26,34 +22,28 @@ function ShortcutTile({ icon, label, desc, navKey, onClick }: ShortcutItem & { o
return (
<button
onClick={() => onClick(navKey)}
className="glass-card flex items-center gap-3.5 px-4 py-3.5 text-left transition-all duration-150 hover:bg-foreground/[0.04] group"
className="w-full flex items-center gap-3 px-3.5 py-3 text-left rounded-lg transition-colors duration-150 hover:bg-foreground/5 dark:hover:bg-white/5 group"
>
<span className="flex items-center justify-center w-9 h-9 rounded-lg bg-foreground/[0.06] text-foreground/60 group-hover:text-foreground/80 shrink-0">
<span className="flex items-center justify-center w-8 h-8 rounded-md bg-foreground/[0.06] dark:bg-white/[0.08] text-foreground/55 group-hover:text-foreground/80 shrink-0 transition-colors">
{icon}
</span>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-foreground truncate">{label}</p>
<p className="text-[12px] text-muted-foreground truncate">{desc}</p>
<p className="text-[13px] font-medium text-foreground truncate">{label}</p>
<p className="text-[11.5px] text-muted-foreground truncate mt-0.5">{desc}</p>
</div>
<ChevronRight className="w-4 h-4 text-foreground/25 shrink-0" />
<ChevronRight className="w-4 h-4 text-foreground/20 shrink-0" />
</button>
);
}
const modeLabels: Record<string, string> = {
dev: "开发版",
beta: "测试版",
run: "正式版",
};
const shortcuts: ShortcutItem[] = [
{ icon: <UserCircle className="w-[18px] h-[18px]" />, label: "Koring 账户", desc: "同步数据、皮肤与个人配置", navKey: "account" },
{ icon: <Gamepad2 className="w-[18px] h-[18px]" />, label: "游戏账户与档案", desc: "管理游戏内账户和档案配置", navKey: "game-account" },
{ icon: <Palette className="w-[18px] h-[18px]" />, label: "主题与背景", desc: "深色模式、背景图片与视差", navKey: "theme-bg" },
{ icon: <Download className="w-[18px] h-[18px]" />, label: "下载设置", desc: "下载线程数与存储路径", navKey: "download" },
{ icon: <Globe className="w-[18px] h-[18px]" />, label: "联机功能", desc: "以太联机与陶瓦联机", navKey: "ether-online" },
{ icon: <Cpu className="w-[18px] h-[18px]" />, label: "Java 虚拟机与内存", desc: "Java 路径与内存分配", navKey: "java-mem" },
{ icon: <Info className="w-[18px] h-[18px]" />, label: "关于 Koring Launcher", desc: "版本信息与更新", navKey: "about" },
{ icon: <UserCircle className="w-4 h-4" />, label: "Koring 账户", desc: "同步数据、皮肤与个人配置", navKey: "account" },
{ icon: <Gamepad2 className="w-4 h-4" />, label: "游戏账户与档案", desc: "管理游戏内账户和档案配置", navKey: "game-account" },
{ icon: <Palette className="w-4 h-4" />, label: "主题与背景", desc: "深色模式、背景图片与视差", navKey: "theme-bg" },
{ icon: <Download className="w-4 h-4" />, label: "下载设置", desc: "下载线程数与存储路径", navKey: "download" },
{ icon: <Globe className="w-4 h-4" />, label: "联机功能", desc: "以太联机与陶瓦联机", navKey: "ether-online" },
{ icon: <Cpu className="w-4 h-4" />, label: "Java 虚拟机与内存", desc: "Java 路径与内存分配", navKey: "java-mem" },
{ icon: <Info className="w-4 h-4" />, label: "关于 Koring Launcher", desc: "版本信息与更新", navKey: "about" },
];
interface HomeSettingProps {
@@ -61,55 +51,22 @@ interface HomeSettingProps {
}
export function HomeSetting({ onNavigate }: HomeSettingProps) {
const user = useKoringAuthStore((s) => s.user);
const { update } = useUpdateStore();
return (
<div>
<PageHeader title="主页" desc="自定义启动器主页的显示内容与常用设置的快捷入口" />
<PageHeader title="主页" desc="版本信息与常用设置的快捷入口" />
<div className="space-y-6">
<SettingCard className="flex items-center gap-3 px-4 py-3">
<Search className="w-4 h-4 text-muted-foreground shrink-0" />
<span className="text-sm text-muted-foreground"></span>
</SettingCard>
<div>
<SectionTitle></SectionTitle>
<SettingCard>
<div className="flex items-center gap-4">
<Avatar size="lg">
{user?.picture ? (
<Avatar.Image src={user.picture} alt="" />
) : (
<Avatar.Fallback>
<UserCircle className="w-7 h-7 text-foreground/40" />
</Avatar.Fallback>
)}
</Avatar>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-foreground truncate">
{user ? (user.name || user.username) : "未登录"}
</p>
<p className="text-[12px] text-muted-foreground mt-0.5">
{user ? "Koring 账户" : "登录以同步数据和皮肤"}
</p>
</div>
<span className="text-[11px] text-muted-foreground shrink-0">
{modeLabels[BUILD_MODE] ?? BUILD_MODE}
{update ? " · 有更新" : ""}
</span>
</div>
</SettingCard>
</div>
<VersionCard simple />
<div>
<SectionTitle></SectionTitle>
<div className="grid grid-cols-1 gap-2">
{shortcuts.map((s) => (
<ShortcutTile key={s.navKey} {...s} onClick={onNavigate} />
))}
</div>
<Surface padding="sm">
<div className="divide-y divide-border/30 dark:divide-white/[0.04]">
{shortcuts.map((s) => (
<ShortcutTile key={s.navKey} {...s} onClick={onNavigate} />
))}
</div>
</Surface>
</div>
</div>
</div>
+10
View File
@@ -11,6 +11,7 @@ import {
type AdvancedConfig,
type DownloadConfig,
type NetworkConfig,
type InstanceMeta,
} from "@/api/config";
import { DEFAULT_BG } from "@/lib/mode";
@@ -39,6 +40,7 @@ interface ConfigState {
setAdvanced: (patch: Partial<AdvancedConfig>) => void;
setDownload: (patch: Partial<DownloadConfig>) => void;
setNetwork: (patch: Partial<NetworkConfig>) => void;
setInstances: (instances: InstanceMeta[]) => void;
setOobe: (value: boolean) => void;
}
@@ -53,6 +55,7 @@ const DEFAULT_CONFIG: AppConfig = {
advanced: { afterLaunch: "close", winMode: "default", customWidth: 854, customHeight: 480, gameArgs: "", preLaunchCmd: "", debugMode: false },
download: { fileSource: "mirror", versionSource: "mirror", threads: 16, speedLimit: 0 },
network: { securityId: { enabled: false, authUrl: "" } },
instances: [],
};
export const useConfigStore = create<ConfigState>((set, get) => ({
@@ -132,6 +135,13 @@ export const useConfigStore = create<ConfigState>((set, get) => ({
debouncedSave(next);
},
setInstances: (instances) => {
const { config } = get();
const next = { ...config, instances };
set({ config: next });
debouncedSave(next);
},
setOobe: (value) => {
const { config } = get();
const next = { ...config, oobe: value };
+3
View File
@@ -38,5 +38,8 @@ export default defineConfig(async () => ({
server: {
port: 1420,
strictPort: true,
watch: {
ignored: ["**/Koring.yml", "**/koring-auth.json"],
},
},
}));