feat: 发布 v1.2.0 版本,新增多项核心功能

- 引入 electron-updater 实现自动更新功能
- 新增 Java 环境扫描与校验的 IPC 处理逻辑
- 实现离线账号登录功能
- 新增配置变更跨进程广播机制
- 重构游戏启动逻辑,使用主进程内存配置作为唯一权威来源
- 新增界面显示与语言设置的配置页面
- 添加 Windows 平台自动发布 CI 流水线
- 迁移旧版配置/认证文件到用户数据目录
- 修复崩溃日志路径、表单控件等多项 bug
- 重构设置页组件系统统一界面样式
This commit is contained in:
2026-08-28 02:08:12 +08:00
parent 1cf906d0db
commit 3653e26a09
60 changed files with 2908 additions and 961 deletions
+17 -2
View File
@@ -70,14 +70,17 @@ function App() {
// Listen for preloaded config from main process
const unsub = window.electronAPI?.onConfigPreload((data) => {
const { config, isFirstLaunch } = data;
useConfigStore.getState().applyPreloaded(config as AppConfig, isFirstLaunch);
const cfg = config as AppConfig;
useConfigStore.getState().applyPreloaded(cfg, isFirstLaunch);
// 语言偏好 → <html lang>
document.documentElement.lang = (cfg as AppConfig).app?.language ?? "zh-CN";
syncThemeFromConfig();
syncA11yFromConfig();
syncBackgroundFromConfig();
useAuthStore.getState().initFromRegistry();
useKoringAuthStore.getState().initFromDisk();
// Navigate to OOBE on first launch or if oobe not completed
if (isFirstLaunch || config.oobe) {
if (isFirstLaunch || cfg.oobe) {
useRouteStore.getState().navigate("oobe");
}
});
@@ -85,6 +88,18 @@ function App() {
return () => { unsub?.(); };
}, []);
// 主进程权威配置广播 → 覆盖本地镜像并同步派生 store
useEffect(() => {
const unsub = window.electronAPI?.onConfigChanged((config) => {
useConfigStore.getState().applyChanged(config as AppConfig);
syncThemeFromConfig();
syncA11yFromConfig();
syncBackgroundFromConfig();
});
return () => { unsub?.(); };
}, []);
return (
<RootLayout>
<Page key={current} />
+36 -7
View File
@@ -1,4 +1,4 @@
import { ipcInvoke } from './ipc';
import { ipcInvoke, onIpcEvent } from './ipc';
export interface ThemeConfig {
darkMode: string;
@@ -36,6 +36,11 @@ export interface JavaConfig {
jvmArgs: string;
}
export interface ServerConfig {
ip: string;
port: number;
}
export interface AdvancedConfig {
afterLaunch: string;
winMode: string;
@@ -44,6 +49,20 @@ export interface AdvancedConfig {
gameArgs: string;
preLaunchCmd: string;
debugMode: boolean;
/** 快速进入服务器(启动后自动加入;ip 为空则不自动加入) */
server: ServerConfig;
}
export interface AppInfoConfig {
/** 界面语言偏好(zh-CN | en-US);语言包开发中,暂仅保存并设置 <html lang> */
language: string;
}
export interface UiConfig {
/** 首页实例标题显示 */
showInstanceTitle: boolean;
/** 标题栏任务队列按钮显示 */
showTaskButton: boolean;
}
export interface DownloadConfig {
@@ -77,6 +96,7 @@ export interface InstanceMeta {
export interface AppConfig {
version: number;
oobe: boolean;
app: AppInfoConfig;
theme: ThemeConfig;
a11y: A11yConfig;
background: BackgroundConfig;
@@ -85,15 +105,10 @@ export interface AppConfig {
advanced: AdvancedConfig;
download: DownloadConfig;
network: NetworkConfig;
ui: UiConfig;
instances: InstanceMeta[];
}
interface CommandResult {
success: boolean;
data: unknown;
error: string | null;
}
export async function getConfig(): Promise<AppConfig> {
const result = await ipcInvoke<AppConfig>('config:get');
return result;
@@ -102,3 +117,17 @@ export async function getConfig(): Promise<AppConfig> {
export async function saveConfig(config: AppConfig): Promise<void> {
await ipcInvoke('config:save', config);
}
/**
* 主进程权威更新配置:提交 { section, patch } 补丁,
* 主进程深度合并、debounce 稀疏写盘并广播 config:changed。
* 返回合并后的完整配置。
*/
export async function updateConfig(section: string, patch: unknown): Promise<AppConfig> {
return ipcInvoke<AppConfig>('config:update', { section, patch });
}
/** 监听主进程广播的配置变更(完整配置) */
export function onConfigChanged(callback: (config: AppConfig) => void): () => void {
return onIpcEvent<AppConfig>('config:changed', callback);
}
+2 -26
View File
@@ -97,20 +97,6 @@ export async function installInstance(
return ipcInvoke<{ requestId: string }>('instance:install', { name, gamePath });
}
export async function launchInstance(
name: string,
gamePath: string,
options: {
username: string;
uuid: string;
accessToken?: string;
javaPath?: string;
server?: { host: string; port?: number };
}
): Promise<{ requestId: string }> {
return ipcInvoke<{ requestId: string }>('instance:launch', { name, gamePath, ...options });
}
export async function diagnoseInstance(
name: string,
gamePath: string
@@ -162,6 +148,8 @@ export async function importExistingInstance(
java?: string;
minMemory?: number;
maxMemory?: number;
/** 版本文件来源目录(默认 = gamePath */
sourceGamePath?: string;
}
): Promise<InstanceInfo> {
return ipcInvoke<InstanceInfo>('instance:import', {
@@ -201,15 +189,3 @@ export function onInstallComplete(callback: (data: { requestId: string; data: In
export function onInstallError(callback: (data: { requestId: string; error: string }) => void) {
return onIpcEvent('instance:install-error', callback);
}
export function onLaunchEvent(callback: (data: { requestId: string; event: string; [key: string]: unknown }) => void) {
return onIpcEvent('instance:launch-event', callback);
}
export function onLaunchComplete(callback: (data: { requestId: string; data: { pid: number; version: string; username: string } }) => void) {
return onIpcEvent('instance:launch-complete', callback);
}
export function onLaunchError(callback: (data: { requestId: string; error: string }) => void) {
return onIpcEvent('instance:launch-error', callback);
}
+19
View File
@@ -0,0 +1,19 @@
import { ipcInvoke } from './ipc';
export interface JavaInfo {
path: string;
version: string;
majorVersion: number;
}
/** 扫描系统已安装的 JavaJAVA_HOME / PATH / 常见安装目录) */
export async function scanJava(): Promise<JavaInfo[]> {
const data = await ipcInvoke<{ javaList: JavaInfo[] }>('java:scan');
return data?.javaList ?? [];
}
/** 校验指定路径是否为可用的 Java 可执行文件 */
export async function resolveJava(path: string): Promise<JavaInfo | null> {
const data = await ipcInvoke<{ java: JavaInfo | null }>('java:resolve', { path });
return data?.java ?? null;
}
+24 -11
View File
@@ -1,17 +1,25 @@
import { ipcInvoke, onIpcEvent } from './ipc';
export interface LaunchOptions {
gamePath: string;
javaPath: string;
version: string;
/** 启动所需的账户档案(来自 authStore) */
export interface LaunchProfile {
username: string;
uuid: string;
accessToken?: string;
memory?: { min?: string; max?: string };
jvmArgs?: string[];
gameArgs?: string[];
server?: { ip: string; port?: number };
detached?: boolean;
}
/** 快速联机目标服务器 */
export interface LaunchServer {
ip: string;
port?: number;
}
/** 统一启动接口契约:指定实例 + 游戏根目录 + 账户档案(可选快速联机) */
export interface LaunchGamePayload {
instanceName: string;
/** 实例父目录(游戏根目录) */
gamePath: string;
profile: LaunchProfile;
server?: LaunchServer;
}
export interface LaunchResult {
@@ -21,8 +29,12 @@ export interface LaunchResult {
requestId: string;
}
export async function launchGame(options: LaunchOptions): Promise<LaunchResult> {
return ipcInvoke<LaunchResult>('launch:launch', options);
/**
* 启动游戏。主进程会读取权威配置(Koring.yml 内存缓存)自动应用
* Java 路径 / 内存 / GC / JVM 参数 / 游戏参数 / 窗口模式 / 启动前命令等设置。
*/
export async function launchGame(payload: LaunchGamePayload): Promise<LaunchResult> {
return ipcInvoke<LaunchResult>('launch:launch', payload);
}
export async function diagnoseVersion(
@@ -32,6 +44,7 @@ export async function diagnoseVersion(
return ipcInvoke('launch:diagnose', { gamePath, version });
}
/** 订阅某个启动请求的游戏事件流(stdout / stderr / window-ready / exit */
export function onGameEvent(
requestId: string,
callback: (event: { event: string; [key: string]: unknown }) => void
+1 -1
View File
@@ -25,7 +25,7 @@ export class ErrorBoundary extends Component<Props, State> {
</p>
<Button
size="sm"
variant="flat"
variant="outline"
onPress={() => this.setState({ hasError: false, errorMsg: "" })}
>
+13 -3
View File
@@ -1,12 +1,22 @@
import { Typography } from "@heroui/react";
export function PageHeader({ title, desc }: { title: string; desc: string }) {
return (
<>
<h2 className="text-xl font-bold text-foreground mb-1">{title}</h2>
<p className="text-sm text-muted-foreground mb-6">{desc}</p>
<Typography.Heading level={2} className="text-xl font-bold text-foreground mb-1">
{title}
</Typography.Heading>
<Typography.Paragraph size="sm" className="text-sm text-muted-foreground mb-6">
{desc}
</Typography.Paragraph>
</>
);
}
export function SectionTitle({ children }: { children: React.ReactNode }) {
return <h3 className="text-lg font-bold text-foreground mb-3">{children}</h3>;
return (
<Typography.Heading level={3} className="text-lg font-bold text-foreground mb-3">
{children}
</Typography.Heading>
);
}
+36
View File
@@ -0,0 +1,36 @@
// 设置页统一徽章:版本类型 / 加载器 / 状态标签共用一个组件与样式组合。
import { cn } from "@/lib/utils";
export type SettingBadgeVariant = "neutral" | "primary" | "success" | "warning" | "info" | "error" | "violet";
const badgeStyles: Record<SettingBadgeVariant, string> = {
neutral: "bg-foreground/[0.05] dark:bg-white/[0.05] text-muted-foreground border-border/30 dark:border-white/[0.05]",
primary: "bg-primary/10 text-primary border-primary/20",
success: "bg-green-500/10 text-green-600 dark:bg-green-500/15 dark:text-green-400 border-green-500/20",
warning: "bg-amber-500/10 text-amber-600 dark:bg-amber-500/15 dark:text-amber-400 border-amber-500/20",
info: "bg-sky-500/10 text-sky-600 dark:bg-sky-500/15 dark:text-sky-400 border-sky-500/20",
error: "bg-red-500/10 text-red-600 dark:bg-red-500/15 dark:text-red-400 border-red-500/20",
violet: "bg-violet-500/10 text-violet-600 dark:bg-violet-500/15 dark:text-violet-400 border-violet-500/20",
};
export function SettingBadge({
variant = "neutral",
className,
children,
}: {
variant?: SettingBadgeVariant;
className?: string;
children: React.ReactNode;
}) {
return (
<span
className={cn(
"inline-flex items-center gap-1 text-[11px] px-2 py-0.5 rounded-md border font-medium whitespace-nowrap",
badgeStyles[variant],
className,
)}
>
{children}
</span>
);
}
+2 -6
View File
@@ -1,9 +1,5 @@
import { Card } from "@heroui/react";
import { SettingSurface } from "./SettingSurface";
export function SettingCard({ children, className }: { children: React.ReactNode; className?: string }) {
return (
<Card variant="transparent" className={`glass-card px-5 py-4 ${className ?? ""}`}>
{children}
</Card>
);
return <SettingSurface className={`px-5 py-4 ${className ?? ""}`}>{children}</SettingSurface>;
}
@@ -0,0 +1,28 @@
// 设置页列表项容器:版本行 / 扫描结果行等可操作列表项的统一外观。
import { cn } from "@/lib/utils";
export function SettingListItem({
className,
children,
selected = false,
}: {
className?: string;
children: React.ReactNode;
/** 选中态(高亮边框) */
selected?: boolean;
}) {
return (
<div
className={cn(
"flex items-center gap-3 px-3 py-2 rounded-xl border",
"bg-foreground/[0.03] dark:bg-white/[0.03]",
selected
? "border-primary/30 bg-primary/[0.04]"
: "border-black/[0.06] dark:border-white/[0.07]",
className,
)}
>
{children}
</div>
);
}
+8 -2
View File
@@ -1,9 +1,15 @@
import { Typography } from "@heroui/react";
export function SettingRow({ label, desc, children }: { label: string; desc?: string; children: React.ReactNode }) {
return (
<div className="flex items-center justify-between gap-4">
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-foreground">{label}</p>
{desc && <p className="text-[13px] text-muted-foreground mt-0.5">{desc}</p>}
<Typography.Paragraph className="text-sm font-medium text-foreground">{label}</Typography.Paragraph>
{desc && (
<Typography.Paragraph size="xs" className="text-[13px] text-muted-foreground mt-0.5">
{desc}
</Typography.Paragraph>
)}
</div>
<div className="shrink-0">{children}</div>
</div>
+37
View File
@@ -0,0 +1,37 @@
// 设置卡片唯一原语:所有设置子页面卡片统一走这里。
// 基于 HeroUI Surface,用显式类保证确定性的磨砂玻璃外观(与既有设计一致)。
import { Surface } from "@heroui/react";
import { cn } from "@/lib/utils";
export interface SettingSurfaceProps {
className?: string;
children?: React.ReactNode;
/** 是否启用磨砂玻璃(尊重无障碍 reduce-transparency 的全局降级) */
frost?: boolean;
/** 阴影级别:raised 默认、flat 无阴影、bordered 仅边框 */
variant?: "raised" | "flat" | "bordered";
}
const variantCls = {
raised: "shadow-sm",
flat: "shadow-none",
bordered: "shadow-none",
} as const;
export function SettingSurface({ className, frost = true, variant = "raised", children }: SettingSurfaceProps) {
return (
<Surface
variant="transparent"
className={cn(
"rounded-xl",
"bg-white/85 dark:bg-black/45",
"border border-black/[0.06] dark:border-white/[0.07]",
variantCls[variant],
frost && "backdrop-blur-[12px]",
className,
)}
>
{children}
</Surface>
);
}
+15 -25
View File
@@ -1,16 +1,10 @@
import { cn } from "@/lib/utils"
import { cn } from "@/lib/utils";
import { SettingSurface } from "./SettingSurface";
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]",
variant?: "raised" | "flat" | "bordered";
frost?: "none" | "sm" | "md" | "lg";
padding?: "none" | "sm" | "md" | "lg";
}
const paddingMap = {
@@ -18,8 +12,9 @@ const paddingMap = {
sm: "px-3 py-2.5",
md: "px-5 py-4",
lg: "px-6 py-5",
}
};
// Surface 是 SettingSurface 的兼容别名(保留原 API,样式与设置卡片统一)
function Surface({
variant = "raised",
frost = "none",
@@ -29,24 +24,19 @@ function Surface({
...props
}: SurfaceProps) {
return (
<div
data-slot="surface"
<SettingSurface
variant={variant}
frost={frost !== "none"}
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",
variant === "flat" && "bg-transparent border-0 backdrop-blur-none",
className,
)}
{...props}
>
{children}
</div>
)
</SettingSurface>
);
}
function SurfaceHeader({ className, children, ...props }: React.HTMLAttributes<HTMLDivElement>) {
@@ -61,7 +51,7 @@ function SurfaceHeader({ className, children, ...props }: React.HTMLAttributes<H
>
{children}
</div>
)
);
}
function SurfaceContent({ className, children, ...props }: React.HTMLAttributes<HTMLDivElement>) {
@@ -69,7 +59,7 @@ function SurfaceContent({ className, children, ...props }: React.HTMLAttributes<
<div data-slot="surface-content" className={cn("space-y-3", className)} {...props}>
{children}
</div>
)
);
}
export { Surface, SurfaceHeader, SurfaceContent }
+318
View File
@@ -0,0 +1,318 @@
// 可复用设置控件:HeroUI 3 复合组件封装,统一风格,绑定 configStore setter 自动保存。
// 每个控件都遵循 label / desc / value / onChange 通用接口。
import { useState } from "react";
import {
Button,
Input,
ListBox,
ListBoxItem,
NumberField,
Radio,
RadioGroup,
Select,
Switch,
TextArea,
} from "@heroui/react";
import { Check, ChevronDown, FolderOpen, FolderSearch, Loader2 } from "lucide-react";
import { ipcInvoke } from "@/api/ipc";
import { SettingRow } from "./SettingRow";
export interface SettingOption {
value: string;
label: string;
desc?: string;
}
// HeroUI 默认主题 field 边框宽度为 0--field-border-width: 0px),
// 统一补上与 Select.Trigger 一致的显式边框/背景,保证控件视觉完整。
export const fieldCls =
"rounded-lg border border-border/40 dark:border-white/[0.08] bg-white/60 dark:bg-black/30 text-[13px] text-foreground placeholder:text-muted-foreground/50 focus:border-primary/40 transition-colors";
// ---------- 下拉选择 ----------
export function SettingSelect({
label,
desc,
value,
options,
onChange,
placeholder = "请选择",
className,
}: {
label: string;
desc?: string;
value: string;
options: SettingOption[];
onChange: (v: string) => void;
placeholder?: string;
className?: string;
}) {
const selectedKey = options.some((o) => o.value === value) ? value : "__none__";
return (
<SettingRow label={label} desc={desc}>
<Select.Root
selectedKey={selectedKey}
onSelectionChange={(keys) => {
// RAC 单选时可能传 Key | null,也可能传 Set<Key>;两种形状都兼容
let v: string | undefined;
if (keys === null || keys === undefined) {
v = undefined;
} else if (typeof keys === "string" || typeof keys === "number") {
v = String(keys);
} else if ((keys as unknown) instanceof Set) {
const arr = Array.from(keys);
v = arr.length > 0 ? String(arr[0]) : undefined;
}
if (v && v !== "__none__") onChange(v);
}}
className={className}
>
<Select.Trigger className="h-8 rounded-lg border border-border/40 dark:border-white/[0.08] bg-white/60 dark:bg-black/30 px-3 hover:border-primary/30 transition-colors">
<Select.Value className="text-[13px] text-foreground">
{options.find((o) => o.value === value)?.label ?? placeholder}
</Select.Value>
<Select.Indicator>
<ChevronDown className="w-3.5 h-3.5 text-muted-foreground/60" />
</Select.Indicator>
</Select.Trigger>
<Select.Popover className="z-50 rounded-xl border border-border/50 dark:border-white/[0.08] bg-background shadow-xl p-1.5 min-w-[10rem]">
<ListBox className="max-h-72 overflow-y-auto scroll-area outline-none">
{options.map((opt) => (
<ListBoxItem
key={opt.value}
id={opt.value}
className="text-[13px] py-1.5 px-2.5 rounded-lg data-[selected=true]:bg-primary/10 data-[selected=true]:text-primary outline-none cursor-pointer"
>
{opt.label}
</ListBoxItem>
))}
</ListBox>
</Select.Popover>
</Select.Root>
</SettingRow>
);
}
// ---------- 数字输入 ----------
export function SettingNumberField({
label,
desc,
value,
onChange,
min,
max,
step = 1,
suffix,
className,
}: {
label: string;
desc?: string;
value: number;
onChange: (v: number) => void;
min?: number;
max?: number;
step?: number;
suffix?: string;
className?: string;
}) {
return (
<SettingRow label={label} desc={desc}>
<div className={`flex items-center gap-1.5 ${className ?? ""}`}>
<NumberField.Root
value={value}
onChange={onChange}
minValue={min}
maxValue={max}
step={step}
className="w-28"
>
<NumberField.Group className="flex items-center rounded-lg border border-border/40 dark:border-white/[0.08] bg-white/60 dark:bg-black/30 overflow-hidden focus-within:border-primary/40 transition-colors">
<NumberField.DecrementButton
aria-label="减少"
className="flex items-center justify-center w-7 h-8 shrink-0 text-muted-foreground hover:text-foreground hover:bg-foreground/[0.05] cursor-pointer select-none"
>
</NumberField.DecrementButton>
<NumberField.Input className="w-14 h-8 bg-transparent text-center text-[13px] text-foreground outline-none" />
<NumberField.IncrementButton
aria-label="增加"
className="flex items-center justify-center w-7 h-8 shrink-0 text-muted-foreground hover:text-foreground hover:bg-foreground/[0.05] cursor-pointer select-none"
>
+
</NumberField.IncrementButton>
</NumberField.Group>
</NumberField.Root>
{suffix && <span className="text-[13px] text-muted-foreground shrink-0">{suffix}</span>}
</div>
</SettingRow>
);
}
// ---------- 开关 ----------
export function SettingSwitch({
label,
desc,
checked,
onChange,
}: {
label: string;
desc?: string;
checked: boolean;
onChange: (v: boolean) => void;
}) {
return (
<SettingRow label={label} desc={desc}>
{/* 注:HeroUI 3 基于 react-ariaSwitch 使用 onChange 而非 onValueChange */}
<Switch isSelected={checked} onChange={onChange}>
<Switch.Control>
<Switch.Thumb />
</Switch.Control>
</Switch>
</SettingRow>
);
}
// ---------- 单选组 ----------
export function SettingRadioGroup({
label,
desc,
value,
options,
onChange,
horizontal = false,
}: {
label?: string;
desc?: string;
value: string;
options: SettingOption[];
onChange: (v: string) => void;
horizontal?: boolean;
}) {
return (
<div>
{label && <p className="text-sm font-medium text-foreground">{label}</p>}
{desc && <p className="text-[13px] text-muted-foreground mt-0.5 mb-2">{desc}</p>}
<RadioGroup
value={value}
onChange={(v) => onChange(String(v))}
className={horizontal ? "flex items-center gap-4" : "space-y-2"}
>
{options.map((opt) => (
<Radio key={opt.value} value={opt.value}>
<Radio.Control className="border border-border/40 dark:border-white/[0.08] bg-white/60 dark:bg-black/30">
<Radio.Indicator />
</Radio.Control>
<Radio.Content>{opt.label}</Radio.Content>
</Radio>
))}
</RadioGroup>
</div>
);
}
// ---------- 多行文本 ----------
export function SettingTextArea({
label,
desc,
value,
onChange,
rows = 3,
placeholder,
}: {
label: string;
desc?: string;
value: string;
onChange: (v: string) => void;
rows?: number;
placeholder?: string;
}) {
return (
<div className="space-y-2">
{label && <p className="text-sm font-medium text-foreground">{label}</p>}
{desc && <p className="text-[13px] text-muted-foreground">{desc}</p>}
<TextArea
value={value}
onChange={(e) => onChange(e.target.value)}
rows={rows}
placeholder={placeholder}
fullWidth
className={fieldCls}
/>
</div>
);
}
// ---------- 文本输入 + 浏览按钮 ----------
export function SettingFilePicker({
label,
desc,
value,
onChange,
placeholder,
mode = "file",
filters,
showCheck,
}: {
label: string;
desc?: string;
value: string;
onChange: (v: string) => void;
placeholder?: string;
/** file:选择文件;folder:选择文件夹 */
mode?: "file" | "folder";
filters?: { name: string; extensions: string[] }[];
/** 显示路径校验通过标记(配合外部 resolveJava 校验) */
showCheck?: boolean;
}) {
const [browsing, setBrowsing] = useState(false);
const handleBrowse = async () => {
setBrowsing(true);
try {
if (mode === "file") {
const result = await ipcInvoke<{ srcPath: string; ext: string } | null>("dialog:openFile", {
filters,
});
if (result) onChange(result.srcPath);
} else {
const result = await ipcInvoke<{ folderPath: string } | null>("dialog:openFolder");
if (result) onChange(result.folderPath);
}
} catch {
// 对话框取消或失败则忽略
} finally {
setBrowsing(false);
}
};
return (
<div>
{label && <p className="text-sm font-medium text-foreground">{label}</p>}
{desc && <p className="text-[13px] text-muted-foreground mt-0.5 mb-2">{desc}</p>}
<div className="flex items-center gap-2">
<div className="flex-1 min-w-0 relative">
<Input
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
fullWidth
className={fieldCls}
/>
{showCheck && value && (
<Check className="absolute right-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-green-500" />
)}
</div>
<Button size="sm" variant="outline" onPress={handleBrowse} isDisabled={browsing} className="shrink-0">
{browsing ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : mode === "file" ? (
<FolderSearch className="w-3.5 h-3.5" />
) : (
<FolderOpen className="w-3.5 h-3.5" />
)}
</Button>
</div>
</div>
);
}
+13 -1
View File
@@ -2,4 +2,16 @@ 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";
export { SettingBadge, type SettingBadgeVariant } from "./SettingBadge";
export { SettingListItem } from "./SettingListItem";
export {
SettingSelect,
SettingNumberField,
SettingSwitch,
SettingRadioGroup,
SettingTextArea,
SettingFilePicker,
fieldCls,
type SettingOption,
} from "./controls";
export { Skeleton } from "@/components/ui/skeleton";
+3 -1
View File
@@ -2,6 +2,7 @@ import { useState, useEffect } from "react";
import { BUILD_MODE } from "@/lib/mode";
import { TaskButton } from "@/components/task/TaskButton";
import { useRouteStore } from "@/stores/routeStore";
import { useConfigStore } from "@/stores/configStore";
import { Info } from "lucide-react";
import clsx from "clsx";
@@ -22,6 +23,7 @@ export function WindowControls({
}: WindowControlsProps) {
const [isMaximized, setIsMaximized] = useState(false);
const navigate = useRouteStore((s) => s.navigate);
const showTaskButton = useConfigStore((s) => s.config.ui?.showTaskButton ?? true);
useEffect(() => {
window.electronAPI?.isMaximized().then(setIsMaximized);
@@ -42,7 +44,7 @@ export function WindowControls({
const showBadge = BUILD_MODE !== "run";
const badgeLabel = BUILD_MODE === "dev" ? "DEV" : "BETA";
const showTask = !isSub && !isOobe;
const showTask = !isSub && !isOobe && showTaskButton;
const showInfo = isOobe;
return (
+12 -29
View File
@@ -15,28 +15,28 @@ import { toast } from "sonner";
import { useInstanceStore } from "@/stores/instanceStore";
import { useConfigStore } from "@/stores/configStore";
import { useAuthStore } from "@/stores/authStore";
import { useLaunchStore } from "@/stores/launchStore";
import { useConfirmDialogStore } from "@/stores/confirmDialogStore";
import { useRouteStore } from "@/stores/routeStore";
import { onLaunchComplete, onLaunchError } from "@/api/instance";
import { openPath } from "@/api/system";
import { InstanceList } from "./InstanceList";
import { InstanceDetail } from "./InstanceDetail";
import { EditDialog } from "./EditDialog";
export function Gallery() {
const { instances, loading, fetchInstances, remove, launch } = useInstanceStore();
const { instances, loading, fetchInstances, remove } = useInstanceStore();
const gameConfig = useConfigStore((s) => s.config.game);
const javaConfig = useConfigStore((s) => s.config.java);
const configInstances = useConfigStore((s) => s.config.instances);
const setInstances = useConfigStore((s) => s.setInstances);
const user = useAuthStore((s) => s.user);
const openConfirm = useConfirmDialogStore((s) => s.openDialog);
const navigate = useRouteStore((s) => s.navigate);
const setStoreSection = useRouteStore((s) => s.setStoreSection);
const launch = useLaunchStore((s) => s.launch);
const launching = useLaunchStore((s) => s.launching);
const [selectedName, setSelectedName] = useState<string | null>(null);
const [editOpen, setEditOpen] = useState(false);
const [launching, setLaunching] = useState(false);
// 跳转到资源中心"原版游戏"分类创建实例
const goCreateInstance = useCallback(() => {
@@ -63,41 +63,24 @@ export function Gallery() {
}
}, [instances, selectedName]);
// 监听启动结果
useEffect(() => {
const offComplete = onLaunchComplete(() => {
setLaunching(false);
toast.success("游戏启动成功");
});
const offError = onLaunchError(({ error }) => {
setLaunching(false);
toast.error(`启动失败: ${error}`);
});
return () => {
offComplete();
offError();
};
}, []);
const selected = instances.find((i) => i.name === selectedName) ?? null;
// 启动游戏
// 启动游戏(统一接口:主进程自动应用权威配置)
const handlePlay = async () => {
if (!selected) return;
if (!user?.username || !user?.uuid) {
toast.warning("请先在设置中登录账号");
return;
}
setLaunching(true);
try {
await launch(selected.name, gameConfig.gameDir, {
username: user.username,
uuid: user.uuid,
accessToken: user.accessToken,
javaPath: javaConfig.javaPath || undefined,
});
await launch(selected.name, gameConfig.gameDir);
const error = useLaunchStore.getState().error;
if (error) {
toast.error(`启动失败: ${error}`);
} else {
toast.success("游戏已启动");
}
} catch (e: any) {
setLaunching(false);
toast.error(`启动失败: ${e.message || e}`);
}
};
+4
View File
@@ -1,10 +1,14 @@
import { useInstanceStore } from "@/stores/instanceStore";
import { useRouteStore } from "@/stores/routeStore";
import { useConfigStore } from "@/stores/configStore";
import clsx from "clsx";
export function InstanceTitle() {
const currentInstance = useInstanceStore((s) => s.currentInstance);
const navigate = useRouteStore((s) => s.navigate);
const showTitle = useConfigStore((s) => s.config.ui?.showInstanceTitle ?? true);
if (!showTitle) return null;
return (
<div
+39 -10
View File
@@ -1,13 +1,34 @@
import { Play, Settings, Package } from "lucide-react";
import { Play, Settings, Package, Loader2 } from "lucide-react";
import { toast } from "sonner";
import { useRouteStore } from "@/stores/routeStore";
import { useInstanceStore } from "@/stores/instanceStore";
import { useConfigStore } from "@/stores/configStore";
import { useLaunchStore } from "@/stores/launchStore";
import clsx from "clsx";
interface StartCardProps {
onSettingsClick?: () => void;
}
export function StartCard({ onSettingsClick }: StartCardProps) {
export function StartCard() {
const navigate = useRouteStore((s) => s.navigate);
const currentInstance = useInstanceStore((s) => s.currentInstance);
const gameDir = useConfigStore((s) => s.config.game.gameDir);
const launching = useLaunchStore((s) => s.launching);
const running = useLaunchStore((s) => s.running);
const launch = useLaunchStore((s) => s.launch);
const clearError = useLaunchStore((s) => s.clearError);
// 启动游戏:自动携带实例 + 主进程权威配置(Java/内存/GC/窗口等)
const handleLaunch = async () => {
clearError();
if (!currentInstance) {
toast.warning("请先选择一个游戏实例");
navigate("gallery");
return;
}
await launch(currentInstance.name, gameDir);
const error = useLaunchStore.getState().error;
if (error) {
toast.error(error);
}
};
return (
<div
@@ -20,7 +41,7 @@ export function StartCard({ onSettingsClick }: StartCardProps) {
>
{/* Settings button */}
<button
onClick={onSettingsClick ?? (() => navigate("setting"))}
onClick={() => navigate("setting")}
className="flex items-center justify-center w-9 h-9 ml-0.5 text-muted-foreground hover:text-foreground hover:bg-muted/50 transition-all duration-150 cursor-pointer shrink-0 rounded-full"
aria-label="设置"
>
@@ -28,10 +49,18 @@ export function StartCard({ onSettingsClick }: StartCardProps) {
</button>
{/* Launch button */}
<button className="flex items-center justify-center h-9 rounded-full cursor-pointer shrink-0 active:scale-[0.97] transition-all duration-150">
<button
onClick={handleLaunch}
disabled={launching}
className="flex items-center justify-center h-9 rounded-full cursor-pointer shrink-0 active:scale-[0.97] transition-all duration-150 disabled:cursor-not-allowed disabled:opacity-70"
>
<span className="flex items-center gap-1.5 px-4 h-7 rounded-full font-medium text-sm text-primary bg-primary/10 hover:bg-primary/20 transition-colors">
<Play className="w-3.5 h-3.5 fill-current" />
{launching ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<Play className="w-3.5 h-3.5 fill-current" />
)}
{launching ? "正在启动..." : running ? "游戏中" : "启动游戏"}
</span>
</button>
+105 -71
View File
@@ -1,7 +1,15 @@
import { Input } from "@heroui/react";
import { useConfigStore } from "@/stores/configStore";
import { Switch, RadioGroup, Radio, Input, TextArea } from "@heroui/react";
import { SettingCard, SettingRow, PageHeader, SectionTitle } from "@/components/setting";
import {
SettingCard,
SettingSelect,
SettingSwitch,
SettingNumberField,
SettingFilePicker,
fieldCls,
PageHeader,
SectionTitle,
} from "@/components/setting";
const launcherBehavior = [
{ value: "close", label: "关闭启动器" },
{ value: "minimize", label: "最小化到任务栏" },
@@ -23,69 +31,52 @@ export function AdvancedSetting() {
<PageHeader title="高级设置" desc="游戏高级启动参数、调试选项与实验性功能" />
<div className="space-y-6">
{/* 启动行为 */}
<div>
<SectionTitle></SectionTitle>
<div className="space-y-3">
<SettingCard>
<div className="space-y-2">
<p className="text-sm font-medium text-foreground"></p>
<p className="text-[13px] text-muted-foreground"></p>
<RadioGroup
value={adv.afterLaunch}
onValueChange={(v) => setAdvanced({ afterLaunch: v })}
className="mt-2 space-y-2"
>
{launcherBehavior.map((opt) => (
<Radio key={opt.value} value={opt.value}>
<Radio.Content>{opt.label}</Radio.Content>
</Radio>
))}
</RadioGroup>
</div>
<SettingSelect
label="启动后启动器行为"
desc="游戏窗口就绪后启动器的处理方式"
value={adv.afterLaunch}
options={launcherBehavior}
onChange={(v) => setAdvanced({ afterLaunch: v })}
/>
</SettingCard>
</div>
</div>
{/* 窗口设置 */}
<div>
<SectionTitle></SectionTitle>
<div className="space-y-3">
<SettingCard>
<div className="space-y-3">
<div className="space-y-2">
<p className="text-sm font-medium text-foreground"></p>
<RadioGroup
value={adv.winMode}
onValueChange={(v) => setAdvanced({ winMode: v })}
className="space-y-2"
>
{windowSize.map((opt) => (
<Radio key={opt.value} value={opt.value}>
<Radio.Content>{opt.label}</Radio.Content>
</Radio>
))}
</RadioGroup>
</div>
<div className="space-y-4">
<SettingSelect
label="窗口大小"
value={adv.winMode}
options={windowSize}
onChange={(v) => setAdvanced({ winMode: v })}
/>
{adv.winMode === "custom" && (
<div className="flex items-center gap-3">
<div className="flex items-center gap-2">
<span className="text-[13px] text-muted-foreground"></span>
<Input
type="number"
value={String(adv.customWidth)}
onChange={(e) => setAdvanced({ customWidth: Number(e.target.value) })}
className="w-20"
/>
</div>
<span className="text-muted-foreground">×</span>
<div className="flex items-center gap-2">
<span className="text-[13px] text-muted-foreground"></span>
<Input
type="number"
value={String(adv.customHeight)}
onChange={(e) => setAdvanced({ customHeight: Number(e.target.value) })}
className="w-20"
/>
</div>
<div className="flex items-center gap-4">
<SettingNumberField
label="宽"
value={adv.customWidth}
onChange={(v) => setAdvanced({ customWidth: v })}
min={320}
max={7680}
suffix="px"
/>
<SettingNumberField
label="高"
value={adv.customHeight}
onChange={(v) => setAdvanced({ customHeight: v })}
min={240}
max={4320}
suffix="px"
/>
</div>
)}
</div>
@@ -93,53 +84,96 @@ export function AdvancedSetting() {
</div>
</div>
{/* 快速进入服务器 */}
<div>
<SectionTitle></SectionTitle>
<div className="space-y-3">
<SettingCard>
<div className="space-y-2">
<p className="text-sm font-medium text-foreground"></p>
<p className="text-[13px] text-muted-foreground">
IP
</p>
<div className="flex items-center gap-2">
<Input
value={adv.server?.ip ?? ""}
onChange={(e) =>
setAdvanced({ server: { ip: e.target.value, port: adv.server?.port ?? 25565 } })
}
placeholder="例如 mc.example.com"
fullWidth
className={fieldCls}
/>
<Input
type="number"
value={String(adv.server?.port ?? 25565)}
onChange={(e) =>
setAdvanced({ server: { ip: adv.server?.ip ?? "", port: Number(e.target.value) || 25565 } })
}
className={`w-24 shrink-0 ${fieldCls}`}
aria-label="服务器端口"
/>
<span className="text-[13px] text-muted-foreground shrink-0"></span>
</div>
</div>
</SettingCard>
</div>
</div>
{/* 游戏参数 */}
<div>
<SectionTitle></SectionTitle>
<div className="space-y-3">
<SettingCard>
<div className="space-y-2">
<p className="text-sm font-medium text-foreground"></p>
<p className="text-[13px] text-muted-foreground"></p>
<p className="text-[13px] text-muted-foreground">
--demo
</p>
<Input
value={adv.gameArgs}
onChange={(e) => setAdvanced({ gameArgs: e.target.value })}
placeholder="可选,例如 --demo"
fullWidth
className={fieldCls}
/>
</div>
</SettingCard>
</div>
</div>
{/* 启动命令 */}
<div>
<SectionTitle></SectionTitle>
<div className="space-y-3">
<SettingCard>
<div className="space-y-2">
<p className="text-sm font-medium text-foreground"></p>
<p className="text-[13px] text-muted-foreground"></p>
<Input
value={adv.preLaunchCmd}
onChange={(e) => setAdvanced({ preLaunchCmd: e.target.value })}
placeholder="可选,例如 D:\scripts\pre-launch.bat"
fullWidth
/>
</div>
<SettingFilePicker
label="启动前执行命令"
desc="游戏启动前自动执行的命令或程序路径(Windows 批处理需以 cmd /c 开头)"
value={adv.preLaunchCmd}
onChange={(v) => setAdvanced({ preLaunchCmd: v })}
placeholder="例如 cmd /c D:\scripts\pre-launch.bat"
mode="file"
filters={[
{ name: "批处理 / 可执行文件", extensions: ["bat", "cmd", "exe"] },
{ name: "所有文件", extensions: ["*"] },
]}
/>
</SettingCard>
</div>
</div>
{/* 调试 */}
<div>
<SectionTitle></SectionTitle>
<div className="space-y-3">
<SettingCard>
<SettingRow label="调试模式" desc="启用后将在控制台输出详细日志,可能影响性能">
<Switch isSelected={adv.debugMode} onValueChange={(v) => setAdvanced({ debugMode: v })}>
<Switch.Control>
<Switch.Thumb />
</Switch.Control>
</Switch>
</SettingRow>
<SettingSwitch
label="调试模式"
desc="启用后附加 -Dkoring.debugMode=true 并在控制台输出详细日志,可能影响性能"
checked={adv.debugMode}
onChange={(v) => setAdvanced({ debugMode: v })}
/>
</SettingCard>
</div>
</div>
+164 -8
View File
@@ -1,15 +1,171 @@
import { EmptyState } from "@heroui/react";
import { Gamepad2 } from "lucide-react";
import { PageHeader } from "@/components/setting";
import { useState } from "react";
import { Button, Avatar, Input } from "@heroui/react";
import { Gamepad2, LogOut, Loader2, UserRound, Wifi, ShieldQuestion, AlertCircle } from "lucide-react";
import { toast } from "sonner";
import { useAuthStore } from "@/stores/authStore";
import {
SettingCard,
SettingBadge,
PageHeader,
SectionTitle,
fieldCls,
} from "@/components/setting";
export function GameAccountSetting() {
const user = useAuthStore((s) => s.user);
const loading = useAuthStore((s) => s.loading);
const error = useAuthStore((s) => s.error);
const logout = useAuthStore((s) => s.logout);
const loginOffline = useAuthStore((s) => s.loginOffline);
const clearError = useAuthStore((s) => s.clearError);
const [username, setUsername] = useState("");
// 离线账号登录
const handleOfflineLogin = async () => {
clearError();
const name = username.trim();
if (!name) {
toast.warning("请输入离线用户名");
return;
}
await loginOffline(name);
if (!useAuthStore.getState().error) {
toast.success(`已登录离线账号:${name}`);
setUsername("");
}
};
// 退出登录
const handleLogout = async () => {
await logout();
toast.success("已退出游戏账号");
};
return (
<div>
<PageHeader title="游戏账户&档案" desc="管理 Minecraft 游戏内账户、正版验证与游戏档案配置" />
<EmptyState className="py-16">
<Gamepad2 className="w-10 h-10 text-muted-foreground/30" />
<p className="text-sm text-muted-foreground mt-3"></p>
</EmptyState>
<PageHeader title="游戏账户&档案" desc="管理 Minecraft 游戏内账户(离线 / 微软)与登录状态" />
<div className="space-y-6">
{/* 当前账号 */}
<div>
<SectionTitle></SectionTitle>
<div className="space-y-3">
<SettingCard>
{user ? (
<div className="flex items-center gap-4">
<Avatar size="lg" className="shrink-0">
<Avatar.Fallback>
<UserRound className="w-7 h-7 text-foreground/40" />
</Avatar.Fallback>
</Avatar>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<p className="text-base font-medium text-foreground truncate">{user.username}</p>
{user.accessToken ? (
<SettingBadge variant="info"></SettingBadge>
) : (
<SettingBadge variant="neutral">线</SettingBadge>
)}
</div>
<p className="text-[12px] text-muted-foreground/70 mt-0.5 font-mono truncate">UUID: {user.uuid}</p>
<p className="text-[12px] text-muted-foreground/60 mt-0.5">
使
</p>
</div>
<Button size="sm" variant="danger-soft" className="shrink-0" onPress={handleLogout} isDisabled={loading}>
<LogOut className="w-3.5 h-3.5" />
退
</Button>
</div>
) : (
<div className="flex flex-col items-center gap-3 py-10 text-center">
<div className="w-14 h-14 rounded-full bg-foreground/[0.06] flex items-center justify-center shrink-0">
<Gamepad2 className="w-8 h-8 text-foreground/30" />
</div>
<div>
<p className="text-sm text-muted-foreground"></p>
<p className="text-[12px] text-muted-foreground/60 mt-1">
使线
</p>
</div>
</div>
)}
</SettingCard>
</div>
</div>
{/* 登录方式 */}
<div>
<SectionTitle></SectionTitle>
<div className="space-y-3">
{/* 离线账号 */}
<SettingCard>
<div className="space-y-3">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-foreground/[0.05] dark:bg-white/[0.05] flex items-center justify-center shrink-0">
<Wifi className="w-4 h-4 text-muted-foreground/70" />
</div>
<div>
<p className="text-sm font-medium text-foreground">线</p>
<p className="text-[12px] text-muted-foreground/70"></p>
</div>
</div>
<div className="flex items-center gap-2">
<Input
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="输入离线用户名(不超过 16 字符)"
maxLength={16}
fullWidth
className={fieldCls}
onKeyDown={(e) => {
if (e.key === "Enter") handleOfflineLogin();
}}
/>
<Button
size="sm"
variant="primary"
className="shrink-0"
onPress={handleOfflineLogin}
isDisabled={loading}
>
{loading ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <UserRound className="w-3.5 h-3.5" />}
</Button>
</div>
{error && (
<p className="text-[12px] text-red-500/80 flex items-center gap-1">
<AlertCircle className="w-3.5 h-3.5" />
{error}
</p>
)}
</div>
</SettingCard>
{/* 微软账号(开发中) */}
<SettingCard>
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-foreground/[0.05] dark:bg-white/[0.05] flex items-center justify-center shrink-0">
<ShieldQuestion className="w-4 h-4 text-muted-foreground/70" />
</div>
<div>
<div className="flex items-center gap-2">
<p className="text-sm font-medium text-foreground"></p>
<SettingBadge variant="warning"></SettingBadge>
</div>
<p className="text-[12px] text-muted-foreground/70"></p>
</div>
</div>
<Button size="sm" variant="outline" className="shrink-0" isDisabled>
</Button>
</div>
</SettingCard>
</div>
</div>
</div>
</div>
);
}
+67 -49
View File
@@ -8,30 +8,30 @@
// 未经允许的情况下删除此版权头可能会受到民事指控
// 请勿在未经Lingke Network (china)允许的范围内修改代码并分发
import { useState, useEffect, useCallback } from "react";
import { useState, useEffect, useRef, useCallback } from "react";
import { Button, Skeleton } from "@heroui/react";
import { RefreshCw, FolderOpen, Trash2, Search, Plus, CircleCheck, CircleAlert, Home, Check, Download, Loader2 } from "lucide-react";
import { toast } from "sonner";
import { useConfigStore } from "@/stores/configStore";
import { useInstanceStore } from "@/stores/instanceStore";
import { scanGameDir, selectFolder, importExistingInstance, type ScannedVersion } from "@/api/instance";
import { SettingCard, PageHeader, SectionTitle } from "@/components/setting";
import { listInstances, scanGameDir, selectFolder, importExistingInstance, type ScannedVersion } from "@/api/instance";
import { SettingCard, SettingBadge, SettingListItem, PageHeader, SectionTitle, type SettingBadgeVariant } from "@/components/setting";
// 版本类型标签样式
const TYPE_BADGE: Record<string, { label: string; cls: string }> = {
release: { label: "正式版", cls: "bg-green-500/10 text-green-600 dark:text-green-400" },
snapshot: { label: "预览版", cls: "bg-amber-500/10 text-amber-600 dark:text-amber-400" },
"old_alpha": { label: "Alpha", cls: "bg-purple-500/10 text-purple-600 dark:text-purple-400" },
"old_beta": { label: "Beta", cls: "bg-indigo-500/10 text-indigo-600 dark:text-indigo-400" },
unknown: { label: "未知", cls: "bg-foreground/[0.06] dark:bg-white/[0.06] text-muted-foreground" },
// 版本类型标签(统一徽章变体)
const TYPE_BADGE: Record<string, { label: string; variant: SettingBadgeVariant }> = {
release: { label: "正式版", variant: "success" },
snapshot: { label: "预览版", variant: "warning" },
old_alpha: { label: "Alpha", variant: "violet" },
old_beta: { label: "Beta", variant: "info" },
unknown: { label: "未知", variant: "neutral" },
};
// 加载器标签样式
const LOADER_BADGE: Record<string, { label: string; cls: string }> = {
forge: { label: "Forge", cls: "bg-orange-500/10 text-orange-600 dark:text-orange-400" },
fabric: { label: "Fabric", cls: "bg-sky-500/10 text-sky-600 dark:text-sky-400" },
quilt: { label: "Quilt", cls: "bg-pink-500/10 text-pink-600 dark:text-pink-400" },
optifine: { label: "OptiFine", cls: "bg-violet-500/10 text-violet-600 dark:text-violet-400" },
// 加载器标签(统一徽章变体)
const LOADER_BADGE: Record<string, { label: string; variant: SettingBadgeVariant }> = {
forge: { label: "Forge", variant: "warning" },
fabric: { label: "Fabric", variant: "info" },
quilt: { label: "Quilt", variant: "violet" },
optifine: { label: "OptiFine", variant: "violet" },
};
function getBadge(type: string) {
@@ -46,10 +46,10 @@ function formatTime(iso?: string): string {
function FileStatus({ ok, label }: { ok: boolean; label: string }) {
return (
<span className={`inline-flex items-center gap-1 text-[11px] ${ok ? "text-green-600 dark:text-green-400" : "text-red-500/70"}`}>
<SettingBadge variant={ok ? "success" : "error"}>
{ok ? <CircleCheck className="w-3 h-3" /> : <CircleAlert className="w-3 h-3" />}
{label}
</span>
</SettingBadge>
);
}
@@ -90,21 +90,27 @@ export function GameDirSetting() {
// 组件挂载时自动扫描主目录
useEffect(() => {
handleScan(gameDir);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// 主目录变化时自动扫描
// 主目录变化时自动重扫新目录(跳过首次挂载)
const firstRunRef = useRef(true);
useEffect(() => {
if (scanTarget && scanTarget !== gameDir) {
// 目录已变化但还没扫描过新目录
if (firstRunRef.current) {
firstRunRef.current = false;
return;
}
}, [gameDir]);
handleScan(gameDir);
}, [gameDir, handleScan]);
// 导入单个版本
// 导入单个版本(来源 = 当前扫描目录,实例建在主库)
const handleImport = async (versionId: string) => {
const source = scanTarget || gameDir;
setImporting(versionId);
try {
await importExistingInstance(versionId, gameDir, versionId, {
description: `Imported from ${gameDir}`,
description: `Imported from ${source}`,
sourceGamePath: source,
});
await fetchInstances(gameDir);
toast.success(`已导入版本 ${versionId}`);
@@ -114,7 +120,7 @@ export function GameDirSetting() {
setImporting(null);
};
// 批量导入所有健康版本
// 批量导入所有健康版本(幂等:已存在实例跳过)
const handleImportAll = async () => {
if (!scanResults || scanResults.length === 0) return;
const healthy = scanResults.filter((v) => v.healthy);
@@ -122,13 +128,26 @@ export function GameDirSetting() {
toast.info("没有可导入的健康版本");
return;
}
const source = scanTarget || gameDir;
setImportingBatch(true);
let success = 0;
let failed = 0;
let skipped = 0;
// 已有实例名集合,避免重复导入全部失败
let existing = new Set<string>();
try {
const list = await listInstances(gameDir);
existing = new Set(list.map((i) => i.name));
} catch {}
for (const v of healthy) {
if (existing.has(v.id)) {
skipped++;
continue;
}
try {
await importExistingInstance(v.id, gameDir, v.id, {
description: `Imported from ${gameDir}`,
description: `Imported from ${source}`,
sourceGamePath: source,
});
success++;
} catch {
@@ -137,10 +156,15 @@ export function GameDirSetting() {
}
await fetchInstances(gameDir);
setImportingBatch(false);
if (failed === 0) {
const parts = [`成功 ${success}`];
if (skipped > 0) parts.push(`跳过 ${skipped}`);
if (failed > 0) parts.push(`失败 ${failed}`);
if (failed === 0 && skipped === 0) {
toast.success(`成功导入 ${success} 个版本`);
} else if (failed === 0) {
toast.success(`导入完成:${parts.join('')}`);
} else {
toast.warning(`导入完成:${success} 成功,${failed} 失败`);
toast.warning(`导入完成:${parts.join('')}`);
}
};
@@ -261,29 +285,24 @@ export function GameDirSetting() {
const badge = getBadge(v.type);
const isImporting = importing === v.id;
return (
<div
key={v.id}
className="flex items-center gap-3 px-3.5 py-2.5 rounded-xl bg-foreground/[0.03] dark:bg-white/[0.03] border border-border/20 dark:border-white/[0.04]"
>
<SettingListItem key={v.id}>
{/* 版本图标 */}
<div className={`w-7 h-7 rounded-lg flex items-center justify-center shrink-0 ${badge.cls}`}>
<Home className="w-3.5 h-3.5" />
<div className="w-7 h-7 rounded-lg bg-foreground/[0.05] dark:bg-white/[0.05] flex items-center justify-center shrink-0">
<Home className="w-3.5 h-3.5 text-muted-foreground/70" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<p className="text-[13px] font-mono font-semibold text-foreground">{v.id}</p>
<span className={`text-[10px] px-1.5 py-0.5 rounded font-medium ${badge.cls}`}>
{badge.label}
</span>
<SettingBadge variant={badge.variant}>{badge.label}</SettingBadge>
{/* 加载器标签 */}
{v.loaders.map((loader) => (
<span
key={loader}
className={`text-[10px] px-1.5 py-0.5 rounded font-medium ${LOADER_BADGE[loader]?.cls ?? ""}`}
>
{LOADER_BADGE[loader]?.label ?? loader}
</span>
))}
{v.loaders.map((loader) => {
const lb = LOADER_BADGE[loader];
return lb ? (
<SettingBadge key={loader} variant={lb.variant}>
{lb.label}
</SettingBadge>
) : null;
})}
<span className="text-[11px] text-muted-foreground/60">{formatTime(v.releaseTime)}</span>
</div>
<div className="flex items-center gap-3 mt-1">
@@ -311,7 +330,7 @@ export function GameDirSetting() {
)}
</Button>
</div>
</SettingListItem>
);
})}
</div>
@@ -390,10 +409,9 @@ export function GameDirSetting() {
{scanResults.map((v) => {
const badge = getBadge(v.type);
return (
<span key={v.id} className="inline-flex items-center gap-1 text-[11px] px-2 py-1 rounded-md bg-foreground/[0.04] dark:bg-white/[0.04]">
<span className={`w-1.5 h-1.5 rounded-full ${badge.cls.replace(/\/\d+/, "").replace(/\s.*/, "")}`} />
<SettingBadge key={v.id} variant={badge.variant}>
{v.id}
</span>
</SettingBadge>
);
})}
</div>
+171 -79
View File
@@ -1,18 +1,20 @@
import { useCallback, useEffect, useState } from "react";
import { Button, Slider, Skeleton } from "@heroui/react";
import { Cpu, Loader2, RefreshCw, Check, AlertCircle } from "lucide-react";
import { toast } from "sonner";
import { useConfigStore } from "@/stores/configStore";
import { Button, Slider, RadioGroup, Radio, Input, TextArea } from "@heroui/react";
import { Cpu, FolderSearch } from "lucide-react";
import { SettingCard, SettingRow, PageHeader, SectionTitle } from "@/components/setting";
interface JavaInfo {
path: string;
version: string;
vendor: string;
}
const mockJavaList: JavaInfo[] = [
{ path: "C:\\Program Files\\Java\\jdk-21\\bin\\javaw.exe", version: "21.0.3", vendor: "Oracle OpenJDK" },
{ path: "C:\\Program Files\\Eclipse Adoptium\\jdk-17.0.10.7-hotspot\\bin\\javaw.exe", version: "17.0.10", vendor: "Eclipse Temurin" },
];
import { scanJava, resolveJava, type JavaInfo } from "@/api/java";
import {
SettingCard,
SettingRow,
SettingSelect,
SettingRadioGroup,
SettingTextArea,
SettingFilePicker,
SettingListItem,
PageHeader,
SectionTitle,
} from "@/components/setting";
const gcOptions = [
{ value: "auto", label: "不指定(由 Java 自动选择)" },
@@ -20,80 +22,179 @@ const gcOptions = [
{ value: "g1", label: "G1GC(标准,兼容性好)" },
];
// 根据路径推断发行版名称(展示用)
function javaVendorLabel(j: JavaInfo): string {
const p = j.path.toLowerCase();
if (p.includes("temurin") || p.includes("adoptium")) return "Eclipse Temurin";
if (p.includes("zulu")) return "Azul Zulu";
if (p.includes("corretto") || p.includes("amazon")) return "Amazon Corretto";
if (p.includes("microsoft")) return "Microsoft OpenJDK";
if (p.includes("oracle") || p.includes("jdk") || p.includes("java")) return "OpenJDK";
return "Java";
}
export function JavaMemSetting() {
const java = useConfigStore((s) => s.config.java);
const setJava = useConfigStore((s) => s.setJava);
const [scanning, setScanning] = useState(false);
const [javaList, setJavaList] = useState<JavaInfo[]>([]);
const [validated, setValidated] = useState<JavaInfo | null>(null);
const [validating, setValidating] = useState(false);
// 扫描系统 Java
const handleScan = useCallback(async () => {
setScanning(true);
try {
const list = await scanJava();
setJavaList(list);
if (list.length === 0) {
toast.info("未检测到已安装的 Java,请手动指定路径");
} else {
toast.success(`检测到 ${list.length} 个 Java 环境`);
}
} catch (e: any) {
toast.error(`检测失败: ${e?.message || e}`);
}
setScanning(false);
}, []);
// 配置路径变化后自动校验(700ms debounce,避免每次按键都 spawn java
useEffect(() => {
if (!java.javaPath.trim()) {
setValidated(null);
setValidating(false);
return;
}
setValidating(true);
const timer = setTimeout(async () => {
try {
const info = await resolveJava(java.javaPath.trim());
setValidated(info);
} catch {
setValidated(null);
}
setValidating(false);
}, 700);
return () => clearTimeout(timer);
}, [java.javaPath]);
const isCurrent = (path: string) => java.javaPath === path;
return (
<div>
<PageHeader title="Java 虚拟机与内存" desc="配置 Java 运行环境路径、JVM 参数与游戏内存分配" />
<div className="space-y-6">
{/* Java 环境 */}
<div>
<SectionTitle>Java </SectionTitle>
<div className="space-y-3">
<SettingCard>
<SettingRow label="自动检测" desc="扫描系统中已安装的 Java 版本">
<Button size="sm" variant="outline">
<FolderSearch className="w-3.5 h-3.5 mr-1.5" />
<SettingRow label="自动检测" desc="扫描系统中已安装的 JavaJAVA_HOME / PATH / 常见安装目录)">
<Button size="sm" variant="outline" onPress={handleScan} isDisabled={scanning}>
{scanning ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <RefreshCw className="w-3.5 h-3.5" />}
{scanning ? "检测中..." : "检测"}
</Button>
</SettingRow>
</SettingCard>
{mockJavaList.map((j) => (
<SettingCard key={j.path}>
<div className="flex items-center gap-3">
<Cpu className="w-4 h-4 text-muted-foreground shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-foreground">
{j.vendor} {j.version}
</p>
<p className="text-[11px] text-muted-foreground/60 mt-0.5 font-mono truncate">
{j.path}
</p>
</div>
<Button size="sm" variant="outline" className="shrink-0" onPress={() => setJava({ javaPath: j.path })}>
使
</Button>
{scanning && (
<SettingCard>
<div className="space-y-2">
{Array.from({ length: 2 }).map((_, i) => (
<Skeleton key={i} className="h-10 w-full rounded-lg" />
))}
</div>
</SettingCard>
))}
)}
{!scanning && javaList.length > 0 && (
<SettingCard>
<div className="space-y-2">
<p className="text-[12px] text-muted-foreground/70">使</p>
{javaList.map((j) => {
const current = isCurrent(j.path);
return (
<SettingListItem key={j.path} selected={current}>
<Cpu className="w-4 h-4 text-muted-foreground shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-foreground">
{javaVendorLabel(j)} {j.version}
</p>
<p className="text-[11px] text-muted-foreground/60 mt-0.5 font-mono truncate">{j.path}</p>
</div>
<Button
size="sm"
variant={current ? "primary" : "outline"}
className="shrink-0"
isDisabled={current}
onPress={() => setJava({ javaPath: j.path })}
>
{current ? (
<>
<Check className="w-3.5 h-3.5" />
</>
) : (
"使用"
)}
</Button>
</SettingListItem>
);
})}
</div>
</SettingCard>
)}
<SettingCard>
<div className="space-y-2">
<p className="text-sm font-medium text-foreground"></p>
<div className="flex items-center gap-2">
<Input
value={java.javaPath}
onChange={(e) => setJava({ javaPath: e.target.value })}
placeholder="输入 javaw.exe 完整路径"
fullWidth
/>
<Button size="sm" variant="outline"></Button>
</div>
<p className="text-[13px] text-muted-foreground">
javaw.exe / java.exe
{validating && "(校验中..."}
</p>
<SettingFilePicker
label=""
value={java.javaPath}
onChange={(v) => setJava({ javaPath: v })}
placeholder="例如 C:\Program Files\Java\jdk-21\bin\javaw.exe"
mode="file"
filters={[{ name: "Java 可执行文件", extensions: ["exe"] }]}
showCheck={!!validated}
/>
{validated && (
<p className="text-[12px] text-green-600 dark:text-green-400 flex items-center gap-1">
<Check className="w-3.5 h-3.5" />
{javaVendorLabel(validated)} {validated.version}Java {validated.majorVersion}
</p>
)}
{!validating && java.javaPath.trim() && !validated && (
<p className="text-[12px] text-red-500/80 flex items-center gap-1">
<AlertCircle className="w-3.5 h-3.5" />
Java
</p>
)}
</div>
</SettingCard>
</div>
</div>
{/* 内存分配 */}
<div>
<SectionTitle></SectionTitle>
<div className="space-y-3">
<SettingCard>
<div className="space-y-3">
<RadioGroup
<SettingRadioGroup
value={java.memMode}
onValueChange={(v) => setJava({ memMode: v })}
className="flex items-center gap-4"
>
<Radio value="auto">
<Radio.Content></Radio.Content>
</Radio>
<Radio value="custom">
<Radio.Content></Radio.Content>
</Radio>
</RadioGroup>
options={[
{ value: "auto", label: "自动配置" },
{ value: "custom", label: "自定义" },
]}
onChange={(v) => setJava({ memMode: v })}
horizontal
/>
{java.memMode === "custom" && (
<div>
<div className="flex items-center justify-between mb-1.5">
@@ -123,43 +224,34 @@ export function JavaMemSetting() {
</div>
</div>
{/* JVM 参数 */}
<div>
<SectionTitle>JVM </SectionTitle>
<div className="space-y-3">
<SettingCard>
<div className="space-y-2">
<p className="text-sm font-medium text-foreground"> JVM </p>
<p className="text-[13px] text-muted-foreground"> -XX:+UseZGC</p>
<TextArea
value={java.jvmArgs}
onChange={(e) => setJava({ jvmArgs: e.target.value })}
placeholder="可选,留空使用默认参数"
rows={3}
fullWidth
/>
</div>
<SettingTextArea
label="额外 JVM 启动参数"
desc="每行一个参数,例如 -XX:+UseZGC;支持引号包裹含空格的值"
value={java.jvmArgs}
onChange={(v) => setJava({ jvmArgs: v })}
rows={3}
placeholder="可选,留空使用默认参数"
/>
</SettingCard>
</div>
</div>
{/* 垃圾回收 */}
<div>
<SectionTitle></SectionTitle>
<div className="space-y-3">
<SettingCard>
<div className="space-y-2">
<p className="text-sm font-medium text-foreground">GC </p>
<RadioGroup
value={java.gc}
onValueChange={(v) => setJava({ gc: v })}
className="space-y-2"
>
{gcOptions.map((opt) => (
<Radio key={opt.value} value={opt.value}>
<Radio.Content>{opt.label}</Radio.Content>
</Radio>
))}
</RadioGroup>
</div>
<SettingSelect
label="GC 算法"
value={java.gc}
options={gcOptions}
onChange={(v) => setJava({ gc: v })}
/>
</SettingCard>
</div>
</div>
+4 -4
View File
@@ -1,6 +1,6 @@
import { useConfigStore } from "@/stores/configStore";
import { Slider, RadioGroup, Radio, Input } from "@heroui/react";
import { SettingCard, PageHeader, SectionTitle } from "@/components/setting";
import { SettingCard, PageHeader, SectionTitle, fieldCls } from "@/components/setting";
const downloadSources = [
{ value: "mirror", label: "尽量使用镜像源(推荐国内用户)" },
@@ -26,7 +26,7 @@ export function DownloadSetting() {
<p className="text-[13px] text-muted-foreground">jarlib</p>
<RadioGroup
value={dl.fileSource}
onValueChange={(v) => setDownload({ fileSource: v })}
onChange={(v) => setDownload({ fileSource: String(v) })}
className="mt-2 space-y-2"
>
{downloadSources.map((opt) => (
@@ -44,7 +44,7 @@ export function DownloadSetting() {
<p className="text-[13px] text-muted-foreground"></p>
<RadioGroup
value={dl.versionSource}
onValueChange={(v) => setDownload({ versionSource: v })}
onChange={(v) => setDownload({ versionSource: String(v) })}
className="mt-2 space-y-2"
>
{downloadSources.map((opt) => (
@@ -103,7 +103,7 @@ export function DownloadSetting() {
type="number"
value={String(dl.speedLimit)}
onChange={(e) => setDownload({ speedLimit: Number(e.target.value) })}
className="w-28"
className={`w-28 ${fieldCls}`}
/>
<span className="text-[13px] text-muted-foreground">KB/s</span>
</div>
+3 -2
View File
@@ -2,7 +2,7 @@ import { useCallback } from "react";
import { useConfigStore } from "@/stores/configStore";
import { Switch, Input } from "@heroui/react";
import { ShieldCheck } from "lucide-react";
import { SettingCard, SettingRow, PageHeader, SectionTitle } from "@/components/setting";
import { SettingCard, SettingRow, PageHeader, SectionTitle, fieldCls } from "@/components/setting";
export function SecurityIdSetting() {
const enabled = useConfigStore((s) => s.config.network.securityId.enabled);
@@ -30,7 +30,7 @@ export function SecurityIdSetting() {
label="启用第三方认证"
desc="使用自定义认证服务器替代 Microsoft 认证(适用于离线服务器)"
>
<Switch isSelected={enabled} onValueChange={handleToggle}>
<Switch isSelected={enabled} onChange={handleToggle}>
<Switch.Control>
<Switch.Thumb />
</Switch.Control>
@@ -50,6 +50,7 @@ export function SecurityIdSetting() {
onChange={handleUrlChange}
placeholder="https://auth.example.com"
fullWidth
className={fieldCls}
/>
</div>
</div>
+3 -3
View File
@@ -15,7 +15,7 @@ export function A11ySetting() {
<div className="space-y-3">
<SettingCard>
<SettingRow label="减少动画" desc="关闭页面切换动画和背景动效">
<Switch isSelected={reduceMotion} onValueChange={setReduceMotion}>
<Switch isSelected={reduceMotion} onChange={setReduceMotion}>
<Switch.Control>
<Switch.Thumb />
</Switch.Control>
@@ -25,7 +25,7 @@ export function A11ySetting() {
<SettingCard>
<SettingRow label="减少透明度" desc="将磨砂玻璃效果替换为纯色背景,提升可读性">
<Switch isSelected={reduceTransparency} onValueChange={setReduceTransparency}>
<Switch isSelected={reduceTransparency} onChange={setReduceTransparency}>
<Switch.Control>
<Switch.Thumb />
</Switch.Control>
@@ -35,7 +35,7 @@ export function A11ySetting() {
<SettingCard>
<SettingRow label="高对比度" desc="增强文字与背景的对比度,改善可读性">
<Switch isSelected={highContrast} onValueChange={setHighContrast}>
<Switch isSelected={highContrast} onChange={setHighContrast}>
<Switch.Control>
<Switch.Thumb />
</Switch.Control>
+55 -7
View File
@@ -1,15 +1,63 @@
import { EmptyState } from "@heroui/react";
import { Languages } from "lucide-react";
import { PageHeader } from "@/components/setting";
import { useConfigStore } from "@/stores/configStore";
import {
SettingCard,
SettingSelect,
SettingBadge,
PageHeader,
SectionTitle,
} from "@/components/setting";
const languageOptions = [
{ value: "zh-CN", label: "简体中文" },
{ value: "en-US", label: "English" },
];
export function LangSetting() {
const language = useConfigStore((s) => s.config.app?.language ?? "zh-CN");
const setApp = useConfigStore((s) => s.setApp);
const handleChange = (v: string) => {
setApp({ language: v });
document.documentElement.lang = v;
};
return (
<div>
<PageHeader title="语言" desc="选择启动器界面的显示语言与地区偏好" />
<EmptyState className="py-16">
<Languages className="w-10 h-10 text-muted-foreground/30" />
<p className="text-sm text-muted-foreground mt-3"></p>
</EmptyState>
<div className="space-y-6">
<div>
<SectionTitle></SectionTitle>
<div className="space-y-3">
<SettingCard>
<SettingSelect
label="界面语言"
desc="切换后保存偏好并更新页面 lang 属性"
value={language}
options={languageOptions}
onChange={handleChange}
/>
</SettingCard>
</div>
</div>
<div>
<SectionTitle></SectionTitle>
<SettingCard>
<div className="flex items-start gap-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<p className="text-sm font-medium text-foreground"></p>
<SettingBadge variant="warning"></SettingBadge>
</div>
<p className="text-[13px] text-muted-foreground mt-0.5">
线
</p>
</div>
</div>
</SettingCard>
</div>
</div>
</div>
);
}
@@ -12,14 +12,15 @@ function ThemePreviewCard({ mode, selected, onClick }: { mode: DarkMode; selecte
const label = isAuto ? "跟随系统" : isDark ? "深色模式" : "浅色模式";
return (
<button
onClick={onClick}
<Button
variant="ghost"
onPress={onClick}
className={clsx(
"relative rounded-md p-1 transition-all duration-200",
"relative rounded-md p-1 transition-all duration-200 h-auto min-w-0",
"border-2",
selected
? "border-primary ring-2 ring-primary/20"
: "border-transparent hover:border-muted-foreground/20",
? "!border-primary ring-2 ring-primary/20"
: "!border-transparent hover:!border-muted-foreground/20",
)}
>
<div className="relative w-[140px] h-[96px] rounded overflow-hidden">
@@ -77,7 +78,7 @@ function ThemePreviewCard({ mode, selected, onClick }: { mode: DarkMode; selecte
)}
</div>
<p className="text-[11px] text-center mt-1.5 text-muted-foreground">{label}</p>
</button>
</Button>
);
}
@@ -170,7 +171,7 @@ export function ThemeBgSetting() {
<SettingCard>
<SettingRow label="背景图片视差" desc="背景图片随窗口滚动产生视差位移">
<Switch isSelected={parallax} onValueChange={setParallax}>
<Switch isSelected={parallax} onChange={setParallax}>
<Switch.Control>
<Switch.Thumb />
</Switch.Control>
+50 -8
View File
@@ -1,15 +1,57 @@
import { EmptyState } from "@heroui/react";
import { Monitor } from "lucide-react";
import { PageHeader } from "@/components/setting";
import { useConfigStore } from "@/stores/configStore";
import {
SettingCard,
SettingSwitch,
PageHeader,
SectionTitle,
} from "@/components/setting";
export function UiSetting() {
const ui = useConfigStore((s) => s.config.ui);
const setUi = useConfigStore((s) => s.setUi);
return (
<div>
<PageHeader title="主界面" desc="自定义启动器主界面的布局、模块显示与交互方式" />
<EmptyState className="py-16">
<Monitor className="w-10 h-10 text-muted-foreground/30" />
<p className="text-sm text-muted-foreground mt-3"></p>
</EmptyState>
<PageHeader title="主界面" desc="自定义启动器主界面的元素显示与交互方式" />
<div className="space-y-6">
<div>
<SectionTitle></SectionTitle>
<div className="space-y-3">
<SettingCard>
<SettingSwitch
label="首页实例标题"
desc="在首页左下角显示当前实例的大标题(点击可进入实例管理)"
checked={ui?.showInstanceTitle ?? true}
onChange={(v) => setUi({ showInstanceTitle: v })}
/>
</SettingCard>
<SettingCard>
<SettingSwitch
label="任务队列按钮"
desc="在标题栏右侧显示任务队列入口(安装/下载进度)"
checked={ui?.showTaskButton ?? true}
onChange={(v) => setUi({ showTaskButton: v })}
/>
</SettingCard>
</div>
</div>
<div>
<SectionTitle></SectionTitle>
<SettingCard>
<div className="space-y-1.5">
<p className="text-[13px] text-muted-foreground">
/ /
</p>
<p className="text-[13px] text-muted-foreground">
</p>
</div>
</SettingCard>
</div>
</div>
</div>
);
}
+59 -41
View File
@@ -1,8 +1,9 @@
import { create } from "zustand";
import {
getConfig,
saveConfig,
updateConfig,
type AppConfig,
type AppInfoConfig,
type ThemeConfig,
type A11yConfig,
type BackgroundConfig,
@@ -11,19 +12,17 @@ import {
type AdvancedConfig,
type DownloadConfig,
type NetworkConfig,
type UiConfig,
type InstanceMeta,
} from "@/api/config";
import { DEFAULT_BG } from "@/lib/mode";
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);
}
/**
* 配置 store(主进程权威模型的渲染端镜像):
* - 启动时由 config:preload / config:get 填充
* - 所有 setX 只向主进程提交 { section, patch }config:update),不直接写盘
* - 主进程合并后广播 config:changed,收到后以广播为准覆盖本地
*/
interface ConfigState {
config: AppConfig;
@@ -32,6 +31,8 @@ interface ConfigState {
init: () => Promise<void>;
applyPreloaded: (config: AppConfig, isFirstLaunch: boolean) => void;
applyChanged: (config: AppConfig) => void;
setApp: (patch: Partial<AppInfoConfig>) => void;
setTheme: (patch: Partial<ThemeConfig>) => void;
setA11y: (patch: Partial<A11yConfig>) => void;
setBackground: (patch: Partial<BackgroundConfig>) => void;
@@ -40,6 +41,7 @@ interface ConfigState {
setAdvanced: (patch: Partial<AdvancedConfig>) => void;
setDownload: (patch: Partial<DownloadConfig>) => void;
setNetwork: (patch: Partial<NetworkConfig>) => void;
setUi: (patch: Partial<UiConfig>) => void;
setInstances: (instances: InstanceMeta[]) => void;
setOobe: (value: boolean) => void;
}
@@ -47,17 +49,26 @@ interface ConfigState {
const DEFAULT_CONFIG: AppConfig = {
version: 1,
oobe: true,
app: { language: "zh-CN" },
theme: { darkMode: "auto", parallax: true },
a11y: { reduceMotion: false, reduceTransparency: false, highContrast: false, contentBlurOpacity: 50 },
background: { bgType: "image", image: DEFAULT_BG, blur: 0, opacity: 100 },
game: { gameDir: ".minecraft", resourceDir: "", savesDir: "", instancesDir: ".minecraft/instances", gameDirs: [] },
java: { javaPath: "", memMode: "auto", memGB: 4, gc: "auto", jvmArgs: "" },
advanced: { afterLaunch: "close", winMode: "default", customWidth: 854, customHeight: 480, gameArgs: "", preLaunchCmd: "", debugMode: false },
advanced: { afterLaunch: "close", winMode: "default", customWidth: 854, customHeight: 480, gameArgs: "", preLaunchCmd: "", debugMode: false, server: { ip: "", port: 25565 } },
download: { fileSource: "mirror", versionSource: "mirror", threads: 16, speedLimit: 0 },
network: { securityId: { enabled: false, authUrl: "" } },
ui: { showInstanceTitle: true, showTaskButton: true },
instances: [],
};
// 乐观更新本地 + 提交主进程;主进程广播回来时以广播为准(applyChanged 覆盖)
function submit(section: string, patch: unknown) {
updateConfig(section, patch).catch((e) => {
console.error(`[config] update ${section} failed:`, e);
});
}
export const useConfigStore = create<ConfigState>((set, get) => ({
config: DEFAULT_CONFIG,
loaded: false,
@@ -67,6 +78,11 @@ export const useConfigStore = create<ConfigState>((set, get) => ({
set({ config, isFirstLaunch, loaded: true });
},
// 主进程广播的权威配置 → 整体覆盖本地镜像
applyChanged: (config) => {
set({ config, loaded: true });
},
init: async () => {
// If already preloaded, skip IPC call
if (get().loaded) return;
@@ -79,73 +95,75 @@ export const useConfigStore = create<ConfigState>((set, get) => ({
}
},
setApp: (patch) => {
const { config } = get();
set({ config: { ...config, app: { ...config.app, ...patch } } });
submit("app", patch);
},
setTheme: (patch) => {
const { config } = get();
const next = { ...config, theme: { ...config.theme, ...patch } };
set({ config: next });
debouncedSave(next);
set({ config: { ...config, theme: { ...config.theme, ...patch } } });
submit("theme", patch);
},
setA11y: (patch) => {
const { config } = get();
const next = { ...config, a11y: { ...config.a11y, ...patch } };
set({ config: next });
debouncedSave(next);
set({ config: { ...config, a11y: { ...config.a11y, ...patch } } });
submit("a11y", patch);
},
setBackground: (patch) => {
const { config } = get();
const next = { ...config, background: { ...config.background, ...patch } };
set({ config: next });
debouncedSave(next);
set({ config: { ...config, background: { ...config.background, ...patch } } });
submit("background", patch);
},
setGame: (patch) => {
const { config } = get();
const next = { ...config, game: { ...config.game, ...patch } };
set({ config: next });
debouncedSave(next);
set({ config: { ...config, game: { ...config.game, ...patch } } });
submit("game", patch);
},
setJava: (patch) => {
const { config } = get();
const next = { ...config, java: { ...config.java, ...patch } };
set({ config: next });
debouncedSave(next);
set({ config: { ...config, java: { ...config.java, ...patch } } });
submit("java", patch);
},
setAdvanced: (patch) => {
const { config } = get();
const next = { ...config, advanced: { ...config.advanced, ...patch } };
set({ config: next });
debouncedSave(next);
set({ config: { ...config, advanced: { ...config.advanced, ...patch } } });
submit("advanced", patch);
},
setDownload: (patch) => {
const { config } = get();
const next = { ...config, download: { ...config.download, ...patch } };
set({ config: next });
debouncedSave(next);
set({ config: { ...config, download: { ...config.download, ...patch } } });
submit("download", patch);
},
setNetwork: (patch) => {
const { config } = get();
const next = { ...config, network: { ...config.network, ...patch } };
set({ config: next });
debouncedSave(next);
set({ config: { ...config, network: { ...config.network, ...patch } } });
submit("network", patch);
},
setUi: (patch) => {
const { config } = get();
set({ config: { ...config, ui: { ...config.ui, ...patch } } });
submit("ui", patch);
},
setInstances: (instances) => {
const { config } = get();
const next = { ...config, instances };
set({ config: next });
debouncedSave(next);
set({ config: { ...config, instances } });
submit("instances", instances);
},
setOobe: (value) => {
const { config } = get();
const next = { ...config, oobe: value };
set({ config: next });
debouncedSave(next);
set({ config: { ...config, oobe: value } });
submit("oobe", value);
},
}));
-24
View File
@@ -5,7 +5,6 @@ import {
deleteInstance,
getInstanceInfo,
installInstance,
launchInstance,
type InstanceInfo,
type InstanceRuntime,
} from "../api/instance";
@@ -32,17 +31,6 @@ interface InstanceState {
remove: (name: string, gamePath: string) => Promise<void>;
select: (name: string, gamePath: string) => Promise<void>;
install: (name: string, gamePath: string) => Promise<string>;
launch: (
name: string,
gamePath: string,
options: {
username: string;
uuid: string;
accessToken?: string;
javaPath?: string;
server?: { host: string; port?: number };
}
) => Promise<string>;
clearError: () => void;
}
@@ -112,17 +100,5 @@ export const useInstanceStore = create<InstanceState>((set) => ({
}
},
launch: async (name, gamePath, options) => {
set({ loading: true, error: null });
try {
const { requestId } = await launchInstance(name, gamePath, options);
set({ loading: false });
return requestId;
} catch (e: any) {
set({ error: e.message, loading: false });
throw e;
}
},
clearError: () => set({ error: null }),
}));
+36 -17
View File
@@ -1,6 +1,7 @@
import { create } from "zustand";
import { launchGame, onGameEvent, diagnoseVersion } from "../api/launch";
import type { LaunchOptions, LaunchResult } from "../api/launch";
import type { LaunchResult, LaunchServer } from "../api/launch";
import { useAuthStore } from "./authStore";
interface GameEvent {
event: string;
@@ -8,13 +9,19 @@ interface GameEvent {
}
interface LaunchState {
/** 正在发起启动请求 */
launching: boolean;
launched: boolean;
/** 游戏进程正在运行 */
running: boolean;
gameResult: LaunchResult | null;
events: GameEvent[];
error: string | null;
launch: (options: LaunchOptions) => Promise<void>;
/**
* 统一启动入口:指定实例 + 游戏根目录(可选快速联机)。
* 账户档案从 authStore 自动获取;启动参数(Java/内存/GC/窗口等)由主进程读取权威配置自动应用。
*/
launch: (instanceName: string, gamePath: string, server?: LaunchServer) => Promise<void>;
diagnose: (gamePath: string, version: string) => Promise<void>;
reset: () => void;
clearError: () => void;
@@ -22,30 +29,42 @@ interface LaunchState {
export const useLaunchStore = create<LaunchState>((set) => ({
launching: false,
launched: false,
running: false,
gameResult: null,
events: [],
error: null,
launch: async (options: LaunchOptions) => {
set({ launching: true, error: null, events: [] });
launch: async (instanceName: string, gamePath: string, server?: LaunchServer) => {
const user = useAuthStore.getState().user;
if (!user?.username || !user?.uuid) {
set({ error: "请先在设置中登录账号", launching: false });
return;
}
set({ launching: true, running: false, error: null, events: [], gameResult: null });
try {
const result = await launchGame(options);
set({ gameResult: result, launching: false, launched: true });
// Listen for game events
const unlisten = await onGameEvent(result.requestId, (event) => {
set((state) => ({
events: [...state.events, event],
}));
const result = await launchGame({
instanceName,
gamePath,
profile: {
username: user.username,
uuid: user.uuid,
accessToken: user.accessToken || undefined,
},
server,
});
set({ gameResult: result, launching: false, running: true });
// 订阅事件流(stdout / stderr / window-ready / exit
const unlisten = onGameEvent(result.requestId, (event) => {
set((state) => ({ events: [...state.events, event] }));
if (event.event === "exit") {
set({ launched: false });
set({ running: false });
unlisten();
}
});
} catch (e: any) {
set({ error: e.message, launching: false });
set({ error: e?.message || String(e), launching: false, running: false });
}
},
@@ -58,7 +77,7 @@ export const useLaunchStore = create<LaunchState>((set) => ({
},
reset: () => {
set({ launching: false, launched: false, gameResult: null, events: [] });
set({ launching: false, running: false, gameResult: null, events: [], error: null });
},
clearError: () => set({ error: null }),
+2
View File
@@ -13,6 +13,8 @@ interface ElectronAPI {
onConfigPreload: (callback: (data: { config: unknown; isFirstLaunch: boolean }) => void) => () => void;
onConfigChanged: (callback: (config: unknown) => void) => () => void;
openExternal: (url: string) => Promise<void>;
// Crash monitoring