hot:优化分类

This commit is contained in:
Tthfyth
2025-12-10 10:42:48 +08:00
parent 923bbc0b69
commit 9aec3615c9
13 changed files with 1997 additions and 47 deletions

1
.gitignore vendored
View File

@@ -27,3 +27,4 @@ npm-debug.log.*
*.css.d.ts
*.sass.d.ts
*.scss.d.ts
testSource/

View File

@@ -43,6 +43,7 @@
"lint": "cross-env NODE_ENV=development eslint . --ext .js,.jsx,.ts,.tsx",
"lint:fix": "cross-env NODE_ENV=development eslint . --ext .js,.jsx,.ts,.tsx --fix",
"package": "ts-node ./.erb/scripts/clean.js dist && npm run build && electron-builder build --publish never && npm run build:dll",
"release": "ts-node ./.erb/scripts/clean.js dist && npm run build && electron-builder build --publish always",
"rebuild": "electron-rebuild --parallel --types prod,dev,optional --module-dir release/app",
"prestart": "cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true NODE_OPTIONS=\"-r ts-node/register --no-warnings\" webpack --config ./.erb/configs/webpack.config.main.dev.ts",
"start": "ts-node ./.erb/scripts/check-port-in-use.js && npm run prestart && npm run start:renderer",

View File

