diff --git a/.gitignore b/.gitignore
index da13430..09f56ce 100644
--- a/.gitignore
+++ b/.gitignore
@@ -27,3 +27,4 @@ npm-debug.log.*
*.css.d.ts
*.sass.d.ts
*.scss.d.ts
+testSource/
\ No newline at end of file
diff --git a/package.json b/package.json
index 8faf7d8..b0396cb 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/release/app/package.json b/release/app/package.json
index 834677b..3050b32 100644
--- a/release/app/package.json
+++ b/release/app/package.json
@@ -1,6 +1,6 @@
{
"name": "source-debug",
- "version": "0.1.5",
+ "version": "0.1.6",
"description": "书源调试器 - Legado/异次元书源调试工具",
"license": "MIT",
"author": {
diff --git a/src/main/debug/analyze-url.ts b/src/main/debug/analyze-url.ts
index a55c571..b40315b 100644
--- a/src/main/debug/analyze-url.ts
+++ b/src/main/debug/analyze-url.ts
@@ -384,17 +384,11 @@ export class AnalyzeUrl {
);
// 3. 转换 E4X 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 = ... 但不转换 var x = '...'
+ // 暂时禁用此转换,因为大多数书源不使用 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:... 规则
+ */
+ 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): 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);
},
diff --git a/src/main/debug/index.ts b/src/main/debug/index.ts
index b125b28..02f2c15 100644
--- a/src/main/debug/index.ts
+++ b/src/main/debug/index.ts
@@ -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';
diff --git a/src/main/debug/source-debugger.ts b/src/main/debug/source-debugger.ts
index 9a83233..a53cdd1 100644
--- a/src/main/debug/source-debugger.ts
+++ b/src/main/debug/source-debugger.ts
@@ -1684,4 +1684,442 @@ export class SourceDebugger {
}
}
+/**
+ * 解析发现分类列表
+ * 支持 Legado 的多种格式:
+ * 1. 文本格式:分类名::URL(用 && 或换行分隔)
+ * 2. JSON 格式:[{"title":"分类","url":"..."}]
+ * 3. JS 动态格式:... 或 @js:...
+ * 4. 分组:没有 URL 的项作为分组标题
+ */
+export interface ExploreCategory {
+ title: string;
+ url: string;
+ group: string;
+ style?: any;
+}
+
+export async function parseExploreUrl(
+ source: BookSource,
+ variables?: Record
+): Promise {
+ const exploreUrl = source.exploreUrl || (source as any).ruleFindUrl || '';
+ if (!exploreUrl) return [];
+
+ let ruleStr = exploreUrl;
+
+ // 处理 JS 动态规则
+ if (exploreUrl.trim().startsWith('') || 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('')) {
+ // ... 格式:取 到最后一个 < 之间的内容
+ 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;
+}
+
+// 登录信息存储(内存 + 持久化)
+const loginInfoStore = new Map>();
+const loginHeaderStore = new Map>();
+
+/**
+ * 解析 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('')) {
+ const endIndex = loginUrl.lastIndexOf('<');
+ return loginUrl.substring(4, endIndex > 4 ? endIndex : loginUrl.length);
+ }
+ return loginUrl;
+}
+
+/**
+ * 获取登录信息
+ */
+export function getLoginInfo(sourceKey: string): Record | 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): 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 | 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): 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
+): Promise {
+ 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
+): 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 | 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;
diff --git a/src/main/main.ts b/src/main/main.ts
index 600be0b..d71e763 100644
--- a/src/main/main.ts
+++ b/src/main/main.ts
@@ -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 {
@@ -26,6 +40,11 @@ class AppUpdater {
// 配置自动更新
autoUpdater.autoDownload = false;
autoUpdater.autoInstallOnAppQuit = true;
+
+ // 开发模式下也检查更新(需要 dev-app-update.yml)
+ if (!app.isPackaged) {
+ autoUpdater.forceDevUpdateConfig = true;
+ }
// 检查更新事件
autoUpdater.on('checking-for-update', () => {
@@ -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) => {
+ 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) => {
+ 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', () => {
- return 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);
});
// ============================================
diff --git a/src/main/preload.ts b/src/main/preload.ts
index 30f8867..5fd8a6d 100644
--- a/src/main/preload.ts
+++ b/src/main/preload.ts
@@ -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) => {
+ return ipcRenderer.invoke('debug:executeLogin', source, loginData);
+ },
+
+ /**
+ * 执行按钮动作
+ */
+ executeButtonAction: (source: any, action: string, loginData: Record) => {
+ 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);
diff --git a/src/renderer/components/DebugPanel.tsx b/src/renderer/components/DebugPanel.tsx
index 66986c1..69f3fe8 100644
--- a/src/renderer/components/DebugPanel.tsx
+++ b/src/renderer/components/DebugPanel.tsx
@@ -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('') ||
+ 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();
+ 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();
+ 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() {
>
规则测试器
+ {/* 登录按钮 - 仅当书源配置了 loginUrl 时显示 */}
+ {loginStatus.hasLoginUrl && (
+
+ setLoginDialogOpen(true)}
+ >
+ {loginStatus.isLoggedIn ? : }
+
+
+ )}
@@ -311,20 +540,38 @@ export function DebugPanel() {
{/* URL/关键词输入 */}
- setTestInput(e.currentTarget.value)}
- onKeyDown={(e) => e.key === 'Enter' && handleTest()}
- style={{ flex: 1 }}
- rightSection={
- testHistory.length > 0 && (
- setShowHistory(!showHistory)}>
-
-
- )
- }
- />
+ {testMode === 'explore' && (exploreCategoryCount > 0 || isLoadingCategories) ? (
+ // 发现模式:显示下拉框选择分类(支持分组)
+
+ {/* 发现模式下显示分类数量提示 */}
+ {testMode === 'explore' && (isLoadingCategories || exploreCategoryCount > 0) && (
+
+ {isLoadingCategories
+ ? '正在解析 JS 动态发现规则...'
+ : `已配置 ${exploreCategoryCount} 个发现分类${
+ exploreCategories.length > 0 && 'group' in exploreCategories[0]
+ ? `(${exploreCategories.length} 个分组)`
+ : ''
+ }`
+ }
+
+ )}
+
{/* 历史记录下拉 */}
0}>
@@ -490,24 +751,55 @@ export function DebugPanel() {
) : testResult ? (
-
- 可视化
- 解析结果
- 原始响应
-
+
+
+ 可视化
+ 解析结果
+ 原始响应
+
+ {/* 原始响应搜索和格式化按钮 - 只在选中原始响应 Tab 时显示 */}
+ {activeResultTab === 'raw' && (
+
+ setRawSearchKeyword(e.currentTarget.value)}
+ leftSection={}
+ style={{ flex: 1, minWidth: 300, maxWidth: 500 }}
+ rightSection={
+ rawSearchKeyword && (
+ setRawSearchKeyword('')}>
+
+
+ )
+ }
+ />
+
+ setIsRawFormatted(!isRawFormatted)}
+ >
+
+
+
+
+ )}
+
-
+
{/* 书籍列表 */}
{visualData.books.length > 0 && (
-
- ({ borderBottom: `1px solid ${colorScheme === 'dark' ? theme.colors.dark[4] : theme.colors.gray[3]}` })}>
+
+ ({ borderBottom: `1px solid ${colorScheme === 'dark' ? theme.colors.dark[4] : theme.colors.gray[3]}`, flexShrink: 0 })}>
{testMode === 'explore' ? : }
{testMode === 'explore' ? '发现结果' : '搜索结果'} ({visualData.books.length}本)
点击查看详情
-
+
{visualData.books.map((book, index) => (
))}
-
+
)}
@@ -742,12 +1034,47 @@ export function DebugPanel() {
)}
-
-
+
+
- {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(/>\n<')
+ .replace(/>\s+\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) ? (
+
+ {part}
+
+ ) : part
+ );
+ }
+
+ return displayContent;
+ })()}
-
+
) : (
@@ -1044,6 +1371,17 @@ export function DebugPanel() {
)}
+
+ {/* 登录对话框 */}
+ setLoginDialogOpen(false)}
+ source={currentSource}
+ onLoginSuccess={() => {
+ // 刷新登录状态
+ setLoginStatus(prev => ({ ...prev, isLoggedIn: true }));
+ }}
+ />
);
}
diff --git a/src/renderer/components/SettingsModal.tsx b/src/renderer/components/SettingsModal.tsx
new file mode 100644
index 0000000..28289c5
--- /dev/null
+++ b/src/renderer/components/SettingsModal.tsx
@@ -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('');
+ const [updateStatus, setUpdateStatus] = useState({
+ 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 (
+
+
+
+
+ 设置
+
+ }
+ size="sm"
+ radius="md"
+ centered
+ >
+
+ {/* 应用信息 */}
+ ({
+ 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]}`,
+ })}
+ >
+
+
+
+
+ 书源调试器
+ Legado / 异次元 书源调试工具
+
+ v{appVersion}
+
+
+
+
+
+
+ {/* 更新状态 */}
+
+ 版本更新
+
+ {/* 检查中 */}
+ {updateStatus.checking && (
+ ({
+ borderRadius: theme.radius.sm,
+ backgroundColor: colorScheme === 'dark' ? theme.colors.dark[6] : theme.colors.gray[0],
+ })}
+ >
+
+
+ 正在检查更新...
+
+
+ )}
+
+ {/* 发现新版本 */}
+ {!updateStatus.checking && updateStatus.available && updateStatus.version && (
+ ({
+ 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]}`,
+ })}
+ >
+
+
+
+ 发现新版本 v{updateStatus.version}
+
+ 可以选择自动更新或手动下载安装
+
+ : }
+ onClick={handleAutoUpdate}
+ disabled={updateStatus.downloading}
+ style={{ flex: 1 }}
+ >
+ {updateStatus.downloading ? `下载中 ${updateStatus.progress.toFixed(0)}%` : '自动更新'}
+
+ }
+ onClick={openReleasePage}
+ style={{ flex: 1 }}
+ >
+ 手动下载
+
+
+
+
+ )}
+
+ {/* 已是最新版本 */}
+ {!updateStatus.checking && updateStatus.checked && !updateStatus.available && !updateStatus.error && !updateStatus.downloaded && (
+ ({
+ borderRadius: theme.radius.sm,
+ backgroundColor: colorScheme === 'dark' ? theme.colors.dark[6] : theme.colors.gray[0],
+ })}
+ >
+
+
+ 当前已是最新版本
+
+
+ )}
+
+ {/* 检查失败 */}
+ {!updateStatus.checking && updateStatus.error && (
+ ({
+ 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]}`,
+ })}
+ >
+
+
+
+ 检查更新失败
+
+ {updateStatus.error}
+ }
+ onClick={openReleasePage}
+ mt="xs"
+ >
+ 前往 GitHub 查看最新版本
+
+
+
+ )}
+
+ {/* 更新已下载 */}
+ {updateStatus.downloaded && (
+ ({
+ 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]}`,
+ })}
+ >
+
+
+
+ 更新已下载完成
+
+ 重启应用后将自动安装更新
+
+
+
+ )}
+
+ {/* 未检查状态 */}
+ {!updateStatus.checking && !updateStatus.checked && !updateStatus.downloaded && (
+ ({
+ borderRadius: theme.radius.sm,
+ backgroundColor: colorScheme === 'dark' ? theme.colors.dark[6] : theme.colors.gray[0],
+ })}
+ >
+
+
+ 点击下方按钮检查更新
+
+
+ )}
+
+
+ {/* 操作按钮 */}
+
+ : }
+ onClick={handleCheckUpdate}
+ disabled={updateStatus.checking || updateStatus.downloading}
+ fullWidth
+ >
+ {updateStatus.checking ? '检查中...' : '检查更新'}
+
+
+ }
+ onClick={openGitHub}
+ fullWidth
+ color="gray"
+ >
+ GitHub 仓库
+
+
+
+
+
+ {/* 底部信息 */}
+
+ Made with
+
+ by Tthfyth
+
+
+
+ );
+}
+
+export default SettingsModal;
diff --git a/src/renderer/components/SourceLoginDialog.tsx b/src/renderer/components/SourceLoginDialog.tsx
new file mode 100644
index 0000000..ab7a9ca
--- /dev/null
+++ b/src/renderer/components/SourceLoginDialog.tsx
@@ -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([]);
+ // 表单数据
+ const [formData, setFormData] = useState>({});
+ // 加载状态
+ 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 (
+
+
+ 登录 - {source?.bookSourceName || '书源'}
+
+ }
+ size="lg"
+ >
+ {isLoadingUi ? (
+
+
+ 加载登录配置...
+
+ ) : (
+
+ {/* 登录状态提示 */}
+ {isLoggedIn && (
+ } color="green" variant="light">
+ 已登录
+
+ )}
+
+ {/* 消息提示 */}
+ {message && (
+ : }
+ color={message.type === 'error' ? 'red' : 'green'}
+ variant="light"
+ withCloseButton
+ onClose={() => setMessage(null)}
+ >
+ {message.text}
+
+ )}
+
+
+
+ {/* 输入框 */}
+ {inputItems.map((item, index) => {
+ if (item.type === 'text') {
+ return (
+ updateFormField(item.name, e.currentTarget.value)}
+ disabled={isLoading}
+ />
+ );
+ }
+ if (item.type === 'password') {
+ return (
+ updateFormField(item.name, e.currentTarget.value)}
+ disabled={isLoading}
+ />
+ );
+ }
+ return null;
+ })}
+
+ {/* 按钮网格 */}
+ {buttonItems.length > 0 && (
+
+ 快捷操作 ({buttonItems.length})
+
+ {buttonItems.map((item, index) => (
+
+ ))}
+
+
+ )}
+
+
+
+
+
+ {/* 操作按钮 */}
+
+
+ {isLoggedIn && (
+
+
+
+
+
+ )}
+
+ setFormData({})}
+ disabled={isLoading}
+ >
+
+
+
+
+
+
+ }
+ >
+ 登录
+
+
+
+
+ )}
+
+ );
+}
+
+export default SourceLoginDialog;
diff --git a/src/renderer/components/TopToolbar.tsx b/src/renderer/components/TopToolbar.tsx
index a3c6cdd..622d072 100644
--- a/src/renderer/components/TopToolbar.tsx
+++ b/src/renderer/components/TopToolbar.tsx
@@ -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({
-
+ setSettingsOpen(true)}>
+
+ {/* 设置模态框 */}
+ setSettingsOpen(false)} />
);
}
diff --git a/src/renderer/stores/bookSourceStore.ts b/src/renderer/stores/bookSourceStore.ts
index 245b414..b48c484 100644
--- a/src/renderer/stores/bookSourceStore.ts
+++ b/src/renderer/stores/bookSourceStore.ts
@@ -251,6 +251,13 @@ export const useBookSourceStore = create()(
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;
explore: (source: AnySource, exploreUrl: string) => Promise;
+ parseExploreCategories: (source: AnySource) => Promise<{
+ success: boolean;
+ categories?: Array<{ title: string; url: string; group: string; style?: any }>;
+ error?: string;
+ }>;
bookInfo: (source: AnySource, bookUrl: string) => Promise;
toc: (source: AnySource, tocUrl: string) => Promise;
content: (source: AnySource, contentUrl: string) => Promise;
+ // 登录相关
+ 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;
+ error?: string;
+ }>;
+ executeLogin: (source: AnySource, loginData: Record) => Promise<{
+ success: boolean;
+ message?: string;
+ loginHeader?: Record;
+ }>;
+ executeButtonAction: (source: AnySource, action: string, loginData: Record) => Promise<{
+ success: boolean;
+ message?: string;
+ result?: any;
+ }>;
+ getLoginInfo: (sourceKey: string) => Promise<{
+ success: boolean;
+ info?: Record;
+ error?: string;
+ }>;
+ removeLoginInfo: (sourceKey: string) => Promise<{ success: boolean; error?: string }>;
+ getLoginHeader: (sourceKey: string) => Promise<{
+ success: boolean;
+ header?: Record;
+ error?: string;
+ }>;
+ removeLoginHeader: (sourceKey: string) => Promise<{ success: boolean; error?: string }>;
};
}
}