mirror of
https://github.com/Tthfyth/source.git
synced 2026-03-15 13:53:18 +08:00
hot:优化分类
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -27,3 +27,4 @@ npm-debug.log.*
|
||||
*.css.d.ts
|
||||
*.sass.d.ts
|
||||
*.scss.d.ts
|
||||
testSource/
|
||||
@@ -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",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "source-debug",
|
||||
"version": "0.1.5",
|
||||
"version": "0.1.6",
|
||||
"description": "书源调试器 - Legado/异次元书源调试工具",
|
||||
"license": "MIT",
|
||||
"author": {
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
|
||||
187
src/main/main.ts
187
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 {
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
// ============================================
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
424
src/renderer/components/SettingsModal.tsx
Normal file
424
src/renderer/components/SettingsModal.tsx
Normal 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;
|
||||
330
src/renderer/components/SourceLoginDialog.tsx
Normal file
330
src/renderer/components/SourceLoginDialog.tsx
Normal 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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 }>;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user