@@ -1,6 +1,6 @@
{
"name": "source-debug",
"version": "0.1.5",
"version": "0.1.6",
"description": "书源调试器 - Legado/异次元书源调试工具",
"license": "MIT",
"author": {

View File

@@ -384,17 +384,11 @@ export class AnalyzeUrl {
);
// 3. 转换 E4X XML 字面量 (简单处理,转为字符串)
// <xml>...</xml> -> '<xml>...</xml>'
result = result.replace(
/(<\w+[^>]*>[\s\S]*?<\/\w+>)/g,
(match) => {
// 检查是否在字符串内
if (match.startsWith("'") || match.startsWith('"') || match.startsWith('`')) {
return match;
}
return `'${match.replace(/'/g, "\\'")}'`;
}
);
// 注意:只转换独立的 XML 字面量,不转换字符串中的 XML
// 这个转换很容易出错,所以只在非常明确的情况下才转换
// 例如: var x = <xml>...</xml> 但不转换 var x = '<xml>...</xml>'
// 暂时禁用此转换,因为大多数书源不使用 E4X
// result = result.replace(...);
// 4. 转换 Java 风格的数组声明
// new java.lang.String[] -> []
@@ -437,6 +431,7 @@ export class AnalyzeUrl {
// jsLib 可能是 JSON 对象(指向远程 JS 文件的 URL或直接的 JS 代码
const jsLib = this.variables['_jsLib'];
if (jsLib) {
console.log('[AnalyzeUrl] Loading jsLib, length:', jsLib.length);
try {
// 检查是否是 JSON 对象
if (jsLib.trim().startsWith('{') && jsLib.trim().endsWith('}')) {
@@ -464,18 +459,24 @@ export class AnalyzeUrl {
}
} catch (jsonError) {
// 不是有效的 JSON作为普通 JS 代码执行
console.log('[AnalyzeUrl] jsLib is not JSON, executing as JS code');
const convertedJsLib = this.convertRhinoToES6(jsLib);
const jsLibScript = new vm.Script(convertedJsLib);
jsLibScript.runInContext(vmContext, { timeout: 5000 });
console.log('[AnalyzeUrl] jsLib executed successfully');
}
} else {
// 直接作为 JS 代码执行
console.log('[AnalyzeUrl] Executing jsLib as JS code directly');
const convertedJsLib = this.convertRhinoToES6(jsLib);
const jsLibScript = new vm.Script(convertedJsLib);
jsLibScript.runInContext(vmContext, { timeout: 5000 });
console.log('[AnalyzeUrl] jsLib executed successfully');
}
} catch (e) {
console.error('[AnalyzeUrl] jsLib execution error:', e);
// jsLib 执行失败时,不继续执行主代码
throw e;
}
}
@@ -487,6 +488,88 @@ export class AnalyzeUrl {
}
}
/**
* 执行 JS 获取发现分类 - 公开方法供外部调用
* 用于解析 exploreUrl 中的 <js>...</js> 或 @js:... 规则
*/
evalJsForExplore(jsCode: string): string {
try {
const result = this.evalJS(jsCode, null);
if (result === null || result === undefined) return '';
if (typeof result === 'string') return result;
if (typeof result === 'object') return JSON.stringify(result);
return String(result);
} catch (error) {
console.error('[AnalyzeUrl] evalJsForExplore error:', error);
return '';
}
}
/**
* 执行 JS 并传入登录数据
* 用于登录功能,参考 Legado BaseSource.evalJS()
*/
evalJsWithLoginData(jsCode: string, loginData: Record<string, string>): any {
try {
// 转换 Rhino JS 语法为标准 ES6
const convertedCode = this.convertRhinoToES6(jsCode);
// 创建沙箱环境,传入登录数据
const sandbox = this.createJsSandbox(loginData);
// 额外注入登录数据作为 result
sandbox.result = loginData;
const vmContext = vm.createContext(sandbox);
// 加载 jsLib
const jsLib = this.variables['_jsLib'];
if (jsLib) {
try {
if (jsLib.trim().startsWith('{') && jsLib.trim().endsWith('}')) {
// JSON 格式的 jsLib远程 JS 文件)
try {
const jsMap = JSON.parse(jsLib);
for (const [, url] of Object.entries(jsMap)) {
if (typeof url === 'string' && (url.startsWith('http://') || url.startsWith('https://'))) {
const cacheKey = `_jsLib_${url}`;
let jsCode = CacheManager.get(cacheKey);
if (!jsCode) {
const response = syncHttpRequest(url);
if (response.body) {
jsCode = response.body;
CacheManager.put(cacheKey, jsCode, 86400);
}
}
if (jsCode) {
const convertedJsLib = this.convertRhinoToES6(jsCode);
const jsLibScript = new vm.Script(convertedJsLib);
jsLibScript.runInContext(vmContext, { timeout: 10000 });
}
}
}
} catch {
const convertedJsLib = this.convertRhinoToES6(jsLib);
const jsLibScript = new vm.Script(convertedJsLib);
jsLibScript.runInContext(vmContext, { timeout: 5000 });
}
} else {
const convertedJsLib = this.convertRhinoToES6(jsLib);
const jsLibScript = new vm.Script(convertedJsLib);
jsLibScript.runInContext(vmContext, { timeout: 5000 });
}
} catch (e) {
console.error('[AnalyzeUrl] jsLib execution error in login:', e);
}
}
const script = new vm.Script(convertedCode);
return script.runInContext(vmContext, { timeout: 30000 }); // 登录可能需要更长时间
} catch (error) {
console.error('[AnalyzeUrl] evalJsWithLoginData error:', error);
throw error;
}
}
/**
* 创建 JS 沙箱环境 - 提供 Legado 兼容的 java 对象
* 参考 Legado JsExtensions.kt
@@ -495,6 +578,7 @@ export class AnalyzeUrl {
const self = this;
// cookie 对象 - 使用持久化的 CookieStore
// 参考 Legado JsExtensions.kt 中的 cookie 对象
const cookie = {
getCookie: (tag: string, key?: string) => {
if (key) {
@@ -502,6 +586,9 @@ export class AnalyzeUrl {
}
return CookieStore.getCookie(tag);
},
getKey: (tag: string, key: string) => {
return CookieStore.getKey(tag, key);
},
setCookie: (tag: string, cookie: string) => {
CookieStore.setCookie(tag, cookie);
},

View File

@@ -1,5 +1,29 @@
export { SourceDebugger } from './source-debugger';
export type { BookSource, DebugResult, DebugLog, ParsedBook, ParsedChapter } from './source-debugger';
export {
SourceDebugger,
parseExploreUrl,
// 登录相关
parseLoginUi,
getLoginJs,
getLoginInfo,
putLoginInfo,
removeLoginInfo,
getLoginHeader,
putLoginHeader,
removeLoginHeader,
executeLogin,
executeButtonAction,
checkLoginStatus,
} from './source-debugger';
export type {
BookSource,
DebugResult,
DebugLog,
ParsedBook,
ParsedChapter,
ExploreCategory,
LoginUiItem,
LoginResult,
} from './source-debugger';
export { YiciyuanDebugger, isYiciyuanSource } from './yiciyuan-debugger';
export type { YiciyuanSource } from './yiciyuan-debugger';
export { httpRequest, parseHeaders } from './http-client';

View File

@@ -1684,4 +1684,442 @@ export class SourceDebugger {
}
}
/**
* 解析发现分类列表
* 支持 Legado 的多种格式:
* 1. 文本格式:分类名::URL用 && 或换行分隔)
* 2. JSON 格式:[{"title":"分类","url":"..."}]
* 3. JS 动态格式:<js>...</js> 或 @js:...
* 4. 分组:没有 URL 的项作为分组标题
*/
export interface ExploreCategory {
title: string;
url: string;
group: string;
style?: any;
}
export async function parseExploreUrl(
source: BookSource,
variables?: Record<string, any>
): Promise<ExploreCategory[]> {
const exploreUrl = source.exploreUrl || (source as any).ruleFindUrl || '';
if (!exploreUrl) return [];
let ruleStr = exploreUrl;
// 处理 JS 动态规则
if (exploreUrl.trim().startsWith('<js>') || exploreUrl.trim().toLowerCase().startsWith('@js:')) {
console.log('[parseExploreUrl] Detected JS dynamic rule');
console.log('[parseExploreUrl] jsLib available:', !!source.jsLib);
try {
const analyzeUrl = new AnalyzeUrl(source.bookSourceUrl, {
source,
variables: {
...variables,
_jsLib: source.jsLib,
},
});
// 提取 JS 代码 - 参考 Legado BookSourceExtensions.kt
let jsCode: string;
if (exploreUrl.trim().startsWith('<js>')) {
// <js>...</js> 格式:取 <js> 到最后一个 < 之间的内容
const startIndex = exploreUrl.indexOf('>') + 1;
const endIndex = exploreUrl.lastIndexOf('<');
jsCode = exploreUrl.substring(startIndex, endIndex > startIndex ? endIndex : exploreUrl.length);
} else {
// @js: 格式:取 @js: 之后的所有内容
jsCode = exploreUrl.substring(4);
}
console.log('[parseExploreUrl] JS code length:', jsCode.length);
// 执行 JS
ruleStr = analyzeUrl.evalJsForExplore(jsCode);
console.log('[parseExploreUrl] JS result length:', ruleStr?.length || 0);
if (!ruleStr) {
console.log('[parseExploreUrl] JS returned empty result');
return [];
}
} catch (error) {
console.error('[parseExploreUrl] JS execution error:', error);
return [];
}
}
const categories: ExploreCategory[] = [];
let currentGroup = '默认';
// 尝试 JSON 格式
if (ruleStr.trim().startsWith('[')) {
try {
const jsonData = JSON.parse(ruleStr);
if (Array.isArray(jsonData)) {
for (const item of jsonData) {
if (item.title) {
if (item.url) {
categories.push({
title: item.title,
url: item.url,
group: currentGroup,
style: item.style,
});
} else {
// 没有 URL是分组标题
currentGroup = item.title;
}
}
}
}
return categories;
} catch {
// 不是有效 JSON继续尝试文本格式
}
}
// 文本格式解析(支持 && 和换行分隔)
const lines = ruleStr.split(/&&|\n/).filter((l: string) => l.trim());
for (const line of lines) {
const trimmed = line.trim();
if (trimmed.includes('::')) {
const separatorIndex = trimmed.indexOf('::');
const name = trimmed.substring(0, separatorIndex).trim();
const url = trimmed.substring(separatorIndex + 2).trim();
if (name && url) {
categories.push({
title: name,
url: url,
group: currentGroup,
});
} else if (name && !url) {
// 只有名称没有 URL是分组标题
currentGroup = name;
}
} else if (trimmed.startsWith('http')) {
// 纯 URL
categories.push({
title: trimmed,
url: trimmed,
group: currentGroup,
});
} else if (trimmed) {
// 纯文本,作为分组标题
currentGroup = trimmed;
}
}
return categories;
}
// ============================================
// 登录功能实现
// 参考 Legado BaseSource.kt
// ============================================
/**
* 登录 UI 配置项
*/
export interface LoginUiItem {
name: string;
type: 'text' | 'password' | 'button';
action?: string; // 按钮动作URL 或 JS
}
/**
* 登录结果
*/
export interface LoginResult {
success: boolean;
message?: string;
loginHeader?: Record<string, string>;
}
// 登录信息存储(内存 + 持久化)
const loginInfoStore = new Map<string, Record<string, string>>();
const loginHeaderStore = new Map<string, Record<string, string>>();
/**
* 解析 loginUi JSON
* 支持非标准 JSON单引号
*/
export function parseLoginUi(loginUi?: string): LoginUiItem[] {
if (!loginUi) return [];
try {
// 尝试标准 JSON 解析
let items: any[];
try {
items = JSON.parse(loginUi);
} catch {
// 如果失败,尝试修复非标准 JSON
// 使用更智能的方式处理单引号
let fixedJson = loginUi;
// 1. 先将已转义的单引号临时替换
fixedJson = fixedJson.replace(/\\'/g, '___ESCAPED_QUOTE___');
// 2. 将属性名的单引号替换为双引号: 'name': -> "name":
fixedJson = fixedJson.replace(/'(\w+)'(\s*:)/g, '"$1"$2');
// 3. 将字符串值的单引号替换为双引号: : 'value' -> : "value"
// 但要小心处理值中包含单引号的情况
fixedJson = fixedJson.replace(/:\s*'([^']*)'/g, ': "$1"');
// 4. 恢复转义的单引号(在双引号字符串中变成普通单引号)
fixedJson = fixedJson.replace(/___ESCAPED_QUOTE___/g, "'");
// 5. 移除尾随逗号
fixedJson = fixedJson.replace(/,(\s*[}\]])/g, '$1');
items = JSON.parse(fixedJson);
}
if (Array.isArray(items)) {
return items.map((item: any) => ({
name: item.name || '',
type: item.type || 'text',
action: item.action,
}));
}
} catch (e) {
console.error('[parseLoginUi] Failed to parse loginUi:', e);
// 尝试使用 eval 作为最后手段(不推荐,但某些书源可能需要)
try {
// eslint-disable-next-line no-eval
const items = eval(`(${loginUi})`);
if (Array.isArray(items)) {
return items.map((item: any) => ({
name: item.name || '',
type: item.type || 'text',
action: item.action,
}));
}
} catch (evalError) {
console.error('[parseLoginUi] Eval fallback also failed:', evalError);
}
}
return [];
}
/**
* 提取登录 JS 代码
* 参考 Legado BaseSource.getLoginJs()
*/
export function getLoginJs(loginUrl?: string): string | null {
if (!loginUrl) return null;
if (loginUrl.startsWith('@js:')) {
return loginUrl.substring(4);
}
if (loginUrl.startsWith('<js>')) {
const endIndex = loginUrl.lastIndexOf('<');
return loginUrl.substring(4, endIndex > 4 ? endIndex : loginUrl.length);
}
return loginUrl;
}
/**
* 获取登录信息
*/
export function getLoginInfo(sourceKey: string): Record<string, string> | null {
// 先从内存获取
if (loginInfoStore.has(sourceKey)) {
return loginInfoStore.get(sourceKey)!;
}
// 从持久化存储获取
const cached = CacheManager.get(`loginInfo_${sourceKey}`);
if (cached) {
try {
const info = JSON.parse(cached);
loginInfoStore.set(sourceKey, info);
return info;
} catch {
// ignore
}
}
return null;
}
/**
* 保存登录信息
*/
export function putLoginInfo(sourceKey: string, info: Record<string, string>): void {
loginInfoStore.set(sourceKey, info);
CacheManager.put(`loginInfo_${sourceKey}`, JSON.stringify(info));
}
/**
* 删除登录信息
*/
export function removeLoginInfo(sourceKey: string): void {
loginInfoStore.delete(sourceKey);
CacheManager.delete(`loginInfo_${sourceKey}`);
}
/**
* 获取登录头部
*/
export function getLoginHeader(sourceKey: string): Record<string, string> | null {
if (loginHeaderStore.has(sourceKey)) {
return loginHeaderStore.get(sourceKey)!;
}
const cached = CacheManager.get(`loginHeader_${sourceKey}`);
if (cached) {
try {
const header = JSON.parse(cached);
loginHeaderStore.set(sourceKey, header);
return header;
} catch {
// ignore
}
}
return null;
}
/**
* 保存登录头部
*/
export function putLoginHeader(sourceKey: string, header: Record<string, string>): void {
loginHeaderStore.set(sourceKey, header);
CacheManager.put(`loginHeader_${sourceKey}`, JSON.stringify(header));
// 如果有 Cookie同步到 CookieStore
const cookie = header['Cookie'] || header['cookie'];
if (cookie) {
CookieStore.replaceCookie(sourceKey, cookie);
}
}
/**
* 删除登录头部
*/
export function removeLoginHeader(sourceKey: string): void {
loginHeaderStore.delete(sourceKey);
CacheManager.delete(`loginHeader_${sourceKey}`);
CookieStore.removeCookie(sourceKey);
}
/**
* 执行登录
* 参考 Legado BaseSource.login()
*/
export async function executeLogin(
source: BookSource,
loginData: Record<string, string>
): Promise<LoginResult> {
const sourceKey = source.bookSourceUrl;
// 保存登录信息
putLoginInfo(sourceKey, loginData);
const loginJs = getLoginJs(source.loginUrl);
if (!loginJs) {
return { success: false, message: '未配置登录规则' };
}
try {
// 创建 AnalyzeUrl 执行 JS
const analyzeUrl = new AnalyzeUrl(sourceKey, {
source,
variables: {
_jsLib: source.jsLib,
},
});
// 构建登录 JS - 参考 Legado
// loginJs 中应该定义一个 login 函数
const fullJs = `
${loginJs}
if (typeof login === 'function') {
login.apply(this);
} else {
throw new Error('Function login not implemented!');
}
`;
// 执行登录 JS传入登录数据
const result = analyzeUrl.evalJsWithLoginData(fullJs, loginData);
// 检查结果
if (result && typeof result === 'object') {
// 如果返回了 header保存它
if (result.header || result.headers) {
putLoginHeader(sourceKey, result.header || result.headers);
}
return {
success: true,
message: result.message || '登录成功',
loginHeader: result.header || result.headers,
};
}
return { success: true, message: '登录成功' };
} catch (error: any) {
console.error('[executeLogin] Error:', error);
return {
success: false,
message: error.message || '登录失败',
};
}
}
/**
* 执行按钮动作
*/
export async function executeButtonAction(
source: BookSource,
action: string,
loginData: Record<string, string>
): Promise<{ success: boolean; message?: string; result?: any }> {
if (!action) {
return { success: false, message: '未配置按钮动作' };
}
// 如果是 URL返回让前端打开
if (action.startsWith('http://') || action.startsWith('https://')) {
return { success: true, result: { type: 'url', url: action } };
}
// 执行 JS
try {
const loginJs = getLoginJs(source.loginUrl) || '';
const analyzeUrl = new AnalyzeUrl(source.bookSourceUrl, {
source,
variables: {
_jsLib: source.jsLib,
},
});
const fullJs = `${loginJs}\n${action}`;
const result = analyzeUrl.evalJsWithLoginData(fullJs, loginData);
return { success: true, result };
} catch (error: any) {
return { success: false, message: error.message };
}
}
/**
* 检查登录状态
*/
export function checkLoginStatus(source: BookSource): {
hasLoginUrl: boolean;
hasLoginUi: boolean;
isLoggedIn: boolean;
loginInfo: Record<string, string> | null;
} {
const sourceKey = source.bookSourceUrl;
const loginInfo = getLoginInfo(sourceKey);
const loginHeader = getLoginHeader(sourceKey);
return {
hasLoginUrl: !!source.loginUrl,
hasLoginUi: !!source.loginUi,
isLoggedIn: !!(loginInfo || loginHeader),
loginInfo,
};
}
// 导入 CacheManager 和 CookieStore
import CacheManager from './cache-manager';
import CookieStore from './cookie-manager';
export default SourceDebugger;

