mirror of
https://github.com/dream-pep/koring-launcher.git
synced 2026-09-12 05:45:18 +08:00
feat: 发布 v1.2.0 版本,新增多项核心功能
- 引入 electron-updater 实现自动更新功能 - 新增 Java 环境扫描与校验的 IPC 处理逻辑 - 实现离线账号登录功能 - 新增配置变更跨进程广播机制 - 重构游戏启动逻辑,使用主进程内存配置作为唯一权威来源 - 新增界面显示与语言设置的配置页面 - 添加 Windows 平台自动发布 CI 流水线 - 迁移旧版配置/认证文件到用户数据目录 - 修复崩溃日志路径、表单控件等多项 bug - 重构设置页组件系统统一界面样式
This commit is contained in:
+17
-2
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { ipcInvoke } from './ipc';
|
||||
|
||||
export interface JavaInfo {
|
||||
path: string;
|
||||
version: string;
|
||||
majorVersion: number;
|
||||
}
|
||||
|
||||
/** 扫描系统已安装的 Java(JAVA_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
@@ -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
|
||||
|
||||
@@ -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: "" })}
|
||||
>
|
||||
重试
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
@@ -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-aria,Switch 使用 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>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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
@@ -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}`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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="扫描系统中已安装的 Java(JAVA_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>
|
||||
|
||||
@@ -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">游戏文件(jar、lib)的下载来源</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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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);
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -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
@@ -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 }),
|
||||
|
||||
Vendored
+2
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user