Add Koring auth flow and settings UI refactor

Implements Koring OIDC device-auth flow and integrates it into the app: core auth (TS/JS), IPC handlers, renderer API, KoringLogin React component, and a zustand koringAuth store. Handlers support device-code request, polling, refresh, get-user and logout and persist user info to existing auth/config. Also refactors many settings pages to use shared Setting components (SettingCard, SettingRow, SectionTitle, PageHeader) and replaces custom controls with @heroui/react primitives (Avatar, Button, Switch, RadioGroup, Slider, Input, TextArea, EmptyState). Adds OOBE login/welcome pages, a setting login route, installer header generator script, and a sample koring-auth.json for development.
This commit is contained in:
2026-07-26 06:42:41 +08:00
parent cdbb1e54a3
commit 27acc66252
35 changed files with 1504 additions and 509 deletions
+175
View File
@@ -0,0 +1,175 @@
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
exports.requestDeviceCode = requestDeviceCode;
exports.pollForTokenOnce = pollForTokenOnce;
exports.refreshAccessToken = refreshAccessToken;
exports.decodeIdToken = decodeIdToken;
exports.saveKoringAuth = saveKoringAuth;
exports.readKoringAuth = readKoringAuth;
exports.deleteKoringAuth = deleteKoringAuth;
const https = __importStar(require("https"));
const auth_1 = require("../auth");
const CLIENT_ID = '547qe8ky1pr69f08b71kj';
const DEVICE_AUTH_URL = 'https://oac.lingke.ink/oidc/device/auth';
const TOKEN_URL = 'https://oac.lingke.ink/oidc/token';
const SCOPE = 'openid offline_access profile';
function postForm(url, data) {
return new Promise((resolve, reject) => {
const params = new URLSearchParams(data);
const body = params.toString().replace(/\+/g, '%20');
const urlObj = new URL(url);
console.log(`[koring-auth] POST ${url}`);
console.log(`[koring-auth] body: ${body}`);
const req = https.request({
hostname: urlObj.hostname,
port: urlObj.port || 443,
path: urlObj.pathname,
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Content-Length': Buffer.byteLength(body),
},
}, (res) => {
let raw = '';
res.on('data', (chunk) => (raw += chunk));
res.on('end', () => {
console.log(`[koring-auth] response (${res.statusCode}): ${raw}`);
try {
resolve(JSON.parse(raw));
}
catch {
reject(new Error(`Invalid response: ${raw}`));
}
});
});
req.on('error', reject);
req.write(body);
req.end();
});
}
function parseJwt(token) {
try {
const payload = token.split('.')[1];
const decoded = Buffer.from(payload, 'base64url').toString('utf-8');
return JSON.parse(decoded);
}
catch {
return {};
}
}
async function requestDeviceCode() {
const res = await postForm(DEVICE_AUTH_URL, {
client_id: CLIENT_ID,
scope: SCOPE,
});
if (res.error)
throw new Error(res.error_description || res.error);
return res;
}
async function pollForTokenOnce(device_code) {
const res = await postForm(TOKEN_URL, {
client_id: CLIENT_ID,
grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
device_code,
});
if (res.access_token) {
return res;
}
if (res.error === 'expired_token' || res.error === 'access_denied') {
throw new Error(res.error);
}
// authorization_pending or slow_down — throw so caller can retry
throw new Error(res.error || 'authorization_pending');
}
async function refreshAccessToken(refresh_token) {
const res = await postForm(TOKEN_URL, {
client_id: CLIENT_ID,
grant_type: 'refresh_token',
refresh_token,
});
if (res.error)
throw new Error(res.error_description || res.error);
return res;
}
function decodeIdToken(id_token) {
const payload = parseJwt(id_token);
return {
sub: payload.sub || '',
name: payload.name || '',
username: payload.username || payload.name || '',
email: payload.email || '',
picture: payload.picture || '',
};
}
function saveKoringAuth(tokenRes) {
const user = decodeIdToken(tokenRes.id_token);
const auth = {
user,
access_token: tokenRes.access_token,
refresh_token: tokenRes.refresh_token,
id_token: tokenRes.id_token,
expires_at: Date.now() + tokenRes.expires_in * 1000,
};
// Reuse existing auth file for storage
(0, auth_1.writeAuth)({
username: user.username,
uuid: user.sub,
accessToken: auth.access_token,
refreshToken: auth.refresh_token,
xboxProfile: JSON.stringify(user),
});
return user;
}
function readKoringAuth() {
const auth = (0, auth_1.readAuth)();
if (!auth.username || !auth.refreshToken)
return null;
let user = { sub: '', name: '', username: '', email: '', picture: '' };
try {
user = JSON.parse(auth.xboxProfile || '{}');
}
catch { }
return {
user,
access_token: auth.accessToken,
refresh_token: auth.refreshToken,
id_token: '',
expires_at: 0,
};
}
function deleteKoringAuth() {
(0, auth_1.writeAuth)({ username: '', uuid: '', accessToken: '', refreshToken: '', xboxProfile: '' });
}
+178
View File
@@ -0,0 +1,178 @@
import * as https from 'https';
import { readAuth, writeAuth } from '../auth';
const CLIENT_ID = '547qe8ky1pr69f08b71kj';
const DEVICE_AUTH_URL = 'https://oac.lingke.ink/oidc/device/auth';
const TOKEN_URL = 'https://oac.lingke.ink/oidc/token';
const SCOPE = 'openid offline_access profile';
export interface DeviceAuthResponse {
device_code: string;
user_code: string;
verification_uri: string;
verification_uri_complete: string;
expires_in: number;
}
export interface TokenResponse {
access_token: string;
id_token: string;
refresh_token: string;
token_type: string;
expires_in: number;
scope: string;
}
export interface KoringUser {
sub: string;
name: string;
username: string;
email: string;
picture: string;
}
function postForm(url: string, data: Record<string, string>): Promise<any> {
return new Promise((resolve, reject) => {
const params = new URLSearchParams(data);
const body = params.toString().replace(/\+/g, '%20');
const urlObj = new URL(url);
console.log(`[koring-auth] POST ${url}`);
console.log(`[koring-auth] body: ${body}`);
const req = https.request(
{
hostname: urlObj.hostname,
port: urlObj.port || 443,
path: urlObj.pathname,
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Content-Length': Buffer.byteLength(body),
},
},
(res) => {
let raw = '';
res.on('data', (chunk) => (raw += chunk));
res.on('end', () => {
console.log(`[koring-auth] response (${res.statusCode}): ${raw}`);
try {
resolve(JSON.parse(raw));
} catch {
reject(new Error(`Invalid response: ${raw}`));
}
});
}
);
req.on('error', reject);
req.write(body);
req.end();
});
}
function parseJwt(token: string): Record<string, unknown> {
try {
const payload = token.split('.')[1];
const decoded = Buffer.from(payload, 'base64url').toString('utf-8');
return JSON.parse(decoded);
} catch {
return {};
}
}
export async function requestDeviceCode(): Promise<DeviceAuthResponse> {
const res = await postForm(DEVICE_AUTH_URL, {
client_id: CLIENT_ID,
scope: SCOPE,
});
if (res.error) throw new Error(res.error_description || res.error);
return res as DeviceAuthResponse;
}
export async function pollForTokenOnce(
device_code: string
): Promise<TokenResponse> {
const res = await postForm(TOKEN_URL, {
client_id: CLIENT_ID,
grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
device_code,
});
if (res.access_token) {
return res as TokenResponse;
}
if (res.error === 'expired_token' || res.error === 'access_denied') {
throw new Error(res.error);
}
// authorization_pending or slow_down — throw so caller can retry
throw new Error(res.error || 'authorization_pending');
}
export async function refreshAccessToken(refresh_token: string): Promise<TokenResponse> {
const res = await postForm(TOKEN_URL, {
client_id: CLIENT_ID,
grant_type: 'refresh_token',
refresh_token,
});
if (res.error) throw new Error(res.error_description || res.error);
return res as TokenResponse;
}
export function decodeIdToken(id_token: string): KoringUser {
const payload = parseJwt(id_token);
return {
sub: (payload.sub as string) || '',
name: (payload.name as string) || '',
username: (payload.username as string) || (payload.name as string) || '',
email: (payload.email as string) || '',
picture: (payload.picture as string) || '',
};
}
export interface StoredKoringAuth {
user: KoringUser;
access_token: string;
refresh_token: string;
id_token: string;
expires_at: number;
}
export function saveKoringAuth(tokenRes: TokenResponse): KoringUser {
const user = decodeIdToken(tokenRes.id_token);
const auth: StoredKoringAuth = {
user,
access_token: tokenRes.access_token,
refresh_token: tokenRes.refresh_token,
id_token: tokenRes.id_token,
expires_at: Date.now() + tokenRes.expires_in * 1000,
};
// Reuse existing auth file for storage
writeAuth({
username: user.username,
uuid: user.sub,
accessToken: auth.access_token,
refreshToken: auth.refresh_token,
xboxProfile: JSON.stringify(user),
});
return user;
}
export function readKoringAuth(): StoredKoringAuth | null {
const auth = readAuth();
if (!auth.username || !auth.refreshToken) return null;
let user: KoringUser = { sub: '', name: '', username: '', email: '', picture: '' };
try {
user = JSON.parse(auth.xboxProfile || '{}') as KoringUser;
} catch {}
return {
user,
access_token: auth.accessToken,
refresh_token: auth.refreshToken,
id_token: '',
expires_at: 0,
};
}
export function deleteKoringAuth(): void {
writeAuth({ username: '', uuid: '', accessToken: '', refreshToken: '', xboxProfile: '' });
}
+113
View File
@@ -0,0 +1,113 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.registerKoringAuthHandlers = registerKoringAuthHandlers;
const electron_1 = __importDefault(require("electron"));
const { ipcMain } = electron_1.default;
const koring_auth_1 = require("../core/koring-auth");
const config_1 = require("../config");
function registerKoringAuthHandlers() {
ipcMain.handle('koring-auth:request-device-code', async () => {
try {
const result = await (0, koring_auth_1.requestDeviceCode)();
return { success: true, data: result, error: null };
}
catch (e) {
return { success: false, data: null, error: String(e) };
}
});
ipcMain.handle('koring-auth:poll-token', async (_event, deviceCode) => {
try {
const result = await (0, koring_auth_1.pollForTokenOnce)(deviceCode);
const user = (0, koring_auth_1.saveKoringAuth)(result);
// 同时写入配置文件
try {
const config = (0, config_1.loadConfig)();
config.koringUser = {
sub: user.sub,
name: user.name,
username: user.username,
email: user.email,
picture: user.picture,
accessToken: result.access_token,
refreshToken: result.refresh_token,
};
(0, config_1.saveConfig)(config);
}
catch (e) {
console.error('[koring-auth] failed to save user to config:', e);
}
return { success: true, data: { user }, error: null };
}
catch (e) {
const msg = e instanceof Error ? e.message : String(e);
return { success: false, data: null, error: msg };
}
});
ipcMain.handle('koring-auth:refresh', async () => {
try {
const stored = (0, koring_auth_1.readKoringAuth)();
if (!stored?.refresh_token)
throw new Error('No refresh token');
const result = await (0, koring_auth_1.refreshAccessToken)(stored.refresh_token);
const user = (0, koring_auth_1.saveKoringAuth)(result);
// 同步到配置文件
try {
const config = (0, config_1.loadConfig)();
config.koringUser = {
sub: user.sub,
name: user.name,
username: user.username,
email: user.email,
picture: user.picture,
accessToken: result.access_token,
refreshToken: result.refresh_token,
};
(0, config_1.saveConfig)(config);
}
catch { }
return { success: true, data: { user }, error: null };
}
catch (e) {
return { success: false, data: null, error: String(e) };
}
});
ipcMain.handle('koring-auth:get-user', () => {
try {
const stored = (0, koring_auth_1.readKoringAuth)();
// 也从配置文件读取
if (!stored?.user?.sub) {
try {
const config = (0, config_1.loadConfig)();
const ku = config.koringUser;
if (ku?.sub) {
return { success: true, data: { user: ku, access_token: '', refresh_token: '', id_token: '', expires_at: 0 }, error: null };
}
}
catch { }
}
return { success: true, data: stored, error: null };
}
catch (e) {
return { success: false, data: null, error: String(e) };
}
});
ipcMain.handle('koring-auth:logout', () => {
try {
(0, koring_auth_1.deleteKoringAuth)();
// 清除配置文件中的用户数据
try {
const config = (0, config_1.loadConfig)();
delete config.koringUser;
(0, config_1.saveConfig)(config);
}
catch { }
return { success: true, data: null, error: null };
}
catch (e) {
return { success: false, data: null, error: String(e) };
}
});
}
+113
View File
@@ -0,0 +1,113 @@
import electron from 'electron';
const { ipcMain } = electron;
import {
requestDeviceCode,
pollForTokenOnce,
refreshAccessToken,
saveKoringAuth,
readKoringAuth,
deleteKoringAuth,
} from '../core/koring-auth';
import { loadConfig, saveConfig } from '../config';
export function registerKoringAuthHandlers() {
ipcMain.handle('koring-auth:request-device-code', async () => {
try {
const result = await requestDeviceCode();
return { success: true, data: result, error: null };
} catch (e: unknown) {
return { success: false, data: null, error: String(e) };
}
});
ipcMain.handle('koring-auth:poll-token', async (_event, deviceCode: string) => {
try {
const result = await pollForTokenOnce(deviceCode);
const user = saveKoringAuth(result);
// 同时写入配置文件
try {
const config = loadConfig();
(config as any).koringUser = {
sub: user.sub,
name: user.name,
username: user.username,
email: user.email,
picture: user.picture,
accessToken: result.access_token,
refreshToken: result.refresh_token,
};
saveConfig(config);
} catch (e) {
console.error('[koring-auth] failed to save user to config:', e);
}
return { success: true, data: { user }, error: null };
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e);
return { success: false, data: null, error: msg };
}
});
ipcMain.handle('koring-auth:refresh', async () => {
try {
const stored = readKoringAuth();
if (!stored?.refresh_token) throw new Error('No refresh token');
const result = await refreshAccessToken(stored.refresh_token);
const user = saveKoringAuth(result);
// 同步到配置文件
try {
const config = loadConfig();
(config as any).koringUser = {
sub: user.sub,
name: user.name,
username: user.username,
email: user.email,
picture: user.picture,
accessToken: result.access_token,
refreshToken: result.refresh_token,
};
saveConfig(config);
} catch {}
return { success: true, data: { user }, error: null };
} catch (e: unknown) {
return { success: false, data: null, error: String(e) };
}
});
ipcMain.handle('koring-auth:get-user', () => {
try {
const stored = readKoringAuth();
// 也从配置文件读取
if (!stored?.user?.sub) {
try {
const config = loadConfig();
const ku = (config as any).koringUser;
if (ku?.sub) {
return { success: true, data: { user: ku, access_token: '', refresh_token: '', id_token: '', expires_at: 0 }, error: null };
}
} catch {}
}
return { success: true, data: stored, error: null };
} catch (e: unknown) {
return { success: false, data: null, error: String(e) };
}
});
ipcMain.handle('koring-auth:logout', () => {
try {
deleteKoringAuth();
// 清除配置文件中的用户数据
try {
const config = loadConfig();
delete (config as any).koringUser;
saveConfig(config);
} catch {}
return { success: true, data: null, error: null };
} catch (e: unknown) {
return { success: false, data: null, error: String(e) };
}
});
}
+7
View File
@@ -0,0 +1,7 @@
{
"username": "dream_pep",
"uuid": "rqkzikchwvjt",
"accessToken": "Nsk5O07OxX_oDQRFHWdIh6oDNHB8WxleTsAi3yAUtCL",
"refreshToken": "rFvsd9kmvyiBYNLID8A5xmeWTqF-Efselc21ug3cGiy",
"xboxProfile": "{\"sub\":\"rqkzikchwvjt\",\"name\":\"周逸\",\"username\":\"dream_pep\",\"email\":\"\",\"picture\":\"\"}"
}
+24
View File
@@ -0,0 +1,24 @@
// Run: node scripts/gen-installer-header.js
const { createCanvas } = require('canvas');
const fs = require('fs');
const path = require('path');
const c = createCanvas(493, 58);
const ctx = c.getContext('2d');
const g = ctx.createLinearGradient(0, 0, 493, 0);
g.addColorStop(0, '#1a1a2e');
g.addColorStop(1, '#16213e');
ctx.fillStyle = g;
ctx.fillRect(0, 0, 493, 58);
ctx.fillStyle = '#ffffff';
ctx.font = 'bold 24px sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('Koring Launcher', 246, 29);
const buf = c.toBuffer('image/png');
const out = path.resolve(__dirname, '..', 'build', 'installer-header.png');
fs.writeFileSync(out, buf);
console.log('Created:', out);
+47
View File
@@ -0,0 +1,47 @@
import { ipcInvoke } from './ipc';
export interface DeviceAuthResponse {
device_code: string;
user_code: string;
verification_uri: string;
verification_uri_complete: string;
expires_in: number;
}
export interface KoringUser {
sub: string;
name: string;
username: string;
email: string;
picture: string;
}
export interface KoringAuthData {
user: KoringUser;
access_token: string;
refresh_token: string;
id_token: string;
expires_at: number;
}
export async function requestDeviceCode(): Promise<DeviceAuthResponse> {
return ipcInvoke<DeviceAuthResponse>('koring-auth:request-device-code');
}
export async function pollForToken(
device_code: string
): Promise<{ user: KoringUser }> {
return ipcInvoke<{ user: KoringUser }>('koring-auth:poll-token', device_code);
}
export async function refreshKoringToken(): Promise<{ user: KoringUser }> {
return ipcInvoke<{ user: KoringUser }>('koring-auth:refresh');
}
export async function getKoringUser(): Promise<KoringAuthData | null> {
return ipcInvoke<KoringAuthData | null>('koring-auth:get-user');
}
export async function logoutKoring(): Promise<void> {
return ipcInvoke<void>('koring-auth:logout');
}
+194
View File
@@ -0,0 +1,194 @@
import { useState, useEffect, useRef, useCallback } from "react";
import { QRCodeSVG } from "qrcode.react";
import {
requestDeviceCode,
pollForToken,
type KoringUser,
} from "@/api/koring-auth";
import { useKoringAuthStore } from "@/stores/koringAuthStore";
import { Loader2, CheckCircle2, Copy, ExternalLink } from "lucide-react";
type Step = "loading" | "scan" | "polling" | "success" | "error";
interface KoringLoginProps {
onLoginSuccess?: (user: KoringUser) => void;
}
export function KoringLogin({ onLoginSuccess }: KoringLoginProps) {
const [step, setStep] = useState<Step>("loading");
const [userCode, setUserCode] = useState("");
const [verifyUri, setVerifyUri] = useState("");
const [verifyUriComplete, setVerifyUriComplete] = useState("");
const [error, setError] = useState("");
const [copied, setCopied] = useState(false);
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
const setUser = useKoringAuthStore((s) => s.setUser);
const cleanup = useCallback(() => {
if (pollRef.current) {
clearInterval(pollRef.current);
pollRef.current = null;
}
}, []);
useEffect(() => cleanup, [cleanup]);
// Auto-start on mount
useEffect(() => {
let cancelled = false;
(async () => {
try {
const res = await requestDeviceCode();
if (cancelled) return;
setUserCode(res.user_code);
setVerifyUri(res.verification_uri);
setVerifyUriComplete(res.verification_uri_complete);
setStep("scan");
startPolling(res.device_code, res.expires_in, cancelled);
} catch (e: any) {
if (!cancelled) {
setError(e.message || "请求设备码失败");
setStep("error");
}
}
})();
return () => { cancelled = true; cleanup(); };
}, []);
const startPolling = (deviceCode: string, expires: number, cancelled: boolean) => {
setStep("polling");
const startTime = Date.now();
pollRef.current = setInterval(async () => {
if (cancelled) { cleanup(); return; }
if (Date.now() - startTime > expires * 1000) {
cleanup();
setError("二维码已过期,请重新获取");
setStep("error");
return;
}
try {
const result = await pollForToken(deviceCode);
if (cancelled) return;
cleanup();
setUser(result.user);
setStep("success");
onLoginSuccess?.(result.user);
} catch (e: any) {
if (e.message !== "authorization_pending" && e.message !== "slow_down") {
if (!cancelled) {
cleanup();
setError(e.message || "验证失败");
setStep("error");
}
}
}
}, 5000);
};
const retry = async () => {
setStep("loading");
setError("");
try {
const res = await requestDeviceCode();
setUserCode(res.user_code);
setVerifyUri(res.verification_uri);
setVerifyUriComplete(res.verification_uri_complete);
setStep("scan");
startPolling(res.device_code, res.expires_in, false);
} catch (e: any) {
setError(e.message || "请求设备码失败");
setStep("error");
}
};
const copyCode = () => {
navigator.clipboard.writeText(userCode);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
const openVerify = () => {
window.electronAPI?.openExternal(verifyUri);
};
if (step === "loading") {
return (
<div className="flex flex-col items-center gap-3 py-6">
<Loader2 className="w-6 h-6 animate-spin text-foreground/40" />
<span className="text-sm text-muted-foreground">...</span>
</div>
);
}
if (step === "error") {
return (
<div className="flex flex-col items-center gap-3 py-4">
<p className="text-sm text-red-500">{error}</p>
<button
onClick={retry}
className="px-4 py-1.5 rounded-md text-[13px] font-medium bg-foreground/[0.06] hover:bg-foreground/[0.12] text-foreground/60 hover:text-foreground transition-colors"
>
</button>
</div>
);
}
return (
<div className="flex flex-col items-center gap-4">
{/* QR Code */}
<div className="p-3 bg-white rounded-xl">
<QRCodeSVG value={verifyUriComplete} size={160} level="M" />
</div>
{/* 提示文字 */}
<p className="text-sm text-muted-foreground text-center leading-relaxed">
使
<br />
访
</p>
{/* 验证链接 */}
<button
onClick={openVerify}
className="inline-flex items-center gap-1.5 text-[13px] text-primary hover:underline"
>
{verifyUri}
<ExternalLink className="w-3 h-3" />
</button>
{/* 用户码 */}
<div className="flex items-center gap-2">
<span className="text-2xl font-mono font-bold tracking-[0.3em] text-foreground">
{userCode}
</span>
<button
onClick={copyCode}
className="p-1.5 rounded-md hover:bg-foreground/[0.06] transition-colors"
title="复制验证码"
>
{copied ? (
<CheckCircle2 className="w-4 h-4 text-green-500" />
) : (
<Copy className="w-4 h-4 text-foreground/40" />
)}
</button>
</div>
{/* 轮询状态 */}
{step === "polling" && (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Loader2 className="w-3 h-3 animate-spin" />
...
</div>
)}
{step === "success" && (
<div className="flex items-center gap-2 text-sm text-green-600 dark:text-green-400">
<CheckCircle2 className="w-4 h-4" />
</div>
)}
</div>
);
}
+12
View File
@@ -0,0 +1,12 @@
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>
</>
);
}
export function SectionTitle({ children }: { children: React.ReactNode }) {
return <h3 className="text-lg font-bold text-foreground mb-3">{children}</h3>;
}
+9
View File
@@ -0,0 +1,9 @@
import { Card } from "@heroui/react";
export function SettingCard({ children, className }: { children: React.ReactNode; className?: string }) {
return (
<Card variant="transparent" className={`glass-card px-5 py-4 ${className ?? ""}`}>
{children}
</Card>
);
}
+11
View File
@@ -0,0 +1,11 @@
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>}
</div>
<div className="shrink-0">{children}</div>
</div>
);
}
+3
View File
@@ -0,0 +1,3 @@
export { SettingCard } from "./SettingCard";
export { SettingRow } from "./SettingRow";
export { PageHeader, SectionTitle } from "./SectionTitle";
+63
View File
@@ -0,0 +1,63 @@
import { useState, useEffect, useCallback } from "react";
import { useRouteStore } from "@/stores/routeStore";
import { useKoringAuthStore } from "@/stores/koringAuthStore";
import { OobeLayout } from "./layout";
import { NextButton } from "./next-button";
import { KoringLogin } from "@/components/KoringLogin";
export function OobeLogin() {
const navigate = useRouteStore((s) => s.navigate);
const user = useKoringAuthStore((s) => s.user);
const [showSkip, setShowSkip] = useState(false);
const [justLoggedIn, setJustLoggedIn] = useState(false);
// 10秒后显示"我暂时不需要"
useEffect(() => {
const t = setTimeout(() => setShowSkip(true), 10000);
return () => clearTimeout(t);
}, []);
const handleSuccess = useCallback(() => {
setJustLoggedIn(true);
setTimeout(() => navigate("oobe/agreement"), 1500);
}, [navigate]);
return (
<OobeLayout>
<div className="w-full max-w-md flex flex-col items-center gap-6 px-6">
{/* 标题 */}
<div className="text-center space-y-1">
<h2 className="text-lg font-bold text-foreground"> Koring </h2>
<p className="text-xs text-muted-foreground">
</p>
</div>
{/* 登录组件 — 自动显示 QR */}
<KoringLogin onLoginSuccess={handleSuccess} />
{/* 已登录提示 */}
{justLoggedIn && (
<p className="text-xs text-green-600 dark:text-green-400">
...
</p>
)}
</div>
{/* 下一步按钮 — 需要登录才能点击 */}
<NextButton onClick={() => navigate("oobe/agreement")} disabled={!user && !justLoggedIn} />
{/* 跳过按钮 — 10秒后显示 */}
{showSkip && !user && !justLoggedIn && (
<div className="absolute bottom-6">
<button
onClick={() => navigate("oobe/agreement")}
className="text-[12px] text-foreground/30 hover:text-foreground/50 transition-colors"
>
</button>
</div>
)}
</OobeLayout>
);
}
+44
View File
@@ -0,0 +1,44 @@
import { useState, useEffect } from "react";
import { useRouteStore } from "@/stores/routeStore";
import { useKoringAuthStore } from "@/stores/koringAuthStore";
import { OobeLayout } from "./layout";
import { NextButton } from "./next-button";
export function OobeWelcome() {
const navigate = useRouteStore((s) => s.navigate);
const user = useKoringAuthStore((s) => s.user);
const [showBtn, setShowBtn] = useState(false);
const [animClass, setAnimClass] = useState("scale-90 opacity-0");
useEffect(() => {
requestAnimationFrame(() => {
setAnimClass("scale-100 opacity-100");
});
}, []);
useEffect(() => {
const t = setTimeout(() => setShowBtn(true), 1500);
return () => clearTimeout(t);
}, []);
const displayName = user?.name || user?.username || "用户";
return (
<OobeLayout>
<div className="flex flex-col items-center gap-4">
<h2
className={`text-4xl font-bold text-foreground transition-all duration-700 ease-out ${animClass}`}
>
{displayName}
</h2>
{user?.sub && (
<p className="text-xs text-muted-foreground font-mono">
UUID: {user.sub}
</p>
)}
</div>
{showBtn && <NextButton onClick={() => navigate("oobe/version")} />}
</OobeLayout>
);
}
+51 -64
View File
@@ -1,21 +1,6 @@
import { useConfigStore } from "@/stores/configStore";
import { Switch } from "@/components/ui/switch";
function GlassCard({ children }: { children: React.ReactNode }) {
return <div className="glass-card px-5 py-4">{children}</div>;
}
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>}
</div>
<div className="shrink-0">{children}</div>
</div>
);
}
import { Switch, RadioGroup, Radio, Input, TextArea } from "@heroui/react";
import { SettingCard, SettingRow, PageHeader, SectionTitle } from "@/components/setting";
const launcherBehavior = [
{ value: "close", label: "关闭启动器" },
@@ -35,125 +20,127 @@ export function AdvancedSetting() {
return (
<div>
<h2 className="text-xl font-bold text-foreground mb-1"></h2>
<p className="text-sm text-muted-foreground mb-6"></p>
<PageHeader title="高级设置" desc="游戏高级启动参数、调试选项与实验性功能" />
<div className="space-y-6">
{/* ===== 启动行为 ===== */}
<div>
<h3 className="text-lg font-bold text-foreground mb-3"></h3>
<SectionTitle></SectionTitle>
<div className="space-y-3">
<GlassCard>
<SettingCard>
<div className="space-y-2">
<p className="text-sm font-medium text-foreground"></p>
<p className="text-[13px] text-muted-foreground"></p>
<div className="space-y-2 mt-2">
<RadioGroup
value={adv.afterLaunch}
onValueChange={(v) => setAdvanced({ afterLaunch: v })}
className="mt-2 space-y-2"
>
{launcherBehavior.map((opt) => (
<label key={opt.value} className="flex items-center gap-2 cursor-pointer">
<input type="radio" name="afterLaunch" checked={adv.afterLaunch === opt.value} onChange={() => setAdvanced({ afterLaunch: opt.value })} className="accent-primary" />
<span className="text-sm text-foreground">{opt.label}</span>
</label>
<Radio key={opt.value} value={opt.value}>
<Radio.Content>{opt.label}</Radio.Content>
</Radio>
))}
</RadioGroup>
</div>
</div>
</GlassCard>
</SettingCard>
</div>
</div>
{/* ===== 窗口设置 ===== */}
<div>
<h3 className="text-lg font-bold text-foreground mb-3"></h3>
<SectionTitle></SectionTitle>
<div className="space-y-3">
<GlassCard>
<SettingCard>
<div className="space-y-3">
<div className="space-y-2">
<p className="text-sm font-medium text-foreground"></p>
<div className="space-y-2">
<RadioGroup
value={adv.winMode}
onValueChange={(v) => setAdvanced({ winMode: v })}
className="space-y-2"
>
{windowSize.map((opt) => (
<label key={opt.value} className="flex items-center gap-2 cursor-pointer">
<input type="radio" name="winMode" checked={adv.winMode === opt.value} onChange={() => setAdvanced({ winMode: opt.value })} className="accent-primary" />
<span className="text-sm text-foreground">{opt.label}</span>
</label>
<Radio key={opt.value} value={opt.value}>
<Radio.Content>{opt.label}</Radio.Content>
</Radio>
))}
</div>
</RadioGroup>
</div>
{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
<Input
type="number"
value={adv.customWidth}
value={String(adv.customWidth)}
onChange={(e) => setAdvanced({ customWidth: Number(e.target.value) })}
className="w-20 h-8 px-2 rounded-md border border-input bg-background text-sm text-center font-mono focus:outline-none focus:ring-1 focus:ring-ring"
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
<Input
type="number"
value={adv.customHeight}
value={String(adv.customHeight)}
onChange={(e) => setAdvanced({ customHeight: Number(e.target.value) })}
className="w-20 h-8 px-2 rounded-md border border-input bg-background text-sm text-center font-mono focus:outline-none focus:ring-1 focus:ring-ring"
className="w-20"
/>
</div>
</div>
)}
</div>
</GlassCard>
</SettingCard>
</div>
</div>
{/* ===== 游戏参数 ===== */}
<div>
<h3 className="text-lg font-bold text-foreground mb-3"></h3>
<SectionTitle></SectionTitle>
<div className="space-y-3">
<GlassCard>
<SettingCard>
<div className="space-y-2">
<p className="text-sm font-medium text-foreground"></p>
<p className="text-[13px] text-muted-foreground"></p>
<input
type="text"
<Input
value={adv.gameArgs}
onChange={(e) => setAdvanced({ gameArgs: e.target.value })}
placeholder="可选,例如 --demo"
className="w-full h-8 px-3 rounded-md border border-input bg-background text-sm font-mono placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring"
fullWidth
/>
</div>
</GlassCard>
</SettingCard>
</div>
</div>
{/* ===== 启动命令 ===== */}
<div>
<h3 className="text-lg font-bold text-foreground mb-3"></h3>
<SectionTitle></SectionTitle>
<div className="space-y-3">
<GlassCard>
<SettingCard>
<div className="space-y-2">
<p className="text-sm font-medium text-foreground"></p>
<p className="text-[13px] text-muted-foreground"></p>
<input
type="text"
<Input
value={adv.preLaunchCmd}
onChange={(e) => setAdvanced({ preLaunchCmd: e.target.value })}
placeholder="可选,例如 D:\scripts\pre-launch.bat"
className="w-full h-8 px-3 rounded-md border border-input bg-background text-sm font-mono placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring"
fullWidth
/>
</div>
</GlassCard>
</SettingCard>
</div>
</div>
{/* ===== 调试 ===== */}
<div>
<h3 className="text-lg font-bold text-foreground mb-3"></h3>
<SectionTitle></SectionTitle>
<div className="space-y-3">
<GlassCard>
<SettingCard>
<SettingRow label="调试模式" desc="启用后将在控制台输出详细日志,可能影响性能">
<Switch checked={adv.debugMode} onCheckedChange={(v) => setAdvanced({ debugMode: v })} />
<Switch isSelected={adv.debugMode} onValueChange={(v) => setAdvanced({ debugMode: v })}>
<Switch.Control>
<Switch.Thumb />
</Switch.Control>
</Switch>
</SettingRow>
</GlassCard>
</SettingCard>
</div>
</div>
</div>
+9 -2
View File
@@ -1,8 +1,15 @@
import { EmptyState } from "@heroui/react";
import { Gamepad2 } from "lucide-react";
import { PageHeader } from "@/components/setting";
export function GameAccountSetting() {
return (
<div>
<h2 className="text-xl font-bold text-foreground mb-1">&</h2>
<p className="text-sm text-muted-foreground mb-6"> Minecraft </p>
<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>
</div>
);
}
+12 -20
View File
@@ -1,30 +1,26 @@
import { useConfigStore } from "@/stores/configStore";
import { Button } from "@/components/ui/button";
import { Button, Input } from "@heroui/react";
import { RefreshCw } from "lucide-react";
function GlassCard({ children }: { children: React.ReactNode }) {
return <div className="glass-card px-5 py-4">{children}</div>;
}
import { SettingCard, PageHeader, SectionTitle } from "@/components/setting";
function PathInput({ label, desc, value, onChange }: { label: string; desc: string; value: string; onChange: (v: string) => void }) {
return (
<GlassCard>
<SettingCard>
<div className="space-y-2">
<div>
<p className="text-sm font-medium text-foreground">{label}</p>
<p className="text-[13px] text-muted-foreground mt-0.5">{desc}</p>
</div>
<div className="flex items-center gap-2">
<input
type="text"
<Input
value={value}
onChange={(e) => onChange(e.target.value)}
className="flex-1 h-8 px-3 rounded-md border border-input bg-background text-sm font-mono placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring"
fullWidth
/>
<Button size="sm" variant="outline"></Button>
</div>
</div>
</GlassCard>
</SettingCard>
);
}
@@ -34,13 +30,11 @@ export function GameDirSetting() {
return (
<div>
<h2 className="text-xl font-bold text-foreground mb-1"></h2>
<p className="text-sm text-muted-foreground mb-6"> Minecraft </p>
<PageHeader title="游戏目录" desc="设置 Minecraft 游戏安装路径、存档位置与资源包目录" />
<div className="space-y-6">
{/* ===== 游戏路径 ===== */}
<div>
<h3 className="text-lg font-bold text-foreground mb-3"></h3>
<SectionTitle></SectionTitle>
<div className="space-y-3">
<PathInput
label="游戏安装根目录"
@@ -48,7 +42,7 @@ export function GameDirSetting() {
value={game.gameDir}
onChange={(v) => setGame({ gameDir: v })}
/>
<GlassCard>
<SettingCard>
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-foreground"></p>
@@ -59,13 +53,12 @@ export function GameDirSetting() {
</Button>
</div>
</GlassCard>
</SettingCard>
</div>
</div>
{/* ===== 资源包 ===== */}
<div>
<h3 className="text-lg font-bold text-foreground mb-3"></h3>
<SectionTitle></SectionTitle>
<div className="space-y-3">
<PathInput
label="资源包路径"
@@ -76,9 +69,8 @@ export function GameDirSetting() {
</div>
</div>
{/* ===== 存档 ===== */}
<div>
<h3 className="text-lg font-bold text-foreground mb-3"></h3>
<SectionTitle></SectionTitle>
<div className="space-y-3">
<PathInput
label="存档路径"
+55 -67
View File
@@ -1,23 +1,7 @@
import { useConfigStore } from "@/stores/configStore";
import { Button } from "@/components/ui/button";
import { Slider } from "@/components/ui/slider";
import { Button, Slider, RadioGroup, Radio, Input, TextArea } from "@heroui/react";
import { Cpu, FolderSearch } from "lucide-react";
function GlassCard({ children }: { children: React.ReactNode }) {
return <div className="glass-card px-5 py-4">{children}</div>;
}
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>}
</div>
<div className="shrink-0">{children}</div>
</div>
);
}
import { SettingCard, SettingRow, PageHeader, SectionTitle } from "@/components/setting";
interface JavaInfo {
path: string;
@@ -42,25 +26,23 @@ export function JavaMemSetting() {
return (
<div>
<h2 className="text-xl font-bold text-foreground mb-1">Java </h2>
<p className="text-sm text-muted-foreground mb-6"> Java JVM </p>
<PageHeader title="Java 虚拟机与内存" desc="配置 Java 运行环境路径、JVM 参数与游戏内存分配" />
<div className="space-y-6">
{/* ===== Java 环境 ===== */}
<div>
<h3 className="text-lg font-bold text-foreground mb-3">Java </h3>
<SectionTitle>Java </SectionTitle>
<div className="space-y-3">
<GlassCard>
<SettingCard>
<SettingRow label="自动检测" desc="扫描系统中已安装的 Java 版本">
<Button size="sm" variant="outline">
<FolderSearch className="w-3.5 h-3.5 mr-1.5" />
</Button>
</SettingRow>
</GlassCard>
</SettingCard>
{mockJavaList.map((j) => (
<GlassCard key={j.path}>
<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">
@@ -71,47 +53,47 @@ export function JavaMemSetting() {
{j.path}
</p>
</div>
<Button size="sm" variant="outline" className="shrink-0" onClick={() => setJava({ javaPath: j.path })}>
<Button size="sm" variant="outline" className="shrink-0" onPress={() => setJava({ javaPath: j.path })}>
使
</Button>
</div>
</GlassCard>
</SettingCard>
))}
<GlassCard>
<SettingCard>
<div className="space-y-2">
<p className="text-sm font-medium text-foreground"></p>
<div className="flex items-center gap-2">
<input
type="text"
<Input
value={java.javaPath}
onChange={(e) => setJava({ javaPath: e.target.value })}
placeholder="输入 javaw.exe 完整路径"
className="flex-1 h-8 px-3 rounded-md border border-input bg-background text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring"
fullWidth
/>
<Button size="sm" variant="outline"></Button>
</div>
</div>
</GlassCard>
</SettingCard>
</div>
</div>
{/* ===== 内存分配 ===== */}
<div>
<h3 className="text-lg font-bold text-foreground mb-3"></h3>
<SectionTitle></SectionTitle>
<div className="space-y-3">
<GlassCard>
<SettingCard>
<div className="space-y-3">
<div className="flex items-center gap-4">
<label className="flex items-center gap-2 cursor-pointer">
<input type="radio" name="mem" checked={java.memMode === "auto"} onChange={() => setJava({ memMode: "auto" })} className="accent-primary" />
<span className="text-sm text-foreground"></span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input type="radio" name="mem" checked={java.memMode === "custom"} onChange={() => setJava({ memMode: "custom" })} className="accent-primary" />
<span className="text-sm text-foreground"></span>
</label>
</div>
<RadioGroup
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>
{java.memMode === "custom" && (
<div>
<div className="flex items-center justify-between mb-1.5">
@@ -119,12 +101,17 @@ export function JavaMemSetting() {
<span className="text-[13px] text-muted-foreground tabular-nums">{java.memGB} GB</span>
</div>
<Slider
value={[java.memGB]}
onValueChange={(v) => setJava({ memGB: Array.isArray(v) ? v[0] : v })}
min={1}
max={16}
value={java.memGB}
onChange={(v) => setJava({ memGB: typeof v === "number" ? v : v[0] })}
minValue={1}
maxValue={16}
step={1}
/>
>
<Slider.Track>
<Slider.Fill />
<Slider.Thumb />
</Slider.Track>
</Slider>
<div className="flex justify-between mt-1">
<span className="text-[11px] text-muted-foreground/50">1 GB</span>
<span className="text-[11px] text-muted-foreground/50">16 GB</span>
@@ -132,47 +119,48 @@ export function JavaMemSetting() {
</div>
)}
</div>
</GlassCard>
</SettingCard>
</div>
</div>
{/* ===== JVM 参数 ===== */}
<div>
<h3 className="text-lg font-bold text-foreground mb-3">JVM </h3>
<SectionTitle>JVM </SectionTitle>
<div className="space-y-3">
<GlassCard>
<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
<TextArea
value={java.jvmArgs}
onChange={(e) => setJava({ jvmArgs: e.target.value })}
placeholder="可选,留空使用默认参数"
rows={3}
className="w-full px-3 py-2 rounded-md border border-input bg-background text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring resize-none font-mono"
fullWidth
/>
</div>
</GlassCard>
</SettingCard>
</div>
</div>
{/* ===== 垃圾回收 ===== */}
<div>
<h3 className="text-lg font-bold text-foreground mb-3"></h3>
<SectionTitle></SectionTitle>
<div className="space-y-3">
<GlassCard>
<SettingCard>
<div className="space-y-2">
<p className="text-sm font-medium text-foreground">GC </p>
<div className="space-y-2">
<RadioGroup
value={java.gc}
onValueChange={(v) => setJava({ gc: v })}
className="space-y-2"
>
{gcOptions.map((opt) => (
<label key={opt.value} className="flex items-center gap-2 cursor-pointer">
<input type="radio" name="gc" checked={java.gc === opt.value} onChange={() => setJava({ gc: opt.value })} className="accent-primary" />
<span className="text-sm text-foreground">{opt.label}</span>
</label>
<Radio key={opt.value} value={opt.value}>
<Radio.Content>{opt.label}</Radio.Content>
</Radio>
))}
</RadioGroup>
</div>
</div>
</GlassCard>
</SettingCard>
</div>
</div>
</div>
+24 -52
View File
@@ -2,22 +2,8 @@ import { VersionCard } from "@/components/VersionCard";
import { BUILD_MODE } from "@/lib/mode";
import { ExternalLink, GitFork, RotateCcw } from "lucide-react";
import { useConfirmDialogStore } from "@/stores/confirmDialogStore";
function GlassCard({ children }: { children: React.ReactNode }) {
return <div className="glass-card px-5 py-4">{children}</div>;
}
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>}
</div>
<div className="shrink-0">{children}</div>
</div>
);
}
import { Button, Link } from "@heroui/react";
import { SettingCard, SettingRow, PageHeader, SectionTitle } from "@/components/setting";
const modeLabels: Record<string, string> = {
dev: "开发版",
@@ -47,79 +33,65 @@ export function AboutSetting() {
return (
<div>
<h2 className="text-xl font-bold text-foreground mb-1"></h2>
<p className="text-sm text-muted-foreground mb-6"></p>
<PageHeader title="关于" desc="查看版本信息、更新状态与项目相关链接" />
<div className="space-y-6">
{/* 版本卡片 */}
<VersionCard />
{/* 项目信息 */}
<div>
<h3 className="text-lg font-bold text-foreground mb-3"></h3>
<SectionTitle></SectionTitle>
<div className="space-y-3">
<GlassCard>
<SettingCard>
<SettingRow label="应用名称" desc="Koring Launcher">
<span className="text-[13px] text-muted-foreground">Lingke Network </span>
</SettingRow>
</GlassCard>
<GlassCard>
</SettingCard>
<SettingCard>
<SettingRow label="构建模式">
<span className="text-[13px] text-muted-foreground">{modeLabels[BUILD_MODE] ?? BUILD_MODE}</span>
</SettingRow>
</GlassCard>
<GlassCard>
</SettingCard>
<SettingCard>
<SettingRow label="技术栈" desc="Electron + React 19 + TypeScript + @xmcl">
<span className="text-[13px] text-muted-foreground">Node.js</span>
</SettingRow>
</GlassCard>
</SettingCard>
</div>
</div>
{/* 链接 */}
<div>
<h3 className="text-lg font-bold text-foreground mb-3"></h3>
<SectionTitle></SectionTitle>
<div className="space-y-3">
<GlassCard>
<SettingCard>
<SettingRow label="GitHub 仓库" desc="查看源代码、提交 Issue">
<button
onClick={() => openLink(GITHUB_URL)}
className="inline-flex items-center gap-1.5 text-[13px] text-primary hover:underline"
>
<Link onPress={() => openLink(GITHUB_URL)}>
<GitFork className="w-4 h-4" />
<ExternalLink className="w-3 h-3" />
</button>
</Link>
</SettingRow>
</GlassCard>
<GlassCard>
</SettingCard>
<SettingCard>
<SettingRow label="官方网站" desc="了解更多功能与文档">
<button
onClick={() => openLink(OFFICIAL_URL)}
className="inline-flex items-center gap-1.5 text-[13px] text-primary hover:underline"
>
<Link onPress={() => openLink(OFFICIAL_URL)}>
访
<ExternalLink className="w-3 h-3" />
</button>
</Link>
</SettingRow>
</GlassCard>
</SettingCard>
</div>
</div>
{/* 危险操作 */}
<div>
<h3 className="text-lg font-bold text-foreground mb-3"></h3>
<GlassCard>
<SectionTitle></SectionTitle>
<SettingCard>
<SettingRow label="还原所有设置" desc="删除所有配置文件并重启应用">
<button
onClick={handleResetClick}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-md text-[13px] font-medium bg-red-500/10 text-red-600 dark:text-red-400 hover:bg-red-500/20 transition-colors"
>
<Button variant="danger" size="sm" onPress={handleResetClick}>
<RotateCcw className="w-4 h-4" />
</button>
</Button>
</SettingRow>
</GlassCard>
</SettingCard>
</div>
</div>
</div>
+28 -26
View File
@@ -2,10 +2,8 @@ import { useEffect } from "react";
import { useKoringAuthStore } from "@/stores/koringAuthStore";
import { useRouteStore } from "@/stores/routeStore";
import { UserCircle, LogOut, Loader2, ChevronRight } from "lucide-react";
function GlassCard({ children }: { children: React.ReactNode }) {
return <div className="glass-card px-5 py-4">{children}</div>;
}
import { Button, Avatar } from "@heroui/react";
import { SettingCard, PageHeader, SectionTitle } from "@/components/setting";
export function AccountSetting() {
const { user, loading, initFromDisk, logout } = useKoringAuthStore();
@@ -17,21 +15,22 @@ export function AccountSetting() {
return (
<div>
<h2 className="text-xl font-bold text-foreground mb-1">Koring </h2>
<p className="text-sm text-muted-foreground mb-6"> Koring </p>
<PageHeader title="Koring 账户" desc="管理 Koring 工作室账户,用于同步数据、皮肤与个人配置" />
<div className="space-y-6">
{/* 账户信息 */}
<div>
<h3 className="text-lg font-bold text-foreground mb-3"></h3>
<button
<SectionTitle></SectionTitle>
<SettingCard
className={
!user && !loading
? "cursor-pointer hover:bg-foreground/[0.04] transition-all duration-200"
: "cursor-default"
}
>
<div
onClick={() => {
if (!user && !loading) navigate("setting/login");
}}
className={[
"glass-card px-5 py-4 w-full text-left transition-all duration-200",
!user && !loading ? "cursor-pointer hover:bg-foreground/[0.04]" : "cursor-default",
].join(" ")}
>
{loading ? (
<div className="flex items-center gap-2 py-2">
@@ -40,13 +39,15 @@ export function AccountSetting() {
</div>
) : user ? (
<div className="flex items-center gap-4">
<div className="w-14 h-14 rounded-full bg-foreground/[0.06] flex items-center justify-center shrink-0 overflow-hidden">
<Avatar size="lg">
{user.picture ? (
<img src={user.picture} alt="" className="w-full h-full object-cover" />
<Avatar.Image src={user.picture} alt="" />
) : (
<Avatar.Fallback>
<UserCircle className="w-8 h-8 text-foreground/40" />
</Avatar.Fallback>
)}
</div>
</Avatar>
<div className="flex-1 min-w-0">
<p className="text-base font-medium text-foreground truncate">
{user.name || user.username}
@@ -69,29 +70,30 @@ export function AccountSetting() {
<ChevronRight className="w-4 h-4 text-foreground/20 shrink-0" />
</div>
)}
</button>
</div>
</SettingCard>
</div>
{/* 退出登录 */}
{user && (
<div>
<h3 className="text-lg font-bold text-foreground mb-3"></h3>
<GlassCard>
<SectionTitle></SectionTitle>
<SettingCard>
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-foreground">退</p>
<p className="text-[13px] text-muted-foreground mt-0.5">退</p>
</div>
<button
onClick={logout}
disabled={loading}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-md text-[13px] font-medium bg-red-500/10 text-red-600 dark:text-red-400 hover:bg-red-500/20 transition-colors disabled:opacity-40"
<Button
variant="danger-soft"
size="sm"
isDisabled={loading}
onPress={logout}
>
<LogOut className="w-3.5 h-3.5" />
退
</button>
</Button>
</div>
</GlassCard>
</SettingCard>
</div>
)}
</div>
+17 -41
View File
@@ -1,20 +1,6 @@
import { ExternalLink } from "lucide-react";
function GlassCard({ children }: { children: React.ReactNode }) {
return <div className="glass-card px-5 py-4">{children}</div>;
}
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>}
</div>
<div className="shrink-0">{children}</div>
</div>
);
}
import { Link } from "@heroui/react";
import { SettingCard, SettingRow, PageHeader, SectionTitle } from "@/components/setting";
const openLink = (url: string) => {
window.electronAPI?.openExternal(url);
@@ -28,7 +14,7 @@ const licenses = [
{ name: "@xmcl/user", license: "MIT", url: "https://github.com/VoxelCogs/xmcl" },
{ name: "Zustand", license: "MIT", url: "https://github.com/pmndrs/zustand" },
{ name: "Tailwind CSS", license: "MIT", url: "https://github.com/tailwindlabs/tailwindcss" },
{ name: "shadcn/ui", license: "MIT", url: "https://github.com/shadcn-ui/ui" },
{ name: "HeroUI", license: "MIT", url: "https://github.com/heroui-inc/heroui" },
{ name: "Vite", license: "MIT", url: "https://github.com/vitejs/vite" },
{ name: "Lucide React", license: "ISC", url: "https://github.com/lucide-icons/lucide" },
];
@@ -42,55 +28,45 @@ const fonts = [
export function CopyrightSetting() {
return (
<div>
<h2 className="text-xl font-bold text-foreground mb-1"></h2>
<p className="text-sm text-muted-foreground mb-6"></p>
<PageHeader title="版权" desc="开源协议、依赖项目授权与字体版权信息" />
<div className="space-y-6">
{/* 项目协议 */}
<div>
<h3 className="text-lg font-bold text-foreground mb-3"></h3>
<GlassCard>
<SectionTitle></SectionTitle>
<SettingCard>
<SettingRow label="LL-1.0 (LingkeLice 1.0)" desc="Copyright © Shenzhen Lingke Network Technology Co., Ltd.">
<button
onClick={() => openLink("https://support.lingke.ink/LL-1.0")}
className="inline-flex items-center gap-1.5 text-[13px] text-primary hover:underline"
>
<Link onPress={() => openLink("https://support.lingke.ink/LL-1.0")}>
<ExternalLink className="w-3 h-3" />
</button>
</Link>
</SettingRow>
</GlassCard>
</SettingCard>
</div>
{/* 开源依赖 */}
<div>
<h3 className="text-lg font-bold text-foreground mb-3"></h3>
<SectionTitle></SectionTitle>
<div className="space-y-2">
{licenses.map((dep) => (
<GlassCard key={dep.name}>
<SettingCard key={dep.name}>
<SettingRow label={dep.name} desc={dep.license}>
<button
onClick={() => openLink(dep.url)}
className="inline-flex items-center gap-1 text-[13px] text-primary hover:underline"
>
<Link onPress={() => openLink(dep.url)}>
<ExternalLink className="w-3 h-3" />
</button>
</Link>
</SettingRow>
</GlassCard>
</SettingCard>
))}
</div>
</div>
{/* 字体 */}
<div>
<h3 className="text-lg font-bold text-foreground mb-3"></h3>
<SectionTitle></SectionTitle>
<div className="space-y-2">
{fonts.map((font) => (
<GlassCard key={font.name}>
<SettingCard key={font.name}>
<SettingRow label={font.name} desc={font.license}>
<span className="text-[12px] text-muted-foreground">{font.note}</span>
</SettingRow>
</GlassCard>
</SettingCard>
))}
</div>
</div>
+16 -23
View File
@@ -1,5 +1,4 @@
import { useKoringAuthStore } from "@/stores/koringAuthStore";
import { useRouteStore } from "@/stores/routeStore";
import { BUILD_MODE } from "@/lib/mode";
import { useUpdateStore } from "@/stores/updateStore";
import {
@@ -11,11 +10,10 @@ import {
Cpu,
Info,
ChevronRight,
Search,
} from "lucide-react";
function GlassCard({ children, className }: { children: React.ReactNode; className?: string }) {
return <div className={`glass-card px-5 py-4 ${className ?? ""}`}>{children}</div>;
}
import { Avatar } from "@heroui/react";
import { SettingCard, PageHeader, SectionTitle } from "@/components/setting";
interface ShortcutItem {
icon: React.ReactNode;
@@ -68,31 +66,27 @@ export function HomeSetting({ onNavigate }: HomeSettingProps) {
return (
<div>
<h2 className="text-xl font-bold text-foreground mb-1"></h2>
<p className="text-sm text-muted-foreground mb-6"></p>
<PageHeader title="主页" desc="自定义启动器主页的显示内容与常用设置的快捷入口" />
<div className="space-y-6">
{/* ===== 搜索栏占位 ===== */}
<div className="glass-card flex items-center gap-3 px-4 py-3">
<svg className="w-4 h-4 text-muted-foreground shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="11" cy="11" r="8" />
<path d="m21 21-4.3-4.3" />
</svg>
<SettingCard className="flex items-center gap-3 px-4 py-3">
<Search className="w-4 h-4 text-muted-foreground shrink-0" />
<span className="text-sm text-muted-foreground"></span>
</div>
</SettingCard>
{/* ===== 快捷状态 ===== */}
<div>
<h3 className="text-lg font-bold text-foreground mb-3"></h3>
<GlassCard>
<SectionTitle></SectionTitle>
<SettingCard>
<div className="flex items-center gap-4">
<div className="w-12 h-12 rounded-full bg-foreground/[0.06] flex items-center justify-center shrink-0 overflow-hidden">
<Avatar size="lg">
{user?.picture ? (
<img src={user.picture} alt="" className="w-full h-full object-cover" />
<Avatar.Image src={user.picture} alt="" />
) : (
<Avatar.Fallback>
<UserCircle className="w-7 h-7 text-foreground/40" />
</Avatar.Fallback>
)}
</div>
</Avatar>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-foreground truncate">
{user ? (user.name || user.username) : "未登录"}
@@ -106,12 +100,11 @@ export function HomeSetting({ onNavigate }: HomeSettingProps) {
{update ? " · 有更新" : ""}
</span>
</div>
</GlassCard>
</SettingCard>
</div>
{/* ===== 快捷入口 ===== */}
<div>
<h3 className="text-lg font-bold text-foreground mb-3"></h3>
<SectionTitle></SectionTitle>
<div className="grid grid-cols-1 gap-2">
{shortcuts.map((s) => (
<ShortcutTile key={s.navKey} {...s} onClick={onNavigate} />
+12 -8
View File
@@ -133,12 +133,13 @@ export function Setting() {
return (
<div className="flex h-full">
{/* 侧边栏 — 无动画 */}
<aside className="scroll-area w-[240px] shrink-0 h-full overflow-y-auto py-5 pl-5 pr-2">
<nav className="space-y-5">
{menuData.map((group) => (
{/* 侧边栏 */}
<aside className="scroll-area w-[220px] shrink-0 h-full overflow-y-auto py-5 pl-4 pr-2">
<nav className="space-y-6">
{menuData.map((group, gi) => (
<div key={group.title}>
<h3 className="text-[11px] font-semibold uppercase tracking-wider text-foreground/30 mb-2 px-2">
{gi > 0 && <div className="mx-3 mb-3 border-t border-border/40" />}
<h3 className="text-[11px] font-semibold uppercase tracking-wider text-foreground/40 mb-2 px-3">
{group.title}
</h3>
<div className="space-y-0.5">
@@ -149,12 +150,15 @@ export function Setting() {
key={item.key}
onClick={() => handleItemClick(item)}
className={[
"w-full flex items-center gap-2.5 px-2.5 py-2 rounded-lg text-[14px] transition-all duration-150",
"relative w-full flex items-center gap-2.5 px-3 py-2 rounded-md text-[13.5px] transition-all duration-150",
active
? "bg-foreground/[0.08] text-foreground font-medium shadow-sm"
? "text-foreground font-medium"
: "text-foreground/50 hover:text-foreground/80 hover:bg-foreground/[0.04]",
].join(" ")}
>
{active && (
<span className="absolute left-0 top-1/2 -translate-y-1/2 w-[3px] h-[16px] rounded-full bg-primary" />
)}
<span className={active ? "text-foreground/80" : "text-foreground/35"}>
{item.icon}
</span>
@@ -168,7 +172,7 @@ export function Setting() {
</nav>
</aside>
{/* 内容区 — 仅内容有 fade 动画 */}
{/* 内容区 */}
<main className="scroll-area flex-1 h-full overflow-y-auto p-8">
<div key={animKey} className="setting-page-enter">
{current?.component}
+23
View File
@@ -0,0 +1,23 @@
import { useCallback } from "react";
import { useRouteStore } from "@/stores/routeStore";
import { KoringLogin } from "@/components/KoringLogin";
export function SettingLogin() {
const goBack = useRouteStore((s) => s.goBack);
const handleSuccess = useCallback(() => {
setTimeout(() => goBack(), 1500);
}, [goBack]);
return (
<div className="h-full flex flex-col items-center justify-center p-8">
<div className="w-full max-w-sm flex flex-col items-center gap-6">
<div className="text-center space-y-1">
<h2 className="text-lg font-bold text-foreground"></h2>
<p className="text-xs text-muted-foreground">使</p>
</div>
<KoringLogin onLoginSuccess={handleSuccess} />
</div>
</div>
);
}
+45 -42
View File
@@ -1,9 +1,6 @@
import { useConfigStore } from "@/stores/configStore";
import { Slider } from "@/components/ui/slider";
function GlassCard({ children }: { children: React.ReactNode }) {
return <div className="glass-card px-5 py-4">{children}</div>;
}
import { Slider, RadioGroup, Radio, Input } from "@heroui/react";
import { SettingCard, PageHeader, SectionTitle } from "@/components/setting";
const downloadSources = [
{ value: "mirror", label: "尽量使用镜像源(推荐国内用户)" },
@@ -17,51 +14,54 @@ export function DownloadSetting() {
return (
<div>
<h2 className="text-xl font-bold text-foreground mb-1"></h2>
<p className="text-sm text-muted-foreground mb-6"></p>
<PageHeader title="下载" desc="配置游戏资源下载源、并发数与存储路径" />
<div className="space-y-6">
{/* ===== 下载源 ===== */}
<div>
<h3 className="text-lg font-bold text-foreground mb-3"></h3>
<SectionTitle></SectionTitle>
<div className="space-y-3">
<GlassCard>
<SettingCard>
<div className="space-y-2">
<p className="text-sm font-medium text-foreground"></p>
<p className="text-[13px] text-muted-foreground">jarlib</p>
<div className="space-y-2 mt-2">
<RadioGroup
value={dl.fileSource}
onValueChange={(v) => setDownload({ fileSource: v })}
className="mt-2 space-y-2"
>
{downloadSources.map((opt) => (
<label key={opt.value} className="flex items-center gap-2 cursor-pointer">
<input type="radio" name="fileSource" checked={dl.fileSource === opt.value} onChange={() => setDownload({ fileSource: opt.value })} className="accent-primary" />
<span className="text-sm text-foreground">{opt.label}</span>
</label>
<Radio key={opt.value} value={opt.value}>
<Radio.Content>{opt.label}</Radio.Content>
</Radio>
))}
</RadioGroup>
</div>
</div>
</GlassCard>
</SettingCard>
<GlassCard>
<SettingCard>
<div className="space-y-2">
<p className="text-sm font-medium text-foreground"></p>
<p className="text-[13px] text-muted-foreground"></p>
<div className="space-y-2 mt-2">
<RadioGroup
value={dl.versionSource}
onValueChange={(v) => setDownload({ versionSource: v })}
className="mt-2 space-y-2"
>
{downloadSources.map((opt) => (
<label key={opt.value} className="flex items-center gap-2 cursor-pointer">
<input type="radio" name="versionSource" checked={dl.versionSource === opt.value} onChange={() => setDownload({ versionSource: opt.value })} className="accent-primary" />
<span className="text-sm text-foreground">{opt.label}</span>
</label>
<Radio key={opt.value} value={opt.value}>
<Radio.Content>{opt.label}</Radio.Content>
</Radio>
))}
</RadioGroup>
</div>
</div>
</GlassCard>
</SettingCard>
</div>
</div>
{/* ===== 并发控制 ===== */}
<div>
<h3 className="text-lg font-bold text-foreground mb-3"></h3>
<SectionTitle></SectionTitle>
<div className="space-y-3">
<GlassCard>
<SettingCard>
<div>
<div className="flex items-center justify-between mb-1.5">
<div className="flex-1 min-w-0">
@@ -71,41 +71,44 @@ export function DownloadSetting() {
<span className="text-[13px] text-muted-foreground tabular-nums shrink-0 ml-4">{dl.threads}</span>
</div>
<Slider
value={[dl.threads]}
onValueChange={(v) => setDownload({ threads: Array.isArray(v) ? v[0] : v })}
min={1}
max={64}
value={dl.threads}
onChange={(v) => setDownload({ threads: typeof v === "number" ? v : v[0] })}
minValue={1}
maxValue={64}
step={1}
/>
>
<Slider.Track>
<Slider.Fill />
<Slider.Thumb />
</Slider.Track>
</Slider>
<div className="flex justify-between mt-1">
<span className="text-[11px] text-muted-foreground/50">1</span>
<span className="text-[11px] text-muted-foreground/50">64</span>
</div>
</div>
</GlassCard>
</SettingCard>
</div>
</div>
{/* ===== 速度限制 ===== */}
<div>
<h3 className="text-lg font-bold text-foreground mb-3"></h3>
<SectionTitle></SectionTitle>
<div className="space-y-3">
<GlassCard>
<SettingCard>
<div className="space-y-2">
<p className="text-sm font-medium text-foreground"></p>
<p className="text-[13px] text-muted-foreground"> KB/s0 </p>
<div className="flex items-center gap-2">
<input
<Input
type="number"
value={dl.speedLimit}
value={String(dl.speedLimit)}
onChange={(e) => setDownload({ speedLimit: Number(e.target.value) })}
min={0}
className="w-28 h-8 px-3 rounded-md border border-input bg-background text-sm font-mono placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring"
className="w-28"
/>
<span className="text-[13px] text-muted-foreground">KB/s</span>
</div>
</div>
</GlassCard>
</SettingCard>
</div>
</div>
</div>
+9 -2
View File
@@ -1,8 +1,15 @@
import { EmptyState } from "@heroui/react";
import { Globe } from "lucide-react";
import { PageHeader } from "@/components/setting";
export function EtherOnlineSetting() {
return (
<div>
<h2 className="text-xl font-bold text-foreground mb-1"></h2>
<p className="text-sm text-muted-foreground mb-6"></p>
<PageHeader title="以太联机" desc="配置以太联机服务,实现多人局域网或远程联机" />
<EmptyState className="py-16">
<Globe className="w-10 h-10 text-muted-foreground/30" />
<p className="text-sm text-muted-foreground mt-3"></p>
</EmptyState>
</div>
);
}
+15 -28
View File
@@ -1,23 +1,8 @@
import { useCallback } from "react";
import { useConfigStore } from "@/stores/configStore";
import { Switch } from "@/components/ui/switch";
import { Switch, Input } from "@heroui/react";
import { ShieldCheck } from "lucide-react";
function GlassCard({ children }: { children: React.ReactNode }) {
return <div className="glass-card px-5 py-4">{children}</div>;
}
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>}
</div>
<div className="shrink-0">{children}</div>
</div>
);
}
import { SettingCard, SettingRow, PageHeader, SectionTitle } from "@/components/setting";
export function SecurityIdSetting() {
const enabled = useConfigStore((s) => s.config.network.securityId.enabled);
@@ -34,39 +19,41 @@ export function SecurityIdSetting() {
return (
<div>
<h2 className="text-xl font-bold text-foreground mb-1"></h2>
<p className="text-sm text-muted-foreground mb-6"></p>
<PageHeader title="安全识别服务" desc="管理账户安全验证、设备识别与登录保护" />
<div className="space-y-6">
<div>
<h3 className="text-lg font-bold text-foreground mb-3"></h3>
<SectionTitle></SectionTitle>
<div className="space-y-3">
<GlassCard>
<SettingCard>
<SettingRow
label="启用第三方认证"
desc="使用自定义认证服务器替代 Microsoft 认证(适用于离线服务器)"
>
<Switch checked={enabled} onCheckedChange={handleToggle} />
<Switch isSelected={enabled} onValueChange={handleToggle}>
<Switch.Control>
<Switch.Thumb />
</Switch.Control>
</Switch>
</SettingRow>
</GlassCard>
</SettingCard>
{enabled && (
<GlassCard>
<SettingCard>
<div className="space-y-2">
<p className="text-sm font-medium text-foreground"> URL</p>
<p className="text-[13px] text-muted-foreground"></p>
<div className="flex items-center gap-2">
<ShieldCheck className="w-4 h-4 text-muted-foreground shrink-0" />
<input
type="text"
<Input
value={authUrl}
onChange={handleUrlChange}
placeholder="https://auth.example.com"
className="flex-1 h-8 px-3 rounded-md border border-input bg-background text-sm font-mono placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring"
fullWidth
/>
</div>
</div>
</GlassCard>
</SettingCard>
)}
</div>
</div>
+9 -2
View File
@@ -1,8 +1,15 @@
import { EmptyState } from "@heroui/react";
import { Network } from "lucide-react";
import { PageHeader } from "@/components/setting";
export function TawaOnlineSetting() {
return (
<div>
<h2 className="text-xl font-bold text-foreground mb-1"></h2>
<p className="text-sm text-muted-foreground mb-6"></p>
<PageHeader title="陶瓦联机" desc="配置陶瓦联机服务,通过中继服务器进行多人游戏" />
<EmptyState className="py-16">
<Network className="w-10 h-10 text-muted-foreground/30" />
<p className="text-sm text-muted-foreground mt-3"></p>
</EmptyState>
</div>
);
}
+9 -2
View File
@@ -1,8 +1,15 @@
import { EmptyState } from "@heroui/react";
import { MessageSquareHeart } from "lucide-react";
import { PageHeader } from "@/components/setting";
export function FeedbackSetting() {
return (
<div>
<h2 className="text-xl font-bold text-foreground mb-1"></h2>
<p className="text-sm text-muted-foreground mb-6"></p>
<PageHeader title="服务与反馈" desc="提交问题反馈、功能建议与联系开发团队" />
<EmptyState className="py-16">
<MessageSquareHeart className="w-10 h-10 text-muted-foreground/30" />
<p className="text-sm text-muted-foreground mt-3"></p>
</EmptyState>
</div>
);
}
+9 -2
View File
@@ -1,8 +1,15 @@
import { EmptyState } from "@heroui/react";
import { Heart } from "lucide-react";
import { PageHeader } from "@/components/setting";
export function SponsorSetting() {
return (
<div>
<h2 className="text-xl font-bold text-foreground mb-1"></h2>
<p className="text-sm text-muted-foreground mb-6"> Koring Launcher </p>
<PageHeader title="赞助我们" desc="如果 Koring Launcher 对你有帮助,欢迎赞助支持开发" />
<EmptyState className="py-16">
<Heart className="w-10 h-10 text-muted-foreground/30" />
<p className="text-sm text-muted-foreground mt-3"></p>
</EmptyState>
</div>
);
}
+25 -29
View File
@@ -1,51 +1,47 @@
import { useA11yStore } from "@/stores/a11yStore";
import { Switch } from "@/components/ui/switch";
function GlassCard({ children }: { children: React.ReactNode }) {
return <div className="glass-card px-5 py-4">{children}</div>;
}
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>
<p className="text-[13px] text-muted-foreground mt-0.5">{desc}</p>
</div>
<div className="shrink-0">{children}</div>
</div>
);
}
import { Switch } from "@heroui/react";
import { SettingCard, SettingRow, PageHeader, SectionTitle } from "@/components/setting";
export function A11ySetting() {
const { reduceMotion, setReduceMotion, reduceTransparency, setReduceTransparency, highContrast, setHighContrast } = useA11yStore();
return (
<div>
<h2 className="text-xl font-bold text-foreground mb-1"></h2>
<p className="text-sm text-muted-foreground mb-6">使</p>
<PageHeader title="辅助功能" desc="调整动画、透明度与对比度以改善使用体验" />
<div className="space-y-6">
<div>
<h3 className="text-lg font-bold text-foreground mb-3"></h3>
<SectionTitle></SectionTitle>
<div className="space-y-3">
<GlassCard>
<SettingCard>
<SettingRow label="减少动画" desc="关闭页面切换动画和背景动效">
<Switch checked={reduceMotion} onCheckedChange={setReduceMotion} />
<Switch isSelected={reduceMotion} onValueChange={setReduceMotion}>
<Switch.Control>
<Switch.Thumb />
</Switch.Control>
</Switch>
</SettingRow>
</GlassCard>
</SettingCard>
<GlassCard>
<SettingCard>
<SettingRow label="减少透明度" desc="将磨砂玻璃效果替换为纯色背景,提升可读性">
<Switch checked={reduceTransparency} onCheckedChange={setReduceTransparency} />
<Switch isSelected={reduceTransparency} onValueChange={setReduceTransparency}>
<Switch.Control>
<Switch.Thumb />
</Switch.Control>
</Switch>
</SettingRow>
</GlassCard>
</SettingCard>
<GlassCard>
<SettingCard>
<SettingRow label="高对比度" desc="增强文字与背景的对比度,改善可读性">
<Switch checked={highContrast} onCheckedChange={setHighContrast} />
<Switch isSelected={highContrast} onValueChange={setHighContrast}>
<Switch.Control>
<Switch.Thumb />
</Switch.Control>
</Switch>
</SettingRow>
</GlassCard>
</SettingCard>
</div>
</div>
</div>
+9 -2
View File
@@ -1,8 +1,15 @@
import { EmptyState } from "@heroui/react";
import { Languages } from "lucide-react";
import { PageHeader } from "@/components/setting";
export function LangSetting() {
return (
<div>
<h2 className="text-xl font-bold text-foreground mb-1"></h2>
<p className="text-sm text-muted-foreground mb-6"></p>
<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>
);
}
+46 -62
View File
@@ -1,28 +1,9 @@
import { useThemeStore, type DarkMode } from "@/stores/themeStore";
import { useBackgroundStore } from "@/stores/backgroundStore";
import { Switch } from "@/components/ui/switch";
import { Slider } from "@/components/ui/slider";
import { Button } from "@/components/ui/button";
import { Switch, Button, Slider } from "@heroui/react";
import { DEFAULT_BG } from "@/lib/mode";
import clsx from "clsx";
function GlassCard({ children }: { children: React.ReactNode }) {
return <div className="glass-card px-5 py-4">{children}</div>;
}
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>}
</div>
<div className="shrink-0">{children}</div>
</div>
);
}
/* ======== 深色模式预览卡片 ======== */
import { SettingCard, SettingRow, PageHeader, SectionTitle } from "@/components/setting";
function ThemePreviewCard({ mode, selected, onClick }: { mode: DarkMode; selected: boolean; onClick: () => void }) {
const isDark = mode === "dark";
@@ -43,7 +24,6 @@ function ThemePreviewCard({ mode, selected, onClick }: { mode: DarkMode; selecte
>
<div className="relative w-[140px] h-[96px] rounded overflow-hidden">
{isAuto ? (
/* 跟随系统 — 左右分屏 */
<div className="flex w-full h-full">
<div className="w-1/2 h-full bg-white flex flex-col">
<div className="h-[6px] bg-gray-200 flex items-center px-1 gap-[2px]">
@@ -69,7 +49,6 @@ function ThemePreviewCard({ mode, selected, onClick }: { mode: DarkMode; selecte
</div>
</div>
) : isDark ? (
/* 深色模式 */
<div className="w-full h-full bg-[#1c1c1e] flex flex-col">
<div className="h-[6px] bg-[#2c2c2e] flex items-center px-1 gap-[2px]">
<div className="w-[3px] h-[3px] rounded-full bg-red-400" />
@@ -83,7 +62,6 @@ function ThemePreviewCard({ mode, selected, onClick }: { mode: DarkMode; selecte
</div>
</div>
) : (
/* 浅色模式 */
<div className="w-full h-full bg-white flex flex-col">
<div className="h-[6px] bg-gray-200 flex items-center px-1 gap-[2px]">
<div className="w-[3px] h-[3px] rounded-full bg-red-400" />
@@ -103,8 +81,6 @@ function ThemePreviewCard({ mode, selected, onClick }: { mode: DarkMode; selecte
);
}
/* ======== 页面 ======== */
export function ThemeBgSetting() {
const { darkMode, setDarkMode, parallax, setParallax } = useThemeStore();
const { image, opacity, setOpacity, blur, setBlur, setImage, reset } = useBackgroundStore();
@@ -122,29 +98,26 @@ export function ThemeBgSetting() {
return (
<div>
<h2 className="text-xl font-bold text-foreground mb-1"></h2>
<p className="text-sm text-muted-foreground mb-6"></p>
<PageHeader title="主题与背景" desc="切换深色模式、更换背景图片与调整视觉效果" />
<div className="space-y-6">
{/* ===== 深色模式 ===== */}
<div>
<h3 className="text-lg font-bold text-foreground mb-3"></h3>
<GlassCard>
<SectionTitle></SectionTitle>
<SettingCard>
<div className="flex items-center justify-center gap-4 py-2">
<ThemePreviewCard mode="light" selected={darkMode === "light"} onClick={() => setDarkMode("light")} />
<ThemePreviewCard mode="auto" selected={darkMode === "auto"} onClick={() => setDarkMode("auto")} />
<ThemePreviewCard mode="dark" selected={darkMode === "dark"} onClick={() => setDarkMode("dark")} />
</div>
</GlassCard>
</SettingCard>
</div>
{/* ===== 背景 ===== */}
<div>
<h3 className="text-lg font-bold text-foreground mb-3"></h3>
<SectionTitle></SectionTitle>
<div className="space-y-3">
<GlassCard>
<SettingCard>
<SettingRow label="背景图片" desc="更换启动器背景图片">
<Button variant="outline" size="sm" onClick={handlePickImage}>
<Button variant="outline" size="sm" onPress={handlePickImage}>
</Button>
</SettingRow>
@@ -157,50 +130,61 @@ export function ThemeBgSetting() {
/>
</div>
)}
</GlassCard>
</SettingCard>
<GlassCard>
<SettingCard>
<SettingRow label="背景模糊" desc={`对背景图施加高斯模糊,当前 ${blur}px`}>
<Slider
className="w-[180px]"
value={[blur]}
min={0}
max={20}
value={blur}
minValue={0}
maxValue={20}
step={1}
onValueChange={(v) => setBlur(Array.isArray(v) ? v[0] : v)}
/>
onChange={(v) => setBlur(typeof v === "number" ? v : v[0])}
>
<Slider.Track>
<Slider.Fill />
<Slider.Thumb />
</Slider.Track>
</Slider>
</SettingRow>
</GlassCard>
</SettingCard>
<GlassCard>
<SettingCard>
<SettingRow label="背景不透明度" desc={`控制背景图的可见程度,当前 ${Math.round(opacity * 100)}%`}>
<Slider
className="w-[180px]"
value={[opacity * 100]}
min={0}
max={100}
value={opacity * 100}
minValue={0}
maxValue={100}
step={1}
onValueChange={(v) => setOpacity((Array.isArray(v) ? v[0] : v) / 100)}
/>
</SettingRow>
</GlassCard>
<GlassCard>
<SettingRow
label="背景图片视差"
desc="背景图片随窗口滚动产生视差位移"
onChange={(v) => setOpacity((typeof v === "number" ? v : v[0]) / 100)}
>
<Switch checked={parallax} onCheckedChange={setParallax} />
<Slider.Track>
<Slider.Fill />
<Slider.Thumb />
</Slider.Track>
</Slider>
</SettingRow>
</GlassCard>
</SettingCard>
<GlassCard>
<SettingCard>
<SettingRow label="背景图片视差" desc="背景图片随窗口滚动产生视差位移">
<Switch isSelected={parallax} onValueChange={setParallax}>
<Switch.Control>
<Switch.Thumb />
</Switch.Control>
</Switch>
</SettingRow>
</SettingCard>
<SettingCard>
<SettingRow label="恢复默认" desc="重置所有背景设置为初始状态">
<Button variant="destructive" size="sm" onClick={handleReset}>
<Button variant="danger" size="sm" onPress={handleReset}>
</Button>
</SettingRow>
</GlassCard>
</SettingCard>
</div>
</div>
</div>
+9 -2
View File
@@ -1,8 +1,15 @@
import { EmptyState } from "@heroui/react";
import { Monitor } from "lucide-react";
import { PageHeader } from "@/components/setting";
export function UiSetting() {
return (
<div>
<h2 className="text-xl font-bold text-foreground mb-1"></h2>
<p className="text-sm text-muted-foreground mb-6"></p>
<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>
</div>
);
}
+46
View File
@@ -0,0 +1,46 @@
import { create } from "zustand";
import {
getKoringUser,
logoutKoring,
type KoringUser,
type KoringAuthData,
} from "../api/koring-auth";
interface KoringAuthState {
user: KoringUser | null;
authData: KoringAuthData | null;
loading: boolean;
initFromDisk: () => Promise<void>;
setUser: (user: KoringUser) => void;
logout: () => Promise<void>;
}
export const useKoringAuthStore = create<KoringAuthState>((set) => ({
user: null,
authData: null,
loading: false,
initFromDisk: async () => {
// 如果已经有用户数据,跳过重复读取
if (get().user) return;
set({ loading: true });
try {
const data = await getKoringUser();
if (data?.user?.sub) {
set({ user: data.user, authData: data, loading: false });
} else {
set({ loading: false });
}
} catch {
set({ loading: false });
}
},
setUser: (user) => set({ user }),
logout: async () => {
set({ user: null, authData: null });
await logoutKoring().catch(() => {});
},
}));