View File

@@ -15,7 +15,21 @@ import { autoUpdater } from 'electron-updater';
import log from 'electron-log';
// import MenuBuilder from './menu'; // 已禁用默认菜单
import { resolveHtmlPath } from './util';
import { SourceDebugger, BookSource, YiciyuanDebugger, isYiciyuanSource } from './debug';
import {
SourceDebugger,
BookSource,
YiciyuanDebugger,
isYiciyuanSource,
parseExploreUrl,
parseLoginUi,
checkLoginStatus,
executeLogin,
executeButtonAction,
getLoginInfo,
removeLoginInfo,
getLoginHeader,
removeLoginHeader,
} from './debug';
import { getAIService, ChatMessage } from './ai/ai-service';
class AppUpdater {
@@ -27,6 +41,11 @@ class AppUpdater {
autoUpdater.autoDownload = false;
autoUpdater.autoInstallOnAppQuit = true;
// 开发模式下也检查更新(需要 dev-app-update.yml
if (!app.isPackaged) {
autoUpdater.forceDevUpdateConfig = true;
}
// 检查更新事件
autoUpdater.on('checking-for-update', () => {
log.info('正在检查更新...');
@@ -59,7 +78,18 @@ class AppUpdater {
autoUpdater.on('error', (err) => {
log.error('更新错误:', err);
this.sendStatusToWindow('error', err.message);
// 简化错误信息,避免显示过长的技术细节
let errorMessage = '网络连接失败,请稍后重试';
if (err.message) {
if (err.message.includes('404') || err.message.includes('latest.yml')) {
errorMessage = '暂无更新信息,请前往 GitHub 查看';
} else if (err.message.includes('net::') || err.message.includes('ENOTFOUND') || err.message.includes('ETIMEDOUT')) {
errorMessage = '网络连接失败,请检查网络后重试';
} else if (err.message.includes('certificate') || err.message.includes('SSL')) {
errorMessage = '网络安全验证失败,请检查网络环境';
}
}
this.sendStatusToWindow('error', errorMessage);
});
autoUpdater.on('download-progress', (progressObj) => {
@@ -163,6 +193,26 @@ ipcMain.handle(
}
);
/**
* 解析发现分类列表
* 支持 JS 动态规则
*/
ipcMain.handle(
'debug:parseExploreCategories',
async (_event, source: any) => {
try {
const categories = await parseExploreUrl(source as BookSource);
return { success: true, categories };
} catch (error: any) {
return {
success: false,
categories: [],
error: error.message || String(error),
};
}
}
);
/**
* 书籍详情测试
* 自动识别源格式Legado 或 异次元)
@@ -244,6 +294,106 @@ ipcMain.handle(
}
);
// ============================================
// IPC 通信接口 - 登录功能
// ============================================
/**
* 解析登录 UI 配置
*/
ipcMain.handle('debug:parseLoginUi', async (_event, loginUi: string) => {
try {
const items = parseLoginUi(loginUi);
return { success: true, items };
} catch (error: any) {
return { success: false, items: [], error: error.message };
}
});
/**
* 检查登录状态
*/
ipcMain.handle('debug:checkLoginStatus', async (_event, source: any) => {
try {
const status = checkLoginStatus(source as BookSource);
return { success: true, ...status };
} catch (error: any) {
return { success: false, error: error.message };
}
});
/**
* 执行登录
*/
ipcMain.handle('debug:executeLogin', async (_event, source: any, loginData: Record<string, string>) => {
try {
const result = await executeLogin(source as BookSource, loginData);
return result;
} catch (error: any) {
return { success: false, message: error.message };
}
});
/**
* 执行按钮动作
*/
ipcMain.handle('debug:executeButtonAction', async (_event, source: any, action: string, loginData: Record<string, string>) => {
try {
const result = await executeButtonAction(source as BookSource, action, loginData);
return result;
} catch (error: any) {
return { success: false, message: error.message };
}
});
/**
* 获取登录信息
*/
ipcMain.handle('debug:getLoginInfo', async (_event, sourceKey: string) => {
try {
const info = getLoginInfo(sourceKey);
return { success: true, info };
} catch (error: any) {
return { success: false, error: error.message };
}
});
/**
* 删除登录信息
*/
ipcMain.handle('debug:removeLoginInfo', async (_event, sourceKey: string) => {
try {
removeLoginInfo(sourceKey);
return { success: true };
} catch (error: any) {
return { success: false, error: error.message };
}
});
/**
* 获取登录头部
*/
ipcMain.handle('debug:getLoginHeader', async (_event, sourceKey: string) => {
try {
const header = getLoginHeader(sourceKey);
return { success: true, header };
} catch (error: any) {
return { success: false, error: error.message };
}
});
/**
* 删除登录头部
*/
ipcMain.handle('debug:removeLoginHeader', async (_event, sourceKey: string) => {
try {
removeLoginHeader(sourceKey);
return { success: true };
} catch (error: any) {
return { success: false, error: error.message };
}
});
// ============================================
// IPC 通信接口 - 文件操作
// ============================================
@@ -370,7 +520,40 @@ ipcMain.handle('app:quitAndInstall', () => {
* 获取应用版本
*/
ipcMain.handle('app:getVersion', () => {
// 打包后使用 app.getVersion(),开发模式下从 package.json 读取
if (app.isPackaged) {
return app.getVersion();
}
// 开发模式下读取 release/app/package.json
try {
const path = require('path');
const fs = require('fs');
// 从项目根目录查找
const possiblePaths = [
path.join(process.cwd(), 'release/app/package.json'),
path.join(__dirname, '../../../release/app/package.json'),
path.join(__dirname, '../../../../release/app/package.json'),
];
for (const pkgPath of possiblePaths) {
if (fs.existsSync(pkgPath)) {
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
if (pkg.version) {
return pkg.version;
}
}
}
} catch (e) {
console.error('Failed to read version:', e);
}
return '0.0.0';
});
/**
* 打开外部链接
*/
ipcMain.handle('app:openExternal', async (_event, url: string) => {
const { shell } = require('electron');
await shell.openExternal(url);
});
// ============================================

View File

@@ -2,7 +2,7 @@
/* eslint no-unused-vars: off */
import { contextBridge, ipcRenderer, IpcRendererEvent } from 'electron';
export type Channels = 'ipc-example';
export type Channels = 'ipc-example' | 'update-status';
const electronHandler = {
ipcRenderer: {
@@ -21,6 +21,12 @@ const electronHandler = {
once(channel: Channels, func: (...args: unknown[]) => void) {
ipcRenderer.once(channel, (_event, ...args) => func(...args));
},
invoke(channel: string, ...args: unknown[]) {
return ipcRenderer.invoke(channel, ...args);
},
removeListener(channel: string, func: (...args: unknown[]) => void) {
ipcRenderer.removeListener(channel, func);
},
},
};
@@ -42,6 +48,13 @@ const debugApiHandler = {
return ipcRenderer.invoke('debug:explore', source, exploreUrl);
},
/**
* 解析发现分类列表(支持 JS 动态规则)
*/
parseExploreCategories: (source: any) => {
return ipcRenderer.invoke('debug:parseExploreCategories', source);
},
/**
* 书籍详情测试
*/
@@ -62,6 +75,64 @@ const debugApiHandler = {
content: (source: any, contentUrl: string) => {
return ipcRenderer.invoke('debug:content', source, contentUrl);
},
// ===== 登录相关 =====
/**
* 解析登录 UI 配置
*/
parseLoginUi: (loginUi: string) => {
return ipcRenderer.invoke('debug:parseLoginUi', loginUi);
},
/**
* 检查登录状态
*/
checkLoginStatus: (source: any) => {
return ipcRenderer.invoke('debug:checkLoginStatus', source);
},
/**
* 执行登录
*/
executeLogin: (source: any, loginData: Record<string, string>) => {
return ipcRenderer.invoke('debug:executeLogin', source, loginData);
},
/**
* 执行按钮动作
*/
executeButtonAction: (source: any, action: string, loginData: Record<string, string>) => {
return ipcRenderer.invoke('debug:executeButtonAction', source, action, loginData);
},
/**
* 获取登录信息
*/
getLoginInfo: (sourceKey: string) => {
return ipcRenderer.invoke('debug:getLoginInfo', sourceKey);
},
/**
* 删除登录信息
*/
removeLoginInfo: (sourceKey: string) => {
return ipcRenderer.invoke('debug:removeLoginInfo', sourceKey);
},
/**
* 获取登录头部
*/
getLoginHeader: (sourceKey: string) => {
return ipcRenderer.invoke('debug:getLoginHeader', sourceKey);
},
/**
* 删除登录头部
*/
removeLoginHeader: (sourceKey: string) => {
return ipcRenderer.invoke('debug:removeLoginHeader', sourceKey);
},
};
contextBridge.exposeInMainWorld('debugApi', debugApiHandler);

View File

@@ -19,6 +19,7 @@ import {
SegmentedControl,
Tooltip,
Modal,
Select,
useMantineColorScheme,
} from '@mantine/core';
import {
@@ -50,9 +51,14 @@ import {
IconLayoutColumns,
IconPlayerSkipBack,
IconPlayerSkipForward,
IconSearch,
IconCode,
IconLogin,
IconUser,
} from '@tabler/icons-react';
import { useBookSourceStore } from '../stores/bookSourceStore';
import type { BookItem, ChapterItem, TestMode } from '../types';
import { SourceLoginDialog } from './SourceLoginDialog';
const testModeOptions: { label: string; value: TestMode }[] = [
{ label: '搜索', value: 'search' },
@@ -81,8 +87,171 @@ export function DebugPanel() {
setAiAnalysisEnabled,
chapterList,
currentChapterIndex,
sources,
activeSourceId,
sourceCode,
} = useBookSourceStore();
// 发现分类状态(支持 JS 动态规则,需要后端解析)
const [exploreCategories, setExploreCategories] = useState<
Array<{ label: string; value: string }> | Array<{ group: string; items: { label: string; value: string }[] }>
>([]);
const [exploreCategoryCount, setExploreCategoryCount] = useState(0);
const [isLoadingCategories, setIsLoadingCategories] = useState(false);
// 解析发现分类列表(支持 JS 动态规则)
useEffect(() => {
const parseCategories = async () => {
// 获取当前书源
let currentSource: any = null;
try {
if (sourceCode) {
currentSource = JSON.parse(sourceCode);
}
} catch {
currentSource = sources.find(s => s.bookSourceUrl === activeSourceId);
}
if (!currentSource) {
setExploreCategories([]);
setExploreCategoryCount(0);
return;
}
const exploreUrl = currentSource.exploreUrl || currentSource.ruleFindUrl || '';
if (!exploreUrl) {
setExploreCategories([]);
setExploreCategoryCount(0);
return;
}
// 检查是否是 JS 动态规则
const isJsRule = exploreUrl.trim().startsWith('<js>') ||
exploreUrl.trim().toLowerCase().startsWith('@js:');
if (isJsRule) {
// JS 动态规则,调用后端 API 解析
setIsLoadingCategories(true);
try {
const result = await window.debugApi?.parseExploreCategories(currentSource);
if (result?.success && result?.categories) {
// 去重:使用 Set 记录已出现的 value
const seenValues = new Set<string>();
const items = result.categories
.map((cat: any) => ({
label: cat.title,
value: `${cat.title}::${cat.url}`,
group: cat.group || '默认',
}))
.filter((item: { label: string; value: string; group: string }) => {
if (seenValues.has(item.value)) {
return false;
}
seenValues.add(item.value);
return true;
});
// 转换为 Mantine Select 格式
const formatted = formatCategoriesToSelect(items);
setExploreCategories(formatted.data);
setExploreCategoryCount(formatted.count);
} else {
setExploreCategories([]);
setExploreCategoryCount(0);
}
} catch (error) {
console.error('Failed to parse explore categories:', error);
setExploreCategories([]);
setExploreCategoryCount(0);
} finally {
setIsLoadingCategories(false);
}
} else {
// 静态规则,前端直接解析
const items: { label: string; value: string; group: string }[] = [];
let currentGroup = '默认';
// 尝试 JSON 格式
if (exploreUrl.trim().startsWith('[')) {
try {
const jsonData = JSON.parse(exploreUrl);
if (Array.isArray(jsonData)) {
jsonData.forEach((item: any) => {
if (item.title) {
if (item.url) {
items.push({
label: item.title,
value: `${item.title}::${item.url}`,
group: currentGroup
});
} else {
currentGroup = item.title;
}
}
});
}
} catch {
// 不是有效 JSON
}
}
// 文本格式解析
if (items.length === 0) {
currentGroup = '默认';
const lines = exploreUrl.split(/&&|\n/).filter((l: string) => l.trim());
for (const line of lines) {
const trimmed = line.trim();
if (trimmed.includes('::')) {
const separatorIndex = trimmed.indexOf('::');
const name = trimmed.substring(0, separatorIndex).trim();
const url = trimmed.substring(separatorIndex + 2).trim();
if (name && url) {
items.push({ label: name, value: trimmed, group: currentGroup });
} else if (name && !url) {
currentGroup = name;
}
} else if (trimmed.startsWith('http')) {
items.push({ label: trimmed, value: trimmed, group: currentGroup });
} else if (trimmed) {
currentGroup = trimmed;
}
}
}
const formatted = formatCategoriesToSelect(items);
setExploreCategories(formatted.data);
setExploreCategoryCount(formatted.count);
}
};
parseCategories();
}, [sourceCode, sources, activeSourceId]);
// 辅助函数:转换分类为 Mantine Select 格式
const formatCategoriesToSelect = (items: { label: string; value: string; group: string }[]) => {
const groupMap = new Map<string, { label: string; value: string }[]>();
for (const item of items) {
if (!groupMap.has(item.group)) {
groupMap.set(item.group, []);
}
groupMap.get(item.group)!.push({ label: item.label, value: item.value });
}
const count = items.length;
// 如果只有一个分组且是默认分组,返回扁平数组
if (groupMap.size === 1 && groupMap.has('默认')) {
return { data: groupMap.get('默认')!, count };
}
// 返回分组格式
const result: Array<{ group: string; items: { label: string; value: string }[] }> = [];
for (const [group, groupItems] of groupMap) {
result.push({ group, items: groupItems });
}
return { data: result, count };
};
const { colorScheme } = useMantineColorScheme();
const [showConfig, setShowConfig] = useState(false);
const [showHistory, setShowHistory] = useState(false);
@@ -94,6 +263,53 @@ export function DebugPanel() {
const [currentImageIndex, setCurrentImageIndex] = useState(0);
const [viewMode, setViewMode] = useState<'horizontal' | 'vertical'>('horizontal'); // 横向翻页 / 纵向条漫
// 原始响应搜索和格式化状态
const [rawSearchKeyword, setRawSearchKeyword] = useState('');
const [isRawFormatted, setIsRawFormatted] = useState(false);
// 登录对话框状态
const [loginDialogOpen, setLoginDialogOpen] = useState(false);
const [loginStatus, setLoginStatus] = useState<{
hasLoginUrl: boolean;
isLoggedIn: boolean;
}>({ hasLoginUrl: false, isLoggedIn: false });
// 获取当前书源对象
const currentSource = useMemo(() => {
try {
if (sourceCode) {
return JSON.parse(sourceCode);
}
} catch {
// ignore
}
return sources.find(s => s.bookSourceUrl === activeSourceId);
}, [sourceCode, sources, activeSourceId]);
// 检查登录状态
useEffect(() => {
const checkLogin = async () => {
if (!currentSource) {
setLoginStatus({ hasLoginUrl: false, isLoggedIn: false });
return;
}
try {
const result = await window.debugApi?.checkLoginStatus(currentSource);
if (result?.success) {
setLoginStatus({
hasLoginUrl: result.hasLoginUrl ?? false,
isLoggedIn: result.isLoggedIn ?? false,
});
}
} catch {
// ignore
}
};
checkLogin();
}, [currentSource]);
// 可视化数据
const visualData = useMemo(() => {
if (!testResult?.rawParsedItems) return { books: [], chapters: [], content: '', bookDetail: null, imageUrls: [] };
@@ -265,6 +481,19 @@ export function DebugPanel() {
>
<Text size="sm" fw={600}></Text>
<Group gap="xs">
{/* 登录按钮 - 仅当书源配置了 loginUrl 时显示 */}
{loginStatus.hasLoginUrl && (
<Tooltip label={loginStatus.isLoggedIn ? '已登录,点击管理' : '点击登录'}>
<ActionIcon
variant={loginStatus.isLoggedIn ? 'filled' : 'light'}
color={loginStatus.isLoggedIn ? 'green' : 'blue'}
size="sm"
onClick={() => setLoginDialogOpen(true)}
>
{loginStatus.isLoggedIn ? <IconUser size={14} /> : <IconLogin size={14} />}
</ActionIcon>
</Tooltip>
)}
<Tooltip label={aiAnalysisEnabled ? "开启后AI对话将附加测试结果数据" : "关闭状态"}>
<Group gap={4}>
<IconSparkles size={14} color={aiAnalysisEnabled ? 'var(--mantine-color-teal-6)' : 'var(--mantine-color-dimmed)'} />
@@ -311,8 +540,25 @@ export function DebugPanel() {
{/* URL/关键词输入 */}
<Stack gap="xs">
<Group gap="xs">
{testMode === 'explore' && (exploreCategoryCount > 0 || isLoadingCategories) ? (
// 发现模式:显示下拉框选择分类(支持分组)
<Select
placeholder={isLoadingCategories ? "正在加载分类..." : "选择发现分类..."}
data={exploreCategories}
value={testInput || null}
onChange={(value) => setTestInput(value || '')}
searchable
clearable
disabled={isLoadingCategories}
style={{ flex: 1 }}
nothingFoundMessage="无匹配分类"
leftSection={isLoadingCategories ? <Loader size={14} /> : <IconCompass size={16} />}
maxDropdownHeight={300}
/>
) : (
// 其他模式:显示文本输入框
<TextInput
placeholder={testMode === 'search' ? '输入搜索关键词...' : testMode === 'explore' ? '选择发现分类...' : '输入URL...'}
placeholder={testMode === 'search' ? '输入搜索关键词...' : testMode === 'explore' ? '输入发现URL未配置分类或JS动态规则...' : '输入URL...'}
value={testInput}
onChange={(e) => setTestInput(e.currentTarget.value)}
onKeyDown={(e) => e.key === 'Enter' && handleTest()}
@@ -325,6 +571,7 @@ export function DebugPanel() {
)
}
/>
)}
<Button
onClick={handleTest}
loading={isLoading}
@@ -334,6 +581,20 @@ export function DebugPanel() {
</Button>
</Group>
{/* 发现模式下显示分类数量提示 */}
{testMode === 'explore' && (isLoadingCategories || exploreCategoryCount > 0) && (
<Text size="xs" c="dimmed">
{isLoadingCategories
? '正在解析 JS 动态发现规则...'
: `已配置 ${exploreCategoryCount} 个发现分类${
exploreCategories.length > 0 && 'group' in exploreCategories[0]
? `${exploreCategories.length} 个分组)`
: ''
}`
}
</Text>
)}
{/* 历史记录下拉 */}
<Collapse in={showHistory && testHistory.length > 0}>
<Paper withBorder p="xs">
@@ -490,24 +751,55 @@ export function DebugPanel() {
</Box>
) : testResult ? (
<Tabs value={activeResultTab} onChange={setActiveResultTab}>
<Group justify="space-between" align="center">
<Tabs.List>
<Tabs.Tab value="visual"></Tabs.Tab>
<Tabs.Tab value="parsed"></Tabs.Tab>
<Tabs.Tab value="raw"></Tabs.Tab>
</Tabs.List>
{/* 原始响应搜索和格式化按钮 - 只在选中原始响应 Tab 时显示 */}
{activeResultTab === 'raw' && (
<Group gap="xs">
<TextInput
placeholder="搜索..."
size="xs"
value={rawSearchKeyword}
onChange={(e) => setRawSearchKeyword(e.currentTarget.value)}
leftSection={<IconSearch size={14} />}
style={{ flex: 1, minWidth: 300, maxWidth: 500 }}
rightSection={
rawSearchKeyword && (
<ActionIcon variant="subtle" size="xs" onClick={() => setRawSearchKeyword('')}>
<IconX size={12} />
</ActionIcon>
)
}
/>
<Tooltip label={isRawFormatted ? '显示原始' : '格式化'}>
<ActionIcon
variant={isRawFormatted ? 'filled' : 'light'}
size="sm"
onClick={() => setIsRawFormatted(!isRawFormatted)}
>
<IconCode size={16} />
</ActionIcon>
</Tooltip>
</Group>
)}
</Group>
<Tabs.Panel value="visual" pt="sm">
<Tabs.Panel value="visual" pt="sm" style={{ height: 'calc(100vh - 400px)', minHeight: 300 }}>
{/* 书籍列表 */}
{visualData.books.length > 0 && (
<Paper withBorder>
<Group px="sm" py="xs" style={(theme) => ({ borderBottom: `1px solid ${colorScheme === 'dark' ? theme.colors.dark[4] : theme.colors.gray[3]}` })}>
<Paper withBorder style={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
<Group px="sm" py="xs" style={(theme) => ({ borderBottom: `1px solid ${colorScheme === 'dark' ? theme.colors.dark[4] : theme.colors.gray[3]}`, flexShrink: 0 })}>
{testMode === 'explore' ? <IconCompass size={16} /> : <IconWorld size={16} />}
<Text size="sm" fw={500}>
{testMode === 'explore' ? '发现结果' : '搜索结果'} ({visualData.books.length})
</Text>
<Text size="xs" c="dimmed" ml="auto"></Text>
</Group>
<ScrollArea.Autosize mah={240}>
<ScrollArea style={{ flex: 1 }}>
<Stack gap={0}>
{visualData.books.map((book, index) => (
<Box
@@ -540,7 +832,7 @@ export function DebugPanel() {
</Box>
))}
</Stack>
</ScrollArea.Autosize>
</ScrollArea>
</Paper>
)}
@@ -742,12 +1034,47 @@ export function DebugPanel() {
)}
</Tabs.Panel>
<Tabs.Panel value="raw" pt="sm">
<ScrollArea.Autosize mah={240}>
<Tabs.Panel value="raw" pt="sm" style={{ flex: 1, display: 'flex', flexDirection: 'column' }}>
<ScrollArea style={{ flex: 1 }}>
<Paper p="sm" bg={colorScheme === 'dark' ? 'dark.6' : 'gray.0'} style={{ fontFamily: 'monospace', fontSize: 12, whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}>
{testResult.rawResponse || '无响应内容'}
{(() => {
const rawContent = testResult.rawResponse || '无响应内容';
// 格式化处理
let displayContent = rawContent;
if (isRawFormatted && rawContent !== '无响应内容') {
try {
// 尝试 JSON 格式化
const parsed = JSON.parse(rawContent);
displayContent = JSON.stringify(parsed, null, 2);
} catch {
// 尝试 XML/HTML 格式化
if (rawContent.trim().startsWith('<')) {
displayContent = rawContent
.replace(/></g, '>\n<')
.replace(/>\s+</g, '>\n<');
}
}
}
// 搜索高亮处理
if (rawSearchKeyword.trim()) {
const keyword = rawSearchKeyword.trim();
const regex = new RegExp(`(${keyword.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi');
const parts = displayContent.split(regex);
return parts.map((part, index) =>
regex.test(part) ? (
<span key={index} style={{ backgroundColor: 'var(--mantine-color-yellow-4)', color: 'black', padding: '0 2px', borderRadius: 2 }}>
{part}
</span>
) : part
);
}
return displayContent;
})()}
</Paper>
</ScrollArea.Autosize>
</ScrollArea>
</Tabs.Panel>
</Tabs>
) : (
@@ -1044,6 +1371,17 @@ export function DebugPanel() {
</Box>
)}
</Modal>
{/* 登录对话框 */}
<SourceLoginDialog
opened={loginDialogOpen}
onClose={() => setLoginDialogOpen(false)}
source={currentSource}
onLoginSuccess={() => {
// 刷新登录状态
setLoginStatus(prev => ({ ...prev, isLoggedIn: true }));
}}
/>
</Box>
);
}

View File

@@ -0,0 +1,424 @@
import { useState, useEffect } from 'react';
import appIcon from '../../../assets/icon.png';
import {
Modal,
Stack,
Group,
Text,
Button,
Divider,
Box,
Loader,
Badge,
useMantineColorScheme,
ThemeIcon,
} from '@mantine/core';
import {
IconSettings,
IconRefresh,
IconDownload,
IconCheck,
IconBrandGithub,
IconHeart,
IconAlertCircle,
IconInfoCircle,
IconExternalLink,
} from '@tabler/icons-react';
interface SettingsModalProps {
opened: boolean;
onClose: () => void;
}
interface UpdateStatus {
checking: boolean;
available: boolean;
downloading: boolean;
downloaded: boolean;
error: string | null;
version: string | null;
progress: number;
checked: boolean; // 是否已检查过
}
export function SettingsModal({ opened, onClose }: SettingsModalProps) {
const { colorScheme } = useMantineColorScheme();
const [appVersion, setAppVersion] = useState<string>('');
const [updateStatus, setUpdateStatus] = useState<UpdateStatus>({
checking: false,
available: false,
downloading: false,
downloaded: false,
error: null,
version: null,
progress: 0,
checked: false,
});
// 获取应用版本
useEffect(() => {
if (opened) {
window.electron?.ipcRenderer?.invoke('app:getVersion').then((version: string) => {
setAppVersion(version || '0.0.0');
}).catch(() => {
setAppVersion('0.0.0');
});
}
}, [opened]);
// 监听更新状态
useEffect(() => {
const handleUpdateStatus = (...args: unknown[]) => {
const data = args[0] as { status: string; data?: any };
if (!data || typeof data.status !== 'string') return;
switch (data.status) {
case 'checking-for-update':
setUpdateStatus(prev => ({ ...prev, checking: true, error: null }));
break;
case 'update-available':
setUpdateStatus(prev => ({
...prev,
checking: false,
available: true,
checked: true,
version: data.data?.version,
}));
break;
case 'update-not-available':
setUpdateStatus(prev => ({
...prev,
checking: false,
available: false,
checked: true,
error: null,
}));
break;
case 'download-progress':
setUpdateStatus(prev => ({
...prev,
downloading: true,
progress: data.data?.percent || 0,
}));
break;
case 'update-downloaded':
setUpdateStatus(prev => ({
...prev,
downloading: false,
downloaded: true,
}));
break;
case 'error':
setUpdateStatus(prev => ({
...prev,
checking: false,
downloading: false,
checked: true,
error: typeof data.data === 'string' ? data.data : '检查更新失败',
}));
break;
}
};
const unsubscribe = window.electron?.ipcRenderer?.on('update-status', handleUpdateStatus);
return () => {
unsubscribe?.();
};
}, []);
// 检查更新
const handleCheckUpdate = async () => {
setUpdateStatus({
checking: true,
available: false,
downloading: false,
downloaded: false,
error: null,
version: null,
progress: 0,
checked: false,
});
try {
await window.electron?.ipcRenderer?.invoke('app:checkForUpdates');
} catch (error: any) {
setUpdateStatus(prev => ({
...prev,
checking: false,
error: error.message || '检查更新失败',
}));
}
};
// 自动更新(下载更新)
const handleAutoUpdate = async () => {
try {
await window.electron?.ipcRenderer?.invoke('app:downloadUpdate');
} catch (error: any) {
setUpdateStatus(prev => ({
...prev,
error: error.message || '下载更新失败',
}));
}
};
// 重启并安装
const handleQuitAndInstall = () => {
window.electron?.ipcRenderer?.invoke('app:quitAndInstall');
};
// 打开 GitHub
const openGitHub = () => {
window.electron?.ipcRenderer?.invoke('app:openExternal', 'https://github.com/Tthfyth/source');
};
// 打开 Release 页面
const openReleasePage = () => {
window.electron?.ipcRenderer?.invoke('app:openExternal', 'https://github.com/Tthfyth/source/releases');
};
return (
<Modal
opened={opened}
onClose={onClose}
title={
<Group gap="xs">
<ThemeIcon variant="light" size="md" radius="md">
<IconSettings size={18} />
</ThemeIcon>
<Text fw={600}></Text>
</Group>
}
size="sm"
radius="md"
centered
>
<Stack gap="lg">
{/* 应用信息 */}
<Box
p="md"
style={(theme) => ({
borderRadius: theme.radius.md,
background: colorScheme === 'dark'
? 'linear-gradient(135deg, rgba(34, 139, 230, 0.1) 0%, rgba(32, 201, 151, 0.1) 100%)'
: 'linear-gradient(135deg, rgba(34, 139, 230, 0.08) 0%, rgba(32, 201, 151, 0.08) 100%)',
border: `1px solid ${colorScheme === 'dark' ? theme.colors.dark[4] : theme.colors.gray[2]}`,
})}
>
<Stack gap="sm" align="center">
<Box
style={{
width: 64,
height: 64,
borderRadius: 16,
overflow: 'hidden',
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
}}
>
<img
src={appIcon}
alt="App Icon"
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
/>
</Box>
<Text size="lg" fw={700}></Text>
<Text size="sm" c="dimmed">Legado / </Text>
<Badge size="lg" variant="light" color="blue">
v{appVersion}
</Badge>
</Stack>
</Box>
<Divider />
{/* 更新状态 */}
<Stack gap="sm">
<Text size="sm" fw={500}></Text>
{/* 检查中 */}
{updateStatus.checking && (
<Box
p="sm"
style={(theme) => ({
borderRadius: theme.radius.sm,
backgroundColor: colorScheme === 'dark' ? theme.colors.dark[6] : theme.colors.gray[0],
})}
>
<Group gap="xs">
<Loader size="sm" />
<Text size="sm">...</Text>
</Group>
</Box>
)}
{/* 发现新版本 */}
{!updateStatus.checking && updateStatus.available && updateStatus.version && (
<Box
p="sm"
style={(theme) => ({
borderRadius: theme.radius.sm,
backgroundColor: colorScheme === 'dark' ? 'rgba(32, 201, 151, 0.1)' : 'rgba(32, 201, 151, 0.1)',
border: `1px solid ${theme.colors.teal[5]}`,
})}
>
<Stack gap="xs">
<Group gap="xs">
<IconDownload size={18} color="var(--mantine-color-teal-6)" />
<Text size="sm" fw={500} c="teal"> v{updateStatus.version}</Text>
</Group>
<Text size="xs" c="dimmed"></Text>
<Group gap="xs" mt="xs">
<Button
size="xs"
variant="filled"
color="teal"
leftSection={updateStatus.downloading ? <Loader size={14} color="white" /> : <IconDownload size={14} />}
onClick={handleAutoUpdate}
disabled={updateStatus.downloading}
style={{ flex: 1 }}
>
{updateStatus.downloading ? `下载中 ${updateStatus.progress.toFixed(0)}%` : '自动更新'}
</Button>
<Button
size="xs"
variant="light"
leftSection={<IconExternalLink size={14} />}
onClick={openReleasePage}
style={{ flex: 1 }}
>
</Button>
</Group>
</Stack>
</Box>
)}
{/* 已是最新版本 */}
{!updateStatus.checking && updateStatus.checked && !updateStatus.available && !updateStatus.error && !updateStatus.downloaded && (
<Box
p="sm"
style={(theme) => ({
borderRadius: theme.radius.sm,
backgroundColor: colorScheme === 'dark' ? theme.colors.dark[6] : theme.colors.gray[0],
})}
>
<Group gap="xs">
<IconCheck size={18} color="var(--mantine-color-green-6)" />
<Text size="sm" c="green"></Text>
</Group>
</Box>
)}
{/* 检查失败 */}
{!updateStatus.checking && updateStatus.error && (
<Box
p="sm"
style={(theme) => ({
borderRadius: theme.radius.sm,
backgroundColor: colorScheme === 'dark' ? 'rgba(250, 82, 82, 0.1)' : 'rgba(250, 82, 82, 0.1)',
border: `1px solid ${theme.colors.red[5]}`,
})}
>
<Stack gap="xs">
<Group gap="xs">
<IconAlertCircle size={18} color="var(--mantine-color-red-6)" />
<Text size="sm" c="red"></Text>
</Group>
<Text size="xs" c="dimmed">{updateStatus.error}</Text>
<Button
size="xs"
variant="light"
color="red"
leftSection={<IconExternalLink size={14} />}
onClick={openReleasePage}
mt="xs"
>
GitHub
</Button>
</Stack>
</Box>
)}
{/* 更新已下载 */}
{updateStatus.downloaded && (
<Box
p="sm"
style={(theme) => ({
borderRadius: theme.radius.sm,
backgroundColor: colorScheme === 'dark' ? 'rgba(32, 201, 151, 0.1)' : 'rgba(32, 201, 151, 0.1)',
border: `1px solid ${theme.colors.teal[5]}`,
})}
>
<Stack gap="xs">
<Group gap="xs">
<IconCheck size={18} color="var(--mantine-color-teal-6)" />
<Text size="sm" fw={500} c="teal"></Text>
</Group>
<Text size="xs" c="dimmed"></Text>
<Button
size="xs"
variant="filled"
color="teal"
onClick={handleQuitAndInstall}
mt="xs"
>
</Button>
</Stack>
</Box>
)}
{/* 未检查状态 */}
{!updateStatus.checking && !updateStatus.checked && !updateStatus.downloaded && (
<Box
p="sm"
style={(theme) => ({
borderRadius: theme.radius.sm,
backgroundColor: colorScheme === 'dark' ? theme.colors.dark[6] : theme.colors.gray[0],
})}
>
<Group gap="xs">
<IconInfoCircle size={18} color="var(--mantine-color-dimmed)" />
<Text size="sm" c="dimmed"></Text>
</Group>
</Box>
)}
</Stack>
{/* 操作按钮 */}
<Stack gap="xs">
<Button
variant="light"
leftSection={updateStatus.checking ? <Loader size={16} /> : <IconRefresh size={16} />}
onClick={handleCheckUpdate}
disabled={updateStatus.checking || updateStatus.downloading}
fullWidth
>
{updateStatus.checking ? '检查中...' : '检查更新'}
</Button>
<Button
variant="subtle"
leftSection={<IconBrandGithub size={16} />}
onClick={openGitHub}
fullWidth
color="gray"
>
GitHub
</Button>
</Stack>
<Divider />
{/* 底部信息 */}
<Group justify="center" gap={4}>
<Text size="xs" c="dimmed">Made with</Text>
<IconHeart size={12} color="var(--mantine-color-red-5)" />
<Text size="xs" c="dimmed">by Tthfyth</Text>
</Group>
</Stack>
</Modal>
);
}
export default SettingsModal;

View File

@@ -0,0 +1,330 @@
/**
* 书源登录对话框
* 参考 Legado SourceLoginDialog.kt 实现
*/
import { useState, useEffect, useCallback } from 'react';
import {
Modal,
Stack,
TextInput,
PasswordInput,
Button,
Group,
Text,
Alert,
Loader,
ActionIcon,
Tooltip,
Divider,
ScrollArea,
SimpleGrid,
Box,
} from '@mantine/core';
import {
IconLogin,
IconLogout,
IconAlertCircle,
IconCheck,
IconTrash,
IconExternalLink,
} from '@tabler/icons-react';
// 登录 UI 配置项类型
interface LoginUiItem {
name: string;
type: 'text' | 'password' | 'button';
action?: string;
}
// 组件属性
interface SourceLoginDialogProps {
opened: boolean;
onClose: () => void;
source: any; // BookSource
onLoginSuccess?: () => void;
}
export function SourceLoginDialog({
opened,
onClose,
source,
onLoginSuccess,
}: SourceLoginDialogProps) {
// 登录 UI 配置
const [loginUiItems, setLoginUiItems] = useState<LoginUiItem[]>([]);
// 表单数据
const [formData, setFormData] = useState<Record<string, string>>({});
// 加载状态
const [isLoading, setIsLoading] = useState(false);
const [isLoadingUi, setIsLoadingUi] = useState(false);
// 错误/成功消息
const [message, setMessage] = useState<{ type: 'error' | 'success'; text: string } | null>(null);
// 登录状态
const [isLoggedIn, setIsLoggedIn] = useState(false);
// 加载登录 UI 配置和已保存的登录信息
useEffect(() => {
if (!opened || !source) return;
const loadLoginUi = async () => {
setIsLoadingUi(true);
setMessage(null);
try {
// 解析 loginUi
if (source.loginUi) {
const result = await window.debugApi?.parseLoginUi(source.loginUi);
if (result?.success && result.items) {
setLoginUiItems(result.items as LoginUiItem[]);
}
} else {
// 如果没有 loginUi创建默认的用户名密码表单
setLoginUiItems([
{ name: '用户名', type: 'text' },
{ name: '密码', type: 'password' },
]);
}
// 检查登录状态并加载已保存的登录信息
const statusResult = await window.debugApi?.checkLoginStatus(source);
if (statusResult?.success) {
setIsLoggedIn(statusResult.isLoggedIn ?? false);
if (statusResult.loginInfo) {
setFormData(statusResult.loginInfo);
}
}
} catch (error) {
console.error('Failed to load login UI:', error);
setMessage({ type: 'error', text: '加载登录配置失败' });
} finally {
setIsLoadingUi(false);
}
};
loadLoginUi();
}, [opened, source]);
// 更新表单数据
const updateFormField = useCallback((name: string, value: string) => {
setFormData(prev => ({ ...prev, [name]: value }));
}, []);
// 执行登录
const handleLogin = async () => {
if (!source) return;
setIsLoading(true);
setMessage(null);
try {
const result = await window.debugApi?.executeLogin(source, formData);
if (result?.success) {
setMessage({ type: 'success', text: result.message || '登录成功' });
setIsLoggedIn(true);
onLoginSuccess?.();
// 延迟关闭
setTimeout(() => {
onClose();
}, 1000);
} else {
setMessage({ type: 'error', text: result?.message || '登录失败' });
}
} catch (error: any) {
setMessage({ type: 'error', text: error.message || '登录失败' });
} finally {
setIsLoading(false);
}
};
// 执行按钮动作
const handleButtonAction = async (action?: string) => {
if (!action || !source) return;
// 如果是 URL在浏览器中打开
if (action.startsWith('http://') || action.startsWith('https://')) {
window.open(action, '_blank');
return;
}
// 执行 JS
setIsLoading(true);
setMessage(null);
try {
const result = await window.debugApi?.executeButtonAction(source, action, formData);
if (result?.success) {
if (result.result?.type === 'url') {
window.open(result.result.url, '_blank');
} else {
setMessage({ type: 'success', text: '操作成功' });
}
} else {
setMessage({ type: 'error', text: result?.message || '操作失败' });
}
} catch (error: any) {
setMessage({ type: 'error', text: error.message || '操作失败' });
} finally {
setIsLoading(false);
}
};
// 退出登录
const handleLogout = async () => {
if (!source) return;
setIsLoading(true);
try {
await window.debugApi?.removeLoginInfo(source.bookSourceUrl);
await window.debugApi?.removeLoginHeader(source.bookSourceUrl);
setIsLoggedIn(false);
setFormData({});
setMessage({ type: 'success', text: '已退出登录' });
} catch (error: any) {
setMessage({ type: 'error', text: error.message || '退出失败' });
} finally {
setIsLoading(false);
}
};
// 分离输入框和按钮
const inputItems = loginUiItems.filter(item => item.type === 'text' || item.type === 'password');
const buttonItems = loginUiItems.filter(item => item.type === 'button');
return (
<Modal
opened={opened}
onClose={onClose}
title={
<Group gap="xs">
<IconLogin size={20} />
<Text fw={500}> - {source?.bookSourceName || '书源'}</Text>
</Group>
}
size="lg"
>
{isLoadingUi ? (
<Stack align="center" py="xl">
<Loader size="md" />
<Text size="sm" c="dimmed">...</Text>
</Stack>
) : (
<Stack gap="md">
{/* 登录状态提示 */}
{isLoggedIn && (
<Alert icon={<IconCheck size={16} />} color="green" variant="light">
</Alert>
)}
{/* 消息提示 */}
{message && (
<Alert
icon={message.type === 'error' ? <IconAlertCircle size={16} /> : <IconCheck size={16} />}
color={message.type === 'error' ? 'red' : 'green'}
variant="light"
withCloseButton
onClose={() => setMessage(null)}
>
{message.text}
</Alert>
)}
<ScrollArea.Autosize mah={400}>
<Stack gap="md">
{/* 输入框 */}
{inputItems.map((item, index) => {
if (item.type === 'text') {
return (
<TextInput
key={`input-${index}`}
label={item.name}
value={formData[item.name] || ''}
onChange={(e) => updateFormField(item.name, e.currentTarget.value)}
disabled={isLoading}
/>
);
}
if (item.type === 'password') {
return (
<PasswordInput
key={`input-${index}`}
label={item.name}
value={formData[item.name] || ''}
onChange={(e) => updateFormField(item.name, e.currentTarget.value)}
disabled={isLoading}
/>
);
}
return null;
})}
{/* 按钮网格 */}
{buttonItems.length > 0 && (
<Box>
<Text size="sm" c="dimmed" mb="xs"> ({buttonItems.length})</Text>
<SimpleGrid cols={2} spacing="xs">
{buttonItems.map((item, index) => (
<Button
key={`btn-${index}`}
variant="light"
size="xs"
onClick={() => handleButtonAction(item.action)}
disabled={isLoading}
leftSection={item.action?.startsWith('http') ? <IconExternalLink size={14} /> : undefined}
>
{item.name}
</Button>
))}
</SimpleGrid>
</Box>
)}
</Stack>
</ScrollArea.Autosize>
<Divider />
{/* 操作按钮 */}
<Group justify="space-between">
<Group gap="xs">
{isLoggedIn && (
<Tooltip label="退出登录">
<ActionIcon
variant="light"
color="red"
onClick={handleLogout}
disabled={isLoading}
>
<IconLogout size={18} />
</ActionIcon>
</Tooltip>
)}
<Tooltip label="清除表单">
<ActionIcon
variant="light"
onClick={() => setFormData({})}
disabled={isLoading}
>
<IconTrash size={18} />
</ActionIcon>
</Tooltip>
</Group>
<Group gap="xs">
<Button variant="default" onClick={onClose} disabled={isLoading}>
</Button>
<Button
onClick={handleLogin}
loading={isLoading}
leftSection={<IconLogin size={16} />}
>
</Button>
</Group>
</Group>
</Stack>
)}
</Modal>
);
}
export default SourceLoginDialog;

View File

@@ -30,6 +30,7 @@ import { useBookSourceStore } from '../stores/bookSourceStore';
import { SourceFormat, detectSourceFormat, getSourceFormatLabel } from '../types';
import { useAppTour } from './AppTour';
import { convertSource } from '../utils/sourceConverter';
import { SettingsModal } from './SettingsModal';
interface TopToolbarProps {
isLeftCollapsed: boolean;
@@ -66,6 +67,7 @@ export function TopToolbar({
const { colorScheme, toggleColorScheme } = useMantineColorScheme();
const { resetTour } = useAppTour();
const [settingsOpen, setSettingsOpen] = useState(false);
const activeSource = sources.find(
(s) => s.bookSourceUrl === activeSourceId
@@ -414,11 +416,14 @@ export function TopToolbar({
</Tooltip>
<Tooltip label="设置" position="bottom">
<ActionIcon variant="subtle" size="lg">
<ActionIcon variant="subtle" size="lg" onClick={() => setSettingsOpen(true)}>
<IconSettings size={18} />
</ActionIcon>
</Tooltip>
</Group>
{/* 设置模态框 */}
<SettingsModal opened={settingsOpen} onClose={() => setSettingsOpen(false)} />
</Group>
);
}

View File

@@ -251,6 +251,13 @@ export const useBookSourceStore = create<BookSourceState>()(
activeSourceId: url,
sourceCode: source ? JSON.stringify(source, null, 2) : '',
isModified: false,
// 切换书源时重置测试器状态
testMode: 'search',
testInput: '',
testResult: null,
debugLogs: [],
chapterList: [],
currentChapterIndex: -1,
});
},
@@ -713,9 +720,50 @@ declare global {
debugApi?: {
search: (source: AnySource, keyword: string) => Promise<unknown>;
explore: (source: AnySource, exploreUrl: string) => Promise<unknown>;
parseExploreCategories: (source: AnySource) => Promise<{
success: boolean;
categories?: Array<{ title: string; url: string; group: string; style?: any }>;
error?: string;
}>;
bookInfo: (source: AnySource, bookUrl: string) => Promise<unknown>;
toc: (source: AnySource, tocUrl: string) => Promise<unknown>;
content: (source: AnySource, contentUrl: string) => Promise<unknown>;
// 登录相关
parseLoginUi: (loginUi: string) => Promise<{
success: boolean;
items?: Array<{ name: string; type: string; action?: string }>;
error?: string;
}>;
checkLoginStatus: (source: AnySource) => Promise<{
success: boolean;
hasLoginUrl?: boolean;
hasLoginUi?: boolean;
isLoggedIn?: boolean;
loginInfo?: Record<string, string>;
error?: string;
}>;
executeLogin: (source: AnySource, loginData: Record<string, string>) => Promise<{
success: boolean;
message?: string;
loginHeader?: Record<string, string>;
}>;
executeButtonAction: (source: AnySource, action: string, loginData: Record<string, string>) => Promise<{
success: boolean;
message?: string;
result?: any;
}>;
getLoginInfo: (sourceKey: string) => Promise<{
success: boolean;
info?: Record<string, string>;
error?: string;
}>;
removeLoginInfo: (sourceKey: string) => Promise<{ success: boolean; error?: string }>;
getLoginHeader: (sourceKey: string) => Promise<{
success: boolean;
header?: Record<string, string>;
error?: string;
}>;
removeLoginHeader: (sourceKey: string) => Promise<{ success: boolean; error?: string }>;
};
}
}