mirror of
https://github.com/dream-pep/koring-launcher.git
synced 2026-09-12 05:45:18 +08:00
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:
@@ -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: '' });
|
||||
}
|
||||
@@ -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: '' });
|
||||
}
|
||||
@@ -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) };
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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) };
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"username": "dream_pep",
|
||||
"uuid": "rqkzikchwvjt",
|
||||
"accessToken": "Nsk5O07OxX_oDQRFHWdIh6oDNHB8WxleTsAi3yAUtCL",
|
||||
"refreshToken": "rFvsd9kmvyiBYNLID8A5xmeWTqF-Efselc21ug3cGiy",
|
||||
"xboxProfile": "{\"sub\":\"rqkzikchwvjt\",\"name\":\"周逸\",\"username\":\"dream_pep\",\"email\":\"\",\"picture\":\"\"}"
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export { SettingCard } from "./SettingCard";
|
||||
export { SettingRow } from "./SettingRow";
|
||||
export { PageHeader, SectionTitle } from "./SectionTitle";
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
))}
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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="存档路径"
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
</GlassCard>
|
||||
</SettingCard>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,81 +15,85 @@ 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
|
||||
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(" ")}
|
||||
<SectionTitle>账户信息</SectionTitle>
|
||||
<SettingCard
|
||||
className={
|
||||
!user && !loading
|
||||
? "cursor-pointer hover:bg-foreground/[0.04] transition-all duration-200"
|
||||
: "cursor-default"
|
||||
}
|
||||
>
|
||||
{loading ? (
|
||||
<div className="flex items-center gap-2 py-2">
|
||||
<Loader2 className="w-4 h-4 animate-spin text-muted-foreground" />
|
||||
<span className="text-sm text-muted-foreground">加载中...</span>
|
||||
</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">
|
||||
{user.picture ? (
|
||||
<img src={user.picture} alt="" className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<UserCircle className="w-8 h-8 text-foreground/40" />
|
||||
)}
|
||||
<div
|
||||
onClick={() => {
|
||||
if (!user && !loading) navigate("setting/login");
|
||||
}}
|
||||
>
|
||||
{loading ? (
|
||||
<div className="flex items-center gap-2 py-2">
|
||||
<Loader2 className="w-4 h-4 animate-spin text-muted-foreground" />
|
||||
<span className="text-sm text-muted-foreground">加载中...</span>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-base font-medium text-foreground truncate">
|
||||
{user.name || user.username}
|
||||
</p>
|
||||
<p className="text-[13px] text-muted-foreground mt-0.5">Koring 账户</p>
|
||||
{user.email && (
|
||||
<p className="text-[11px] text-muted-foreground/60 mt-0.5">{user.email}</p>
|
||||
)}
|
||||
) : user ? (
|
||||
<div className="flex items-center gap-4">
|
||||
<Avatar size="lg">
|
||||
{user.picture ? (
|
||||
<Avatar.Image src={user.picture} alt="" />
|
||||
) : (
|
||||
<Avatar.Fallback>
|
||||
<UserCircle className="w-8 h-8 text-foreground/40" />
|
||||
</Avatar.Fallback>
|
||||
)}
|
||||
</Avatar>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-base font-medium text-foreground truncate">
|
||||
{user.name || user.username}
|
||||
</p>
|
||||
<p className="text-[13px] text-muted-foreground mt-0.5">Koring 账户</p>
|
||||
{user.email && (
|
||||
<p className="text-[11px] text-muted-foreground/60 mt-0.5">{user.email}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<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">
|
||||
<UserCircle className="w-8 h-8 text-foreground/30" />
|
||||
) : (
|
||||
<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">
|
||||
<UserCircle className="w-8 h-8 text-foreground/30" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm text-muted-foreground">尚未登录</p>
|
||||
<p className="text-[12px] text-muted-foreground/60 mt-0.5">点击登录 Koring 账户</p>
|
||||
</div>
|
||||
<ChevronRight className="w-4 h-4 text-foreground/20 shrink-0" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm text-muted-foreground">尚未登录</p>
|
||||
<p className="text-[12px] text-muted-foreground/60 mt-0.5">点击登录 Koring 账户</p>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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="" />
|
||||
) : (
|
||||
<UserCircle className="w-7 h-7 text-foreground/40" />
|
||||
<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} />
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">游戏文件(jar、lib)的下载来源</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>
|
||||
))}
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</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>
|
||||
))}
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</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/s,0 表示不限速</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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
onChange={(v) => setOpacity((typeof v === "number" ? v : v[0]) / 100)}
|
||||
>
|
||||
<Slider.Track>
|
||||
<Slider.Fill />
|
||||
<Slider.Thumb />
|
||||
</Slider.Track>
|
||||
</Slider>
|
||||
</SettingRow>
|
||||
</GlassCard>
|
||||
</SettingCard>
|
||||
|
||||
<GlassCard>
|
||||
<SettingRow
|
||||
label="背景图片视差"
|
||||
desc="背景图片随窗口滚动产生视差位移"
|
||||
>
|
||||
<Switch checked={parallax} onCheckedChange={setParallax} />
|
||||
<SettingCard>
|
||||
<SettingRow label="背景图片视差" desc="背景图片随窗口滚动产生视差位移">
|
||||
<Switch isSelected={parallax} onValueChange={setParallax}>
|
||||
<Switch.Control>
|
||||
<Switch.Thumb />
|
||||
</Switch.Control>
|
||||
</Switch>
|
||||
</SettingRow>
|
||||
</GlassCard>
|
||||
</SettingCard>
|
||||
|
||||
<GlassCard>
|
||||
<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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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(() => {});
|
||||
},
|
||||
}));
|
||||
Reference in New Issue
Block a user