refactor: 迁移组件库至shadcn/ui并新增版本更新功能

迁移项目内所有HeroUI表单组件至shadcn/ui,统一组件风格并修复HeroUI Switch点击无响应的问题
新增全局版本更新弹窗功能,包含状态管理store、全局弹窗组件与调试页面调试工具
优化配置广播逻辑,添加250ms防抖避免频繁触发页面重渲染
新增开发环境下日志直接输出到终端的能力,完善日志输出规则
调整调试模式设置项位置,从高级设置页移动至关于设置页
为背景层添加pointer-events: none,避免遮挡页面点击事件
新增点击拦截诊断工具至资源调试页面,用于排查控件无法点击的问题
更新Koring.yml配置模板,新增advanced.preLaunchCmd字段
This commit is contained in:
2026-09-05 00:45:06 +08:00
parent a11e51d62c
commit dfda648147
21 changed files with 530 additions and 65 deletions
+1 -1
View File
@@ -67,7 +67,7 @@ build/ ← generated by switch-icon.js (gitignored)
- `electron/auth.ts`: Auth data persistence (JSON file) - `electron/auth.ts`: Auth data persistence (JSON file)
- `electron/core/`: @xmcl/* integrations (auth, installer, launcher, modrinth, instance) - `electron/core/`: @xmcl/* integrations (auth, installer, launcher, modrinth, instance)
- `electron/core/background-image.ts`: 背景图处理服务 —— 自选壁纸复制到 userData 并按屏幕尺寸降采样/重编码**落盘**,配置文件只存**文件路径**(不使用 BASE64 - `electron/core/background-image.ts`: 背景图处理服务 —— 自选壁纸复制到 userData 并按屏幕尺寸降采样/重编码**落盘**,配置文件只存**文件路径**(不使用 BASE64
- `electron/core/logger.ts`: 统一日志 —— 全局包装 `ipcMain.handle`channel/耗时/成败);开启 debug 模式(`config.advanced.debugMode`)后写 `userData/koring.log`(5MB 轮转),否则仅控制台;渲染端经 `log:write` 桥汇入 - `electron/core/logger.ts`: 统一日志 —— 全局包装 `ipcMain.handle`channel/耗时/成败);开启 debug 模式(`config.advanced.debugMode`)后写 `userData/koring.log`5MB 轮转),否则仅控制台;**dev(未打包)运行下日志直接写进程 stdout/stderr 输出到启动终端**渲染端经 `log:write` 桥汇入
- `electron/resource-protocol.ts`: `koring-res://` 特权自定义协议 —— 渲染进程以「资源引用」流式读取本地壁纸;仅服务 userData 内 `background-custom*` 白名单文件(realpath 二次校验,防目录穿越) - `electron/resource-protocol.ts`: `koring-res://` 特权自定义协议 —— 渲染进程以「资源引用」流式读取本地壁纸;仅服务 userData 内 `background-custom*` 白名单文件(realpath 二次校验,防目录穿越)
- `electron/handlers/`: IPC handlers (config, auth, install, launch, mods, instance, background, task, system, window) - `electron/handlers/`: IPC handlers (config, auth, install, launch, mods, instance, background, task, system, window)
+2
View File
@@ -1,2 +1,4 @@
appVersion: 1.2.6 appVersion: 1.2.6
oobe: false oobe: false
advanced:
preLaunchCmd: '123'
File diff suppressed because one or more lines are too long
+14 -4
View File
@@ -5,6 +5,7 @@
* - 默认(非 debug):warn/error/info 输出到控制台,debug 不输出; * - 默认(非 debug):warn/error/info 输出到控制台,debug 不输出;
* - 用户开启「调试模式」(config.advanced.debugMode,设置→游戏→高级): * - 用户开启「调试模式」(config.advanced.debugMode,设置→游戏→高级):
* debug 也输出控制台,并把全部级别写入 userData/koring.log(超过 5MB 自动轮转为 .old); * debug 也输出控制台,并把全部级别写入 userData/koring.log(超过 5MB 自动轮转为 .old);
* - 开发(未打包)运行且开启调试模式时:日志额外/直接输出到启动该进程的终端(stdout/stderr);
* - 渲染进程经 `log:write`ipcRenderer.send)汇入同一套格式/文件。 * - 渲染进程经 `log:write`ipcRenderer.send)汇入同一套格式/文件。
*/ */
@@ -25,6 +26,9 @@ export interface Logger {
const MAX_LOG_BYTES = 5 * 1024 * 1024; const MAX_LOG_BYTES = 5 * 1024 * 1024;
/** 开发(未打包)运行:直接跑在终端里,日志写 stdout/stderr 用户即可实时看到 */
const isDevRun = !app.isPackaged;
let debugModeProvider: () => boolean = () => false; let debugModeProvider: () => boolean = () => false;
export function setDebugModeProvider(fn: () => boolean): void { export function setDebugModeProvider(fn: () => boolean): void {
debugModeProvider = fn; debugModeProvider = fn;
@@ -109,10 +113,16 @@ function write(scope: string, level: LogLevel, args: unknown[]): void {
const line = `[${timestamp()}][${scope}][${level.toUpperCase()}] ${msg}`; const line = `[${timestamp()}][${scope}][${level.toUpperCase()}] ${msg}`;
const enabled = isDebugMode(); const enabled = isDebugMode();
// debug 仅在调试模式可见;info/warn/error 始终走控制台 // debug 仅在调试模式可见;info/warn/error 始终输出
const showConsole = level !== 'debug' || enabled; const show = level !== 'debug' || enabled;
if (showConsole) { if (show) {
if (level === 'error') console.error(line); if (isDevRun) {
// dev(未打包,pnpm dev / electron .):直接写进程 stdout/stderr
// 让详细日志实时出现在启动它的终端里(不依赖外部控制台捕获)。
const text = `${line}\n`;
if (level === 'error' || level === 'warn') process.stderr.write(text);
else process.stdout.write(text);
} else if (level === 'error') console.error(line);
else if (level === 'warn') console.warn(line); else if (level === 'warn') console.warn(line);
else if (level === 'info') console.info(line); else if (level === 'info') console.info(line);
else console.debug(line); else console.debug(line);
+15 -2
View File
@@ -26,13 +26,26 @@ export function registerConfigHandlers(win: WinRef) {
} }
}); });
// 广播防抖:输入框/滑块每键触发 update 时,不立刻全树广播(避免整页重渲染打断交互),
// 合并到 250ms 后只广播一次最新配置;渲染端乐观更新保证即时反馈。
let broadcastTimer: ReturnType<typeof setTimeout> | null = null;
const scheduleBroadcast = () => {
if (broadcastTimer) clearTimeout(broadcastTimer);
broadcastTimer = setTimeout(() => {
broadcastTimer = null;
if (win.mainWindow && !win.mainWindow.isDestroyed()) {
win.mainWindow.webContents.send('config:changed', getConfig());
}
}, 250);
};
// 主进程权威更新:渲染进程提交 { section, patch } 补丁, // 主进程权威更新:渲染进程提交 { section, patch } 补丁,
// 主进程深度合并到内存配置 → debounce 稀疏写盘 → 广播完整配置给所有渲染进程 // 主进程深度合并到内存配置 → debounce 稀疏写盘 → 防抖广播完整配置给所有渲染进程
ipcMain.handle('config:update', (_event, payload: { section: string; patch: unknown }) => { ipcMain.handle('config:update', (_event, payload: { section: string; patch: unknown }) => {
try { try {
const { section, patch } = payload; const { section, patch } = payload;
const config = updateConfig({ [section]: patch } as Record<string, unknown>); const config = updateConfig({ [section]: patch } as Record<string, unknown>);
win.mainWindow?.webContents.send('config:changed', config); scheduleBroadcast();
return { success: true, data: config, error: null }; return { success: true, data: config, error: null };
} catch (e: unknown) { } catch (e: unknown) {
return { success: false, data: null, error: String(e) }; return { success: false, data: null, error: String(e) };
+2 -1
View File
@@ -25,7 +25,8 @@ const { app } = electron;
const isDev = !app.isPackaged; const isDev = !app.isPackaged;
// 统一日志:debug 模式(config.advanced.debugMode)→ 控制台 + userData/koring.log否则仅控制台 // 统一日志:debug 模式(config.advanced.debugMode)→ 控制台 + userData/koring.log
// dev(未打包)运行下直接写进程 stdout/stderr,日志实时输出到启动它的终端;否则仅控制台
const log = createLogger('main'); const log = createLogger('main');
setDebugModeProvider(() => { setDebugModeProvider(() => {
try { try {
+117
View File
@@ -0,0 +1,117 @@
import { useEffect, useRef } from "react";
import {
AlertDialog,
AlertDialogContent,
AlertDialogDescription,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { onUpdateStatus } from "@/api/update";
import { useRouteStore } from "@/stores/routeStore";
import { useUpdateDialogStore } from "@/stores/updateDialogStore";
import { BUILD_MODE } from "@/lib/mode";
import { VERSION } from "@/lib/version";
import { VersionCard } from "@/components/VersionCard";
// 与 VersionCard 一致的构建模式配色(dev 橙 / beta 绿 / run 蓝),用于主按钮底色
const modeGradients: Record<string, string> = {
dev: "linear-gradient(135deg, #F59E0B, #D97706)",
beta: "linear-gradient(135deg, #10B981, #059669)",
run: "linear-gradient(135deg, #3B82F6, #2563EB)",
};
/**
* "发现新版本" 弹窗(全局,RootLayout 挂载):
* - 主进程检查到新版本(状态进入 available)且不在版本更新页时自动弹出
* - 开发者工具可通过 useUpdateDialogStore.show(version) 手动唤起(用于预览)
* - 上半部分直接复用 VersionCard(模式渐变 + Silk + Logo,随构建模式变色)
* - 按钮:稍后更新 / 立即更新(跳转版本更新页面)
*/
export function UpdateAvailableDialog() {
const open = useUpdateDialogStore((s) => s.open);
const version = useUpdateDialogStore((s) => s.version);
const hide = useUpdateDialogStore((s) => s.hide);
const show = useUpdateDialogStore((s) => s.show);
const prevStateRef = useRef<string>("idle");
const currentRouteRef = useRef<string>(useRouteStore.getState().current);
useEffect(() => {
// 跟随路由(更新页自身不弹,避免打扰已在该页操作的用户)
return useRouteStore.subscribe(() => {
currentRouteRef.current = useRouteStore.getState().current;
});
}, []);
useEffect(() => {
const unsub = onUpdateStatus((status) => {
// 仅在「进入 available」这一跳变时自动弹出(checking→available / 再次手动检查会重新触发)
const alreadyOpen = useUpdateDialogStore.getState().open;
if (
status.state === "available" &&
prevStateRef.current !== "available" &&
currentRouteRef.current !== "update" &&
!alreadyOpen
) {
show(status.version);
}
prevStateRef.current = status.state;
});
return unsub;
}, [show]);
const goUpdate = () => {
hide();
// 等关闭动画结束后再切换路由,避免弹窗关闭与 view transition 快照抢帧导致无过渡
window.setTimeout(() => {
useRouteStore.getState().navigate("update");
}, 220);
};
const gradient = modeGradients[BUILD_MODE] ?? modeGradients.run;
const targetVersion = version || VERSION;
return (
<AlertDialog open={open} onOpenChange={(o) => !o && hide()}>
<AlertDialogContent
className="gap-0 overflow-hidden rounded-2xl p-0"
style={{ width: "min(720px, calc(100vw - 2rem))", maxWidth: "min(720px, calc(100vw - 2rem))" }}
>
{/* 统一内边距容器:卡片与正文共用同一水平宽度(安全区不粘连边框) */}
<div className="flex flex-col p-2.5 sm:p-3">
{/* 上半部分:直接复用 VersionCard(全宽;关闭共享过渡名,避免打断路由切换动画) */}
<VersionCard noViewTransition className="w-full" />
{/* 下半部分:与版本卡同宽的说明 + 按钮 */}
<div className="flex flex-col gap-4 px-1 pt-4 pb-1">
<div>
<AlertDialogTitle className="font-heading text-base font-semibold text-foreground">
</AlertDialogTitle>
<AlertDialogDescription className="mt-1.5 text-[13px] leading-relaxed">
v{VERSION} v{targetVersion}
</AlertDialogDescription>
</div>
<div className="flex gap-2.5">
<button
onClick={hide}
className="flex-1 h-10 rounded-lg text-[13px] font-medium bg-foreground/[0.05] hover:bg-foreground/[0.1] text-foreground/70 hover:text-foreground transition-colors cursor-pointer"
>
</button>
<button
onClick={goUpdate}
className="flex-[1.4] h-10 rounded-lg text-[13px] font-semibold text-white transition-colors cursor-pointer"
style={{ background: gradient, boxShadow: "0 2px 10px rgba(0,0,0,0.12)" }}
onMouseEnter={(e) => (e.currentTarget.style.filter = "brightness(1.08)")}
onMouseLeave={(e) => (e.currentTarget.style.filter = "none")}
>
</button>
</div>
</div>
</div>
</AlertDialogContent>
</AlertDialog>
);
}
+13 -2
View File
@@ -40,6 +40,11 @@ interface VersionCardProps {
* 并显示「查看更新」小字提示;其他页面为 false * 并显示「查看更新」小字提示;其他页面为 false
*/ */
isSettingPage?: boolean; isSettingPage?: boolean;
/**
* 关闭共享元素过渡(view-transition-name):弹窗/浮层里复用 VersionCard 时开启,
* 避免与页面上的 VersionCard 重名导致路由切换过渡失效
*/
noViewTransition?: boolean;
} }
export function VersionCard({ export function VersionCard({
@@ -49,6 +54,7 @@ export function VersionCard({
simple = false, simple = false,
oobe = false, oobe = false,
isSettingPage = false, isSettingPage = false,
noViewTransition = false,
}: VersionCardProps) { }: VersionCardProps) {
const color = modeColors[overrideMode ?? BUILD_MODE] ?? modeColors.run; const color = modeColors[overrideMode ?? BUILD_MODE] ?? modeColors.run;
const gradient = modeGradients[overrideMode ?? BUILD_MODE] ?? modeGradients.run; const gradient = modeGradients[overrideMode ?? BUILD_MODE] ?? modeGradients.run;
@@ -76,8 +82,13 @@ export function VersionCard({
)} )}
onClick={handleCardClick} onClick={handleCardClick}
// 共享元素过渡:路由切换时(startViewTransition),新旧页面中同名 view-transition-name // 共享元素过渡:路由切换时(startViewTransition),新旧页面中同名 view-transition-name
// 的元素会从上一个位置平滑移动/形变到当前页面的位置 // 的元素会从上一个位置平滑移动/形变到当前页面的位置
style={{ viewTransitionName: "version-card" } as React.CSSProperties} // 浮层/弹窗复用(noViewTransition)时关闭,避免与页面卡片重名打断过渡。
style={
noViewTransition
? undefined
: ({ viewTransitionName: "version-card" } as React.CSSProperties)
}
> >
{/* 背景层 */} {/* 背景层 */}
<div className="absolute inset-0" style={{ background: gradient }} /> <div className="absolute inset-0" style={{ background: gradient }} />
@@ -177,6 +177,7 @@ export function BackgroundLayer() {
const imageLayerStyle = (url: string): React.CSSProperties => ({ const imageLayerStyle = (url: string): React.CSSProperties => ({
position: "absolute", position: "absolute",
inset: 0, inset: 0,
pointerEvents: "none", // 背景层永不接收任何指针事件
backgroundImage: `url(${url})`, backgroundImage: `url(${url})`,
backgroundSize: "cover", backgroundSize: "cover",
backgroundPosition: "center", backgroundPosition: "center",
+9 -7
View File
@@ -11,11 +11,11 @@ import {
Radio, Radio,
RadioGroup, RadioGroup,
Select, Select,
Switch,
TextArea, TextArea,
} from "@heroui/react"; } from "@heroui/react";
import { Check, ChevronDown, FolderOpen, FolderSearch, Loader2 } from "lucide-react"; import { Check, ChevronDown, FolderOpen, FolderSearch, Loader2 } from "lucide-react";
import { ipcInvoke } from "@/api/ipc"; import { ipcInvoke } from "@/api/ipc";
import { Switch } from "@/components/ui/switch";
import { SettingRow } from "./SettingRow"; import { SettingRow } from "./SettingRow";
export interface SettingOption { export interface SettingOption {
@@ -51,6 +51,7 @@ export function SettingSelect({
return ( return (
<SettingRow label={label} desc={desc}> <SettingRow label={label} desc={desc}>
<Select.Root <Select.Root
aria-label={label}
selectedKey={selectedKey} selectedKey={selectedKey}
onSelectionChange={(keys) => { onSelectionChange={(keys) => {
// RAC 单选时可能传 Key | null,也可能传 Set<Key>;两种形状都兼容 // RAC 单选时可能传 Key | null,也可能传 Set<Key>;两种形状都兼容
@@ -119,6 +120,7 @@ export function SettingNumberField({
<SettingRow label={label} desc={desc}> <SettingRow label={label} desc={desc}>
<div className={`flex items-center gap-1.5 ${className ?? ""}`}> <div className={`flex items-center gap-1.5 ${className ?? ""}`}>
<NumberField.Root <NumberField.Root
aria-label={label}
value={value} value={value}
onChange={onChange} onChange={onChange}
minValue={min} minValue={min}
@@ -162,12 +164,12 @@ export function SettingSwitch({
}) { }) {
return ( return (
<SettingRow label={label} desc={desc}> <SettingRow label={label} desc={desc}>
{/* 注:HeroUI 3 基于 react-ariaSwitch 使用 onChange 而非 onValueChange */} {/* shadcn Switch@base-ui/react/switch):替代 HeroUI 3 Switch(鼠标点击不触发 change */}
<Switch isSelected={checked} onChange={onChange}> <Switch
<Switch.Control> aria-label={label}
<Switch.Thumb /> checked={checked}
</Switch.Control> onCheckedChange={onChange}
</Switch> />
</SettingRow> </SettingRow>
); );
} }
+202
View File
@@ -0,0 +1,202 @@
"use client"
/**
* shadcn 风格 Select(基于 @base-ui/react/select,与项目 switch/radio/slider 同底座)。
* 用法:
* <Select value={v} onValueChange={setV}>
* <SelectTrigger aria-label="x"><SelectValue placeholder="请选择" /></SelectTrigger>
* <SelectContent>
* <SelectItem value="a">A</SelectItem>
* </SelectContent>
* </Select>
*/
import { Select as SelectPrimitive } from "@base-ui/react/select"
import { cn } from "@/lib/utils"
function Select({
className,
...props
}: SelectPrimitive.Root.Props<string>) {
return (
<SelectPrimitive.Root
data-slot="select"
className={cn("", className)}
{...props}
/>
)
}
function SelectValue({
className,
placeholder,
...props
}: SelectPrimitive.Value.Props & { placeholder?: string }) {
if (placeholder) {
return (
<SelectPrimitive.Value
placeholder={placeholder}
data-slot="select-value"
className={cn("text-[13px] text-foreground data-[placeholder]:text-muted-foreground/60", className)}
{...props}
/>
)
}
return (
<SelectPrimitive.Value
data-slot="select-value"
className={cn("text-[13px] text-foreground", className)}
{...props}
/>
)
}
function SelectTrigger({
className,
size = "default",
...props
}: SelectPrimitive.Trigger.Props & { size?: "sm" | "default" }) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"inline-flex h-8 w-full shrink-0 items-center justify-between gap-2 rounded-lg border border-border/40 bg-white/60 px-3 text-[13px] text-foreground outline-none transition-colors select-none dark:bg-black/30 dark:border-white/[0.08] focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 hover:border-ring/50",
className
)}
{...props}
/>
)
}
function SelectIcon({ className, ...props }: SelectPrimitive.Icon.Props) {
return (
<SelectPrimitive.Icon
data-slot="select-icon"
className={cn("shrink-0 text-muted-foreground/60", className)}
{...props}
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
>
<path d="m6 9 6 6 6-6" />
</svg>
</SelectPrimitive.Icon>
)
}
function SelectContent({
className,
position = "popper",
side = "bottom",
sideOffset = 4,
...props
}: SelectPrimitive.Popup.Props & {
position?: "popper" | "item-aligned"
side?: "top" | "bottom" | "left" | "right"
sideOffset?: number
}) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Positioner
side={side}
sideOffset={sideOffset}
className={cn(
"z-50",
position === "popper" &&
"w-[var(--anchor-width)] min-w-[10rem]"
)}
>
<SelectPrimitive.Popup
data-slot="select-content"
className={cn(
"relative max-h-72 min-w-[10rem] overflow-y-auto scroll-area rounded-xl border border-border/50 bg-background p-1.5 text-[13px] text-foreground shadow-xl outline-none",
className
)}
{...props}
/>
</SelectPrimitive.Positioner>
</SelectPrimitive.Portal>
)
}
function SelectItem({
className,
children,
...props
}: SelectPrimitive.Item.Props) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"group/item relative flex w-full cursor-pointer items-center gap-2 rounded-lg py-1.5 pr-8 pl-2.5 text-[13px] text-foreground outline-none select-none",
"data-highlighted:bg-muted data-selected:bg-primary/10 data-selected:text-primary",
"data-disabled:pointer-events-none data-disabled:opacity-50",
className
)}
{...props}
>
<SelectPrimitive.ItemText className="min-w-0 flex-1 truncate">
{children}
</SelectPrimitive.ItemText>
<SelectPrimitive.ItemIndicator className="absolute right-2 text-primary">
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
>
<path d="M20 6 9 17l-5-5" />
</svg>
</SelectPrimitive.ItemIndicator>
</SelectPrimitive.Item>
)
}
function SelectLabel({ className, ...props }: SelectPrimitive.Label.Props) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("px-2.5 py-1.5 text-[12px] text-muted-foreground", className)}
{...props}
/>
)
}
function SelectSeparator({
className,
...props
}: SelectPrimitive.Separator.Props) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("my-1 h-px bg-border", className)}
{...props}
/>
)
}
export {
Select,
SelectValue,
SelectTrigger,
SelectIcon,
SelectContent,
SelectItem,
SelectLabel,
SelectSeparator,
}
+4
View File
@@ -2,6 +2,7 @@ import { type ReactNode } from "react";
import { BackgroundLayer } from "@/components/background/BackgroundLayer"; import { BackgroundLayer } from "@/components/background/BackgroundLayer";
import { SystemLayer } from "@/components/system/SystemLayer"; import { SystemLayer } from "@/components/system/SystemLayer";
import { ConfirmDialog } from "@/components/ui/confirm-dialog"; import { ConfirmDialog } from "@/components/ui/confirm-dialog";
import { UpdateAvailableDialog } from "@/components/UpdateAvailableDialog";
import { Toaster } from "sonner"; import { Toaster } from "sonner";
import { useA11yStore } from "@/stores/a11yStore"; import { useA11yStore } from "@/stores/a11yStore";
import clsx from "clsx"; import clsx from "clsx";
@@ -55,6 +56,9 @@ export function RootLayout({
{/* Global confirm dialog */} {/* Global confirm dialog */}
<ConfirmDialog /> <ConfirmDialog />
{/* 全局:发现新版本弹窗(检查到新版本时自动弹出) */}
<UpdateAvailableDialog />
{/* Sonner toaster */} {/* Sonner toaster */}
<Toaster <Toaster
position="bottom-right" position="bottom-right"
+2 -1
View File
@@ -4,7 +4,8 @@
* 规则(与主进程 electron/core/logger.ts 对齐): * 规则(与主进程 electron/core/logger.ts 对齐):
* - 默认(非 debug):error/warn/info 输出到控制台(DevTools),debug 不输出; * - 默认(非 debug):error/warn/info 输出到控制台(DevTools),debug 不输出;
* - 用户开启「调试模式」(config.advanced.debugMode)后:debug 也输出, * - 用户开启「调试模式」(config.advanced.debugMode)后:debug 也输出,
* 并经由 electronAPI.log → 主进程 log:write 桥写入 userData/koring.log。 * 并经由 electronAPI.log → 主进程 log:write 桥汇入主进程统一日志:
* dev(未打包)运行下同步输出到启动终端的 stdout/stderr,同时写入 userData/koring.log。
*/ */
import { useConfigStore } from "@/stores/configStore"; import { useConfigStore } from "@/stores/configStore";
+74
View File
@@ -31,6 +31,7 @@ export function ResourceDebug() {
const [heap, setHeap] = useState<JsHeapInfo | null>(null); const [heap, setHeap] = useState<JsHeapInfo | null>(null);
const [domNodes, setDomNodes] = useState(0); const [domNodes, setDomNodes] = useState(0);
const [logInfo, setLogInfo] = useState<{ filePath: string | null; debugMode: boolean } | null>(null); const [logInfo, setLogInfo] = useState<{ filePath: string | null; debugMode: boolean } | null>(null);
const [hitInfo, setHitInfo] = useState<string[] | null>(null);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null); const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const snapshot = useResourceStore((s) => s.snapshot); const snapshot = useResourceStore((s) => s.snapshot);
@@ -38,6 +39,54 @@ export function ResourceDebug() {
const clearFree = useResourceStore((s) => s.clearFree); const clearFree = useResourceStore((s) => s.clearFree);
const resetCounters = useResourceStore((s) => s.resetCounters); const resetCounters = useResourceStore((s) => s.resetCounters);
/** 诊断「控件无法点击」:找出全屏覆盖且 pointer-events≠none 的元素,并采样几个点位的最上层元素 */
const runHitTest = () => {
const lines: string[] = [];
const all = document.querySelectorAll<HTMLElement>("body *");
// 1) 疑似全屏拦截层
const seen = new Set<HTMLElement>();
all.forEach((el) => {
const rect = el.getBoundingClientRect();
const cs = getComputedStyle(el);
const covers =
rect.width >= window.innerWidth * 0.97 && rect.height >= window.innerHeight * 0.97;
const clickable = cs.pointerEvents !== "none";
if (!covers || !clickable || seen.has(el)) return;
seen.add(el);
const tag = el.tagName.toLowerCase();
lines.push(
`[全屏可点] <${tag}${el.id ? `#${el.id}` : ""}> pos=${cs.position} z=${cs.zIndex} class="${String(el.className).slice(0, 120)}"`,
);
});
// 2) 采样几个位置的最上层元素
const points: Array<[number, number, string]> = [
[0.5, 0.5, "中央"],
[0.5, 0.12, "标题栏下沿"],
[0.25, 0.6, "内容区"],
[0.75, 0.85, "内容区右下"],
];
for (const [fx, fy, label] of points) {
const el = document.elementFromPoint(Math.floor(innerWidth * fx), Math.floor(innerHeight * fy));
if (!el || el === document.body) {
lines.push(`[${label}] (${fx},${fy}) → 无元素/body`);
continue;
}
const target = el as HTMLElement;
const cs = getComputedStyle(target);
const chain: string[] = [];
let node: HTMLElement | null = target;
for (let i = 0; node && i < 5; i++) {
chain.push(
`${node.tagName.toLowerCase()}${node.id ? `#${node.id}` : ""}${node.className ? `.${String(node.className).split(/\s+/).filter(Boolean).slice(0, 2).join(".")}` : ""}`,
);
node = node.parentElement;
}
lines.push(`[${label}] (${fx},${fy}) → ${chain.join(" < ")} | pe=${cs.pointerEvents}`);
}
if (lines.length === 0) lines.push("未发现明显拦截层(可再多点几个位置)");
setHitInfo(lines);
};
const sample = async () => { const sample = async () => {
setHeap(readJsHeap()); setHeap(readJsHeap());
setDomNodes(document.querySelectorAll("*").length); setDomNodes(document.querySelectorAll("*").length);
@@ -140,6 +189,31 @@ export function ResourceDebug() {
</GlassCard> </GlassCard>
</div> </div>
{/* 点击拦截诊断 */}
<div>
<h3 className="text-sm font-semibold text-foreground/40 uppercase tracking-wider mb-3">
使
</h3>
<GlassCard>
<div className="flex items-center justify-between gap-2 mb-2">
<p className="text-[13px] text-muted-foreground">
4
</p>
<button
onClick={runHitTest}
className="inline-flex items-center gap-1 rounded-md border border-foreground/10 px-2 py-1 text-[12px] text-muted-foreground hover:text-foreground transition-colors cursor-pointer"
>
</button>
</div>
{hitInfo && (
<pre className="max-h-56 overflow-auto rounded-md bg-foreground/[0.04] p-3 text-[12px] leading-relaxed font-mono whitespace-pre-wrap text-foreground/80">
{hitInfo.join("\n")}
</pre>
)}
</GlassCard>
</div>
{/* 进程工作集 */} {/* 进程工作集 */}
<div> <div>
<h3 className="text-sm font-semibold text-foreground/40 uppercase tracking-wider mb-3"> <h3 className="text-sm font-semibold text-foreground/40 uppercase tracking-wider mb-3">
+22 -1
View File
@@ -18,6 +18,7 @@ import {
type UpdateStatusPayload, type UpdateStatusPayload,
type VersionCompareResult, type VersionCompareResult,
} from "@/api/update"; } from "@/api/update";
import { useUpdateDialogStore } from "@/stores/updateDialogStore";
/** 更新功能测试:版本识别 / 检查 / 介绍 / 比对 / 下载 */ /** 更新功能测试:版本识别 / 检查 / 介绍 / 比对 / 下载 */
export function UpdateDebug() { export function UpdateDebug() {
@@ -71,7 +72,27 @@ export function UpdateDebug() {
</GlassCard> </GlassCard>
</div> </div>
{/* 2. 设置版本号 */} {/* 2. 新版本弹窗预览(唤起全局"发现新版本"弹窗) */}
<div>
<h3 className="text-sm font-semibold text-foreground/40 uppercase tracking-wider mb-3">
</h3>
<GlassCard>
<div className="flex flex-wrap gap-2 items-center">
<Button size="sm" onClick={() => useUpdateDialogStore.getState().show(s?.version || "9.9.9")}>
</Button>
<Button size="sm" variant="outline" onClick={() => useUpdateDialogStore.getState().hide()}>
</Button>
<span className="text-[12px] text-muted-foreground font-mono ml-1">
{s?.version || "9.9.9"} 9.9.9
</span>
</div>
</GlassCard>
</div>
{/* 3. 设置版本号 */}
<div> <div>
<h3 className="text-sm font-semibold text-foreground/40 uppercase tracking-wider mb-3"> <h3 className="text-sm font-semibold text-foreground/40 uppercase tracking-wider mb-3">
+1 -16
View File
@@ -3,7 +3,6 @@ import { useConfigStore } from "@/stores/configStore";
import { import {
SettingCard, SettingCard,
SettingSelect, SettingSelect,
SettingSwitch,
SettingNumberField, SettingNumberField,
SettingFilePicker, SettingFilePicker,
fieldCls, fieldCls,
@@ -28,7 +27,7 @@ export function AdvancedSetting() {
return ( return (
<div> <div>
<PageHeader title="高级设置" desc="游戏高级启动参数、调试选项与实验性功能" /> <PageHeader title="高级设置" desc="游戏高级启动参数与实验性功能" />
<div className="space-y-6"> <div className="space-y-6">
{/* 启动行为 */} {/* 启动行为 */}
@@ -163,20 +162,6 @@ export function AdvancedSetting() {
</div> </div>
</div> </div>
{/* 调试 */}
<div>
<SectionTitle></SectionTitle>
<div className="space-y-3">
<SettingCard>
<SettingSwitch
label="调试模式"
desc="启用后附加 -Dkoring.debugMode=true 并在控制台输出详细日志,可能影响性能"
checked={adv.debugMode}
onChange={(v) => setAdvanced({ debugMode: v })}
/>
</SettingCard>
</div>
</div>
</div> </div>
</div> </div>
); );
+19 -1
View File
@@ -4,7 +4,8 @@ import { BUILD_MODE } from "@/lib/mode";
import { BUILD_COMMIT, BUILD_ID } from "@/lib/buildInfo"; import { BUILD_COMMIT, BUILD_ID } from "@/lib/buildInfo";
import { ExternalLink, GitFork, RotateCcw, ChevronDown } from "lucide-react"; import { ExternalLink, GitFork, RotateCcw, ChevronDown } from "lucide-react";
import { Link, Select, ListBox, ListBoxItem } from "@heroui/react"; import { Link, Select, ListBox, ListBoxItem } from "@heroui/react";
import { SettingCard, SettingRow, PageHeader, SectionTitle } from "@/components/setting"; import { useConfigStore } from "@/stores/configStore";
import { SettingCard, SettingRow, SettingSwitch, PageHeader, SectionTitle } from "@/components/setting";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
getUpdateChannels, getUpdateChannels,
@@ -47,6 +48,9 @@ export function AboutSetting() {
const [countdown, setCountdown] = useState(5); const [countdown, setCountdown] = useState(5);
const canConfirm = countdown <= 0; const canConfirm = countdown <= 0;
const adv = useConfigStore((s) => s.config.advanced);
const setAdvanced = useConfigStore((s) => s.setAdvanced);
// 设备识别码(组合指纹:主板/硬盘/BIOS → 回退系统安装标识) // 设备识别码(组合指纹:主板/硬盘/BIOS → 回退系统安装标识)
const [device, setDevice] = useState<DeviceIdentity | null>(null); const [device, setDevice] = useState<DeviceIdentity | null>(null);
useEffect(() => { useEffect(() => {
@@ -201,6 +205,20 @@ export function AboutSetting() {
</div> </div>
</div> </div>
<div>
<SectionTitle></SectionTitle>
<div className="space-y-3">
<SettingCard>
<SettingSwitch
label="调试模式"
desc="启用后附加 -Dkoring.debugMode=true 并在控制台输出详细日志(dev 运行时会同步输出到终端),可能影响性能"
checked={adv?.debugMode ?? false}
onChange={(v) => setAdvanced({ debugMode: v })}
/>
</SettingCard>
</div>
</div>
<div> <div>
<SectionTitle></SectionTitle> <SectionTitle></SectionTitle>
<div className="space-y-3"> <div className="space-y-3">
+3 -6
View File
@@ -1,6 +1,7 @@
import { useCallback } from "react"; import { useCallback } from "react";
import { useConfigStore } from "@/stores/configStore"; import { useConfigStore } from "@/stores/configStore";
import { Switch, Input } from "@heroui/react"; import { Input } from "@heroui/react";
import { Switch } from "@/components/ui/switch";
import { ShieldCheck } from "lucide-react"; import { ShieldCheck } from "lucide-react";
import { SettingCard, SettingRow, PageHeader, SectionTitle, fieldCls } from "@/components/setting"; import { SettingCard, SettingRow, PageHeader, SectionTitle, fieldCls } from "@/components/setting";
@@ -30,11 +31,7 @@ export function SecurityIdSetting() {
label="启用第三方认证" label="启用第三方认证"
desc="使用自定义认证服务器替代 Microsoft 认证(适用于离线服务器)" desc="使用自定义认证服务器替代 Microsoft 认证(适用于离线服务器)"
> >
<Switch isSelected={enabled} onChange={handleToggle}> <Switch aria-label="启用第三方认证" checked={enabled} onCheckedChange={handleToggle} />
<Switch.Control>
<Switch.Thumb />
</Switch.Control>
</Switch>
</SettingRow> </SettingRow>
</SettingCard> </SettingCard>
+4 -16
View File
@@ -1,5 +1,5 @@
import { useA11yStore } from "@/stores/a11yStore"; import { useA11yStore } from "@/stores/a11yStore";
import { Switch } from "@heroui/react"; import { Switch } from "@/components/ui/switch";
import { SettingCard, SettingRow, PageHeader, SectionTitle } from "@/components/setting"; import { SettingCard, SettingRow, PageHeader, SectionTitle } from "@/components/setting";
export function A11ySetting() { export function A11ySetting() {
@@ -15,31 +15,19 @@ export function A11ySetting() {
<div className="space-y-3"> <div className="space-y-3">
<SettingCard> <SettingCard>
<SettingRow label="减少动画" desc="关闭页面切换动画和背景动效"> <SettingRow label="减少动画" desc="关闭页面切换动画和背景动效">
<Switch isSelected={reduceMotion} onChange={setReduceMotion}> <Switch aria-label="减少动画" checked={reduceMotion} onCheckedChange={setReduceMotion} />
<Switch.Control>
<Switch.Thumb />
</Switch.Control>
</Switch>
</SettingRow> </SettingRow>
</SettingCard> </SettingCard>
<SettingCard> <SettingCard>
<SettingRow label="减少透明度" desc="将磨砂玻璃效果替换为纯色背景,提升可读性"> <SettingRow label="减少透明度" desc="将磨砂玻璃效果替换为纯色背景,提升可读性">
<Switch isSelected={reduceTransparency} onChange={setReduceTransparency}> <Switch aria-label="减少透明度" checked={reduceTransparency} onCheckedChange={setReduceTransparency} />
<Switch.Control>
<Switch.Thumb />
</Switch.Control>
</Switch>
</SettingRow> </SettingRow>
</SettingCard> </SettingCard>
<SettingCard> <SettingCard>
<SettingRow label="高对比度" desc="增强文字与背景的对比度,改善可读性"> <SettingRow label="高对比度" desc="增强文字与背景的对比度,改善可读性">
<Switch isSelected={highContrast} onChange={setHighContrast}> <Switch aria-label="高对比度" checked={highContrast} onCheckedChange={setHighContrast} />
<Switch.Control>
<Switch.Thumb />
</Switch.Control>
</Switch>
</SettingRow> </SettingRow>
</SettingCard> </SettingCard>
</div> </div>
@@ -1,7 +1,8 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useThemeStore, type DarkMode } from "@/stores/themeStore"; import { useThemeStore, type DarkMode } from "@/stores/themeStore";
import { useBackgroundStore } from "@/stores/backgroundStore"; import { useBackgroundStore } from "@/stores/backgroundStore";
import { Switch, Button, Slider } from "@heroui/react"; import { Button, Slider } from "@heroui/react";
import { Switch } from "@/components/ui/switch";
import { DEFAULT_BG } from "@/lib/mode"; import { DEFAULT_BG } from "@/lib/mode";
import clsx from "clsx"; import clsx from "clsx";
import { SettingCard, SettingRow, PageHeader, SectionTitle } from "@/components/setting"; import { SettingCard, SettingRow, PageHeader, SectionTitle } from "@/components/setting";
@@ -207,11 +208,7 @@ export function ThemeBgSetting() {
<SettingCard> <SettingCard>
<SettingRow label="背景图片视差" desc="背景图片随窗口滚动产生视差位移"> <SettingRow label="背景图片视差" desc="背景图片随窗口滚动产生视差位移">
<Switch isSelected={parallax} onChange={setParallax}> <Switch aria-label="背景图片视差" checked={parallax} onCheckedChange={setParallax} />
<Switch.Control>
<Switch.Thumb />
</Switch.Control>
</Switch>
</SettingRow> </SettingRow>
</SettingCard> </SettingCard>
+21
View File
@@ -0,0 +1,21 @@
import { create } from "zustand";
/**
* "发现新版本"
* - UpdateAvailableDialog available
*
*/
interface UpdateDialogState {
open: boolean;
/** 目标(新)版本号;为空时展示当前版本 */
version: string;
show: (version?: string) => void;
hide: () => void;
}
export const useUpdateDialogStore = create<UpdateDialogState>((set) => ({
open: false,
version: "",
show: (version = "") => set({ open: true, version }),
hide: () => set({ open: false }),
}));