@@ -0,0 +1 @@
|
||||
node_modules
|
||||
@@ -0,0 +1,670 @@
|
||||
// build-scripts/build.js
|
||||
const { minify } = require('terser');
|
||||
const fs = require('fs-extra');
|
||||
const path = require('path');
|
||||
|
||||
// [修复] 设置控制台输出编码为 UTF-8,解决乱码问题
|
||||
if (process.platform === 'win32') {
|
||||
try {
|
||||
// Windows 下设置控制台编码
|
||||
process.stdout.setDefaultEncoding('utf8');
|
||||
process.stderr.setDefaultEncoding('utf8');
|
||||
// 尝试设置控制台代码页为 UTF-8
|
||||
const { execSync } = require('child_process');
|
||||
try {
|
||||
execSync('chcp 65001 >nul 2>&1', { encoding: 'utf8' });
|
||||
} catch (e) {
|
||||
// 忽略编码设置错误
|
||||
}
|
||||
} catch (e) {
|
||||
// 忽略编码设置错误
|
||||
}
|
||||
}
|
||||
|
||||
// ====================================================================================
|
||||
// --- 🚀 核心配置区 ---
|
||||
// ====================================================================================
|
||||
|
||||
const rootDir = path.join(__dirname, '..');
|
||||
const outputDir = path.join(rootDir, 'app_dist');
|
||||
|
||||
// ====================================================================================
|
||||
// --- 🔍 自动化识别需要保留的内容 ---
|
||||
// ====================================================================================
|
||||
|
||||
/**
|
||||
* [自动化] 从 preload.js 和 main.js 中提取所有 IPC 通信频道名称
|
||||
* @returns {Promise<Set<string>>} - 返回所有频道名称的集合
|
||||
*/
|
||||
async function extractIpcChannels() {
|
||||
const channels = new Set();
|
||||
|
||||
try {
|
||||
// 从 preload.js 提取
|
||||
const preloadPath = path.join(rootDir, 'preload.js');
|
||||
if (await fs.pathExists(preloadPath)) {
|
||||
const preloadContent = await fs.readFile(preloadPath, 'utf8');
|
||||
// 匹配 ipcRenderer.invoke('channel'), ipcRenderer.send('channel'), ipcRenderer.on('channel')
|
||||
const preloadRegex = /ipcRenderer\.(?:invoke|send|on)\(['"]([^'"]+)['"]/g;
|
||||
let match;
|
||||
while ((match = preloadRegex.exec(preloadContent)) !== null) {
|
||||
channels.add(match[1]);
|
||||
}
|
||||
}
|
||||
|
||||
// 从 main.js 提取
|
||||
const mainPath = path.join(rootDir, 'main.js');
|
||||
if (await fs.pathExists(mainPath)) {
|
||||
const mainContent = await fs.readFile(mainPath, 'utf8');
|
||||
// 匹配 ipcMain.handle('channel'), ipcMain.on('channel')
|
||||
const mainRegex = /ipcMain\.(?:handle|on)\(['"]([^'"]+)['"]/g;
|
||||
let match;
|
||||
while ((match = mainRegex.exec(mainContent)) !== null) {
|
||||
channels.add(match[1]);
|
||||
}
|
||||
}
|
||||
|
||||
return channels;
|
||||
} catch (error) {
|
||||
try {
|
||||
process.stderr.write(Buffer.from(`❌ 提取 IPC 频道失败: ${error.message || error}\n`, 'utf8'));
|
||||
} catch (e) {
|
||||
console.error('❌ 提取 IPC 频道失败:', error);
|
||||
}
|
||||
return channels;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* [自动化] 从 contextBridge.exposeInMainWorld 中提取所有暴露的 API 名称
|
||||
* @returns {Promise<Set<string>>} - 返回所有 API 名称的集合
|
||||
*/
|
||||
async function extractExposedApis() {
|
||||
const apis = new Set();
|
||||
|
||||
try {
|
||||
const preloadPath = path.join(rootDir, 'preload.js');
|
||||
if (await fs.pathExists(preloadPath)) {
|
||||
const content = await fs.readFile(preloadPath, 'utf8');
|
||||
// 匹配 contextBridge.exposeInMainWorld('electronAPI', { ... })
|
||||
const exposeRegex = /contextBridge\.exposeInMainWorld\(['"]([^'"]+)['"]/g;
|
||||
let match;
|
||||
while ((match = exposeRegex.exec(content)) !== null) {
|
||||
apis.add(match[1]);
|
||||
}
|
||||
|
||||
// 提取对象中的所有方法名
|
||||
const objectRegex = /contextBridge\.exposeInMainWorld\([^,]+,\s*\{([^}]+)\}/s;
|
||||
const objectMatch = content.match(objectRegex);
|
||||
if (objectMatch) {
|
||||
const objectContent = objectMatch[1];
|
||||
// 匹配方法名: methodName:
|
||||
const methodRegex = /([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g;
|
||||
let methodMatch;
|
||||
while ((methodMatch = methodRegex.exec(objectContent)) !== null) {
|
||||
apis.add(methodMatch[1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return apis;
|
||||
} catch (error) {
|
||||
try {
|
||||
process.stderr.write(Buffer.from(`❌ 提取暴露 API 失败: ${error.message || error}\n`, 'utf8'));
|
||||
} catch (e) {
|
||||
console.error('❌ 提取暴露 API 失败:', error);
|
||||
}
|
||||
return apis;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* [自动化] 从所有 JS 文件中提取 export 的类名、函数名和变量名
|
||||
* @returns {Promise<{names: Set<string>, strings: Set<string>}>} - 返回标识符和关键字符串
|
||||
*/
|
||||
async function extractExportedIdentifiers() {
|
||||
const names = new Set();
|
||||
const strings = new Set();
|
||||
|
||||
// 添加 ES6 模块关键字
|
||||
['export', 'default', 'import', 'from', 'as', 'module', 'exports'].forEach(s => strings.add(s));
|
||||
|
||||
try {
|
||||
const jsDir = path.join(rootDir, 'src', 'js');
|
||||
const files = await findJsFiles(jsDir);
|
||||
|
||||
for (const filePath of files) {
|
||||
try {
|
||||
const content = await fs.readFile(filePath, 'utf8');
|
||||
|
||||
// 提取 export default class ClassName
|
||||
const classExportRegex = /export\s+default\s+class\s+([a-zA-Z_$][a-zA-Z0-9_$]*)/g;
|
||||
let match;
|
||||
while ((match = classExportRegex.exec(content)) !== null) {
|
||||
names.add(match[1]);
|
||||
}
|
||||
|
||||
// 提取 export default new ClassName()
|
||||
const newExportRegex = /export\s+default\s+new\s+([a-zA-Z_$][a-zA-Z0-9_$]*)/g;
|
||||
while ((match = newExportRegex.exec(content)) !== null) {
|
||||
names.add(match[1]);
|
||||
}
|
||||
|
||||
// 提取 export default functionName
|
||||
const funcExportRegex = /export\s+default\s+(?:function\s+)?([a-zA-Z_$][a-zA-Z0-9_$]*)/g;
|
||||
while ((match = funcExportRegex.exec(content)) !== null) {
|
||||
names.add(match[1]);
|
||||
}
|
||||
|
||||
// 提取 export { name1, name2 }
|
||||
const namedExportRegex = /export\s+\{([^}]+)\}/g;
|
||||
while ((match = namedExportRegex.exec(content)) !== null) {
|
||||
const exports = match[1].split(',').map(s => s.trim().split(/\s+as\s+/)[0].trim());
|
||||
exports.forEach(name => {
|
||||
if (name && /^[a-zA-Z_$]/.test(name)) {
|
||||
names.add(name);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 提取 window.xxx = 或 window['xxx'] =
|
||||
const windowAssignRegex = /window\.([a-zA-Z_$][a-zA-Z0-9_$]*)\s*=/g;
|
||||
while ((match = windowAssignRegex.exec(content)) !== null) {
|
||||
names.add(match[1]);
|
||||
strings.add(match[1]);
|
||||
}
|
||||
|
||||
// 提取 window['xxx'] =
|
||||
const windowBracketRegex = /window\[['"]([^'"]+)['"]\]\s*=/g;
|
||||
while ((match = windowBracketRegex.exec(content)) !== null) {
|
||||
names.add(match[1]);
|
||||
strings.add(match[1]);
|
||||
}
|
||||
} catch (err) {
|
||||
// 忽略单个文件的错误
|
||||
}
|
||||
}
|
||||
|
||||
return { names, strings };
|
||||
} catch (error) {
|
||||
try {
|
||||
process.stderr.write(Buffer.from(`❌ 提取导出标识符失败: ${error.message || error}\n`, 'utf8'));
|
||||
} catch (e) {
|
||||
console.error('❌ 提取导出标识符失败:', error);
|
||||
}
|
||||
return { names, strings };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* [自动化] 从代码中提取所有类的方法名(通过分析类定义)
|
||||
* @returns {Promise<Set<string>>} - 返回所有方法名的集合
|
||||
*/
|
||||
async function extractClassMethods() {
|
||||
const methods = new Set();
|
||||
|
||||
try {
|
||||
const jsDir = path.join(rootDir, 'src', 'js');
|
||||
const files = await findJsFiles(jsDir);
|
||||
|
||||
for (const filePath of files) {
|
||||
try {
|
||||
const content = await fs.readFile(filePath, 'utf8');
|
||||
|
||||
// 提取类方法: 匹配 class 内部的方法定义
|
||||
// 1. 匹配 async methodName() { 或 methodName() {
|
||||
const classMethodRegex = /(?:async\s+)?([a-zA-Z_$][a-zA-Z0-9_$]*)\s*\([^)]*\)\s*\{/g;
|
||||
let match;
|
||||
while ((match = classMethodRegex.exec(content)) !== null) {
|
||||
const methodName = match[1];
|
||||
// 排除常见的关键字和内置方法
|
||||
if (!['constructor', 'toString', 'valueOf', 'hasOwnProperty', 'isPrototypeOf', 'catch', 'then', 'finally'].includes(methodName)) {
|
||||
methods.add(methodName);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 也匹配对象方法: methodName: function() { 或 methodName: () => {
|
||||
const objectMethodRegex = /([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:\s*(?:function|\([^)]*\)\s*=>)/g;
|
||||
while ((match = objectMethodRegex.exec(content)) !== null) {
|
||||
const methodName = match[1];
|
||||
if (!['constructor', 'toString', 'valueOf', 'hasOwnProperty', 'isPrototypeOf'].includes(methodName)) {
|
||||
methods.add(methodName);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 匹配 this.methodName 调用,提取方法名
|
||||
const thisMethodRegex = /this\.([a-zA-Z_$][a-zA-Z0-9_$]*)\s*(?:\(|\.bind)/g;
|
||||
while ((match = thisMethodRegex.exec(content)) !== null) {
|
||||
const methodName = match[1];
|
||||
if (!['constructor', 'toString', 'valueOf', 'hasOwnProperty', 'isPrototypeOf'].includes(methodName)) {
|
||||
methods.add(methodName);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// 忽略单个文件的错误
|
||||
}
|
||||
}
|
||||
|
||||
return methods;
|
||||
} catch (error) {
|
||||
try {
|
||||
process.stderr.write(Buffer.from(`❌ 提取类方法失败: ${error.message || error}\n`, 'utf8'));
|
||||
} catch (e) {
|
||||
console.error('❌ 提取类方法失败:', error);
|
||||
}
|
||||
return methods;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* [自动化] 从 main.js 中提取所有使用的 Node.js 内置模块方法名
|
||||
* @returns {Promise<Set<string>>} - 返回所有方法名的集合
|
||||
*/
|
||||
async function extractNodeBuiltinMethods() {
|
||||
const methods = new Set();
|
||||
|
||||
try {
|
||||
const mainPath = path.join(rootDir, 'main.js');
|
||||
if (await fs.pathExists(mainPath)) {
|
||||
const content = await fs.readFile(mainPath, 'utf8');
|
||||
|
||||
// 提取 path.xxx, fs.xxx, crypto.xxx, os.xxx 等方法调用
|
||||
const builtinModuleRegex = /(?:path|fs|crypto|os|https|http|stream|child_process|worker_threads|electron)\.([a-zA-Z_$][a-zA-Z0-9_$]*)\s*(?:\(|\[)/g;
|
||||
let match;
|
||||
while ((match = builtinModuleRegex.exec(content)) !== null) {
|
||||
methods.add(match[1]);
|
||||
}
|
||||
}
|
||||
|
||||
// 添加常用的 Node.js 内置模块方法(确保不被压缩)
|
||||
const commonNodeMethods = [
|
||||
// path 模块
|
||||
'join', 'resolve', 'dirname', 'basename', 'extname', 'parse', 'format', 'normalize', 'isAbsolute',
|
||||
// fs 模块
|
||||
'readFile', 'writeFile', 'readFileSync', 'writeFileSync', 'existsSync', 'mkdirSync', 'statSync', 'readdirSync',
|
||||
'createReadStream', 'createWriteStream', 'access', 'accessSync', 'unlink', 'unlinkSync', 'rmdir', 'rmdirSync',
|
||||
// crypto 模块
|
||||
'createHash', 'createHmac', 'randomBytes', 'createCipheriv', 'createDecipheriv',
|
||||
// os 模块
|
||||
'platform', 'arch', 'homedir', 'tmpdir', 'uptime', 'totalmem', 'freemem', 'cpus', 'networkInterfaces',
|
||||
// stream 模块
|
||||
'pipeline', 'Readable', 'Writable', 'Transform',
|
||||
// child_process 模块
|
||||
'exec', 'execSync', 'spawn', 'spawnSync',
|
||||
// https/http 模块
|
||||
'get', 'request', 'createServer',
|
||||
// worker_threads 模块
|
||||
'Worker', 'isMainThread', 'parentPort',
|
||||
// Electron 模块
|
||||
'app', 'BrowserWindow', 'ipcMain', 'ipcRenderer', 'dialog', 'shell', 'nativeTheme', 'screen', 'session',
|
||||
'getPath', 'isPackaged', 'getVersion', 'getLocale', 'quit', 'exit', 'relaunch',
|
||||
'on', 'handle', 'send', 'invoke', 'webContents', 'loadURL', 'loadFile', 'show', 'hide', 'minimize', 'maximize', 'close',
|
||||
'showMessageBox', 'showOpenDialog', 'showSaveDialog', 'openExternal', 'openPath',
|
||||
'shouldUseDarkColors', 'themeSource', 'getAllDisplays', 'getPrimaryDisplay',
|
||||
'defaultSession', 'fromPartition', 'webRequest', 'onHeadersReceived', 'setPreloads'
|
||||
];
|
||||
|
||||
commonNodeMethods.forEach(method => methods.add(method));
|
||||
|
||||
return methods;
|
||||
} catch (error) {
|
||||
try {
|
||||
process.stderr.write(Buffer.from(`❌ 提取 Node.js 内置方法失败: ${error.message || error}\n`, 'utf8'));
|
||||
} catch (e) {
|
||||
console.error('❌ 提取 Node.js 内置方法失败:', error);
|
||||
}
|
||||
return methods;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 递归查找所有 JS 文件
|
||||
*/
|
||||
async function findJsFiles(dir) {
|
||||
let results = [];
|
||||
try {
|
||||
const list = await fs.readdir(dir);
|
||||
for (const file of list) {
|
||||
const filePath = path.join(dir, file);
|
||||
const stat = await fs.stat(filePath);
|
||||
if (stat && stat.isDirectory()) {
|
||||
results = results.concat(await findJsFiles(filePath));
|
||||
} else if (filePath.endsWith('.js')) {
|
||||
results.push(filePath);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// 忽略错误
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
// ====================================================================================
|
||||
// --- ⚙️ 构建脚本执行区 ---
|
||||
// ====================================================================================
|
||||
|
||||
const sourcesToProcess = [
|
||||
'main.js',
|
||||
'preload.js',
|
||||
'config',
|
||||
'src'
|
||||
];
|
||||
|
||||
/**
|
||||
* 判断是否为关键文件(需要特殊处理)
|
||||
*/
|
||||
function isCriticalFile(filePath) {
|
||||
const criticalPatterns = [
|
||||
/main\.js$/,
|
||||
/preload\.js$/,
|
||||
/configManager\.js$/,
|
||||
/mainPage\.js$/,
|
||||
/uiManager\.js$/,
|
||||
/toolStatusManager\.js$/,
|
||||
/toolHealthChecker\.js$/,
|
||||
/toolHealthCheckModal\.js$/
|
||||
];
|
||||
return criticalPatterns.some(pattern => pattern.test(filePath));
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用 Terser 压缩代码(轻量级,不会导致方法调用问题)
|
||||
* @param {string} code - 原始代码
|
||||
* @param {string} filePath - 文件路径
|
||||
* @param {Object} reservedData - 预提取的保留数据
|
||||
* @returns {Promise<string>} - 压缩后的代码
|
||||
*/
|
||||
async function minifyCode(code, filePath, reservedData) {
|
||||
try {
|
||||
const { reservedNames, reservedStrings } = reservedData;
|
||||
|
||||
const result = await minify(code, {
|
||||
compress: {
|
||||
drop_console: false, // 保留 console,方便调试
|
||||
drop_debugger: true,
|
||||
pure_funcs: [], // 不删除任何函数调用
|
||||
passes: 2 // 压缩次数
|
||||
},
|
||||
mangle: {
|
||||
reserved: Array.from(reservedNames), // 保留的标识符
|
||||
// [修复] 完全禁用属性压缩,避免类方法被压缩导致的问题
|
||||
properties: false // 禁用属性压缩,确保 this.methodName 不被压缩
|
||||
},
|
||||
format: {
|
||||
comments: false // 删除注释
|
||||
},
|
||||
keep_classnames: true, // 保留类名
|
||||
keep_fnames: true, // [修复] 保留函数名,确保类方法名不被压缩
|
||||
safari10: true // Safari 10 兼容性
|
||||
});
|
||||
|
||||
return result.code || code;
|
||||
} catch (error) {
|
||||
const errorMsg = `⚠️ 压缩 ${path.relative(outputDir, filePath)} 失败,使用原始代码: ${error.message}`;
|
||||
try {
|
||||
process.stderr.write(Buffer.from(errorMsg + '\n', 'utf8'));
|
||||
} catch (e) {
|
||||
console.error(errorMsg);
|
||||
}
|
||||
return code;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 主构建函数
|
||||
*/
|
||||
async function run() {
|
||||
// [修复] 确保输出使用 UTF-8 编码
|
||||
const log = (message) => {
|
||||
try {
|
||||
process.stdout.write(Buffer.from(message + '\n', 'utf8'));
|
||||
} catch (e) {
|
||||
console.log(message);
|
||||
}
|
||||
};
|
||||
|
||||
log('🧹 [1/6] 清理旧的构建目录...');
|
||||
await fs.remove(outputDir);
|
||||
await fs.ensureDir(outputDir);
|
||||
|
||||
log('🔄 [2/6] 复制并清理文件用于发布...');
|
||||
const packageJsonPath = path.join(rootDir, 'package.json');
|
||||
const packageJson = await fs.readJson(packageJsonPath);
|
||||
const prodPackageJson = {
|
||||
name: packageJson.name,
|
||||
version: packageJson.version,
|
||||
productName: packageJson.productName,
|
||||
description: packageJson.description,
|
||||
main: packageJson.main,
|
||||
author: packageJson.author,
|
||||
dependencies: packageJson.dependencies
|
||||
};
|
||||
await fs.writeJson(path.join(outputDir, 'package.json'), prodPackageJson, { spaces: 2 });
|
||||
log('- 已清理并写入 package.json');
|
||||
|
||||
// 复制 lang 目录
|
||||
const langSrc = path.join(rootDir, 'lang');
|
||||
const langDest = path.join(outputDir, 'lang');
|
||||
if (await fs.pathExists(langSrc)) {
|
||||
await fs.copy(langSrc, langDest);
|
||||
log('- 已复制 lang 目录到 app_dist');
|
||||
} else {
|
||||
throw new Error(`❌ 找不到 lang 目录:${langSrc}`);
|
||||
}
|
||||
|
||||
// 复制 config-template.ini
|
||||
const configTemplatePath = path.join(rootDir, 'build-scripts', 'config-template.ini');
|
||||
const configTemplateDest = path.join(outputDir, 'config-template.ini');
|
||||
if (await fs.pathExists(configTemplatePath)) {
|
||||
await fs.copy(configTemplatePath, configTemplateDest);
|
||||
log('- 已复制 config-template.ini 到 app_dist');
|
||||
} else {
|
||||
throw new Error(`❌ 找不到 config-template.ini:${configTemplatePath}`);
|
||||
}
|
||||
|
||||
// 复制其他源文件
|
||||
for (const source of sourcesToProcess) {
|
||||
const sourcePath = path.join(rootDir, source);
|
||||
const destPath = path.join(outputDir, source);
|
||||
if (await fs.pathExists(sourcePath)) {
|
||||
await fs.copy(sourcePath, destPath);
|
||||
}
|
||||
}
|
||||
log('- 其他所有源文件已复制。');
|
||||
|
||||
// 自动化识别需要保留的内容(只执行一次)
|
||||
log('🔍 [3/6] 自动化识别需要保留的内容...');
|
||||
const ipcChannels = await extractIpcChannels();
|
||||
const exposedApis = await extractExposedApis();
|
||||
const exportedIds = await extractExportedIdentifiers();
|
||||
const classMethods = await extractClassMethods();
|
||||
const nodeBuiltinMethods = await extractNodeBuiltinMethods();
|
||||
|
||||
// 合并所有需要保留的内容
|
||||
const reservedNames = new Set();
|
||||
const reservedStrings = new Set();
|
||||
|
||||
// 添加 IPC 频道到字符串保留列表
|
||||
ipcChannels.forEach(ch => reservedStrings.add(ch));
|
||||
|
||||
// 添加暴露的 API
|
||||
exposedApis.forEach(api => {
|
||||
reservedNames.add(api);
|
||||
reservedStrings.add(api);
|
||||
});
|
||||
|
||||
// 添加导出标识符
|
||||
exportedIds.names.forEach(name => reservedNames.add(name));
|
||||
exportedIds.strings.forEach(str => reservedStrings.add(str));
|
||||
|
||||
// 添加类方法名
|
||||
classMethods.forEach(method => reservedNames.add(method));
|
||||
|
||||
// [修复] 添加 MainPage 的关键方法名到保留列表(防止方法被压缩)
|
||||
// 这些方法名必须保留,否则 this.methodName.bind() 会失败
|
||||
const mainPageMethods = [
|
||||
'renderWelcomePage',
|
||||
'renderToolHealthCheckPage',
|
||||
'renderLogsPage',
|
||||
'renderSettingsPage',
|
||||
'updateActiveNavButton',
|
||||
'navigateTo',
|
||||
'initializePage',
|
||||
'init',
|
||||
'bindWindowControls',
|
||||
'bindNavigationEvents',
|
||||
'bindThemeToggle',
|
||||
'addRippleEffectListener',
|
||||
'bindGlobalKeyListener',
|
||||
'loadLanguage',
|
||||
'autoCheckUpdates',
|
||||
'listenForDownloadProgress',
|
||||
'listenForGlobalNetworkSpeed',
|
||||
'handleSecretTrigger',
|
||||
'renderTrafficChart',
|
||||
'handleLanguageChange',
|
||||
'updateLockedToolsList',
|
||||
'updateToolHealthStats',
|
||||
'bindSettingsPageEvents'
|
||||
];
|
||||
mainPageMethods.forEach(method => {
|
||||
reservedNames.add(method);
|
||||
reservedStrings.add(method);
|
||||
});
|
||||
|
||||
// 添加 UIManager 的关键方法名到保留列表
|
||||
const uiManagerMethods = [
|
||||
'getHealthIndicator',
|
||||
'renderToolboxPage',
|
||||
'_renderCurrentPage',
|
||||
'_filterAndRenderTools'
|
||||
];
|
||||
uiManagerMethods.forEach(method => {
|
||||
reservedNames.add(method);
|
||||
reservedStrings.add(method);
|
||||
});
|
||||
|
||||
// 添加 Node.js 内置模块方法名(重要!防止 path.join 等被压缩)
|
||||
nodeBuiltinMethods.forEach(method => {
|
||||
reservedNames.add(method);
|
||||
reservedStrings.add(method);
|
||||
});
|
||||
|
||||
// 添加 JavaScript 原生方法
|
||||
['bind', 'call', 'apply', 'toString', 'valueOf', 'hasOwnProperty', 'isPrototypeOf', 'propertyIsEnumerable'].forEach(name => {
|
||||
reservedNames.add(name);
|
||||
reservedStrings.add(name);
|
||||
});
|
||||
|
||||
// 添加 Electron 和浏览器相关
|
||||
['electronAPI', 'window', 'document', 'require', 'module', 'exports', 'ipcRenderer', 'ipcMain', 'contextBridge', 'BrowserWindow', 'app', 'process', 'global', 'console'].forEach(name => {
|
||||
reservedNames.add(name);
|
||||
reservedStrings.add(name);
|
||||
});
|
||||
|
||||
// 添加 Node.js 内置模块名
|
||||
['path', 'fs', 'crypto', 'os', 'https', 'http', 'stream', 'child_process', 'worker_threads', 'electron', 'util', 'events', 'buffer'].forEach(name => {
|
||||
reservedNames.add(name);
|
||||
reservedStrings.add(name);
|
||||
});
|
||||
|
||||
log(`- 识别完成: ${ipcChannels.size} 个 IPC 频道, ${exposedApis.size} 个暴露 API, ${exportedIds.names.size} 个导出标识符, ${classMethods.size} 个类方法, ${nodeBuiltinMethods.size} 个 Node.js 内置方法`);
|
||||
log(`- 总计保留: ${reservedNames.size} 个标识符, ${reservedStrings.size} 个字符串`);
|
||||
|
||||
// 准备保留数据对象
|
||||
const reservedData = { reservedNames, reservedStrings };
|
||||
|
||||
log('📦 [4/6] 压缩 JavaScript 文件...');
|
||||
const jsFiles = await findJsFiles(outputDir);
|
||||
|
||||
// 分离关键文件和普通文件
|
||||
const criticalFiles = [];
|
||||
const normalFiles = [];
|
||||
for (const filePath of jsFiles) {
|
||||
if (isCriticalFile(filePath)) {
|
||||
criticalFiles.push(filePath);
|
||||
} else {
|
||||
normalFiles.push(filePath);
|
||||
}
|
||||
}
|
||||
|
||||
log(`- 发现 ${criticalFiles.length} 个关键文件,${normalFiles.length} 个普通文件`);
|
||||
|
||||
// 处理所有文件
|
||||
let successCount = 0;
|
||||
let failCount = 0;
|
||||
|
||||
for (const filePath of jsFiles) {
|
||||
try {
|
||||
const code = await fs.readFile(filePath, 'utf8');
|
||||
const minified = await minifyCode(code, filePath, reservedData);
|
||||
await fs.writeFile(filePath, minified);
|
||||
successCount++;
|
||||
if (successCount % 10 === 0) {
|
||||
try {
|
||||
process.stdout.write(Buffer.from(`\r- 已处理: ${successCount}/${jsFiles.length}`, 'utf8'));
|
||||
} catch (e) {
|
||||
process.stdout.write(`\r- 已处理: ${successCount}/${jsFiles.length}`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
const relativePath = path.relative(outputDir, filePath);
|
||||
const errorMsg = `\n❌ 处理 ${relativePath} 失败: ${error.message || error}`;
|
||||
try {
|
||||
process.stderr.write(Buffer.from(errorMsg + '\n', 'utf8'));
|
||||
} catch (e) {
|
||||
console.error(errorMsg);
|
||||
}
|
||||
failCount++;
|
||||
}
|
||||
}
|
||||
|
||||
log(`\n- 处理完成: ${successCount} 成功, ${failCount} 失败`);
|
||||
|
||||
log('📊 [5/6] 生成构建报告...');
|
||||
const buildReport = {
|
||||
timestamp: new Date().toISOString(),
|
||||
version: packageJson.version,
|
||||
filesProcessed: {
|
||||
total: jsFiles.length,
|
||||
critical: criticalFiles.length,
|
||||
normal: normalFiles.length,
|
||||
success: successCount,
|
||||
failed: failCount
|
||||
},
|
||||
security: {
|
||||
minificationEnabled: true,
|
||||
reservedIdentifiers: {
|
||||
ipcChannels: ipcChannels.size,
|
||||
exposedApis: exposedApis.size,
|
||||
exportedIdentifiers: exportedIds.names.size,
|
||||
classMethods: classMethods.size,
|
||||
nodeBuiltinMethods: nodeBuiltinMethods.size,
|
||||
total: reservedNames.size
|
||||
},
|
||||
autoDetectionEnabled: true
|
||||
}
|
||||
};
|
||||
await fs.writeJson(
|
||||
path.join(outputDir, 'build-report.json'),
|
||||
buildReport,
|
||||
{ spaces: 2 }
|
||||
);
|
||||
log('- 构建报告已生成');
|
||||
|
||||
log('✅ [6/6] 构建过程成功完成!');
|
||||
log(`📦 最终的应用文件已准备就绪,位于: ${outputDir}`);
|
||||
log(`🔐 安全措施: 代码压缩 + 自动识别保留内容`);
|
||||
log(`📈 处理统计: ${jsFiles.length} 个文件 (${successCount} 成功, ${failCount} 失败)`);
|
||||
}
|
||||
|
||||
// 运行构建
|
||||
run().catch(err => {
|
||||
const errorMsg = `❌ 在构建过程中发生错误:\n${err.stack || err.message || err}`;
|
||||
try {
|
||||
process.stderr.write(Buffer.from(errorMsg + '\n', 'utf8'));
|
||||
} catch (e) {
|
||||
console.error(errorMsg);
|
||||
}
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
; YMhut Box Configuration
|
||||
|
||||
[Settings]
|
||||
; App Version - DO NOT EDIT
|
||||
Version=${VERSION}
|
||||
|
||||
; Language setting
|
||||
; auto = Use system language (default)
|
||||
; en-US = English
|
||||
; zh-CN = Chinese (Simplified)
|
||||
Language=auto
|
||||
|
||||
CheckForUpdates=true
|
||||
|
||||
[Paths]
|
||||
; Path for language packs (relative to app root)
|
||||
LanguagePath=resources/lang
|
||||
|
||||
; Path for plugins (reserved for future use)
|
||||
PluginPath=plugins
|
||||
@@ -0,0 +1,189 @@
|
||||
; ============================================================================
|
||||
; 终极修复版 V2 (Root-Bin 架构 | 修复文件名后缀问题)
|
||||
; ============================================================================
|
||||
|
||||
!include "FileFunc.nsh"
|
||||
!include "LogicLib.nsh"
|
||||
!include "MUI2.nsh"
|
||||
|
||||
; ----------------------------------------------------------------------------
|
||||
; 1. 定义启动函数 (修复编译报错 & 路径指向 bin)
|
||||
; ----------------------------------------------------------------------------
|
||||
!ifdef NSIS_WIN32_MAKENSIS
|
||||
!ifndef BUILD_UNINSTALLER
|
||||
Function LaunchBinApp
|
||||
; [修复] 添加 .exe 后缀
|
||||
ExecShell "" "$INSTDIR\bin\${APP_FILENAME}.exe"
|
||||
FunctionEnd
|
||||
|
||||
; ---------------- [修改开始] ----------------
|
||||
; 注释掉以下三行以移除安装完成后的“运行”选项
|
||||
; !define MUI_FINISHPAGE_RUN
|
||||
; !define MUI_FINISHPAGE_RUN_TEXT "运行 ${PRODUCT_NAME}"
|
||||
; !define MUI_FINISHPAGE_RUN_FUNCTION "LaunchBinApp"
|
||||
; ---------------- [修改结束] ----------------
|
||||
!endif
|
||||
!endif
|
||||
|
||||
; ----------------------------------------------------------------------------
|
||||
; [阶段 1] 初始化
|
||||
; ----------------------------------------------------------------------------
|
||||
!macro customInit
|
||||
; 读取注册表
|
||||
ReadRegStr $0 HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${APP_GUID}" "InstallLocation"
|
||||
${If} $0 != ""
|
||||
StrCpy $INSTDIR $0
|
||||
${EndIf}
|
||||
|
||||
; 防套娃检查 (检查 .exe 是否存在)
|
||||
${GetFileName} "$INSTDIR" $R0
|
||||
${If} $R0 == "${PRODUCT_FILENAME}"
|
||||
${GetParent} "$INSTDIR" $R1
|
||||
IfFileExists "$R1\config.ini" fix_path 0
|
||||
IfFileExists "$R1\data" fix_path 0
|
||||
Goto check_done
|
||||
fix_path:
|
||||
StrCpy $INSTDIR "$R1"
|
||||
${EndIf}
|
||||
check_done:
|
||||
!macroend
|
||||
|
||||
; ----------------------------------------------------------------------------
|
||||
; [阶段 2] 安装执行 (核心:文件移动)
|
||||
; ----------------------------------------------------------------------------
|
||||
!macro customInstall
|
||||
SetOutPath "$INSTDIR"
|
||||
|
||||
; 1. 建立目录
|
||||
CreateDirectory "$INSTDIR\bin"
|
||||
CreateDirectory "$INSTDIR\data"
|
||||
|
||||
; 2. 处理 config.ini
|
||||
IfFileExists "$INSTDIR\config.ini" config_ready 0
|
||||
IfFileExists "$INSTDIR\resources\config-template.ini" 0 try_root_template
|
||||
CopyFiles "$INSTDIR\resources\config-template.ini" "$INSTDIR\config.ini"
|
||||
Goto patch_version
|
||||
try_root_template:
|
||||
IfFileExists "$INSTDIR\config-template.ini" 0 config_ready
|
||||
CopyFiles "$INSTDIR\config-template.ini" "$INSTDIR\config.ini"
|
||||
|
||||
patch_version:
|
||||
nsExec::ExecToStack 'powershell -Command "(Get-Content -Path \"$INSTDIR\config.ini\" -Raw) -replace \"Version=.*\", \"Version=${VERSION}\" | Set-Content -Path \"$INSTDIR\config.ini\" -Encoding utf8"'
|
||||
config_ready:
|
||||
|
||||
; 3. 移动文件夹
|
||||
RMDir /r "$INSTDIR\bin\resources"
|
||||
Rename "$INSTDIR\resources" "$INSTDIR\bin\resources"
|
||||
|
||||
RMDir /r "$INSTDIR\bin\locales"
|
||||
Rename "$INSTDIR\locales" "$INSTDIR\bin\locales"
|
||||
|
||||
RMDir /r "$INSTDIR\bin\lang"
|
||||
Rename "$INSTDIR\lang" "$INSTDIR\bin\lang"
|
||||
|
||||
; 4. [关键修复] 移动主程序 EXE 到 Bin (显式添加 .exe)
|
||||
; 先清理目标
|
||||
Delete "$INSTDIR\bin\${APP_FILENAME}.exe"
|
||||
|
||||
; 尝试重命名移动
|
||||
Rename "$INSTDIR\${APP_FILENAME}.exe" "$INSTDIR\bin\${APP_FILENAME}.exe"
|
||||
|
||||
; [双重保险] 如果 Rename 失败(极少见),使用 Copy + Delete
|
||||
IfFileExists "$INSTDIR\bin\${APP_FILENAME}.exe" +3 0
|
||||
CopyFiles "$INSTDIR\${APP_FILENAME}.exe" "$INSTDIR\bin\${APP_FILENAME}.exe"
|
||||
Delete "$INSTDIR\${APP_FILENAME}.exe"
|
||||
|
||||
; 5. 移动依赖文件
|
||||
; 清理旧文件
|
||||
Delete "$INSTDIR\bin\*.dll"
|
||||
Delete "$INSTDIR\bin\*.pak"
|
||||
Delete "$INSTDIR\bin\*.bin"
|
||||
Delete "$INSTDIR\bin\*.dat"
|
||||
Delete "$INSTDIR\bin\*.json"
|
||||
Delete "$INSTDIR\bin\LICENSE*"
|
||||
Delete "$INSTDIR\bin\version"
|
||||
Delete "$INSTDIR\bin\*.ico"
|
||||
|
||||
; 移动新文件
|
||||
CopyFiles "$INSTDIR\*.dll" "$INSTDIR\bin"
|
||||
Delete "$INSTDIR\*.dll"
|
||||
|
||||
CopyFiles "$INSTDIR\*.pak" "$INSTDIR\bin"
|
||||
Delete "$INSTDIR\*.pak"
|
||||
|
||||
CopyFiles "$INSTDIR\*.bin" "$INSTDIR\bin"
|
||||
Delete "$INSTDIR\*.bin"
|
||||
|
||||
CopyFiles "$INSTDIR\*.dat" "$INSTDIR\bin"
|
||||
Delete "$INSTDIR\*.dat"
|
||||
|
||||
CopyFiles "$INSTDIR\*.json" "$INSTDIR\bin"
|
||||
Delete "$INSTDIR\*.json"
|
||||
|
||||
CopyFiles "$INSTDIR\LICENSE*" "$INSTDIR\bin"
|
||||
Delete "$INSTDIR\LICENSE*"
|
||||
|
||||
CopyFiles "$INSTDIR\version" "$INSTDIR\bin"
|
||||
Delete "$INSTDIR\version"
|
||||
|
||||
CopyFiles "$INSTDIR\*.ico" "$INSTDIR\bin"
|
||||
Delete "$INSTDIR\*.ico"
|
||||
|
||||
; 清理临时文件
|
||||
Delete "$INSTDIR\config-template.ini"
|
||||
!macroend
|
||||
|
||||
; ----------------------------------------------------------------------------
|
||||
; [阶段 3] 卸载逻辑
|
||||
; ----------------------------------------------------------------------------
|
||||
!macro customUninstall
|
||||
; 1. 暴力清理 bin
|
||||
RMDir /r "$INSTDIR\bin"
|
||||
|
||||
; 2. 清理根目录残留 (添加 .exe)
|
||||
Delete "$INSTDIR\${APP_FILENAME}.exe"
|
||||
Delete "$INSTDIR\Uninstall ${PRODUCT_NAME}.exe"
|
||||
Delete "$INSTDIR\Uninstall.exe"
|
||||
|
||||
; 3. 删除根目录
|
||||
RMDir "$INSTDIR"
|
||||
!macroend
|
||||
|
||||
; ----------------------------------------------------------------------------
|
||||
; [阶段 4] 拦截默认快捷方式
|
||||
; ----------------------------------------------------------------------------
|
||||
!macro customCreateShortcut
|
||||
; 留空,手动创建
|
||||
!macroend
|
||||
|
||||
; ----------------------------------------------------------------------------
|
||||
; [阶段 5] 安装后处理
|
||||
; ----------------------------------------------------------------------------
|
||||
Function .onInstSuccess
|
||||
; 1. 移动卸载程序
|
||||
Delete "$INSTDIR\bin\Uninstall.exe"
|
||||
|
||||
IfFileExists "$INSTDIR\Uninstall ${PRODUCT_NAME}.exe" 0 try_short_name
|
||||
Rename "$INSTDIR\Uninstall ${PRODUCT_NAME}.exe" "$INSTDIR\bin\Uninstall.exe"
|
||||
Goto reg_fix
|
||||
try_short_name:
|
||||
Rename "$INSTDIR\Uninstall.exe" "$INSTDIR\bin\Uninstall.exe"
|
||||
|
||||
reg_fix:
|
||||
; 2. 修正注册表 (添加 .exe)
|
||||
WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${APP_GUID}" "DisplayIcon" "$INSTDIR\bin\${APP_FILENAME}.exe"
|
||||
WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${APP_GUID}" "UninstallString" "$INSTDIR\bin\Uninstall.exe"
|
||||
|
||||
; 3. 创建快捷方式
|
||||
Delete "$DESKTOP\${PRODUCT_NAME}.lnk"
|
||||
Delete "$SMPROGRAMS\${PRODUCT_NAME}.lnk"
|
||||
|
||||
SetOutPath "$INSTDIR\bin"
|
||||
|
||||
; [关键修复] 指向 bin 下的 .exe
|
||||
CreateShortCut "$DESKTOP\${PRODUCT_NAME}.lnk" "$INSTDIR\bin\${APP_FILENAME}.exe"
|
||||
|
||||
CreateDirectory "$SMPROGRAMS\${PRODUCT_NAME}"
|
||||
CreateShortCut "$SMPROGRAMS\${PRODUCT_NAME}\${PRODUCT_NAME}.lnk" "$INSTDIR\bin\${APP_FILENAME}.exe"
|
||||
CreateShortCut "$SMPROGRAMS\${PRODUCT_NAME}\Uninstall.lnk" "$INSTDIR\bin\Uninstall.exe"
|
||||
FunctionEnd
|
||||
@@ -0,0 +1,162 @@
|
||||
// config/api-reservations.js
|
||||
/**
|
||||
* [重构] 接口预留位置配置
|
||||
* 定义所有接口的预留位置,方便后续扩展
|
||||
*/
|
||||
|
||||
module.exports = {
|
||||
// 更新服务接口
|
||||
update: {
|
||||
apiId: 'update-service',
|
||||
category: 'system',
|
||||
description: '应用更新服务',
|
||||
priority: 10,
|
||||
enabled: true,
|
||||
config: {
|
||||
url: 'https://update.ymhut.cn/update-info.json',
|
||||
method: 'GET',
|
||||
timeout: 10000,
|
||||
retries: 2,
|
||||
healthCheckUrl: 'https://update.ymhut.cn/update-info.json',
|
||||
healthCheckInterval: 3600000 // 1小时检查一次
|
||||
}
|
||||
},
|
||||
|
||||
// 远程配置接口
|
||||
remoteConfig: {
|
||||
apiId: 'remote-config',
|
||||
category: 'system',
|
||||
description: '远程配置服务',
|
||||
priority: 10,
|
||||
enabled: true,
|
||||
config: {
|
||||
urls: {
|
||||
media_types: 'https://update.ymhut.cn/media-types.json',
|
||||
update_info: 'https://update.ymhut.cn/update-info.json',
|
||||
tool_status: 'https://update.ymhut.cn/tool-status.json'
|
||||
},
|
||||
timeout: 10000,
|
||||
retries: 2
|
||||
}
|
||||
},
|
||||
|
||||
// 天气服务接口
|
||||
weather: {
|
||||
apiId: 'weather-service',
|
||||
category: 'tool',
|
||||
description: '天气查询服务',
|
||||
priority: 5,
|
||||
enabled: true,
|
||||
config: {
|
||||
url: 'https://uapis.cn/api/v1/misc/weather',
|
||||
method: 'GET',
|
||||
timeout: 8000,
|
||||
retries: 2,
|
||||
healthCheckInterval: 1800000 // 30分钟检查一次
|
||||
}
|
||||
},
|
||||
|
||||
// IP查询服务接口
|
||||
ipQuery: {
|
||||
apiId: 'ip-query-service',
|
||||
category: 'tool',
|
||||
description: 'IP查询服务',
|
||||
priority: 5,
|
||||
enabled: true,
|
||||
config: {
|
||||
urls: [
|
||||
'https://api.ipify.org?format=json',
|
||||
'https://ipinfo.io/json'
|
||||
],
|
||||
timeout: 5000,
|
||||
retries: 1
|
||||
}
|
||||
},
|
||||
|
||||
// 智能搜索服务接口
|
||||
smartSearch: {
|
||||
apiId: 'smart-search-service',
|
||||
category: 'tool',
|
||||
description: '智能搜索服务',
|
||||
priority: 8,
|
||||
enabled: true,
|
||||
config: {
|
||||
url: 'https://uapis.cn/api/v1/search/aggregate',
|
||||
method: 'POST',
|
||||
timeout: 15000,
|
||||
retries: 2,
|
||||
healthCheckInterval: 600000 // 10分钟检查一次
|
||||
}
|
||||
},
|
||||
|
||||
// 工具健康检查接口(预留)
|
||||
toolHealthCheck: {
|
||||
apiId: 'tool-health-check',
|
||||
category: 'system',
|
||||
description: '工具健康检查服务',
|
||||
priority: 9,
|
||||
enabled: true,
|
||||
config: {
|
||||
// 此接口用于检查各个工具的健康状态
|
||||
// 具体配置由各个工具自行定义
|
||||
}
|
||||
},
|
||||
|
||||
// 语言包下载接口
|
||||
languagePack: {
|
||||
apiId: 'language-pack',
|
||||
category: 'system',
|
||||
description: '语言包下载服务',
|
||||
priority: 7,
|
||||
enabled: true,
|
||||
config: {
|
||||
url: 'https://update.ymhut.cn/lang',
|
||||
method: 'GET',
|
||||
timeout: 30000,
|
||||
retries: 3
|
||||
}
|
||||
},
|
||||
|
||||
// 图标缓存接口
|
||||
iconCache: {
|
||||
apiId: 'icon-cache',
|
||||
category: 'system',
|
||||
description: '图标缓存服务',
|
||||
priority: 3,
|
||||
enabled: true,
|
||||
config: {
|
||||
// 图标URL由各个工具提供
|
||||
}
|
||||
},
|
||||
|
||||
// 字体下载接口
|
||||
fontDownload: {
|
||||
apiId: 'font-download',
|
||||
category: 'system',
|
||||
description: '字体下载服务',
|
||||
priority: 4,
|
||||
enabled: true,
|
||||
config: {
|
||||
url: 'https://update.ymhut.cn/fonts',
|
||||
method: 'GET',
|
||||
timeout: 60000,
|
||||
retries: 3
|
||||
}
|
||||
},
|
||||
|
||||
// IP封禁列表接口
|
||||
ipBanList: {
|
||||
apiId: 'ip-ban-list',
|
||||
category: 'security',
|
||||
description: 'IP封禁列表服务',
|
||||
priority: 6,
|
||||
enabled: true,
|
||||
config: {
|
||||
url: 'https://update.ymhut.cn/ip-ban-list.json',
|
||||
method: 'GET',
|
||||
timeout: 5000,
|
||||
retries: 1,
|
||||
healthCheckInterval: 3600000 // 1小时检查一次
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,494 @@
|
||||
// config/database.js
|
||||
const Database = require('better-sqlite3');
|
||||
|
||||
class AppDatabase {
|
||||
constructor(dbPath) {
|
||||
if (!dbPath) throw new Error("Database path must be provided.");
|
||||
try {
|
||||
this.db = new Database(dbPath);
|
||||
console.log(`成功连接到数据库: ${dbPath}`);
|
||||
this.initTables();
|
||||
} catch (err) {
|
||||
console.error('数据库连接错误:', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
run(sql, params = []) { return this.db.prepare(sql).run(params); }
|
||||
get(sql, params = []) { return this.db.prepare(sql).get(params); }
|
||||
all(sql, params = []) { return this.db.prepare(sql).all(params); }
|
||||
|
||||
initTables() {
|
||||
// 日志表
|
||||
this.run(`CREATE TABLE IF NOT EXISTS logs (id INTEGER PRIMARY KEY AUTOINCREMENT, timestamp TEXT NOT NULL, action TEXT NOT NULL, category TEXT)`);
|
||||
// 基础配置表
|
||||
this.run(`CREATE TABLE IF NOT EXISTS app_config (key TEXT PRIMARY KEY, value TEXT)`);
|
||||
// 流量统计表
|
||||
this.run(`CREATE TABLE IF NOT EXISTS traffic_log (log_date TEXT PRIMARY KEY, bytes_used INTEGER NOT NULL)`);
|
||||
|
||||
// 远程配置存储表
|
||||
this.run(`CREATE TABLE IF NOT EXISTS remote_configs (id TEXT PRIMARY KEY, data TEXT, updated_at TEXT)`);
|
||||
|
||||
// [重构] 接口健康度校验表
|
||||
this.run(`CREATE TABLE IF NOT EXISTS api_health (
|
||||
id TEXT PRIMARY KEY,
|
||||
api_id TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
response_time INTEGER,
|
||||
error_message TEXT,
|
||||
checked_at TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)`);
|
||||
|
||||
// [重构] 接口预留位置表
|
||||
this.run(`CREATE TABLE IF NOT EXISTS api_reservations (
|
||||
id TEXT PRIMARY KEY,
|
||||
api_id TEXT NOT NULL UNIQUE,
|
||||
category TEXT,
|
||||
description TEXT,
|
||||
priority INTEGER DEFAULT 0,
|
||||
enabled INTEGER DEFAULT 1,
|
||||
config TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)`);
|
||||
|
||||
// [重构] 接口调用记录表
|
||||
this.run(`CREATE TABLE IF NOT EXISTS api_call_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
api_id TEXT NOT NULL,
|
||||
method TEXT,
|
||||
url TEXT,
|
||||
status_code INTEGER,
|
||||
response_time INTEGER,
|
||||
success INTEGER DEFAULT 0,
|
||||
error_message TEXT,
|
||||
called_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)`);
|
||||
|
||||
// [重构] 工具健康检查结果表(5天强制检查)
|
||||
this.run(`CREATE TABLE IF NOT EXISTS tool_health_check_results (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
tool_id TEXT NOT NULL,
|
||||
check_date TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
response_time INTEGER,
|
||||
error_message TEXT,
|
||||
http_status INTEGER,
|
||||
attempt INTEGER,
|
||||
reason TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)`);
|
||||
|
||||
// [重构] 工具健康检查汇总表(用于5天强制检查判断)
|
||||
this.run(`CREATE TABLE IF NOT EXISTS tool_health_check_summary (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
check_date TEXT NOT NULL UNIQUE,
|
||||
total_tools INTEGER NOT NULL,
|
||||
success_count INTEGER NOT NULL,
|
||||
failed_count INTEGER NOT NULL,
|
||||
check_duration INTEGER,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)`);
|
||||
|
||||
// 创建索引以提高查询性能
|
||||
this.run(`CREATE INDEX IF NOT EXISTS idx_api_health_api_id ON api_health(api_id)`);
|
||||
this.run(`CREATE INDEX IF NOT EXISTS idx_api_health_checked_at ON api_health(checked_at)`);
|
||||
this.run(`CREATE INDEX IF NOT EXISTS idx_api_call_logs_api_id ON api_call_logs(api_id)`);
|
||||
this.run(`CREATE INDEX IF NOT EXISTS idx_api_call_logs_called_at ON api_call_logs(called_at)`);
|
||||
this.run(`CREATE INDEX IF NOT EXISTS idx_tool_health_check_results_tool_id ON tool_health_check_results(tool_id)`);
|
||||
this.run(`CREATE INDEX IF NOT EXISTS idx_tool_health_check_results_check_date ON tool_health_check_results(check_date)`);
|
||||
this.run(`CREATE INDEX IF NOT EXISTS idx_tool_health_check_summary_check_date ON tool_health_check_summary(check_date)`);
|
||||
|
||||
// 初始化默认配置
|
||||
const initialConfigs = {
|
||||
'theme': 'dark', 'global_volume': '0.5', 'total_downloaded_bytes': '0',
|
||||
'background_image': '', 'background_opacity': '1.0', 'card_opacity': '0.7',
|
||||
'window_width': '1200', 'window_height': '800', 'window_x': 'null', 'window_y': 'null',
|
||||
'secret_code_attempts': '0', 'secret_code_lockout_until': '0',
|
||||
'app_version': '0.0.0',
|
||||
// [新增] 用户协议状态 (默认为 false)
|
||||
'user_agreement_accepted': 'false'
|
||||
};
|
||||
|
||||
const stmt = this.db.prepare(`INSERT OR IGNORE INTO app_config (key, value) VALUES (?, ?)`);
|
||||
this.db.transaction(() => {
|
||||
for (const [key, value] of Object.entries(initialConfigs)) {
|
||||
stmt.run(key, value);
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
// --- 远程配置操作 ---
|
||||
saveRemoteConfig(id, data) {
|
||||
const jsonStr = JSON.stringify(data);
|
||||
const time = new Date().toISOString();
|
||||
const sql = `INSERT OR REPLACE INTO remote_configs (id, data, updated_at) VALUES (?, ?, ?)`;
|
||||
return this.run(sql, [id, jsonStr, time]);
|
||||
}
|
||||
|
||||
getRemoteConfig(id) {
|
||||
const result = this.get(`SELECT data FROM remote_configs WHERE id = ?`, [id]);
|
||||
return result ? JSON.parse(result.data) : null;
|
||||
}
|
||||
|
||||
// --- 版本升级检查 ---
|
||||
checkAndRecordVersion(currentVersion) {
|
||||
const oldVersion = this.getConfig('app_version') || '0.0.0';
|
||||
|
||||
if (oldVersion !== currentVersion) {
|
||||
this.setConfig('app_version', currentVersion);
|
||||
return { isUpgrade: true, oldVersion: oldVersion };
|
||||
}
|
||||
return { isUpgrade: false, oldVersion: oldVersion };
|
||||
}
|
||||
|
||||
logAction(logData) {
|
||||
const { timestamp, action, category = 'general' } = logData;
|
||||
this.run(`INSERT INTO logs (timestamp, action, category) VALUES (?, ?, ?)`, [timestamp, action, category]);
|
||||
}
|
||||
|
||||
getLogs(filterDate = null, limit = 500) {
|
||||
if (filterDate) return this.all(`SELECT * FROM logs WHERE date(timestamp) = ? ORDER BY timestamp DESC`, [filterDate]);
|
||||
return this.all(`SELECT * FROM logs ORDER BY timestamp DESC LIMIT ?`, [limit]);
|
||||
}
|
||||
|
||||
clearLogs() { return this.run(`DELETE FROM logs`); }
|
||||
|
||||
getConfig(key) {
|
||||
const result = this.get(`SELECT value FROM app_config WHERE key = ?`, [key]);
|
||||
return result ? result.value : null;
|
||||
}
|
||||
|
||||
setConfig(key, value) {
|
||||
return this.run(`INSERT OR REPLACE INTO app_config (key, value) VALUES (?, ?)`, [key, value]);
|
||||
}
|
||||
|
||||
getTrafficStats() {
|
||||
const result = this.get(`SELECT value FROM app_config WHERE key = 'total_downloaded_bytes'`);
|
||||
return result ? parseInt(result.value, 10) : 0;
|
||||
}
|
||||
|
||||
getTrafficHistory() {
|
||||
return this.all(`SELECT log_date, bytes_used FROM traffic_log ORDER BY log_date ASC`);
|
||||
}
|
||||
|
||||
addTraffic(bytes) {
|
||||
if (typeof bytes !== 'number' || bytes <= 0) return;
|
||||
const currentTotalBytes = this.getTrafficStats();
|
||||
this.setConfig('total_downloaded_bytes', (currentTotalBytes + bytes).toString());
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
this.run(`INSERT INTO traffic_log (log_date, bytes_used) VALUES (?, ?) ON CONFLICT(log_date) DO UPDATE SET bytes_used = bytes_used + excluded.bytes_used;`, [today, bytes]);
|
||||
}
|
||||
|
||||
// --- [重构] 接口健康度校验操作 ---
|
||||
|
||||
/**
|
||||
* 记录接口健康度
|
||||
* @param {Object} healthData - 健康度数据
|
||||
*/
|
||||
recordApiHealth(healthData) {
|
||||
const { apiId, status, responseTime, errorMessage } = healthData;
|
||||
const checkedAt = new Date().toISOString();
|
||||
|
||||
// 删除旧记录(只保留最近100条)
|
||||
this.run(`DELETE FROM api_health WHERE api_id = ? AND id NOT IN (
|
||||
SELECT id FROM api_health WHERE api_id = ? ORDER BY checked_at DESC LIMIT 99
|
||||
)`, [apiId, apiId]);
|
||||
|
||||
// 插入新记录
|
||||
const id = `${apiId}_${Date.now()}`;
|
||||
this.run(`INSERT INTO api_health (id, api_id, status, response_time, error_message, checked_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
[id, apiId, status, responseTime || null, errorMessage || null, checkedAt]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取接口健康度历史
|
||||
* @param {string} apiId - 接口ID
|
||||
* @param {number} limit - 限制数量
|
||||
* @returns {Array} 健康度历史记录
|
||||
*/
|
||||
getApiHealthHistory(apiId, limit = 100) {
|
||||
return this.all(`SELECT * FROM api_health WHERE api_id = ? ORDER BY checked_at DESC LIMIT ?`, [apiId, limit]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取接口最新健康度
|
||||
* @param {string} apiId - 接口ID
|
||||
* @returns {Object|null} 最新健康度记录
|
||||
*/
|
||||
getLatestApiHealth(apiId) {
|
||||
return this.get(`SELECT * FROM api_health WHERE api_id = ? ORDER BY checked_at DESC LIMIT 1`, [apiId]);
|
||||
}
|
||||
|
||||
// --- [重构] 接口预留位置操作 ---
|
||||
|
||||
/**
|
||||
* 注册接口预留位置
|
||||
* @param {Object} reservation - 预留位置数据
|
||||
*/
|
||||
registerApiReservation(reservation) {
|
||||
const { apiId, category, description, priority, enabled, config } = reservation;
|
||||
const updatedAt = new Date().toISOString();
|
||||
|
||||
this.run(`INSERT OR REPLACE INTO api_reservations
|
||||
(id, api_id, category, description, priority, enabled, config, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[apiId, apiId, category || null, description || null, priority || 0, enabled ? 1 : 0,
|
||||
config ? JSON.stringify(config) : null, updatedAt]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取接口预留位置
|
||||
* @param {string} apiId - 接口ID
|
||||
* @returns {Object|null} 预留位置数据
|
||||
*/
|
||||
getApiReservation(apiId) {
|
||||
const result = this.get(`SELECT * FROM api_reservations WHERE api_id = ?`, [apiId]);
|
||||
if (result && result.config) {
|
||||
try {
|
||||
result.config = JSON.parse(result.config);
|
||||
} catch (e) {
|
||||
result.config = null;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有接口预留位置
|
||||
* @param {string} category - 分类过滤(可选)
|
||||
* @returns {Array} 预留位置列表
|
||||
*/
|
||||
getAllApiReservations(category = null) {
|
||||
if (category) {
|
||||
return this.all(`SELECT * FROM api_reservations WHERE category = ? ORDER BY priority DESC, created_at ASC`, [category]);
|
||||
}
|
||||
return this.all(`SELECT * FROM api_reservations ORDER BY priority DESC, created_at ASC`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新接口预留位置
|
||||
* @param {string} apiId - 接口ID
|
||||
* @param {Object} updates - 更新数据
|
||||
*/
|
||||
updateApiReservation(apiId, updates) {
|
||||
const fields = [];
|
||||
const values = [];
|
||||
|
||||
if (updates.category !== undefined) {
|
||||
fields.push('category = ?');
|
||||
values.push(updates.category);
|
||||
}
|
||||
if (updates.description !== undefined) {
|
||||
fields.push('description = ?');
|
||||
values.push(updates.description);
|
||||
}
|
||||
if (updates.priority !== undefined) {
|
||||
fields.push('priority = ?');
|
||||
values.push(updates.priority);
|
||||
}
|
||||
if (updates.enabled !== undefined) {
|
||||
fields.push('enabled = ?');
|
||||
values.push(updates.enabled ? 1 : 0);
|
||||
}
|
||||
if (updates.config !== undefined) {
|
||||
fields.push('config = ?');
|
||||
values.push(updates.config ? JSON.stringify(updates.config) : null);
|
||||
}
|
||||
|
||||
if (fields.length === 0) return;
|
||||
|
||||
fields.push('updated_at = ?');
|
||||
values.push(new Date().toISOString());
|
||||
values.push(apiId);
|
||||
|
||||
this.run(`UPDATE api_reservations SET ${fields.join(', ')} WHERE api_id = ?`, values);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除接口预留位置
|
||||
* @param {string} apiId - 接口ID
|
||||
*/
|
||||
deleteApiReservation(apiId) {
|
||||
this.run(`DELETE FROM api_reservations WHERE api_id = ?`, [apiId]);
|
||||
}
|
||||
|
||||
// --- [重构] 接口调用记录操作 ---
|
||||
|
||||
/**
|
||||
* 记录接口调用
|
||||
* @param {Object} callData - 调用数据
|
||||
*/
|
||||
recordApiCall(callData) {
|
||||
const { apiId, method, url, statusCode, responseTime, success, errorMessage } = callData;
|
||||
|
||||
this.run(`INSERT INTO api_call_logs
|
||||
(api_id, method, url, status_code, response_time, success, error_message)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
[apiId, method || null, url || null, statusCode || null, responseTime || null,
|
||||
success ? 1 : 0, errorMessage || null]);
|
||||
|
||||
// 自动清理旧记录(只保留最近10000条)
|
||||
this.run(`DELETE FROM api_call_logs WHERE id NOT IN (
|
||||
SELECT id FROM api_call_logs ORDER BY called_at DESC LIMIT 10000
|
||||
)`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取接口调用统计
|
||||
* @param {string} apiId - 接口ID
|
||||
* @param {string} startDate - 开始日期(可选)
|
||||
* @param {string} endDate - 结束日期(可选)
|
||||
* @returns {Object} 调用统计
|
||||
*/
|
||||
getApiCallStats(apiId, startDate = null, endDate = null) {
|
||||
let query = `SELECT
|
||||
COUNT(*) as total_calls,
|
||||
SUM(CASE WHEN success = 1 THEN 1 ELSE 0 END) as success_calls,
|
||||
SUM(CASE WHEN success = 0 THEN 1 ELSE 0 END) as failed_calls,
|
||||
AVG(response_time) as avg_response_time,
|
||||
MIN(response_time) as min_response_time,
|
||||
MAX(response_time) as max_response_time
|
||||
FROM api_call_logs WHERE api_id = ?`;
|
||||
|
||||
const params = [apiId];
|
||||
|
||||
if (startDate) {
|
||||
query += ` AND called_at >= ?`;
|
||||
params.push(startDate);
|
||||
}
|
||||
if (endDate) {
|
||||
query += ` AND called_at <= ?`;
|
||||
params.push(endDate);
|
||||
}
|
||||
|
||||
return this.get(query, params);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取接口调用历史
|
||||
* @param {string} apiId - 接口ID
|
||||
* @param {number} limit - 限制数量
|
||||
* @returns {Array} 调用历史记录
|
||||
*/
|
||||
getApiCallHistory(apiId, limit = 100) {
|
||||
return this.all(`SELECT * FROM api_call_logs WHERE api_id = ? ORDER BY called_at DESC LIMIT ?`, [apiId, limit]);
|
||||
}
|
||||
|
||||
// --- [重构] 工具健康检查结果操作 ---
|
||||
|
||||
/**
|
||||
* 记录工具健康检查结果
|
||||
* @param {Object} resultData - 检查结果数据
|
||||
*/
|
||||
recordToolHealthCheckResult(resultData) {
|
||||
const { toolId, checkDate, status, responseTime, errorMessage, httpStatus, attempt, reason } = resultData;
|
||||
const createdAt = new Date().toISOString();
|
||||
|
||||
this.run(`INSERT INTO tool_health_check_results
|
||||
(tool_id, check_date, status, response_time, error_message, http_status, attempt, reason, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[toolId, checkDate, status, responseTime || null, errorMessage || null, httpStatus || null, attempt || 1, reason || null, createdAt]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取工具的最新健康检查结果
|
||||
* @param {string} toolId - 工具ID
|
||||
* @returns {Object|null} 最新检查结果
|
||||
*/
|
||||
getLatestToolHealthCheckResult(toolId) {
|
||||
return this.get(`SELECT * FROM tool_health_check_results WHERE tool_id = ? ORDER BY check_date DESC, created_at DESC LIMIT 1`, [toolId]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取工具的健康检查历史
|
||||
* @param {string} toolId - 工具ID
|
||||
* @param {number} limit - 限制数量
|
||||
* @returns {Array} 检查历史记录
|
||||
*/
|
||||
getToolHealthCheckHistory(toolId, limit = 100) {
|
||||
return this.all(`SELECT * FROM tool_health_check_results WHERE tool_id = ? ORDER BY check_date DESC, created_at DESC LIMIT ?`, [toolId, limit]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取指定日期的所有工具健康检查结果
|
||||
* @param {string} checkDate - 检查日期 (YYYY-MM-DD)
|
||||
* @returns {Array} 检查结果列表
|
||||
*/
|
||||
getToolHealthCheckResultsByDate(checkDate) {
|
||||
return this.all(`SELECT * FROM tool_health_check_results WHERE check_date = ? ORDER BY tool_id`, [checkDate]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取最后一次健康检查的日期
|
||||
* @returns {string|null} 最后一次检查的日期
|
||||
*/
|
||||
getLastHealthCheckDate() {
|
||||
const result = this.get(`SELECT check_date FROM tool_health_check_summary ORDER BY check_date DESC LIMIT 1`);
|
||||
return result ? result.check_date : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查距离上次检查是否超过指定天数
|
||||
* @param {number} days - 天数(默认5天)
|
||||
* @returns {boolean} true 表示超过指定天数
|
||||
*/
|
||||
shouldForceHealthCheck(days = 5) {
|
||||
const lastDate = this.getLastHealthCheckDate();
|
||||
if (!lastDate) {
|
||||
return true; // 从未检查过
|
||||
}
|
||||
|
||||
const lastCheck = new Date(lastDate);
|
||||
const now = new Date();
|
||||
const daysDiff = Math.floor((now - lastCheck) / (1000 * 60 * 60 * 24));
|
||||
|
||||
return daysDiff >= days;
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存工具健康检查汇总
|
||||
* @param {Object} summaryData - 汇总数据
|
||||
*/
|
||||
saveToolHealthCheckSummary(summaryData) {
|
||||
const { checkDate, totalTools, successCount, failedCount, checkDuration } = summaryData;
|
||||
|
||||
// 删除同一天的旧记录
|
||||
this.run(`DELETE FROM tool_health_check_summary WHERE check_date = ?`, [checkDate]);
|
||||
|
||||
// 插入新记录
|
||||
this.run(`INSERT INTO tool_health_check_summary
|
||||
(check_date, total_tools, success_count, failed_count, check_duration, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
[checkDate, totalTools, successCount, failedCount, checkDuration || null, new Date().toISOString()]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取工具健康检查汇总
|
||||
* @param {string} checkDate - 检查日期(可选,不提供则返回最新的)
|
||||
* @returns {Object|null} 汇总数据
|
||||
*/
|
||||
getToolHealthCheckSummary(checkDate = null) {
|
||||
if (checkDate) {
|
||||
return this.get(`SELECT * FROM tool_health_check_summary WHERE check_date = ?`, [checkDate]);
|
||||
} else {
|
||||
return this.get(`SELECT * FROM tool_health_check_summary ORDER BY check_date DESC LIMIT 1`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有健康检查汇总(按日期排序)
|
||||
* @param {number} limit - 限制数量
|
||||
* @returns {Array} 汇总列表
|
||||
*/
|
||||
getAllToolHealthCheckSummaries(limit = 100) {
|
||||
return this.all(`SELECT * FROM tool_health_check_summary ORDER BY check_date DESC LIMIT ?`, [limit]);
|
||||
}
|
||||
|
||||
close() {
|
||||
if (this.db) { this.db.close(); console.log('数据库连接已关闭。'); }
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = AppDatabase;
|
||||
@@ -0,0 +1,395 @@
|
||||
{
|
||||
"comment": "English Language Pack",
|
||||
"nav.home": "Home",
|
||||
"nav.toolbox": "Toolbox",
|
||||
"nav.logs": "Logs",
|
||||
"nav.settings": "Settings",
|
||||
"home.greeting.morning": "Good morning, have a wonderful day.",
|
||||
"home.greeting.noon": "Good afternoon, time for a break.",
|
||||
"home.greeting.afternoon": "Good afternoon, keep up the good work.",
|
||||
"home.greeting.evening": "Good evening, time to relax.",
|
||||
"home.greeting.night": "It's late, please rest.",
|
||||
"home.welcome": "Welcome to YMhut Box, wish you a great day.",
|
||||
"home.announcement": "Announcement",
|
||||
"home.announcement.failed": "Failed to load announcement...",
|
||||
"home.announcement.viewFull": "View Full Announcement",
|
||||
"home.search.placeholder": "Smart Search: Enter query...",
|
||||
"home.search.button": "Search",
|
||||
"home.search.disabled": "Smart Search is currently disabled",
|
||||
"home.search.results.stats": "Found about {count} results (in {time}ms)",
|
||||
"home.search.results.viewFull": "View Full Results (incl. Advanced Options)",
|
||||
"home.search.results.empty.title": "No results found for \"{query}\"",
|
||||
"home.search.results.empty.sub": "Please try different keywords.",
|
||||
"home.search.failed.title": "Search Failed",
|
||||
"home.updates": "Update Log",
|
||||
"home.updates.close": "Close",
|
||||
"home.updates.modal.title": "Full Announcement",
|
||||
"home.updates.empty": "No update details available.",
|
||||
"tool.smartSearch.name": "Smart Search",
|
||||
"tool.smartSearch.desc": "AI aggregated search for high-quality results",
|
||||
"common.loading": "Loading...",
|
||||
"common.search": "Search",
|
||||
"common.backToToolbox": "Back to Toolbox",
|
||||
"common.options": "Advanced Options",
|
||||
"common.error": "Error",
|
||||
"common.loading.tool": "Loading tool module...",
|
||||
"common.notification.title.success": "Success",
|
||||
"common.notification.title.error": "Error",
|
||||
"common.notification.title.info": "Info",
|
||||
"settings.appearance": "Appearance",
|
||||
"settings.updates": "Update Management",
|
||||
"settings.about": "About",
|
||||
"settings.status.monitor": "Status Monitor",
|
||||
"settings.status.cpu": "CPU",
|
||||
"settings.status.mem": "Memory",
|
||||
"settings.status.gpu": "GPU",
|
||||
"settings.status.uptime": "Uptime",
|
||||
"settings.status.normal": "System running normally",
|
||||
"settings.about.motto": "Efforts will not be wasted, success will come",
|
||||
"settings.appearance.title": "Appearance Settings",
|
||||
"settings.appearance.theme": "Theme",
|
||||
"settings.appearance.language": "Language",
|
||||
"settings.appearance.font": "UI Font",
|
||||
"settings.appearance.language.auto": "Auto (Default)",
|
||||
"settings.appearance.language.zh-CN": "简体中文",
|
||||
"tool.jsonFormat.name": "JSON Formatter",
|
||||
"tool.jsonFormat.format": "Format",
|
||||
"tool.jsonFormat.compress": "Compress",
|
||||
"tool.jsonFormat.copy": "Copy Result",
|
||||
"tool.jsonFormat.clear": "Clear",
|
||||
"tool.jsonFormat.inputPlaceholder": "Enter JSON here...",
|
||||
"tool.jsonFormat.outputPlaceholder": "Output result...",
|
||||
"tool.textStatistics.name": "Text Statistics",
|
||||
"tool.textStatistics.inputPlaceholder": "Enter text here...",
|
||||
"tool.textStatistics.chars": "Total Characters",
|
||||
"tool.textStatistics.charsNoSpace": "Characters (no spaces)",
|
||||
"tool.textStatistics.words": "Words",
|
||||
"tool.textStatistics.lines": "Lines",
|
||||
"tool.textStatistics.paragraphs": "Paragraphs",
|
||||
"tool.textStatistics.chinese": "Chinese Characters",
|
||||
"tool.textStatistics.english": "English Characters",
|
||||
"tool.textStatistics.numbers": "Numbers",
|
||||
"translation.progress": "Translating...",
|
||||
"translation.saving": "Saving translation results...",
|
||||
"translation.complete": "Language pack translation completed, restarting application...",
|
||||
"translation.failed": "Translation failed",
|
||||
"translation.hint": "Please wait, using public translation API for translation...",
|
||||
"settings.appearance.language.en-US": "English",
|
||||
"tool.fileHash.name": "File Hash Calculator",
|
||||
"tool.fileHash.selectFile": "Select File",
|
||||
"tool.fileHash.fileName": "File Name",
|
||||
"tool.fileHash.fileSize": "Size",
|
||||
"tool.fileHash.results": "Hash Results",
|
||||
"tool.fileHash.calculating": "Calculating hash...",
|
||||
"tool.fileHash.calculateSuccess": "Hash calculation completed",
|
||||
"tool.caseConverter.name": "Case Converter",
|
||||
"tool.caseConverter.input": "Input Text",
|
||||
"tool.caseConverter.inputPlaceholder": "Enter text to convert...",
|
||||
"tool.caseConverter.output": "Converted Result",
|
||||
"tool.caseConverter.upperCase": "UPPERCASE",
|
||||
"tool.caseConverter.lowerCase": "lowercase",
|
||||
"tool.caseConverter.titleCase": "Title Case",
|
||||
"tool.caseConverter.sentenceCase": "Sentence case",
|
||||
"tool.caseConverter.camelCase": "camelCase",
|
||||
"tool.caseConverter.pascalCase": "PascalCase",
|
||||
"tool.caseConverter.snakeCase": "snake_case",
|
||||
"tool.caseConverter.kebabCase": "kebab-case",
|
||||
"tool.caseConverter.emptyInput": "Please enter text to convert",
|
||||
"tool.randomGenerator.name": "Random Generator",
|
||||
"tool.randomGenerator.number": "Random Number",
|
||||
"tool.randomGenerator.min": "Minimum",
|
||||
"tool.randomGenerator.max": "Maximum",
|
||||
"tool.randomGenerator.count": "Count",
|
||||
"tool.randomGenerator.generate": "Generate",
|
||||
"tool.randomGenerator.result": "Result",
|
||||
"tool.randomGenerator.string": "Random String",
|
||||
"tool.randomGenerator.length": "Length",
|
||||
"tool.randomGenerator.charset": "Character Set",
|
||||
"tool.randomGenerator.uppercase": "Uppercase (A-Z)",
|
||||
"tool.randomGenerator.lowercase": "Lowercase (a-z)",
|
||||
"tool.randomGenerator.numbers": "Numbers (0-9)",
|
||||
"tool.randomGenerator.symbols": "Symbols (!@#$%...)",
|
||||
"tool.randomGenerator.color": "Random Color",
|
||||
"tool.randomGenerator.minMaxError": "Minimum cannot be greater than maximum",
|
||||
"tool.randomGenerator.charsetError": "Please select at least one character set",
|
||||
"common.select": "Select",
|
||||
"common.copy": "Copy",
|
||||
"common.clear": "Clear",
|
||||
"common.copied": "Copied to clipboard",
|
||||
"settings.appearance.language.restartMsg": "Language change will take effect after restart.",
|
||||
"settings.appearance.bg": "Custom Background",
|
||||
"settings.appearance.bg.select": "Select",
|
||||
"settings.appearance.bg.clear": "Clear",
|
||||
"settings.appearance.bg.opacity": "Background Opacity",
|
||||
"settings.appearance.card.opacity": "Card Opacity",
|
||||
"settings.traffic.title": "Traffic Statistics",
|
||||
"settings.traffic.total": "Total Usage",
|
||||
"settings.traffic.chart.empty.title": "No traffic history",
|
||||
"settings.traffic.chart.empty.sub": "Data will be recorded starting today.",
|
||||
"settings.update.title": "Update Management",
|
||||
"settings.update.checkBtn": "Check for Updates",
|
||||
"settings.update.checking": "Checking...",
|
||||
"settings.update.checkDefault": "Click button to check for new version",
|
||||
"settings.about.title": "About & Environment",
|
||||
"settings.about.version": "Client Version",
|
||||
"settings.about.configVersion": "Config Version",
|
||||
"settings.about.developer": "Developer",
|
||||
"settings.about.moreInfo": "View Detailed Environment Parameters",
|
||||
"settings.about.env.title": "Installed Environments",
|
||||
"tool.bmi.title": "BMI Calculator",
|
||||
"tool.bmi.description": "Calculate Body Mass Index to assess health status",
|
||||
"tool.bmi.metric": "Metric (cm/kg)",
|
||||
"tool.bmi.imperial": "Imperial (ft/lbs)",
|
||||
"tool.bmi.height": "Height",
|
||||
"tool.bmi.heightPlaceholder": "Enter height",
|
||||
"tool.bmi.weight": "Weight",
|
||||
"tool.bmi.weightPlaceholder": "Enter weight",
|
||||
"tool.bmi.calculate": "Calculate BMI",
|
||||
"tool.bmi.result": "Result",
|
||||
"tool.bmi.yourBmi": "Your BMI Index",
|
||||
"tool.bmi.enterData": "Please enter height and weight",
|
||||
"tool.bmi.formula": "Weight(kg) / Height(m)²",
|
||||
"tool.bmi.formulaImperial": "Weight(lbs) / Height(in)² × 703",
|
||||
"tool.bmi.ranges": "BMI Range Reference",
|
||||
"tool.bmi.underweight": "Underweight",
|
||||
"tool.bmi.underweightDesc": "Weight is insufficient, consider gaining weight",
|
||||
"tool.bmi.normal": "Normal",
|
||||
"tool.bmi.normalDesc": "Weight is normal, keep it up",
|
||||
"tool.bmi.overweight": "Overweight",
|
||||
"tool.bmi.overweightDesc": "Weight is overweight, consider losing weight",
|
||||
"tool.bmi.obese": "Obese",
|
||||
"tool.bmi.obeseDesc": "Obesity, consider weight loss and consult a doctor",
|
||||
"tool.bmi.enterValidData": "Please enter valid height and weight",
|
||||
"tool.earthquake.title": "Recent Global Earthquakes",
|
||||
"tool.earthquake.description": "Get recent global earthquake information including location, magnitude, time, depth, etc.",
|
||||
"tool.earthquake.refresh": "Refresh Data",
|
||||
"tool.earthquake.loading": "Fetching earthquake information, please wait...",
|
||||
"tool.earthquake.results": "Earthquake Data",
|
||||
"tool.earthquake.rank": "Rank",
|
||||
"tool.earthquake.place": "Place",
|
||||
"tool.earthquake.latitude": "Latitude",
|
||||
"tool.earthquake.longitude": "Longitude",
|
||||
"tool.earthquake.magnitude": "Magnitude",
|
||||
"tool.earthquake.time": "Time",
|
||||
"tool.earthquake.depth": "Depth",
|
||||
"tool.earthquake.queryFailed": "Query failed",
|
||||
"tool.earthquake.sslError": "SSL certificate verification failed. The API server certificate may have expired. Please try again later or contact the administrator.",
|
||||
"tool.earthquake.noData": "No earthquake information found",
|
||||
"tool.earthquake.refreshing": "Refreshing...",
|
||||
"tool.goldPrice.title": "Today's Gold Price",
|
||||
"tool.goldPrice.description": "Get the latest gold prices and detailed information about various gold types",
|
||||
"tool.goldPrice.refresh": "Refresh Data",
|
||||
"tool.goldPrice.currentPrice": "Today's Gold Price",
|
||||
"tool.goldPrice.unit": "CNY/g",
|
||||
"tool.goldPrice.updateTime": "Update Time",
|
||||
"tool.goldPrice.detailList": "Detailed Price List",
|
||||
"tool.goldPrice.rank": "Rank",
|
||||
"tool.goldPrice.name": "Gold Name",
|
||||
"tool.goldPrice.category": "Category",
|
||||
"tool.goldPrice.change": "Change",
|
||||
"tool.goldPrice.high": "High",
|
||||
"tool.goldPrice.low": "Low",
|
||||
"tool.goldPrice.buyHigh": "Highest Buy Price",
|
||||
"tool.goldPrice.sellLow": "Lowest Sell Price",
|
||||
"tool.goldPrice.date": "Date",
|
||||
"tool.goldPrice.loading": "Fetching gold price data, please wait...",
|
||||
"tool.goldPrice.queryFailed": "Query failed",
|
||||
"tool.goldPrice.sslError": "SSL certificate verification failed. The API server certificate may have expired. Please try again later or contact the administrator.",
|
||||
"tool.goldPrice.noData": "No gold price information found",
|
||||
"tool.goldPrice.refreshing": "Refreshing...",
|
||||
"tool.zhihuHot.title": "Zhihu Hot Search",
|
||||
"tool.zhihuHot.description": "Get real-time Zhihu hot search data and trending topics",
|
||||
"tool.zhihuHot.refresh": "Refresh Data",
|
||||
"tool.zhihuHot.hotList": "Hot Search List",
|
||||
"tool.zhihuHot.loading": "Fetching Zhihu hot search data, please wait...",
|
||||
"tool.zhihuHot.queryFailed": "Query failed",
|
||||
"tool.zhihuHot.timeout": "Request timeout, please try again later",
|
||||
"tool.zhihuHot.noData": "No hot search data available, please click refresh to fetch",
|
||||
"tool.zhihuHot.refreshing": "Refreshing...",
|
||||
"tool.zhihuHot.fresh": "Real-time Update",
|
||||
"tool.zhihuHot.view": "View Details",
|
||||
"tool.movieBoxOffice.title": "Maoyan Movie Real-time Box Office",
|
||||
"tool.movieBoxOffice.description": "Get the latest Maoyan movie real-time box office list, including movie names, box office, screening rates, etc.",
|
||||
"tool.movieBoxOffice.refresh": "Refresh Data",
|
||||
"tool.movieBoxOffice.updateTime": "Update Time",
|
||||
"tool.movieBoxOffice.movieCount": "Movie Count",
|
||||
"tool.movieBoxOffice.boxOfficeList": "Box Office Ranking",
|
||||
"tool.movieBoxOffice.rank": "Rank",
|
||||
"tool.movieBoxOffice.movieName": "Movie Name",
|
||||
"tool.movieBoxOffice.boxOffice": "Real-time Box Office",
|
||||
"tool.movieBoxOffice.totalBoxOffice": "Total Box Office",
|
||||
"tool.movieBoxOffice.screeningRate": "Screening Rate",
|
||||
"tool.movieBoxOffice.attendanceRate": "Attendance Rate",
|
||||
"tool.movieBoxOffice.loading": "Fetching box office data, please wait...",
|
||||
"tool.movieBoxOffice.queryFailed": "Query failed",
|
||||
"tool.movieBoxOffice.noData": "No box office data available, please click refresh to fetch",
|
||||
"tool.movieBoxOffice.refreshing": "Refreshing...",
|
||||
"tool.footballNews.title": "Football News",
|
||||
"tool.footballNews.description": "Get the latest football news and trending topics",
|
||||
"tool.footballNews.refresh": "Refresh News",
|
||||
"tool.footballNews.clickToView": "Click to view details",
|
||||
"tool.footballNews.newsList": "News List",
|
||||
"tool.footballNews.backToList": "Back to List",
|
||||
"tool.footballNews.loading": "Fetching football news, please wait...",
|
||||
"tool.footballNews.queryFailed": "Query failed",
|
||||
"tool.footballNews.detailFailed": "Failed to get details",
|
||||
"tool.footballNews.noData": "No news data available",
|
||||
"tool.footballNews.detail": "News Detail",
|
||||
"tool.footballNews.noContent": "No content available",
|
||||
"tool.footballNews.refreshing": "Refreshing...",
|
||||
"tool.trainQuery.title": "Train Schedule Query",
|
||||
"tool.trainQuery.description": "Query train and high-speed rail schedule information nationwide",
|
||||
"tool.trainQuery.departure": "Departure City",
|
||||
"tool.trainQuery.departurePlaceholder": "Enter departure city",
|
||||
"tool.trainQuery.arrival": "Arrival City",
|
||||
"tool.trainQuery.arrivalPlaceholder": "Enter arrival city",
|
||||
"tool.trainQuery.swap": "Swap Cities",
|
||||
"tool.trainQuery.trainType": "Train Type",
|
||||
"tool.trainQuery.all": "All",
|
||||
"tool.trainQuery.highSpeed": "High-Speed Rail",
|
||||
"tool.trainQuery.train": "Train",
|
||||
"tool.trainQuery.date": "Query Date",
|
||||
"tool.trainQuery.count": "Result Count",
|
||||
"tool.trainQuery.items": "items",
|
||||
"tool.trainQuery.query": "Query Trains",
|
||||
"tool.trainQuery.results": "Query Results",
|
||||
"tool.trainQuery.total": "Total",
|
||||
"tool.trainQuery.trains": "trains",
|
||||
"tool.trainQuery.seats": "Seat Information",
|
||||
"tool.trainQuery.yuan": "CNY",
|
||||
"tool.trainQuery.remaining": "Remaining",
|
||||
"tool.trainQuery.noTicket": "No Ticket",
|
||||
"tool.trainQuery.loading": "Querying train schedules, please wait...",
|
||||
"tool.trainQuery.queryFailed": "Query failed",
|
||||
"tool.trainQuery.enterCities": "Please enter departure and arrival cities",
|
||||
"tool.trainQuery.noData": "No train schedule information found",
|
||||
"tool.trainQuery.querying": "Querying...",
|
||||
"settings.toolHealth": "Tool Health Check",
|
||||
"settings.toolHealth.title": "Tool Health Check",
|
||||
"settings.toolHealth.description": "Automatically detect the health status of network tools",
|
||||
"settings.toolHealth.detail": "The system will automatically check the status of tools that use APIs and networks. If a tool cannot be accessed normally (404, 502, 503 errors, etc.), it will be automatically locked. Only checked once per day, you can also manually trigger a check.",
|
||||
"settings.toolHealth.manualCheck": "Manual Check",
|
||||
"settings.toolHealth.startCheck": "Start Check",
|
||||
"settings.toolHealth.checking": "Checking...",
|
||||
"settings.toolHealth.lockedTools": "Locked Tools",
|
||||
"settings.toolHealth.noLocked": "No locked tools",
|
||||
"settings.toolHealth.lastCheck": "Last Check Time",
|
||||
"settings.toolHealth.never": "Never",
|
||||
"settings.toolHealth.networkTools": "Total Network Tools",
|
||||
"settings.toolHealth.tools": "tools",
|
||||
"settings.toolHealth.calculating": "Calculating...",
|
||||
"toolHealthCheck.title": "Tool Health Check",
|
||||
"toolHealthCheck.subtitle": "Checking network tool status...",
|
||||
"toolHealthCheck.page.title": "Tool Health Check",
|
||||
"toolHealthCheck.page.description": "Checking API availability of network tools, please wait...",
|
||||
"toolHealthCheck.page.preparing": "Preparing...",
|
||||
"toolHealthCheck.page.checking": "Checking...",
|
||||
"toolHealthCheck.page.note": "Will automatically jump to toolbox interface after check is complete",
|
||||
"toolHealthCheck.status.success": "Normal",
|
||||
"toolHealthCheck.status.failed": "Failed",
|
||||
"toolHealthCheck.status.checking": "Checking",
|
||||
"toolHealthCheck.status.unknown": "Unknown",
|
||||
"toolHealthCheck.preparing": "Preparing...",
|
||||
"toolHealthCheck.checkingTool": "Checking",
|
||||
"toolHealthCheck.completed": "Completed",
|
||||
"toolHealthCheck.calculating": "Calculating...",
|
||||
"toolHealthCheck.totalTime": "Total Time",
|
||||
"toolHealthCheck.estimatedRemaining": "Estimated Remaining",
|
||||
"toolHealthCheck.minute": "m",
|
||||
"toolHealthCheck.second": "s",
|
||||
"toolHealthCheck.noNetworkTools": "No network tools to check",
|
||||
"toolHealthCheck.error": "Check failed",
|
||||
"toolHealthCheck.status.skip": "Skipped",
|
||||
"toolHealthCheck.reason.sslError": "SSL Certificate Error",
|
||||
"toolHealthCheck.reason.timeout": "Request Timeout",
|
||||
"toolHealthCheck.reason.networkError": "Network Error",
|
||||
"toolHealthCheck.reason.httpError": "HTTP Error",
|
||||
"toolHealthCheck.reason.noData": "No Data",
|
||||
"toolHealthCheck.reason.noValidData": "Invalid Data",
|
||||
"toolHealthCheck.reason.parseError": "Parse Error",
|
||||
"toolHealthCheck.reason.emptyResponse": "Empty Response",
|
||||
"toolHealthCheck.reason.maxRetries": "Max Retries Exceeded",
|
||||
"tool.carInfo.title": "Vehicle Information Query",
|
||||
"tool.carInfo.description": "Query vehicle brand, series, price and other detailed information",
|
||||
"tool.carInfo.vehicleName": "Vehicle Name/Brand",
|
||||
"tool.carInfo.vehicleNamePlaceholder": "Enter vehicle name or brand, e.g.: AITO, BYD",
|
||||
"tool.carInfo.query": "Query Vehicle Info",
|
||||
"tool.carInfo.results": "Query Results",
|
||||
"tool.carInfo.loading": "Querying vehicle information, please wait...",
|
||||
"tool.carInfo.enterVehicleName": "Please enter vehicle name or brand",
|
||||
"tool.carInfo.queryFailed": "Query failed",
|
||||
"tool.carInfo.noResults": "No vehicle information found",
|
||||
"tool.carInfo.querying": "Querying...",
|
||||
"tool.carInfo.unknownBrand": "Unknown Brand",
|
||||
"tool.carInfo.unknownModel": "Unknown Model",
|
||||
"tool.carInfo.priceNegotiable": "Price Negotiable",
|
||||
"tool.carInfo.unknownLevel": "Unknown Level",
|
||||
"tool.cctvNews.title": "CCTV News Hotspots",
|
||||
"tool.cctvNews.description": "Get the latest CCTV news hotspots",
|
||||
"tool.cctvNews.count": "News Count",
|
||||
"tool.cctvNews.fetch": "Fetch News",
|
||||
"tool.cctvNews.newsList": "News List",
|
||||
"tool.cctvNews.loading": "Fetching CCTV news hotspots, please wait...",
|
||||
"tool.cctvNews.fetchFailed": "Failed to fetch news",
|
||||
"tool.cctvNews.noNews": "No news available",
|
||||
"tool.cctvNews.unknownTitle": "Unknown Title",
|
||||
"tool.cctvNews.fetching": "Fetching...",
|
||||
"tool.oilPrice.title": "National Oil Price Query",
|
||||
"tool.oilPrice.description": "Query the latest oil price information for cities nationwide, including 92#, 95#, 98# gasoline and 0# diesel prices",
|
||||
"tool.oilPrice.cityName": "City Name",
|
||||
"tool.oilPrice.cityPlaceholder": "Enter city name, e.g.: Beijing, Shanghai, Guangzhou",
|
||||
"tool.oilPrice.hotCities": "Hot Cities",
|
||||
"tool.oilPrice.query": "Query Price",
|
||||
"tool.oilPrice.loading": "Querying oil price, please wait...",
|
||||
"tool.oilPrice.results": "Query Results",
|
||||
"tool.oilPrice.type": "Oil Type",
|
||||
"tool.oilPrice.price": "Price (Yuan/Liter)",
|
||||
"tool.oilPrice.enterCity": "Please enter city name",
|
||||
"tool.oilPrice.queryFailed": "Query failed",
|
||||
"tool.oilPrice.querying": "Querying...",
|
||||
"tool.oilPrice.gasoline92": "92# Gasoline",
|
||||
"tool.oilPrice.gasoline95": "95# Gasoline",
|
||||
"tool.oilPrice.gasoline98": "98# Gasoline",
|
||||
"tool.oilPrice.diesel0": "0# Diesel",
|
||||
"tool.oilPrice.yuanPerLiter": "Yuan/Liter",
|
||||
"tool.historyToday.title": "Today in History",
|
||||
"tool.historyToday.description": "Explore important events that happened today in history",
|
||||
"tool.historyToday.refresh": "Refresh Data",
|
||||
"tool.historyToday.events": "Historical Events",
|
||||
"tool.historyToday.loading": "Fetching data, please wait...",
|
||||
"tool.historyToday.fetchFailed": "Failed to fetch data",
|
||||
"tool.historyToday.noEvents": "No historical events",
|
||||
"tool.historyToday.refreshing": "Refreshing...",
|
||||
"tool.historyToday.dateFormat": "{year}/{month}/{day}",
|
||||
"tool.historyToday.unknownYear": "Unknown Year",
|
||||
"tool.domainPrice.title": "Domain Price Comparison",
|
||||
"tool.domainPrice.description": "Query domain suffix registration, renewal, and transfer price rankings across platforms",
|
||||
"tool.domainPrice.domain": "Domain Suffix",
|
||||
"tool.domainPrice.domainPlaceholder": "Enter domain suffix, e.g.: cn, com, net",
|
||||
"tool.domainPrice.type": "Query Type",
|
||||
"tool.domainPrice.new": "Registration Price",
|
||||
"tool.domainPrice.renew": "Renewal Price",
|
||||
"tool.domainPrice.transfer": "Transfer Price",
|
||||
"tool.domainPrice.query": "Query Price",
|
||||
"tool.domainPrice.loading": "Querying domain price, please wait...",
|
||||
"tool.domainPrice.enterDomain": "Please enter domain suffix and select query type to start",
|
||||
"tool.domainPrice.results": "Comparison Results",
|
||||
"tool.domainPrice.rank": "Rank",
|
||||
"tool.domainPrice.platform": "Platform",
|
||||
"tool.domainPrice.price": "Price",
|
||||
"tool.domainPrice.queryFailed": "Query failed",
|
||||
"tool.domainPrice.querying": "Querying...",
|
||||
"tool.domainPrice.totalPlatforms": "Total",
|
||||
"tool.domainPrice.platforms": "platforms",
|
||||
"tool.domainPrice.noResults": "No price data available",
|
||||
"tool.techNews.title": "Latest Tech News",
|
||||
"tool.techNews.description": "Get the latest real-time tech news information",
|
||||
"tool.techNews.refresh": "Refresh News",
|
||||
"tool.techNews.newsList": "News List",
|
||||
"tool.techNews.loading": "Fetching real-time tech news, please wait...",
|
||||
"tool.techNews.fetchFailed": "Failed to fetch data",
|
||||
"tool.techNews.noNews": "No news available",
|
||||
"tool.techNews.unknownTitle": "Unknown Title",
|
||||
"tool.techNews.refreshing": "Refreshing...",
|
||||
"tool.techNews.updateTime": "Update Time"
|
||||
}
|
||||
@@ -0,0 +1,395 @@
|
||||
{
|
||||
"comment": "简体中文语言包",
|
||||
"nav.home": "主页",
|
||||
"nav.toolbox": "工具箱",
|
||||
"nav.logs": "日志",
|
||||
"nav.settings": "设置",
|
||||
"home.greeting.morning": "早上好, 新的一天元气满满",
|
||||
"home.greeting.noon": "中午好, 午休时间到了",
|
||||
"home.greeting.afternoon": "下午好, 继续努力吧",
|
||||
"home.greeting.evening": "晚上好, 放松一下吧",
|
||||
"home.greeting.night": "凌晨了, 注意休息哦",
|
||||
"home.welcome": "欢迎使用 YMhut Box, 愿你拥有美好的一天。",
|
||||
"home.announcement": "公告",
|
||||
"home.announcement.failed": "公告加载失败...",
|
||||
"home.announcement.viewFull": "查看完整公告",
|
||||
"home.search.placeholder": "智能搜索:输入查询内容...",
|
||||
"home.search.button": "搜索",
|
||||
"home.search.disabled": "智能搜索工具当前不可用",
|
||||
"home.search.results.stats": "找到约 {count} 条结果 (耗时 {time}ms)",
|
||||
"home.search.results.viewFull": "查看完整结果 (含高级选项)",
|
||||
"home.search.results.empty.title": "未找到关于 \"{query}\" 的结果",
|
||||
"home.search.results.empty.sub": "请尝试更换关键词。",
|
||||
"home.search.failed.title": "搜索失败",
|
||||
"home.updates": "更新日志",
|
||||
"home.updates.close": "关闭",
|
||||
"home.updates.modal.title": "完整公告",
|
||||
"home.updates.empty": "暂无更新详情。",
|
||||
"tool.smartSearch.name": "智能搜索",
|
||||
"tool.smartSearch.desc": "AI 聚合搜索,获取高质量结果",
|
||||
"common.loading": "加载中...",
|
||||
"common.search": "搜索",
|
||||
"common.backToToolbox": "返回工具箱",
|
||||
"common.options": "高级选项",
|
||||
"common.error": "错误",
|
||||
"common.loading.tool": "正在初始化工具模块...",
|
||||
"common.notification.title.success": "成功",
|
||||
"common.notification.title.error": "错误",
|
||||
"common.notification.title.info": "提示",
|
||||
"settings.appearance": "外观",
|
||||
"settings.updates": "更新管理",
|
||||
"settings.about": "关于",
|
||||
"settings.status.monitor": "状态监控",
|
||||
"settings.status.cpu": "CPU",
|
||||
"settings.status.mem": "内存",
|
||||
"settings.status.gpu": "GPU",
|
||||
"settings.status.uptime": "运行时长",
|
||||
"settings.status.normal": "系统运行正常",
|
||||
"settings.about.motto": "功不唐捐,玉汝于成",
|
||||
"settings.appearance.title": "外观设置",
|
||||
"settings.appearance.theme": "界面主题",
|
||||
"settings.appearance.language": "界面语言",
|
||||
"settings.appearance.font": "UI 字体",
|
||||
"settings.appearance.language.auto": "自动 (Auto)",
|
||||
"settings.appearance.language.zh-CN": "简体中文",
|
||||
"tool.jsonFormat.name": "JSON 格式化",
|
||||
"tool.jsonFormat.format": "格式化",
|
||||
"tool.jsonFormat.compress": "压缩",
|
||||
"tool.jsonFormat.copy": "复制结果",
|
||||
"tool.jsonFormat.clear": "清空",
|
||||
"tool.jsonFormat.inputPlaceholder": "在此输入 JSON...",
|
||||
"tool.jsonFormat.outputPlaceholder": "结果输出...",
|
||||
"tool.textStatistics.name": "文本统计",
|
||||
"tool.textStatistics.inputPlaceholder": "在此输入文本...",
|
||||
"tool.textStatistics.chars": "字符总数",
|
||||
"tool.textStatistics.charsNoSpace": "字符(不含空格)",
|
||||
"tool.textStatistics.words": "单词数",
|
||||
"tool.textStatistics.lines": "行数",
|
||||
"tool.textStatistics.paragraphs": "段落数",
|
||||
"tool.textStatistics.chinese": "中文字符",
|
||||
"tool.textStatistics.english": "英文字符",
|
||||
"tool.textStatistics.numbers": "数字",
|
||||
"translation.progress": "正在翻译...",
|
||||
"translation.saving": "正在保存翻译结果...",
|
||||
"translation.complete": "语言包翻译完成,正在重启应用...",
|
||||
"translation.failed": "翻译失败",
|
||||
"translation.hint": "请稍候,正在使用公共翻译接口进行翻译...",
|
||||
"settings.appearance.language.en-US": "English",
|
||||
"tool.fileHash.name": "文件哈希计算",
|
||||
"tool.fileHash.selectFile": "选择文件",
|
||||
"tool.fileHash.fileName": "文件名",
|
||||
"tool.fileHash.fileSize": "大小",
|
||||
"tool.fileHash.results": "哈希结果",
|
||||
"tool.fileHash.calculating": "正在计算哈希值...",
|
||||
"tool.fileHash.calculateSuccess": "哈希值计算完成",
|
||||
"tool.caseConverter.name": "大小写转换",
|
||||
"tool.caseConverter.input": "输入文本",
|
||||
"tool.caseConverter.inputPlaceholder": "在此输入要转换的文本...",
|
||||
"tool.caseConverter.output": "转换结果",
|
||||
"tool.caseConverter.upperCase": "大写",
|
||||
"tool.caseConverter.lowerCase": "小写",
|
||||
"tool.caseConverter.titleCase": "标题",
|
||||
"tool.caseConverter.sentenceCase": "句子",
|
||||
"tool.caseConverter.camelCase": "驼峰",
|
||||
"tool.caseConverter.pascalCase": "帕斯卡",
|
||||
"tool.caseConverter.snakeCase": "蛇形",
|
||||
"tool.caseConverter.kebabCase": "短横线",
|
||||
"tool.caseConverter.emptyInput": "请输入要转换的文本",
|
||||
"tool.randomGenerator.name": "随机生成器",
|
||||
"tool.randomGenerator.number": "随机数字",
|
||||
"tool.randomGenerator.min": "最小值",
|
||||
"tool.randomGenerator.max": "最大值",
|
||||
"tool.randomGenerator.count": "生成数量",
|
||||
"tool.randomGenerator.generate": "生成",
|
||||
"tool.randomGenerator.result": "结果",
|
||||
"tool.randomGenerator.string": "随机字符串",
|
||||
"tool.randomGenerator.length": "长度",
|
||||
"tool.randomGenerator.charset": "字符集",
|
||||
"tool.randomGenerator.uppercase": "大写字母 (A-Z)",
|
||||
"tool.randomGenerator.lowercase": "小写字母 (a-z)",
|
||||
"tool.randomGenerator.numbers": "数字 (0-9)",
|
||||
"tool.randomGenerator.symbols": "符号 (!@#$%...)",
|
||||
"tool.randomGenerator.color": "随机颜色",
|
||||
"tool.randomGenerator.minMaxError": "最小值不能大于最大值",
|
||||
"tool.randomGenerator.charsetError": "请至少选择一种字符集",
|
||||
"common.select": "选择",
|
||||
"common.copy": "复制",
|
||||
"common.clear": "清空",
|
||||
"common.copied": "已复制到剪贴板",
|
||||
"settings.appearance.language.restartMsg": "语言设置将在重启后生效。",
|
||||
"settings.appearance.bg": "自定义背景",
|
||||
"settings.appearance.bg.select": "选择",
|
||||
"settings.appearance.bg.clear": "清除",
|
||||
"settings.appearance.bg.opacity": "背景透明度",
|
||||
"settings.appearance.card.opacity": "卡片透明度",
|
||||
"settings.traffic.title": "流量统计",
|
||||
"settings.traffic.total": "累计使用流量",
|
||||
"settings.traffic.chart.empty.title": "暂无历史流量数据",
|
||||
"settings.traffic.chart.empty.sub": "数据将从今天开始记录",
|
||||
"settings.update.title": "更新管理",
|
||||
"settings.update.checkBtn": "检查更新",
|
||||
"settings.update.checking": "正在检查...",
|
||||
"settings.update.checkDefault": "点击按钮检查新版本",
|
||||
"settings.about.title": "关于与软件环境",
|
||||
"settings.about.version": "客户端版本",
|
||||
"settings.about.configVersion": "配置版本",
|
||||
"settings.about.developer": "开发者",
|
||||
"settings.about.moreInfo": "查看详细环境参数",
|
||||
"settings.about.env.title": "已安装的开发环境",
|
||||
"tool.bmi.title": "BMI 计算器",
|
||||
"tool.bmi.description": "计算身体质量指数,评估健康状况",
|
||||
"tool.bmi.metric": "公制 (cm/kg)",
|
||||
"tool.bmi.imperial": "英制 (ft/lbs)",
|
||||
"tool.bmi.height": "身高",
|
||||
"tool.bmi.heightPlaceholder": "请输入身高",
|
||||
"tool.bmi.weight": "体重",
|
||||
"tool.bmi.weightPlaceholder": "请输入体重",
|
||||
"tool.bmi.calculate": "计算BMI",
|
||||
"tool.bmi.result": "计算结果",
|
||||
"tool.bmi.yourBmi": "您的BMI指数",
|
||||
"tool.bmi.enterData": "请输入身高和体重",
|
||||
"tool.bmi.formula": "体重(kg) / 身高(m)²",
|
||||
"tool.bmi.formulaImperial": "体重(lbs) / 身高(in)² × 703",
|
||||
"tool.bmi.ranges": "BMI范围说明",
|
||||
"tool.bmi.underweight": "偏瘦",
|
||||
"tool.bmi.underweightDesc": "体重不足,建议适当增重",
|
||||
"tool.bmi.normal": "正常",
|
||||
"tool.bmi.normalDesc": "体重正常,继续保持",
|
||||
"tool.bmi.overweight": "超重",
|
||||
"tool.bmi.overweightDesc": "体重超重,建议适当减重",
|
||||
"tool.bmi.obese": "肥胖",
|
||||
"tool.bmi.obeseDesc": "肥胖,建议减重并咨询医生",
|
||||
"tool.bmi.enterValidData": "请输入有效的身高和体重",
|
||||
"tool.earthquake.title": "近期全球地震信息",
|
||||
"tool.earthquake.description": "获取近期全球的地震信息,包括地点、震级、时间、深度等",
|
||||
"tool.earthquake.refresh": "刷新数据",
|
||||
"tool.earthquake.loading": "正在获取地震信息,请稍候...",
|
||||
"tool.earthquake.results": "地震数据",
|
||||
"tool.earthquake.rank": "排名",
|
||||
"tool.earthquake.place": "地点",
|
||||
"tool.earthquake.latitude": "纬度",
|
||||
"tool.earthquake.longitude": "经度",
|
||||
"tool.earthquake.magnitude": "震级",
|
||||
"tool.earthquake.time": "时间",
|
||||
"tool.earthquake.depth": "深度",
|
||||
"tool.earthquake.queryFailed": "查询失败",
|
||||
"tool.earthquake.sslError": "SSL 证书验证失败,可能是 API 服务器证书已过期。请稍后重试或联系管理员。",
|
||||
"tool.earthquake.noData": "未找到地震信息",
|
||||
"tool.earthquake.refreshing": "刷新中...",
|
||||
"tool.goldPrice.title": "今日黄金价格",
|
||||
"tool.goldPrice.description": "获取最新的黄金价格以及各种黄金的详细信息",
|
||||
"tool.goldPrice.refresh": "刷新数据",
|
||||
"tool.goldPrice.currentPrice": "今日黄金价格",
|
||||
"tool.goldPrice.unit": "元/克",
|
||||
"tool.goldPrice.updateTime": "更新时间",
|
||||
"tool.goldPrice.detailList": "详细价格列表",
|
||||
"tool.goldPrice.rank": "序号",
|
||||
"tool.goldPrice.name": "黄金名称",
|
||||
"tool.goldPrice.category": "目录",
|
||||
"tool.goldPrice.change": "涨跌幅",
|
||||
"tool.goldPrice.high": "最高价",
|
||||
"tool.goldPrice.low": "最低价",
|
||||
"tool.goldPrice.buyHigh": "最高买入价",
|
||||
"tool.goldPrice.sellLow": "最低卖出价",
|
||||
"tool.goldPrice.date": "日期",
|
||||
"tool.goldPrice.loading": "正在获取黄金价格数据,请稍候...",
|
||||
"tool.goldPrice.queryFailed": "查询失败",
|
||||
"tool.goldPrice.sslError": "SSL 证书验证失败,可能是 API 服务器证书已过期。请稍后重试或联系管理员。",
|
||||
"tool.goldPrice.noData": "未找到黄金价格信息",
|
||||
"tool.goldPrice.refreshing": "刷新中...",
|
||||
"tool.zhihuHot.title": "知乎热搜榜",
|
||||
"tool.zhihuHot.description": "实时获取知乎热搜榜数据,了解热门话题",
|
||||
"tool.zhihuHot.refresh": "刷新数据",
|
||||
"tool.zhihuHot.hotList": "热搜列表",
|
||||
"tool.zhihuHot.loading": "正在获取知乎热搜榜数据,请稍候...",
|
||||
"tool.zhihuHot.queryFailed": "查询失败",
|
||||
"tool.zhihuHot.timeout": "请求超时,请稍后重试",
|
||||
"tool.zhihuHot.noData": "暂无热搜数据,请点击刷新按钮获取",
|
||||
"tool.zhihuHot.refreshing": "刷新中...",
|
||||
"tool.zhihuHot.fresh": "实时更新",
|
||||
"tool.zhihuHot.view": "查看详情",
|
||||
"tool.movieBoxOffice.title": "猫眼电影实时票房排行",
|
||||
"tool.movieBoxOffice.description": "获取最新猫眼电影实时票房名单,包括电影名称、票房、排片率等信息",
|
||||
"tool.movieBoxOffice.refresh": "刷新数据",
|
||||
"tool.movieBoxOffice.updateTime": "更新时间",
|
||||
"tool.movieBoxOffice.movieCount": "电影数量",
|
||||
"tool.movieBoxOffice.boxOfficeList": "票房排行",
|
||||
"tool.movieBoxOffice.rank": "排名",
|
||||
"tool.movieBoxOffice.movieName": "电影名称",
|
||||
"tool.movieBoxOffice.boxOffice": "实时票房",
|
||||
"tool.movieBoxOffice.totalBoxOffice": "累计票房",
|
||||
"tool.movieBoxOffice.screeningRate": "排片率",
|
||||
"tool.movieBoxOffice.attendanceRate": "上座率",
|
||||
"tool.movieBoxOffice.loading": "正在获取票房数据,请稍候...",
|
||||
"tool.movieBoxOffice.queryFailed": "查询失败",
|
||||
"tool.movieBoxOffice.noData": "暂无票房数据,请点击刷新按钮获取",
|
||||
"tool.movieBoxOffice.refreshing": "刷新中...",
|
||||
"tool.footballNews.title": "足球赛事热点",
|
||||
"tool.footballNews.description": "获取最新的足球赛事热点新闻",
|
||||
"tool.footballNews.refresh": "刷新热点",
|
||||
"tool.footballNews.clickToView": "点击查看详细",
|
||||
"tool.footballNews.newsList": "新闻列表",
|
||||
"tool.footballNews.backToList": "返回列表",
|
||||
"tool.footballNews.loading": "正在获取足球赛事热点,请稍候...",
|
||||
"tool.footballNews.queryFailed": "查询失败",
|
||||
"tool.footballNews.detailFailed": "获取详情失败",
|
||||
"tool.footballNews.noData": "暂无新闻数据",
|
||||
"tool.footballNews.detail": "新闻详情",
|
||||
"tool.footballNews.noContent": "暂无内容",
|
||||
"tool.footballNews.refreshing": "刷新中...",
|
||||
"tool.trainQuery.title": "火车批次查询",
|
||||
"tool.trainQuery.description": "查询全国火车和高铁班次信息",
|
||||
"tool.trainQuery.departure": "出发城市",
|
||||
"tool.trainQuery.departurePlaceholder": "请输入出发城市",
|
||||
"tool.trainQuery.arrival": "到达城市",
|
||||
"tool.trainQuery.arrivalPlaceholder": "请输入到达城市",
|
||||
"tool.trainQuery.swap": "交换城市",
|
||||
"tool.trainQuery.trainType": "列车类型",
|
||||
"tool.trainQuery.all": "全部",
|
||||
"tool.trainQuery.highSpeed": "高铁",
|
||||
"tool.trainQuery.train": "火车",
|
||||
"tool.trainQuery.date": "查询日期",
|
||||
"tool.trainQuery.count": "返回条数",
|
||||
"tool.trainQuery.items": "条",
|
||||
"tool.trainQuery.query": "查询火车班次",
|
||||
"tool.trainQuery.results": "查询结果",
|
||||
"tool.trainQuery.total": "共",
|
||||
"tool.trainQuery.trains": "趟",
|
||||
"tool.trainQuery.seats": "座位信息",
|
||||
"tool.trainQuery.yuan": "元",
|
||||
"tool.trainQuery.remaining": "余票",
|
||||
"tool.trainQuery.noTicket": "无票",
|
||||
"tool.trainQuery.loading": "正在查询火车班次,请稍候...",
|
||||
"tool.trainQuery.queryFailed": "查询失败",
|
||||
"tool.trainQuery.enterCities": "请输入出发和到达城市",
|
||||
"tool.trainQuery.noData": "未找到火车班次信息",
|
||||
"tool.trainQuery.querying": "查询中...",
|
||||
"settings.toolHealth": "工具健康检查",
|
||||
"settings.toolHealth.title": "工具健康检查",
|
||||
"settings.toolHealth.description": "自动检测网络工具的健康状态",
|
||||
"settings.toolHealth.detail": "系统会自动检查使用API和网络工具的状态,如果工具无法正常访问(404、502、503等错误),将自动锁定该工具。每天只检查一次,您也可以手动触发检查。",
|
||||
"settings.toolHealth.manualCheck": "手动启动检查",
|
||||
"settings.toolHealth.startCheck": "开始检查",
|
||||
"settings.toolHealth.checking": "检查中...",
|
||||
"settings.toolHealth.lockedTools": "已锁定工具",
|
||||
"settings.toolHealth.noLocked": "暂无锁定工具",
|
||||
"settings.toolHealth.lastCheck": "上次检查时间",
|
||||
"settings.toolHealth.never": "从未检查",
|
||||
"settings.toolHealth.networkTools": "网络工具总数",
|
||||
"settings.toolHealth.tools": "个工具",
|
||||
"settings.toolHealth.calculating": "计算中...",
|
||||
"toolHealthCheck.title": "工具健康检查",
|
||||
"toolHealthCheck.subtitle": "正在检查网络工具状态...",
|
||||
"toolHealthCheck.page.title": "工具健康检查",
|
||||
"toolHealthCheck.page.description": "正在检查网络工具的API可用性,请稍候...",
|
||||
"toolHealthCheck.page.preparing": "准备中...",
|
||||
"toolHealthCheck.page.checking": "检查中...",
|
||||
"toolHealthCheck.page.note": "检查完成后将自动跳转到工具箱界面",
|
||||
"toolHealthCheck.status.success": "正常",
|
||||
"toolHealthCheck.status.failed": "异常",
|
||||
"toolHealthCheck.status.checking": "检查中",
|
||||
"toolHealthCheck.status.unknown": "未知",
|
||||
"toolHealthCheck.preparing": "准备中...",
|
||||
"toolHealthCheck.checkingTool": "正在检查",
|
||||
"toolHealthCheck.completed": "检查完成",
|
||||
"toolHealthCheck.calculating": "计算中...",
|
||||
"toolHealthCheck.totalTime": "总耗时",
|
||||
"toolHealthCheck.estimatedRemaining": "预计剩余",
|
||||
"toolHealthCheck.minute": "分",
|
||||
"toolHealthCheck.second": "秒",
|
||||
"toolHealthCheck.noNetworkTools": "没有需要检查的网络工具",
|
||||
"toolHealthCheck.error": "检查失败",
|
||||
"toolHealthCheck.status.skip": "跳过",
|
||||
"toolHealthCheck.reason.sslError": "SSL证书错误",
|
||||
"toolHealthCheck.reason.timeout": "请求超时",
|
||||
"toolHealthCheck.reason.networkError": "网络错误",
|
||||
"toolHealthCheck.reason.httpError": "HTTP错误",
|
||||
"toolHealthCheck.reason.noData": "无数据",
|
||||
"toolHealthCheck.reason.noValidData": "数据无效",
|
||||
"toolHealthCheck.reason.parseError": "解析错误",
|
||||
"toolHealthCheck.reason.emptyResponse": "空响应",
|
||||
"toolHealthCheck.reason.maxRetries": "重试次数超限",
|
||||
"tool.carInfo.title": "车辆信息查询",
|
||||
"tool.carInfo.description": "查询车辆品牌、系列、价格等详细信息",
|
||||
"tool.carInfo.vehicleName": "车辆名称/品牌",
|
||||
"tool.carInfo.vehicleNamePlaceholder": "请输入车辆名称或品牌,例如:问界、比亚迪",
|
||||
"tool.carInfo.query": "查询车辆信息",
|
||||
"tool.carInfo.results": "查询结果",
|
||||
"tool.carInfo.loading": "正在查询车辆信息,请稍候...",
|
||||
"tool.carInfo.enterVehicleName": "请输入车辆名称或品牌",
|
||||
"tool.carInfo.queryFailed": "查询失败",
|
||||
"tool.carInfo.noResults": "未找到车辆信息",
|
||||
"tool.carInfo.querying": "查询中...",
|
||||
"tool.carInfo.unknownBrand": "未知品牌",
|
||||
"tool.carInfo.unknownModel": "未知型号",
|
||||
"tool.carInfo.priceNegotiable": "价格面议",
|
||||
"tool.carInfo.unknownLevel": "未知级别",
|
||||
"tool.cctvNews.title": "央视新闻热点",
|
||||
"tool.cctvNews.description": "获取最新的央视新闻热点资讯",
|
||||
"tool.cctvNews.count": "新闻数量",
|
||||
"tool.cctvNews.fetch": "获取新闻",
|
||||
"tool.cctvNews.newsList": "新闻列表",
|
||||
"tool.cctvNews.loading": "正在获取央视新闻热点,请稍候...",
|
||||
"tool.cctvNews.fetchFailed": "获取新闻失败",
|
||||
"tool.cctvNews.noNews": "暂无新闻",
|
||||
"tool.cctvNews.unknownTitle": "未知标题",
|
||||
"tool.cctvNews.fetching": "获取中...",
|
||||
"tool.oilPrice.title": "全国油价查询",
|
||||
"tool.oilPrice.description": "查询全国各城市的最新油价信息,包括92#、95#、98#汽油和0#柴油价格",
|
||||
"tool.oilPrice.cityName": "城市名称",
|
||||
"tool.oilPrice.cityPlaceholder": "请输入城市名称,如 北京、上海、广州",
|
||||
"tool.oilPrice.hotCities": "热门城市",
|
||||
"tool.oilPrice.query": "查询油价",
|
||||
"tool.oilPrice.loading": "正在查询油价,请稍候...",
|
||||
"tool.oilPrice.results": "查询结果",
|
||||
"tool.oilPrice.type": "油品类型",
|
||||
"tool.oilPrice.price": "价格(元/升)",
|
||||
"tool.oilPrice.enterCity": "请输入城市名称",
|
||||
"tool.oilPrice.queryFailed": "查询失败",
|
||||
"tool.oilPrice.querying": "查询中...",
|
||||
"tool.oilPrice.gasoline92": "92#汽油",
|
||||
"tool.oilPrice.gasoline95": "95#汽油",
|
||||
"tool.oilPrice.gasoline98": "98#汽油",
|
||||
"tool.oilPrice.diesel0": "0#柴油",
|
||||
"tool.oilPrice.yuanPerLiter": "元/升",
|
||||
"tool.historyToday.title": "历史上的今天",
|
||||
"tool.historyToday.description": "探索历史上今天发生的重要事件",
|
||||
"tool.historyToday.refresh": "刷新数据",
|
||||
"tool.historyToday.events": "历史事件",
|
||||
"tool.historyToday.loading": "正在获取数据,请稍候...",
|
||||
"tool.historyToday.fetchFailed": "获取数据失败",
|
||||
"tool.historyToday.noEvents": "暂无历史事件",
|
||||
"tool.historyToday.refreshing": "刷新中...",
|
||||
"tool.historyToday.dateFormat": "{year}年{month}月{day}日",
|
||||
"tool.historyToday.unknownYear": "未知年份",
|
||||
"tool.domainPrice.title": "域名比价查询",
|
||||
"tool.domainPrice.description": "查询域名后缀在各平台的注册、续费、转入价格排行",
|
||||
"tool.domainPrice.domain": "域名后缀",
|
||||
"tool.domainPrice.domainPlaceholder": "请输入域名后缀,如:cn、com、net",
|
||||
"tool.domainPrice.type": "查询类型",
|
||||
"tool.domainPrice.new": "注册价格",
|
||||
"tool.domainPrice.renew": "续费价格",
|
||||
"tool.domainPrice.transfer": "转入价格",
|
||||
"tool.domainPrice.query": "查询价格",
|
||||
"tool.domainPrice.loading": "正在查询域名价格,请稍候...",
|
||||
"tool.domainPrice.enterDomain": "请输入域名后缀并选择查询类型开始查询",
|
||||
"tool.domainPrice.results": "比价结果",
|
||||
"tool.domainPrice.rank": "排名",
|
||||
"tool.domainPrice.platform": "平台",
|
||||
"tool.domainPrice.price": "价格",
|
||||
"tool.domainPrice.queryFailed": "查询失败",
|
||||
"tool.domainPrice.querying": "查询中...",
|
||||
"tool.domainPrice.totalPlatforms": "共",
|
||||
"tool.domainPrice.platforms": "个平台",
|
||||
"tool.domainPrice.noResults": "暂无价格数据",
|
||||
"tool.techNews.title": "最新科技资讯",
|
||||
"tool.techNews.description": "获取当前时间的最新实时科技资讯信息",
|
||||
"tool.techNews.refresh": "刷新资讯",
|
||||
"tool.techNews.newsList": "资讯列表",
|
||||
"tool.techNews.loading": "正在获取实时科技资讯,请稍候...",
|
||||
"tool.techNews.fetchFailed": "获取数据失败",
|
||||
"tool.techNews.noNews": "暂无资讯",
|
||||
"tool.techNews.unknownTitle": "未知标题",
|
||||
"tool.techNews.refreshing": "刷新中...",
|
||||
"tool.techNews.updateTime": "更新时间"
|
||||
}
|
||||
+1448
File diff suppressed because it is too large
Load Diff
Generated
+5235
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,96 @@
|
||||
{
|
||||
"name": "ymhut-box",
|
||||
"version": "1.4.39",
|
||||
"description": "A multi-functional toolbox developed by YMHUT",
|
||||
"main": "main.js",
|
||||
"scripts": {
|
||||
"start:dev": "electron .",
|
||||
"rebuild": "npx electron-rebuild -f -w better-sqlite3 --version 25.8.1",
|
||||
"build": "npm run rebuild && node ./build-scripts/build.js",
|
||||
"start": "npm run build && electron ./app_dist",
|
||||
"dist": "npm run build && electron-builder",
|
||||
"dist:win": "npm run build && electron-builder --win",
|
||||
"dist:linux": "npm run build && electron-builder --linux"
|
||||
},
|
||||
"keywords": [
|
||||
"electron",
|
||||
"tools",
|
||||
"ymhut"
|
||||
],
|
||||
"author": "YMHUT",
|
||||
"devDependencies": {
|
||||
"electron": "25.8.1",
|
||||
"electron-builder": "^24.13.3",
|
||||
"electron-rebuild": "^3.2.9",
|
||||
"fs-extra": "^11.3.2",
|
||||
"terser": "^5.31.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"adm-zip": "^0.5.10",
|
||||
"archiver": "^5.3.1",
|
||||
"better-sqlite3": "9.4.5",
|
||||
"iconv-lite": "^0.6.3",
|
||||
"ini": "^4.0.0",
|
||||
"sudo-prompt": "^9.2.1",
|
||||
"systeminformation": "^5.22.11"
|
||||
},
|
||||
"build": {
|
||||
"appId": "cn.ymhut.box",
|
||||
"productName": "YmhutBox",
|
||||
"asar": true,
|
||||
"directories": {
|
||||
"app": "app_dist",
|
||||
"output": "installer"
|
||||
},
|
||||
"extraResources": [
|
||||
{
|
||||
"from": "./lang",
|
||||
"to": "lang"
|
||||
},
|
||||
{
|
||||
"from": "./build-scripts/config-template.ini",
|
||||
"to": "config-template.ini"
|
||||
}
|
||||
],
|
||||
"files": [
|
||||
"**/*",
|
||||
"!node_modules/*",
|
||||
"node_modules/better-sqlite3/**/*",
|
||||
"node_modules/iconv-lite/**/*",
|
||||
"node_modules/sudo-prompt/**/*",
|
||||
"node_modules/systeminformation/**/*",
|
||||
"node_modules/ini/**/*",
|
||||
"node_modules/adm-zip/**/*",
|
||||
"node_modules/archiver/**/*"
|
||||
],
|
||||
"win": {
|
||||
"target": "nsis",
|
||||
"icon": "src/assets/icon.ico",
|
||||
"requestedExecutionLevel": "asInvoker"
|
||||
},
|
||||
"nsis": {
|
||||
"include": "build-scripts/installer.nsh",
|
||||
"oneClick": false,
|
||||
"allowToChangeInstallationDirectory": true,
|
||||
"runAfterFinish": false,
|
||||
"installerIcon": "src/assets/icon.ico",
|
||||
"uninstallerIcon": "src/assets/icon.ico",
|
||||
"installerHeaderIcon": "src/assets/icon.ico",
|
||||
"createDesktopShortcut": true,
|
||||
"createStartMenuShortcut": true,
|
||||
"warningsAsErrors": false,
|
||||
"unicode": true
|
||||
},
|
||||
"linux": {
|
||||
"target": [
|
||||
"deb",
|
||||
"rpm",
|
||||
"AppImage"
|
||||
],
|
||||
"icon": "src/assets/icon.png",
|
||||
"category": "Utility",
|
||||
"synopsis": "Toolbox by YMHUT",
|
||||
"description": "A multi-functional toolbox developed by YMHUT"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
// preload.js
|
||||
const { contextBridge, ipcRenderer } = require('electron');
|
||||
|
||||
contextBridge.exposeInMainWorld('electronAPI', {
|
||||
|
||||
runInitialization: () => ipcRenderer.invoke('run-initialization'),
|
||||
onInitProgress: (callback) => ipcRenderer.on('init-progress', (_event, value) => callback(value)),
|
||||
initializationComplete: (result) => ipcRenderer.send('initialization-complete', result),
|
||||
|
||||
onInitialData: (callback) => ipcRenderer.on('initial-data', (_event, value) => callback(value)),
|
||||
|
||||
// [新增] 字体下载进度监听
|
||||
onFontDownloadProgress: (callback) => ipcRenderer.on('font-download-progress', (_event, value) => callback(value)),
|
||||
getLanguageConfig: () => ipcRenderer.invoke('get-language-config'),
|
||||
saveLanguageConfig: (lang) => ipcRenderer.invoke('save-language-config', lang),
|
||||
saveTranslatedLanguagePack: (langCode, translatedPack) => ipcRenderer.invoke('save-translated-language-pack', langCode, translatedPack),
|
||||
requestAdminRelaunch: () => ipcRenderer.invoke('check-and-relaunch-as-admin'),
|
||||
|
||||
minimizeWindow: () => ipcRenderer.send('window-minimize'),
|
||||
maximizeWindow: () => ipcRenderer.send('window-maximize'),
|
||||
closeWindow: () => ipcRenderer.send('window-close'),
|
||||
relaunchApp: () => ipcRenderer.send('relaunch-app'),
|
||||
|
||||
openAcknowledgementsWindow: (theme, versions) => ipcRenderer.send('open-acknowledgements-window', theme, versions),
|
||||
closeCurrentWindow: () => ipcRenderer.send('close-current-window'),
|
||||
|
||||
openSecretWindow: (theme) => ipcRenderer.send('open-secret-window', theme),
|
||||
openToolWindow: (viewName, toolId, theme) => ipcRenderer.send('open-tool-window', viewName, toolId, theme),
|
||||
secretWindowMinimize: () => ipcRenderer.send('secret-window-minimize'),
|
||||
secretWindowMaximize: () => ipcRenderer.send('secret-window-maximize'),
|
||||
|
||||
logAction: (logData) => ipcRenderer.invoke('log-action', logData),
|
||||
getLogs: (filterDate) => ipcRenderer.invoke('get-logs', filterDate),
|
||||
clearLogs: () => ipcRenderer.invoke('clear-logs'),
|
||||
getTrafficStats: () => ipcRenderer.invoke('get-traffic-stats'),
|
||||
addTraffic: (bytes) => ipcRenderer.invoke('add-traffic', bytes),
|
||||
reportTraffic: (bytes) => ipcRenderer.send('report-traffic', bytes),
|
||||
|
||||
getAppVersion: () => ipcRenderer.invoke('get-app-version'),
|
||||
|
||||
checkUpdates: () => ipcRenderer.invoke('check-updates'),
|
||||
downloadUpdate: (url) => ipcRenderer.invoke('download-update', url),
|
||||
cancelDownload: () => ipcRenderer.invoke('cancel-download'),
|
||||
onDownloadProgress: (callback) => ipcRenderer.on('download-progress', (_event, value) => callback(value)),
|
||||
onNetworkSpeedUpdate: (callback) => ipcRenderer.on('network-speed-update', (_event, value) => callback(value)),
|
||||
|
||||
// [新增] 安装更新接口
|
||||
installUpdate: (filePath) => ipcRenderer.invoke('install-update', filePath),
|
||||
|
||||
openFile: (filePath) => ipcRenderer.invoke('open-file', filePath),
|
||||
showItemInFolder: (filePath) => ipcRenderer.invoke('show-item-in-folder', filePath),
|
||||
openExternalLink: (url) => ipcRenderer.invoke('open-external-link', url),
|
||||
|
||||
saveMedia: (data) => ipcRenderer.invoke('save-media', data),
|
||||
|
||||
setTheme: (theme) => ipcRenderer.invoke('set-theme', theme),
|
||||
setGlobalVolume: (volume) => ipcRenderer.invoke('set-global-volume'),
|
||||
selectBackgroundImage: () => ipcRenderer.invoke('select-background-image'),
|
||||
clearBackgroundImage: () => ipcRenderer.invoke('clear-background-image'),
|
||||
setBackgroundOpacity: (opacity) => ipcRenderer.invoke('set-background-opacity'),
|
||||
setCardOpacity: (opacity) => ipcRenderer.invoke('set-card-opacity'),
|
||||
|
||||
getSystemInfo: () => ipcRenderer.invoke('get-system-info'),
|
||||
getMemoryUpdate: () => ipcRenderer.invoke('get-memory-update'),
|
||||
getRealtimeStats: () => ipcRenderer.invoke('get-realtime-stats'),
|
||||
getGpuStats: () => ipcRenderer.invoke('get-gpu-stats'),
|
||||
getTrafficHistory: () => ipcRenderer.invoke('get-traffic-history'),
|
||||
|
||||
getCachedIcon: (toolId, url) => ipcRenderer.invoke('get-cached-icon', toolId, url),
|
||||
// [新增] 字体下载接口
|
||||
downloadFont: (data) => ipcRenderer.invoke('download-font', data),
|
||||
|
||||
compressFiles: (data) => ipcRenderer.invoke('compress-files', data),
|
||||
decompressFile: (data) => ipcRenderer.invoke('decompress-file', data),
|
||||
onArchiveProgress: (callback) => ipcRenderer.on('archive-progress', (_event, value) => callback(value)),
|
||||
onArchiveLog: (callback) => ipcRenderer.on('archive-log', (_event, value) => callback(value)),
|
||||
|
||||
launchSystemTool: (command) => ipcRenderer.send('launch-system-tool', command),
|
||||
showConfirmationDialog: (options) => ipcRenderer.invoke('show-confirmation-dialog', options),
|
||||
checkSecretAccess: () => ipcRenderer.invoke('check-secret-access'),
|
||||
recordSecretFailure: () => ipcRenderer.invoke('record-secret-failure'),
|
||||
resetSecretAttempts: () => ipcRenderer.invoke('reset-secret-attempts'),
|
||||
checkAndRelaunchAsAdmin: () => ipcRenderer.invoke('check-and-relaunch-as-admin'),
|
||||
requestNewWindow: (url, options) => ipcRenderer.send('request-new-window', url, options),
|
||||
checkUserAgreement: () => ipcRenderer.invoke('check-user-agreement'),
|
||||
confirmUserAgreement: () => ipcRenderer.invoke('confirm-user-agreement'),
|
||||
selectDirectory: () => ipcRenderer.invoke('select-directory'),
|
||||
downloadResourceAtomic: (data) => ipcRenderer.invoke('download-resource-atomic', data),
|
||||
downloadFileDirect: (data) => ipcRenderer.invoke('download-file-direct', data),
|
||||
cleanEmptyDirs: (path) => ipcRenderer.invoke('clean-empty-dirs', path),
|
||||
setConfigKey: (key, value) => ipcRenderer.invoke('set-config-key', { key, value }),
|
||||
openDownloadWindow: (url) => ipcRenderer.invoke('open-download-window', url),
|
||||
calculateHash: (buffer, algorithm) => ipcRenderer.invoke('calculate-hash', buffer, algorithm),
|
||||
|
||||
// [重构] 接口健康度校验 API
|
||||
getApiHealthStatus: (apiId) => ipcRenderer.invoke('get-api-health-status', apiId),
|
||||
recordApiCall: (callData) => ipcRenderer.invoke('record-api-call', callData),
|
||||
recordApiHealth: (healthData) => ipcRenderer.invoke('record-api-health', healthData),
|
||||
getApiReservations: (category) => ipcRenderer.invoke('get-api-reservations', category),
|
||||
registerApiReservation: (reservation) => ipcRenderer.invoke('register-api-reservation', reservation),
|
||||
updateApiReservation: (apiId, updates) => ipcRenderer.invoke('update-api-reservation', apiId, updates),
|
||||
|
||||
// [重构] 工具健康检查数据库操作 API
|
||||
recordToolHealthCheckResult: (resultData) => ipcRenderer.invoke('record-tool-health-check-result', resultData),
|
||||
getLatestToolHealthCheckResult: (toolId) => ipcRenderer.invoke('get-latest-tool-health-check-result', toolId),
|
||||
getToolHealthCheckHistory: (toolId, limit) => ipcRenderer.invoke('get-tool-health-check-history', toolId, limit),
|
||||
getToolHealthCheckResultsByDate: (checkDate) => ipcRenderer.invoke('get-tool-health-check-results-by-date', checkDate),
|
||||
getLastHealthCheckDate: () => ipcRenderer.invoke('get-last-health-check-date'),
|
||||
shouldForceHealthCheck: (days) => ipcRenderer.invoke('should-force-health-check', days),
|
||||
saveToolHealthCheckSummary: (summaryData) => ipcRenderer.invoke('save-tool-health-check-summary', summaryData),
|
||||
getToolHealthCheckSummary: (checkDate) => ipcRenderer.invoke('get-tool-health-check-summary', checkDate),
|
||||
getAllToolHealthCheckSummaries: (limit) => ipcRenderer.invoke('get-all-tool-health-check-summaries', limit),
|
||||
|
||||
});
|
||||
@@ -0,0 +1,224 @@
|
||||
{
|
||||
"layout_version": "1.0.8",
|
||||
"last_updated": "2025-09-9T17:45:00Z",
|
||||
"categories": [
|
||||
{
|
||||
"id": "image",
|
||||
"name": "随机图片",
|
||||
"icon": "fas fa-image",
|
||||
"enabled": true,
|
||||
"layout": {
|
||||
"columns": 1,
|
||||
"aspect_ratio": "16:9",
|
||||
"show_preview": true,
|
||||
"transition_effect": "fade"
|
||||
},
|
||||
"subcategories": [
|
||||
{
|
||||
"id": "xjj",
|
||||
"name": "小姐姐",
|
||||
"description": "精选小姐姐图片",
|
||||
"api_url": "https://xjj.ymhut.bid/xjj",
|
||||
"thumbnail_url": "https://pic2.zhimg.com/v2-379be37e0b4d372aa60046f9ce771f12_r.jpg",
|
||||
"supported_formats": ["jpg", "jpeg", "png", "webp"],
|
||||
"refresh_interval": 30,
|
||||
"downloadable": true
|
||||
},
|
||||
{
|
||||
"id": "baisi",
|
||||
"name": "白丝",
|
||||
"description": "随机白丝图片",
|
||||
"api_url": "https://api.ppqa.cn/api/baisi",
|
||||
"thumbnail_url": "https://n.sinaimg.cn/sinacn10112/760/w640h920/20200126/4b00-innckcf8208822.jpg",
|
||||
"supported_formats": ["jpg", "jpeg", "png", "webp"],
|
||||
"refresh_interval": 30,
|
||||
"downloadable": true
|
||||
},
|
||||
{
|
||||
"id": "heisi",
|
||||
"name": "黑丝",
|
||||
"description": "随机黑丝图片",
|
||||
"api_url": "https://v2.xxapi.cn/api/heisi?return=302",
|
||||
"thumbnail_url": "https://img-baofun.zhhainiao.com/pcwallpaper_ugc_mobile/static/6902725194a8c081767ee82373d3b017.jpeg",
|
||||
"supported_formats": ["jpg", "jpeg", "png", "webp"],
|
||||
"refresh_interval": 30,
|
||||
"downloadable": true
|
||||
},
|
||||
{
|
||||
"id": "acg",
|
||||
"name": "二次元(4K)",
|
||||
"description": "二次元类图(包含动漫、漫画、游戏)",
|
||||
"api_url": "https://v2.xxapi.cn/api/random4kPic?type=acg&return=302",
|
||||
"thumbnail_url": "https://www.sgpjbg.com/FileUpload/News/c358f121-6683-490b-beed-6debb44e4824.jpg",
|
||||
"supported_formats": ["jpg", "jpeg", "png", "webp"],
|
||||
"refresh_interval": 30,
|
||||
"downloadable": true
|
||||
},
|
||||
{
|
||||
"id": "miku",
|
||||
"name": "初音未来",
|
||||
"description": "miku的随机图",
|
||||
"api_url": "https://apii.ctose.cn/api/cy/api/",
|
||||
"thumbnail_url": "https://apii.ctose.cn/api/cy/api/",
|
||||
"supported_formats": ["jpg", "jpeg", "png", "webp"],
|
||||
"refresh_interval": 30,
|
||||
"downloadable": true
|
||||
},
|
||||
{
|
||||
"id": "猫羽雫",
|
||||
"name": "猫羽雫",
|
||||
"description": "猫羽雫的随机图",
|
||||
"api_url": "https://api.suyanw.cn/api/mao.php",
|
||||
"thumbnail_url": "https://api.suyanw.cn/api/mao.php",
|
||||
"supported_formats": ["jpg", "jpeg", "png", "webp"],
|
||||
"refresh_interval": 30,
|
||||
"downloadable": true
|
||||
},
|
||||
{
|
||||
"id": "wappller",
|
||||
"name": "高清壁纸",
|
||||
"description": "随机高清壁纸",
|
||||
"api_url": "https://api.suyanw.cn/api/scenery.php",
|
||||
"thumbnail_url": "https://api.suyanw.cn/api/scenery.php",
|
||||
"supported_formats": ["jpg", "jpeg", "png", "webp"],
|
||||
"refresh_interval": 30,
|
||||
"downloadable": true
|
||||
},
|
||||
{
|
||||
"id": "动漫",
|
||||
"name": "动漫",
|
||||
"description": "随机动漫壁纸",
|
||||
"api_url": "https://api.nsmao.net/api/Img/query?key=m0mdNC37AkL62mH8AqFnWe6kf4&sort=acg",
|
||||
"thumbnail_url": "https://api.nsmao.net/api/Img/query?key=m0mdNC37AkL62mH8AqFnWe6kf4&sort=acg",
|
||||
"supported_formats": ["jpg", "jpeg", "png", "webp"],
|
||||
"refresh_interval": 30,
|
||||
"downloadable": true
|
||||
},
|
||||
{
|
||||
"id": "小姐姐",
|
||||
"name": "小姐姐",
|
||||
"description": "奶思猫小姐姐壁纸",
|
||||
"api_url": "https://api.nsmao.net/api/Img/query?key=m0mdNC37AkL62mH8AqFnWe6kf4&sort=belle",
|
||||
"thumbnail_url": "https://api.nsmao.net/api/Img/query?key=m0mdNC37AkL62mH8AqFnWe6kf4&sort=belle",
|
||||
"supported_formats": ["jpg", "jpeg", "png", "webp"],
|
||||
"refresh_interval": 30,
|
||||
"downloadable": true
|
||||
},
|
||||
{
|
||||
"id": "随机动漫图片",
|
||||
"name": "随机动漫图片",
|
||||
"description": "二次元随机动漫图片PE版",
|
||||
"api_url": "https://api.suyanw.cn/api/comic2.php",
|
||||
"thumbnail_url": "https://api.suyanw.cn/api/comic2.php",
|
||||
"supported_formats": ["jpg", "jpeg", "png", "webp"],
|
||||
"refresh_interval": 30,
|
||||
"downloadable": true
|
||||
},
|
||||
{
|
||||
"id": "随机动漫图片",
|
||||
"name": "随机动漫图片",
|
||||
"description": "二次元随机动漫图片自动适应版",
|
||||
"api_url": "https://api.suyanw.cn/api/comic3.php",
|
||||
"thumbnail_url": "https://api.suyanw.cn/api/comic3.php",
|
||||
"supported_formats": ["jpg", "jpeg", "png", "webp"],
|
||||
"refresh_interval": 30,
|
||||
"downloadable": true
|
||||
},
|
||||
{
|
||||
"id": "随机妹子",
|
||||
"name": "随机妹子",
|
||||
"description": "随机风格妹子图片",
|
||||
"api_url": "https://api.suyanw.cn/api/meizi.php",
|
||||
"thumbnail_url": "https://api.suyanw.cn/api/meizi.php",
|
||||
"supported_formats": ["jpg", "jpeg", "png", "webp"],
|
||||
"refresh_interval": 30,
|
||||
"downloadable": true
|
||||
},
|
||||
{
|
||||
"id": "随机白丝妹子",
|
||||
"name": "随机姐妹",
|
||||
"description": "白丝风格妹子图片",
|
||||
"api_url": "https://v1.nsuuu.com/api/baisi",
|
||||
"thumbnail_url": "https://tse1.mm.bing.net/th/id/OIP.f8uUIvf0Ppa9pMFKPaMz9gHaFj?cb=ucfimg2&ucfimg=1&rs=1&pid=ImgDetMain&o=7&rm=3",
|
||||
"supported_formats": ["jpg", "jpeg", "png", "webp"],
|
||||
"refresh_interval": 30,
|
||||
"downloadable": true
|
||||
},
|
||||
{
|
||||
"id": "随机jk妹子",
|
||||
"name": "随机jk姐妹",
|
||||
"description": "随机jk裙妹子图片",
|
||||
"api_url": "https://v1.nsuuu.com/api/jk",
|
||||
"thumbnail_url": "https://tse3.mm.bing.net/th/id/OIP.ogV1dqaN5E6bddzwKow2bgHaJ3?cb=ucfimg2&ucfimg=1&rs=1&pid=ImgDetMain&o=7&rm=3",
|
||||
"supported_formats": ["jpg", "jpeg", "png", "webp"],
|
||||
"refresh_interval": 30,
|
||||
"downloadable": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "video",
|
||||
"name": "随机视频",
|
||||
"icon": "fas fa-video",
|
||||
"enabled": true,
|
||||
"layout": {
|
||||
"columns": 1,
|
||||
"aspect_ratio": "16:9",
|
||||
"show_preview": true,
|
||||
"auto_play": false,
|
||||
"transition_effect": "slide"
|
||||
},
|
||||
"subcategories": [
|
||||
{
|
||||
"id": "radom_xjj_leixing",
|
||||
"name": "小姐姐不同风格视频",
|
||||
"description": "随机风格类型视频",
|
||||
"api_url": "https://v2.xxapi.cn/api/meinv?return=302",
|
||||
"thumbnail_url": "https://n.sinaimg.cn/sinacn19/176/w888h888/20181119/0c26-hmhhnqt1050818.jpg",
|
||||
"supported_formats": ["mp4", "webm"],
|
||||
"refresh_interval": 60,
|
||||
"downloadable": true
|
||||
},
|
||||
{
|
||||
"id": "radom_xjj_short",
|
||||
"name": "短视频",
|
||||
"description": "随机风格小姐姐的视频",
|
||||
"api_url": "https://api.dwo.cc/api/v",
|
||||
"thumbnail_url": "https://weather-real.oss-cn-shanghai.aliyuncs.com/weather/2025-06-17/1750091559255t7FNOX.jpg",
|
||||
"supported_formats": ["mp4", "webm"],
|
||||
"refresh_interval": 60,
|
||||
"downloadable": true
|
||||
},
|
||||
{
|
||||
"id": "radom_xjj_mv",
|
||||
"name": "JK视频",
|
||||
"description": "随机一条小姐姐穿JK的视频",
|
||||
"api_url": "https://api.suyanw.cn/api/jksp.php",
|
||||
"thumbnail_url": "https://ts2.tc.mm.bing.net/th/id/OIP-C.zn32XKqCBw6PziENqqKqQAHaJ3?cb=ucfimg2&ucfimg=1&rs=1&pid=ImgDetMain&o=7&rm=3",
|
||||
"supported_formats": ["mp4", "webm"],
|
||||
"refresh_interval": 60,
|
||||
"downloadable": true
|
||||
},
|
||||
{
|
||||
"id": "radom_xjj_menv",
|
||||
"name": "随机美女",
|
||||
"description": "随机一条小姐姐视频",
|
||||
"api_url": "https://api.suyanw.cn/api/jksp.php",
|
||||
"thumbnail_url": "https://c-ssl.dtstatic.com/uploads/item/201707/20/20170720150800_PWUKd.thumb.1000_0.png",
|
||||
"supported_formats": ["mp4", "webm"],
|
||||
"refresh_interval": 60,
|
||||
"downloadable": true
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"ui_config": {
|
||||
"dark_mode": false,
|
||||
"show_thumbnails": false,
|
||||
"default_view": "grid",
|
||||
"animations": {
|
||||
"transition_effect": "fade",
|
||||
"duration": 300
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
{
|
||||
"comment": "工具状态控制文件。 'enabled: false' 将禁用该工具。",
|
||||
"comment_health_check": "[健康检查预留位置] 每个工具可以包含以下字段:enabled, message, healthStatus, lastHealthCheck, lockReason, metadata, tags, priority",
|
||||
"smart-search": {
|
||||
"enabled": true,
|
||||
"message": "",
|
||||
"healthStatus": "unknown",
|
||||
"lastHealthCheck": null,
|
||||
"lockReason": null,
|
||||
"metadata": {},
|
||||
"tags": [],
|
||||
"priority": 0
|
||||
},
|
||||
"ai-translation": {
|
||||
"enabled": true,
|
||||
"message": ""
|
||||
},
|
||||
"ip-query": {
|
||||
"enabled": true,
|
||||
"message": ""
|
||||
},
|
||||
"bili-hot-ranking": {
|
||||
"enabled": true,
|
||||
"message": "B站热搜接口正在维护,预计短时间内不会恢复。"
|
||||
},
|
||||
"baidu-hot": {
|
||||
"enabled": true,
|
||||
"message": "百度热榜接口正在维护,预计短时间内不会恢复。"
|
||||
},
|
||||
"ip-info": {
|
||||
"enabled": true,
|
||||
"message": ""
|
||||
},
|
||||
"dns-query": {
|
||||
"enabled": true,
|
||||
"message": ""
|
||||
},
|
||||
"image-processor": {
|
||||
"enabled": false,
|
||||
"message": "该工具问题严重,等待后续版本修复后可正常使用!"
|
||||
},
|
||||
"weather-details": {
|
||||
"enabled": true,
|
||||
"message": ""
|
||||
},
|
||||
"bmi-calculator": {
|
||||
"enabled": true,
|
||||
"message": ""
|
||||
},
|
||||
"earthquake-info": {
|
||||
"enabled": true,
|
||||
"message": ""
|
||||
},
|
||||
"car-info": {
|
||||
"enabled": true,
|
||||
"message": ""
|
||||
},
|
||||
"cctv-news": {
|
||||
"enabled": true,
|
||||
"message": ""
|
||||
},
|
||||
"oil-price": {
|
||||
"enabled": true,
|
||||
"message": ""
|
||||
},
|
||||
"history-today": {
|
||||
"enabled": true,
|
||||
"message": ""
|
||||
},
|
||||
"domain-price": {
|
||||
"enabled": true,
|
||||
"message": ""
|
||||
},
|
||||
"tech-news": {
|
||||
"enabled": true,
|
||||
"message": ""
|
||||
},
|
||||
"gold-price": {
|
||||
"enabled": true,
|
||||
"message": ""
|
||||
},
|
||||
"zhihu-hot": {
|
||||
"enabled": true,
|
||||
"message": ""
|
||||
},
|
||||
"movie-box-office": {
|
||||
"enabled": true,
|
||||
"message": ""
|
||||
},
|
||||
"football-news": {
|
||||
"enabled": true,
|
||||
"message": ""
|
||||
},
|
||||
"train-query": {
|
||||
"enabled": true,
|
||||
"message": ""
|
||||
},
|
||||
|
||||
"comment_screening_room": "随机放映室使用 '分类ID.子分类ID' 作为键",
|
||||
|
||||
"image.xjj": {
|
||||
"enabled": true,
|
||||
"message": ""
|
||||
},
|
||||
"image.baisi": {
|
||||
"enabled": true,
|
||||
"message": ""
|
||||
},
|
||||
"image.heisi": {
|
||||
"enabled": true,
|
||||
"message": ""
|
||||
},
|
||||
"image.猫羽雫": {
|
||||
"enabled": true,
|
||||
"message": "猫羽雫 暂时下线,请先浏览其他分类。"
|
||||
},
|
||||
"image.wappller": {
|
||||
"enabled": true,
|
||||
"message": "高清壁纸 暂时下线,请先浏览其他分类。"
|
||||
},
|
||||
"video.radom_xjj_leixing": {
|
||||
"enabled": true,
|
||||
"message": ""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
{
|
||||
"app_version": "1.5.01",
|
||||
"last_updated": "2025-01-15T10:00:00Z",
|
||||
"download_url": "https://update.ymhut.cn/downloads/YmhutBox Setup 1.5.01.exe",
|
||||
"api_keys": {
|
||||
"uapipro": "",
|
||||
"nsuuu": "b3b80af6a8c2a8a2"
|
||||
},
|
||||
"download_mirrors": [
|
||||
{
|
||||
"id": "main",
|
||||
"name": "官方高速源 (主线路)",
|
||||
"url": "https://update.ymhut.cn/downloads/YmhutBox_Setup_1.5.01.exe",
|
||||
"type": "direct",
|
||||
"enabled": true
|
||||
}
|
||||
],
|
||||
|
||||
"home_notes": "🚀 <b>v1.5.01 架构重构版已发布!</b><br>本次更新聚焦底层架构优化与用户体验提升。全新通用模态框框架统一管理所有弹窗交互,工具健康检查系统全面优化,界面过渡动画更加流畅。同时修复了主题切换、字体下载等多项关键问题,系统稳定性显著提升。",
|
||||
|
||||
"update_notes": {
|
||||
"🏗️ 架构重构与统一框架": "1. **[通用模态框框架 (ModalManager)]**:全新设计并实现统一的模态框管理系统,支持多层模态框堆叠、自动 z-index 管理、ESC 键关闭、背景点击关闭等通用功能。所有模态框(锁定工具、健康历史、公告、字体下载、天气详情、免责声明等)现已统一迁移至该框架,代码复用率提升 80%,维护成本大幅降低。\n2. **[工具健康检查系统优化]**:移除冗余的模态框版本健康检查,统一使用工具箱验证页面进行健康检查。设置页面的手动检查现直接调用底层检查器,不再弹出独立模态框,界面更加简洁统一。\n3. **[代码模块化重构]**:重构外观设置模块的所有功能逻辑,统一常量命名规范(APPEARANCE_* 前缀),消除参数冲突,提升代码可维护性。",
|
||||
|
||||
"✨ 用户体验优化 (UI/UX)": "1. **[工具健康检查列表优化]**:优化检查列表容器显示逻辑,当工具已检查且处于跳过状态时自动隐藏列表容器,仅在需要检查时显示。修复悬停时列表溢出和横向滚动条问题,确保列表完全显示在可视区域内。\n2. **[主题切换动画增强]**:修复深色/浅色主题切换时的过渡动画问题,新增月亮/太阳滑入发光动画效果,解决切换后文字显示异常的问题。优化主题切换过程中字体下载模态框意外弹出的问题。\n3. **[模态框交互统一]**:所有模态框现支持统一的关闭方式(关闭按钮、ESC 键、背景点击),交互体验更加一致。修复模态框在主题切换时的样式更新问题。",
|
||||
|
||||
"🔧 功能修复与完善": "1. **[工具健康检查修复]**:修复"上次检查时间"不自动更新的问题,确保从工具箱、设置页面手动触发的检查都能正确记录和显示。修复网络工具总数始终显示"计算中"的状态问题。\n2. **[字体下载功能优化]**:修复主题切换时字体下载模态框意外弹出的问题,完善字体选择下拉框的事件绑定逻辑,确保只在用户主动选择字体时才触发下载流程。\n3. **[日志功能扩展]**:大幅扩展日志功能的记录范围,新增对工具健康检查(开始、完成、跳过、错误)、主题切换、字体下载、模态框交互等关键操作的日志记录,提升系统可观测性。\n4. **[常量声明修复]**:修复多处重复声明常量导致的 SyntaxError 问题(APPEARANCE_FONT_SELECT_OPTIONS_ID、FONT_MODAL_ID 等),统一使用类级别静态常量管理。"
|
||||
},
|
||||
|
||||
"last_update_notes": {
|
||||
"v1.5.01": {
|
||||
"🏗️ 架构重构与统一框架": "1. **[通用模态框框架 (ModalManager)]**:全新设计并实现统一的模态框管理系统,支持多层模态框堆叠、自动 z-index 管理、ESC 键关闭、背景点击关闭等通用功能。所有模态框现已统一迁移至该框架。\n2. **[工具健康检查系统优化]**:移除冗余的模态框版本,统一使用工具箱验证页面进行健康检查。\n3. **[代码模块化重构]**:重构外观设置模块,统一常量命名规范,消除参数冲突。",
|
||||
"✨ 用户体验优化": "1. **[工具健康检查列表优化]**:优化检查列表显示逻辑,修复悬停溢出问题。\n2. **[主题切换动画增强]**:新增月亮/太阳滑入发光动画,修复文字显示异常。\n3. **[模态框交互统一]**:所有模态框支持统一的关闭方式,交互体验更加一致。",
|
||||
"🔧 功能修复与完善": "1. **[工具健康检查修复]**:修复"上次检查时间"不更新、网络工具总数显示异常等问题。\n2. **[字体下载功能优化]**:修复主题切换时模态框意外弹出的问题。\n3. **[日志功能扩展]**:新增对关键操作的日志记录,提升系统可观测性。"
|
||||
},
|
||||
"v1.4.39": {
|
||||
"✨ 交互与界面革命 (UI/UX)": "1. **[灵动侧边栏 V3]**:工具箱分类导航新增物理模拟拖拽支持,优化点击与拖拽冲突判定。\n2. **[智能响应式分页]**:工具箱网格现根据窗口大小实时计算每页最佳图标数量。\n3. **[沉浸式工具设计]**:重绘「保质期计算」、「HMAC生成器」等工具,采用全融合无边框输入组。",
|
||||
"🛠️ 新增 15+ 款硬核工具": "1. **[开发调试]**:JSON 格式化/压缩、正则测试、代码压缩/混淆/加密等。\n2. **[安全加密]**:HMAC 哈希生成器、MD5 加密、高强度密码生成器。\n3. **[计算与数据]**:保质期智能推算、ULID 唯一标识生成、科学计算器等。"
|
||||
},
|
||||
"v1.4.35": {
|
||||
"🌤️ 天气灵动岛 (重大更新)": "1. **[全局胶囊组件]**:标题栏新增常驻式天气灵动岛,采用纯 CSS 动态图标绘制,支持深色/浅色模式自适应。\n2. **[多源数据融合]**:独创4源数据清洗算法,实时温度采用高频源交叉校验,解决温度滞后问题。\n3. **[商业级定位]**:引入 UApiPro 商业接口与 IP.SB 双重校验,支持精准到"区/县"的定位。",
|
||||
"🛡️ 启动与合规": "1. **[独立免责进程]**:全新的"免责声明"窗口采用独立进程渲染。\n2. **[数据库隔离]**:用户配置数据迁移至本地 SQLite 加密存储。"
|
||||
}
|
||||
},
|
||||
|
||||
"_comment_category_mapping": "工具分类与关键词映射",
|
||||
"tool_metadata": {
|
||||
"smart-search": { "category": "query", "keywords": ["搜索", "AI", "聚合", "百度", "谷歌"] },
|
||||
"hotboard": { "category": "data", "keywords": ["热搜", "微博", "知乎", "B站", "抖音"] },
|
||||
"weather-details": { "category": "life", "keywords": ["天气", "气温", "预报", "空气质量"] },
|
||||
"bili-hot-ranking": { "category": "data", "keywords": ["B站", "视频", "二次元", "排行"] },
|
||||
"baidu-hot": { "category": "data", "keywords": ["百度", "热榜", "新闻", "搜索"] },
|
||||
|
||||
"ai-translation": { "category": "text", "keywords": ["翻译", "多语言", "AI", "外语"] },
|
||||
"chinese-converter": { "category": "text", "keywords": ["简繁", "转换", "繁体", "中文"] },
|
||||
"profanity-check": { "category": "text", "keywords": ["敏感词", "检测", "过滤", "审核"] },
|
||||
"diff-tool": { "category": "text", "keywords": ["对比", "差异", "比较", "Diff"] },
|
||||
|
||||
"image-processor": { "category": "image", "keywords": ["图片", "压缩", "裁剪", "编辑"] },
|
||||
"qq-avatar": { "category": "image", "keywords": ["QQ", "头像", "下载", "查询"] },
|
||||
"sanguosha-downloader": { "category": "image", "keywords": ["三国杀", "皮肤", "图鉴", "下载"] },
|
||||
|
||||
"base64-converter": { "category": "dev", "keywords": ["Base64", "编码", "解码", "加密"] },
|
||||
"json-format": { "category": "dev", "keywords": ["JSON", "格式化", "校验", "美化"] },
|
||||
"url-tool": { "category": "dev", "keywords": ["URL", "网址", "编码", "解码"] },
|
||||
"timestamp-tool": { "category": "dev", "keywords": ["时间戳", "日期", "转换", "Unix"] },
|
||||
"regex-tool": { "category": "dev", "keywords": ["正则", "匹配", "测试", "RegExp"] },
|
||||
"html-minifier": { "category": "dev", "keywords": ["HTML", "压缩", "CSS", "JS", "Minify"] },
|
||||
"html-entity": { "category": "dev", "keywords": ["HTML", "实体", "转义", "Escape"] },
|
||||
"js-obfuscator": { "category": "dev", "keywords": ["JS", "加密", "混淆", "保护"] },
|
||||
"ulid-generator": { "category": "dev", "keywords": ["ULID", "UUID", "ID", "唯一标识"] },
|
||||
|
||||
"md5-tool": { "category": "security", "keywords": ["MD5", "哈希", "摘要", "加密"] },
|
||||
"hmac-generator": { "category": "security", "keywords": ["HMAC", "哈希", "签名", "SHA"] },
|
||||
"password-tool": { "category": "security", "keywords": ["密码", "生成", "随机", "安全"] },
|
||||
|
||||
"qr-code-generator": { "category": "generate", "keywords": ["二维码", "生成", "QR", "制作"] },
|
||||
|
||||
"system-info": { "category": "dev", "keywords": ["硬件", "系统", "CPU", "内存", "监控"] },
|
||||
"system-tool": { "category": "dev", "keywords": ["CMD", "系统工具", "注册表", "任务管理器"] },
|
||||
"pc-benchmark": { "category": "simulate", "keywords": ["跑分", "性能", "测试", "Benchmark"] },
|
||||
|
||||
"ip-query": { "category": "network", "keywords": ["IP", "归属地", "网络", "查询"] },
|
||||
"ip-info": { "category": "network", "keywords": ["IP", "定位", "详情", "ASN"] },
|
||||
"dns-query": { "category": "network", "keywords": ["DNS", "解析", "域名", "A记录"] },
|
||||
"wx-domain-check": { "category": "network", "keywords": ["微信", "域名", "拦截", "检测"] },
|
||||
|
||||
"calculator-tool": { "category": "calculator", "keywords": ["计算器", "数学", "运算"] },
|
||||
"unit-tool": { "category": "calculator", "keywords": ["单位", "换算", "长度", "重量"] },
|
||||
"expiry-calculator": { "category": "calculator", "keywords": ["保质期", "过期", "日期", "计算"] },
|
||||
|
||||
"color-tool": { "category": "design", "keywords": ["颜色", "取色", "RGB", "HEX", "调色板"] },
|
||||
|
||||
"media-player": { "category": "life", "keywords": ["播放器", "视频", "音乐", "本地"] }
|
||||
},
|
||||
|
||||
"category_list": [
|
||||
{ "id": "all", "name": "全部工具", "icon": "fas fa-layer-group" },
|
||||
{ "id": "dev", "name": "开发调试", "icon": "fas fa-code" },
|
||||
{ "id": "network", "name": "网络工具", "icon": "fas fa-network-wired" },
|
||||
{ "id": "security", "name": "密码安全", "icon": "fas fa-shield-alt" },
|
||||
{ "id": "data", "name": "数据热榜", "icon": "fas fa-chart-line" },
|
||||
{ "id": "calculator", "name": "计算换算", "icon": "fas fa-calculator" },
|
||||
{ "id": "text", "name": "文本处理", "icon": "fas fa-font" },
|
||||
{ "id": "image", "name": "图片多媒体", "icon": "fas fa-photo-video" },
|
||||
{ "id": "design", "name": "设计辅助", "icon": "fas fa-palette" },
|
||||
{ "id": "life", "name": "生活娱乐", "icon": "fas fa-coffee" },
|
||||
{ "id": "simulate", "name": "系统模拟", "icon": "fas fa-microchip" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>关于 & 鸣谢</title>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0-beta3/css/all.min.css">
|
||||
<link rel="stylesheet" href="./css/style.css">
|
||||
<style>
|
||||
/* --- 覆盖基础样式以适应独立窗口 --- */
|
||||
html, body {
|
||||
overflow: hidden;
|
||||
background-color: transparent !important;
|
||||
font-family: var(--font-family);
|
||||
color: var(--text-color);
|
||||
margin: 0;
|
||||
padding: 5px; /* 留出阴影空间 */
|
||||
height: 100vh;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* 继承主程序的玻璃框架 */
|
||||
.ack-glass-frame {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
background: rgba(var(--card-background-rgb), 0.90);
|
||||
backdrop-filter: blur(40px) saturate(180%);
|
||||
border-radius: 20px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.3);
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* 顶部标题栏 */
|
||||
.ack-header {
|
||||
height: 50px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 20px;
|
||||
background: linear-gradient(to bottom, rgba(var(--bg-color-rgb), 0.5), transparent);
|
||||
border-bottom: 1px solid rgba(128, 128, 128, 0.1);
|
||||
-webkit-app-region: drag;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ack-title {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
background: linear-gradient(135deg, var(--primary-color), var(--secondary-color));
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.window-controls-btn {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: rgba(128, 128, 128, 0.1);
|
||||
color: var(--text-secondary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
.window-controls-btn:hover {
|
||||
background: var(--error-color);
|
||||
color: white;
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
/* 内容滚动区 */
|
||||
.ack-content {
|
||||
flex-grow: 1;
|
||||
overflow-y: auto;
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.ack-content::-webkit-scrollbar { width: 4px; }
|
||||
.ack-content::-webkit-scrollbar-thumb { background: var(--border-color); border-radius: 2px; }
|
||||
|
||||
/* 分组标题 */
|
||||
.section-label {
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
margin-bottom: 10px;
|
||||
padding-left: 5px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* 灵动岛卡片 */
|
||||
.island-link-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: rgba(var(--bg-color-rgb), 0.5);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 16px;
|
||||
padding: 15px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.island-link-card:hover {
|
||||
transform: translateY(-3px);
|
||||
background: rgba(var(--card-background-rgb), 0.8);
|
||||
border-color: rgba(var(--primary-rgb), 0.4);
|
||||
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.island-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 12px;
|
||||
background: rgba(var(--primary-rgb), 0.1);
|
||||
color: var(--primary-color);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 20px;
|
||||
margin-right: 15px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.island-text {
|
||||
flex-grow: 1;
|
||||
}
|
||||
.island-text h4 { margin: 0 0 4px 0; font-size: 15px; color: var(--text-color); }
|
||||
.island-text p { margin: 0; font-size: 12px; color: var(--text-secondary); opacity: 0.8; }
|
||||
|
||||
.island-arrow {
|
||||
color: var(--text-secondary);
|
||||
opacity: 0.5;
|
||||
transition: transform 0.3s;
|
||||
}
|
||||
.island-link-card:hover .island-arrow {
|
||||
opacity: 1;
|
||||
color: var(--primary-color);
|
||||
transform: translateX(3px);
|
||||
}
|
||||
|
||||
/* 环境信息列表 */
|
||||
.env-list {
|
||||
background: rgba(var(--bg-color-rgb), 0.3);
|
||||
border-radius: 16px;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.env-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 10px;
|
||||
font-size: 13px;
|
||||
border-bottom: 1px dashed var(--border-color);
|
||||
}
|
||||
.env-item:last-child { border-bottom: none; }
|
||||
.env-key { color: var(--text-secondary); font-weight: 600; }
|
||||
.env-val { color: var(--text-color); font-family: 'Consolas', monospace; }
|
||||
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="ack-glass-frame">
|
||||
<div class="ack-header">
|
||||
<div class="ack-title">
|
||||
<i class="fas fa-feather-alt"></i> 关于 & 鸣谢
|
||||
</div>
|
||||
<button id="close-btn" class="window-controls-btn" title="关闭">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="ack-content">
|
||||
|
||||
<div>
|
||||
<div class="section-label">核心数据源 (Data Providers)</div>
|
||||
<div style="display: flex; flex-direction: column; gap: 10px;">
|
||||
|
||||
<div class="island-link-card ripple" id="suyan-api-link">
|
||||
<div class="island-icon"><i class="fas fa-server"></i></div>
|
||||
<div class="island-text">
|
||||
<h4>素颜 API</h4>
|
||||
<p>稳定高效的综合数据接口服务</p>
|
||||
</div>
|
||||
<i class="fas fa-chevron-right island-arrow"></i>
|
||||
</div>
|
||||
|
||||
<div class="island-link-card ripple" id="uapipro-link">
|
||||
<div class="island-icon"><i class="fas fa-cogs"></i></div>
|
||||
<div class="island-text">
|
||||
<h4>UApiPro</h4>
|
||||
<p>专业的企业级 API 解决方案</p>
|
||||
</div>
|
||||
<i class="fas fa-chevron-right island-arrow"></i>
|
||||
</div>
|
||||
|
||||
<div class="island-link-card ripple" id="nsuuu-api-link">
|
||||
<div class="island-icon"><i class="fas fa-cloud-sun"></i></div>
|
||||
<div class="island-text">
|
||||
<h4>鸭梨 API</h4>
|
||||
<p>精准的天气与生活服务数据支持</p>
|
||||
</div>
|
||||
<i class="fas fa-chevron-right island-arrow"></i>
|
||||
</div>
|
||||
|
||||
<div class="island-link-card ripple" id="milora-api-link">
|
||||
<div class="island-icon"><i class="fas fa-network-wired"></i></div>
|
||||
<div class="island-text">
|
||||
<h4>MiloraAPI</h4>
|
||||
<p>优质的数据接口服务支持</p>
|
||||
</div>
|
||||
<i class="fas fa-chevron-right island-arrow"></i>
|
||||
</div>
|
||||
|
||||
<div class="island-link-card ripple" id="pear-api-link">
|
||||
<div class="island-icon"><i class="fas fa-project-diagram"></i></div>
|
||||
<div class="island-text">
|
||||
<h4>PearAPI</h4>
|
||||
<p>多功能聚合数据接口</p>
|
||||
</div>
|
||||
<i class="fas fa-chevron-right island-arrow"></i>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="env-section" style="display: none;">
|
||||
<div class="section-label">运行环境 (Runtime Info)</div>
|
||||
<div id="env-list" class="env-list">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const theme = urlParams.get('theme') || 'dark';
|
||||
document.body.setAttribute('data-theme', theme);
|
||||
|
||||
document.getElementById('close-btn').addEventListener('click', () => window.electronAPI.closeCurrentWindow());
|
||||
|
||||
// 绑定鸣谢链接
|
||||
const openLink = (url) => window.electronAPI.openExternalLink(url);
|
||||
document.getElementById('suyan-api-link').addEventListener('click', () => openLink('https://api.suyanw.cn'));
|
||||
document.getElementById('uapipro-link').addEventListener('click', () => openLink('http://uapis.cn'));
|
||||
document.getElementById('nsuuu-api-link').addEventListener('click', () => openLink('https://api.nsuuu.com'));
|
||||
document.getElementById('milora-api-link').addEventListener('click', () => openLink('https://api.milorapart.top'));
|
||||
document.getElementById('pear-api-link').addEventListener('click', () => openLink('https://api.pearktrue.cn'));
|
||||
|
||||
// 渲染环境信息
|
||||
try {
|
||||
const versionsString = urlParams.get('versions');
|
||||
if (versionsString && versionsString !== '{}') {
|
||||
const versions = JSON.parse(versionsString);
|
||||
const container = document.getElementById('env-list');
|
||||
const section = document.getElementById('env-section');
|
||||
|
||||
let html = '';
|
||||
const formatKey = (k) => k.charAt(0).toUpperCase() + k.slice(1);
|
||||
|
||||
// 优先展示
|
||||
const priority = ['app_version', 'electron', 'node', 'chromium'];
|
||||
|
||||
priority.forEach(key => {
|
||||
if (versions[key]) {
|
||||
html += `<div class="env-item"><span class="env-key">${formatKey(key)}</span><span class="env-val">${versions[key]}</span></div>`;
|
||||
}
|
||||
});
|
||||
|
||||
// 其他信息
|
||||
for (const [key, value] of Object.entries(versions)) {
|
||||
if (!priority.includes(key) && key !== 'last_checked_date' && value) {
|
||||
html += `<div class="env-item"><span class="env-key">${formatKey(key)}</span><span class="env-val">${value}</span></div>`;
|
||||
}
|
||||
}
|
||||
|
||||
if (html) {
|
||||
container.innerHTML = html;
|
||||
section.style.display = 'block';
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('环境信息加载失败', e);
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
After Width: | Height: | Size: 5.5 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 9.6 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 9.9 MiB |
@@ -0,0 +1,468 @@
|
||||
/* src/css/modern-ui.css */
|
||||
/**
|
||||
* [重构] 现代化 UI 样式
|
||||
* 提供统一的 UI 组件样式和动画效果
|
||||
*/
|
||||
|
||||
:root {
|
||||
/* 颜色变量(由 ModernUI 动态设置) */
|
||||
--color-primary: #6366f1;
|
||||
--color-secondary: #8b5cf6;
|
||||
--color-success: #10b981;
|
||||
--color-warning: #f59e0b;
|
||||
--color-error: #ef4444;
|
||||
--color-background: #0f172a;
|
||||
--color-surface: #1e293b;
|
||||
--color-text: #f1f5f9;
|
||||
--color-text-secondary: #94a3b8;
|
||||
|
||||
/* 间距 */
|
||||
--spacing-xs: 0.25rem;
|
||||
--spacing-sm: 0.5rem;
|
||||
--spacing-md: 1rem;
|
||||
--spacing-lg: 1.5rem;
|
||||
--spacing-xl: 2rem;
|
||||
|
||||
/* 圆角 */
|
||||
--radius-sm: 0.375rem;
|
||||
--radius-md: 0.5rem;
|
||||
--radius-lg: 0.75rem;
|
||||
--radius-xl: 1rem;
|
||||
|
||||
/* 阴影 */
|
||||
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
|
||||
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
|
||||
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1);
|
||||
|
||||
/* 过渡 */
|
||||
--transition-fast: 150ms ease;
|
||||
--transition-base: 300ms ease;
|
||||
--transition-slow: 500ms ease;
|
||||
}
|
||||
|
||||
/* 通知组件 */
|
||||
.modern-notification {
|
||||
position: fixed;
|
||||
top: var(--spacing-lg);
|
||||
right: var(--spacing-lg);
|
||||
min-width: 300px;
|
||||
max-width: 500px;
|
||||
background: var(--color-surface);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-xl);
|
||||
padding: var(--spacing-md);
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--spacing-md);
|
||||
z-index: 10000;
|
||||
opacity: 0;
|
||||
transform: translateX(100%);
|
||||
transition: all var(--transition-base);
|
||||
border-left: 4px solid var(--color-primary);
|
||||
}
|
||||
|
||||
.modern-notification.show {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.modern-notification.hide {
|
||||
opacity: 0;
|
||||
transform: translateX(100%);
|
||||
}
|
||||
|
||||
.modern-notification-success {
|
||||
border-left-color: var(--color-success);
|
||||
}
|
||||
|
||||
.modern-notification-error {
|
||||
border-left-color: var(--color-error);
|
||||
}
|
||||
|
||||
.modern-notification-warning {
|
||||
border-left-color: var(--color-warning);
|
||||
}
|
||||
|
||||
.modern-notification-info {
|
||||
border-left-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.notification-icon {
|
||||
font-size: 1.5rem;
|
||||
color: var(--color-primary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.modern-notification-success .notification-icon {
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.modern-notification-error .notification-icon {
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
.modern-notification-warning .notification-icon {
|
||||
color: var(--color-warning);
|
||||
}
|
||||
|
||||
.notification-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.notification-title {
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
margin-bottom: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.notification-message {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.notification-close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
padding: var(--spacing-xs);
|
||||
border-radius: var(--radius-sm);
|
||||
transition: all var(--transition-fast);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.notification-close:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
/* 模态框组件 */
|
||||
.modern-modal {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 10001;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity var(--transition-base);
|
||||
}
|
||||
|
||||
.modern-modal.show {
|
||||
opacity: 1;
|
||||
pointer-events: all;
|
||||
}
|
||||
|
||||
.modern-modal.hide {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.modal-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.modal-container {
|
||||
position: relative;
|
||||
background: var(--color-surface);
|
||||
border-radius: var(--radius-xl);
|
||||
box-shadow: var(--shadow-xl);
|
||||
max-width: 90vw;
|
||||
max-height: 90vh;
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transform: scale(0.95);
|
||||
transition: transform var(--transition-base);
|
||||
}
|
||||
|
||||
.modern-modal.show .modal-container {
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--spacing-lg);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
margin: 0;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
padding: var(--spacing-xs);
|
||||
border-radius: var(--radius-sm);
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.modal-close:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: var(--spacing-lg);
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: var(--spacing-md);
|
||||
padding: var(--spacing-lg);
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.modal-button {
|
||||
padding: var(--spacing-sm) var(--spacing-lg);
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.modal-button-primary {
|
||||
background: var(--color-primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.modal-button-primary:hover {
|
||||
background: #4f46e5;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.modal-button-secondary {
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.modal-button-secondary:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
/* 加载指示器 */
|
||||
.modern-loading {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 10002;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
backdrop-filter: blur(4px);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity var(--transition-base);
|
||||
}
|
||||
|
||||
.modern-loading.show {
|
||||
opacity: 1;
|
||||
pointer-events: all;
|
||||
}
|
||||
|
||||
.modern-loading.hide {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border: 4px solid rgba(255, 255, 255, 0.1);
|
||||
border-top-color: var(--color-primary);
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.loading-message {
|
||||
margin-top: var(--spacing-lg);
|
||||
color: var(--color-text);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
/* 卡片组件 */
|
||||
.modern-card {
|
||||
background: var(--color-surface);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-md);
|
||||
overflow: hidden;
|
||||
transition: all var(--transition-base);
|
||||
}
|
||||
|
||||
.modern-card:hover {
|
||||
box-shadow: var(--shadow-lg);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
padding: var(--spacing-lg);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.card-title {
|
||||
margin: 0;
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: var(--spacing-lg);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.card-footer {
|
||||
padding: var(--spacing-lg);
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.card-actions {
|
||||
display: flex;
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
.card-action-btn {
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.card-action-primary {
|
||||
background: var(--color-primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.card-action-primary:hover {
|
||||
background: #4f46e5;
|
||||
}
|
||||
|
||||
/* 按钮组件 */
|
||||
.modern-button {
|
||||
padding: var(--spacing-sm) var(--spacing-lg);
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.modern-button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.modern-button-primary {
|
||||
background: var(--color-primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.modern-button-primary:hover:not(:disabled) {
|
||||
background: #4f46e5;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.modern-button-secondary {
|
||||
background: var(--color-secondary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.modern-button-secondary:hover:not(:disabled) {
|
||||
background: #7c3aed;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.modern-button-success {
|
||||
background: var(--color-success);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.modern-button-success:hover:not(:disabled) {
|
||||
background: #059669;
|
||||
}
|
||||
|
||||
.modern-button-error {
|
||||
background: var(--color-error);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.modern-button-error:hover:not(:disabled) {
|
||||
background: #dc2626;
|
||||
}
|
||||
|
||||
.modern-button-small {
|
||||
padding: var(--spacing-xs) var(--spacing-md);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.modern-button-medium {
|
||||
padding: var(--spacing-sm) var(--spacing-lg);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.modern-button-large {
|
||||
padding: var(--spacing-md) var(--spacing-xl);
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
.modern-notification {
|
||||
right: var(--spacing-md);
|
||||
left: var(--spacing-md);
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.modal-container {
|
||||
max-width: 95vw;
|
||||
margin: var(--spacing-md);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,261 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>用户协议</title>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0-beta3/css/all.min.css">
|
||||
<link rel="stylesheet" href="./css/style.css">
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 10px; /* 给阴影留空间 */
|
||||
height: 100vh;
|
||||
background: transparent;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
font-family: var(--font-family);
|
||||
}
|
||||
|
||||
.disclaimer-island {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(145deg,
|
||||
rgba(var(--card-background-rgb), 0.98),
|
||||
rgba(var(--card-background-rgb), 0.92));
|
||||
backdrop-filter: blur(50px) saturate(200%);
|
||||
-webkit-backdrop-filter: blur(50px) saturate(200%);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 28px;
|
||||
box-shadow:
|
||||
0 30px 80px rgba(0, 0, 0, 0.4),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.1);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
-webkit-app-region: drag; /* 允许拖动窗口 */
|
||||
animation: disclaimerFadeIn 0.5s ease-out;
|
||||
}
|
||||
|
||||
@keyframes disclaimerFadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95) translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1) translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.header {
|
||||
padding: 25px 25px 10px 25px;
|
||||
text-align: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.icon-box {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, var(--primary-color), var(--secondary-color));
|
||||
color: white;
|
||||
font-size: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 0 auto 18px auto;
|
||||
box-shadow:
|
||||
0 8px 24px rgba(var(--primary-rgb), 0.4),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.2);
|
||||
animation: iconPulse 2s ease-in-out infinite;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.icon-box::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, var(--primary-color), var(--secondary-color));
|
||||
opacity: 0.3;
|
||||
animation: iconRipple 2s ease-out infinite;
|
||||
}
|
||||
|
||||
@keyframes iconPulse {
|
||||
0%, 100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes iconRipple {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
opacity: 0.3;
|
||||
}
|
||||
100% {
|
||||
transform: scale(1.5);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
h2 { margin: 0 0 5px 0; font-size: 20px; color: var(--text-color); }
|
||||
p.subtitle { margin: 0; font-size: 13px; color: var(--text-secondary); opacity: 0.8; }
|
||||
|
||||
.content {
|
||||
flex-grow: 1;
|
||||
padding: 15px 30px;
|
||||
overflow-y: auto;
|
||||
font-size: 14px;
|
||||
line-height: 1.8;
|
||||
color: var(--text-color);
|
||||
text-align: justify;
|
||||
-webkit-app-region: no-drag; /* 内容区不可拖动以允许选择 */
|
||||
}
|
||||
|
||||
/* [优化] 滚动条样式 */
|
||||
.content::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
.content::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
.content::-webkit-scrollbar-thumb {
|
||||
background: var(--border-color);
|
||||
border-radius: 3px;
|
||||
transition: background 0.3s ease;
|
||||
}
|
||||
.content::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--primary-color);
|
||||
}
|
||||
|
||||
/* [优化] 内容段落样式 */
|
||||
.content p {
|
||||
margin-bottom: 16px;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.content strong {
|
||||
color: var(--primary-color);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.footer {
|
||||
padding: 20px 25px;
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
background: linear-gradient(to top, rgba(var(--bg-color-rgb), 0.5), transparent);
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
.btn {
|
||||
flex: 1;
|
||||
padding: 14px 20px;
|
||||
border-radius: 16px;
|
||||
border: none;
|
||||
font-weight: 600;
|
||||
font-size: 15px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.btn::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
transform: translate(-50%, -50%);
|
||||
transition: width 0.6s, height 0.6s;
|
||||
}
|
||||
|
||||
.btn:active::before {
|
||||
width: 300px;
|
||||
height: 300px;
|
||||
}
|
||||
|
||||
.btn-quit {
|
||||
background: rgba(var(--text-color), 0.08);
|
||||
color: var(--text-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
.btn-quit:hover {
|
||||
background: rgba(var(--error-color-rgb), 0.15);
|
||||
color: var(--error-color);
|
||||
border-color: var(--error-color);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(var(--error-color-rgb), 0.2);
|
||||
}
|
||||
|
||||
.btn-agree {
|
||||
background: linear-gradient(135deg, var(--primary-color), var(--secondary-color));
|
||||
color: white;
|
||||
box-shadow:
|
||||
0 6px 20px rgba(var(--primary-rgb), 0.4),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
.btn-agree:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow:
|
||||
0 8px 24px rgba(var(--primary-rgb), 0.5),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
.btn-agree:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body data-theme="dark">
|
||||
<div class="disclaimer-island">
|
||||
<div class="header">
|
||||
<div class="icon-box"><i class="fas fa-shield-alt"></i></div>
|
||||
<h2>服务协议与免责声明</h2>
|
||||
<p class="subtitle">请仔细阅读以下条款</p>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<p>欢迎使用 <strong>YMhut Box</strong>。本软件是一款由个人开发的<strong>非盈利性</strong>系统工具箱。</p>
|
||||
<p>1. <strong>数据来源说明</strong><br>本软件集成的部分功能(如天气查询、热榜、IP归属等)通过公开网络接口聚合数据。软件本身不生产数据,亦不对第三方数据的准确性、及时性或合法性承担责任。</p>
|
||||
<p>2. <strong>合法使用承诺</strong><br>请确保您在遵守当地法律法规的前提下使用本软件。严禁利用本软件提供的网络工具进行任何非法的扫描、攻击或侵入行为。用户需对自己的行为承担全部法律责任。</p>
|
||||
<p>3. <strong>免责条款</strong><br>作者不对因使用本软件及其功能而导致的任何直接或间接损失(包括但不限于数据丢失、系统故障、业务中断)负责。软件按“原样”提供,不包含任何明示或暗示的担保。</p>
|
||||
<p>4. <strong>隐私与存储</strong><br>本软件承诺不收集用户的个人隐私信息。所有配置数据及日志均存储于您本地的数据库中,不会上传至任何第三方服务器。</p>
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<button class="btn btn-quit" id="quit-btn">拒绝并退出</button>
|
||||
<button class="btn btn-agree" id="agree-btn">我已阅读并同意</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// 初始化主题
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
document.body.setAttribute('data-theme', params.get('theme') || 'dark');
|
||||
|
||||
document.getElementById('quit-btn').addEventListener('click', () => {
|
||||
// [修复] 使用 closeWindow,main.js 已修复为关闭当前发送指令的窗口
|
||||
// 且因为主窗口未显示,App 会自动退出
|
||||
window.electronAPI.closeWindow();
|
||||
});
|
||||
|
||||
document.getElementById('agree-btn').addEventListener('click', async () => {
|
||||
const btn = document.getElementById('agree-btn');
|
||||
btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> 正在进入...';
|
||||
btn.disabled = true;
|
||||
|
||||
// 调用 Main 进程接口:保存状态 -> 关闭此窗口 -> 打开主窗口
|
||||
await window.electronAPI.confirmUserAgreement();
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,76 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>YMhut box</title>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0-beta3/css/all.min.css">
|
||||
<link rel="stylesheet" href="./css/style.css">
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/qrcodejs/1.0.0/qrcode.min.js"></script>
|
||||
</head>
|
||||
<body id="app-body" data-theme="dark">
|
||||
<div id="background-layer"></div>
|
||||
|
||||
<nav class="navbar">
|
||||
<div id="nav-active-slider" class="nav-slider-pill"></div>
|
||||
<button id="home-btn" class="nav-btn active" title="主页"><i class="fas fa-home"></i></button>
|
||||
<button id="toolbox-btn" class="nav-btn" title="工具箱"><i class="fas fa-toolbox"></i></button>
|
||||
<button id="log-btn" class="nav-btn" title="日志"><i class="fas fa-history"></i></button>
|
||||
<button id="settings-btn" class="nav-btn" title="设置"><i class="fas fa-cog"></i></button>
|
||||
<div class="nav-spacer"></div>
|
||||
<button id="theme-toggle" class="nav-btn" title="切换主题"><i class="fas fa-sun"></i></button>
|
||||
<div id="network-status" class="network-status online" title="网络状态"><i class="fas fa-wifi"></i></div>
|
||||
<div id="network-speed" class="network-speed" title="实时下载速度"></div>
|
||||
</nav>
|
||||
|
||||
<div class="main-content">
|
||||
<div class="title-bar">
|
||||
<div class="title-bar-right-container">
|
||||
|
||||
<div id="title-weather-widget" class="weather-widget-pill">
|
||||
<div class="css-weather-icon" id="tw-icon">
|
||||
<div class="css-icon-cloud"></div>
|
||||
</div>
|
||||
<span class="weather-pill-location" id="tw-location">定位中</span>
|
||||
<span class="weather-pill-temp" id="tw-temp">--°</span>
|
||||
|
||||
<div class="weather-dropdown-card">
|
||||
<div class="wd-header">
|
||||
<span class="wd-city" id="tw-card-city">定位中...</span>
|
||||
<span class="wd-date" id="tw-date">--/--</span>
|
||||
</div>
|
||||
<div class="wd-temp-row">
|
||||
<div class="wd-big-temp" id="tw-big-temp">--°</div>
|
||||
<div class="wd-detail">
|
||||
<span id="tw-weather">获取中</span>
|
||||
<span id="tw-wind" style="font-size: 11px; opacity: 0.8;">--</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wd-grid">
|
||||
<div class="wd-grid-item"><i class="fas fa-tint"></i> <span id="tw-humidity">--%</span></div>
|
||||
<div class="wd-grid-item"><i class="fas fa-leaf"></i> <span id="tw-air">--</span></div>
|
||||
</div>
|
||||
<div class="wd-hint">
|
||||
<span>更多的信息</span> <i class="fas fa-chevron-right" style="font-size: 10px;"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="window-controls">
|
||||
<button id="minimize-btn" class="window-control-btn" title="最小化"><i class="fas fa-window-minimize"></i></button>
|
||||
<button id="maximize-btn" class="window-control-btn" title="最大化"><i class="fas fa-window-maximize"></i></button>
|
||||
<button id="close-btn" class="window-control-btn" title="关闭"><i class="fas fa-times"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="content-area" class="content-area"></div>
|
||||
</div>
|
||||
|
||||
<div id="toast-container"></div>
|
||||
|
||||
<script type="module" src="./js/mainPage.js"></script>
|
||||
<!-- <script src="./js/ban_F12.js"></script> -->
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,7 @@
|
||||
// 通常,此类文件包含阻止用户打开开发者工具的逻辑,例如:
|
||||
document.addEventListener('keydown', function (event) {
|
||||
if (event.key === 'F12' || (event.ctrlKey && event.shiftKey && event.key === 'I')) {
|
||||
event.preventDefault();
|
||||
}
|
||||
});
|
||||
document.addEventListener('contextmenu', event => event.preventDefault());
|
||||
@@ -0,0 +1,219 @@
|
||||
import uiManager from './uiManager.js';
|
||||
import configManager from './configManager.js';
|
||||
import EventBus from './core/EventBus.js';
|
||||
import toolStatusManager from './toolStatusManager.js';
|
||||
|
||||
/**
|
||||
* 工具基类
|
||||
* [重构] 统一工具接口,提供完整的生命周期管理
|
||||
*/
|
||||
class BaseTool {
|
||||
constructor(id, name, options = {}) {
|
||||
if (!id || !name) {
|
||||
throw new Error("工具必须提供 id 和 name。");
|
||||
}
|
||||
this.id = id;
|
||||
this.name = name;
|
||||
this.options = options;
|
||||
this.worker = null;
|
||||
this.workerUrl = null;
|
||||
this.isInitialized = false;
|
||||
this.isDestroyed = false;
|
||||
this.dom = {}; // DOM元素缓存
|
||||
|
||||
// [预留框架] 工具元数据
|
||||
this.metadata = {
|
||||
version: options.version || '1.0.0',
|
||||
author: options.author || '',
|
||||
description: options.description || '',
|
||||
category: options.category || 'other',
|
||||
tags: options.tags || [],
|
||||
dependencies: options.dependencies || [],
|
||||
...options.metadata
|
||||
};
|
||||
|
||||
// [预留框架] 工具配置
|
||||
this.config = {
|
||||
enabled: true,
|
||||
...options.config
|
||||
};
|
||||
|
||||
// [预留框架] 健康检查配置
|
||||
this.healthCheckConfig = options.healthCheckConfig || null;
|
||||
|
||||
// 初始化Worker(如果提供)
|
||||
if (options.workerCode) {
|
||||
this._initWorker(options.workerCode);
|
||||
}
|
||||
|
||||
// 触发工具创建事件
|
||||
EventBus.emit('tool:created', this);
|
||||
}
|
||||
|
||||
/**
|
||||
* [预留框架] 初始化Worker
|
||||
* @param {string} workerCode - Worker代码
|
||||
*/
|
||||
_initWorker(workerCode) {
|
||||
try {
|
||||
const blob = new Blob([workerCode], { type: 'application/javascript' });
|
||||
this.workerUrl = URL.createObjectURL(blob);
|
||||
this.worker = new Worker(this.workerUrl, { type: 'module' });
|
||||
this.worker.onmessage = (event) => this._onWorkerMessage(event.data);
|
||||
this.worker.onerror = (error) => this._onWorkerError(error);
|
||||
} catch (e) {
|
||||
console.error(`创建工具 ${this.name} 的 Worker 失败:`, e);
|
||||
}
|
||||
}
|
||||
|
||||
// --- [重构] 日志记录优化 ---
|
||||
_log(action, category = 'tool') {
|
||||
const message = `[${this.name}] ${action}`;
|
||||
if (configManager && configManager.logAction) {
|
||||
configManager.logAction(message, category);
|
||||
}
|
||||
}
|
||||
|
||||
_notify(title, message, type = 'info') {
|
||||
if (uiManager && uiManager.showNotification) {
|
||||
uiManager.showNotification(title, message, type);
|
||||
}
|
||||
}
|
||||
|
||||
_postMessageToWorker(message) {
|
||||
if (this.worker) {
|
||||
this.worker.postMessage(message);
|
||||
}
|
||||
}
|
||||
|
||||
_onWorkerMessage(data) {
|
||||
this._log(`收到 Worker 消息: ${JSON.stringify(data)}`);
|
||||
// [预留框架] 触发Worker消息事件
|
||||
EventBus.emit(`tool:${this.id}:worker:message`, data);
|
||||
}
|
||||
|
||||
_onWorkerError(error) {
|
||||
console.error(`工具 ${this.name} 的 Worker 发生错误:`, error);
|
||||
this._log(`Worker 错误: ${error.message}`, 'error');
|
||||
// [预留框架] 触发Worker错误事件
|
||||
EventBus.emit(`tool:${this.id}:worker:error`, error);
|
||||
}
|
||||
|
||||
/**
|
||||
* [重构] 渲染工具UI
|
||||
* 子类必须实现此方法
|
||||
* @returns {string} HTML字符串
|
||||
*/
|
||||
render() {
|
||||
throw new Error(`工具 ${this.name} 必须实现 render() 方法。`);
|
||||
}
|
||||
|
||||
/**
|
||||
* [重构] 初始化工具
|
||||
* 子类必须实现此方法
|
||||
*/
|
||||
init() {
|
||||
if (this.isInitialized) {
|
||||
console.warn(`工具 ${this.name} 已经初始化`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this._beforeInit();
|
||||
this._doInit();
|
||||
this._afterInit();
|
||||
this.isInitialized = true;
|
||||
|
||||
// [预留框架] 触发初始化完成事件
|
||||
EventBus.emit(`tool:${this.id}:initialized`, this);
|
||||
} catch (error) {
|
||||
console.error(`工具 ${this.name} 初始化失败:`, error);
|
||||
this._log(`初始化失败: ${error.message}`, 'error');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* [预留框架] 初始化前钩子
|
||||
*/
|
||||
_beforeInit() {
|
||||
// 子类可以重写此方法
|
||||
}
|
||||
|
||||
/**
|
||||
* [预留框架] 执行初始化(子类实现)
|
||||
*/
|
||||
_doInit() {
|
||||
// 子类必须实现此方法或重写init方法
|
||||
throw new Error(`工具 ${this.name} 必须实现 _doInit() 或 init() 方法。`);
|
||||
}
|
||||
|
||||
/**
|
||||
* [预留框架] 初始化后钩子
|
||||
*/
|
||||
_afterInit() {
|
||||
// 子类可以重写此方法
|
||||
}
|
||||
|
||||
/**
|
||||
* [重构] 销毁工具
|
||||
*/
|
||||
destroy() {
|
||||
if (this.isDestroyed) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// [预留框架] 触发销毁前事件
|
||||
EventBus.emit(`tool:${this.id}:before:destroy`, this);
|
||||
|
||||
// 清理Worker
|
||||
if (this.worker) {
|
||||
this.worker.terminate();
|
||||
this.worker = null;
|
||||
}
|
||||
if (this.workerUrl) {
|
||||
URL.revokeObjectURL(this.workerUrl);
|
||||
this.workerUrl = null;
|
||||
}
|
||||
|
||||
// 清理DOM引用
|
||||
this.dom = {};
|
||||
|
||||
// [预留框架] 触发销毁后事件
|
||||
EventBus.emit(`tool:${this.id}:destroyed`, this);
|
||||
|
||||
this.isDestroyed = true;
|
||||
} catch (error) {
|
||||
console.error(`工具 ${this.name} 销毁失败:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* [预留框架] 获取工具状态
|
||||
* @returns {Object} 工具状态
|
||||
*/
|
||||
getStatus() {
|
||||
return toolStatusManager.getToolStatus(this.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* [预留框架] 检查工具是否可用
|
||||
* @returns {boolean} 工具是否可用
|
||||
*/
|
||||
isAvailable() {
|
||||
return toolStatusManager.isToolEnabled(this.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* [预留框架] 更新工具配置
|
||||
* @param {Object} newConfig - 新配置
|
||||
*/
|
||||
updateConfig(newConfig) {
|
||||
this.config = { ...this.config, ...newConfig };
|
||||
// [预留框架] 触发配置更新事件
|
||||
EventBus.emit(`tool:${this.id}:config:updated`, this.config);
|
||||
}
|
||||
}
|
||||
|
||||
export default BaseTool;
|
||||
@@ -0,0 +1,130 @@
|
||||
// src/js/configManager.js
|
||||
|
||||
class ConfigManager {
|
||||
constructor() {
|
||||
this.config = null;
|
||||
this.globalVolume = 0.4;
|
||||
}
|
||||
|
||||
setConfig(config) {
|
||||
this.config = config;
|
||||
if (config.dbSettings) {
|
||||
if (typeof config.dbSettings.globalVolume === 'number') {
|
||||
this.globalVolume = config.dbSettings.globalVolume;
|
||||
}
|
||||
this.applyAppSettings(config.dbSettings);
|
||||
}
|
||||
}
|
||||
|
||||
applyAppSettings(settings) {
|
||||
const root = document.documentElement;
|
||||
|
||||
// 1. 应用主题
|
||||
document.body.setAttribute('data-theme', settings.theme || 'dark');
|
||||
|
||||
// 2. 应用自定义背景
|
||||
const bgLayer = document.getElementById('background-layer');
|
||||
const hasBgImage = settings.backgroundImage && settings.backgroundImage.length > 0;
|
||||
|
||||
if (bgLayer) {
|
||||
bgLayer.style.backgroundImage = hasBgImage ? `url(${settings.backgroundImage})` : 'none';
|
||||
}
|
||||
|
||||
// 3. 应用透明度
|
||||
const bgOpacity = settings.backgroundOpacity || 1.0;
|
||||
const cardOpacity = settings.cardOpacity || 0.7;
|
||||
|
||||
root.style.setProperty('--background-opacity', bgOpacity);
|
||||
root.style.setProperty('--card-opacity', cardOpacity);
|
||||
|
||||
const navOpacity = 1 - (1 - bgOpacity) / 2;
|
||||
root.style.setProperty('--navbar-opacity', navOpacity);
|
||||
|
||||
// -------------------------------------------------------
|
||||
// 4. [修复] 全局字体应用逻辑 (排除图标字体)
|
||||
// -------------------------------------------------------
|
||||
|
||||
const oldStyle = document.getElementById('custom-font-style');
|
||||
if (oldStyle) oldStyle.remove();
|
||||
|
||||
if (window.location.href.includes('view-browser.html')) {
|
||||
root.style.removeProperty('--font-family');
|
||||
return;
|
||||
}
|
||||
|
||||
if (settings.customFontName && settings.customFontPath) {
|
||||
const styleTag = document.createElement('style');
|
||||
styleTag.id = 'custom-font-style';
|
||||
const cacheBuster = Date.now();
|
||||
|
||||
// [关键修改]
|
||||
// 1. 定义自定义字体
|
||||
// 2. 全局应用自定义字体
|
||||
// 3. [核心] 显式排除 FontAwesome 图标类,强制它们使用图标字体
|
||||
styleTag.textContent = `
|
||||
@font-face {
|
||||
font-family: 'UserCustomFont';
|
||||
src: url('${settings.customFontPath}?v=${cacheBuster}');
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
:root {
|
||||
--font-family: 'UserCustomFont', -apple-system, BlinkMacSystemFont, "Segoe UI", "Microsoft YaHei", sans-serif !important;
|
||||
}
|
||||
|
||||
/* 应用于所有普通元素 */
|
||||
*:not(.fa):not(.fas):not(.far):not(.fab):not(i) {
|
||||
font-family: 'UserCustomFont', -apple-system, BlinkMacSystemFont, "Segoe UI", "Microsoft YaHei", sans-serif !important;
|
||||
}
|
||||
|
||||
/* [核心修复] 强制图标使用 FontAwesome 字体,防止被全局字体覆盖成方块 */
|
||||
.fa, .fas {
|
||||
font-family: "Font Awesome 6 Free" !important;
|
||||
font-weight: 900 !important;
|
||||
}
|
||||
.far {
|
||||
font-family: "Font Awesome 6 Free" !important;
|
||||
font-weight: 400 !important;
|
||||
}
|
||||
.fab {
|
||||
font-family: "Font Awesome 6 Brands" !important;
|
||||
font-weight: 400 !important;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(styleTag);
|
||||
} else {
|
||||
root.style.removeProperty('--font-family');
|
||||
}
|
||||
}
|
||||
|
||||
setVolume(newVolume) {
|
||||
const cleanNewVolume = Math.max(0, Math.min(1, newVolume));
|
||||
if (this.globalVolume !== cleanNewVolume) {
|
||||
const oldVolumePercent = Math.round(this.globalVolume * 100);
|
||||
const newVolumePercent = Math.round(cleanNewVolume * 100);
|
||||
this.logAction(`全局音量从 ${oldVolumePercent}% 调整为 ${newVolumePercent}%`, 'settings');
|
||||
this.globalVolume = cleanNewVolume;
|
||||
window.electronAPI.setGlobalVolume(cleanNewVolume);
|
||||
}
|
||||
}
|
||||
|
||||
logAction(action, category = 'general') {
|
||||
const timestamp = new Date().toISOString();
|
||||
try {
|
||||
window.electronAPI.logAction({ timestamp, action, category });
|
||||
} catch (error) {
|
||||
console.error('日志记录失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
formatBytes(bytes, decimals = 2) {
|
||||
if (!bytes || bytes === 0) return '0 Bytes';
|
||||
const k = 1024;
|
||||
const dm = decimals < 0 ? 0 : decimals;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
|
||||
}
|
||||
}
|
||||
|
||||
export default new ConfigManager();
|
||||
@@ -0,0 +1,494 @@
|
||||
// src/js/core/ApiManager.js
|
||||
/**
|
||||
* 接口管理器
|
||||
* [重构] 统一管理所有接口调用、超时、重试、健康度校验
|
||||
*/
|
||||
import EventBus from './EventBus.js';
|
||||
import ExecutionManager from './ExecutionManager.js';
|
||||
|
||||
class ApiManager {
|
||||
constructor() {
|
||||
// 接口注册表
|
||||
this.apis = new Map();
|
||||
|
||||
// 接口健康度记录
|
||||
this.healthStatus = new Map();
|
||||
|
||||
// 接口调用统计
|
||||
this.callStats = new Map();
|
||||
|
||||
// 默认配置
|
||||
this.defaultConfig = {
|
||||
timeout: 10000, // 10秒超时
|
||||
retries: 2, // 重试2次
|
||||
retryDelay: 1000, // 重试延迟1秒
|
||||
healthCheckInterval: 60000, // 健康检查间隔60秒
|
||||
healthCheckTimeout: 5000, // 健康检查超时5秒
|
||||
maxFailures: 3, // 最大失败次数
|
||||
failureWindow: 300000, // 失败窗口5分钟
|
||||
enabled: true
|
||||
};
|
||||
|
||||
// 执行管理器
|
||||
this.executionManager = ExecutionManager;
|
||||
|
||||
// 健康检查定时器
|
||||
this.healthCheckTimers = new Map();
|
||||
|
||||
// 初始化
|
||||
this._init();
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化
|
||||
*/
|
||||
_init() {
|
||||
// 监听执行管理器事件
|
||||
EventBus.on('execution:task:failed', (taskItem, error) => {
|
||||
if (taskItem.apiId) {
|
||||
this._recordFailure(taskItem.apiId, error);
|
||||
}
|
||||
});
|
||||
|
||||
EventBus.on('execution:task:completed', (taskItem) => {
|
||||
if (taskItem.apiId) {
|
||||
this._recordSuccess(taskItem.apiId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册接口
|
||||
* @param {string} id - 接口ID
|
||||
* @param {Object} config - 接口配置
|
||||
*/
|
||||
register(id, config) {
|
||||
if (!id || !config.url) {
|
||||
throw new Error('接口ID和URL不能为空');
|
||||
}
|
||||
|
||||
const apiConfig = {
|
||||
id,
|
||||
url: config.url,
|
||||
method: config.method || 'GET',
|
||||
headers: config.headers || {},
|
||||
timeout: config.timeout || this.defaultConfig.timeout,
|
||||
retries: config.retries !== undefined ? config.retries : this.defaultConfig.retries,
|
||||
retryDelay: config.retryDelay || this.defaultConfig.retryDelay,
|
||||
healthCheckUrl: config.healthCheckUrl || config.url,
|
||||
healthCheckInterval: config.healthCheckInterval || this.defaultConfig.healthCheckInterval,
|
||||
healthCheckTimeout: config.healthCheckTimeout || this.defaultConfig.healthCheckTimeout,
|
||||
maxFailures: config.maxFailures || this.defaultConfig.maxFailures,
|
||||
failureWindow: config.failureWindow || this.defaultConfig.failureWindow,
|
||||
enabled: config.enabled !== undefined ? config.enabled : this.defaultConfig.enabled,
|
||||
category: config.category || 'general',
|
||||
description: config.description || '',
|
||||
...config
|
||||
};
|
||||
|
||||
this.apis.set(id, apiConfig);
|
||||
|
||||
// 初始化健康状态
|
||||
this.healthStatus.set(id, {
|
||||
status: 'unknown', // unknown, healthy, unhealthy, degraded
|
||||
lastCheck: null,
|
||||
lastSuccess: null,
|
||||
lastFailure: null,
|
||||
failureCount: 0,
|
||||
successCount: 0,
|
||||
averageResponseTime: 0,
|
||||
responseTimes: []
|
||||
});
|
||||
|
||||
// 初始化调用统计
|
||||
this.callStats.set(id, {
|
||||
totalCalls: 0,
|
||||
successCalls: 0,
|
||||
failedCalls: 0,
|
||||
totalResponseTime: 0,
|
||||
lastCallTime: null
|
||||
});
|
||||
|
||||
// 如果启用,启动健康检查
|
||||
if (apiConfig.enabled && apiConfig.healthCheckInterval > 0) {
|
||||
this._startHealthCheck(id);
|
||||
}
|
||||
|
||||
EventBus.emit('api:registered', id, apiConfig);
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量注册接口
|
||||
* @param {Object} apis - 接口映射 {id: config}
|
||||
*/
|
||||
registerBatch(apis) {
|
||||
Object.entries(apis).forEach(([id, config]) => {
|
||||
this.register(id, config);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 调用接口
|
||||
* @param {string} id - 接口ID
|
||||
* @param {Object} options - 调用选项
|
||||
* @returns {Promise} 接口响应
|
||||
*/
|
||||
async call(id, options = {}) {
|
||||
const apiConfig = this.apis.get(id);
|
||||
if (!apiConfig) {
|
||||
throw new Error(`接口 ${id} 未注册`);
|
||||
}
|
||||
|
||||
if (!apiConfig.enabled) {
|
||||
throw new Error(`接口 ${id} 已禁用`);
|
||||
}
|
||||
|
||||
// 检查健康状态
|
||||
const health = this.healthStatus.get(id);
|
||||
if (health && health.status === 'unhealthy') {
|
||||
throw new Error(`接口 ${id} 当前不健康,请稍后重试`);
|
||||
}
|
||||
|
||||
// 更新调用统计
|
||||
const stats = this.callStats.get(id);
|
||||
stats.totalCalls++;
|
||||
stats.lastCallTime = Date.now();
|
||||
|
||||
// 构建请求配置
|
||||
const requestConfig = {
|
||||
url: options.url || apiConfig.url,
|
||||
method: options.method || apiConfig.method,
|
||||
headers: {
|
||||
...apiConfig.headers,
|
||||
...options.headers
|
||||
},
|
||||
body: options.body,
|
||||
timeout: options.timeout || apiConfig.timeout,
|
||||
retries: options.retries !== undefined ? options.retries : apiConfig.retries,
|
||||
retryDelay: options.retryDelay || apiConfig.retryDelay
|
||||
};
|
||||
|
||||
// 使用执行管理器执行请求
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
const result = await this.executionManager.addTask(
|
||||
() => this._makeRequest(requestConfig),
|
||||
{
|
||||
id: `api_${id}_${Date.now()}`,
|
||||
name: `API Call: ${id}`,
|
||||
priority: options.priority || 0,
|
||||
timeout: requestConfig.timeout,
|
||||
retries: requestConfig.retries,
|
||||
apiId: id
|
||||
}
|
||||
);
|
||||
|
||||
const responseTime = Date.now() - startTime;
|
||||
|
||||
// 更新统计
|
||||
stats.successCalls++;
|
||||
stats.totalResponseTime += responseTime;
|
||||
|
||||
// 更新健康状态
|
||||
this._recordSuccess(id, responseTime);
|
||||
|
||||
EventBus.emit('api:call:success', id, result, responseTime);
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
const responseTime = Date.now() - startTime;
|
||||
|
||||
// 更新统计
|
||||
stats.failedCalls++;
|
||||
|
||||
// 更新健康状态
|
||||
this._recordFailure(id, error);
|
||||
|
||||
EventBus.emit('api:call:failed', id, error, responseTime);
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发起HTTP请求
|
||||
* @param {Object} config - 请求配置
|
||||
* @returns {Promise} 响应数据
|
||||
*/
|
||||
async _makeRequest(config) {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), config.timeout);
|
||||
|
||||
try {
|
||||
const fetchOptions = {
|
||||
method: config.method,
|
||||
headers: config.headers,
|
||||
signal: controller.signal
|
||||
};
|
||||
|
||||
if (config.body && (config.method === 'POST' || config.method === 'PUT' || config.method === 'PATCH')) {
|
||||
if (typeof config.body === 'string') {
|
||||
fetchOptions.body = config.body;
|
||||
} else {
|
||||
fetchOptions.body = JSON.stringify(config.body);
|
||||
if (!fetchOptions.headers['Content-Type']) {
|
||||
fetchOptions.headers['Content-Type'] = 'application/json';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const response = await fetch(config.url, fetchOptions);
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const contentType = response.headers.get('content-type');
|
||||
if (contentType && contentType.includes('application/json')) {
|
||||
return await response.json();
|
||||
} else {
|
||||
return await response.text();
|
||||
}
|
||||
} catch (error) {
|
||||
clearTimeout(timeoutId);
|
||||
if (error.name === 'AbortError') {
|
||||
throw new Error('请求超时');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录成功
|
||||
* @param {string} id - 接口ID
|
||||
* @param {number} responseTime - 响应时间
|
||||
*/
|
||||
_recordSuccess(id, responseTime = 0) {
|
||||
const health = this.healthStatus.get(id);
|
||||
if (!health) return;
|
||||
|
||||
health.lastSuccess = Date.now();
|
||||
health.successCount++;
|
||||
health.failureCount = 0;
|
||||
|
||||
if (responseTime > 0) {
|
||||
health.responseTimes.push(responseTime);
|
||||
if (health.responseTimes.length > 100) {
|
||||
health.responseTimes.shift();
|
||||
}
|
||||
health.averageResponseTime = health.responseTimes.reduce((a, b) => a + b, 0) / health.responseTimes.length;
|
||||
}
|
||||
|
||||
// 更新状态
|
||||
if (health.status === 'unhealthy' || health.status === 'unknown') {
|
||||
health.status = 'healthy';
|
||||
EventBus.emit('api:health:recovered', id);
|
||||
}
|
||||
|
||||
health.lastCheck = Date.now();
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录失败
|
||||
* @param {string} id - 接口ID
|
||||
* @param {Error} error - 错误对象
|
||||
*/
|
||||
_recordFailure(id, error) {
|
||||
const health = this.healthStatus.get(id);
|
||||
if (!health) return;
|
||||
|
||||
const apiConfig = this.apis.get(id);
|
||||
if (!apiConfig) return;
|
||||
|
||||
health.lastFailure = Date.now();
|
||||
health.failureCount++;
|
||||
|
||||
// 检查是否需要标记为不健康
|
||||
const recentFailures = this._getRecentFailures(id, apiConfig.failureWindow);
|
||||
if (recentFailures >= apiConfig.maxFailures) {
|
||||
if (health.status !== 'unhealthy') {
|
||||
health.status = 'unhealthy';
|
||||
EventBus.emit('api:health:degraded', id, error);
|
||||
}
|
||||
} else if (health.status === 'healthy') {
|
||||
health.status = 'degraded';
|
||||
}
|
||||
|
||||
health.lastCheck = Date.now();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取最近的失败次数
|
||||
* @param {string} id - 接口ID
|
||||
* @param {number} window - 时间窗口(毫秒)
|
||||
* @returns {number} 失败次数
|
||||
*/
|
||||
_getRecentFailures(id, window) {
|
||||
const health = this.healthStatus.get(id);
|
||||
if (!health) return 0;
|
||||
|
||||
const now = Date.now();
|
||||
const windowStart = now - window;
|
||||
|
||||
// 这里简化处理,实际应该记录每次失败的时间
|
||||
// 暂时使用 failureCount,但需要重置机制
|
||||
return health.failureCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动健康检查
|
||||
* @param {string} id - 接口ID
|
||||
*/
|
||||
_startHealthCheck(id) {
|
||||
const apiConfig = this.apis.get(id);
|
||||
if (!apiConfig || !apiConfig.enabled) return;
|
||||
|
||||
// 清除旧的定时器
|
||||
if (this.healthCheckTimers.has(id)) {
|
||||
clearInterval(this.healthCheckTimers.get(id));
|
||||
}
|
||||
|
||||
// 立即执行一次健康检查
|
||||
this._performHealthCheck(id);
|
||||
|
||||
// 设置定时检查
|
||||
const timer = setInterval(() => {
|
||||
this._performHealthCheck(id);
|
||||
}, apiConfig.healthCheckInterval);
|
||||
|
||||
this.healthCheckTimers.set(id, timer);
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行健康检查
|
||||
* @param {string} id - 接口ID
|
||||
*/
|
||||
async _performHealthCheck(id) {
|
||||
const apiConfig = this.apis.get(id);
|
||||
if (!apiConfig || !apiConfig.enabled) return;
|
||||
|
||||
const health = this.healthStatus.get(id);
|
||||
if (!health) return;
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), apiConfig.healthCheckTimeout);
|
||||
|
||||
const response = await fetch(apiConfig.healthCheckUrl, {
|
||||
method: 'HEAD', // 使用HEAD请求减少带宽
|
||||
signal: controller.signal
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
const responseTime = Date.now() - startTime;
|
||||
|
||||
if (response.ok) {
|
||||
this._recordSuccess(id, responseTime);
|
||||
} else {
|
||||
this._recordFailure(id, new Error(`健康检查失败: HTTP ${response.status}`));
|
||||
}
|
||||
} catch (error) {
|
||||
const responseTime = Date.now() - startTime;
|
||||
this._recordFailure(id, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取接口健康状态
|
||||
* @param {string} id - 接口ID
|
||||
* @returns {Object} 健康状态
|
||||
*/
|
||||
getHealthStatus(id) {
|
||||
return this.healthStatus.get(id) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取接口调用统计
|
||||
* @param {string} id - 接口ID
|
||||
* @returns {Object} 调用统计
|
||||
*/
|
||||
getCallStats(id) {
|
||||
return this.callStats.get(id) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有接口健康状态
|
||||
* @returns {Object} 所有接口的健康状态
|
||||
*/
|
||||
getAllHealthStatus() {
|
||||
const result = {};
|
||||
this.healthStatus.forEach((health, id) => {
|
||||
result[id] = health;
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 启用接口
|
||||
* @param {string} id - 接口ID
|
||||
*/
|
||||
enable(id) {
|
||||
const apiConfig = this.apis.get(id);
|
||||
if (apiConfig) {
|
||||
apiConfig.enabled = true;
|
||||
this._startHealthCheck(id);
|
||||
EventBus.emit('api:enabled', id);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 禁用接口
|
||||
* @param {string} id - 接口ID
|
||||
*/
|
||||
disable(id) {
|
||||
const apiConfig = this.apis.get(id);
|
||||
if (apiConfig) {
|
||||
apiConfig.enabled = false;
|
||||
if (this.healthCheckTimers.has(id)) {
|
||||
clearInterval(this.healthCheckTimers.get(id));
|
||||
this.healthCheckTimers.delete(id);
|
||||
}
|
||||
EventBus.emit('api:disabled', id);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 手动触发健康检查
|
||||
* @param {string} id - 接口ID
|
||||
*/
|
||||
async checkHealth(id) {
|
||||
await this._performHealthCheck(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取接口配置
|
||||
* @param {string} id - 接口ID
|
||||
* @returns {Object} 接口配置
|
||||
*/
|
||||
getApiConfig(id) {
|
||||
return this.apis.get(id) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有已注册的接口ID
|
||||
* @returns {Array<string>} 接口ID列表
|
||||
*/
|
||||
getAllApiIds() {
|
||||
return Array.from(this.apis.keys());
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除所有健康检查定时器
|
||||
*/
|
||||
clearAllHealthChecks() {
|
||||
this.healthCheckTimers.forEach(timer => clearInterval(timer));
|
||||
this.healthCheckTimers.clear();
|
||||
}
|
||||
}
|
||||
|
||||
export default new ApiManager();
|
||||
@@ -0,0 +1,283 @@
|
||||
// src/js/core/Application.js
|
||||
/**
|
||||
* 应用程序核心类
|
||||
* 统一管理应用生命周期、模块初始化、错误处理
|
||||
*/
|
||||
import configManager from '../configManager.js';
|
||||
import uiManager from '../uiManager.js';
|
||||
import toolStatusManager from '../toolStatusManager.js';
|
||||
|
||||
class Application {
|
||||
constructor() {
|
||||
this.isInitialized = false;
|
||||
this.modules = new Map();
|
||||
this.errorHandlers = [];
|
||||
this.lifecycleHooks = {
|
||||
beforeInit: [],
|
||||
afterInit: [],
|
||||
beforeDestroy: [],
|
||||
afterDestroy: []
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册模块
|
||||
* @param {string} name - 模块名称
|
||||
* @param {Object} module - 模块对象
|
||||
*/
|
||||
registerModule(name, module) {
|
||||
if (this.modules.has(name)) {
|
||||
console.warn(`模块 ${name} 已存在,将被覆盖`);
|
||||
}
|
||||
this.modules.set(name, module);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取模块
|
||||
* @param {string} name - 模块名称
|
||||
* @returns {Object|null} 模块对象
|
||||
*/
|
||||
getModule(name) {
|
||||
return this.modules.get(name) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册生命周期钩子
|
||||
* @param {string} hook - 钩子名称 (beforeInit, afterInit, beforeDestroy, afterDestroy)
|
||||
* @param {Function} callback - 回调函数
|
||||
*/
|
||||
onLifecycle(hook, callback) {
|
||||
if (this.lifecycleHooks[hook]) {
|
||||
this.lifecycleHooks[hook].push(callback);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册错误处理器
|
||||
* @param {Function} handler - 错误处理函数
|
||||
*/
|
||||
onError(handler) {
|
||||
this.errorHandlers.push(handler);
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理错误
|
||||
* @param {Error} error - 错误对象
|
||||
* @param {string} context - 错误上下文
|
||||
*/
|
||||
handleError(error, context = 'unknown') {
|
||||
console.error(`[${context}]`, error);
|
||||
|
||||
// 记录错误日志
|
||||
if (configManager && configManager.logAction) {
|
||||
configManager.logAction(
|
||||
`错误 [${context}]: ${error.message}\n${error.stack || ''}`,
|
||||
'error'
|
||||
);
|
||||
}
|
||||
|
||||
// 调用所有错误处理器
|
||||
this.errorHandlers.forEach(handler => {
|
||||
try {
|
||||
handler(error, context);
|
||||
} catch (e) {
|
||||
console.error('错误处理器执行失败:', e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化应用
|
||||
*/
|
||||
async init() {
|
||||
if (this.isInitialized) {
|
||||
console.warn('应用已经初始化');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 执行 beforeInit 钩子
|
||||
await this._executeHooks('beforeInit');
|
||||
|
||||
// 1. 加载语言
|
||||
await this._loadLanguage();
|
||||
|
||||
// 2. 加载配置
|
||||
await this._loadConfig();
|
||||
|
||||
// 3. 初始化核心模块
|
||||
await this._initCoreModules();
|
||||
|
||||
// 4. 初始化UI
|
||||
await this._initUI();
|
||||
|
||||
// 5. 绑定全局事件
|
||||
this._bindGlobalEvents();
|
||||
|
||||
// 执行 afterInit 钩子
|
||||
await this._executeHooks('afterInit');
|
||||
|
||||
this.isInitialized = true;
|
||||
console.log('应用初始化完成');
|
||||
} catch (error) {
|
||||
this.handleError(error, 'Application.init');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载语言
|
||||
*/
|
||||
async _loadLanguage() {
|
||||
try {
|
||||
// 动态导入i18n,避免循环依赖
|
||||
const i18nModule = await import('../i18n.js');
|
||||
const i18n = i18nModule.default;
|
||||
|
||||
// 如果i18n有loadLanguage方法,调用它
|
||||
if (i18n && typeof i18n.loadLanguage === 'function') {
|
||||
await i18n.loadLanguage();
|
||||
} else if (i18n && typeof i18n.init === 'function') {
|
||||
// 否则尝试使用init方法(需要从electronAPI获取配置)
|
||||
if (window.electronAPI && window.electronAPI.getLanguageConfig) {
|
||||
try {
|
||||
const langConfig = await window.electronAPI.getLanguageConfig();
|
||||
if (langConfig) {
|
||||
i18n.init(langConfig.pack, langConfig.fallback, langConfig.actual || langConfig.current);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('无法加载语言配置:', e);
|
||||
i18n.init(null, {}, 'zh-CN');
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('语言加载失败,使用默认语言:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载配置
|
||||
*/
|
||||
async _loadConfig() {
|
||||
try {
|
||||
const config = await new Promise((resolve) => {
|
||||
const timeout = setTimeout(() => {
|
||||
console.warn('配置加载超时,使用默认配置');
|
||||
resolve({ dbSettings: { theme: 'dark' }, isOffline: false });
|
||||
}, 3000);
|
||||
|
||||
if (window.electronAPI && window.electronAPI.onInitialData) {
|
||||
window.electronAPI.onInitialData((data) => {
|
||||
clearTimeout(timeout);
|
||||
resolve(data);
|
||||
});
|
||||
} else {
|
||||
clearTimeout(timeout);
|
||||
resolve({ dbSettings: { theme: 'dark' }, isOffline: false });
|
||||
}
|
||||
});
|
||||
|
||||
if (config.isOffline) {
|
||||
if (uiManager && uiManager.renderOfflinePage) {
|
||||
uiManager.renderOfflinePage(config.error);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (configManager) {
|
||||
configManager.setConfig(config);
|
||||
}
|
||||
} catch (error) {
|
||||
this.handleError(error, 'Application._loadConfig');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化核心模块
|
||||
*/
|
||||
async _initCoreModules() {
|
||||
// 初始化工具状态管理器
|
||||
if (toolStatusManager && toolStatusManager.refresh) {
|
||||
toolStatusManager.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化UI
|
||||
*/
|
||||
async _initUI() {
|
||||
if (uiManager && uiManager.init) {
|
||||
uiManager.init();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 绑定全局事件
|
||||
*/
|
||||
_bindGlobalEvents() {
|
||||
// 全局错误处理
|
||||
window.addEventListener('error', (event) => {
|
||||
const error = event.error || new Error(event.message);
|
||||
this.handleError(error, 'GlobalError');
|
||||
});
|
||||
|
||||
// Promise 未捕获错误
|
||||
window.addEventListener('unhandledrejection', (event) => {
|
||||
const error = event.reason instanceof Error
|
||||
? event.reason
|
||||
: new Error(String(event.reason));
|
||||
this.handleError(error, 'UnhandledRejection');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行生命周期钩子
|
||||
* @param {string} hook - 钩子名称
|
||||
*/
|
||||
async _executeHooks(hook) {
|
||||
const hooks = this.lifecycleHooks[hook] || [];
|
||||
for (const callback of hooks) {
|
||||
try {
|
||||
await callback();
|
||||
} catch (error) {
|
||||
console.error(`生命周期钩子 ${hook} 执行失败:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 销毁应用
|
||||
*/
|
||||
async destroy() {
|
||||
if (!this.isInitialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 执行 beforeDestroy 钩子
|
||||
await this._executeHooks('beforeDestroy');
|
||||
|
||||
// 清理模块
|
||||
this.modules.forEach((module, name) => {
|
||||
if (module && typeof module.destroy === 'function') {
|
||||
try {
|
||||
module.destroy();
|
||||
} catch (error) {
|
||||
console.error(`模块 ${name} 销毁失败:`, error);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 执行 afterDestroy 钩子
|
||||
await this._executeHooks('afterDestroy');
|
||||
|
||||
this.modules.clear();
|
||||
this.isInitialized = false;
|
||||
} catch (error) {
|
||||
this.handleError(error, 'Application.destroy');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new Application();
|
||||
@@ -0,0 +1,102 @@
|
||||
// src/js/core/EventBus.js
|
||||
/**
|
||||
* 事件总线
|
||||
* 统一管理应用内的事件通信
|
||||
*/
|
||||
class EventBus {
|
||||
constructor() {
|
||||
this.events = new Map();
|
||||
this.onceEvents = new Map();
|
||||
}
|
||||
|
||||
/**
|
||||
* 订阅事件
|
||||
* @param {string} event - 事件名称
|
||||
* @param {Function} callback - 回调函数
|
||||
* @returns {Function} 取消订阅函数
|
||||
*/
|
||||
on(event, callback) {
|
||||
if (!this.events.has(event)) {
|
||||
this.events.set(event, []);
|
||||
}
|
||||
this.events.get(event).push(callback);
|
||||
|
||||
// 返回取消订阅函数
|
||||
return () => {
|
||||
this.off(event, callback);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 订阅事件(仅触发一次)
|
||||
* @param {string} event - 事件名称
|
||||
* @param {Function} callback - 回调函数
|
||||
*/
|
||||
once(event, callback) {
|
||||
if (!this.onceEvents.has(event)) {
|
||||
this.onceEvents.set(event, []);
|
||||
}
|
||||
this.onceEvents.get(event).push(callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消订阅事件
|
||||
* @param {string} event - 事件名称
|
||||
* @param {Function} callback - 回调函数(可选)
|
||||
*/
|
||||
off(event, callback) {
|
||||
if (callback) {
|
||||
const callbacks = this.events.get(event);
|
||||
if (callbacks) {
|
||||
const index = callbacks.indexOf(callback);
|
||||
if (index > -1) {
|
||||
callbacks.splice(index, 1);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
this.events.delete(event);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 触发事件
|
||||
* @param {string} event - 事件名称
|
||||
* @param {...any} args - 事件参数
|
||||
*/
|
||||
emit(event, ...args) {
|
||||
// 触发普通订阅
|
||||
const callbacks = this.events.get(event);
|
||||
if (callbacks) {
|
||||
callbacks.forEach(callback => {
|
||||
try {
|
||||
callback(...args);
|
||||
} catch (error) {
|
||||
console.error(`事件 ${event} 的回调执行失败:`, error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 触发一次性订阅
|
||||
const onceCallbacks = this.onceEvents.get(event);
|
||||
if (onceCallbacks) {
|
||||
onceCallbacks.forEach(callback => {
|
||||
try {
|
||||
callback(...args);
|
||||
} catch (error) {
|
||||
console.error(`事件 ${event} 的一次性回调执行失败:`, error);
|
||||
}
|
||||
});
|
||||
this.onceEvents.delete(event);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除所有事件监听
|
||||
*/
|
||||
clear() {
|
||||
this.events.clear();
|
||||
this.onceEvents.clear();
|
||||
}
|
||||
}
|
||||
|
||||
export default new EventBus();
|
||||
@@ -0,0 +1,215 @@
|
||||
// src/js/core/ExecutionManager.js
|
||||
/**
|
||||
* 执行逻辑管理器
|
||||
* 统一管理任务执行、队列、优先级、错误处理
|
||||
*/
|
||||
import EventBus from './EventBus.js';
|
||||
|
||||
class ExecutionManager {
|
||||
constructor() {
|
||||
this.taskQueue = [];
|
||||
this.isRunning = false;
|
||||
this.maxConcurrency = 5; // 最大并发数
|
||||
this.runningTasks = new Set();
|
||||
this.taskHistory = [];
|
||||
this.maxHistorySize = 100;
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加任务到队列
|
||||
* @param {Function} task - 任务函数(返回Promise)
|
||||
* @param {Object} options - 任务选项
|
||||
* @returns {Promise} 任务Promise
|
||||
*/
|
||||
async addTask(task, options = {}) {
|
||||
const {
|
||||
priority = 0,
|
||||
timeout = 0,
|
||||
retries = 0,
|
||||
id = `task_${Date.now()}_${Math.random()}`,
|
||||
name = 'Unnamed Task'
|
||||
} = options;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const taskItem = {
|
||||
id,
|
||||
name,
|
||||
task,
|
||||
priority,
|
||||
timeout,
|
||||
retries,
|
||||
resolve,
|
||||
reject,
|
||||
attempts: 0,
|
||||
createdAt: Date.now()
|
||||
};
|
||||
|
||||
// 按优先级插入队列
|
||||
this._insertTask(taskItem);
|
||||
|
||||
// 触发任务添加事件
|
||||
EventBus.emit('execution:task:added', taskItem);
|
||||
|
||||
// 尝试执行
|
||||
this._processQueue();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 按优先级插入任务
|
||||
* @param {Object} taskItem - 任务项
|
||||
*/
|
||||
_insertTask(taskItem) {
|
||||
let inserted = false;
|
||||
for (let i = 0; i < this.taskQueue.length; i++) {
|
||||
if (this.taskQueue[i].priority < taskItem.priority) {
|
||||
this.taskQueue.splice(i, 0, taskItem);
|
||||
inserted = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!inserted) {
|
||||
this.taskQueue.push(taskItem);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理任务队列
|
||||
*/
|
||||
async _processQueue() {
|
||||
if (this.isRunning && this.runningTasks.size >= this.maxConcurrency) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.taskQueue.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.isRunning = true;
|
||||
|
||||
while (this.taskQueue.length > 0 && this.runningTasks.size < this.maxConcurrency) {
|
||||
const taskItem = this.taskQueue.shift();
|
||||
this._executeTask(taskItem);
|
||||
}
|
||||
|
||||
if (this.runningTasks.size === 0) {
|
||||
this.isRunning = false;
|
||||
EventBus.emit('execution:queue:empty');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行任务
|
||||
* @param {Object} taskItem - 任务项
|
||||
*/
|
||||
async _executeTask(taskItem) {
|
||||
this.runningTasks.add(taskItem.id);
|
||||
EventBus.emit('execution:task:started', taskItem);
|
||||
|
||||
const startTime = Date.now();
|
||||
let lastError = null;
|
||||
|
||||
try {
|
||||
// 创建带超时的Promise
|
||||
let taskPromise = taskItem.task();
|
||||
|
||||
if (taskItem.timeout > 0) {
|
||||
taskPromise = Promise.race([
|
||||
taskPromise,
|
||||
new Promise((_, reject) =>
|
||||
setTimeout(() => reject(new Error('Task timeout')), taskItem.timeout)
|
||||
)
|
||||
]);
|
||||
}
|
||||
|
||||
const result = await taskPromise;
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
// 记录历史
|
||||
this._recordHistory({
|
||||
...taskItem,
|
||||
status: 'success',
|
||||
duration,
|
||||
completedAt: Date.now()
|
||||
});
|
||||
|
||||
taskItem.resolve(result);
|
||||
EventBus.emit('execution:task:completed', taskItem, result);
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
taskItem.attempts++;
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
// 记录历史
|
||||
this._recordHistory({
|
||||
...taskItem,
|
||||
status: 'failed',
|
||||
error: error.message,
|
||||
duration,
|
||||
completedAt: Date.now()
|
||||
});
|
||||
|
||||
// 如果还有重试次数,重新加入队列
|
||||
if (taskItem.attempts <= taskItem.retries) {
|
||||
console.warn(`任务 ${taskItem.name} 失败,重试中 (${taskItem.attempts}/${taskItem.retries})`);
|
||||
this._insertTask(taskItem);
|
||||
EventBus.emit('execution:task:retry', taskItem);
|
||||
} else {
|
||||
taskItem.reject(error);
|
||||
EventBus.emit('execution:task:failed', taskItem, error);
|
||||
}
|
||||
} finally {
|
||||
this.runningTasks.delete(taskItem.id);
|
||||
|
||||
// 继续处理队列
|
||||
this._processQueue();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录任务历史
|
||||
* @param {Object} record - 任务记录
|
||||
*/
|
||||
_recordHistory(record) {
|
||||
this.taskHistory.push(record);
|
||||
if (this.taskHistory.length > this.maxHistorySize) {
|
||||
this.taskHistory.shift();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取任务历史
|
||||
* @param {number} limit - 限制数量
|
||||
* @returns {Array} 任务历史
|
||||
*/
|
||||
getHistory(limit = 10) {
|
||||
return this.taskHistory.slice(-limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除任务队列
|
||||
*/
|
||||
clearQueue() {
|
||||
this.taskQueue.forEach(taskItem => {
|
||||
taskItem.reject(new Error('Task queue cleared'));
|
||||
});
|
||||
this.taskQueue = [];
|
||||
EventBus.emit('execution:queue:cleared');
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取队列状态
|
||||
* @returns {Object} 队列状态
|
||||
*/
|
||||
getStatus() {
|
||||
return {
|
||||
queueLength: this.taskQueue.length,
|
||||
runningCount: this.runningTasks.size,
|
||||
maxConcurrency: this.maxConcurrency,
|
||||
isRunning: this.isRunning
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default new ExecutionManager();
|
||||
@@ -0,0 +1,208 @@
|
||||
// src/js/core/InitializationSystem.js
|
||||
/**
|
||||
* 初始化系统
|
||||
* [重构] 统一管理应用启动流程,支持插件化扩展
|
||||
*/
|
||||
import EventBus from './EventBus.js';
|
||||
import ServiceContainer from './ServiceContainer.js';
|
||||
import PluginSystem from './PluginSystem.js';
|
||||
import ApiManager from './ApiManager.js';
|
||||
|
||||
class InitializationSystem {
|
||||
constructor() {
|
||||
this.stages = [];
|
||||
this.currentStage = null;
|
||||
this.isInitialized = false;
|
||||
this.initProgress = {
|
||||
current: 0,
|
||||
total: 0,
|
||||
stage: null
|
||||
};
|
||||
|
||||
// 初始化阶段定义
|
||||
this.defaultStages = [
|
||||
{ id: 'pre-init', name: '预初始化', weight: 5 },
|
||||
{ id: 'database', name: '数据库初始化', weight: 10 },
|
||||
{ id: 'config', name: '配置加载', weight: 10 },
|
||||
{ id: 'language', name: '语言加载', weight: 5 },
|
||||
{ id: 'api-registration', name: '接口注册', weight: 15 },
|
||||
{ id: 'health-check', name: '健康检查', weight: 10 },
|
||||
{ id: 'plugins', name: '插件加载', weight: 15 },
|
||||
{ id: 'ui', name: 'UI初始化', weight: 20 },
|
||||
{ id: 'post-init', name: '后初始化', weight: 10 }
|
||||
];
|
||||
|
||||
// 注册默认阶段
|
||||
this.defaultStages.forEach(stage => {
|
||||
this.registerStage(stage.id, stage.name, stage.weight);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册初始化阶段
|
||||
* @param {string} id - 阶段ID
|
||||
* @param {string} name - 阶段名称
|
||||
* @param {number} weight - 阶段权重(用于计算进度)
|
||||
*/
|
||||
registerStage(id, name, weight = 10) {
|
||||
if (this.stages.find(s => s.id === id)) {
|
||||
console.warn(`初始化阶段 ${id} 已存在,将被覆盖`);
|
||||
}
|
||||
|
||||
this.stages.push({
|
||||
id,
|
||||
name,
|
||||
weight,
|
||||
handlers: []
|
||||
});
|
||||
|
||||
// 按权重排序
|
||||
this.stages.sort((a, b) => {
|
||||
const order = ['pre-init', 'database', 'config', 'language', 'api-registration', 'health-check', 'plugins', 'ui', 'post-init'];
|
||||
const aIndex = order.indexOf(a.id);
|
||||
const bIndex = order.indexOf(b.id);
|
||||
if (aIndex !== -1 && bIndex !== -1) return aIndex - bIndex;
|
||||
if (aIndex !== -1) return -1;
|
||||
if (bIndex !== -1) return 1;
|
||||
return 0;
|
||||
});
|
||||
|
||||
// 重新计算总权重
|
||||
this._calculateTotalWeight();
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册阶段处理器
|
||||
* @param {string} stageId - 阶段ID
|
||||
* @param {Function} handler - 处理器函数
|
||||
* @param {number} priority - 优先级(数字越大越先执行)
|
||||
*/
|
||||
registerHandler(stageId, handler, priority = 0) {
|
||||
const stage = this.stages.find(s => s.id === stageId);
|
||||
if (!stage) {
|
||||
throw new Error(`初始化阶段 ${stageId} 不存在`);
|
||||
}
|
||||
|
||||
if (typeof handler !== 'function') {
|
||||
throw new Error('处理器必须是函数');
|
||||
}
|
||||
|
||||
stage.handlers.push({
|
||||
handler,
|
||||
priority
|
||||
});
|
||||
|
||||
// 按优先级排序
|
||||
stage.handlers.sort((a, b) => b.priority - a.priority);
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算总权重
|
||||
*/
|
||||
_calculateTotalWeight() {
|
||||
this.initProgress.total = this.stages.reduce((sum, stage) => sum + stage.weight, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化应用
|
||||
* @param {Function} progressCallback - 进度回调函数
|
||||
* @returns {Promise} 初始化Promise
|
||||
*/
|
||||
async init(progressCallback = null) {
|
||||
if (this.isInitialized) {
|
||||
console.warn('应用已经初始化');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
EventBus.emit('initialization:start');
|
||||
|
||||
// 计算总权重
|
||||
this._calculateTotalWeight();
|
||||
|
||||
let completedWeight = 0;
|
||||
|
||||
// 按顺序执行各阶段
|
||||
for (const stage of this.stages) {
|
||||
this.currentStage = stage.id;
|
||||
this.initProgress.stage = stage.name;
|
||||
|
||||
EventBus.emit('initialization:stage:start', stage.id, stage.name);
|
||||
|
||||
// 执行该阶段的所有处理器
|
||||
for (const handlerItem of stage.handlers) {
|
||||
try {
|
||||
await handlerItem.handler();
|
||||
} catch (error) {
|
||||
console.error(`初始化阶段 ${stage.id} 的处理器执行失败:`, error);
|
||||
EventBus.emit('initialization:stage:error', stage.id, error);
|
||||
// 继续执行其他处理器,不中断初始化
|
||||
}
|
||||
}
|
||||
|
||||
completedWeight += stage.weight;
|
||||
this.initProgress.current = (completedWeight / this.initProgress.total) * 100;
|
||||
|
||||
// 调用进度回调
|
||||
if (progressCallback) {
|
||||
progressCallback({
|
||||
stage: stage.name,
|
||||
progress: this.initProgress.current,
|
||||
current: completedWeight,
|
||||
total: this.initProgress.total
|
||||
});
|
||||
}
|
||||
|
||||
EventBus.emit('initialization:stage:complete', stage.id, stage.name);
|
||||
}
|
||||
|
||||
this.isInitialized = true;
|
||||
this.currentStage = null;
|
||||
|
||||
EventBus.emit('initialization:complete');
|
||||
} catch (error) {
|
||||
console.error('初始化失败:', error);
|
||||
EventBus.emit('initialization:error', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前进度
|
||||
* @returns {Object} 进度信息
|
||||
*/
|
||||
getProgress() {
|
||||
return { ...this.initProgress };
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前阶段
|
||||
* @returns {string} 当前阶段ID
|
||||
*/
|
||||
getCurrentStage() {
|
||||
return this.currentStage;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否已初始化
|
||||
* @returns {boolean} 是否已初始化
|
||||
*/
|
||||
isReady() {
|
||||
return this.isInitialized;
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置初始化状态(用于测试)
|
||||
*/
|
||||
reset() {
|
||||
this.isInitialized = false;
|
||||
this.currentStage = null;
|
||||
this.initProgress = {
|
||||
current: 0,
|
||||
total: 0,
|
||||
stage: null
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default new InitializationSystem();
|
||||
@@ -0,0 +1,165 @@
|
||||
// src/js/core/PluginSystem.js
|
||||
/**
|
||||
* 插件系统
|
||||
* [预留框架] 支持动态加载和注册插件
|
||||
*/
|
||||
import EventBus from './EventBus.js';
|
||||
import ServiceContainer from './ServiceContainer.js';
|
||||
|
||||
class PluginSystem {
|
||||
constructor() {
|
||||
this.plugins = new Map();
|
||||
this.hooks = new Map();
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册插件
|
||||
* @param {string} name - 插件名称
|
||||
* @param {Object} plugin - 插件对象
|
||||
*/
|
||||
register(name, plugin) {
|
||||
if (this.plugins.has(name)) {
|
||||
console.warn(`插件 ${name} 已存在,将被覆盖`);
|
||||
}
|
||||
|
||||
// 验证插件结构
|
||||
if (!plugin.init || typeof plugin.init !== 'function') {
|
||||
throw new Error(`插件 ${name} 必须实现 init() 方法`);
|
||||
}
|
||||
|
||||
this.plugins.set(name, {
|
||||
name,
|
||||
...plugin,
|
||||
enabled: true,
|
||||
loaded: false
|
||||
});
|
||||
|
||||
// 注册到服务容器
|
||||
ServiceContainer.register(`plugin:${name}`, plugin, true);
|
||||
|
||||
EventBus.emit('plugin:registered', name, plugin);
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载插件
|
||||
* @param {string} name - 插件名称
|
||||
*/
|
||||
async load(name) {
|
||||
const plugin = this.plugins.get(name);
|
||||
if (!plugin) {
|
||||
throw new Error(`插件 ${name} 未注册`);
|
||||
}
|
||||
|
||||
if (plugin.loaded) {
|
||||
console.warn(`插件 ${name} 已经加载`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (plugin.beforeLoad) {
|
||||
await plugin.beforeLoad();
|
||||
}
|
||||
|
||||
await plugin.init();
|
||||
|
||||
if (plugin.afterLoad) {
|
||||
await plugin.afterLoad();
|
||||
}
|
||||
|
||||
plugin.loaded = true;
|
||||
EventBus.emit('plugin:loaded', name, plugin);
|
||||
} catch (error) {
|
||||
console.error(`加载插件失败 [${name}]:`, error);
|
||||
EventBus.emit('plugin:load:failed', name, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 卸载插件
|
||||
* @param {string} name - 插件名称
|
||||
*/
|
||||
async unload(name) {
|
||||
const plugin = this.plugins.get(name);
|
||||
if (!plugin) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!plugin.loaded) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (plugin.beforeUnload) {
|
||||
await plugin.beforeUnload();
|
||||
}
|
||||
|
||||
if (plugin.destroy && typeof plugin.destroy === 'function') {
|
||||
await plugin.destroy();
|
||||
}
|
||||
|
||||
if (plugin.afterUnload) {
|
||||
await plugin.afterUnload();
|
||||
}
|
||||
|
||||
plugin.loaded = false;
|
||||
EventBus.emit('plugin:unloaded', name, plugin);
|
||||
} catch (error) {
|
||||
console.error(`卸载插件失败 [${name}]:`, error);
|
||||
EventBus.emit('plugin:unload:failed', name, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取插件
|
||||
* @param {string} name - 插件名称
|
||||
* @returns {Object|null} 插件对象
|
||||
*/
|
||||
get(name) {
|
||||
return this.plugins.get(name) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册钩子
|
||||
* @param {string} hook - 钩子名称
|
||||
* @param {Function} callback - 回调函数
|
||||
*/
|
||||
registerHook(hook, callback) {
|
||||
if (!this.hooks.has(hook)) {
|
||||
this.hooks.set(hook, []);
|
||||
}
|
||||
this.hooks.get(hook).push(callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* 触发钩子
|
||||
* @param {string} hook - 钩子名称
|
||||
* @param {...any} args - 参数
|
||||
* @returns {Promise<Array>} 所有回调的结果
|
||||
*/
|
||||
async triggerHook(hook, ...args) {
|
||||
const callbacks = this.hooks.get(hook) || [];
|
||||
const results = [];
|
||||
|
||||
for (const callback of callbacks) {
|
||||
try {
|
||||
const result = await callback(...args);
|
||||
results.push(result);
|
||||
} catch (error) {
|
||||
console.error(`钩子 ${hook} 的回调执行失败:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有插件
|
||||
* @returns {Array} 插件列表
|
||||
*/
|
||||
getAllPlugins() {
|
||||
return Array.from(this.plugins.values());
|
||||
}
|
||||
}
|
||||
|
||||
export default new PluginSystem();
|
||||
@@ -0,0 +1,95 @@
|
||||
// src/js/core/ServiceContainer.js
|
||||
/**
|
||||
* 服务容器
|
||||
* 依赖注入容器,统一管理服务实例
|
||||
*/
|
||||
class ServiceContainer {
|
||||
constructor() {
|
||||
this.services = new Map();
|
||||
this.singletons = new Map();
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册服务
|
||||
* @param {string} name - 服务名称
|
||||
* @param {Function|Object} service - 服务类或服务实例
|
||||
* @param {boolean} singleton - 是否单例
|
||||
*/
|
||||
register(name, service, singleton = true) {
|
||||
if (singleton && this.singletons.has(name)) {
|
||||
console.warn(`服务 ${name} 已存在,将被覆盖`);
|
||||
}
|
||||
|
||||
this.services.set(name, {
|
||||
service,
|
||||
singleton
|
||||
});
|
||||
|
||||
if (singleton && typeof service !== 'function') {
|
||||
// 如果是实例且为单例,直接保存
|
||||
this.singletons.set(name, service);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取服务
|
||||
* @param {string} name - 服务名称
|
||||
* @returns {Object|null} 服务实例
|
||||
*/
|
||||
get(name) {
|
||||
const serviceDef = this.services.get(name);
|
||||
if (!serviceDef) {
|
||||
console.warn(`服务 ${name} 未注册`);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (serviceDef.singleton) {
|
||||
// 单例模式
|
||||
if (!this.singletons.has(name)) {
|
||||
if (typeof serviceDef.service === 'function') {
|
||||
// 如果是类,实例化
|
||||
this.singletons.set(name, new serviceDef.service());
|
||||
} else {
|
||||
// 如果是实例,直接保存
|
||||
this.singletons.set(name, serviceDef.service);
|
||||
}
|
||||
}
|
||||
return this.singletons.get(name);
|
||||
} else {
|
||||
// 非单例模式,每次创建新实例
|
||||
if (typeof serviceDef.service === 'function') {
|
||||
return new serviceDef.service();
|
||||
} else {
|
||||
return serviceDef.service;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查服务是否存在
|
||||
* @param {string} name - 服务名称
|
||||
* @returns {boolean} 服务是否存在
|
||||
*/
|
||||
has(name) {
|
||||
return this.services.has(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除服务
|
||||
* @param {string} name - 服务名称
|
||||
*/
|
||||
remove(name) {
|
||||
this.services.delete(name);
|
||||
this.singletons.delete(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除所有服务
|
||||
*/
|
||||
clear() {
|
||||
this.services.clear();
|
||||
this.singletons.clear();
|
||||
}
|
||||
}
|
||||
|
||||
export default new ServiceContainer();
|
||||
@@ -0,0 +1,218 @@
|
||||
// src/js/core/ToolFramework.js
|
||||
/**
|
||||
* 工具框架核心类
|
||||
* 统一管理工具的生命周期、注册、加载、执行
|
||||
*/
|
||||
import BaseTool from '../baseTool.js';
|
||||
import toolStatusManager from '../toolStatusManager.js';
|
||||
import configManager from '../configManager.js';
|
||||
|
||||
class ToolFramework {
|
||||
constructor() {
|
||||
this.tools = new Map(); // 工具注册表
|
||||
this.activeTools = new Map(); // 当前活动的工具实例
|
||||
this.toolMetadata = new Map(); // 工具元数据
|
||||
this.loaders = new Map(); // 工具加载器
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册工具
|
||||
* @param {string} id - 工具ID
|
||||
* @param {Class} ToolClass - 工具类
|
||||
* @param {Object} metadata - 工具元数据
|
||||
*/
|
||||
registerTool(id, ToolClass, metadata = {}) {
|
||||
if (!id || !ToolClass) {
|
||||
throw new Error('工具ID和工具类不能为空');
|
||||
}
|
||||
|
||||
if (!(ToolClass.prototype instanceof BaseTool)) {
|
||||
throw new Error(`工具 ${id} 必须继承自 BaseTool`);
|
||||
}
|
||||
|
||||
this.tools.set(id, ToolClass);
|
||||
this.toolMetadata.set(id, {
|
||||
id,
|
||||
name: metadata.name || id,
|
||||
description: metadata.description || '',
|
||||
iconUrl: metadata.iconUrl || '',
|
||||
category: metadata.category || 'other',
|
||||
launchType: metadata.launchType || 'inline',
|
||||
view: metadata.view || 'view-tool',
|
||||
healthCheckConfig: metadata.healthCheckConfig || null,
|
||||
...metadata
|
||||
});
|
||||
|
||||
console.log(`工具已注册: ${id}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量注册工具
|
||||
* @param {Object} tools - 工具映射 {id: {ToolClass, metadata}}
|
||||
*/
|
||||
registerTools(tools) {
|
||||
Object.entries(tools).forEach(([id, { ToolClass, metadata }]) => {
|
||||
this.registerTool(id, ToolClass, metadata);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取工具类
|
||||
* @param {string} id - 工具ID
|
||||
* @returns {Class|null} 工具类
|
||||
*/
|
||||
getToolClass(id) {
|
||||
return this.tools.get(id) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取工具元数据
|
||||
* @param {string} id - 工具ID
|
||||
* @returns {Object|null} 工具元数据
|
||||
*/
|
||||
getToolMetadata(id) {
|
||||
return this.toolMetadata.get(id) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有工具ID
|
||||
* @returns {Array<string>} 工具ID列表
|
||||
*/
|
||||
getAllToolIds() {
|
||||
return Array.from(this.tools.keys());
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查工具是否可用
|
||||
* @param {string} id - 工具ID
|
||||
* @returns {boolean} 工具是否可用
|
||||
*/
|
||||
isToolAvailable(id) {
|
||||
if (!this.tools.has(id)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const status = toolStatusManager.getToolStatus(id);
|
||||
return status.enabled && status.healthStatus !== 'unhealthy';
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建工具实例
|
||||
* @param {string} id - 工具ID
|
||||
* @returns {BaseTool|null} 工具实例
|
||||
*/
|
||||
createToolInstance(id) {
|
||||
const ToolClass = this.getToolClass(id);
|
||||
if (!ToolClass) {
|
||||
console.error(`工具 ${id} 未注册`);
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const instance = new ToolClass();
|
||||
return instance;
|
||||
} catch (error) {
|
||||
console.error(`创建工具实例失败 [${id}]:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动工具
|
||||
* @param {string} id - 工具ID
|
||||
* @param {Object} options - 启动选项
|
||||
* @returns {Promise<BaseTool|null>} 工具实例
|
||||
*/
|
||||
async launchTool(id, options = {}) {
|
||||
// 检查工具是否可用
|
||||
if (!this.isToolAvailable(id)) {
|
||||
const status = toolStatusManager.getToolStatus(id);
|
||||
const message = status.message || '工具暂时不可用';
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
// 获取工具元数据
|
||||
const metadata = this.getToolMetadata(id);
|
||||
if (!metadata) {
|
||||
throw new Error(`工具 ${id} 的元数据不存在`);
|
||||
}
|
||||
|
||||
// 创建工具实例
|
||||
const instance = this.createToolInstance(id);
|
||||
if (!instance) {
|
||||
throw new Error(`工具 ${id} 实例化失败`);
|
||||
}
|
||||
|
||||
// 记录日志
|
||||
if (configManager && configManager.logAction) {
|
||||
configManager.logAction(`[${metadata.name}] 启动工具`, 'tool');
|
||||
}
|
||||
|
||||
// 保存活动实例
|
||||
this.activeTools.set(id, instance);
|
||||
|
||||
return instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* 销毁工具实例
|
||||
* @param {string} id - 工具ID
|
||||
*/
|
||||
destroyTool(id) {
|
||||
const instance = this.activeTools.get(id);
|
||||
if (instance) {
|
||||
try {
|
||||
if (typeof instance.destroy === 'function') {
|
||||
instance.destroy();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`销毁工具失败 [${id}]:`, error);
|
||||
}
|
||||
this.activeTools.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 销毁所有活动工具
|
||||
*/
|
||||
destroyAllTools() {
|
||||
this.activeTools.forEach((instance, id) => {
|
||||
this.destroyTool(id);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取活动工具实例
|
||||
* @param {string} id - 工具ID
|
||||
* @returns {BaseTool|null} 工具实例
|
||||
*/
|
||||
getActiveTool(id) {
|
||||
return this.activeTools.get(id) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册工具加载器
|
||||
* @param {string} type - 加载器类型
|
||||
* @param {Function} loader - 加载器函数
|
||||
*/
|
||||
registerLoader(type, loader) {
|
||||
this.loaders.set(type, loader);
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用加载器加载工具
|
||||
* @param {string} id - 工具ID
|
||||
* @param {string} type - 加载器类型
|
||||
* @param {Object} options - 加载选项
|
||||
*/
|
||||
async loadTool(id, type = 'default', options = {}) {
|
||||
const loader = this.loaders.get(type);
|
||||
if (!loader) {
|
||||
throw new Error(`加载器 ${type} 不存在`);
|
||||
}
|
||||
|
||||
return await loader(id, options);
|
||||
}
|
||||
}
|
||||
|
||||
export default new ToolFramework();
|
||||
@@ -0,0 +1,13 @@
|
||||
// src/js/core/index.js
|
||||
/**
|
||||
* [重构] 核心模块统一导出
|
||||
* 提供完整的应用框架系统
|
||||
*/
|
||||
export { default as Application } from './Application.js';
|
||||
export { default as ToolFramework } from './ToolFramework.js';
|
||||
export { default as EventBus } from './EventBus.js';
|
||||
export { default as ServiceContainer } from './ServiceContainer.js';
|
||||
export { default as ExecutionManager } from './ExecutionManager.js';
|
||||
export { default as PluginSystem } from './PluginSystem.js';
|
||||
export { default as ApiManager } from './ApiManager.js';
|
||||
export { default as InitializationSystem } from './InitializationSystem.js';
|
||||
@@ -0,0 +1,145 @@
|
||||
// src/js/i18n.js
|
||||
|
||||
class Translator {
|
||||
constructor() {
|
||||
this.strings = {};
|
||||
this.fallbackStrings = {};
|
||||
this.currentLang = 'zh-CN';
|
||||
this.isTranslating = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化翻译器
|
||||
* @param {object} languagePack - 从 main.js 加载的语言包 (e.g., en-US.json)
|
||||
* @param {object} fallbackPack - 默认的中文语言包
|
||||
* @param {string} currentLang - 当前语言代码
|
||||
*/
|
||||
init(languagePack, fallbackPack, currentLang = 'zh-CN') {
|
||||
this.strings = languagePack || {};
|
||||
this.fallbackStrings = fallbackPack || {};
|
||||
this.currentLang = currentLang || 'zh-CN';
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取翻译后的字符串
|
||||
* @param {string} key - 语言键 (e.g., "nav.home")
|
||||
* @param {object} [replaces] - (可选) 替换占位符, e.g., {count: 5, time: 100}
|
||||
* @param {string} [defaultValue] - (可选) 默认值,如果找不到翻译则使用此值
|
||||
* @returns {string} - 翻译后的字符串
|
||||
*/
|
||||
t(key, replaces = null, defaultValue = null) {
|
||||
let str = this.strings[key];
|
||||
|
||||
if (str === undefined) {
|
||||
str = this.fallbackStrings[key];
|
||||
}
|
||||
|
||||
// 如果仍然找不到,使用默认值或硬编码的兜底翻译
|
||||
if (str === undefined) {
|
||||
if (defaultValue !== null) {
|
||||
str = defaultValue;
|
||||
} else {
|
||||
// 硬编码的兜底翻译(中文)
|
||||
str = this._getFallbackTranslation(key);
|
||||
}
|
||||
|
||||
// 只在开发环境或调试模式下警告
|
||||
if (str === key && (process?.env?.NODE_ENV === 'development' || window.location.href.includes('localhost'))) {
|
||||
console.warn(`[i18n] Missing translation for key: ${key}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 处理占位符
|
||||
if (replaces && str) {
|
||||
for (const [placeholder, value] of Object.entries(replaces)) {
|
||||
// 使用字符串替换而不是正则表达式,避免混淆器破坏正则表达式语法
|
||||
// 转义花括号以确保它们被当作字面量处理
|
||||
const escapedPlaceholder = `{${placeholder}}`;
|
||||
// 使用全局字符串替换(手动实现,因为 replace 默认只替换第一个)
|
||||
str = str.split(escapedPlaceholder).join(String(value));
|
||||
}
|
||||
}
|
||||
|
||||
return str || key;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取硬编码的兜底翻译(中文)
|
||||
* @param {string} key - 语言键
|
||||
* @returns {string} - 兜底翻译或键名
|
||||
*/
|
||||
_getFallbackTranslation(key) {
|
||||
const hardcodedTranslations = {
|
||||
'nav.home': '主页',
|
||||
'nav.toolbox': '工具箱',
|
||||
'nav.logs': '日志',
|
||||
'nav.settings': '设置',
|
||||
'home.greeting.morning': '早上好, 新的一天元气满满',
|
||||
'home.greeting.noon': '中午好, 午休时间到了',
|
||||
'home.greeting.afternoon': '下午好, 继续努力吧',
|
||||
'home.greeting.evening': '晚上好, 放松一下吧',
|
||||
'home.greeting.night': '凌晨了, 注意休息哦',
|
||||
'home.welcome': '欢迎使用 YMhut Box, 愿你拥有美好的一天。',
|
||||
'home.announcement': '公告',
|
||||
'home.announcement.failed': '公告加载失败...',
|
||||
'home.announcement.viewFull': '查看完整公告',
|
||||
'home.search.placeholder': '智能搜索:输入查询内容...',
|
||||
'home.search.button': '搜索',
|
||||
'home.search.disabled': '智能搜索工具当前不可用',
|
||||
'home.updates': '更新日志',
|
||||
'common.loading': '加载中...',
|
||||
'common.search': '搜索',
|
||||
'common.backToToolbox': '返回工具箱',
|
||||
'common.error': '错误',
|
||||
'common.loading.tool': '正在初始化工具模块...',
|
||||
'common.notification.title.success': '成功',
|
||||
'common.notification.title.error': '错误',
|
||||
'common.notification.title.info': '提示',
|
||||
'settings.appearance': '外观',
|
||||
'settings.updates': '更新管理',
|
||||
'settings.about': '关于'
|
||||
};
|
||||
|
||||
return hardcodedTranslations[key] || key;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前语言
|
||||
* @returns {string} - 当前语言代码
|
||||
*/
|
||||
getCurrentLang() {
|
||||
return this.currentLang;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置当前语言
|
||||
* @param {string} lang - 语言代码
|
||||
*/
|
||||
setCurrentLang(lang) {
|
||||
this.currentLang = lang;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否有翻译
|
||||
* @param {string} key - 语言键
|
||||
* @returns {boolean} - 是否有翻译
|
||||
*/
|
||||
hasTranslation(key) {
|
||||
return this.strings[key] !== undefined || this.fallbackStrings[key] !== undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有翻译键
|
||||
* @returns {string[]} - 所有翻译键
|
||||
*/
|
||||
getAllKeys() {
|
||||
const keys = new Set();
|
||||
Object.keys(this.strings).forEach(k => keys.add(k));
|
||||
Object.keys(this.fallbackStrings).forEach(k => keys.add(k));
|
||||
return Array.from(keys);
|
||||
}
|
||||
}
|
||||
|
||||
// 导出一个单例
|
||||
const i18n = new Translator();
|
||||
export default i18n;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,79 @@
|
||||
// src/js/splash.js
|
||||
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
const progressBar = document.getElementById('splash-progress-bar');
|
||||
const statusText = document.getElementById('splash-status');
|
||||
const percentText = document.getElementById('splash-percent');
|
||||
|
||||
let currentProgress = 0;
|
||||
let targetProgress = 0;
|
||||
let animationFrameId;
|
||||
|
||||
// 缓动动画:更平滑的阻尼效果
|
||||
const updateUI = () => {
|
||||
// 距离目标的差值
|
||||
const diff = targetProgress - currentProgress;
|
||||
|
||||
// 动态速度:距离越远跑得越快,距离近了慢慢停下
|
||||
// 0.08 的系数比之前的更小,意味着动画更“粘稠”、更有质感
|
||||
if (Math.abs(diff) > 0.1) {
|
||||
currentProgress += diff * 0.08;
|
||||
} else {
|
||||
currentProgress = targetProgress;
|
||||
}
|
||||
|
||||
// 更新 DOM
|
||||
const displayPercent = Math.floor(currentProgress);
|
||||
progressBar.style.width = `${currentProgress}%`;
|
||||
percentText.textContent = `${displayPercent}%`;
|
||||
|
||||
// 如果还没停止
|
||||
if (currentProgress < 100 && (Math.abs(diff) > 0.1 || targetProgress < 100)) {
|
||||
animationFrameId = requestAnimationFrame(updateUI);
|
||||
} else if (currentProgress >= 99.9) {
|
||||
// 确保最后定格在 100%
|
||||
progressBar.style.width = '100%';
|
||||
percentText.textContent = '100%';
|
||||
}
|
||||
};
|
||||
|
||||
window.electronAPI.onInitProgress(({ status, progress }) => {
|
||||
// 文字带有轻微的淡入淡出效果 (可选优化)
|
||||
statusText.style.opacity = 0;
|
||||
setTimeout(() => {
|
||||
statusText.textContent = status;
|
||||
statusText.style.opacity = 0.7;
|
||||
}, 150);
|
||||
|
||||
targetProgress = progress;
|
||||
|
||||
if (!animationFrameId) {
|
||||
updateUI();
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await window.electronAPI.runInitialization();
|
||||
|
||||
if (result.success) {
|
||||
targetProgress = 100;
|
||||
// 确保动画还在跑
|
||||
if (!animationFrameId) updateUI();
|
||||
|
||||
statusText.style.color = 'var(--success-color)';
|
||||
statusText.textContent = '检查完毕,正在启动...';
|
||||
|
||||
// 给予用户 1.2秒 的时间看到 "100%" 和 "检查完毕"
|
||||
// 因为 main.js 已经有了延时,这里的延时是为了展示最终完成态
|
||||
setTimeout(() => {
|
||||
window.electronAPI.initializationComplete(result);
|
||||
}, 1200);
|
||||
} else {
|
||||
statusText.style.color = 'var(--error-color)';
|
||||
statusText.textContent = `启动失败: ${result.error}`;
|
||||
}
|
||||
} catch (error) {
|
||||
statusText.style.color = 'var(--error-color)';
|
||||
statusText.textContent = `严重错误: ${error.message}`;
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,178 @@
|
||||
// src/js/tool-registry.js
|
||||
/**
|
||||
* [重构] 工具注册表
|
||||
* 使用新的ToolFramework统一管理工具注册
|
||||
*/
|
||||
import ToolFramework from './core/ToolFramework.js';
|
||||
import EventBus from './core/EventBus.js';
|
||||
|
||||
// 导入所有模块化工具的类
|
||||
import IPQueryTool from './tools/ipQueryTool.js';
|
||||
import SystemInfoTool from './tools/systemInfoTool.js';
|
||||
import SystemTool from './tools/systemTool.js';
|
||||
import BiliHotTool from './tools/biliHotTool.js';
|
||||
import QQAvatarTool from './tools/qqAvatarTool.js';
|
||||
import BaiduHotTool from './tools/baiduHotTool.js';
|
||||
import Base64Tool from './tools/base64Tool.js';
|
||||
import ChineseConverterTool from './tools/chineseConverterTool.js';
|
||||
import QRCodeGeneratorTool from './tools/qrCodeGeneratorTool.js';
|
||||
import ProfanityCheckTool from './tools/profanityCheckTool.js';
|
||||
import WxDomainCheckTool from './tools/wxDomainCheckTool.js';
|
||||
import IpInfoTool from './tools/ipInfoTool.js';
|
||||
import DnsQueryTool from './tools/dnsQueryTool.js';
|
||||
import HotboardTool from './tools/hotboardTool.js';
|
||||
import SmartSearchTool from './tools/smartSearchTool.js';
|
||||
import AITranslationTool from './tools/aiTranslationTool.js';
|
||||
// [修复 1] 导入缺失的工具类
|
||||
import ImageProcessorTool from './tools/imageProcessorTool.js';
|
||||
import ArchiveTool from './tools/archiveTool.js';
|
||||
import MediaPlayerTool from './tools/mediaPlayerTool.js';
|
||||
import PcBenchmarkTool from './tools/pcBenchmarkTool.js';
|
||||
import WeatherDetailsTool from './tools/weatherDetailsTool.js';
|
||||
import SanguoshaTool from './tools/sanguoshaTool.js';
|
||||
import JsonFormatTool from './tools/jsonFormatTool.js';
|
||||
import UrlTool from './tools/urlTool.js';
|
||||
import TimestampTool from './tools/timestampTool.js';
|
||||
import ColorTool from './tools/colorTool.js';
|
||||
import RegexTool from './tools/regexTool.js';
|
||||
import CalculatorTool from './tools/calculatorTool.js';
|
||||
import UnitTool from './tools/unitTool.js';
|
||||
import PasswordTool from './tools/passwordTool.js';
|
||||
import DiffTool from './tools/diffTool.js';
|
||||
import HmacGeneratorTool from './tools/hmacGeneratorTool.js';
|
||||
import HtmlEntityTool from './tools/htmlEntityTool.js';
|
||||
import JsObfuscatorTool from './tools/jsObfuscatorTool.js';
|
||||
import UlidGeneratorTool from './tools/ulidGeneratorTool.js';
|
||||
import ExpiryCalculatorTool from './tools/expiryCalculatorTool.js';
|
||||
import TextStatisticsTool from './tools/textStatisticsTool.js';
|
||||
import UuidGeneratorTool from './tools/uuidGeneratorTool.js';
|
||||
import AsciiArtTool from './tools/asciiArtTool.js';
|
||||
import QrcodeScannerTool from './tools/qrcodeScannerTool.js';
|
||||
import FileHashTool from './tools/fileHashTool.js';
|
||||
import CaseConverterTool from './tools/caseConverterTool.js';
|
||||
import RandomGeneratorTool from './tools/randomGeneratorTool.js';
|
||||
import BMITool from './tools/bmiTool.js';
|
||||
import EarthquakeTool from './tools/earthquakeTool.js';
|
||||
import CarInfoTool from './tools/carInfoTool.js';
|
||||
import CCTVNewsTool from './tools/cctvNewsTool.js';
|
||||
import OilPriceTool from './tools/oilPriceTool.js';
|
||||
import HistoryTodayTool from './tools/historyTodayTool.js';
|
||||
import DomainPriceTool from './tools/domainPriceTool.js';
|
||||
import TechNewsTool from './tools/techNewsTool.js';
|
||||
import GoldPriceTool from './tools/goldPriceTool.js';
|
||||
import ZhihuHotTool from './tools/zhihuHotTool.js';
|
||||
import MovieBoxOfficeTool from './tools/movieBoxOfficeTool.js';
|
||||
import FootballNewsTool from './tools/footballNewsTool.js';
|
||||
import TrainQueryTool from './tools/trainQueryTool.js';
|
||||
|
||||
/**
|
||||
* 工具注册表
|
||||
* 将工具ID映射到它们的类定义
|
||||
* 键 (key) 必须与 uiManager.js 中定义的 ID 一致
|
||||
*
|
||||
* [健康检查预留位置]
|
||||
* 每个工具可以添加以下元数据(可选):
|
||||
* - healthCheckConfig: 健康检查配置
|
||||
* - enabled: 是否启用健康检查
|
||||
* - apiUrl: API地址(用于健康检查)
|
||||
* - timeout: 超时时间(毫秒)
|
||||
* - retries: 重试次数
|
||||
* - checkInterval: 检查间隔(毫秒)
|
||||
*/
|
||||
export const toolRegistry = {
|
||||
'ip-query': IPQueryTool,
|
||||
'system-info': SystemInfoTool,
|
||||
'system-tool': SystemTool,
|
||||
'bili-hot-ranking': BiliHotTool,
|
||||
'qq-avatar': QQAvatarTool,
|
||||
'baidu-hot': BaiduHotTool,
|
||||
'base64-converter': Base64Tool,
|
||||
'chinese-converter': ChineseConverterTool,
|
||||
'qr-code-generator': QRCodeGeneratorTool,
|
||||
'profanity-check': ProfanityCheckTool,
|
||||
'wx-domain-check': WxDomainCheckTool,
|
||||
'ip-info': IpInfoTool,
|
||||
'dns-query': DnsQueryTool,
|
||||
'hotboard': HotboardTool,
|
||||
'smart-search': SmartSearchTool,
|
||||
'ai-translation': AITranslationTool,
|
||||
// [修复 1] 注册工具
|
||||
'image-processor': ImageProcessorTool,
|
||||
'archive-tool': ArchiveTool,
|
||||
'media-player': MediaPlayerTool,
|
||||
'pc-benchmark': PcBenchmarkTool,
|
||||
'weather-details': WeatherDetailsTool,
|
||||
'sanguosha-downloader': SanguoshaTool,
|
||||
'json-format': JsonFormatTool,
|
||||
'url-tool': UrlTool,
|
||||
'timestamp-tool': TimestampTool,
|
||||
'color-tool': ColorTool,
|
||||
'regex-tool': RegexTool,
|
||||
'calculator-tool': CalculatorTool,
|
||||
'unit-tool': UnitTool,
|
||||
'password-tool': PasswordTool,
|
||||
'diff-tool': DiffTool,
|
||||
'hmac-generator': HmacGeneratorTool,
|
||||
'html-entity': HtmlEntityTool,
|
||||
'js-obfuscator': JsObfuscatorTool,
|
||||
'ulid-generator': UlidGeneratorTool,
|
||||
'expiry-calculator': ExpiryCalculatorTool,
|
||||
// [新增] 扩充工具库
|
||||
'text-statistics': TextStatisticsTool,
|
||||
'uuid-generator': UuidGeneratorTool,
|
||||
'ascii-art': AsciiArtTool,
|
||||
'qrcode-scanner': QrcodeScannerTool,
|
||||
'file-hash': FileHashTool,
|
||||
'case-converter': CaseConverterTool,
|
||||
'random-generator': RandomGeneratorTool,
|
||||
'bmi-calculator': BMITool,
|
||||
'earthquake-info': EarthquakeTool,
|
||||
'car-info': CarInfoTool,
|
||||
'cctv-news': CCTVNewsTool,
|
||||
'oil-price': OilPriceTool,
|
||||
'history-today': HistoryTodayTool,
|
||||
'domain-price': DomainPriceTool,
|
||||
'tech-news': TechNewsTool,
|
||||
'gold-price': GoldPriceTool,
|
||||
'zhihu-hot': ZhihuHotTool,
|
||||
'movie-box-office': MovieBoxOfficeTool,
|
||||
'football-news': FootballNewsTool,
|
||||
'train-query': TrainQueryTool
|
||||
};
|
||||
|
||||
/**
|
||||
* [重构] 使用ToolFramework注册所有工具
|
||||
* 从uiManager获取工具元数据并注册
|
||||
*/
|
||||
function registerAllTools() {
|
||||
// 从uiManager获取工具元数据(延迟加载,避免循环依赖)
|
||||
setTimeout(() => {
|
||||
if (window.uiManager && window.uiManager.modularTools) {
|
||||
Object.entries(window.uiManager.modularTools).forEach(([id, metadata]) => {
|
||||
const ToolClass = toolRegistry[id];
|
||||
if (ToolClass) {
|
||||
ToolFramework.registerTool(id, ToolClass, {
|
||||
name: metadata.name,
|
||||
description: metadata.description,
|
||||
iconUrl: metadata.iconUrl,
|
||||
category: metadata.category || 'other',
|
||||
launchType: metadata.launchType || 'inline',
|
||||
view: metadata.view || 'view-tool',
|
||||
...metadata
|
||||
});
|
||||
}
|
||||
});
|
||||
console.log(`[ToolFramework] 已注册 ${ToolFramework.getAllToolIds().length} 个工具`);
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
|
||||
// [重构] 自动注册工具
|
||||
if (typeof window !== 'undefined') {
|
||||
// 等待DOM加载完成
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', registerAllTools);
|
||||
} else {
|
||||
registerAllTools();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,937 @@
|
||||
// src/js/toolHealthCheckModal.js
|
||||
import toolHealthChecker from './toolHealthChecker.js';
|
||||
import toolStatusManager from './toolStatusManager.js'; // [健康检查预留位置] 导入工具状态管理器
|
||||
import { toolRegistry } from './tool-registry.js';
|
||||
import i18n from './i18n.js';
|
||||
|
||||
/**
|
||||
* 工具健康检查模态框(灵动岛风格)
|
||||
* [重构] 使用通用模态框框架
|
||||
*/
|
||||
class ToolHealthCheckModal {
|
||||
constructor() {
|
||||
this.modal = null;
|
||||
this.modalManager = null;
|
||||
this.islandContent = null; // 存储 island 内容元素,用于直接更新
|
||||
this.isVisible = false;
|
||||
this._checkCompleteResolver = null; // 用于跟踪检查完成的 Promise resolver
|
||||
}
|
||||
|
||||
/**
|
||||
* [重构] 创建模态框(使用通用模态框框架)
|
||||
*/
|
||||
async createModal() {
|
||||
// 动态导入 ModalManager
|
||||
if (!this.modalManager) {
|
||||
const { default: modalManager } = await import('./ui/ModalManager.js');
|
||||
this.modalManager = modalManager;
|
||||
}
|
||||
|
||||
// 如果模态框已存在且打开,重置状态但不重新创建
|
||||
if (this.modalManager.isOpen('tool-health-check-modal')) {
|
||||
this.resetModal();
|
||||
return;
|
||||
}
|
||||
|
||||
// 生成 island 内容 HTML
|
||||
const islandContent = `
|
||||
<div class="tool-health-check-island">
|
||||
<div class="tool-health-check-header">
|
||||
<div class="tool-health-check-icon">
|
||||
<i class="fas fa-shield-alt"></i>
|
||||
</div>
|
||||
<div class="tool-health-check-title">
|
||||
<h3>${i18n.t('toolHealthCheck.title', '工具健康检查')}</h3>
|
||||
<p class="tool-health-check-subtitle">${i18n.t('toolHealthCheck.subtitle', '正在检查网络工具状态...')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tool-health-check-progress">
|
||||
<div class="tool-health-check-current-tool" id="tool-health-check-current-tool">
|
||||
<i class="fas fa-spinner fa-spin"></i>
|
||||
<span id="tool-health-check-current-tool-name">${i18n.t('toolHealthCheck.preparing', '准备中...')}</span>
|
||||
</div>
|
||||
<div class="tool-health-check-progress-bar">
|
||||
<div class="tool-health-check-progress-fill" id="tool-health-check-progress-fill"></div>
|
||||
</div>
|
||||
<div class="tool-health-check-progress-info">
|
||||
<span id="tool-health-check-progress-text">0 / 0</span>
|
||||
<span id="tool-health-check-progress-percent">0%</span>
|
||||
</div>
|
||||
<div class="tool-health-check-stats" id="tool-health-check-stats">
|
||||
<span class="stat-item">
|
||||
<i class="fas fa-check-circle" style="color: #10b981;"></i>
|
||||
<span id="tool-health-check-success-count">0</span>
|
||||
</span>
|
||||
<span class="stat-item">
|
||||
<i class="fas fa-times-circle" style="color: #ef4444;"></i>
|
||||
<span id="tool-health-check-failed-count">0</span>
|
||||
</span>
|
||||
<span class="stat-item">
|
||||
<i class="fas fa-clock" style="color: #6b7280;"></i>
|
||||
<span id="tool-health-check-pending-count">0</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="tool-health-check-time-estimate" id="tool-health-check-time-estimate" style="display: none;">
|
||||
<i class="far fa-clock"></i>
|
||||
<span id="tool-health-check-remaining-time">${i18n.t('toolHealthCheck.calculating', '计算中...')}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tool-health-check-list" id="tool-health-check-list"></div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// 使用通用模态框框架创建模态框
|
||||
this.modal = this.modalManager.create({
|
||||
id: 'tool-health-check-modal',
|
||||
title: '', // 不使用标题,使用自定义 island 样式
|
||||
content: islandContent,
|
||||
size: 'medium',
|
||||
closable: false, // 检查过程中不允许关闭
|
||||
closeOnBackdrop: false,
|
||||
className: 'tool-health-check-modal-wrapper'
|
||||
});
|
||||
|
||||
// 获取 island 内容元素,用于后续直接更新
|
||||
const modalBody = this.modal.querySelector('.universal-modal-body');
|
||||
if (modalBody) {
|
||||
this.islandContent = modalBody.querySelector('.tool-health-check-island');
|
||||
}
|
||||
|
||||
// 添加样式
|
||||
this.addStyles();
|
||||
|
||||
// 强制重排,确保样式应用
|
||||
if (this.modal) {
|
||||
this.modal.offsetHeight;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置模态框状态
|
||||
*/
|
||||
resetModal() {
|
||||
if (!this.modal) return;
|
||||
|
||||
// 重置进度
|
||||
const progressFill = document.getElementById('tool-health-check-progress-fill');
|
||||
if (progressFill) progressFill.style.width = '0%';
|
||||
|
||||
const progressText = document.getElementById('tool-health-check-progress-text');
|
||||
const progressPercent = document.getElementById('tool-health-check-progress-percent');
|
||||
if (progressText) progressText.textContent = '0 / 0';
|
||||
if (progressPercent) progressPercent.textContent = '0%';
|
||||
|
||||
// 重置统计
|
||||
const successCount = document.getElementById('tool-health-check-success-count');
|
||||
const failedCount = document.getElementById('tool-health-check-failed-count');
|
||||
const pendingCount = document.getElementById('tool-health-check-pending-count');
|
||||
if (successCount) successCount.textContent = '0';
|
||||
if (failedCount) failedCount.textContent = '0';
|
||||
if (pendingCount) pendingCount.textContent = '0';
|
||||
|
||||
// 重置当前工具
|
||||
const currentToolName = document.getElementById('tool-health-check-current-tool-name');
|
||||
if (currentToolName) currentToolName.textContent = i18n.t('toolHealthCheck.preparing', '准备中...');
|
||||
|
||||
// 清空列表
|
||||
const listEl = document.getElementById('tool-health-check-list');
|
||||
if (listEl) listEl.innerHTML = '';
|
||||
|
||||
// 隐藏时间估算
|
||||
const timeEstimateEl = document.getElementById('tool-health-check-time-estimate');
|
||||
if (timeEstimateEl) {
|
||||
timeEstimateEl.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加样式
|
||||
*/
|
||||
addStyles() {
|
||||
if (document.getElementById('tool-health-check-styles')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const style = document.createElement('style');
|
||||
style.id = 'tool-health-check-styles';
|
||||
style.textContent = `
|
||||
/* [重构] 适配通用模态框框架的样式 */
|
||||
.tool-health-check-modal-wrapper .universal-modal-container {
|
||||
background: transparent;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
padding: 0;
|
||||
max-width: 600px;
|
||||
width: 90%;
|
||||
}
|
||||
|
||||
.tool-health-check-modal-wrapper .universal-modal-body {
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.tool-health-check-island {
|
||||
background: rgba(var(--card-background-rgb), 0.95);
|
||||
backdrop-filter: blur(20px);
|
||||
border-radius: 24px;
|
||||
padding: 32px;
|
||||
max-width: 600px;
|
||||
width: 100%;
|
||||
max-height: 80vh;
|
||||
overflow-y: auto;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.tool-health-check-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.tool-health-check-icon {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 16px;
|
||||
background: linear-gradient(135deg, var(--primary-color) 0%, rgba(var(--primary-rgb), 0.8) 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 24px;
|
||||
color: white;
|
||||
box-shadow: 0 8px 16px rgba(var(--primary-rgb), 0.3);
|
||||
}
|
||||
|
||||
.tool-health-check-title h3 {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.tool-health-check-subtitle {
|
||||
margin: 4px 0 0 0;
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.tool-health-check-progress {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.tool-health-check-current-tool {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
padding: 12px 16px;
|
||||
background: rgba(var(--primary-rgb), 0.1);
|
||||
border-radius: 12px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.tool-health-check-current-tool i {
|
||||
color: var(--primary-color);
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.tool-health-check-progress-bar {
|
||||
width: 100%;
|
||||
height: 12px;
|
||||
background: rgba(var(--primary-rgb), 0.1);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 12px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.tool-health-check-progress-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, var(--primary-color) 0%, rgba(var(--primary-rgb), 0.8) 100%);
|
||||
border-radius: 6px;
|
||||
transition: width 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
width: 0%;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
will-change: width;
|
||||
}
|
||||
|
||||
.tool-health-check-progress-fill::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.3), transparent);
|
||||
animation: shimmer 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% { transform: translateX(-100%); }
|
||||
100% { transform: translateX(100%); }
|
||||
}
|
||||
|
||||
.tool-health-check-progress-info {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.tool-health-check-progress-info span {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tool-health-check-progress-percent {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.tool-health-check-time-estimate {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
padding: 8px 12px;
|
||||
background: rgba(var(--primary-rgb), 0.05);
|
||||
border-radius: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.tool-health-check-time-estimate i {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.tool-health-check-stats {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.tool-health-check-stats .stat-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.tool-health-check-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.tool-health-check-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
background: rgba(var(--card-background-rgb), 0.5);
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--border-color);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.tool-health-check-item:hover {
|
||||
background: rgba(var(--card-background-rgb), 0.8);
|
||||
transform: translateX(4px);
|
||||
}
|
||||
|
||||
.tool-health-check-item-name {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.tool-health-check-item-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tool-health-check-item-status.success {
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
.tool-health-check-item-status.failed {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.tool-health-check-item-status.checking {
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
/* 移除旧的动画,改用 transition */
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示模态框
|
||||
* @param {boolean} force - 是否强制检查(忽略每日限制)
|
||||
* @returns {Promise<void>} - 检查完成后的 Promise
|
||||
*/
|
||||
async show(force = false) {
|
||||
if (this.isVisible) {
|
||||
// 如果已经在显示,返回一个等待当前检查完成的 Promise
|
||||
return new Promise((resolve) => {
|
||||
const checkInterval = setInterval(() => {
|
||||
if (!this.isVisible) {
|
||||
clearInterval(checkInterval);
|
||||
resolve();
|
||||
}
|
||||
}, 100);
|
||||
});
|
||||
}
|
||||
|
||||
// 确保 toolHealthChecker 已加载
|
||||
if (!toolHealthChecker) {
|
||||
console.error('toolHealthChecker is not available');
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
// [重构] 使用通用模态框框架创建模态框
|
||||
await this.createModal();
|
||||
|
||||
// 使用 requestAnimationFrame 确保 DOM 已渲染
|
||||
await new Promise(resolve => requestAnimationFrame(resolve));
|
||||
|
||||
// 模态框已由 ModalManager 自动显示,标记为可见
|
||||
this.isVisible = true;
|
||||
|
||||
// 创建一个 Promise 来跟踪检查完成
|
||||
return new Promise((resolve) => {
|
||||
this._checkCompleteResolver = resolve;
|
||||
|
||||
// 如果不是强制检查,且今天已经检查过,直接显示结果
|
||||
if (!force && !toolHealthChecker.shouldCheckToday()) {
|
||||
this.displayResults();
|
||||
} else {
|
||||
// 开始检查(强制检查会忽略每日限制)
|
||||
this.startCheck(force);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示已检查的结果
|
||||
*/
|
||||
displayResults() {
|
||||
try {
|
||||
if (!toolRegistry || typeof toolRegistry !== 'object') {
|
||||
console.error('toolRegistry is not available');
|
||||
this.updateCurrentTool(i18n.t('toolHealthCheck.error', '检查失败:工具注册表未加载'), 0, 0);
|
||||
setTimeout(() => {
|
||||
this.hide();
|
||||
// 通知检查完成(即使失败)
|
||||
if (this._checkCompleteResolver) {
|
||||
this._checkCompleteResolver();
|
||||
this._checkCompleteResolver = null;
|
||||
}
|
||||
}, 2000);
|
||||
return;
|
||||
}
|
||||
|
||||
const allTools = Object.keys(toolRegistry);
|
||||
const networkTools = allTools.filter(toolId => toolHealthChecker.isNetworkTool(toolId));
|
||||
const listEl = document.getElementById('tool-health-check-list');
|
||||
if (!listEl) {
|
||||
// 如果找不到列表元素,直接完成
|
||||
if (this._checkCompleteResolver) {
|
||||
this._checkCompleteResolver();
|
||||
this._checkCompleteResolver = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
listEl.innerHTML = '';
|
||||
|
||||
let success = 0;
|
||||
let failed = 0;
|
||||
|
||||
networkTools.forEach(toolId => {
|
||||
// [健康检查预留位置] 使用 toolStatusManager 检查工具锁定状态
|
||||
const toolStatus = toolStatusManager.getToolStatus(toolId);
|
||||
const isLocked = toolStatusManager.isToolLocked(toolId);
|
||||
const status = isLocked ? 'failed' : 'success';
|
||||
if (isLocked) failed++;
|
||||
else success++;
|
||||
|
||||
const item = this.createToolItem(toolId, status);
|
||||
listEl.appendChild(item);
|
||||
});
|
||||
|
||||
// 更新当前工具显示为完成状态
|
||||
if (networkTools.length > 0) {
|
||||
this.updateCurrentTool(i18n.t('toolHealthCheck.completed', '检查完成'), networkTools.length, networkTools.length);
|
||||
} else {
|
||||
this.updateCurrentTool(i18n.t('toolHealthCheck.noNetworkTools', '没有需要检查的网络工具'), 0, 0);
|
||||
}
|
||||
|
||||
// 隐藏时间估算(因为已经完成)
|
||||
const timeEstimateEl = document.getElementById('tool-health-check-time-estimate');
|
||||
if (timeEstimateEl) {
|
||||
timeEstimateEl.style.display = 'none';
|
||||
}
|
||||
|
||||
this.updateProgress(networkTools.length, networkTools.length, success, failed);
|
||||
|
||||
// 2秒后自动关闭(减少等待时间)
|
||||
setTimeout(() => {
|
||||
this.hide();
|
||||
// 通知检查完成
|
||||
if (this._checkCompleteResolver) {
|
||||
this._checkCompleteResolver();
|
||||
this._checkCompleteResolver = null;
|
||||
}
|
||||
}, 2000);
|
||||
} catch (error) {
|
||||
console.error('显示检查结果失败:', error);
|
||||
this.updateCurrentTool(i18n.t('toolHealthCheck.error', '检查失败') + ': ' + (error.message || String(error)), 0, 0);
|
||||
setTimeout(() => {
|
||||
this.hide();
|
||||
// 通知检查完成(即使失败)
|
||||
if (this._checkCompleteResolver) {
|
||||
this._checkCompleteResolver();
|
||||
this._checkCompleteResolver = null;
|
||||
}
|
||||
}, 2000);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* [重构] 隐藏模态框(使用通用模态框框架)
|
||||
*/
|
||||
hide() {
|
||||
if (!this.isVisible) {
|
||||
// 即使不可见,也要确保 Promise 被 resolve
|
||||
if (this._checkCompleteResolver) {
|
||||
this._checkCompleteResolver();
|
||||
this._checkCompleteResolver = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// [重构] 使用通用模态框框架关闭模态框
|
||||
if (this.modalManager && this.modalManager.isOpen('tool-health-check-modal')) {
|
||||
this.modalManager.close('tool-health-check-modal');
|
||||
}
|
||||
|
||||
this.isVisible = false;
|
||||
|
||||
// 如果还有未完成的 Promise,立即 resolve(防止卡住)
|
||||
if (this._checkCompleteResolver) {
|
||||
this._checkCompleteResolver();
|
||||
this._checkCompleteResolver = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 开始检查
|
||||
* @param {boolean} force - 是否强制检查(忽略每日限制)
|
||||
*/
|
||||
async startCheck(force = false) {
|
||||
try {
|
||||
// 确保 toolRegistry 已加载
|
||||
if (!toolRegistry || typeof toolRegistry !== 'object') {
|
||||
console.error('toolRegistry is not available');
|
||||
this.updateCurrentTool(i18n.t('toolHealthCheck.error', '检查失败:工具注册表未加载'), 0, 0);
|
||||
setTimeout(() => this.hide(), 3000);
|
||||
return;
|
||||
}
|
||||
|
||||
const allTools = Object.keys(toolRegistry);
|
||||
const networkTools = allTools.filter(toolId => toolHealthChecker.isNetworkTool(toolId));
|
||||
const total = networkTools.length;
|
||||
let checked = 0;
|
||||
let success = 0;
|
||||
let failed = 0;
|
||||
|
||||
// 记录开始时间和每个工具的检查时间
|
||||
const startTime = Date.now();
|
||||
const toolCheckTimes = [];
|
||||
|
||||
// 初始化列表
|
||||
const listEl = document.getElementById('tool-health-check-list');
|
||||
if (!listEl) {
|
||||
console.error('tool-health-check-list element not found');
|
||||
return;
|
||||
}
|
||||
listEl.innerHTML = '';
|
||||
|
||||
if (networkTools.length === 0) {
|
||||
// 如果没有网络工具,显示提示
|
||||
this.updateCurrentTool(i18n.t('toolHealthCheck.noNetworkTools', '没有需要检查的网络工具'), 0, 0);
|
||||
setTimeout(() => {
|
||||
this.hide();
|
||||
}, 2000);
|
||||
return;
|
||||
}
|
||||
|
||||
networkTools.forEach(toolId => {
|
||||
const item = this.createToolItem(toolId, 'checking');
|
||||
listEl.appendChild(item);
|
||||
// 同时在自检页面创建工具项
|
||||
this.updatePageToolItem(toolId, 'checking');
|
||||
});
|
||||
|
||||
// 显示时间估算
|
||||
const timeEstimateEl = document.getElementById('tool-health-check-time-estimate');
|
||||
if (timeEstimateEl) {
|
||||
timeEstimateEl.style.display = 'flex';
|
||||
}
|
||||
|
||||
// 初始化进度显示
|
||||
this.updateProgress(0, total, 0, 0, null);
|
||||
const firstToolInfo = this.getToolInfo(networkTools[0]);
|
||||
this.updateCurrentTool(firstToolInfo.name, 1, total);
|
||||
|
||||
// 逐个检查
|
||||
for (let i = 0; i < networkTools.length; i++) {
|
||||
const toolId = networkTools[i];
|
||||
const toolInfo = this.getToolInfo(toolId);
|
||||
|
||||
// 更新当前检查的工具名称
|
||||
this.updateCurrentTool(toolInfo.name, i + 1, total);
|
||||
|
||||
const toolStartTime = Date.now();
|
||||
const result = await toolHealthChecker.checkToolHealth(toolId);
|
||||
const toolCheckTime = Date.now() - toolStartTime;
|
||||
toolCheckTimes.push(toolCheckTime);
|
||||
|
||||
checked++;
|
||||
|
||||
if (result.status === 'success') {
|
||||
success++;
|
||||
// [健康检查预留位置] 使用 toolStatusManager 更新状态(toolHealthChecker 内部已处理)
|
||||
// toolHealthChecker.checkToolHealth 已经通过 toolStatusManager.updateHealthStatus 更新了状态
|
||||
} else if (result.status === 'failed') {
|
||||
failed++;
|
||||
// [健康检查预留位置] 使用 toolStatusManager 更新状态(toolHealthChecker 内部已处理)
|
||||
// toolHealthChecker.checkToolHealth 已经通过 toolStatusManager.updateHealthStatus 更新了状态
|
||||
}
|
||||
|
||||
// 计算平均检查时间和预估剩余时间
|
||||
const avgCheckTime = toolCheckTimes.reduce((a, b) => a + b, 0) / toolCheckTimes.length;
|
||||
const remaining = total - checked;
|
||||
const estimatedRemaining = Math.ceil(avgCheckTime * remaining / 1000); // 转换为秒
|
||||
|
||||
// 更新进度
|
||||
this.updateProgress(checked, total, success, failed, estimatedRemaining);
|
||||
this.updateToolItem(toolId, result.status, result.reason);
|
||||
|
||||
// 同时更新自检页面的工具列表
|
||||
this.updatePageToolItem(toolId, result.status, result.reason);
|
||||
|
||||
// [重构] 同时更新验证页面的工具项(如果存在)
|
||||
if (window.toolboxVerificationPage && window.toolboxVerificationPage.updateToolItem) {
|
||||
window.toolboxVerificationPage.updateToolItem(toolId, result.status, result.reason);
|
||||
}
|
||||
}
|
||||
|
||||
// 检查完成
|
||||
const totalTime = ((Date.now() - startTime) / 1000).toFixed(1);
|
||||
this.updateCurrentTool(i18n.t('toolHealthCheck.completed', '检查完成'), total, total);
|
||||
this.updateTimeEstimate(i18n.t('toolHealthCheck.totalTime', '总耗时') + `: ${totalTime}${i18n.t('toolHealthCheck.second', '秒')}`, true);
|
||||
|
||||
// 更新自检页面状态
|
||||
const pageStatus = document.getElementById('tool-health-check-page-status');
|
||||
if (pageStatus) {
|
||||
pageStatus.innerHTML = `<i class="fas fa-check-circle" style="margin-right: 8px; color: #10b981;"></i>${i18n.t('toolHealthCheck.completed', '检查完成')}`;
|
||||
}
|
||||
|
||||
// [修复] 保存检查日期(使用 ISO 格式,与数据库保持一致)
|
||||
if (toolHealthChecker) {
|
||||
const checkDateTime = new Date().toISOString(); // 完整的日期时间
|
||||
const checkDate = checkDateTime.split('T')[0]; // YYYY-MM-DD
|
||||
toolHealthChecker.lastCheckDate = checkDateTime;
|
||||
if (window.configManager) {
|
||||
if (!window.configManager.config) {
|
||||
window.configManager.config = {};
|
||||
}
|
||||
window.configManager.config.tool_health_last_check = checkDate;
|
||||
window.configManager.saveConfig();
|
||||
}
|
||||
}
|
||||
|
||||
// 2秒后自动关闭(减少等待时间)
|
||||
setTimeout(() => {
|
||||
this.hide();
|
||||
// 刷新工具箱页面以显示锁定状态
|
||||
if (window.uiManager) {
|
||||
window.uiManager.renderToolboxPage();
|
||||
}
|
||||
// 更新设置页面的统计信息
|
||||
if (window.mainPage && window.mainPage.updateToolHealthStats) {
|
||||
window.mainPage.updateToolHealthStats();
|
||||
}
|
||||
// 通知检查完成
|
||||
if (this._checkCompleteResolver) {
|
||||
this._checkCompleteResolver();
|
||||
this._checkCompleteResolver = null;
|
||||
}
|
||||
}, 2000);
|
||||
} catch (error) {
|
||||
console.error('工具健康检查失败:', error);
|
||||
this.updateCurrentTool(i18n.t('toolHealthCheck.error', '检查失败') + ': ' + (error.message || String(error)), 0, 0);
|
||||
this.updateTimeEstimate(i18n.t('toolHealthCheck.error', '检查失败'), false);
|
||||
// 重要:即使失败或超时,也要等待用户看到错误信息后再关闭
|
||||
// 绝对不能跳过,必须等待完成
|
||||
setTimeout(() => {
|
||||
this.hide();
|
||||
// 即使失败也要通知完成,允许进入工具箱(但必须等待完成)
|
||||
if (this._checkCompleteResolver) {
|
||||
this._checkCompleteResolver();
|
||||
this._checkCompleteResolver = null;
|
||||
}
|
||||
}, 3000); // 失败时延长显示时间,确保用户看到错误信息
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建工具项
|
||||
*/
|
||||
createToolItem(toolId, status) {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'tool-health-check-item';
|
||||
item.id = `tool-health-check-item-${toolId}`;
|
||||
|
||||
const toolInfo = this.getToolInfo(toolId);
|
||||
|
||||
item.innerHTML = `
|
||||
<div class="tool-health-check-item-name">
|
||||
<span>${toolInfo.name}</span>
|
||||
</div>
|
||||
<div class="tool-health-check-item-status ${status}">
|
||||
${this.getStatusIcon(status)}
|
||||
<span>${this.getStatusText(status, null)}</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
return item;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新工具项状态
|
||||
*/
|
||||
updateToolItem(toolId, status, reason = null) {
|
||||
const item = document.getElementById(`tool-health-check-item-${toolId}`);
|
||||
if (!item) return;
|
||||
|
||||
const statusEl = item.querySelector('.tool-health-check-item-status');
|
||||
if (statusEl) {
|
||||
statusEl.className = `tool-health-check-item-status ${status}`;
|
||||
statusEl.innerHTML = `
|
||||
${this.getStatusIcon(status)}
|
||||
<span>${this.getStatusText(status, reason)}</span>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新自检页面的工具项
|
||||
*/
|
||||
updatePageToolItem(toolId, status, reason = null) {
|
||||
const pageListEl = document.getElementById('tool-health-check-page-list');
|
||||
if (!pageListEl) return;
|
||||
|
||||
// 查找或创建工具项
|
||||
let itemEl = document.getElementById(`tool-health-check-page-item-${toolId}`);
|
||||
if (!itemEl) {
|
||||
itemEl = document.createElement('div');
|
||||
itemEl.id = `tool-health-check-page-item-${toolId}`;
|
||||
itemEl.className = 'tool-health-check-page-item';
|
||||
itemEl.style.cssText = 'display: flex; align-items: center; justify-content: space-between; padding: 12px 16px; margin-bottom: 8px; background: rgba(var(--card-background-rgb), 0.5); border-radius: 8px; border: 1px solid var(--border-color); transition: all 0.3s ease;';
|
||||
pageListEl.appendChild(itemEl);
|
||||
}
|
||||
|
||||
const toolInfo = this.getToolInfo(toolId);
|
||||
const statusColor = status === 'success' ? '#10b981' : status === 'failed' ? '#ef4444' : '#6b7280';
|
||||
itemEl.innerHTML = `
|
||||
<div style="display: flex; align-items: center; gap: 12px; flex: 1;">
|
||||
<span style="font-weight: 600; color: var(--text-primary);">${toolInfo.name}</span>
|
||||
</div>
|
||||
<div style="display: flex; align-items: center; gap: 8px; font-size: 14px; font-weight: 600; color: ${statusColor};">
|
||||
${this.getStatusIcon(status)}
|
||||
<span>${this.getStatusText(status, reason)}</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取状态图标
|
||||
*/
|
||||
getStatusIcon(status) {
|
||||
switch (status) {
|
||||
case 'success':
|
||||
return '<i class="fas fa-check-circle"></i>';
|
||||
case 'failed':
|
||||
return '<i class="fas fa-times-circle"></i>';
|
||||
case 'checking':
|
||||
return '<i class="fas fa-spinner fa-spin"></i>';
|
||||
default:
|
||||
return '<i class="fas fa-question-circle"></i>';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取状态文本
|
||||
*/
|
||||
getStatusText(status, reason = null) {
|
||||
switch (status) {
|
||||
case 'success':
|
||||
return i18n.t('toolHealthCheck.status.success', '正常');
|
||||
case 'failed':
|
||||
if (reason) {
|
||||
const reasonTexts = {
|
||||
'ssl_error': i18n.t('toolHealthCheck.reason.sslError', 'SSL证书错误'),
|
||||
'timeout': i18n.t('toolHealthCheck.reason.timeout', '请求超时'),
|
||||
'network_error': i18n.t('toolHealthCheck.reason.networkError', '网络错误'),
|
||||
'http_error': i18n.t('toolHealthCheck.reason.httpError', 'HTTP错误'),
|
||||
'no_data': i18n.t('toolHealthCheck.reason.noData', '无数据'),
|
||||
'no_valid_data': i18n.t('toolHealthCheck.reason.noValidData', '数据无效'),
|
||||
'parse_error': i18n.t('toolHealthCheck.reason.parseError', '解析错误'),
|
||||
'empty_response': i18n.t('toolHealthCheck.reason.emptyResponse', '空响应'),
|
||||
'max_retries': i18n.t('toolHealthCheck.reason.maxRetries', '重试次数超限')
|
||||
};
|
||||
return reasonTexts[reason] || i18n.t('toolHealthCheck.status.failed', '异常');
|
||||
}
|
||||
return i18n.t('toolHealthCheck.status.failed', '异常');
|
||||
case 'checking':
|
||||
return i18n.t('toolHealthCheck.status.checking', '检查中');
|
||||
case 'skip':
|
||||
return i18n.t('toolHealthCheck.status.skip', '跳过');
|
||||
default:
|
||||
return i18n.t('toolHealthCheck.status.unknown', '未知');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取工具信息
|
||||
*/
|
||||
getToolInfo(toolId) {
|
||||
// 从 uiManager 获取工具信息
|
||||
if (window.uiManager && window.uiManager.modularTools[toolId]) {
|
||||
return window.uiManager.modularTools[toolId];
|
||||
}
|
||||
return { name: toolId };
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新当前检查的工具
|
||||
*/
|
||||
updateCurrentTool(toolName, current, total) {
|
||||
const currentToolNameEl = document.getElementById('tool-health-check-current-tool-name');
|
||||
if (currentToolNameEl) {
|
||||
if (total > 0 && current > 0) {
|
||||
currentToolNameEl.textContent = `${i18n.t('toolHealthCheck.checkingTool', '正在检查')}: ${toolName} (${current}/${total})`;
|
||||
} else {
|
||||
currentToolNameEl.textContent = toolName;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新时间估算
|
||||
*/
|
||||
updateTimeEstimate(text, isCompleted = false) {
|
||||
const timeEstimateEl = document.getElementById('tool-health-check-remaining-time');
|
||||
if (timeEstimateEl) {
|
||||
timeEstimateEl.textContent = text;
|
||||
if (isCompleted) {
|
||||
const container = document.getElementById('tool-health-check-time-estimate');
|
||||
if (container) {
|
||||
container.style.background = 'rgba(16, 185, 129, 0.1)';
|
||||
container.querySelector('i').className = 'fas fa-check-circle';
|
||||
container.querySelector('i').style.color = '#10b981';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新进度(同时更新模态框和自检页面)
|
||||
*/
|
||||
updateProgress(checked, total, success, failed, estimatedRemainingSeconds = null) {
|
||||
const progress = total > 0 ? (checked / total) * 100 : 0;
|
||||
|
||||
// 更新模态框进度
|
||||
const progressFill = document.getElementById('tool-health-check-progress-fill');
|
||||
if (progressFill) {
|
||||
progressFill.style.width = `${progress}%`;
|
||||
}
|
||||
|
||||
// 更新模态框进度文本
|
||||
const progressTextEl = document.getElementById('tool-health-check-progress-text');
|
||||
const progressPercentEl = document.getElementById('tool-health-check-progress-percent');
|
||||
if (progressTextEl) {
|
||||
progressTextEl.textContent = `${checked} / ${total}`;
|
||||
}
|
||||
if (progressPercentEl) {
|
||||
progressPercentEl.textContent = `${Math.round(progress)}%`;
|
||||
}
|
||||
|
||||
// 更新模态框统计
|
||||
const successCount = document.getElementById('tool-health-check-success-count');
|
||||
const failedCount = document.getElementById('tool-health-check-failed-count');
|
||||
const pendingCount = document.getElementById('tool-health-check-pending-count');
|
||||
|
||||
if (successCount) successCount.textContent = success;
|
||||
if (failedCount) failedCount.textContent = failed;
|
||||
if (pendingCount) pendingCount.textContent = total - checked;
|
||||
|
||||
// 更新自检页面进度(如果存在)
|
||||
const pageProgressBar = document.getElementById('tool-health-check-page-progress-bar');
|
||||
const pageProgressText = document.getElementById('tool-health-check-page-progress-text');
|
||||
const pageStatus = document.getElementById('tool-health-check-page-status');
|
||||
const pageSuccess = document.getElementById('tool-health-check-page-success');
|
||||
const pageFailed = document.getElementById('tool-health-check-page-failed');
|
||||
const pagePending = document.getElementById('tool-health-check-page-pending');
|
||||
|
||||
if (pageProgressBar) {
|
||||
pageProgressBar.style.width = `${progress}%`;
|
||||
}
|
||||
if (pageProgressText) {
|
||||
pageProgressText.textContent = `${checked} / ${total}`;
|
||||
}
|
||||
if (pageStatus) {
|
||||
if (checked < total) {
|
||||
pageStatus.innerHTML = `<i class="fas fa-spinner fa-spin" style="margin-right: 8px;"></i>${i18n.t('toolHealthCheck.page.checking', '检查中...')}`;
|
||||
} else {
|
||||
pageStatus.innerHTML = `<i class="fas fa-check-circle" style="margin-right: 8px; color: #10b981;"></i>${i18n.t('toolHealthCheck.completed', '检查完成')}`;
|
||||
}
|
||||
}
|
||||
if (pageSuccess) pageSuccess.textContent = success;
|
||||
if (pageFailed) pageFailed.textContent = failed;
|
||||
if (pagePending) pagePending.textContent = total - checked;
|
||||
|
||||
// 更新剩余时间估算
|
||||
if (estimatedRemainingSeconds !== null && estimatedRemainingSeconds > 0) {
|
||||
const minutes = Math.floor(estimatedRemainingSeconds / 60);
|
||||
const seconds = estimatedRemainingSeconds % 60;
|
||||
let timeText = '';
|
||||
if (minutes > 0) {
|
||||
timeText = `${minutes}${i18n.t('toolHealthCheck.minute', '分')}${seconds}${i18n.t('toolHealthCheck.second', '秒')}`;
|
||||
} else {
|
||||
timeText = `${seconds}${i18n.t('toolHealthCheck.second', '秒')}`;
|
||||
}
|
||||
this.updateTimeEstimate(i18n.t('toolHealthCheck.estimatedRemaining', '预计剩余') + `: ${timeText}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new ToolHealthCheckModal();
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,263 @@
|
||||
// src/js/toolStatusManager.js
|
||||
/**
|
||||
* 工具状态管理模块
|
||||
* 统一管理工具的状态(启用/禁用、健康检查状态、锁定状态等)
|
||||
* 为健康性检查预留完整的接口和位置
|
||||
*/
|
||||
|
||||
import configManager from './configManager.js';
|
||||
|
||||
class ToolStatusManager {
|
||||
constructor() {
|
||||
this.statusCache = new Map(); // 工具状态缓存
|
||||
this.healthStatusCache = new Map(); // 健康检查状态缓存
|
||||
this.loadStatusConfig();
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载工具状态配置
|
||||
*/
|
||||
loadStatusConfig() {
|
||||
try {
|
||||
const statusConfig = configManager.config?.tool_status || {};
|
||||
this.statusCache.clear();
|
||||
|
||||
// 加载所有工具的状态配置
|
||||
Object.keys(statusConfig).forEach(toolId => {
|
||||
const status = statusConfig[toolId];
|
||||
this.statusCache.set(toolId, {
|
||||
enabled: status.enabled !== false, // 默认为true
|
||||
message: status.message || '',
|
||||
healthStatus: status.healthStatus || 'unknown', // unknown, healthy, unhealthy, checking
|
||||
lastHealthCheck: status.lastHealthCheck || null,
|
||||
lockReason: status.lockReason || null,
|
||||
// 预留字段:为未来扩展预留
|
||||
metadata: status.metadata || {},
|
||||
tags: status.tags || [],
|
||||
priority: status.priority || 0
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('加载工具状态配置失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取工具状态
|
||||
* @param {string} toolId - 工具ID
|
||||
* @returns {Object} 工具状态对象
|
||||
*/
|
||||
getToolStatus(toolId) {
|
||||
// 先从缓存获取
|
||||
if (this.statusCache.has(toolId)) {
|
||||
return this.statusCache.get(toolId);
|
||||
}
|
||||
|
||||
// 如果缓存中没有,返回默认状态
|
||||
// [健康检查预留位置] 默认状态应该从配置中加载,如果没有则使用默认值
|
||||
const defaultStatus = {
|
||||
enabled: true,
|
||||
message: '',
|
||||
healthStatus: 'unknown',
|
||||
lastHealthCheck: null,
|
||||
lockReason: null,
|
||||
metadata: {},
|
||||
tags: [],
|
||||
priority: 0
|
||||
};
|
||||
|
||||
// [健康检查预留位置] 检查配置中的锁定状态(避免循环依赖)
|
||||
// 注意:这里不再直接访问 toolHealthChecker,而是通过配置和状态缓存来管理
|
||||
// toolHealthChecker 会通过 updateHealthStatus 来更新状态,这里只需要读取缓存即可
|
||||
|
||||
return defaultStatus;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新工具状态
|
||||
* @param {string} toolId - 工具ID
|
||||
* @param {Object} updates - 要更新的状态字段
|
||||
*/
|
||||
updateToolStatus(toolId, updates) {
|
||||
const currentStatus = this.getToolStatus(toolId);
|
||||
const newStatus = {
|
||||
...currentStatus,
|
||||
...updates
|
||||
};
|
||||
|
||||
this.statusCache.set(toolId, newStatus);
|
||||
|
||||
// 保存到配置
|
||||
if (!configManager.config.tool_status) {
|
||||
configManager.config.tool_status = {};
|
||||
}
|
||||
if (!configManager.config.tool_status[toolId]) {
|
||||
configManager.config.tool_status[toolId] = {};
|
||||
}
|
||||
|
||||
// 只保存必要的字段到配置文件
|
||||
configManager.config.tool_status[toolId] = {
|
||||
enabled: newStatus.enabled,
|
||||
message: newStatus.message,
|
||||
healthStatus: newStatus.healthStatus,
|
||||
lastHealthCheck: newStatus.lastHealthCheck,
|
||||
lockReason: newStatus.lockReason,
|
||||
metadata: newStatus.metadata,
|
||||
tags: newStatus.tags,
|
||||
priority: newStatus.priority
|
||||
};
|
||||
|
||||
configManager.saveConfig();
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新健康检查状态
|
||||
* @param {string} toolId - 工具ID
|
||||
* @param {string} healthStatus - 健康状态 (healthy, unhealthy, checking, unknown)
|
||||
* @param {Object} checkResult - 检查结果详情
|
||||
*/
|
||||
updateHealthStatus(toolId, healthStatus, checkResult = {}) {
|
||||
const updates = {
|
||||
healthStatus: healthStatus,
|
||||
lastHealthCheck: new Date().toISOString(),
|
||||
lockReason: healthStatus === 'unhealthy' ? (checkResult.reason || 'health_check_failed') : null
|
||||
};
|
||||
|
||||
// 如果检查失败,自动禁用工具
|
||||
if (healthStatus === 'unhealthy' && checkResult.autoLock !== false) {
|
||||
updates.enabled = false;
|
||||
updates.message = checkResult.message || '工具健康检查失败,已自动锁定';
|
||||
}
|
||||
|
||||
// 如果检查成功,恢复启用状态
|
||||
if (healthStatus === 'healthy' && checkResult.autoUnlock !== false) {
|
||||
updates.enabled = true;
|
||||
updates.message = '';
|
||||
updates.lockReason = null;
|
||||
}
|
||||
|
||||
this.updateToolStatus(toolId, updates);
|
||||
this.healthStatusCache.set(toolId, {
|
||||
status: healthStatus,
|
||||
timestamp: Date.now(),
|
||||
result: checkResult
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查工具是否可用
|
||||
* @param {string} toolId - 工具ID
|
||||
* @returns {boolean} 工具是否可用
|
||||
*/
|
||||
isToolEnabled(toolId) {
|
||||
const status = this.getToolStatus(toolId);
|
||||
return status.enabled && status.healthStatus !== 'unhealthy';
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查工具是否健康
|
||||
* @param {string} toolId - 工具ID
|
||||
* @returns {boolean} 工具是否健康
|
||||
*/
|
||||
isToolHealthy(toolId) {
|
||||
const status = this.getToolStatus(toolId);
|
||||
return status.healthStatus === 'healthy';
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取工具的健康状态
|
||||
* @param {string} toolId - 工具ID
|
||||
* @returns {string} 健康状态 (healthy, unhealthy, checking, unknown)
|
||||
*/
|
||||
getHealthStatus(toolId) {
|
||||
const status = this.getToolStatus(toolId);
|
||||
return status.healthStatus;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有工具的状态
|
||||
* @returns {Map} 所有工具的状态映射
|
||||
*/
|
||||
getAllToolStatuses() {
|
||||
return new Map(this.statusCache);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有不健康的工具
|
||||
* @returns {Array} 不健康的工具ID列表
|
||||
*/
|
||||
getUnhealthyTools() {
|
||||
const unhealthy = [];
|
||||
this.statusCache.forEach((status, toolId) => {
|
||||
if (status.healthStatus === 'unhealthy') {
|
||||
unhealthy.push(toolId);
|
||||
}
|
||||
});
|
||||
return unhealthy;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有被锁定的工具
|
||||
* @returns {Array} 被锁定的工具ID列表
|
||||
*/
|
||||
getLockedTools() {
|
||||
const locked = [];
|
||||
this.statusCache.forEach((status, toolId) => {
|
||||
// [健康检查预留位置] 工具被锁定的条件:
|
||||
// 1. enabled 为 false(手动禁用或健康检查失败自动锁定)
|
||||
// 2. 有 lockReason(健康检查失败等原因)
|
||||
// 3. healthStatus 为 unhealthy(健康检查失败)
|
||||
if (!status.enabled || status.lockReason || status.healthStatus === 'unhealthy') {
|
||||
locked.push({
|
||||
toolId,
|
||||
reason: status.lockReason,
|
||||
message: status.message,
|
||||
healthStatus: status.healthStatus
|
||||
});
|
||||
}
|
||||
});
|
||||
return locked;
|
||||
}
|
||||
|
||||
/**
|
||||
* [健康检查预留位置] 检查工具是否被锁定
|
||||
* @param {string} toolId - 工具ID
|
||||
* @returns {boolean} 工具是否被锁定
|
||||
*/
|
||||
isToolLocked(toolId) {
|
||||
const status = this.getToolStatus(toolId);
|
||||
return !status.enabled || !!status.lockReason || status.healthStatus === 'unhealthy';
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量更新工具状态
|
||||
* @param {Object} statuses - 工具状态映射 {toolId: {enabled, message, ...}}
|
||||
*/
|
||||
batchUpdateStatuses(statuses) {
|
||||
Object.keys(statuses).forEach(toolId => {
|
||||
this.updateToolStatus(toolId, statuses[toolId]);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置工具状态(用于测试或恢复)
|
||||
* @param {string} toolId - 工具ID
|
||||
*/
|
||||
resetToolStatus(toolId) {
|
||||
this.statusCache.delete(toolId);
|
||||
if (configManager.config.tool_status && configManager.config.tool_status[toolId]) {
|
||||
delete configManager.config.tool_status[toolId];
|
||||
configManager.saveConfig();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新状态缓存(从配置重新加载)
|
||||
*/
|
||||
refresh() {
|
||||
this.loadStatusConfig();
|
||||
}
|
||||
}
|
||||
|
||||
// 导出单例
|
||||
export default new ToolStatusManager();
|
||||
@@ -0,0 +1,696 @@
|
||||
// src/js/toolboxVerificationPage.js
|
||||
/**
|
||||
* [重构] 工具箱验证页面
|
||||
* 1级界面:验证是否需要进行健康检查
|
||||
* 1.5级界面:健康检查进行中
|
||||
* 2级界面:工具箱实际界面
|
||||
*/
|
||||
import configManager from './configManager.js';
|
||||
import toolHealthChecker from './toolHealthChecker.js';
|
||||
import i18n from './i18n.js';
|
||||
import uiManager from './uiManager.js';
|
||||
|
||||
// [日志] 确保 configManager 可用于日志记录
|
||||
|
||||
// 延迟导入 toolStatusManager,避免循环依赖
|
||||
let toolStatusManagerInstance = null;
|
||||
async function getToolStatusManager() {
|
||||
if (!toolStatusManagerInstance) {
|
||||
const module = await import('./toolStatusManager.js');
|
||||
toolStatusManagerInstance = module.default;
|
||||
}
|
||||
return toolStatusManagerInstance;
|
||||
}
|
||||
|
||||
class ToolboxVerificationPage {
|
||||
constructor() {
|
||||
this.isChecking = false;
|
||||
this.isBackgroundChecking = false;
|
||||
this.checkPromise = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染验证界面(1级界面)
|
||||
* 检查是否需要健康检查,如果需要则进入1.5级界面
|
||||
*/
|
||||
async renderVerificationPage() {
|
||||
const contentArea = document.getElementById('content-area');
|
||||
if (!contentArea) return;
|
||||
|
||||
// [修复] 检查是否有正在进行的健康检查
|
||||
if (toolHealthChecker.isChecking) {
|
||||
// 有检查正在进行,显示检查页面并恢复进度显示(显示检查列表)
|
||||
this._renderCheckPage(true);
|
||||
await this._initializeToolsList();
|
||||
|
||||
// 恢复进度显示
|
||||
this._restoreProgressDisplay();
|
||||
|
||||
// 注册进度回调以更新界面
|
||||
const progressCallback = (progress) => {
|
||||
this._updateCheckProgress(progress);
|
||||
if (progress.lastResult) {
|
||||
this.updateToolItem(progress.lastResult.toolId, progress.lastResult.status, progress.lastResult.reason);
|
||||
}
|
||||
};
|
||||
|
||||
toolHealthChecker.onProgress(progressCallback);
|
||||
|
||||
// 等待检查完成
|
||||
if (toolHealthChecker.checkPromise) {
|
||||
toolHealthChecker.checkPromise.then(results => {
|
||||
this._onCheckComplete(results);
|
||||
toolHealthChecker.offProgress(progressCallback);
|
||||
}).catch(error => {
|
||||
console.error('[ToolboxVerification] 健康检查失败:', error);
|
||||
this._onCheckError(error);
|
||||
toolHealthChecker.offProgress(progressCallback);
|
||||
});
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查是否需要健康检查
|
||||
const needCheck = await this._shouldPerformHealthCheck();
|
||||
|
||||
if (!needCheck) {
|
||||
// [优化] 不需要检查,显示简化的检查页面(不显示检查列表),然后快速跳转到工具箱
|
||||
this._renderCheckPage(false);
|
||||
|
||||
// 更新状态显示为已完成
|
||||
const statusEl = document.getElementById('tool-health-check-page-status');
|
||||
if (statusEl) {
|
||||
statusEl.innerHTML = '<i class="fas fa-check-circle" style="color: #10b981;"></i> 检查已完成';
|
||||
}
|
||||
|
||||
// 快速跳转到工具箱界面(延迟很短,让用户看到状态)
|
||||
setTimeout(() => {
|
||||
this._renderToolboxPage();
|
||||
}, 800);
|
||||
return;
|
||||
}
|
||||
|
||||
// 需要检查,显示验证界面(显示检查列表)
|
||||
this._renderCheckPage(true);
|
||||
|
||||
// 初始化工具列表
|
||||
await this._initializeToolsList();
|
||||
|
||||
// 自动开始健康检查
|
||||
setTimeout(() => {
|
||||
this.startHealthCheck(false);
|
||||
}, 500);
|
||||
}
|
||||
|
||||
/**
|
||||
* [新增] 恢复进度显示
|
||||
* 当从其他页面切换回来时,恢复显示当前的检查进度
|
||||
*/
|
||||
_restoreProgressDisplay() {
|
||||
const progress = toolHealthChecker.getProgress();
|
||||
if (progress && progress.total > 0) {
|
||||
this._updateCheckProgress(progress);
|
||||
|
||||
// 恢复工具项状态
|
||||
const checkResults = toolHealthChecker.getCheckResults();
|
||||
if (checkResults && Object.keys(checkResults).length > 0) {
|
||||
Object.entries(checkResults).forEach(([toolId, result]) => {
|
||||
this.updateToolItem(toolId, result.status, result.reason);
|
||||
});
|
||||
}
|
||||
|
||||
// 更新状态显示
|
||||
const statusEl = document.getElementById('tool-health-check-page-status');
|
||||
if (statusEl && toolHealthChecker.currentCheckTool) {
|
||||
const toolInfo = this._getToolInfo(toolHealthChecker.currentCheckTool);
|
||||
statusEl.innerHTML = `<i class="fas fa-spinner fa-spin"></i> 正在检查: ${toolInfo.name}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* [优化] 判断是否需要进行健康检查
|
||||
* 逻辑:已检查过且全部通过 -> 直接跳转;未检查或超过5天 -> 强制检查
|
||||
* @returns {Promise<boolean>} true 表示需要检查
|
||||
*/
|
||||
async _shouldPerformHealthCheck() {
|
||||
try {
|
||||
// [优化] 优先检查是否超过5天未检查(强制检查)
|
||||
if (window.electronAPI && window.electronAPI.shouldForceHealthCheck) {
|
||||
const result = await window.electronAPI.shouldForceHealthCheck(5);
|
||||
if (result && result.success && result.data) {
|
||||
if (result.data) {
|
||||
console.log('[ToolboxVerification] 超过5天未检查,需要强制检查');
|
||||
return true; // 需要强制检查
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// [优化] 检查今天是否已经检查过
|
||||
const hasCheckedToday = toolHealthChecker.hasCheckedToday(false);
|
||||
if (hasCheckedToday) {
|
||||
// 今天已经检查过,检查结果是否全部通过
|
||||
const allPassed = await this._checkAllToolsPassed();
|
||||
if (allPassed) {
|
||||
console.log('[ToolboxVerification] 今天已检查过且全部通过,直接跳转工具箱');
|
||||
return false; // 全部通过,不需要检查,直接跳转
|
||||
} else {
|
||||
// 今天检查过但未全部通过,需要重新检查
|
||||
console.log('[ToolboxVerification] 今天已检查过但未全部通过,需要重新检查');
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// [优化] 如果今天未检查,需要检查
|
||||
console.log('[ToolboxVerification] 今天未检查,需要检查');
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('[ToolboxVerification] 判断是否需要健康检查失败:', error);
|
||||
// 出错时默认需要检查
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查所有工具是否都通过
|
||||
* @returns {Promise<boolean>} true 表示全部通过
|
||||
*/
|
||||
async _checkAllToolsPassed() {
|
||||
try {
|
||||
if (window.electronAPI && window.electronAPI.getToolHealthCheckSummary) {
|
||||
const summary = await window.electronAPI.getToolHealthCheckSummary();
|
||||
if (summary && summary.success && summary.data) {
|
||||
const data = summary.data;
|
||||
// 如果失败数量为0,表示全部通过
|
||||
return data.failed_count === 0;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
} catch (error) {
|
||||
console.error('[ToolboxVerification] 检查工具通过状态失败:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否有工具被锁定
|
||||
* @returns {Promise<boolean>} true 表示有工具被锁定
|
||||
*/
|
||||
async _hasLockedTools() {
|
||||
try {
|
||||
const { toolRegistry } = await import('./tool-registry.js');
|
||||
const toolStatusMgr = await getToolStatusManager();
|
||||
const allTools = Object.keys(toolRegistry);
|
||||
const networkTools = allTools.filter(toolId => toolHealthChecker.isNetworkTool(toolId));
|
||||
|
||||
for (const toolId of networkTools) {
|
||||
const toolStatus = toolStatusMgr.getToolStatus(toolId);
|
||||
if (toolStatus.healthStatus === 'unhealthy' || toolStatus.lockReason) {
|
||||
return true;
|
||||
}
|
||||
if (toolHealthChecker.isToolLocked(toolId)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
} catch (error) {
|
||||
console.error('[ToolboxVerification] 检查锁定工具失败:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染健康检查页面(1.5级界面)
|
||||
* @param {boolean} showCheckList - 是否显示检查列表(默认true,已检查过时设为false)
|
||||
*/
|
||||
_renderCheckPage(showCheckList = true) {
|
||||
const contentArea = document.getElementById('content-area');
|
||||
if (!contentArea) return;
|
||||
|
||||
// [优化] 根据是否需要检查来决定是否显示检查列表
|
||||
const checkListHtml = showCheckList ? `
|
||||
<div class="verification-tools-list-container">
|
||||
<div class="verification-tools-list-header">
|
||||
<i class="fas fa-list"></i>
|
||||
<span>检查列表</span>
|
||||
</div>
|
||||
<div id="tool-health-check-page-list" class="verification-tools-list">
|
||||
<div class="verification-loading">
|
||||
<i class="fas fa-spinner fa-spin"></i>
|
||||
<span>正在加载工具列表...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
` : '';
|
||||
|
||||
contentArea.innerHTML = `
|
||||
<div class="page-container toolbox-verification-page">
|
||||
<div class="verification-header">
|
||||
<div class="verification-icon">
|
||||
<i class="fas fa-shield-alt"></i>
|
||||
</div>
|
||||
<h1 class="verification-title">工具健康检查</h1>
|
||||
<p class="verification-subtitle">${showCheckList ? '正在验证网络工具的健康状态...' : '工具健康检查已完成,正在进入工具箱...'}</p>
|
||||
</div>
|
||||
|
||||
<div class="verification-progress-container">
|
||||
<div class="verification-progress-header">
|
||||
<span id="tool-health-check-page-status" class="verification-status">
|
||||
<i class="fas fa-spinner fa-spin"></i> ${showCheckList ? '准备中...' : '已完成'}
|
||||
</span>
|
||||
<span id="tool-health-check-page-progress-text" class="verification-progress-text">0 / 0</span>
|
||||
</div>
|
||||
|
||||
<div class="verification-progress-bar-container">
|
||||
<div id="tool-health-check-page-progress-bar" class="verification-progress-bar"></div>
|
||||
</div>
|
||||
|
||||
<div class="verification-stats">
|
||||
<div class="verification-stat-item">
|
||||
<i class="fas fa-check-circle" style="color: #10b981;"></i>
|
||||
<span>正常: <strong id="tool-health-check-page-success">0</strong></span>
|
||||
</div>
|
||||
<div class="verification-stat-item">
|
||||
<i class="fas fa-times-circle" style="color: #ef4444;"></i>
|
||||
<span>异常: <strong id="tool-health-check-page-failed">0</strong></span>
|
||||
</div>
|
||||
<div class="verification-stat-item">
|
||||
<i class="fas fa-clock" style="color: #6b7280;"></i>
|
||||
<span>检查中: <strong id="tool-health-check-page-pending">0</strong></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${checkListHtml}
|
||||
|
||||
<div class="verification-actions">
|
||||
<button id="verification-skip-btn" class="control-btn ripple" style="display: none;">
|
||||
<i class="fas fa-forward"></i> 跳过检查
|
||||
</button>
|
||||
<button id="verification-background-btn" class="control-btn ripple" style="display: none;">
|
||||
<i class="fas fa-tasks"></i> 后台继续
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// 绑定事件
|
||||
this._bindCheckPageEvents();
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化工具列表
|
||||
*/
|
||||
async _initializeToolsList() {
|
||||
const listEl = document.getElementById('tool-health-check-page-list');
|
||||
if (!listEl) return;
|
||||
|
||||
try {
|
||||
const { toolRegistry } = await import('./tool-registry.js');
|
||||
const allTools = Object.keys(toolRegistry);
|
||||
const networkTools = allTools.filter(toolId => toolHealthChecker.isNetworkTool(toolId));
|
||||
|
||||
if (networkTools.length === 0) {
|
||||
listEl.innerHTML = `
|
||||
<div class="verification-empty">
|
||||
<i class="fas fa-info-circle"></i>
|
||||
<p>没有需要检查的网络工具</p>
|
||||
</div>
|
||||
`;
|
||||
// 如果没有网络工具,直接跳转到工具箱
|
||||
setTimeout(() => {
|
||||
this._renderToolboxPage();
|
||||
}, 1500);
|
||||
return;
|
||||
}
|
||||
|
||||
// 创建工具项占位符
|
||||
const toolStatusMgr = await getToolStatusManager();
|
||||
listEl.innerHTML = networkTools.map(toolId => {
|
||||
const toolInfo = this._getToolInfo(toolId);
|
||||
// 检查工具当前状态
|
||||
const toolStatus = toolStatusMgr.getToolStatus(toolId);
|
||||
const initialStatus = toolStatus.healthStatus === 'unhealthy' ? 'failed' : 'checking';
|
||||
return `
|
||||
<div id="tool-health-check-page-item-${toolId}" class="verification-tool-item" data-tool-id="${toolId}">
|
||||
<div class="verification-tool-name">
|
||||
<i class="fas fa-spinner fa-spin"></i>
|
||||
<span>${toolInfo.name}</span>
|
||||
</div>
|
||||
<div class="verification-tool-status ${initialStatus}">
|
||||
<i class="fas fa-spinner fa-spin"></i>
|
||||
<span>等待检查</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
} catch (error) {
|
||||
console.error('[ToolboxVerification] 初始化工具列表失败:', error);
|
||||
listEl.innerHTML = `
|
||||
<div class="verification-empty">
|
||||
<i class="fas fa-exclamation-triangle"></i>
|
||||
<p>加载工具列表失败: ${error.message}</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* [新增] 从数据库加载上次检查日期
|
||||
*/
|
||||
async loadLastCheckDate() {
|
||||
try {
|
||||
if (window.electronAPI && window.electronAPI.getLastHealthCheckDate) {
|
||||
const result = await window.electronAPI.getLastHealthCheckDate();
|
||||
if (result && result.success && result.data) {
|
||||
return result.data;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[ToolboxVerification] 加载上次检查日期失败:', error);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取工具信息
|
||||
*/
|
||||
_getToolInfo(toolId) {
|
||||
if (window.uiManager && window.uiManager.modularTools[toolId]) {
|
||||
return window.uiManager.modularTools[toolId];
|
||||
}
|
||||
return { name: toolId };
|
||||
}
|
||||
|
||||
/**
|
||||
* 绑定检查页面事件
|
||||
*/
|
||||
_bindCheckPageEvents() {
|
||||
const skipBtn = document.getElementById('verification-skip-btn');
|
||||
const backgroundBtn = document.getElementById('verification-background-btn');
|
||||
|
||||
skipBtn?.addEventListener('click', () => {
|
||||
// 跳过检查,直接进入工具箱
|
||||
this._renderToolboxPage();
|
||||
});
|
||||
|
||||
backgroundBtn?.addEventListener('click', () => {
|
||||
// 后台继续检查
|
||||
if (this.isChecking && !this.isBackgroundChecking) {
|
||||
this.isBackgroundChecking = true;
|
||||
backgroundBtn.style.display = 'none';
|
||||
uiManager.showNotification('提示', '健康检查将在后台继续,您可以继续使用其他功能', 'info');
|
||||
// 跳转到工具箱界面
|
||||
this._renderToolboxPage();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 开始健康检查
|
||||
* @param {boolean} force - 是否强制检查
|
||||
*/
|
||||
async startHealthCheck(force = false) {
|
||||
// [修复] 如果已经在检查,不重复启动
|
||||
if (this.isChecking || toolHealthChecker.isChecking) {
|
||||
console.log('[ToolboxVerification] 健康检查已在进行中,跳过重复启动');
|
||||
return;
|
||||
}
|
||||
|
||||
this.isChecking = true;
|
||||
this.isBackgroundChecking = false;
|
||||
|
||||
// 注册进度回调
|
||||
const progressCallback = (progress) => {
|
||||
this._updateCheckProgress(progress);
|
||||
|
||||
// 更新工具项状态(如果有最后结果)
|
||||
if (progress.lastResult) {
|
||||
const result = progress.lastResult;
|
||||
this.updateToolItem(result.toolId, result.status, result.reason);
|
||||
}
|
||||
};
|
||||
|
||||
toolHealthChecker.onProgress(progressCallback);
|
||||
|
||||
try {
|
||||
// 开始检查(异步执行,不阻塞)
|
||||
this.checkPromise = toolHealthChecker.checkAllTools(force, this.isBackgroundChecking);
|
||||
|
||||
// 不等待完成,让检查在后台进行
|
||||
this.checkPromise.then(results => {
|
||||
// 检查完成
|
||||
this._onCheckComplete(results);
|
||||
}).catch(error => {
|
||||
console.error('[ToolboxVerification] 健康检查失败:', error);
|
||||
this._onCheckError(error);
|
||||
}).finally(() => {
|
||||
toolHealthChecker.offProgress(progressCallback);
|
||||
this.isChecking = false;
|
||||
this.checkPromise = null;
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[ToolboxVerification] 启动健康检查失败:', error);
|
||||
this._onCheckError(error);
|
||||
toolHealthChecker.offProgress(progressCallback);
|
||||
this.isChecking = false;
|
||||
this.checkPromise = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新检查进度
|
||||
* @param {Object} progress - 进度信息
|
||||
*/
|
||||
_updateCheckProgress(progress) {
|
||||
const progressBar = document.getElementById('tool-health-check-page-progress-bar');
|
||||
const progressText = document.getElementById('tool-health-check-page-progress-text');
|
||||
const statusEl = document.getElementById('tool-health-check-page-status');
|
||||
const successEl = document.getElementById('tool-health-check-page-success');
|
||||
const failedEl = document.getElementById('tool-health-check-page-failed');
|
||||
const pendingEl = document.getElementById('tool-health-check-page-pending');
|
||||
|
||||
// [优化] 防止进度条回退,只允许前进
|
||||
if (progressBar && progress.total > 0) {
|
||||
const newPercent = (progress.checked / progress.total) * 100;
|
||||
const currentPercent = parseFloat(progressBar.style.width) || 0;
|
||||
// 只更新更大的进度值
|
||||
if (newPercent >= currentPercent) {
|
||||
progressBar.style.width = `${newPercent}%`;
|
||||
}
|
||||
}
|
||||
|
||||
if (progressText) {
|
||||
progressText.textContent = `${progress.checked} / ${progress.total}`;
|
||||
}
|
||||
|
||||
if (statusEl) {
|
||||
if (progress.completed) {
|
||||
statusEl.innerHTML = `<i class="fas fa-check-circle" style="color: #10b981;"></i> 检查完成`;
|
||||
} else if (progress.currentTool) {
|
||||
const toolInfo = this._getToolInfo(progress.currentTool);
|
||||
statusEl.innerHTML = `<i class="fas fa-spinner fa-spin"></i> 正在检查: ${toolInfo.name}`;
|
||||
// [优化] 更新当前检查项的状态
|
||||
this.updateToolItem(progress.currentTool, 'checking');
|
||||
}
|
||||
}
|
||||
|
||||
if (successEl) successEl.textContent = progress.success || 0;
|
||||
if (failedEl) failedEl.textContent = progress.failed || 0;
|
||||
if (pendingEl) {
|
||||
const pending = (progress.total || 0) - (progress.checked || 0);
|
||||
pendingEl.textContent = pending;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新工具项状态
|
||||
* @param {string} toolId - 工具ID
|
||||
* @param {string} status - 状态 ('success' | 'failed' | 'checking')
|
||||
* @param {string} reason - 失败原因(可选)
|
||||
*/
|
||||
updateToolItem(toolId, status, reason = null) {
|
||||
const itemEl = document.getElementById(`tool-health-check-page-item-${toolId}`);
|
||||
if (!itemEl) return;
|
||||
|
||||
const statusEl = itemEl.querySelector('.verification-tool-status');
|
||||
if (!statusEl) return;
|
||||
|
||||
// [优化] 移除所有工具项的当前追踪样式
|
||||
document.querySelectorAll('.verification-tool-item').forEach(item => {
|
||||
item.classList.remove('current-checking');
|
||||
});
|
||||
|
||||
// [优化] 如果是检查中状态,添加当前追踪样式
|
||||
if (status === 'checking') {
|
||||
itemEl.classList.add('current-checking');
|
||||
// [优化] 滚动到当前检查项
|
||||
itemEl.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
} else {
|
||||
itemEl.classList.remove('current-checking');
|
||||
}
|
||||
|
||||
const statusColors = {
|
||||
'success': '#10b981',
|
||||
'failed': '#ef4444',
|
||||
'checking': '#6b7280'
|
||||
};
|
||||
|
||||
const statusIcons = {
|
||||
'success': 'fa-check-circle',
|
||||
'failed': 'fa-times-circle',
|
||||
'checking': 'fa-spinner fa-spin'
|
||||
};
|
||||
|
||||
const statusTexts = {
|
||||
'success': '正常',
|
||||
'failed': reason || '异常',
|
||||
'checking': '检查中'
|
||||
};
|
||||
|
||||
statusEl.className = `verification-tool-status ${status}`;
|
||||
statusEl.style.color = statusColors[status];
|
||||
statusEl.innerHTML = `
|
||||
<i class="fas ${statusIcons[status]}"></i>
|
||||
<span>${statusTexts[status]}</span>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查完成回调
|
||||
* @param {Array} results - 检查结果
|
||||
*/
|
||||
_onCheckComplete(results) {
|
||||
// [修复] 确保检查状态正确重置
|
||||
this.isChecking = false;
|
||||
|
||||
// 更新所有工具项状态
|
||||
if (results && results.length > 0) {
|
||||
results.forEach(result => {
|
||||
this.updateToolItem(result.toolId, result.status, result.reason);
|
||||
});
|
||||
}
|
||||
|
||||
// 更新状态
|
||||
const statusEl = document.getElementById('tool-health-check-page-status');
|
||||
if (statusEl) {
|
||||
const failedCount = results ? results.filter(r => r.status === 'failed').length : 0;
|
||||
if (failedCount > 0) {
|
||||
statusEl.innerHTML = `<i class="fas fa-exclamation-triangle" style="color: #ef4444;"></i> 检查完成(${failedCount} 个工具异常)`;
|
||||
} else {
|
||||
statusEl.innerHTML = `<i class="fas fa-check-circle" style="color: #10b981;"></i> 检查完成(全部正常)`;
|
||||
}
|
||||
}
|
||||
|
||||
// [修复] 更新设置页面的统计信息(确保上次检查时间被更新)
|
||||
if (window.mainPage && typeof window.mainPage.updateToolHealthStats === 'function') {
|
||||
// 延迟更新,确保数据库操作完成
|
||||
setTimeout(() => {
|
||||
window.mainPage.updateToolHealthStats();
|
||||
}, 500);
|
||||
}
|
||||
|
||||
// [日志] 记录健康检查完成(在工具箱验证页面)
|
||||
if (configManager && configManager.logAction) {
|
||||
const failedCount = results ? results.filter(r => r.status === 'failed').length : 0;
|
||||
const successCount = results ? results.filter(r => r.status === 'success').length : 0;
|
||||
const totalCount = results ? results.length : 0;
|
||||
configManager.logAction(
|
||||
`工具箱验证页面: 健康检查完成 - 总计 ${totalCount},正常 ${successCount},异常 ${failedCount}`,
|
||||
'tool_health_check'
|
||||
);
|
||||
}
|
||||
|
||||
// 显示跳过按钮(如果还在检查页面)
|
||||
const skipBtn = document.getElementById('verification-skip-btn');
|
||||
if (skipBtn && !this.isBackgroundChecking) {
|
||||
skipBtn.innerHTML = '<i class="fas fa-arrow-right"></i> 进入工具箱';
|
||||
skipBtn.style.display = 'inline-flex';
|
||||
}
|
||||
|
||||
// 如果是在后台检查,不自动跳转
|
||||
if (this.isBackgroundChecking) {
|
||||
// 后台检查完成,可以显示通知
|
||||
if (window.uiManager && window.uiManager.showNotification) {
|
||||
const failedCount = results ? results.filter(r => r.status === 'failed').length : 0;
|
||||
if (failedCount > 0) {
|
||||
window.uiManager.showNotification('健康检查完成', `${failedCount} 个工具检查失败,已自动锁定`, 'warning');
|
||||
} else {
|
||||
window.uiManager.showNotification('健康检查完成', '所有工具运行正常', 'success');
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 3秒后自动跳转到工具箱
|
||||
setTimeout(() => {
|
||||
this._renderToolboxPage();
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查错误回调
|
||||
* @param {Error} error - 错误对象
|
||||
*/
|
||||
_onCheckError(error) {
|
||||
const statusEl = document.getElementById('tool-health-check-page-status');
|
||||
if (statusEl) {
|
||||
statusEl.innerHTML = `<i class="fas fa-exclamation-triangle" style="color: #ef4444;"></i> 检查失败: ${error.message}`;
|
||||
}
|
||||
|
||||
// 显示跳过按钮
|
||||
const skipBtn = document.getElementById('verification-skip-btn');
|
||||
if (skipBtn) {
|
||||
skipBtn.textContent = '进入工具箱';
|
||||
skipBtn.style.display = 'inline-flex';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染工具箱页面(2级界面)
|
||||
*/
|
||||
_renderToolboxPage() {
|
||||
// [修复] 如果正在检查,切换到后台模式
|
||||
if ((this.isChecking || toolHealthChecker.isChecking) && !this.isBackgroundChecking) {
|
||||
this.isBackgroundChecking = true;
|
||||
toolHealthChecker.isBackgroundChecking = true;
|
||||
console.log('[ToolboxVerification] 切换到后台检查模式');
|
||||
}
|
||||
|
||||
// 调用 uiManager 的实际渲染方法
|
||||
if (uiManager && uiManager.renderToolboxPageDirect) {
|
||||
uiManager.renderToolboxPageDirect();
|
||||
} else if (uiManager && uiManager._renderToolboxPageContent) {
|
||||
uiManager._renderToolboxPageContent();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 在后台继续健康检查
|
||||
*/
|
||||
async continueCheckInBackground() {
|
||||
if (!this.isChecking && !toolHealthChecker.isChecking) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.isBackgroundChecking = true;
|
||||
toolHealthChecker.isBackgroundChecking = true;
|
||||
|
||||
// 如果检查正在进行,等待完成
|
||||
const checkPromise = this.checkPromise || toolHealthChecker.checkPromise;
|
||||
if (checkPromise) {
|
||||
try {
|
||||
await checkPromise;
|
||||
// 检查完成后,更新工具箱页面显示锁定状态
|
||||
if (uiManager && uiManager.renderToolboxPageDirect) {
|
||||
uiManager.renderToolboxPageDirect();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[ToolboxVerification] 后台检查失败:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new ToolboxVerificationPage();
|
||||
@@ -0,0 +1,185 @@
|
||||
// src/js/tools/aiTranslationTool.js
|
||||
import BaseTool from '../baseTool.js';
|
||||
import configManager from '../configManager.js';
|
||||
import i18n from '../i18n.js';
|
||||
import uiManager from '../uiManager.js';
|
||||
|
||||
class AITranslationTool extends BaseTool {
|
||||
constructor() {
|
||||
super('ai-translation', 'AI 智能翻译');
|
||||
this.apiKey = configManager.config?.api_keys?.uapipro || null;
|
||||
this.abortController = null;
|
||||
|
||||
// 内部状态
|
||||
this.state = {
|
||||
source_lang: 'auto',
|
||||
target_lang: 'zh-CHS',
|
||||
style: 'professional',
|
||||
context: 'general'
|
||||
};
|
||||
|
||||
// 选项定义
|
||||
this.languages = {
|
||||
'auto': '自动检测',
|
||||
'zh-CHS': '中文 (简体)', 'zh-CHT': '中文 (繁体)', 'en': '英语', 'ja': '日语',
|
||||
'ko': '韩语', 'fr': '法语', 'de': '德语', 'es': '西班牙语', 'ru': '俄语'
|
||||
};
|
||||
this.styles = { 'professional': '专业商务', 'casual': '日常口语', 'academic': '学术论文', 'literary': '文学作品' };
|
||||
this.contexts = { 'general': '通用', 'business': '商务会议', 'technical': '技术文档', 'medical': '医疗健康' };
|
||||
}
|
||||
|
||||
render() {
|
||||
// 构建选项 HTML (移出主结构,供 init 调用)
|
||||
const renderOpts = (obj) => Object.entries(obj).map(([k, v]) => `<div class="custom-select-option" data-value="${k}">${v}</div>`).join('');
|
||||
|
||||
const dropdownsHtml = `
|
||||
<div id="opts-source" class="custom-select-options">${renderOpts(this.languages)}</div>
|
||||
<div id="opts-target" class="custom-select-options">${renderOpts(this.languages)}</div>
|
||||
<div id="opts-style" class="custom-select-options">${renderOpts(this.styles)}</div>
|
||||
<div id="opts-context" class="custom-select-options">${renderOpts(this.contexts)}</div>
|
||||
`;
|
||||
|
||||
return `
|
||||
<div class="page-container" style="display: flex; flex-direction: column; height: 100%; gap: 20px;">
|
||||
<div class="section-header">
|
||||
<button id="back-to-toolbox-btn" class="back-btn ripple"><i class="fas fa-arrow-left"></i> 返回工具箱</button>
|
||||
<h1 style="flex-grow: 1; text-align: center;">${this.name}</h1>
|
||||
</div>
|
||||
|
||||
<div class="content-area" style="padding: 0 20px 20px 20px; display: flex; flex-direction: column; flex-grow: 1; min-height: 0; gap: 20px;">
|
||||
|
||||
<div class="island-card" style="height: auto; padding: 15px; background: rgba(var(--card-background-rgb), 0.6); flex-shrink: 0; display: flex; flex-wrap: wrap; gap: 15px; align-items: center; justify-content: space-between;">
|
||||
|
||||
<div style="display: flex; gap: 10px; align-items: center; flex-wrap: wrap;">
|
||||
<div class="custom-select-wrapper" id="dd-source" style="width: 140px;">
|
||||
<div class="custom-select-trigger"><span class="custom-select-value">自动检测</span><i class="fas fa-chevron-down custom-select-arrow"></i></div>
|
||||
</div>
|
||||
<i class="fas fa-arrow-right" style="color: var(--text-secondary);"></i>
|
||||
<div class="custom-select-wrapper" id="dd-target" style="width: 140px;">
|
||||
<div class="custom-select-trigger"><span class="custom-select-value">中文 (简体)</span><i class="fas fa-chevron-down custom-select-arrow"></i></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; gap: 10px; align-items: center; flex-wrap: wrap;">
|
||||
<div class="custom-select-wrapper" id="dd-style" style="width: 120px;">
|
||||
<div class="custom-select-trigger"><span class="custom-select-value">专业商务</span><i class="fas fa-chevron-down custom-select-arrow"></i></div>
|
||||
</div>
|
||||
<div class="custom-select-wrapper" id="dd-context" style="width: 120px;">
|
||||
<div class="custom-select-trigger"><span class="custom-select-value">通用</span><i class="fas fa-chevron-down custom-select-arrow"></i></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button id="ai-trans-btn" class="action-btn ripple" style="padding: 8px 25px;"><i class="fas fa-language"></i> 翻译</button>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; flex-grow: 1; gap: 20px; min-height: 0;">
|
||||
|
||||
<div class="settings-section" style="flex: 1; display: flex; flex-direction: column; padding: 15px; margin: 0;">
|
||||
<textarea id="ai-src-text" placeholder="输入要翻译的文本..." style="flex-grow: 1; border: none; background: transparent; resize: none; outline: none; font-size: 15px; line-height: 1.6;"></textarea>
|
||||
<div style="display: flex; justify-content: flex-end; padding-top: 10px; border-top: 1px dashed var(--border-color);">
|
||||
<button id="btn-paste" class="control-btn mini-btn ripple"><i class="fas fa-paste"></i> 粘贴</button>
|
||||
<button id="btn-clear" class="control-btn mini-btn ripple" style="margin-left: 10px;"><i class="fas fa-times"></i> 清空</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-section" style="flex: 1; display: flex; flex-direction: column; padding: 15px; margin: 0; background: rgba(var(--primary-rgb), 0.05); border-color: rgba(var(--primary-rgb), 0.2);">
|
||||
<textarea id="ai-tgt-text" placeholder="翻译结果..." readonly style="flex-grow: 1; border: none; background: transparent; resize: none; outline: none; font-size: 15px; line-height: 1.6; color: var(--text-color);"></textarea>
|
||||
<div style="display: flex; justify-content: flex-end; padding-top: 10px; border-top: 1px dashed var(--border-color);">
|
||||
<button id="btn-copy" class="control-btn mini-btn ripple"><i class="fas fa-copy"></i> 复制结果</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
${dropdownsHtml}
|
||||
`;
|
||||
}
|
||||
|
||||
init() {
|
||||
this._log('AI 翻译工具初始化');
|
||||
document.getElementById('back-to-toolbox-btn')?.addEventListener('click', () => {
|
||||
window.mainPage.navigateTo('toolbox');
|
||||
window.mainPage.updateActiveNavButton(document.getElementById('toolbox-btn'));
|
||||
});
|
||||
|
||||
// 绑定下拉菜单
|
||||
uiManager.setupAdaptiveDropdown('dd-source', 'opts-source', (val) => this.state.source_lang = val);
|
||||
uiManager.setupAdaptiveDropdown('dd-target', 'opts-target', (val) => this.state.target_lang = val);
|
||||
uiManager.setupAdaptiveDropdown('dd-style', 'opts-style', (val) => this.state.style = val);
|
||||
uiManager.setupAdaptiveDropdown('dd-context', 'opts-context', (val) => this.state.context = val);
|
||||
|
||||
// 绑定按钮
|
||||
document.getElementById('ai-trans-btn').addEventListener('click', () => this._translate());
|
||||
document.getElementById('btn-paste').addEventListener('click', async () => {
|
||||
document.getElementById('ai-src-text').value = await navigator.clipboard.readText();
|
||||
});
|
||||
document.getElementById('btn-clear').addEventListener('click', () => {
|
||||
document.getElementById('ai-src-text').value = '';
|
||||
document.getElementById('ai-tgt-text').value = '';
|
||||
});
|
||||
document.getElementById('btn-copy').addEventListener('click', () => {
|
||||
const txt = document.getElementById('ai-tgt-text').value;
|
||||
if(txt) navigator.clipboard.writeText(txt);
|
||||
});
|
||||
}
|
||||
|
||||
async _translate() {
|
||||
const text = document.getElementById('ai-src-text').value.trim();
|
||||
if (!text) return this._notify('提示', '请输入内容', 'info');
|
||||
|
||||
if (this.abortController) this.abortController.abort();
|
||||
this.abortController = new AbortController();
|
||||
|
||||
const btn = document.getElementById('ai-trans-btn');
|
||||
const tgtArea = document.getElementById('ai-tgt-text');
|
||||
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i>';
|
||||
tgtArea.value = '正在思考并翻译...';
|
||||
|
||||
try {
|
||||
const res = await fetch('https://uapis.cn/api/v1/ai/translate', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(this.apiKey ? { 'Authorization': `Bearer ${this.apiKey}` } : {})
|
||||
},
|
||||
body: JSON.stringify({
|
||||
text: text,
|
||||
source_lang: this.state.source_lang === 'auto' ? null : this.state.source_lang,
|
||||
target_lang: this.state.target_lang,
|
||||
style: this.state.style,
|
||||
context: this.state.context
|
||||
}),
|
||||
signal: this.abortController.signal
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.message || 'API Error');
|
||||
|
||||
if (data.data && data.data.translated_text) {
|
||||
tgtArea.value = data.data.translated_text;
|
||||
this._log('翻译成功');
|
||||
} else {
|
||||
throw new Error('未返回有效结果');
|
||||
}
|
||||
} catch (e) {
|
||||
if (e.name === 'AbortError') return;
|
||||
tgtArea.value = `翻译失败: ${e.message}`;
|
||||
this._notify('错误', e.message, 'error');
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = '<i class="fas fa-language"></i> 翻译';
|
||||
}
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (this.abortController) this.abortController.abort();
|
||||
// 清理下拉菜单 DOM
|
||||
['opts-source', 'opts-target', 'opts-style', 'opts-context'].forEach(id => document.getElementById(id)?.remove());
|
||||
super.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
export default AITranslationTool;
|
||||
@@ -0,0 +1,326 @@
|
||||
// src/js/tools/archiveTool.js
|
||||
import BaseTool from '../baseTool.js';
|
||||
|
||||
class EncryptionCompressionTool extends BaseTool {
|
||||
constructor() {
|
||||
super('archive-tool', '加密&压缩');
|
||||
this.mode = 'encrypt';
|
||||
this.fileList = [];
|
||||
this.decryptTargetFile = null;
|
||||
this.decryptKeyFile = null;
|
||||
}
|
||||
|
||||
render() {
|
||||
return `
|
||||
<div class="page-container archive-tool-container" style="display: flex; flex-direction: column; height: 100%;">
|
||||
<div class="section-header">
|
||||
<button id="back-to-toolbox-btn" class="back-btn ripple"><i class="fas fa-arrow-left"></i> 返回工具箱</button>
|
||||
<h1 style="flex-grow: 1; text-align: center;"><i class="fas fa-shield-alt"></i> ${this.name}</h1>
|
||||
</div>
|
||||
|
||||
<div class="content-area" style="padding: 20px; flex-grow: 1; display: flex; flex-direction: column; overflow: hidden;">
|
||||
<div class="settings-section" style="padding: 20px; height: 100%; display: flex; flex-direction: column; gap: 15px;">
|
||||
|
||||
<div class="sys-info-tabs" id="archive-tabs" style="margin-bottom: 10px; justify-content: center; flex-shrink: 0;">
|
||||
<button class="sys-info-tab active" data-mode="encrypt"><i class="fas fa-lock"></i> 加密打包</button>
|
||||
<button class="sys-info-tab" data-mode="decrypt"><i class="fas fa-key"></i> 解密还原</button>
|
||||
</div>
|
||||
|
||||
<div class="archive-workspace" style="flex-grow: 1; overflow-y: auto; padding-right: 5px; display: flex; flex-direction: column;">
|
||||
|
||||
<div id="panel-encrypt" class="archive-panel" style="display: flex; flex-direction: column; gap: 15px;">
|
||||
<div class="drag-area" id="encrypt-drag-area" style="border: 2px dashed var(--primary-color); border-radius: 12px; padding: 25px; text-align: center; transition: all 0.2s ease; background: rgba(var(--primary-rgb), 0.05);">
|
||||
<i class="fas fa-cloud-upload-alt" style="font-size: 40px; color: var(--primary-color); margin-bottom: 10px;"></i>
|
||||
<p style="margin-bottom: 15px; font-weight: 500;">将文件或文件夹拖入此处</p>
|
||||
<div style="display: flex; gap: 15px; justify-content: center;">
|
||||
<button id="btn-add-files" class="control-btn mini-btn ripple"><i class="fas fa-file"></i> 添加文件</button>
|
||||
<button id="btn-add-folder" class="control-btn mini-btn ripple"><i class="fas fa-folder"></i> 添加文件夹</button>
|
||||
</div>
|
||||
<input type="file" id="encrypt-input-files" multiple style="display: none;">
|
||||
<input type="file" id="encrypt-input-folder" webkitdirectory style="display: none;">
|
||||
</div>
|
||||
|
||||
<div class="file-list-container" style="border: 1px solid var(--border-color); border-radius: 8px; overflow: hidden;">
|
||||
<div style="background: var(--tag-bg-color); padding: 8px 12px; font-size: 12px; font-weight: 600; border-bottom: 1px solid var(--border-color);">待处理列表</div>
|
||||
<div id="encrypt-file-list" class="file-list" style="height: 120px; overflow-y: auto; padding: 5px; background: rgba(var(--card-background-rgb), 0.3);">
|
||||
<div class="empty-state" style="height: 100%; display: flex; align-items: center; justify-content: center; color: var(--text-secondary); font-size: 12px;">
|
||||
<i class="fas fa-box-open" style="margin-right: 5px;"></i> 暂无文件
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="info-box" style="background: rgba(var(--primary-rgb), 0.1); padding: 12px; border-radius: 8px; font-size: 13px; border: 1px solid rgba(var(--primary-rgb), 0.2);">
|
||||
<i class="fas fa-shield-virus"></i> <strong>安全机制:</strong>
|
||||
采用 AES-256-CBC 强加密算法。操作完成后将生成 <code style="background: rgba(0,0,0,0.2); padding: 2px 4px; border-radius: 4px;">.ymenc</code> 数据包和 <code style="background: rgba(0,0,0,0.2); padding: 2px 4px; border-radius: 4px;">.ymkey</code> 密钥文件。
|
||||
</div>
|
||||
|
||||
<button id="btn-start-encrypt" class="action-btn ripple" style="width: 100%; padding: 12px;">
|
||||
<i class="fas fa-lock"></i> 开始加密并生成密钥
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="panel-decrypt" class="archive-panel" style="display: none; flex-direction: column; gap: 15px;">
|
||||
<div class="decrypt-step-card" style="display: flex; gap: 15px; align-items: center; border: 1px solid var(--border-color); padding: 15px; border-radius: 12px; background: rgba(var(--card-background-rgb), 0.5);">
|
||||
<div class="drag-area-small" id="decrypt-drag-area" style="flex: 1; border: 2px dashed var(--success-color); border-radius: 8px; padding: 20px; text-align: center; cursor: pointer; background: rgba(var(--success-color-rgb), 0.05);">
|
||||
<i class="fas fa-file-archive" style="font-size: 24px; color: var(--success-color); margin-bottom: 8px;"></i>
|
||||
<div style="font-size: 13px; font-weight: 600;">步骤 1: 加密包</div>
|
||||
<div style="font-size: 11px; color: var(--text-secondary);">拖入 .ymenc 文件</div>
|
||||
<div id="decrypt-target-status" style="margin-top: 5px; font-size: 12px; color: var(--text-color);">未选择</div>
|
||||
<input type="file" id="decrypt-input-enc" accept=".ymenc" style="display: none;">
|
||||
</div>
|
||||
|
||||
<div style="color: var(--text-secondary);"><i class="fas fa-plus"></i></div>
|
||||
|
||||
<div class="drag-area-small" id="key-drag-area" style="flex: 1; border: 2px dashed var(--accent-color); border-radius: 8px; padding: 20px; text-align: center; cursor: pointer; background: rgba(var(--accent-color-rgb), 0.05);">
|
||||
<i class="fas fa-key" style="font-size: 24px; color: var(--accent-color); margin-bottom: 8px;"></i>
|
||||
<div style="font-size: 13px; font-weight: 600;">步骤 2: 密钥文件</div>
|
||||
<div style="font-size: 11px; color: var(--text-secondary);">拖入 .ymkey 文件</div>
|
||||
<div id="decrypt-key-status" style="margin-top: 5px; font-size: 12px; color: var(--text-color);">未选择</div>
|
||||
<input type="file" id="decrypt-input-key" accept=".ymkey" style="display: none;">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button id="btn-start-decrypt" class="action-btn ripple" style="width: 100%; padding: 12px; background-color: var(--success-color);" disabled>
|
||||
<i class="fas fa-unlock-alt"></i> 验证密钥并解密
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="archive-status-area" style="display: none; flex-shrink: 0; margin-top: 10px; border-top: 1px solid var(--border-color); padding-top: 15px;">
|
||||
<div class="status-header" style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
|
||||
<span style="font-size: 12px; font-weight: 600;"><i class="fas fa-terminal"></i> 处理日志</span>
|
||||
<span id="progress-percent" style="font-size: 12px; font-family: monospace;">0%</span>
|
||||
</div>
|
||||
<div class="status-bar-container" style="height: 6px; background: #333; border-radius: 3px; overflow: hidden; margin-bottom: 10px;">
|
||||
<div id="archive-progress-bar" class="status-bar" style="width: 0%; background: var(--primary-color); height: 100%; transition: width 0.1s linear;"></div>
|
||||
</div>
|
||||
<div id="archive-console" class="terminal-output" style="
|
||||
height: 120px;
|
||||
background: #1e1e1e;
|
||||
color: #4af626;
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
padding: 10px;
|
||||
overflow-y: auto;
|
||||
font-size: 12px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #333;
|
||||
box-shadow: inset 0 2px 5px rgba(0,0,0,0.5);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
"></div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
init() {
|
||||
this._log('加密&压缩工具初始化');
|
||||
|
||||
document.getElementById('back-to-toolbox-btn')?.addEventListener('click', () => {
|
||||
window.mainPage.navigateTo('toolbox');
|
||||
window.mainPage.updateActiveNavButton(document.getElementById('toolbox-btn'));
|
||||
});
|
||||
|
||||
// Tab 切换逻辑
|
||||
const tabs = document.querySelectorAll('#archive-tabs .sys-info-tab');
|
||||
tabs.forEach(tab => {
|
||||
tab.addEventListener('click', () => {
|
||||
tabs.forEach(t => t.classList.remove('active'));
|
||||
tab.classList.add('active');
|
||||
this.mode = tab.dataset.mode;
|
||||
document.getElementById('panel-encrypt').style.display = this.mode === 'encrypt' ? 'flex' : 'none';
|
||||
document.getElementById('panel-decrypt').style.display = this.mode === 'decrypt' ? 'flex' : 'none';
|
||||
this._resetUI();
|
||||
});
|
||||
});
|
||||
|
||||
// --- 加密逻辑 ---
|
||||
const encryptDrag = document.getElementById('encrypt-drag-area');
|
||||
const btnAddFiles = document.getElementById('btn-add-files');
|
||||
const btnAddFolder = document.getElementById('btn-add-folder');
|
||||
const inputFiles = document.getElementById('encrypt-input-files');
|
||||
const inputFolder = document.getElementById('encrypt-input-folder');
|
||||
|
||||
// 按钮触发
|
||||
btnAddFiles.addEventListener('click', (e) => { e.stopPropagation(); inputFiles.click(); });
|
||||
btnAddFolder.addEventListener('click', (e) => { e.stopPropagation(); inputFolder.click(); });
|
||||
|
||||
// 区域点击触发 (默认加文件)
|
||||
encryptDrag.addEventListener('click', (e) => {
|
||||
if(e.target === encryptDrag || e.target.tagName === 'P' || e.target.tagName === 'I') {
|
||||
inputFiles.click();
|
||||
}
|
||||
});
|
||||
|
||||
// 文件变动监听
|
||||
inputFiles.addEventListener('change', (e) => { if (e.target.files.length) this._addFiles(Array.from(e.target.files)); inputFiles.value = ''; });
|
||||
inputFolder.addEventListener('change', (e) => { if (e.target.files.length) this._addFiles(Array.from(e.target.files)); inputFolder.value = ''; });
|
||||
|
||||
// 拖拽监听
|
||||
encryptDrag.addEventListener('dragover', (e) => { e.preventDefault(); encryptDrag.style.borderColor = 'var(--accent-color)'; });
|
||||
encryptDrag.addEventListener('dragleave', (e) => { e.preventDefault(); encryptDrag.style.borderColor = 'var(--primary-color)'; });
|
||||
encryptDrag.addEventListener('drop', (e) => {
|
||||
e.preventDefault();
|
||||
encryptDrag.style.borderColor = 'var(--primary-color)';
|
||||
if (e.dataTransfer.files.length) this._addFiles(Array.from(e.dataTransfer.files));
|
||||
});
|
||||
|
||||
document.getElementById('btn-start-encrypt').addEventListener('click', () => this._startEncryption());
|
||||
|
||||
// --- 解密逻辑 ---
|
||||
const decryptDrag = document.getElementById('decrypt-drag-area');
|
||||
const decryptInput = document.getElementById('decrypt-input-enc');
|
||||
const keyDrag = document.getElementById('key-drag-area');
|
||||
const keyInput = document.getElementById('decrypt-input-key');
|
||||
|
||||
// 加密包交互
|
||||
decryptDrag.addEventListener('click', (e) => { e.stopPropagation(); decryptInput.click(); });
|
||||
decryptInput.addEventListener('change', (e) => this._setDecryptTarget(e.target.files[0]));
|
||||
decryptDrag.addEventListener('dragover', (e) => e.preventDefault());
|
||||
decryptDrag.addEventListener('drop', (e) => { e.preventDefault(); this._setDecryptTarget(e.dataTransfer.files[0]); });
|
||||
|
||||
// 密钥文件交互
|
||||
keyDrag.addEventListener('click', (e) => { e.stopPropagation(); keyInput.click(); });
|
||||
keyInput.addEventListener('change', (e) => this._setDecryptKey(e.target.files[0]));
|
||||
keyDrag.addEventListener('dragover', (e) => e.preventDefault());
|
||||
keyDrag.addEventListener('drop', (e) => { e.preventDefault(); this._setDecryptKey(e.dataTransfer.files[0]); });
|
||||
|
||||
document.getElementById('btn-start-decrypt').addEventListener('click', () => this._startDecryption());
|
||||
|
||||
// Worker 消息监听
|
||||
if (window.electronAPI.onArchiveProgress) {
|
||||
window.electronAPI.onArchiveProgress(this._onProgress.bind(this));
|
||||
window.electronAPI.onArchiveLog((msg) => this._logToConsole(msg));
|
||||
}
|
||||
window.toolInstance = this;
|
||||
}
|
||||
|
||||
_addFiles(files) {
|
||||
const newFiles = files.filter(f => !this.fileList.some(existing => existing.path === f.path));
|
||||
this.fileList = [...this.fileList, ...newFiles];
|
||||
this._renderFileList();
|
||||
}
|
||||
|
||||
_renderFileList() {
|
||||
const container = document.getElementById('encrypt-file-list');
|
||||
if (this.fileList.length === 0) {
|
||||
container.innerHTML = '<div class="empty-state" style="height: 100%; display: flex; align-items: center; justify-content: center; color: var(--text-secondary); font-size: 12px;"><i class="fas fa-box-open" style="margin-right: 5px;"></i> 暂无文件</div>';
|
||||
return;
|
||||
}
|
||||
container.innerHTML = this.fileList.map((f, i) => `
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; font-size: 12px; padding: 6px; border-bottom: 1px solid rgba(255,255,255,0.05); hover:background: rgba(255,255,255,0.05);">
|
||||
<div style="display: flex; align-items: center; overflow: hidden;">
|
||||
<i class="fas ${f.path.indexOf('.') === -1 ? 'fa-folder' : 'fa-file'}" style="margin-right: 8px; color: var(--text-secondary);"></i>
|
||||
<span style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" title="${f.path}">${f.name}</span>
|
||||
</div>
|
||||
<i class="fas fa-times" style="cursor: pointer; color: var(--error-color); padding: 4px;" onclick="window.toolInstance._removeFile(${i})"></i>
|
||||
</div>`).join('');
|
||||
}
|
||||
|
||||
_removeFile(index) {
|
||||
this.fileList.splice(index, 1);
|
||||
this._renderFileList();
|
||||
}
|
||||
|
||||
_setDecryptTarget(file) {
|
||||
if(!file || !file.name.endsWith('.ymenc')) return this._notify('错误', '请选择 .ymenc 格式的加密包', 'error');
|
||||
this.decryptTargetFile = file;
|
||||
const statusEl = document.getElementById('decrypt-target-status');
|
||||
statusEl.innerHTML = `<i class="fas fa-file-archive" style="color: var(--success-color);"></i> ${file.name}`;
|
||||
statusEl.style.fontWeight = "bold";
|
||||
this._checkDecryptReady();
|
||||
}
|
||||
|
||||
_setDecryptKey(file) {
|
||||
if(!file || !file.name.endsWith('.ymkey')) return this._notify('错误', '请选择 .ymkey 格式的密钥文件', 'error');
|
||||
this.decryptKeyFile = file;
|
||||
const statusEl = document.getElementById('decrypt-key-status');
|
||||
statusEl.innerHTML = `<i class="fas fa-key" style="color: var(--accent-color);"></i> ${file.name}`;
|
||||
statusEl.style.fontWeight = "bold";
|
||||
this._checkDecryptReady();
|
||||
}
|
||||
|
||||
_checkDecryptReady() {
|
||||
document.getElementById('btn-start-decrypt').disabled = !(this.decryptTargetFile && this.decryptKeyFile);
|
||||
}
|
||||
|
||||
_resetUI() {
|
||||
document.getElementById('archive-status-area').style.display = 'none';
|
||||
document.getElementById('archive-progress-bar').style.width = '0%';
|
||||
document.getElementById('archive-console').innerHTML = '';
|
||||
document.getElementById('progress-percent').textContent = '0%';
|
||||
}
|
||||
|
||||
_onProgress(data) {
|
||||
const area = document.getElementById('archive-status-area');
|
||||
if (area.style.display === 'none') area.style.display = 'block';
|
||||
|
||||
document.getElementById('archive-progress-bar').style.width = `${data.percent}%`;
|
||||
document.getElementById('progress-percent').textContent = `${data.percent}%`;
|
||||
}
|
||||
|
||||
_logToConsole(msg) {
|
||||
const el = document.getElementById('archive-console');
|
||||
const div = document.createElement('div');
|
||||
const time = new Date().toLocaleTimeString([], {hour12: false});
|
||||
div.innerHTML = `<span style="color: #666;">[${time}]</span> ${msg}`;
|
||||
div.style.marginBottom = '4px';
|
||||
el.appendChild(div);
|
||||
el.scrollTop = el.scrollHeight;
|
||||
}
|
||||
|
||||
async _startEncryption() {
|
||||
if (this.fileList.length === 0) return this._notify('提示', '请添加要加密的文件', 'info');
|
||||
|
||||
this._resetUI();
|
||||
document.getElementById('archive-status-area').style.display = 'block';
|
||||
this._logToConsole('正在初始化安全环境...');
|
||||
|
||||
const filePaths = this.fileList.map(f => f.path);
|
||||
|
||||
const result = await window.electronAPI.compressFiles({
|
||||
files: filePaths,
|
||||
format: 'ymenc'
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
this._logToConsole(`[成功] 加密完成!`);
|
||||
this._logToConsole(`加密包: ${result.path}`);
|
||||
if (result.keyPath) {
|
||||
this._logToConsole(`密钥文件: ${result.keyPath}`);
|
||||
}
|
||||
this._notify('成功', '加密完成,请妥善保管密钥文件!', 'success');
|
||||
this.fileList = [];
|
||||
this._renderFileList();
|
||||
} else {
|
||||
this._logToConsole(`[错误] ${result.error}`);
|
||||
this._notify('失败', result.error, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async _startDecryption() {
|
||||
if (!this.decryptTargetFile || !this.decryptKeyFile) return;
|
||||
|
||||
this._resetUI();
|
||||
document.getElementById('archive-status-area').style.display = 'block';
|
||||
this._logToConsole('正在验证密钥与加密包...');
|
||||
|
||||
const result = await window.electronAPI.decompressFile({
|
||||
filePath: this.decryptTargetFile.path,
|
||||
keyPath: this.decryptKeyFile.path
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
this._logToConsole(`[成功] 文件已解密并解压至: ${result.path}`);
|
||||
this._notify('成功', '解密还原完成', 'success');
|
||||
} else {
|
||||
this._logToConsole(`[错误] ${result.error}`);
|
||||
this._notify('解密失败', result.error, 'error');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default EncryptionCompressionTool;
|
||||
@@ -0,0 +1,178 @@
|
||||
// src/js/tools/asciiArtTool.js
|
||||
import BaseTool from '../baseTool.js';
|
||||
|
||||
export default class AsciiArtTool extends BaseTool {
|
||||
constructor() {
|
||||
super('ascii-art', 'ASCII 艺术字');
|
||||
}
|
||||
|
||||
render() {
|
||||
return `
|
||||
<div class="page-container" style="padding: 20px; display: flex; flex-direction: column; gap: 20px;">
|
||||
<div class="section-header">
|
||||
<button class="back-btn ripple" id="back-btn"><i class="fas fa-arrow-left"></i></button>
|
||||
<h1>${this.name}</h1>
|
||||
</div>
|
||||
|
||||
<div class="island-card" style="padding: 20px;">
|
||||
<div style="margin-bottom: 15px;">
|
||||
<label style="display: block; margin-bottom: 8px; font-weight: 600;">输入文本</label>
|
||||
<input type="text" id="ascii-input" class="common-input" placeholder="输入要转换的文本(建议1-10个字符)" maxlength="20">
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom: 15px;">
|
||||
<label style="display: block; margin-bottom: 8px; font-weight: 600;">字体样式</label>
|
||||
<select id="ascii-font" class="common-input" style="width: 100%;">
|
||||
<option value="standard">标准 (Standard)</option>
|
||||
<option value="block">方块 (Block)</option>
|
||||
<option value="bubble">气泡 (Bubble)</option>
|
||||
<option value="digital">数字 (Digital)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<button id="btn-generate" class="action-btn ripple" style="width: 100%;">
|
||||
<i class="fas fa-magic"></i> 生成 ASCII 艺术字
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="island-card" style="padding: 20px; min-height: 200px;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px;">
|
||||
<h3 style="margin: 0;">生成结果</h3>
|
||||
<button id="btn-copy-result" class="action-btn ripple" style="display: none;">
|
||||
<i class="fas fa-copy"></i> 复制
|
||||
</button>
|
||||
</div>
|
||||
<div id="ascii-output" style="font-family: monospace; white-space: pre; overflow-x: auto; padding: 15px; background: rgba(var(--bg-color-rgb), 0.3); border-radius: 8px; min-height: 100px; text-align: center; color: var(--text-color);">
|
||||
<div style="text-align: center; padding: 40px; color: var(--text-secondary);">
|
||||
<i class="fas fa-font" style="font-size: 48px; opacity: 0.3; margin-bottom: 10px;"></i>
|
||||
<p>输入文本并点击生成</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
init() {
|
||||
document.getElementById('back-btn').addEventListener('click', () => window.mainPage.navigateTo('toolbox'));
|
||||
|
||||
const asciiFonts = {
|
||||
standard: {
|
||||
'A': ' /\\ \n / \\ \n /____\\ \n / \\ \n/ \\',
|
||||
'B': '|____ \n| | \n|____ \n| | \n|____| ',
|
||||
// 简化版ASCII字体
|
||||
},
|
||||
block: {
|
||||
'A': ' ████ \n█ █ \n██████ \n█ █ \n█ █ ',
|
||||
'B': '█████ \n█ █ \n█████ \n█ █ \n█████ ',
|
||||
},
|
||||
bubble: {
|
||||
'A': ' ╭───╮ \n ╱ ╲ \n╱ ╲\n╲ ╱\n ╲___╱ ',
|
||||
'B': '╔═══╗ \n║ ║ \n╠═══╣ \n║ ║ \n╚═══╝ ',
|
||||
},
|
||||
digital: {
|
||||
'A': ' ███ \n█ █ \n████ \n█ █ \n█ █ ',
|
||||
'B': '███ \n█ █ \n███ \n█ █ \n███ ',
|
||||
}
|
||||
};
|
||||
|
||||
const generateSimpleASCII = (text, fontType) => {
|
||||
// 简化版:使用字符映射生成基础ASCII艺术
|
||||
const chars = text.toUpperCase().split('');
|
||||
const lines = [];
|
||||
|
||||
chars.forEach((char, idx) => {
|
||||
if (char === ' ') {
|
||||
// 空格处理
|
||||
if (idx === 0) {
|
||||
for (let i = 0; i < 5; i++) {
|
||||
if (!lines[i]) lines[i] = '';
|
||||
lines[i] += ' ';
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 基础ASCII字符(简化版)
|
||||
const charMap = {
|
||||
'A': [' ███ ', '█ █', '█████', '█ █', '█ █'],
|
||||
'B': ['███ ', '█ █ ', '███ ', '█ █ ', '███ '],
|
||||
'C': [' ███ ', '█ ', '█ ', '█ ', ' ███ '],
|
||||
'D': ['███ ', '█ █ ', '█ █', '█ █ ', '███ '],
|
||||
'E': ['████ ', '█ ', '███ ', '█ ', '████ '],
|
||||
'F': ['████ ', '█ ', '███ ', '█ ', '█ '],
|
||||
'G': [' ███ ', '█ ', '█ ██ ', '█ █', ' ███ '],
|
||||
'H': ['█ █', '█ █', '█████', '█ █', '█ █'],
|
||||
'I': ['███', ' █ ', ' █ ', ' █ ', '███'],
|
||||
'J': [' █', ' █', ' █', '█ █', ' ██ '],
|
||||
'K': ['█ █', '█ █ ', '██ ', '█ █ ', '█ █'],
|
||||
'L': ['█ ', '█ ', '█ ', '█ ', '████ '],
|
||||
'M': ['█ █', '██ ██', '█ █ █', '█ █', '█ █'],
|
||||
'N': ['█ █', '██ █', '█ █ █', '█ ██', '█ █'],
|
||||
'O': [' ███ ', '█ █', '█ █', '█ █', ' ███ '],
|
||||
'P': ['███ ', '█ █ ', '███ ', '█ ', '█ '],
|
||||
'Q': [' ███ ', '█ █', '█ █', '█ ██', ' ████'],
|
||||
'R': ['███ ', '█ █ ', '███ ', '█ █ ', '█ █ '],
|
||||
'S': [' ███ ', '█ ', ' ███ ', ' █', ' ███ '],
|
||||
'T': ['█████', ' █ ', ' █ ', ' █ ', ' █ '],
|
||||
'U': ['█ █', '█ █', '█ █', '█ █', ' ███ '],
|
||||
'V': ['█ █', '█ █', '█ █', ' █ █ ', ' █ '],
|
||||
'W': ['█ █', '█ █', '█ █ █', '██ ██', '█ █'],
|
||||
'X': ['█ █', ' █ █ ', ' █ ', ' █ █ ', '█ █'],
|
||||
'Y': ['█ █', ' █ █ ', ' █ ', ' █ ', ' █ '],
|
||||
'Z': ['█████', ' █ ', ' █ ', ' █ ', '█████'],
|
||||
'0': [' ███ ', '█ ██', '█ █ █', '██ █', ' ███ '],
|
||||
'1': [' █ ', ' ██ ', ' █ ', ' █ ', ' ███ '],
|
||||
'2': [' ███ ', ' █', ' ███ ', '█ ', '█████'],
|
||||
'3': [' ███ ', ' █', ' ███ ', ' █', ' ███ '],
|
||||
'4': ['█ █', '█ █', '█████', ' █', ' █'],
|
||||
'5': ['█████', '█ ', ' ███ ', ' █', '█████'],
|
||||
'6': [' ███ ', '█ ', '████ ', '█ █', ' ███ '],
|
||||
'7': ['█████', ' █', ' █ ', ' █ ', ' █ '],
|
||||
'8': [' ███ ', '█ █', ' ███ ', '█ █', ' ███ '],
|
||||
'9': [' ███ ', '█ █', ' ████', ' █', ' ███ '],
|
||||
};
|
||||
|
||||
const charLines = charMap[char] || ['?', '?', '?', '?', '?'];
|
||||
|
||||
charLines.forEach((line, i) => {
|
||||
if (!lines[i]) lines[i] = '';
|
||||
lines[i] += line + (idx < chars.length - 1 ? ' ' : '');
|
||||
});
|
||||
});
|
||||
|
||||
return lines.join('\n');
|
||||
};
|
||||
|
||||
document.getElementById('btn-generate').addEventListener('click', () => {
|
||||
const text = document.getElementById('ascii-input').value.trim();
|
||||
const fontType = document.getElementById('ascii-font').value;
|
||||
|
||||
if (!text) {
|
||||
this._notify('提示', '请输入要转换的文本', 'info');
|
||||
return;
|
||||
}
|
||||
|
||||
const ascii = generateSimpleASCII(text, fontType);
|
||||
const output = document.getElementById('ascii-output');
|
||||
output.textContent = ascii;
|
||||
output.style.display = 'block';
|
||||
document.getElementById('btn-copy-result').style.display = 'inline-flex';
|
||||
});
|
||||
|
||||
document.getElementById('btn-copy-result').addEventListener('click', () => {
|
||||
const text = document.getElementById('ascii-output').textContent;
|
||||
if (text) {
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
this._notify('已复制', 'ASCII 艺术字已复制到剪贴板', 'success');
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('ascii-input').addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
document.getElementById('btn-generate').click();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
// src/js/tools/baiduHotTool.js
|
||||
import BaseTool from '../baseTool.js';
|
||||
|
||||
class BaiduHotTool extends BaseTool {
|
||||
constructor() {
|
||||
super('baidu-hot', '百度热搜');
|
||||
this.abortController = null;
|
||||
// 定义分榜信息
|
||||
this.boards = [
|
||||
{ id: 'hot-search', name: '热搜榜' },
|
||||
{ id: 'hot-meme', name: '热梗榜' },
|
||||
{ id: 'finance', name: '财经榜' },
|
||||
{ id: 'livelihood', name: '民生榜' },
|
||||
];
|
||||
}
|
||||
|
||||
render() {
|
||||
return `
|
||||
<div class="page-container" style="display: flex; flex-direction: column; height: 100%;">
|
||||
<div class="section-header">
|
||||
<button id="back-to-toolbox-btn" class="back-btn ripple"><i class="fas fa-arrow-left"></i> 返回工具箱</button>
|
||||
<h1 style="flex-grow: 1; text-align: center;">${this.name}</h1>
|
||||
</div>
|
||||
<div class="sys-info-tabs baidu-hot-tabs" id="baidu-hot-tabs">
|
||||
${this.boards.map(board => `
|
||||
<button class="sys-info-tab" data-board-name="${board.name}">${board.name}</button>
|
||||
`).join('')}
|
||||
</div>
|
||||
<div id="baidu-hot-results-container" class="content-area" style="padding: 0 20px 10px 10px; flex-grow: 1; overflow-y: auto;">
|
||||
<div class="loading-container">
|
||||
<img src="./assets/loading.gif" alt="加载中..." class="loading-gif">
|
||||
<p class="loading-text">正在加载热搜...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
init() {
|
||||
this._log('工具已初始化');
|
||||
document.getElementById('back-to-toolbox-btn')?.addEventListener('click', () => {
|
||||
window.mainPage.navigateTo('toolbox');
|
||||
window.mainPage.updateActiveNavButton(document.getElementById('toolbox-btn'));
|
||||
});
|
||||
|
||||
const tabs = document.querySelectorAll('#baidu-hot-tabs .sys-info-tab');
|
||||
tabs.forEach(tab => {
|
||||
tab.addEventListener('click', (e) => {
|
||||
tabs.forEach(t => t.classList.remove('active'));
|
||||
e.currentTarget.classList.add('active');
|
||||
const boardName = e.currentTarget.dataset.boardName;
|
||||
this._fetchAndRenderData(boardName);
|
||||
});
|
||||
});
|
||||
|
||||
// 默认加载第一个榜单
|
||||
if (tabs.length > 0) {
|
||||
tabs[0].click();
|
||||
}
|
||||
}
|
||||
|
||||
async _fetchAndRenderData(boardName) {
|
||||
if (this.abortController) this.abortController.abort();
|
||||
this.abortController = new AbortController();
|
||||
|
||||
const resultsContainer = document.getElementById('baidu-hot-results-container');
|
||||
if (!resultsContainer) return;
|
||||
|
||||
resultsContainer.innerHTML = `
|
||||
<div class="loading-container">
|
||||
<img src="./assets/loading.gif" alt="加载中..." class="loading-gif">
|
||||
<p class="loading-text">正在加载 ${boardName}...</p>
|
||||
</div>`;
|
||||
|
||||
try {
|
||||
const apiUrl = `https://api.suyanw.cn/api/bdrs.php?msg=${encodeURIComponent(boardName)}`;
|
||||
const response = await fetch(apiUrl, { signal: this.abortController.signal });
|
||||
if (!response.ok) throw new Error(`网络请求失败: ${response.status}`);
|
||||
|
||||
const blob = await response.blob();
|
||||
await window.electronAPI.addTraffic(blob.size);
|
||||
const textData = await blob.text();
|
||||
|
||||
this._log(`成功获取 ${boardName} 数据`);
|
||||
this._renderList(textData);
|
||||
} catch (error) {
|
||||
if (error.name === 'AbortError') return;
|
||||
const errorMessage = error.message.includes('API') ? error.message : `解析数据失败或网络异常`;
|
||||
this._log(`获取 ${boardName} 失败: ${errorMessage}`);
|
||||
resultsContainer.innerHTML = `<div class="loading-container"><p class="error-message"><i class="fas fa-exclamation-triangle"></i> 获取失败: ${errorMessage}</p></div>`;
|
||||
}
|
||||
}
|
||||
|
||||
_renderList(textData) {
|
||||
const resultsContainer = document.getElementById('baidu-hot-results-container');
|
||||
const lines = textData.split('\n').filter(line => line.trim() !== '' && !line.startsWith('----'));
|
||||
|
||||
if (lines.length === 0) {
|
||||
throw new Error('API返回数据为空或格式无法解析');
|
||||
}
|
||||
|
||||
// [修复] 改为使用 Flex 布局的 div 结构 (灵动岛风格),而非之前的 table
|
||||
const itemsHtml = lines.map((line, index) => {
|
||||
const match = line.match(/^(\d+):(.*)/);
|
||||
if (!match) return '';
|
||||
|
||||
const rank = match[1];
|
||||
const title = match[2].trim();
|
||||
const searchUrl = `https://www.baidu.com/s?wd=${encodeURIComponent(title)}`;
|
||||
|
||||
let rankClass = '';
|
||||
let iconClass = 'fa-fire';
|
||||
let iconColor = 'var(--text-secondary)';
|
||||
|
||||
if (rank <= 3) {
|
||||
rankClass = `rank-top-3 rank-${rank}`; // 复用 hotboardTool 的样式
|
||||
iconClass = 'fa-fire-alt';
|
||||
iconColor = 'var(--error-color)';
|
||||
}
|
||||
|
||||
// 使用 island-card 样式 (在 style.css 中已定义)
|
||||
return `
|
||||
<div class="island-card ripple" style="height: auto; min-height: 60px; margin-bottom: 10px; padding: 15px;" data-link="${searchUrl}">
|
||||
<div class="island-icon-box" style="width: 40px; height: 40px; border-radius: 10px; background: rgba(var(--bg-color-rgb), 0.5);">
|
||||
<span class="hotboard-rank ${rankClass}" style="margin: 0; font-size: 16px;">${rank}</span>
|
||||
</div>
|
||||
|
||||
<div class="island-content" style="flex-grow: 1; padding-left: 15px;">
|
||||
<h3 class="island-title" style="margin: 0; white-space: normal;">${title}</h3>
|
||||
</div>
|
||||
|
||||
<div class="island-action">
|
||||
<i class="fas fa-chevron-right arrow-icon"></i>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
resultsContainer.innerHTML = `<div style="animation: contentFadeIn 0.5s;">${itemsHtml}</div>`;
|
||||
|
||||
resultsContainer.querySelectorAll('.island-card[data-link]').forEach(card => {
|
||||
card.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
window.electronAPI.openExternalLink(e.currentTarget.dataset.link);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (this.abortController) this.abortController.abort();
|
||||
this._log('工具已销毁');
|
||||
super.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
export default BaiduHotTool;
|
||||
@@ -0,0 +1,264 @@
|
||||
// src/js/tools/base64Tool.js
|
||||
import BaseTool from '../baseTool.js';
|
||||
|
||||
class Base64Tool extends BaseTool {
|
||||
constructor() {
|
||||
super('base64-converter', 'Base64 转换');
|
||||
this.dom = {};
|
||||
this.currentImageType = null; // 用于保存解码出的图片类型
|
||||
}
|
||||
|
||||
render() {
|
||||
// [删除] 已移除 .section-header.tool-window-header
|
||||
return `
|
||||
<div class="page-container base64-tool-container" style="display: flex; flex-direction: column; height: 100%;">
|
||||
|
||||
<div class="content-area" style="padding: 20px; flex-grow: 1; display: flex; flex-direction: column; gap: 20px; overflow-y: auto;">
|
||||
|
||||
<div class="base64-main-area settings-section">
|
||||
<h2><i class="fas fa-edit"></i> 文本 / Base64 输入/输出</h2>
|
||||
<div class="base64-textarea-wrapper">
|
||||
<textarea id="b64-main-textarea" placeholder="在此输入文本或粘贴 Base64 字符串..."></textarea>
|
||||
<div class="textarea-actions">
|
||||
<button id="b64-paste-btn" class="control-btn mini-btn ripple" title="粘贴"><i class="fas fa-paste"></i></button>
|
||||
<button id="b64-copy-btn" class="control-btn mini-btn ripple" title="复制"><i class="fas fa-copy"></i></button>
|
||||
<button id="b64-clear-btn" class="control-btn mini-btn ripple error-btn" title="清空"><i class="fas fa-times"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="base64-actions">
|
||||
<button id="b64-encode-btn" class="action-btn ripple"><i class="fas fa-arrow-down"></i> 编码为 Base64</button>
|
||||
<button id="b64-decode-btn" class="action-btn ripple"><i class="fas fa-arrow-up"></i> 从 Base64 解码</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="base64-image-area settings-section">
|
||||
<h2><i class="fas fa-image"></i> 图片处理</h2>
|
||||
<div class="image-controls">
|
||||
<label class="action-btn ripple" for="b64-image-input" style="cursor: pointer;">
|
||||
<i class="fas fa-upload"></i> 选择图片转 Base64
|
||||
</label>
|
||||
<input type="file" id="b64-image-input" accept="image/png, image/jpeg, image/webp, image/gif" style="display: none;">
|
||||
</div>
|
||||
<div id="b64-image-preview-area" class="image-preview-area" style="display: none;">
|
||||
<h3>解码预览:</h3>
|
||||
<div id="b64-image-output" class="image-output"></div>
|
||||
<button id="b64-download-image-btn" class="control-btn ripple"><i class="fas fa-download"></i> 下载图片</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
init() {
|
||||
this._log('工具已初始化');
|
||||
|
||||
// 绑定返回按钮
|
||||
// const backBtn = document.getElementById('back-to-toolbox-btn');
|
||||
// if (backBtn) {
|
||||
// backBtn.addEventListener('click', () => {
|
||||
// window.electronAPI.closeCurrentWindow();
|
||||
// });
|
||||
// }
|
||||
|
||||
// 缓存 DOM 元素
|
||||
this.dom.mainTextarea = document.getElementById('b64-main-textarea');
|
||||
this.dom.pasteBtn = document.getElementById('b64-paste-btn');
|
||||
this.dom.copyBtn = document.getElementById('b64-copy-btn');
|
||||
this.dom.clearBtn = document.getElementById('b64-clear-btn');
|
||||
this.dom.encodeBtn = document.getElementById('b64-encode-btn');
|
||||
this.dom.decodeBtn = document.getElementById('b64-decode-btn');
|
||||
this.dom.imageInput = document.getElementById('b64-image-input');
|
||||
this.dom.imagePreviewArea = document.getElementById('b64-image-preview-area');
|
||||
this.dom.imageOutput = document.getElementById('b64-image-output');
|
||||
this.dom.downloadImageBtn = document.getElementById('b64-download-image-btn');
|
||||
|
||||
// 绑定事件
|
||||
this.dom.pasteBtn.addEventListener('click', this._handlePaste.bind(this));
|
||||
this.dom.copyBtn.addEventListener('click', this._handleCopy.bind(this));
|
||||
this.dom.clearBtn.addEventListener('click', this._handleClear.bind(this));
|
||||
this.dom.encodeBtn.addEventListener('click', this._handleTextEncode.bind(this));
|
||||
this.dom.decodeBtn.addEventListener('click', this._handleBase64Decode.bind(this));
|
||||
this.dom.imageInput.addEventListener('change', this._handleImageUpload.bind(this));
|
||||
this.dom.downloadImageBtn.addEventListener('click', this._handleDownloadImage.bind(this));
|
||||
}
|
||||
|
||||
async _handlePaste() {
|
||||
try {
|
||||
const text = await navigator.clipboard.readText();
|
||||
this.dom.mainTextarea.value = text;
|
||||
this._log('内容已粘贴');
|
||||
} catch (err) {
|
||||
this._notify('错误', '无法读取剪贴板内容', 'error');
|
||||
this._log('粘贴失败: ' + err.message);
|
||||
}
|
||||
}
|
||||
|
||||
_handleCopy() {
|
||||
const text = this.dom.mainTextarea.value;
|
||||
if (!text) {
|
||||
this._notify('提示', '没有内容可复制', 'info');
|
||||
return;
|
||||
}
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
this._notify('成功', '已复制到剪贴板', 'success');
|
||||
this._log('内容已复制');
|
||||
}).catch(err => {
|
||||
this._notify('错误', '复制失败', 'error');
|
||||
this._log('复制失败: ' + err.message);
|
||||
});
|
||||
}
|
||||
|
||||
_handleClear() {
|
||||
this.dom.mainTextarea.value = '';
|
||||
this.dom.imageInput.value = ''; // 清空文件选择
|
||||
this.dom.imagePreviewArea.style.display = 'none';
|
||||
this.dom.imageOutput.innerHTML = '';
|
||||
this.currentImageType = null;
|
||||
this._log('输入/输出区域已清空');
|
||||
}
|
||||
|
||||
_handleTextEncode() {
|
||||
const text = this.dom.mainTextarea.value;
|
||||
if (!text) {
|
||||
this._notify('提示', '输入文本不能为空', 'info');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const encoded = btoa(unescape(encodeURIComponent(text)));
|
||||
this.dom.mainTextarea.value = encoded;
|
||||
this._log('文本编码为 Base64 成功');
|
||||
// 清除可能存在的图片预览
|
||||
this.dom.imagePreviewArea.style.display = 'none';
|
||||
this.dom.imageOutput.innerHTML = '';
|
||||
this.currentImageType = null;
|
||||
} catch (e) {
|
||||
this._notify('错误', '文本编码失败: ' + e.message, 'error');
|
||||
this._log('文本编码失败: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
_handleBase64Decode() {
|
||||
const data = this.dom.mainTextarea.value.trim();
|
||||
if (!data) {
|
||||
this._notify('提示', '输入 Base64 不能为空', 'info');
|
||||
return;
|
||||
}
|
||||
|
||||
// 清空上次结果
|
||||
this.dom.mainTextarea.value = '';
|
||||
this.dom.imagePreviewArea.style.display = 'none';
|
||||
this.dom.imageOutput.innerHTML = '';
|
||||
this.currentImageType = null;
|
||||
|
||||
if (data.startsWith('data:image/')) {
|
||||
// 识别为图片 Data URL
|
||||
try {
|
||||
const img = new Image();
|
||||
img.style.maxWidth = '100%';
|
||||
img.style.maxHeight = '300px'; // 限制预览高度
|
||||
img.style.objectFit = 'contain';
|
||||
img.src = data;
|
||||
img.onload = () => {
|
||||
this.dom.imageOutput.appendChild(img);
|
||||
this.dom.imagePreviewArea.style.display = 'block';
|
||||
// 从 Data URL 中提取图片类型
|
||||
const match = data.match(/^data:image\/(.+);base64,/);
|
||||
this.currentImageType = match ? match[1] : 'png'; // 默认为 png
|
||||
this._log('Base64 解码为图片成功');
|
||||
};
|
||||
img.onerror = () => {
|
||||
throw new Error('Base64 数据无效或图片已损坏');
|
||||
};
|
||||
} catch (e) {
|
||||
this.dom.mainTextarea.value = `图片解码失败: ${e.message}`; // 将错误信息显示在文本区
|
||||
this._notify('错误', '图片解码失败', 'error');
|
||||
this._log('Base64 解码为图片失败: ' + e.message);
|
||||
}
|
||||
} else {
|
||||
// 尝试解码为文本
|
||||
try {
|
||||
const decoded = decodeURIComponent(escape(atob(data)));
|
||||
this.dom.mainTextarea.value = decoded; // 将解码结果放回文本区
|
||||
this._log('Base64 解码为文本成功');
|
||||
} catch (e) {
|
||||
this.dom.mainTextarea.value = `解码失败: ${e.message}`; // 将错误信息显示在文本区
|
||||
this._notify('错误', '解码失败,不是有效的图片或文本 Base64', 'error');
|
||||
this._log('Base64 解码失败: ' + e.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_handleImageUpload(event) {
|
||||
const file = event.target.files[0];
|
||||
if (!file) return;
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
this.dom.mainTextarea.value = e.target.result; // 将图片 Base64 输出到主文本区
|
||||
this._log(`图片 [${file.name}] 编码为 Base64 成功`);
|
||||
this._notify('成功', `图片 ${file.name} 已转换为 Base64`, 'success');
|
||||
// 清除可能存在的图片预览
|
||||
this.dom.imagePreviewArea.style.display = 'none';
|
||||
this.dom.imageOutput.innerHTML = '';
|
||||
this.currentImageType = null;
|
||||
};
|
||||
reader.onerror = (e) => {
|
||||
this._notify('错误', '读取图片文件失败: ' + e.message, 'error');
|
||||
this._log('读取图片文件失败: ' + e.message);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
|
||||
_handleDownloadImage() {
|
||||
const imgElement = this.dom.imageOutput.querySelector('img');
|
||||
if (!imgElement || !imgElement.src.startsWith('data:image/')) {
|
||||
this._notify('提示', '没有可下载的解码图片', 'info');
|
||||
return;
|
||||
}
|
||||
|
||||
const base64Data = imgElement.src.split(',')[1];
|
||||
if (!base64Data) {
|
||||
this._notify('错误', '无法提取图片数据', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 将 Base64 转换为 Blob
|
||||
const byteString = atob(base64Data);
|
||||
const mimeString = imgElement.src.split(',')[0].split(':')[1].split(';')[0];
|
||||
const ab = new ArrayBuffer(byteString.length);
|
||||
const ia = new Uint8Array(ab);
|
||||
for (let i = 0; i < byteString.length; i++) {
|
||||
ia[i] = byteString.charCodeAt(i);
|
||||
}
|
||||
const blob = new Blob([ab], { type: mimeString });
|
||||
|
||||
// 使用 Electron API 保存 Blob
|
||||
blob.arrayBuffer().then(buffer => {
|
||||
const extension = this.currentImageType || 'png';
|
||||
const defaultPath = `decoded_image_${Date.now()}.${extension}`;
|
||||
window.electronAPI.saveMedia({ buffer: buffer, defaultPath }).then(result => {
|
||||
if (result.success) {
|
||||
this._notify('下载成功', '图片已保存');
|
||||
this._log('解码后的图片已下载');
|
||||
} else if (result.error !== '用户取消保存') {
|
||||
this._notify('下载失败', result.error, 'error');
|
||||
this._log('图片下载失败: ' + result.error);
|
||||
}
|
||||
});
|
||||
});
|
||||
} catch (e) {
|
||||
this._notify('错误', '准备下载时出错: ' + e.message, 'error');
|
||||
this._log('图片下载准备失败: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this._log('工具已销毁');
|
||||
// 如果添加了其他需要清理的资源,在这里处理
|
||||
super.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
export default Base64Tool;
|
||||
@@ -0,0 +1,156 @@
|
||||
// src/js/tools/biliHotTool.js
|
||||
import BaseTool from '../baseTool.js';
|
||||
|
||||
class BiliHotTool extends BaseTool {
|
||||
constructor() {
|
||||
super('bili-hot-ranking', 'B站热搜排行榜');
|
||||
this.abortController = null;
|
||||
}
|
||||
|
||||
render() {
|
||||
return `
|
||||
<div class="page-container" style="display: flex; flex-direction: column; height: 100%;">
|
||||
<div class="section-header">
|
||||
<button id="back-to-toolbox-btn" class="back-btn ripple"><i class="fas fa-arrow-left"></i> 返回工具箱</button>
|
||||
<h1 style="flex-grow: 1; text-align: center;">${this.name}</h1>
|
||||
<button id="refresh-bili-hot" class="control-btn ripple"><i class="fas fa-sync-alt"></i> 刷新</button>
|
||||
</div>
|
||||
<div id="bili-hot-results-container" class="content-area" style="padding: 0 20px 10px 10px; flex-grow: 1; overflow-y: auto;">
|
||||
<div class="loading-container">
|
||||
<img src="./assets/loading.gif" alt="加载中..." class="loading-gif">
|
||||
<p class="loading-text">正在获取B站热搜...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
init() {
|
||||
this._log('工具已初始化');
|
||||
document.getElementById('back-to-toolbox-btn')?.addEventListener('click', () => {
|
||||
window.mainPage.navigateTo('toolbox');
|
||||
window.mainPage.updateActiveNavButton(document.getElementById('toolbox-btn'));
|
||||
});
|
||||
document.getElementById('refresh-bili-hot')?.addEventListener('click', () => this._fetchAndRenderData());
|
||||
|
||||
this._fetchAndRenderData();
|
||||
}
|
||||
|
||||
async _fetchAndRenderData() {
|
||||
if (this.abortController) this.abortController.abort();
|
||||
this.abortController = new AbortController();
|
||||
|
||||
const resultsContainer = document.getElementById('bili-hot-results-container');
|
||||
if (!resultsContainer) return;
|
||||
|
||||
resultsContainer.innerHTML = `
|
||||
<div class="loading-container">
|
||||
<img src="./assets/loading.gif" alt="加载中..." class="loading-gif">
|
||||
<p class="loading-text">正在获取B站热搜...</p>
|
||||
</div>`;
|
||||
|
||||
try {
|
||||
const response = await fetch('https://api.suyanw.cn/api/bl.php?hh=%0A', { signal: this.abortController.signal });
|
||||
if (!response.ok) throw new Error(`网络请求失败: ${response.status}`);
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const chunks = [];
|
||||
let receivedLength = 0;
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
chunks.push(value);
|
||||
receivedLength += value.length;
|
||||
window.electronAPI.reportTraffic(value.length);
|
||||
}
|
||||
|
||||
await window.electronAPI.addTraffic(receivedLength);
|
||||
|
||||
const blob = new Blob(chunks);
|
||||
const data = await blob.text();
|
||||
|
||||
this._log('成功获取B站热搜数据');
|
||||
this._renderTable(data);
|
||||
} catch (error) {
|
||||
if (error.name === 'AbortError') return;
|
||||
this._log(`获取B站热搜失败: ${error.message}`);
|
||||
resultsContainer.innerHTML = `<div class="loading-container"><p class="error-message"><i class="fas fa-exclamation-triangle"></i> 获取失败: ${error.message}</p></div>`;
|
||||
}
|
||||
}
|
||||
|
||||
_renderTable(data) {
|
||||
const resultsContainer = document.getElementById('bili-hot-results-container');
|
||||
const items = data.split('\n').filter(Boolean);
|
||||
|
||||
if (items.length === 0) {
|
||||
resultsContainer.innerHTML = `<div class="loading-container"><p>未能解析到任何热搜条目。</p></div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
const tableRows = items.map(item => {
|
||||
const parts = item.split('±img=');
|
||||
const titlePart = parts[0];
|
||||
// 图片暂不显示,保持布局整洁
|
||||
// const imageUrl = parts[1] ? parts[1].trim() : null;
|
||||
|
||||
const rankMatch = titlePart.match(/^(\d+):/);
|
||||
const rank = rankMatch ? rankMatch[1] : '';
|
||||
const title = rankMatch ? titlePart.substring(rankMatch[0].length).trim() : titlePart.trim();
|
||||
const searchUrl = `https://search.bilibili.com/all?keyword=${encodeURIComponent(title)}`;
|
||||
|
||||
let rankClass = '';
|
||||
if (rank <= 3) {
|
||||
rankClass = `rank-top-3 rank-${rank}`;
|
||||
}
|
||||
|
||||
// [UI 修复] 使用 hotboard-rank 和 hotboard-title-link 统一类名
|
||||
// [逻辑修复] 将 data-link 放在 tr 上
|
||||
return `
|
||||
<tr data-link="${searchUrl}">
|
||||
<td style="width: 50px; text-align: center;">
|
||||
<span class="hotboard-rank ${rankClass}">${rank}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="hotboard-title-link">${title}</span>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
resultsContainer.innerHTML = `
|
||||
<table class="bili-hot-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>排名</th>
|
||||
<th>标题</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${tableRows}
|
||||
</tbody>
|
||||
</table>`;
|
||||
|
||||
// [核心修复] 绑定行点击事件 + URL 安全校验
|
||||
resultsContainer.querySelectorAll('tr[data-link]').forEach(row => {
|
||||
row.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
const url = row.dataset.link;
|
||||
|
||||
// 只有合法的 HTTP/HTTPS 链接才打开
|
||||
if (url && (url.startsWith('http://') || url.startsWith('https://'))) {
|
||||
window.electronAPI.openExternalLink(url);
|
||||
} else {
|
||||
this._notify('无法打开', '该条目没有有效的跳转链接', 'info');
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (this.abortController) this.abortController.abort();
|
||||
this._log('工具已销毁');
|
||||
super.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
export default BiliHotTool;
|
||||
@@ -0,0 +1,215 @@
|
||||
// src/js/tools/bmiTool.js
|
||||
import BaseTool from '../baseTool.js';
|
||||
import i18n from '../i18n.js';
|
||||
|
||||
class BMITool extends BaseTool {
|
||||
constructor() {
|
||||
super('bmi-calculator', 'BMI 计算器');
|
||||
this.dom = {};
|
||||
this.currentUnit = 'metric';
|
||||
}
|
||||
|
||||
render() {
|
||||
return `
|
||||
<div class="page-container bmi-tool-container" style="display: flex; flex-direction: column; height: 100%;">
|
||||
<div class="content-area" style="padding: 20px; flex-grow: 1; display: flex; flex-direction: column; gap: 20px; overflow-y: auto;">
|
||||
|
||||
<div class="settings-section">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; position: relative;">
|
||||
<button id="back-to-toolbox-btn" class="back-btn ripple" style="position: absolute; left: 0; z-index: 10;">
|
||||
<i class="fas fa-arrow-left"></i> ${i18n.t('common.backToToolbox', '返回工具箱')}
|
||||
</button>
|
||||
<div style="flex: 1; text-align: center;">
|
||||
<h2><i class="fas fa-ruler"></i> ${i18n.t('tool.bmi.title', 'BMI 计算器')}</h2>
|
||||
<p style="color: var(--text-secondary); margin-top: 8px;">${i18n.t('tool.bmi.description', '计算身体质量指数,评估健康状况')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="unit-switch" style="display: flex; justify-content: center; gap: 8px; margin-bottom: 30px;">
|
||||
<button class="unit-btn ripple active" data-unit="metric">
|
||||
${i18n.t('tool.bmi.metric', '公制 (cm/kg)')}
|
||||
</button>
|
||||
<button class="unit-btn ripple" data-unit="imperial">
|
||||
${i18n.t('tool.bmi.imperial', '英制 (ft/lbs)')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="input-section" style="display: grid; grid-template-columns: 1fr 1fr; gap: 20px; margin-bottom: 30px;">
|
||||
<div class="input-group">
|
||||
<label class="input-label">${i18n.t('tool.bmi.height', '身高')}</label>
|
||||
<div class="input-field" style="display: flex; align-items: center; gap: 12px;">
|
||||
<input type="number" id="bmi-height" placeholder="${i18n.t('tool.bmi.heightPlaceholder', '请输入身高')}" step="any" style="flex: 1; padding: 16px; border: 1px solid var(--border-color); border-radius: 8px; font-size: 18px; font-weight: 600; background: var(--input-bg); color: var(--text-primary);">
|
||||
<span class="input-unit" id="bmi-height-unit" style="padding: 16px; background: var(--input-bg); border: 1px solid var(--border-color); border-radius: 8px; font-size: 16px; font-weight: 600; color: var(--text-secondary);">cm</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="input-group">
|
||||
<label class="input-label">${i18n.t('tool.bmi.weight', '体重')}</label>
|
||||
<div class="input-field" style="display: flex; align-items: center; gap: 12px;">
|
||||
<input type="number" id="bmi-weight" placeholder="${i18n.t('tool.bmi.weightPlaceholder', '请输入体重')}" step="any" style="flex: 1; padding: 16px; border: 1px solid var(--border-color); border-radius: 8px; font-size: 18px; font-weight: 600; background: var(--input-bg); color: var(--text-primary);">
|
||||
<span class="input-unit" id="bmi-weight-unit" style="padding: 16px; background: var(--input-bg); border: 1px solid var(--border-color); border-radius: 8px; font-size: 16px; font-weight: 600; color: var(--text-secondary);">kg</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="calculate-section" style="display: flex; justify-content: center; margin-bottom: 30px;">
|
||||
<button id="bmi-calculate-btn" class="action-btn ripple">
|
||||
<i class="fas fa-calculator"></i> ${i18n.t('tool.bmi.calculate', '计算BMI')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-section">
|
||||
<h2><i class="fas fa-chart-line"></i> ${i18n.t('tool.bmi.result', '计算结果')}</h2>
|
||||
<div class="result-card" style="padding: 30px; background: var(--card-bg); border: 1px solid var(--border-color); border-radius: 12px; text-align: center; margin-bottom: 20px;">
|
||||
<div class="result-title" style="font-size: 16px; color: var(--text-secondary); margin-bottom: 12px;">${i18n.t('tool.bmi.yourBmi', '您的BMI指数')}</div>
|
||||
<div class="bmi-value" id="bmi-value" style="font-size: 64px; font-weight: 700; color: var(--text-primary); margin-bottom: 16px;">0.0</div>
|
||||
<div class="bmi-status" id="bmi-status" style="font-size: 24px; font-weight: 600; margin-bottom: 16px;">${i18n.t('tool.bmi.enterData', '请输入身高和体重')}</div>
|
||||
<div class="bmi-detail" id="bmi-detail" style="font-size: 14px; color: var(--text-secondary);">BMI = ${i18n.t('tool.bmi.formula', '体重(kg) / 身高(m)²')}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-section">
|
||||
<h2><i class="fas fa-info-circle"></i> ${i18n.t('tool.bmi.ranges', 'BMI范围说明')}</h2>
|
||||
<div class="range-list" style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 16px;">
|
||||
<div class="range-item" style="padding: 16px; background: var(--card-bg); border: 1px solid var(--border-color); border-radius: 8px;">
|
||||
<div class="range-name" style="font-size: 14px; font-weight: 600; color: var(--text-primary); margin-bottom: 8px;">${i18n.t('tool.bmi.underweight', '偏瘦')}</div>
|
||||
<div class="range-value" style="font-size: 12px; color: var(--text-secondary); margin-bottom: 4px;">BMI < 18.5</div>
|
||||
<div class="range-desc" style="font-size: 12px; color: var(--text-tertiary);">${i18n.t('tool.bmi.underweightDesc', '体重不足,建议适当增重')}</div>
|
||||
</div>
|
||||
<div class="range-item" style="padding: 16px; background: var(--card-bg); border: 1px solid var(--border-color); border-radius: 8px;">
|
||||
<div class="range-name" style="font-size: 14px; font-weight: 600; color: var(--text-primary); margin-bottom: 8px;">${i18n.t('tool.bmi.normal', '正常')}</div>
|
||||
<div class="range-value" style="font-size: 12px; color: var(--text-secondary); margin-bottom: 4px;">18.5 ≤ BMI < 24</div>
|
||||
<div class="range-desc" style="font-size: 12px; color: var(--text-tertiary);">${i18n.t('tool.bmi.normalDesc', '体重正常,继续保持')}</div>
|
||||
</div>
|
||||
<div class="range-item" style="padding: 16px; background: var(--card-bg); border: 1px solid var(--border-color); border-radius: 8px;">
|
||||
<div class="range-name" style="font-size: 14px; font-weight: 600; color: var(--text-primary); margin-bottom: 8px;">${i18n.t('tool.bmi.overweight', '超重')}</div>
|
||||
<div class="range-value" style="font-size: 12px; color: var(--text-secondary); margin-bottom: 4px;">24 ≤ BMI < 28</div>
|
||||
<div class="range-desc" style="font-size: 12px; color: var(--text-tertiary);">${i18n.t('tool.bmi.overweightDesc', '体重超重,建议适当减重')}</div>
|
||||
</div>
|
||||
<div class="range-item" style="padding: 16px; background: var(--card-bg); border: 1px solid var(--border-color); border-radius: 8px;">
|
||||
<div class="range-name" style="font-size: 14px; font-weight: 600; color: var(--text-primary); margin-bottom: 8px;">${i18n.t('tool.bmi.obese', '肥胖')}</div>
|
||||
<div class="range-value" style="font-size: 12px; color: var(--text-secondary); margin-bottom: 4px;">BMI ≥ 28</div>
|
||||
<div class="range-desc" style="font-size: 12px; color: var(--text-tertiary);">${i18n.t('tool.bmi.obeseDesc', '肥胖,建议减重并咨询医生')}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
init() {
|
||||
this._log('BMI工具初始化');
|
||||
|
||||
this.dom = {
|
||||
unitBtns: document.querySelectorAll('.unit-btn'),
|
||||
heightInput: document.getElementById('bmi-height'),
|
||||
weightInput: document.getElementById('bmi-weight'),
|
||||
heightUnit: document.getElementById('bmi-height-unit'),
|
||||
weightUnit: document.getElementById('bmi-weight-unit'),
|
||||
calculateBtn: document.getElementById('bmi-calculate-btn'),
|
||||
bmiValue: document.getElementById('bmi-value'),
|
||||
bmiStatus: document.getElementById('bmi-status'),
|
||||
bmiDetail: document.getElementById('bmi-detail')
|
||||
};
|
||||
|
||||
// 单位切换
|
||||
this.dom.unitBtns.forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
this.switchUnit(btn.dataset.unit);
|
||||
});
|
||||
});
|
||||
|
||||
// 计算按钮
|
||||
this.dom.calculateBtn.addEventListener('click', () => {
|
||||
this.calculate();
|
||||
});
|
||||
|
||||
// 实时计算
|
||||
this.dom.heightInput.addEventListener('input', () => {
|
||||
this.calculate();
|
||||
});
|
||||
|
||||
this.dom.weightInput.addEventListener('input', () => {
|
||||
this.calculate();
|
||||
});
|
||||
}
|
||||
|
||||
switchUnit(unit) {
|
||||
this.currentUnit = unit;
|
||||
|
||||
this.dom.unitBtns.forEach(btn => {
|
||||
btn.classList.remove('active');
|
||||
});
|
||||
document.querySelector(`[data-unit="${unit}"]`).classList.add('active');
|
||||
|
||||
if (unit === 'metric') {
|
||||
this.dom.heightUnit.textContent = 'cm';
|
||||
this.dom.weightUnit.textContent = 'kg';
|
||||
this.dom.bmiDetail.textContent = `BMI = ${i18n.t('tool.bmi.formula', '体重(kg) / 身高(m)²')}`;
|
||||
} else {
|
||||
this.dom.heightUnit.textContent = 'ft';
|
||||
this.dom.weightUnit.textContent = 'lbs';
|
||||
this.dom.bmiDetail.textContent = `BMI = ${i18n.t('tool.bmi.formulaImperial', '体重(lbs) / 身高(in)² × 703')}`;
|
||||
}
|
||||
|
||||
this.calculate();
|
||||
}
|
||||
|
||||
calculate() {
|
||||
const height = parseFloat(this.dom.heightInput.value);
|
||||
const weight = parseFloat(this.dom.weightInput.value);
|
||||
|
||||
if (isNaN(height) || isNaN(weight) || height <= 0 || weight <= 0) {
|
||||
this.dom.bmiValue.textContent = '0.0';
|
||||
this.dom.bmiStatus.textContent = i18n.t('tool.bmi.enterValidData', '请输入有效的身高和体重');
|
||||
this.dom.bmiStatus.className = 'bmi-status';
|
||||
return;
|
||||
}
|
||||
|
||||
let bmi;
|
||||
|
||||
if (this.currentUnit === 'metric') {
|
||||
const heightM = height / 100;
|
||||
bmi = weight / (heightM * heightM);
|
||||
} else {
|
||||
const heightIn = height * 12;
|
||||
bmi = (weight / (heightIn * heightIn)) * 703;
|
||||
}
|
||||
|
||||
bmi = parseFloat(bmi.toFixed(1));
|
||||
|
||||
this.dom.bmiValue.textContent = bmi;
|
||||
|
||||
const status = this.getBMIStatus(bmi);
|
||||
this.dom.bmiStatus.textContent = status.name;
|
||||
this.dom.bmiStatus.className = `bmi-status ${status.class}`;
|
||||
}
|
||||
|
||||
getBMIStatus(bmi) {
|
||||
if (bmi < 18.5) {
|
||||
return {
|
||||
name: i18n.t('tool.bmi.underweight', '偏瘦'),
|
||||
class: 'underweight'
|
||||
};
|
||||
} else if (bmi < 24) {
|
||||
return {
|
||||
name: i18n.t('tool.bmi.normal', '正常'),
|
||||
class: 'normal'
|
||||
};
|
||||
} else if (bmi < 28) {
|
||||
return {
|
||||
name: i18n.t('tool.bmi.overweight', '超重'),
|
||||
class: 'overweight'
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
name: i18n.t('tool.bmi.obese', '肥胖'),
|
||||
class: 'obese'
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default BMITool;
|
||||
@@ -0,0 +1,170 @@
|
||||
// src/js/tools/calculatorTool.js
|
||||
import BaseTool from '../baseTool.js';
|
||||
|
||||
export default class CalculatorTool extends BaseTool {
|
||||
constructor() {
|
||||
super('calculator-tool', '计算器');
|
||||
this.expression = '';
|
||||
}
|
||||
|
||||
render() {
|
||||
return `
|
||||
<div class="page-container" style="display: flex; flex-direction: column; height: 100%;">
|
||||
<div class="section-header" style="flex-shrink: 0; margin-bottom: 20px;">
|
||||
<button id="back-to-toolbox-btn" class="back-btn ripple"><i class="fas fa-arrow-left"></i> 返回工具箱</button>
|
||||
<h1 style="flex-grow: 1; text-align: center;">${this.name}</h1>
|
||||
</div>
|
||||
|
||||
<div class="tool-island-container" style="justify-content: center; align-items: center; flex-grow: 1;">
|
||||
<div class="tool-island-card calc-body">
|
||||
<div class="calc-screen">
|
||||
<div id="calc-history" class="calc-history"></div>
|
||||
<div id="calc-display" class="calc-current">0</div>
|
||||
</div>
|
||||
<div class="calc-keypad">
|
||||
<button class="calc-btn fn" data-action="clear">AC</button>
|
||||
<button class="calc-btn fn" data-action="del"><i class="fas fa-backspace"></i></button>
|
||||
<button class="calc-btn fn" data-action="percent">%</button>
|
||||
<button class="calc-btn op" data-action="operator" data-val="/">÷</button>
|
||||
|
||||
<button class="calc-btn num" data-val="7">7</button>
|
||||
<button class="calc-btn num" data-val="8">8</button>
|
||||
<button class="calc-btn num" data-val="9">9</button>
|
||||
<button class="calc-btn op" data-action="operator" data-val="*">×</button>
|
||||
|
||||
<button class="calc-btn num" data-val="4">4</button>
|
||||
<button class="calc-btn num" data-val="5">5</button>
|
||||
<button class="calc-btn num" data-val="6">6</button>
|
||||
<button class="calc-btn op" data-action="operator" data-val="-">-</button>
|
||||
|
||||
<button class="calc-btn num" data-val="1">1</button>
|
||||
<button class="calc-btn num" data-val="2">2</button>
|
||||
<button class="calc-btn num" data-val="3">3</button>
|
||||
<button class="calc-btn op" data-action="operator" data-val="+">+</button>
|
||||
|
||||
<button class="calc-btn num zero" data-val="0">0</button>
|
||||
<button class="calc-btn num" data-val=".">.</button>
|
||||
<button class="calc-btn eq" data-action="eval">=</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<style>
|
||||
.calc-body { width: 320px; padding: 25px; background: rgba(var(--bg-color-rgb), 0.8); }
|
||||
.calc-screen { text-align: right; margin-bottom: 20px; padding: 10px; border-radius: 12px; background: rgba(0,0,0,0.05); min-height: 80px; display: flex; flex-direction: column; justify-content: flex-end; }
|
||||
.calc-history { font-size: 14px; color: var(--text-secondary); min-height: 20px; }
|
||||
.calc-current { font-size: 36px; font-weight: 600; font-family: monospace; overflow-x: auto; white-space: nowrap; }
|
||||
.calc-keypad { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; }
|
||||
.calc-btn {
|
||||
height: 55px; border-radius: 50%; border: none; font-size: 20px; cursor: pointer;
|
||||
background: rgba(var(--card-background-rgb), 0.8); box-shadow: 0 4px 10px rgba(0,0,0,0.1);
|
||||
color: var(--text-color); transition: all 0.1s;
|
||||
}
|
||||
.calc-btn:active { transform: scale(0.95); }
|
||||
.calc-btn.op { background: var(--accent-color); color: #fff; }
|
||||
.calc-btn.fn { color: var(--accent-color); font-weight: bold; }
|
||||
.calc-btn.eq { background: var(--primary-color); color: #fff; }
|
||||
.calc-btn.zero { grid-column: span 2; border-radius: 30px; }
|
||||
</style>
|
||||
`;
|
||||
}
|
||||
|
||||
init() {
|
||||
// 绑定返回按钮
|
||||
document.getElementById('back-to-toolbox-btn')?.addEventListener('click', () => {
|
||||
window.mainPage.navigateTo('toolbox');
|
||||
window.mainPage.updateActiveNavButton(document.getElementById('toolbox-btn'));
|
||||
});
|
||||
|
||||
const display = document.getElementById('calc-display');
|
||||
const history = document.getElementById('calc-history');
|
||||
let current = '0';
|
||||
let shouldReset = false;
|
||||
|
||||
document.querySelectorAll('.calc-btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const action = btn.dataset.action;
|
||||
const val = btn.dataset.val;
|
||||
|
||||
if (val !== undefined && action !== 'operator') {
|
||||
if (current === '0' || shouldReset) {
|
||||
current = val;
|
||||
shouldReset = false;
|
||||
} else {
|
||||
current += val;
|
||||
}
|
||||
} else if (action === 'operator') {
|
||||
current += val;
|
||||
shouldReset = false;
|
||||
} else if (action === 'clear') {
|
||||
current = '0';
|
||||
history.innerText = '';
|
||||
} else if (action === 'del') {
|
||||
current = current.slice(0, -1) || '0';
|
||||
} else if (action === 'percent') {
|
||||
try {
|
||||
current = String(parseFloat(current) / 100);
|
||||
} catch(e) {}
|
||||
} else if (action === 'eval') {
|
||||
try {
|
||||
history.innerText = current + ' =';
|
||||
// 使用安全计算方法替代 Function/eval
|
||||
const res = this.safeCalculate(current);
|
||||
current = String(Number(res.toFixed(10)));
|
||||
shouldReset = true;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
current = 'Error';
|
||||
shouldReset = true;
|
||||
}
|
||||
}
|
||||
display.innerText = current;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 一个极其简易且安全的数学表达式解析器
|
||||
safeCalculate(expression) {
|
||||
// 移除非法字符,只允许数字、运算符和小数点
|
||||
const sanitized = expression.replace(/[^0-9+\-*/.]/g, '');
|
||||
// 防止空输入
|
||||
if(!sanitized) return 0;
|
||||
|
||||
// 使用 new Function 的替代方案:手写解析
|
||||
// 为保持简洁,这里支持基础混合运算。如果需要括号支持,逻辑会更复杂。
|
||||
// 考虑到用户工具箱的轻量级需求,我们把字符串按操作符拆分并计算
|
||||
|
||||
// 更好的方案:如果环境允许,使用 Function 是最简单的,但如果 CSP 禁止,
|
||||
// 则通过以下方式规避(此方法不支持括号优先级,仅顺序执行,建议仅用于简单计算)
|
||||
// 若要完美支持,建议引入 math.js。这里为了不引入库,尝试一次简单的 eval 封装:
|
||||
|
||||
// 注意:在严格 CSP 下,即便是 Function() 也会报错。
|
||||
// 最后的手段:简单的两步计算法(先乘除后加减)
|
||||
|
||||
try {
|
||||
// 拆分数字和运算符
|
||||
let nums = sanitized.split(/[+\-*/]/).map(Number);
|
||||
let ops = sanitized.replace(/[0-9.]/g, '').split('');
|
||||
|
||||
// 先处理乘除
|
||||
for(let i=0; i<ops.length; i++) {
|
||||
if(ops[i] === '*' || ops[i] === '/') {
|
||||
let res = ops[i] === '*' ? nums[i] * nums[i+1] : nums[i] / nums[i+1];
|
||||
nums.splice(i, 2, res);
|
||||
ops.splice(i, 1);
|
||||
i--;
|
||||
}
|
||||
}
|
||||
|
||||
// 再处理加减
|
||||
let result = nums[0];
|
||||
for(let i=0; i<ops.length; i++) {
|
||||
if(ops[i] === '+') result += nums[i+1];
|
||||
else if(ops[i] === '-') result -= nums[i+1];
|
||||
}
|
||||
return result;
|
||||
} catch(e) {
|
||||
throw new Error('Calc Error');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
// src/js/tools/carInfoTool.js
|
||||
import BaseTool from '../baseTool.js';
|
||||
import i18n from '../i18n.js';
|
||||
|
||||
class CarInfoTool extends BaseTool {
|
||||
constructor() {
|
||||
super('car-info', '车辆信息查询');
|
||||
this.dom = {};
|
||||
this.apiUrl = 'https://api.jkyai.top/API/clxxcx.php';
|
||||
}
|
||||
|
||||
render() {
|
||||
return `
|
||||
<div class="page-container car-info-tool-container" style="display: flex; flex-direction: column; height: 100%;">
|
||||
<div class="content-area" style="padding: 20px; flex-grow: 1; display: flex; flex-direction: column; gap: 20px; overflow-y: auto;">
|
||||
|
||||
<div class="settings-section" style="position: relative;">
|
||||
<button id="back-to-toolbox-btn" class="back-btn ripple" style="position: absolute; top: 0; left: 0; z-index: 10;">
|
||||
<i class="fas fa-arrow-left"></i> ${i18n.t('common.backToToolbox', '返回工具箱')}
|
||||
</button>
|
||||
<div style="text-align: center; margin-bottom: 20px;">
|
||||
<h2><i class="fas fa-car"></i> ${i18n.t('tool.carInfo.title', '车辆信息查询')}</h2>
|
||||
<p style="color: var(--text-secondary); margin-top: 8px;">${i18n.t('tool.carInfo.description', '查询车辆品牌、系列、价格等详细信息')}</p>
|
||||
</div>
|
||||
|
||||
<div class="query-section" style="background: rgba(var(--card-background-rgb), 0.5); padding: 24px; border-radius: 12px; margin-bottom: 24px;">
|
||||
<form id="car-info-query-form">
|
||||
<div class="form-row" style="display: flex; gap: 16px; align-items: flex-end; flex-wrap: wrap;">
|
||||
<div class="form-group" style="flex: 1; min-width: 300px;">
|
||||
<label for="car-info-msg" class="form-label" style="display: block; font-size: 14px; font-weight: 600; color: var(--text-primary); margin-bottom: 8px;">${i18n.t('tool.carInfo.vehicleName', '车辆名称/品牌')}</label>
|
||||
<input type="text" id="car-info-msg" name="msg" class="form-input" placeholder="${i18n.t('tool.carInfo.vehicleNamePlaceholder', '请输入车辆名称或品牌,例如:问界、比亚迪')}" required style="width: 100%; padding: 12px 16px; border: 1px solid var(--border-color); border-radius: 8px; font-size: 16px; font-weight: 500; box-sizing: border-box; height: 48px; background: var(--input-bg); color: var(--text-primary);">
|
||||
</div>
|
||||
<button type="submit" class="action-btn ripple" id="car-info-query-btn" style="min-width: 120px; height: 48px;">
|
||||
<i class="fas fa-search"></i> ${i18n.t('tool.carInfo.query', '查询车辆信息')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="error-container" id="car-info-error-container" style="display: none; background: rgba(220, 53, 69, 0.1); border: 1px solid rgba(220, 53, 69, 0.3); border-radius: 8px; padding: 20px; color: #dc3545; margin-bottom: 30px;">
|
||||
<div class="error-message" id="car-info-error-message"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-section" id="car-info-result-section" style="display: none;">
|
||||
<h2><i class="fas fa-list"></i> ${i18n.t('tool.carInfo.results', '查询结果')}</h2>
|
||||
<div class="car-grid" id="car-info-car-grid" style="display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 20px; margin-bottom: 30px;"></div>
|
||||
</div>
|
||||
|
||||
<div class="loading-container" id="car-info-loading-container" style="display: none; text-align: center; padding: 60px 0; color: var(--text-secondary);">
|
||||
<div class="loading-spinner" style="display: inline-block; width: 40px; height: 40px; border: 3px solid rgba(var(--primary-rgb), 0.1); border-radius: 50%; border-top-color: var(--primary-color); animation: spin 1s ease-in-out infinite; margin-bottom: 16px;"></div>
|
||||
<p>${i18n.t('tool.carInfo.loading', '正在查询车辆信息,请稍候...')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
init() {
|
||||
this._log('车辆信息工具初始化');
|
||||
|
||||
this.dom = {
|
||||
queryForm: document.getElementById('car-info-query-form'),
|
||||
msgInput: document.getElementById('car-info-msg'),
|
||||
queryBtn: document.getElementById('car-info-query-btn'),
|
||||
resultSection: document.getElementById('car-info-result-section'),
|
||||
carGrid: document.getElementById('car-info-car-grid'),
|
||||
loadingContainer: document.getElementById('car-info-loading-container'),
|
||||
errorContainer: document.getElementById('car-info-error-container'),
|
||||
errorMessage: document.getElementById('car-info-error-message')
|
||||
};
|
||||
|
||||
this.dom.queryForm.addEventListener('submit', (e) => {
|
||||
e.preventDefault();
|
||||
this.queryCarInfo();
|
||||
});
|
||||
|
||||
// 返回工具箱按钮
|
||||
document.getElementById('back-to-toolbox-btn')?.addEventListener('click', () => {
|
||||
window.mainPage.navigateTo('toolbox');
|
||||
window.mainPage.updateActiveNavButton(document.getElementById('toolbox-btn'));
|
||||
});
|
||||
}
|
||||
|
||||
async queryCarInfo() {
|
||||
const msg = this.dom.msgInput.value.trim();
|
||||
|
||||
if (!msg) {
|
||||
this.showError(i18n.t('tool.carInfo.enterVehicleName', '请输入车辆名称或品牌'));
|
||||
return;
|
||||
}
|
||||
|
||||
this.showLoading();
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
params.append('msg', msg);
|
||||
params.append('type', 'json');
|
||||
|
||||
const requestUrl = `${this.apiUrl}?${params.toString()}`;
|
||||
|
||||
const response = await fetch(requestUrl, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP Error! Status code: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const responseTime = Date.now() - startTime;
|
||||
|
||||
if ((data.code === 200 || data.code === 201) && data.data) {
|
||||
this.showResults(data.data);
|
||||
this._log(`成功查询车辆信息,耗时 ${responseTime}ms`);
|
||||
} else {
|
||||
this.showError(data.message || i18n.t('tool.carInfo.queryFailed', '查询失败'));
|
||||
this._log(`查询车辆信息失败: ${data.message || '未知错误'}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Query failed:', error);
|
||||
this.showError(i18n.t('tool.carInfo.queryFailed', '查询失败') + ': ' + error.message);
|
||||
this._log(`查询车辆信息失败: ${error.message}`);
|
||||
} finally {
|
||||
this.hideLoading();
|
||||
}
|
||||
}
|
||||
|
||||
showResults(carList) {
|
||||
this.dom.carGrid.innerHTML = '';
|
||||
|
||||
if (carList.length > 0) {
|
||||
carList.forEach(car => {
|
||||
const card = this.createCarCard(car);
|
||||
this.dom.carGrid.appendChild(card);
|
||||
});
|
||||
} else {
|
||||
this.dom.carGrid.innerHTML = `<div style="grid-column: 1 / -1; text-align: center; padding: 40px; color: var(--text-secondary);">${i18n.t('tool.carInfo.noResults', '未找到车辆信息')}</div>`;
|
||||
}
|
||||
|
||||
this.dom.resultSection.style.display = 'block';
|
||||
}
|
||||
|
||||
createCarCard(car) {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'car-card';
|
||||
card.style.cssText = 'background: var(--card-bg); border: 1px solid var(--border-color); border-radius: 8px; overflow: hidden; transition: all 0.3s ease;';
|
||||
|
||||
card.innerHTML = `
|
||||
<img src="${car.white_pic_url || 'https://via.placeholder.com/320x200'}" alt="${car.series_name || 'Vehicle Image'}" class="car-image" style="width: 100%; height: 200px; object-fit: cover; display: block;">
|
||||
<div class="car-info" style="padding: 16px;">
|
||||
<div class="car-brand" style="font-size: 12px; font-weight: 600; color: var(--text-secondary); margin-bottom: 4px;">${car.brand_name || i18n.t('tool.carInfo.unknownBrand', '未知品牌')}</div>
|
||||
<div class="car-name" style="font-size: 18px; font-weight: 700; color: var(--text-primary); margin-bottom: 8px;">${car.series_name || i18n.t('tool.carInfo.unknownModel', '未知型号')}</div>
|
||||
<div class="car-price" style="font-size: 16px; font-weight: 700; color: #d63031; margin-bottom: 8px;">${car.official_price || i18n.t('tool.carInfo.priceNegotiable', '价格面议')}</div>
|
||||
<div class="car-level" style="font-size: 14px; color: var(--text-secondary);">${car.level || i18n.t('tool.carInfo.unknownLevel', '未知级别')}</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
card.addEventListener('mouseenter', () => {
|
||||
card.style.transform = 'translateY(-2px)';
|
||||
card.style.boxShadow = '0 4px 12px rgba(0, 0, 0, 0.1)';
|
||||
card.style.borderColor = 'var(--primary-color)';
|
||||
});
|
||||
|
||||
card.addEventListener('mouseleave', () => {
|
||||
card.style.transform = 'translateY(0)';
|
||||
card.style.boxShadow = 'none';
|
||||
card.style.borderColor = 'var(--border-color)';
|
||||
});
|
||||
|
||||
return card;
|
||||
}
|
||||
|
||||
showLoading() {
|
||||
this.dom.queryBtn.disabled = true;
|
||||
this.dom.queryBtn.innerHTML = `<i class="fas fa-spinner fa-spin"></i> ${i18n.t('tool.carInfo.querying', '查询中...')}`;
|
||||
this.dom.loadingContainer.style.display = 'block';
|
||||
this.dom.resultSection.style.display = 'none';
|
||||
this.dom.errorContainer.style.display = 'none';
|
||||
}
|
||||
|
||||
hideLoading() {
|
||||
this.dom.queryBtn.disabled = false;
|
||||
this.dom.queryBtn.innerHTML = `<i class="fas fa-search"></i> ${i18n.t('tool.carInfo.query', '查询车辆信息')}`;
|
||||
this.dom.loadingContainer.style.display = 'none';
|
||||
}
|
||||
|
||||
showError(message) {
|
||||
this.dom.errorMessage.textContent = message;
|
||||
this.dom.errorContainer.style.display = 'block';
|
||||
this.dom.resultSection.style.display = 'none';
|
||||
this.dom.loadingContainer.style.display = 'none';
|
||||
this.dom.queryBtn.disabled = false;
|
||||
this.dom.queryBtn.innerHTML = `<i class="fas fa-search"></i> ${i18n.t('tool.carInfo.query', '查询车辆信息')}`;
|
||||
}
|
||||
}
|
||||
|
||||
export default CarInfoTool;
|
||||
@@ -0,0 +1,155 @@
|
||||
// src/js/tools/caseConverterTool.js
|
||||
import BaseTool from '../baseTool.js';
|
||||
import i18n from '../i18n.js';
|
||||
|
||||
export default class CaseConverterTool extends BaseTool {
|
||||
constructor() {
|
||||
super('case-converter', i18n.t('tool.caseConverter.name', null, '大小写转换'));
|
||||
}
|
||||
|
||||
render() {
|
||||
return `
|
||||
<div class="page-container" style="padding: 24px; display: flex; flex-direction: column; gap: 20px; height: 100%;">
|
||||
<div class="section-header">
|
||||
<button class="back-btn ripple" id="back-btn"><i class="fas fa-arrow-left"></i></button>
|
||||
<h1>${i18n.t('tool.caseConverter.name', null, '大小写转换')}</h1>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; gap: 20px; flex: 1; min-height: 0;">
|
||||
<div class="setting-island" style="flex: 1; display: flex; flex-direction: column; min-width: 0;">
|
||||
<h3 style="display: flex; align-items: center; gap: 10px; margin-bottom: 16px;">
|
||||
<i class="fas fa-keyboard" style="color: var(--primary-color);"></i>
|
||||
${i18n.t('tool.caseConverter.input', null, '输入文本')}
|
||||
</h3>
|
||||
<textarea id="input-text" class="common-textarea" placeholder="${i18n.t('tool.caseConverter.inputPlaceholder', null, '在此输入要转换的文本...')}" style="flex: 1; resize: none; font-family: 'Consolas', 'Monaco', monospace; font-size: 14px;"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="setting-island" style="flex: 1; display: flex; flex-direction: column; min-width: 0;">
|
||||
<h3 style="display: flex; align-items: center; gap: 10px; margin-bottom: 16px;">
|
||||
<i class="fas fa-check-circle" style="color: var(--success-color);"></i>
|
||||
${i18n.t('tool.caseConverter.output', null, '转换结果')}
|
||||
</h3>
|
||||
<textarea id="output-text" class="common-textarea" readonly style="flex: 1; resize: none; font-family: 'Consolas', 'Monaco', monospace; font-size: 14px;"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-island" style="flex-shrink: 0;">
|
||||
<h3 style="display: flex; align-items: center; gap: 10px; margin-bottom: 16px;">
|
||||
<i class="fas fa-magic" style="color: var(--primary-color);"></i>
|
||||
转换选项
|
||||
</h3>
|
||||
<div class="case-button-grid" style="display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 12px;">
|
||||
<button id="btn-upper" class="case-btn ripple" data-type="upper">
|
||||
<i class="fas fa-arrow-up"></i>
|
||||
<span>${i18n.t('tool.caseConverter.upperCase', null, '大写')}</span>
|
||||
</button>
|
||||
<button id="btn-lower" class="case-btn ripple" data-type="lower">
|
||||
<i class="fas fa-arrow-down"></i>
|
||||
<span>${i18n.t('tool.caseConverter.lowerCase', null, '小写')}</span>
|
||||
</button>
|
||||
<button id="btn-title" class="case-btn ripple" data-type="title">
|
||||
<i class="fas fa-text-height"></i>
|
||||
<span>${i18n.t('tool.caseConverter.titleCase', null, '标题')}</span>
|
||||
</button>
|
||||
<button id="btn-sentence" class="case-btn ripple" data-type="sentence">
|
||||
<i class="fas fa-paragraph"></i>
|
||||
<span>${i18n.t('tool.caseConverter.sentenceCase', null, '句子')}</span>
|
||||
</button>
|
||||
<button id="btn-camel" class="case-btn ripple" data-type="camel">
|
||||
<i class="fas fa-code"></i>
|
||||
<span>${i18n.t('tool.caseConverter.camelCase', null, '驼峰')}</span>
|
||||
</button>
|
||||
<button id="btn-pascal" class="case-btn ripple" data-type="pascal">
|
||||
<i class="fas fa-code"></i>
|
||||
<span>${i18n.t('tool.caseConverter.pascalCase', null, '帕斯卡')}</span>
|
||||
</button>
|
||||
<button id="btn-snake" class="case-btn ripple" data-type="snake">
|
||||
<i class="fas fa-minus"></i>
|
||||
<span>${i18n.t('tool.caseConverter.snakeCase', null, '蛇形')}</span>
|
||||
</button>
|
||||
<button id="btn-kebab" class="case-btn ripple" data-type="kebab">
|
||||
<i class="fas fa-minus"></i>
|
||||
<span>${i18n.t('tool.caseConverter.kebabCase', null, '短横线')}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="action-bar" style="display: flex; gap: 12px; justify-content: center; flex-shrink: 0;">
|
||||
<button id="btn-copy" class="action-btn ripple" style="padding: 12px 32px;">
|
||||
<i class="fas fa-copy"></i> ${i18n.t('common.copy', null, '复制')}
|
||||
</button>
|
||||
<button id="btn-clear" class="control-btn ripple" style="padding: 12px 32px;">
|
||||
<i class="fas fa-trash"></i> ${i18n.t('common.clear', null, '清空')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
init() {
|
||||
document.getElementById('back-btn').addEventListener('click', () => window.mainPage.navigateTo('toolbox'));
|
||||
|
||||
const inputText = document.getElementById('input-text');
|
||||
const outputText = document.getElementById('output-text');
|
||||
|
||||
const convert = (type) => {
|
||||
const text = inputText.value;
|
||||
if (!text) {
|
||||
this._notify(i18n.t('common.notification.title.info', null, '提示'), i18n.t('tool.caseConverter.emptyInput', null, '请输入要转换的文本'), 'info');
|
||||
return;
|
||||
}
|
||||
|
||||
let result = '';
|
||||
switch (type) {
|
||||
case 'upper':
|
||||
result = text.toUpperCase();
|
||||
break;
|
||||
case 'lower':
|
||||
result = text.toLowerCase();
|
||||
break;
|
||||
case 'title':
|
||||
result = text.replace(/\w\S*/g, (txt) => txt.charAt(0).toUpperCase() + txt.substr(1).toLowerCase());
|
||||
break;
|
||||
case 'sentence':
|
||||
result = text.toLowerCase().replace(/(^\w{1}|\.\s*\w{1})/gi, (txt) => txt.toUpperCase());
|
||||
break;
|
||||
case 'camel':
|
||||
result = text.replace(/(?:^\w|[A-Z]|\b\w)/g, (word, index) => index === 0 ? word.toLowerCase() : word.toUpperCase()).replace(/\s+/g, '');
|
||||
break;
|
||||
case 'pascal':
|
||||
result = text.replace(/(?:^\w|[A-Z]|\b\w)/g, (word) => word.toUpperCase()).replace(/\s+/g, '');
|
||||
break;
|
||||
case 'snake':
|
||||
result = text.replace(/\W+/g, ' ').split(/ |\B(?=[A-Z])/).map(word => word.toLowerCase()).join('_');
|
||||
break;
|
||||
case 'kebab':
|
||||
result = text.replace(/\W+/g, ' ').split(/ |\B(?=[A-Z])/).map(word => word.toLowerCase()).join('-');
|
||||
break;
|
||||
}
|
||||
outputText.value = result;
|
||||
this._log(`转换类型: ${type}`);
|
||||
};
|
||||
|
||||
document.getElementById('btn-upper').addEventListener('click', () => convert('upper'));
|
||||
document.getElementById('btn-lower').addEventListener('click', () => convert('lower'));
|
||||
document.getElementById('btn-title').addEventListener('click', () => convert('title'));
|
||||
document.getElementById('btn-sentence').addEventListener('click', () => convert('sentence'));
|
||||
document.getElementById('btn-camel').addEventListener('click', () => convert('camel'));
|
||||
document.getElementById('btn-pascal').addEventListener('click', () => convert('pascal'));
|
||||
document.getElementById('btn-snake').addEventListener('click', () => convert('snake'));
|
||||
document.getElementById('btn-kebab').addEventListener('click', () => convert('kebab'));
|
||||
|
||||
document.getElementById('btn-copy').addEventListener('click', () => {
|
||||
if (outputText.value) {
|
||||
navigator.clipboard.writeText(outputText.value).then(() => {
|
||||
this._notify(i18n.t('common.notification.title.success', null, '成功'), i18n.t('common.copied', null, '已复制到剪贴板'), 'success');
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('btn-clear').addEventListener('click', () => {
|
||||
inputText.value = '';
|
||||
outputText.value = '';
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
// src/js/tools/cctvNewsTool.js
|
||||
import BaseTool from '../baseTool.js';
|
||||
import i18n from '../i18n.js';
|
||||
|
||||
class CCTVNewsTool extends BaseTool {
|
||||
constructor() {
|
||||
super('cctv-news', '央视新闻热点');
|
||||
this.dom = {};
|
||||
this.apiUrl = 'https://api.jkyai.top/API/ysxwrd.php';
|
||||
}
|
||||
|
||||
render() {
|
||||
return `
|
||||
<div class="page-container cctv-news-tool-container" style="display: flex; flex-direction: column; height: 100%;">
|
||||
<div class="content-area" style="padding: 20px; flex-grow: 1; display: flex; flex-direction: column; gap: 20px; overflow-y: auto;">
|
||||
|
||||
<div class="settings-section" style="position: relative;">
|
||||
<button id="back-to-toolbox-btn" class="back-btn ripple" style="position: absolute; top: 0; left: 0; z-index: 10;">
|
||||
<i class="fas fa-arrow-left"></i> ${i18n.t('common.backToToolbox', '返回工具箱')}
|
||||
</button>
|
||||
<div style="text-align: center; margin-bottom: 20px;">
|
||||
<h2><i class="fas fa-newspaper"></i> ${i18n.t('tool.cctvNews.title', '央视新闻热点')}</h2>
|
||||
<p style="color: var(--text-secondary); margin-top: 8px;">${i18n.t('tool.cctvNews.description', '获取最新的央视新闻热点资讯')}</p>
|
||||
</div>
|
||||
|
||||
<div class="query-section" style="background: rgba(var(--card-background-rgb), 0.5); padding: 24px; border-radius: 12px; margin-bottom: 24px;">
|
||||
<form id="cctv-news-query-form" class="form-row" style="display: flex; gap: 16px; align-items: center; flex-wrap: wrap;">
|
||||
<div class="form-group" style="display: flex; align-items: center; gap: 8px;">
|
||||
<label for="cctv-news-count" class="form-label" style="font-size: 14px; font-weight: 600; color: var(--text-primary);">${i18n.t('tool.cctvNews.count', '新闻数量')}:</label>
|
||||
<input type="number" id="cctv-news-count" name="count" class="form-input" min="1" max="50" value="10" style="padding: 10px 16px; border: 1px solid var(--border-color); border-radius: 8px; font-size: 14px; font-weight: 500; width: 120px; background: var(--input-bg); color: var(--text-primary);">
|
||||
</div>
|
||||
<button type="submit" class="action-btn ripple" id="cctv-news-query-btn">
|
||||
<i class="fas fa-sync-alt"></i> ${i18n.t('tool.cctvNews.fetch', '获取新闻')}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="error-container" id="cctv-news-error-container" style="display: none; background: rgba(220, 53, 69, 0.1); border: 1px solid rgba(220, 53, 69, 0.3); border-radius: 8px; padding: 20px; color: #dc3545; margin-bottom: 30px;">
|
||||
<div class="error-message" id="cctv-news-error-message"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-section">
|
||||
<h2><i class="fas fa-list"></i> ${i18n.t('tool.cctvNews.newsList', '新闻列表')}</h2>
|
||||
<div class="news-grid" id="cctv-news-grid" style="display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 20px; margin-top: 20px;"></div>
|
||||
</div>
|
||||
|
||||
<div class="loading-container" id="cctv-news-loading-container" style="display: none; text-align: center; padding: 60px 0; color: var(--text-secondary);">
|
||||
<div class="loading-spinner" style="display: inline-block; width: 40px; height: 40px; border: 3px solid rgba(var(--primary-rgb), 0.1); border-radius: 50%; border-top-color: var(--primary-color); animation: spin 1s ease-in-out infinite; margin-bottom: 16px;"></div>
|
||||
<p>${i18n.t('tool.cctvNews.loading', '正在获取央视新闻热点,请稍候...')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
init() {
|
||||
this._log('央视新闻工具初始化');
|
||||
|
||||
this.dom = {
|
||||
queryForm: document.getElementById('cctv-news-query-form'),
|
||||
countInput: document.getElementById('cctv-news-count'),
|
||||
queryBtn: document.getElementById('cctv-news-query-btn'),
|
||||
newsGrid: document.getElementById('cctv-news-grid'),
|
||||
loadingContainer: document.getElementById('cctv-news-loading-container'),
|
||||
errorContainer: document.getElementById('cctv-news-error-container'),
|
||||
errorMessage: document.getElementById('cctv-news-error-message')
|
||||
};
|
||||
|
||||
this.dom.queryForm.addEventListener('submit', (e) => {
|
||||
e.preventDefault();
|
||||
this.fetchNews();
|
||||
});
|
||||
|
||||
// 返回工具箱按钮
|
||||
document.getElementById('back-to-toolbox-btn')?.addEventListener('click', () => {
|
||||
window.mainPage.navigateTo('toolbox');
|
||||
window.mainPage.updateActiveNavButton(document.getElementById('toolbox-btn'));
|
||||
});
|
||||
|
||||
// 自动加载新闻
|
||||
this.fetchNews();
|
||||
}
|
||||
|
||||
async fetchNews() {
|
||||
const count = this.dom.countInput.value || 10;
|
||||
|
||||
this.showLoading();
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
params.append('count', count);
|
||||
params.append('type', 'json');
|
||||
|
||||
const requestUrl = `${this.apiUrl}?${params.toString()}`;
|
||||
|
||||
const response = await fetch(requestUrl, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP Error! Status code: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const responseTime = Date.now() - startTime;
|
||||
|
||||
if (data.code === 200 && data.data) {
|
||||
this.showResults(data.data);
|
||||
this._log(`成功获取央视新闻,耗时 ${responseTime}ms`);
|
||||
} else {
|
||||
this.showError(data.message || i18n.t('tool.cctvNews.fetchFailed', '获取新闻失败'));
|
||||
this._log(`获取央视新闻失败: ${data.message || '未知错误'}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Fetch failed:', error);
|
||||
this.showError(i18n.t('tool.cctvNews.fetchFailed', '获取新闻失败') + ': ' + error.message);
|
||||
this._log(`获取央视新闻失败: ${error.message}`);
|
||||
} finally {
|
||||
this.hideLoading();
|
||||
}
|
||||
}
|
||||
|
||||
showResults(newsList) {
|
||||
this.dom.newsGrid.innerHTML = '';
|
||||
|
||||
if (Array.isArray(newsList) && newsList.length > 0) {
|
||||
newsList.forEach((news, index) => {
|
||||
const card = this.createNewsCard(news, index + 1);
|
||||
this.dom.newsGrid.appendChild(card);
|
||||
});
|
||||
} else {
|
||||
this.dom.newsGrid.innerHTML = `<div style="grid-column: 1 / -1; text-align: center; padding: 40px; color: var(--text-secondary);">${i18n.t('tool.cctvNews.noNews', '暂无新闻')}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
createNewsCard(news, index) {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'news-card';
|
||||
card.style.cssText = 'background: var(--card-bg); border: 1px solid var(--border-color); border-radius: 12px; padding: 20px; transition: all 0.3s ease;';
|
||||
|
||||
const title = news.title || news.news_title || i18n.t('tool.cctvNews.unknownTitle', '未知标题');
|
||||
const time = news.time || news.news_time || '';
|
||||
const url = news.url || news.news_url || '#';
|
||||
|
||||
card.innerHTML = `
|
||||
<div class="news-rank" style="display: inline-flex; align-items: center; justify-content: center; width: 28px; height: 28px; background: linear-gradient(135deg, var(--primary-color) 0%, rgba(var(--primary-rgb), 0.8) 100%); color: #fff; border-radius: 50%; font-size: 14px; font-weight: 700; margin-bottom: 12px;">${index}</div>
|
||||
<h3 class="news-title" style="font-size: 16px; font-weight: 600; color: var(--text-primary); margin-bottom: 12px; line-height: 1.5;">
|
||||
<a href="${url}" target="_blank" class="news-link" style="color: var(--text-primary); text-decoration: none; transition: color 0.2s;">${title}</a>
|
||||
</h3>
|
||||
${time ? `<div class="news-time" style="font-size: 12px; color: var(--text-secondary); display: flex; align-items: center; gap: 4px;">
|
||||
<i class="fas fa-clock"></i> ${time}
|
||||
</div>` : ''}
|
||||
`;
|
||||
|
||||
card.addEventListener('mouseenter', () => {
|
||||
card.style.transform = 'translateY(-2px)';
|
||||
card.style.boxShadow = '0 4px 12px rgba(0, 0, 0, 0.1)';
|
||||
card.style.borderColor = 'var(--primary-color)';
|
||||
});
|
||||
|
||||
card.addEventListener('mouseleave', () => {
|
||||
card.style.transform = 'translateY(0)';
|
||||
card.style.boxShadow = 'none';
|
||||
card.style.borderColor = 'var(--border-color)';
|
||||
});
|
||||
|
||||
return card;
|
||||
}
|
||||
|
||||
showLoading() {
|
||||
this.dom.queryBtn.disabled = true;
|
||||
this.dom.queryBtn.innerHTML = `<i class="fas fa-spinner fa-spin"></i> ${i18n.t('tool.cctvNews.fetching', '获取中...')}`;
|
||||
this.dom.loadingContainer.style.display = 'block';
|
||||
this.dom.newsGrid.innerHTML = '';
|
||||
this.dom.errorContainer.style.display = 'none';
|
||||
}
|
||||
|
||||
hideLoading() {
|
||||
this.dom.queryBtn.disabled = false;
|
||||
this.dom.queryBtn.innerHTML = `<i class="fas fa-sync-alt"></i> ${i18n.t('tool.cctvNews.fetch', '获取新闻')}`;
|
||||
this.dom.loadingContainer.style.display = 'none';
|
||||
}
|
||||
|
||||
showError(message) {
|
||||
this.dom.errorMessage.textContent = message;
|
||||
this.dom.errorContainer.style.display = 'block';
|
||||
this.dom.newsGrid.innerHTML = '';
|
||||
this.dom.loadingContainer.style.display = 'none';
|
||||
this.dom.queryBtn.disabled = false;
|
||||
this.dom.queryBtn.innerHTML = `<i class="fas fa-sync-alt"></i> ${i18n.t('tool.cctvNews.fetch', '获取新闻')}`;
|
||||
}
|
||||
}
|
||||
|
||||
export default CCTVNewsTool;
|
||||
@@ -0,0 +1,193 @@
|
||||
// src/js/tools/chineseConverterTool.js
|
||||
import BaseTool from '../baseTool.js';
|
||||
|
||||
class ChineseConverterTool extends BaseTool {
|
||||
constructor() {
|
||||
super('chinese-converter', '简繁转换');
|
||||
this.dom = {};
|
||||
this.abortController = null;
|
||||
}
|
||||
|
||||
render() {
|
||||
return `
|
||||
<div class="page-container chinese-converter-container" style="display: flex; flex-direction: column; height: 100%;">
|
||||
<div class="section-header tool-window-header">
|
||||
<button id="back-to-toolbox-btn" class="back-btn ripple"><i class="fas fa-arrow-left"></i> 返回</button>
|
||||
<h1 style="flex-grow: 1; text-align: center;">${this.name}</h1>
|
||||
<div style="width: 70px;"></div> </div>
|
||||
|
||||
<div class="content-area" style="padding: 20px; flex-grow: 1; display: flex; flex-direction: column; gap: 20px; overflow-y: auto;">
|
||||
<div class="settings-section" style="padding: 20px; flex: 1; display: flex; flex-direction: column;">
|
||||
<h2><i class="fas fa-language"></i> 输入与输出</h2>
|
||||
<p class="setting-item-description" style="margin-bottom: 15px;">输入需要转换的文本,选择转换类型,然后点击“转换”。</p>
|
||||
|
||||
<div class="converter-text-area">
|
||||
<textarea id="cc-input-textarea" placeholder="在此输入简体或繁体文本..."></textarea>
|
||||
<div class="textarea-actions">
|
||||
<button id="cc-paste-btn" class="control-btn mini-btn ripple" title="粘贴"><i class="fas fa-paste"></i></button>
|
||||
<button id="cc-clear-input-btn" class="control-btn mini-btn ripple error-btn" title="清空输入"><i class="fas fa-times"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="converter-controls">
|
||||
<div class="converter-type-selection">
|
||||
<label><input type="radio" name="cc-type" value="3" checked> 自动识别</label>
|
||||
<label><input type="radio" name="cc-type" value="2"> 简体 → 繁体</label>
|
||||
<label><input type="radio" name="cc-type" value="1"> 繁体 → 简体</label>
|
||||
</div>
|
||||
<button id="cc-convert-btn" class="action-btn ripple"><i class="fas fa-sync-alt"></i> 转换</button>
|
||||
</div>
|
||||
|
||||
<div class="converter-text-area">
|
||||
<textarea id="cc-output-textarea" placeholder="转换结果将显示在这里..." readonly></textarea>
|
||||
<div class="textarea-actions">
|
||||
<button id="cc-copy-btn" class="control-btn mini-btn ripple" title="复制结果"><i class="fas fa-copy"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
init() {
|
||||
this._log('工具已初始化');
|
||||
|
||||
const backBtn = document.getElementById('back-to-toolbox-btn');
|
||||
if (backBtn) {
|
||||
backBtn.addEventListener('click', () => window.electronAPI.closeCurrentWindow());
|
||||
}
|
||||
|
||||
// Cache DOM elements
|
||||
this.dom.inputTextArea = document.getElementById('cc-input-textarea');
|
||||
this.dom.outputTextArea = document.getElementById('cc-output-textarea');
|
||||
this.dom.pasteBtn = document.getElementById('cc-paste-btn');
|
||||
this.dom.clearInputBtn = document.getElementById('cc-clear-input-btn');
|
||||
this.dom.copyBtn = document.getElementById('cc-copy-btn');
|
||||
this.dom.convertBtn = document.getElementById('cc-convert-btn');
|
||||
this.dom.typeRadios = document.querySelectorAll('input[name="cc-type"]');
|
||||
|
||||
// Bind events
|
||||
this.dom.pasteBtn.addEventListener('click', this._handlePaste.bind(this));
|
||||
this.dom.clearInputBtn.addEventListener('click', this._handleClearInput.bind(this));
|
||||
this.dom.copyBtn.addEventListener('click', this._handleCopyOutput.bind(this));
|
||||
this.dom.convertBtn.addEventListener('click', this._handleConvert.bind(this));
|
||||
// Allow Enter key in input textarea to trigger conversion
|
||||
this.dom.inputTextArea.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) { // Enter triggers, Shift+Enter new line
|
||||
e.preventDefault();
|
||||
this._handleConvert();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async _handlePaste() {
|
||||
try {
|
||||
const text = await navigator.clipboard.readText();
|
||||
this.dom.inputTextArea.value = text;
|
||||
this._log('输入内容已粘贴');
|
||||
} catch (err) {
|
||||
this._notify('错误', '无法读取剪贴板内容', 'error');
|
||||
this._log('粘贴失败: ' + err.message);
|
||||
}
|
||||
}
|
||||
|
||||
_handleClearInput() {
|
||||
this.dom.inputTextArea.value = '';
|
||||
// Optionally clear output as well
|
||||
// this.dom.outputTextArea.value = '';
|
||||
this._log('输入内容已清空');
|
||||
}
|
||||
|
||||
_handleCopyOutput() {
|
||||
const text = this.dom.outputTextArea.value;
|
||||
if (!text) {
|
||||
this._notify('提示', '没有结果可复制', 'info');
|
||||
return;
|
||||
}
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
this._notify('成功', '转换结果已复制到剪贴板', 'success');
|
||||
this._log('结果已复制');
|
||||
}).catch(err => {
|
||||
this._notify('错误', '复制失败', 'error');
|
||||
this._log('复制失败: ' + err.message);
|
||||
});
|
||||
}
|
||||
|
||||
async _handleConvert() {
|
||||
const textToConvert = this.dom.inputTextArea.value.trim();
|
||||
if (!textToConvert) {
|
||||
this._notify('提示', '请输入需要转换的内容', 'info');
|
||||
return;
|
||||
}
|
||||
|
||||
let selectedType = '3'; // Default to auto
|
||||
this.dom.typeRadios.forEach(radio => {
|
||||
if (radio.checked) {
|
||||
selectedType = radio.value;
|
||||
}
|
||||
});
|
||||
|
||||
// Abort previous request if any
|
||||
if (this.abortController) {
|
||||
this.abortController.abort();
|
||||
}
|
||||
this.abortController = new AbortController();
|
||||
|
||||
const apiUrl = 'https://api.suyanw.cn/api/jfzh.php';
|
||||
const params = new URLSearchParams({
|
||||
text: textToConvert,
|
||||
type: selectedType,
|
||||
format: 'json'
|
||||
});
|
||||
|
||||
this.dom.convertBtn.disabled = true;
|
||||
this.dom.convertBtn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> 转换中...';
|
||||
this.dom.outputTextArea.value = ''; // Clear previous result
|
||||
|
||||
try {
|
||||
const response = await fetch(`${apiUrl}?${params.toString()}`, {
|
||||
signal: this.abortController.signal
|
||||
});
|
||||
|
||||
// Track traffic
|
||||
const blob = await response.blob();
|
||||
window.electronAPI.addTraffic(blob.size); // Count traffic
|
||||
const json = JSON.parse(await blob.text()); // Parse response
|
||||
|
||||
if (!response.ok || json.code !== 1) {
|
||||
throw new Error(json.text || `API 请求失败,状态码: ${response.status}`);
|
||||
}
|
||||
|
||||
if (json.data && json.data.after) {
|
||||
this.dom.outputTextArea.value = json.data.after;
|
||||
this._log(`文本转换成功: 类型 ${selectedType}`);
|
||||
} else {
|
||||
throw new Error('API 返回的数据格式无效');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
if (error.name === 'AbortError') {
|
||||
this._log('转换请求被中止');
|
||||
} else {
|
||||
this._notify('转换失败', error.message, 'error');
|
||||
this._log(`转换失败: ${error.message}`);
|
||||
this.dom.outputTextArea.value = `错误: ${error.message}`;
|
||||
}
|
||||
} finally {
|
||||
this.dom.convertBtn.disabled = false;
|
||||
this.dom.convertBtn.innerHTML = '<i class="fas fa-sync-alt"></i> 转换';
|
||||
this.abortController = null;
|
||||
}
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (this.abortController) {
|
||||
this.abortController.abort();
|
||||
}
|
||||
this._log('工具已销毁');
|
||||
super.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
export default ChineseConverterTool;
|
||||
@@ -0,0 +1,115 @@
|
||||
// src/js/tools/colorTool.js
|
||||
import BaseTool from '../baseTool.js';
|
||||
|
||||
export default class ColorTool extends BaseTool {
|
||||
constructor() {
|
||||
super('color-tool', '颜色转换');
|
||||
}
|
||||
|
||||
render() {
|
||||
return `
|
||||
<div class="page-container" style="display: flex; flex-direction: column; height: 100%;">
|
||||
<div class="section-header" style="flex-shrink: 0; margin-bottom: 20px;">
|
||||
<button id="back-to-toolbox-btn" class="back-btn ripple"><i class="fas fa-arrow-left"></i> 返回工具箱</button>
|
||||
<h1 style="flex-grow: 1; text-align: center;">${this.name}</h1>
|
||||
</div>
|
||||
|
||||
<div class="tool-island-container">
|
||||
<div style="display: flex; gap: 20px; height: 100%; flex-wrap: wrap;">
|
||||
<div class="tool-island-card" style="flex: 1; min-width: 250px; display: flex; flex-direction: column; justify-content: center; align-items: center; background: rgba(var(--card-background-rgb), 0.6);">
|
||||
<div id="color-preview-box" style="width: 150px; height: 150px; border-radius: 50%; box-shadow: 0 10px 30px rgba(0,0,0,0.2); background: #007aff; border: 4px solid #fff; transition: background 0.1s; cursor: pointer;"></div>
|
||||
<input type="color" id="color-picker" value="#007aff" style="margin-top: 20px; width: 0; height: 0; opacity: 0; position: absolute;">
|
||||
<p style="margin-top: 20px; opacity: 0.6;">点击上方圆圈选择颜色</p>
|
||||
</div>
|
||||
|
||||
<div class="tool-island-card" style="flex: 1.5; min-width: 300px; display: flex; flex-direction: column; justify-content: center; gap: 20px;">
|
||||
<div class="input-group">
|
||||
<label>HEX</label>
|
||||
<div style="display: flex; gap: 10px;">
|
||||
<span style="font-size: 20px; font-family: monospace; align-self: center; color: var(--text-secondary);">#</span>
|
||||
<input type="text" id="hex-input" class="common-input" value="007aff" maxlength="6" style="letter-spacing: 2px; text-transform: uppercase;">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="input-group">
|
||||
<label>RGB</label>
|
||||
<div style="display: flex; gap: 10px;">
|
||||
<input type="number" id="r-input" class="common-input" placeholder="R" max="255">
|
||||
<input type="number" id="g-input" class="common-input" placeholder="G" max="255">
|
||||
<input type="number" id="b-input" class="common-input" placeholder="B" max="255">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="background: rgba(var(--bg-color-rgb), 0.5); padding: 15px; border-radius: 12px; font-size: 13px; color: var(--text-secondary);">
|
||||
<i class="fas fa-info-circle"></i> 支持双向同步:调整颜色选择器、HEX 或 RGB 数值均可实时更新其他项。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<style>
|
||||
/* 确保输入框在深浅模式下清晰可见 */
|
||||
.common-input {
|
||||
background-color: rgba(var(--bg-color-rgb), 0.5);
|
||||
color: var(--text-color);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
</style>
|
||||
`;
|
||||
}
|
||||
|
||||
init() {
|
||||
// 绑定返回按钮
|
||||
document.getElementById('back-to-toolbox-btn')?.addEventListener('click', () => {
|
||||
window.mainPage.navigateTo('toolbox');
|
||||
window.mainPage.updateActiveNavButton(document.getElementById('toolbox-btn'));
|
||||
});
|
||||
|
||||
const picker = document.getElementById('color-picker');
|
||||
const preview = document.getElementById('color-preview-box');
|
||||
// 绑定点击预览触发取色器
|
||||
preview.addEventListener('click', () => picker.click());
|
||||
|
||||
const hexIn = document.getElementById('hex-input');
|
||||
const rIn = document.getElementById('r-input');
|
||||
const gIn = document.getElementById('g-input');
|
||||
const bIn = document.getElementById('b-input');
|
||||
|
||||
const updateUI = (r, g, b) => {
|
||||
const hex = ((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1).toUpperCase();
|
||||
preview.style.background = `rgb(${r},${g},${b})`;
|
||||
picker.value = `#${hex}`;
|
||||
hexIn.value = hex;
|
||||
rIn.value = r; gIn.value = g; bIn.value = b;
|
||||
};
|
||||
|
||||
picker.addEventListener('input', (e) => {
|
||||
const hex = e.target.value;
|
||||
const r = parseInt(hex.slice(1, 3), 16);
|
||||
const g = parseInt(hex.slice(3, 5), 16);
|
||||
const b = parseInt(hex.slice(5, 7), 16);
|
||||
updateUI(r, g, b);
|
||||
});
|
||||
|
||||
const handleRGB = () => {
|
||||
let r = parseInt(rIn.value) || 0;
|
||||
let g = parseInt(gIn.value) || 0;
|
||||
let b = parseInt(bIn.value) || 0;
|
||||
updateUI(Math.min(255, r), Math.min(255, g), Math.min(255, b));
|
||||
};
|
||||
[rIn, gIn, bIn].forEach(el => el.addEventListener('input', handleRGB));
|
||||
|
||||
hexIn.addEventListener('input', () => {
|
||||
let val = hexIn.value.replace(/[^0-9A-Fa-f]/g, '');
|
||||
if (val.length === 6) {
|
||||
const r = parseInt(val.slice(0, 2), 16);
|
||||
const g = parseInt(val.slice(2, 4), 16);
|
||||
const b = parseInt(val.slice(4, 6), 16);
|
||||
updateUI(r, g, b);
|
||||
}
|
||||
});
|
||||
|
||||
// Init
|
||||
updateUI(0, 122, 255);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
// src/js/tools/diffTool.js
|
||||
import BaseTool from '../baseTool.js';
|
||||
|
||||
export default class DiffTool extends BaseTool {
|
||||
constructor() {
|
||||
super('diff-tool', '文本对比');
|
||||
this.diffs = [];
|
||||
}
|
||||
|
||||
render() {
|
||||
return `
|
||||
<div class="tool-island-container" style="gap: 15px;">
|
||||
<div style="display: flex; gap: 15px; height: 40%;">
|
||||
<div class="tool-island-card" style="flex: 1; padding: 10px; display:flex; flex-direction:column;">
|
||||
<div class="tool-group-title">原始文本 (A)</div>
|
||||
<textarea id="diff-text-a" class="common-textarea" style="flex: 1; resize: none; border: none; background: transparent; padding: 5px;" placeholder="粘贴文本 A..."></textarea>
|
||||
</div>
|
||||
<div class="tool-island-card" style="flex: 1; padding: 10px; display:flex; flex-direction:column;">
|
||||
<div class="tool-group-title">修改文本 (B)</div>
|
||||
<textarea id="diff-text-b" class="common-textarea" style="flex: 1; resize: none; border: none; background: transparent; padding: 5px;" placeholder="粘贴文本 B..."></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="text-align: center;">
|
||||
<button id="btn-start-diff" class="action-btn ripple" style="width: 200px;"><i class="fas fa-exchange-alt"></i> 开始对比</button>
|
||||
</div>
|
||||
|
||||
<div class="tool-island-card" style="flex: 1; padding: 0; overflow: hidden; display: flex; flex-direction: column;">
|
||||
<div id="diff-viewer" style="flex: 1; overflow-y: auto; padding: 15px; font-family: monospace; font-size: 13px; line-height: 1.6; background: rgba(0,0,0,0.2);">
|
||||
<div style="text-align: center; color: var(--text-secondary); margin-top: 50px;">点击“开始对比”查看结果</div>
|
||||
</div>
|
||||
|
||||
<div id="diff-summary-panel" style="height: 120px; border-top: 1px solid var(--border-color); background: rgba(var(--card-background-rgb), 0.9); padding: 10px; overflow-y: auto; display: none;">
|
||||
<div class="tool-group-title" style="font-size: 12px;">差异概览 (点击跳转)</div>
|
||||
<div id="diff-summary-list" style="display: flex; flex-direction: column; gap: 5px;"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<style>
|
||||
.diff-line { display: flex; border-bottom: 1px solid rgba(255,255,255,0.05); }
|
||||
.diff-line-num { width: 40px; text-align: right; padding-right: 10px; color: var(--text-secondary); opacity: 0.5; user-select: none; }
|
||||
.diff-line-content { flex: 1; white-space: pre-wrap; word-break: break-all; padding-left: 10px; }
|
||||
|
||||
.diff-add { background: rgba(52, 199, 89, 0.2); } /* Green */
|
||||
.diff-del { background: rgba(255, 59, 48, 0.2); text-decoration: line-through; opacity: 0.7; } /* Red */
|
||||
|
||||
.summary-item {
|
||||
padding: 4px 10px; border-radius: 4px; font-size: 12px; cursor: pointer; display: flex; justify-content: space-between;
|
||||
background: rgba(255,255,255,0.05); transition: background 0.2s;
|
||||
}
|
||||
.summary-item:hover { background: rgba(255,255,255,0.1); }
|
||||
.summary-item.add { border-left: 3px solid var(--success-color); }
|
||||
.summary-item.del { border-left: 3px solid var(--error-color); }
|
||||
</style>
|
||||
`;
|
||||
}
|
||||
|
||||
init() {
|
||||
document.getElementById('btn-start-diff').addEventListener('click', () => {
|
||||
const textA = document.getElementById('diff-text-a').value;
|
||||
const textB = document.getElementById('diff-text-b').value;
|
||||
this.computeAndRender(textA, textB);
|
||||
});
|
||||
}
|
||||
|
||||
computeAndRender(textA, textB) {
|
||||
const linesA = textA.split('\n');
|
||||
const linesB = textB.split('\n');
|
||||
const viewer = document.getElementById('diff-viewer');
|
||||
const summaryPanel = document.getElementById('diff-summary-panel');
|
||||
const summaryList = document.getElementById('diff-summary-list');
|
||||
|
||||
// 简单的差异算法 (为了不引入外部庞大的库,这里使用简化的行匹配)
|
||||
// 实际生产环境建议通过 preload 引入 diff 库
|
||||
let html = '';
|
||||
let summaryHtml = '';
|
||||
let i = 0, j = 0;
|
||||
let diffCount = 0;
|
||||
|
||||
while (i < linesA.length || j < linesB.length) {
|
||||
const lineA = linesA[i];
|
||||
const lineB = linesB[j];
|
||||
|
||||
if (lineA === lineB) {
|
||||
html += this.createLineHtml(i + 1, lineA, 'normal');
|
||||
i++; j++;
|
||||
} else {
|
||||
// 尝试简单的向前查找匹配,判断是新增还是删除
|
||||
let foundMatch = false;
|
||||
// 向下看 3 行
|
||||
for (let k = 1; k <= 3; k++) {
|
||||
if (linesB[j] === linesA[i + k]) {
|
||||
// A 中多出的行 (删除)
|
||||
for (let m = 0; m < k; m++) {
|
||||
html += this.createLineHtml(i + 1 + m, linesA[i + m], 'del', `diff-idx-${diffCount}`);
|
||||
summaryHtml += this.createSummaryItem(diffCount, '删除', i + 1 + m, linesA[i + m], 'del');
|
||||
diffCount++;
|
||||
}
|
||||
i += k;
|
||||
foundMatch = true;
|
||||
break;
|
||||
}
|
||||
if (linesA[i] === linesB[j + k]) {
|
||||
// B 中多出的行 (新增)
|
||||
for (let m = 0; m < k; m++) {
|
||||
html += this.createLineHtml(j + 1 + m, linesB[j + m], 'add', `diff-idx-${diffCount}`);
|
||||
summaryHtml += this.createSummaryItem(diffCount, '新增', j + 1 + m, linesB[j + m], 'add');
|
||||
diffCount++;
|
||||
}
|
||||
j += k;
|
||||
foundMatch = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!foundMatch) {
|
||||
// 直接视为改变 (先删后增)
|
||||
if (i < linesA.length) {
|
||||
html += this.createLineHtml(i + 1, linesA[i], 'del', `diff-idx-${diffCount}`);
|
||||
summaryHtml += this.createSummaryItem(diffCount, '删除', i + 1, linesA[i], 'del');
|
||||
diffCount++;
|
||||
i++;
|
||||
}
|
||||
if (j < linesB.length) {
|
||||
html += this.createLineHtml(j + 1, linesB[j], 'add', `diff-idx-${diffCount}`);
|
||||
summaryHtml += this.createSummaryItem(diffCount, '新增', j + 1, linesB[j], 'add');
|
||||
diffCount++;
|
||||
j++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
viewer.innerHTML = html;
|
||||
if (diffCount > 0) {
|
||||
summaryPanel.style.display = 'block';
|
||||
summaryList.innerHTML = summaryHtml;
|
||||
|
||||
// 绑定跳转事件
|
||||
summaryList.querySelectorAll('.summary-item').forEach(item => {
|
||||
item.addEventListener('click', () => {
|
||||
const targetId = item.dataset.target;
|
||||
const el = document.getElementById(targetId);
|
||||
if (el) {
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
el.style.background = 'rgba(255, 255, 0, 0.2)'; // 高亮闪烁
|
||||
setTimeout(() => el.style.background = '', 500);
|
||||
}
|
||||
});
|
||||
});
|
||||
} else {
|
||||
summaryPanel.style.display = 'none';
|
||||
viewer.innerHTML += '<div style="padding:20px; text-align:center; color:var(--success-color);">两段文本完全一致</div>';
|
||||
}
|
||||
}
|
||||
|
||||
createLineHtml(num, content, type, id = '') {
|
||||
const className = type === 'normal' ? '' : `diff-${type}`;
|
||||
return `<div class="diff-line ${className}" ${id ? `id="${id}"` : ''}>
|
||||
<div class="diff-line-num">${num}</div>
|
||||
<div class="diff-line-content">${this.escapeHtml(content || '')}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
createSummaryItem(idx, typeName, lineNum, content, className) {
|
||||
return `<div class="summary-item ${className}" data-target="diff-idx-${idx}">
|
||||
<span>${typeName} (行 ${lineNum})</span>
|
||||
<span style="opacity:0.6; max-width: 200px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;">${this.escapeHtml(content).trim() || '空行'}</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
escapeHtml(text) {
|
||||
if (!text) return '';
|
||||
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
// src/js/tools/dnsQueryTool.js
|
||||
import BaseTool from '../baseTool.js';
|
||||
import uiManager from '../uiManager.js';
|
||||
|
||||
class DnsQueryTool extends BaseTool {
|
||||
constructor() {
|
||||
super('dns-query', 'DNS 解析查询');
|
||||
this.dom = {};
|
||||
this.abortController = null;
|
||||
this.recordTypes = ['A', 'AAAA', 'MX', 'CNAME', 'TXT', 'NS', 'SOA', 'CAA', 'SRV'];
|
||||
this.currentType = 'A'; // 默认值
|
||||
}
|
||||
|
||||
render() {
|
||||
// [修复] 只生成选项的容器 ID,具体 HTML 移动到 init 中处理
|
||||
return `
|
||||
<div class="page-container" style="display: flex; flex-direction: column; height: 100%;">
|
||||
<div class="content-area" style="padding: 20px; flex-grow: 1; display: flex; flex-direction: column; gap: 20px; overflow-y: auto;">
|
||||
<div class="settings-section" style="padding: 20px; display: flex; flex-direction: column; min-height: 0;">
|
||||
<h2><i class="fas fa-map-signs"></i> DNS 解析查询</h2>
|
||||
<p class="setting-item-description" style="margin-bottom: 20px;">查询指定域名的 DNS 记录。</p>
|
||||
|
||||
<div class="ip-input-group" style="margin-bottom: 20px; flex-shrink: 0;">
|
||||
<input type="text" id="dns-input" placeholder="输入域名 (例如: google.com)" style="flex-grow: 3;">
|
||||
|
||||
<div id="dns-select-wrapper" class="custom-select-wrapper" style="flex-grow: 1;">
|
||||
<div class="custom-select-trigger">
|
||||
<span class="custom-select-value">A 记录</span>
|
||||
<i class="fas fa-chevron-down custom-select-arrow"></i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button id="dns-query-btn" class="action-btn ripple" style="flex-grow: 1;"><i class="fas fa-search"></i> 查询</button>
|
||||
</div>
|
||||
|
||||
<div id="dns-results-container" style="flex-grow: 1; min-height: 150px; overflow-y: auto;">
|
||||
<p class="loading-text" style="text-align: center;">请输入域名并选择类型后点击查询</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="custom-select-options" id="dns-select-options">
|
||||
${this.recordTypes.map(t => `<div class="custom-select-option" data-value="${t}">${t} 记录</div>`).join('')}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
init() {
|
||||
this._log('工具已初始化');
|
||||
|
||||
this.dom.input = document.getElementById('dns-input');
|
||||
this.dom.queryBtn = document.getElementById('dns-query-btn');
|
||||
this.dom.resultsContainer = document.getElementById('dns-results-container');
|
||||
|
||||
this.dom.queryBtn.addEventListener('click', this._handleQuery.bind(this));
|
||||
this.dom.input.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') this._handleQuery();
|
||||
});
|
||||
|
||||
// [修复] 使用 uiManager 的通用方法绑定下拉菜单
|
||||
// 这会自动将 dns-select-options 移动到 body,并处理定位和层级
|
||||
uiManager.setupAdaptiveDropdown('dns-select-wrapper', 'dns-select-options', (value, text) => {
|
||||
this.currentType = value;
|
||||
this._log(`DNS 类型切换为: ${value}`);
|
||||
});
|
||||
}
|
||||
|
||||
async _handleQuery() {
|
||||
const domain = this.dom.input.value.trim();
|
||||
const type = this.currentType;
|
||||
|
||||
if (!domain) {
|
||||
this._notify('输入错误', '请输入要查询的域名', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.abortController) {
|
||||
this.abortController.abort();
|
||||
}
|
||||
this.abortController = new AbortController();
|
||||
|
||||
this.dom.queryBtn.disabled = true;
|
||||
this.dom.queryBtn.innerHTML = '<i class="fas fa-spinner fa-spin"></i>';
|
||||
this.dom.resultsContainer.innerHTML = `
|
||||
<div class="loading-container">
|
||||
<img src="./assets/loading.gif" alt="查询中..." class="loading-gif">
|
||||
<p class="loading-text">正在查询 ${domain} 的 ${type} 记录...</p>
|
||||
</div>`;
|
||||
|
||||
try {
|
||||
const apiUrl = `https://uapis.cn/api/v1/network/dns?domain=${encodeURIComponent(domain)}&type=${type}`;
|
||||
const response = await fetch(apiUrl, { signal: this.abortController.signal });
|
||||
|
||||
const blob = await response.blob();
|
||||
window.electronAPI.addTraffic(blob.size);
|
||||
const json = JSON.parse(await blob.text());
|
||||
|
||||
if (!response.ok || json.code !== 200) {
|
||||
throw new Error(json.message || `API 请求失败: ${response.status}`);
|
||||
}
|
||||
|
||||
if (json.error) {
|
||||
throw new Error(json.error);
|
||||
}
|
||||
|
||||
this._renderResults(json);
|
||||
this._log(`DNS 查询成功: ${domain} (${type})`);
|
||||
|
||||
} catch (error) {
|
||||
if (error.name === 'AbortError') {
|
||||
this._log('查询请求被中止');
|
||||
this.dom.resultsContainer.innerHTML = `<p class="loading-text" style="text-align: center;">查询已取消</p>`;
|
||||
} else {
|
||||
this._notify('查询失败', error.message, 'error');
|
||||
this._log(`查询失败: ${error.message}`);
|
||||
this.dom.resultsContainer.innerHTML = `<p class="error-message" style="text-align: center;"><i class="fas fa-exclamation-triangle"></i> ${error.message}</p>`;
|
||||
}
|
||||
} finally {
|
||||
this.dom.queryBtn.disabled = false;
|
||||
this.dom.queryBtn.innerHTML = '<i class="fas fa-search"></i> 查询';
|
||||
this.abortController = null;
|
||||
}
|
||||
}
|
||||
|
||||
_renderResults(data) {
|
||||
if (!data.records || data.records.length === 0) {
|
||||
this.dom.resultsContainer.innerHTML = `<p class="error-message" style="text-align: center;">未找到 ${data.domain} 的 ${data.type} 记录。</p>`;
|
||||
return;
|
||||
}
|
||||
|
||||
const html = `
|
||||
<table class="ip-comparison-table" style="animation: contentFadeIn 0.5s;">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>类型</th>
|
||||
<th>内容 / 目标</th>
|
||||
<th>TTL</th>
|
||||
<th>优先级</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${data.records.map(record => `
|
||||
<tr>
|
||||
<td><span class="hotboard-tag tag-new">${data.type}</span></td>
|
||||
<td style="word-break: break-all; font-family: monospace;">${record.content || record.target || 'N/A'}</td>
|
||||
<td>${record.ttl || 'N/A'}</td>
|
||||
<td>${record.pri || record.priority || '-'}</td>
|
||||
</tr>
|
||||
`).join('')}
|
||||
</tbody>
|
||||
</table>`;
|
||||
this.dom.resultsContainer.innerHTML = html;
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (this.abortController) {
|
||||
this.abortController.abort();
|
||||
}
|
||||
// 清理 body 中的下拉菜单
|
||||
document.getElementById('dns-select-options')?.remove();
|
||||
this._log('工具已销毁');
|
||||
super.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
export default DnsQueryTool;
|
||||
@@ -0,0 +1,224 @@
|
||||
// src/js/tools/domainPriceTool.js
|
||||
import BaseTool from '../baseTool.js';
|
||||
import i18n from '../i18n.js';
|
||||
|
||||
class DomainPriceTool extends BaseTool {
|
||||
constructor() {
|
||||
super('domain-price', '域名比价查询');
|
||||
this.dom = {};
|
||||
this.apiUrl = 'https://api.jkyai.top/API/ymbjcx.php';
|
||||
}
|
||||
|
||||
render() {
|
||||
return `
|
||||
<div class="page-container domain-price-tool-container" style="display: flex; flex-direction: column; height: 100%;">
|
||||
<div class="content-area" style="padding: 20px; flex-grow: 1; display: flex; flex-direction: column; gap: 20px; overflow-y: auto;">
|
||||
|
||||
<div class="settings-section" style="position: relative;">
|
||||
<button id="back-to-toolbox-btn" class="back-btn ripple" style="position: absolute; top: 0; left: 0; z-index: 10;">
|
||||
<i class="fas fa-arrow-left"></i> ${i18n.t('common.backToToolbox', '返回工具箱')}
|
||||
</button>
|
||||
<div style="text-align: center; margin-bottom: 20px;">
|
||||
<h2><i class="fas fa-globe"></i> ${i18n.t('tool.domainPrice.title', '域名比价查询')}</h2>
|
||||
<p style="color: var(--text-secondary); margin-top: 8px;">${i18n.t('tool.domainPrice.description', '查询域名后缀在各平台的注册、续费、转入价格排行')}</p>
|
||||
</div>
|
||||
|
||||
<div class="query-section" style="background: rgba(var(--card-background-rgb), 0.5); padding: 24px; border-radius: 12px; margin-bottom: 24px;">
|
||||
<form class="query-form" id="domain-price-query-form" style="display: flex; gap: 16px; flex-wrap: wrap; align-items: flex-end;">
|
||||
<div class="form-group" style="flex: 1; min-width: 200px;">
|
||||
<label class="form-label" for="domain-price-domain" style="display: block; font-size: 14px; font-weight: 500; color: var(--text-primary); margin-bottom: 8px;">${i18n.t('tool.domainPrice.domain', '域名后缀')}</label>
|
||||
<input type="text" id="domain-price-domain" class="form-input" placeholder="${i18n.t('tool.domainPrice.domainPlaceholder', '请输入域名后缀,如:cn、com、net')}" required style="width: 100%; padding: 12px 16px; border: 2px solid var(--border-color); border-radius: 8px; font-size: 16px; transition: all 0.3s ease; background: var(--input-bg); color: var(--text-primary);">
|
||||
</div>
|
||||
<div class="form-group" style="min-width: 150px;">
|
||||
<label class="form-label" for="domain-price-type" style="display: block; font-size: 14px; font-weight: 500; color: var(--text-primary); margin-bottom: 8px;">${i18n.t('tool.domainPrice.type', '查询类型')}</label>
|
||||
<select class="type-select" id="domain-price-type" style="width: 100%; padding: 12px 16px; border: 2px solid var(--border-color); border-radius: 8px; font-size: 16px; background: var(--input-bg); color: var(--text-primary); cursor: pointer;">
|
||||
<option value="new">${i18n.t('tool.domainPrice.new', '注册价格')}</option>
|
||||
<option value="renew">${i18n.t('tool.domainPrice.renew', '续费价格')}</option>
|
||||
<option value="transfer">${i18n.t('tool.domainPrice.transfer', '转入价格')}</option>
|
||||
</select>
|
||||
</div>
|
||||
<button type="submit" class="action-btn ripple" id="domain-price-query-btn" style="min-width: 120px; height: 48px;">
|
||||
<i class="fas fa-search"></i> ${i18n.t('tool.domainPrice.query', '查询价格')}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="error-message" id="domain-price-error-message" style="display: none; background: rgba(220, 53, 69, 0.1); border: 1px solid rgba(220, 53, 69, 0.3); border-radius: 8px; padding: 16px; color: #dc3545; margin-bottom: 20px;"></div>
|
||||
</div>
|
||||
|
||||
<div class="loading" id="domain-price-loading" style="display: none; text-align: center; padding: 40px; color: var(--text-secondary);">
|
||||
<div class="loading-spinner" style="border: 4px solid rgba(var(--primary-rgb), 0.1); border-top: 4px solid var(--primary-color); border-radius: 50%; width: 40px; height: 40px; animation: spin 1s linear infinite; margin: 0 auto 16px;"></div>
|
||||
<div>${i18n.t('tool.domainPrice.loading', '正在查询域名价格,请稍候...')}</div>
|
||||
</div>
|
||||
|
||||
<div class="empty-state" id="domain-price-empty-state" style="display: block; text-align: center; padding: 40px; color: var(--text-secondary);">
|
||||
<div style="font-size: 64px; margin-bottom: 16px;">🌐</div>
|
||||
<div>${i18n.t('tool.domainPrice.enterDomain', '请输入域名后缀并选择查询类型开始查询')}</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-section" id="domain-price-result-section" style="display: none;">
|
||||
<div class="results-header" style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; flex-wrap: wrap; gap: 16px;">
|
||||
<h2><i class="fas fa-list"></i> ${i18n.t('tool.domainPrice.results', '比价结果')}</h2>
|
||||
<div class="result-summary" id="domain-price-result-summary" style="font-size: 14px; color: var(--text-secondary);"></div>
|
||||
</div>
|
||||
<div style="overflow-x: auto;">
|
||||
<table class="price-table" id="domain-price-table" style="width: 100%; border-collapse: collapse; background: var(--card-bg); border-radius: 8px; overflow: hidden; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);">
|
||||
<thead>
|
||||
<tr style="background: rgba(var(--primary-rgb), 0.1);">
|
||||
<th style="padding: 12px 16px; text-align: left; border-bottom: 1px solid var(--border-color); font-weight: 600; color: var(--text-primary); font-size: 14px;">${i18n.t('tool.domainPrice.rank', '排名')}</th>
|
||||
<th style="padding: 12px 16px; text-align: left; border-bottom: 1px solid var(--border-color); font-weight: 600; color: var(--text-primary); font-size: 14px;">${i18n.t('tool.domainPrice.platform', '平台')}</th>
|
||||
<th style="padding: 12px 16px; text-align: left; border-bottom: 1px solid var(--border-color); font-weight: 600; color: var(--text-primary); font-size: 14px;">${i18n.t('tool.domainPrice.price', '价格')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="domain-price-table-body">
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
init() {
|
||||
this._log('域名比价工具初始化');
|
||||
|
||||
this.dom = {
|
||||
form: document.getElementById('domain-price-query-form'),
|
||||
domainInput: document.getElementById('domain-price-domain'),
|
||||
typeSelect: document.getElementById('domain-price-type'),
|
||||
queryBtn: document.getElementById('domain-price-query-btn'),
|
||||
loading: document.getElementById('domain-price-loading'),
|
||||
errorMessage: document.getElementById('domain-price-error-message'),
|
||||
emptyState: document.getElementById('domain-price-empty-state'),
|
||||
resultSection: document.getElementById('domain-price-result-section'),
|
||||
resultSummary: document.getElementById('domain-price-result-summary'),
|
||||
tableBody: document.getElementById('domain-price-table-body')
|
||||
};
|
||||
|
||||
this.dom.form.addEventListener('submit', (e) => {
|
||||
e.preventDefault();
|
||||
this.queryDomainPrice();
|
||||
});
|
||||
}
|
||||
|
||||
async queryDomainPrice() {
|
||||
const domain = this.dom.domainInput.value.trim();
|
||||
const type = this.dom.typeSelect.value;
|
||||
|
||||
if (!domain) {
|
||||
this.showError(i18n.t('tool.domainPrice.enterDomain', '请输入域名后缀'));
|
||||
return;
|
||||
}
|
||||
|
||||
this.showLoading();
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
params.append('msg', domain);
|
||||
params.append('type', type);
|
||||
params.append('format', 'json');
|
||||
|
||||
const requestUrl = `${this.apiUrl}?${params.toString()}`;
|
||||
|
||||
const response = await fetch(requestUrl, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP Error! Status code: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const responseTime = Date.now() - startTime;
|
||||
|
||||
if (data.code === 200 && data.data) {
|
||||
this.showResults(data.data, domain, type);
|
||||
this._log(`成功查询域名价格,耗时 ${responseTime}ms`);
|
||||
} else {
|
||||
this.showError(data.message || i18n.t('tool.domainPrice.queryFailed', '查询失败'));
|
||||
this._log(`查询域名价格失败: ${data.message || '未知错误'}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Query failed:', error);
|
||||
this.showError(i18n.t('tool.domainPrice.queryFailed', '查询失败') + ': ' + error.message);
|
||||
this._log(`查询域名价格失败: ${error.message}`);
|
||||
} finally {
|
||||
this.hideLoading();
|
||||
}
|
||||
}
|
||||
|
||||
showResults(priceData, domain, type) {
|
||||
this.dom.tableBody.innerHTML = '';
|
||||
this.dom.emptyState.style.display = 'none';
|
||||
|
||||
const typeLabels = {
|
||||
'new': i18n.t('tool.domainPrice.new', '注册价格'),
|
||||
'renew': i18n.t('tool.domainPrice.renew', '续费价格'),
|
||||
'transfer': i18n.t('tool.domainPrice.transfer', '转入价格')
|
||||
};
|
||||
|
||||
this.dom.resultSummary.textContent = `.${domain} ${typeLabels[type]} - ${i18n.t('tool.domainPrice.totalPlatforms', '共')} ${priceData.length} ${i18n.t('tool.domainPrice.platforms', '个平台')}`;
|
||||
|
||||
if (Array.isArray(priceData) && priceData.length > 0) {
|
||||
priceData.forEach((item, index) => {
|
||||
const row = document.createElement('tr');
|
||||
row.style.cssText = 'border-bottom: 1px solid var(--border-color); transition: background 0.2s;';
|
||||
|
||||
row.innerHTML = `
|
||||
<td style="padding: 12px 16px; font-size: 14px; color: var(--text-primary);">
|
||||
<span style="display: inline-flex; align-items: center; justify-content: center; width: 24px; height: 24px; background: linear-gradient(135deg, var(--primary-color) 0%, rgba(var(--primary-rgb), 0.8) 100%); color: #fff; border-radius: 50%; font-size: 12px; font-weight: 700;">${index + 1}</span>
|
||||
</td>
|
||||
<td style="padding: 12px 16px; font-size: 14px; color: var(--text-primary); font-weight: 600;">${item.platform || item.name || '-'}</td>
|
||||
<td style="padding: 12px 16px; font-size: 16px; font-weight: 700; color: #e74c3c;">${item.price || item.cost || '-'}</td>
|
||||
`;
|
||||
|
||||
row.addEventListener('mouseenter', () => {
|
||||
row.style.background = 'rgba(var(--primary-rgb), 0.05)';
|
||||
});
|
||||
|
||||
row.addEventListener('mouseleave', () => {
|
||||
row.style.background = 'transparent';
|
||||
});
|
||||
|
||||
this.dom.tableBody.appendChild(row);
|
||||
});
|
||||
} else {
|
||||
this.dom.tableBody.innerHTML = `<tr><td colspan="3" style="text-align: center; padding: 40px; color: var(--text-secondary);">${i18n.t('tool.domainPrice.noResults', '暂无价格数据')}</td></tr>`;
|
||||
}
|
||||
|
||||
this.dom.resultSection.style.display = 'block';
|
||||
}
|
||||
|
||||
showLoading() {
|
||||
this.dom.queryBtn.disabled = true;
|
||||
this.dom.queryBtn.innerHTML = `<i class="fas fa-spinner fa-spin"></i> ${i18n.t('tool.domainPrice.querying', '查询中...')}`;
|
||||
this.dom.loading.style.display = 'block';
|
||||
this.dom.resultSection.style.display = 'none';
|
||||
this.dom.emptyState.style.display = 'none';
|
||||
this.dom.errorMessage.style.display = 'none';
|
||||
}
|
||||
|
||||
hideLoading() {
|
||||
this.dom.queryBtn.disabled = false;
|
||||
this.dom.queryBtn.innerHTML = `<i class="fas fa-search"></i> ${i18n.t('tool.domainPrice.query', '查询价格')}`;
|
||||
this.dom.loading.style.display = 'none';
|
||||
}
|
||||
|
||||
showError(message) {
|
||||
this.dom.errorMessage.textContent = message;
|
||||
this.dom.errorMessage.style.display = 'block';
|
||||
this.dom.resultSection.style.display = 'none';
|
||||
this.dom.loading.style.display = 'none';
|
||||
this.dom.emptyState.style.display = 'block';
|
||||
this.dom.queryBtn.disabled = false;
|
||||
this.dom.queryBtn.innerHTML = `<i class="fas fa-search"></i> ${i18n.t('tool.domainPrice.query', '查询价格')}`;
|
||||
}
|
||||
}
|
||||
|
||||
export default DomainPriceTool;
|
||||
@@ -0,0 +1,179 @@
|
||||
// src/js/tools/earthquakeTool.js
|
||||
import BaseTool from '../baseTool.js';
|
||||
import i18n from '../i18n.js';
|
||||
|
||||
class EarthquakeTool extends BaseTool {
|
||||
constructor() {
|
||||
super('earthquake-info', '地震信息');
|
||||
this.dom = {};
|
||||
this.apiUrl = 'https://www.cunyuapi.top/earthquake';
|
||||
}
|
||||
|
||||
render() {
|
||||
return `
|
||||
<div class="page-container earthquake-tool-container" style="display: flex; flex-direction: column; height: 100%;">
|
||||
<div class="content-area" style="padding: 20px; flex-grow: 1; display: flex; flex-direction: column; gap: 20px; overflow-y: auto;">
|
||||
|
||||
<div class="settings-section">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; flex-wrap: wrap; gap: 16px; position: relative;">
|
||||
<button id="back-to-toolbox-btn" class="back-btn ripple" style="position: absolute; left: 0; top: 0; z-index: 10;">
|
||||
<i class="fas fa-arrow-left"></i> ${i18n.t('common.backToToolbox', '返回工具箱')}
|
||||
</button>
|
||||
<div style="flex: 1; text-align: center; margin-left: 120px; margin-right: 120px;">
|
||||
<h2><i class="fas fa-mountain"></i> ${i18n.t('tool.earthquake.title', '近期全球地震信息')}</h2>
|
||||
<p style="color: var(--text-secondary); margin-top: 8px;">${i18n.t('tool.earthquake.description', '获取近期全球的地震信息,包括地点、震级、时间、深度等')}</p>
|
||||
</div>
|
||||
<button id="earthquake-refresh-btn" class="action-btn ripple" style="margin-left: auto;">
|
||||
<i class="fas fa-sync-alt"></i> ${i18n.t('tool.earthquake.refresh', '刷新数据')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="error-message" id="earthquake-error-message" style="display: none; background: rgba(220, 53, 69, 0.1); border: 1px solid rgba(220, 53, 69, 0.3); border-radius: 8px; padding: 16px; color: #dc3545; margin-bottom: 20px;"></div>
|
||||
|
||||
<div class="loading" id="earthquake-loading" style="display: none; text-align: center; padding: 40px; color: var(--text-secondary);">
|
||||
<div class="loading-spinner" style="border: 4px solid rgba(var(--primary-rgb), 0.1); border-top: 4px solid var(--primary-color); border-radius: 50%; width: 40px; height: 40px; animation: spin 1s linear infinite; margin: 0 auto 16px;"></div>
|
||||
<div>${i18n.t('tool.earthquake.loading', '正在获取地震信息,请稍候...')}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-section" id="earthquake-result-section" style="display: none;">
|
||||
<h2><i class="fas fa-table"></i> ${i18n.t('tool.earthquake.results', '地震数据')}</h2>
|
||||
<div style="overflow-x: auto;">
|
||||
<table class="earthquake-table" id="earthquake-table" style="width: 100%; border-collapse: collapse; margin-top: 20px;">
|
||||
<thead>
|
||||
<tr style="background: var(--card-bg); position: sticky; top: 0; z-index: 10;">
|
||||
<th style="padding: 12px 16px; text-align: left; border-bottom: 1px solid var(--border-color); font-weight: 600; color: var(--text-primary); font-size: 14px;">${i18n.t('tool.earthquake.rank', '排名')}</th>
|
||||
<th style="padding: 12px 16px; text-align: left; border-bottom: 1px solid var(--border-color); font-weight: 600; color: var(--text-primary); font-size: 14px;">${i18n.t('tool.earthquake.place', '地点')}</th>
|
||||
<th style="padding: 12px 16px; text-align: left; border-bottom: 1px solid var(--border-color); font-weight: 600; color: var(--text-primary); font-size: 14px;">${i18n.t('tool.earthquake.latitude', '纬度')}</th>
|
||||
<th style="padding: 12px 16px; text-align: left; border-bottom: 1px solid var(--border-color); font-weight: 600; color: var(--text-primary); font-size: 14px;">${i18n.t('tool.earthquake.longitude', '经度')}</th>
|
||||
<th style="padding: 12px 16px; text-align: left; border-bottom: 1px solid var(--border-color); font-weight: 600; color: var(--text-primary); font-size: 14px;">${i18n.t('tool.earthquake.magnitude', '震级')}</th>
|
||||
<th style="padding: 12px 16px; text-align: left; border-bottom: 1px solid var(--border-color); font-weight: 600; color: var(--text-primary); font-size: 14px;">${i18n.t('tool.earthquake.time', '时间')}</th>
|
||||
<th style="padding: 12px 16px; text-align: left; border-bottom: 1px solid var(--border-color); font-weight: 600; color: var(--text-primary); font-size: 14px;">${i18n.t('tool.earthquake.depth', '深度')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="earthquake-table-body">
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
init() {
|
||||
this._log('地震信息工具初始化');
|
||||
|
||||
this.dom = {
|
||||
refreshBtn: document.getElementById('earthquake-refresh-btn'),
|
||||
loading: document.getElementById('earthquake-loading'),
|
||||
errorMessage: document.getElementById('earthquake-error-message'),
|
||||
resultSection: document.getElementById('earthquake-result-section'),
|
||||
tableBody: document.getElementById('earthquake-table-body')
|
||||
};
|
||||
|
||||
this.dom.refreshBtn.addEventListener('click', () => {
|
||||
this.queryEarthquakeData();
|
||||
});
|
||||
|
||||
// 返回工具箱按钮
|
||||
document.getElementById('back-to-toolbox-btn')?.addEventListener('click', () => {
|
||||
window.mainPage.navigateTo('toolbox');
|
||||
window.mainPage.updateActiveNavButton(document.getElementById('toolbox-btn'));
|
||||
});
|
||||
|
||||
// 自动加载数据
|
||||
this.queryEarthquakeData();
|
||||
}
|
||||
|
||||
async queryEarthquakeData() {
|
||||
this.showLoading();
|
||||
this.hideError();
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
const response = await fetch(this.apiUrl, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'User-Agent': 'EarthquakeQuery/1.0'
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.message || `HTTP错误! 状态码: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const responseTime = (Date.now() - startTime) / 1000;
|
||||
|
||||
this.displayResults(data);
|
||||
this._log(`成功获取地震数据,耗时 ${responseTime.toFixed(2)}秒`);
|
||||
} catch (error) {
|
||||
const responseTime = (Date.now() - startTime) / 1000;
|
||||
let errorMessage = error.message;
|
||||
|
||||
// 检测 SSL 证书错误
|
||||
if (error.message.includes('certificate') || error.message.includes('SSL') || error.message.includes('TLS') ||
|
||||
error.name === 'TypeError' && error.message.includes('Failed to fetch')) {
|
||||
errorMessage = i18n.t('tool.earthquake.sslError', 'SSL 证书验证失败,可能是 API 服务器证书已过期。请稍后重试或联系管理员。');
|
||||
}
|
||||
|
||||
this.showError(i18n.t('tool.earthquake.queryFailed', '查询失败') + ': ' + errorMessage);
|
||||
console.error('API请求错误:', error);
|
||||
this._log(`获取地震数据失败: ${error.message}`);
|
||||
} finally {
|
||||
this.hideLoading();
|
||||
}
|
||||
}
|
||||
|
||||
displayResults(earthquakes) {
|
||||
this.dom.tableBody.innerHTML = '';
|
||||
|
||||
if (Array.isArray(earthquakes) && earthquakes.length > 0) {
|
||||
earthquakes.forEach((quake, index) => {
|
||||
const row = document.createElement('tr');
|
||||
row.style.cssText = 'border-bottom: 1px solid var(--border-color);';
|
||||
row.innerHTML = `
|
||||
<td style="padding: 12px 16px; font-size: 14px; color: var(--text-primary);"><span style="font-weight: 600; color: #e74c3c;">${index + 1}</span></td>
|
||||
<td style="padding: 12px 16px; font-size: 14px; color: var(--text-primary);"><span style="font-weight: 600;">${quake.place || '-'}</span></td>
|
||||
<td style="padding: 12px 16px; font-size: 14px; color: var(--text-primary);">${quake.lat || '-'}</td>
|
||||
<td style="padding: 12px 16px; font-size: 14px; color: var(--text-primary);">${quake.lon || '-'}</td>
|
||||
<td style="padding: 12px 16px; font-size: 14px; color: var(--text-primary);"><span style="font-weight: 600; color: #e67e22;">${quake.level || '-'}</span></td>
|
||||
<td style="padding: 12px 16px; font-size: 14px; color: var(--text-primary);"><span style="color: #3498db;">${quake.time || '-'}</span></td>
|
||||
<td style="padding: 12px 16px; font-size: 14px; color: var(--text-primary);">${quake.depth || '-'}</td>
|
||||
`;
|
||||
this.dom.tableBody.appendChild(row);
|
||||
});
|
||||
} else {
|
||||
this.dom.tableBody.innerHTML = `<tr><td colspan="7" style="text-align: center; color: var(--text-secondary); padding: 40px;">${i18n.t('tool.earthquake.noData', '未找到地震信息')}</td></tr>`;
|
||||
}
|
||||
|
||||
this.dom.resultSection.style.display = 'block';
|
||||
}
|
||||
|
||||
showLoading() {
|
||||
this.dom.loading.style.display = 'block';
|
||||
this.dom.refreshBtn.disabled = true;
|
||||
this.dom.refreshBtn.innerHTML = `<i class="fas fa-spinner fa-spin"></i> ${i18n.t('tool.earthquake.refreshing', '刷新中...')}`;
|
||||
}
|
||||
|
||||
hideLoading() {
|
||||
this.dom.loading.style.display = 'none';
|
||||
this.dom.refreshBtn.disabled = false;
|
||||
this.dom.refreshBtn.innerHTML = `<i class="fas fa-sync-alt"></i> ${i18n.t('tool.earthquake.refresh', '刷新数据')}`;
|
||||
}
|
||||
|
||||
showError(message) {
|
||||
this.dom.errorMessage.textContent = message;
|
||||
this.dom.errorMessage.style.display = 'block';
|
||||
}
|
||||
|
||||
hideError() {
|
||||
this.dom.errorMessage.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
export default EarthquakeTool;
|
||||
@@ -0,0 +1,207 @@
|
||||
import BaseTool from '../baseTool.js';
|
||||
|
||||
export default class ExpiryCalculatorTool extends BaseTool {
|
||||
constructor() {
|
||||
super('expiry-calculator', '保质期计算器');
|
||||
}
|
||||
|
||||
render() {
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
|
||||
// CSS 注入:处理日期选择器图标和融合输入框样式
|
||||
const customStyle = `
|
||||
<style>
|
||||
/* 日期选择器适配 */
|
||||
.island-date-input {
|
||||
color-scheme: light dark;
|
||||
font-family: var(--font-family);
|
||||
color: var(--text-color);
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.island-date-input::-webkit-calendar-picker-indicator {
|
||||
opacity: 0.6;
|
||||
cursor: pointer;
|
||||
/* 自动反转颜色以适应深色背景 */
|
||||
filter: invert(var(--dark-mode-invert, 0));
|
||||
}
|
||||
[data-theme="dark"] .island-date-input::-webkit-calendar-picker-indicator {
|
||||
filter: invert(1);
|
||||
}
|
||||
|
||||
/* 融合输入框容器 */
|
||||
.merged-input-group {
|
||||
display: flex;
|
||||
background: rgba(var(--bg-color-rgb), 0.5);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
height: 50px; /* 稍微调小高度使其更精致 */
|
||||
overflow: hidden;
|
||||
transition: all 0.2s;
|
||||
align-items: center;
|
||||
}
|
||||
.merged-input-group:focus-within {
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 0 0 3px rgba(var(--primary-rgb), 0.2);
|
||||
}
|
||||
|
||||
/* 融合的数字输入 */
|
||||
.merged-number {
|
||||
flex: 1;
|
||||
border: none;
|
||||
background: transparent;
|
||||
padding-left: 20px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--text-color);
|
||||
outline: none;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* 融合的单位选择 */
|
||||
.merged-select-wrapper {
|
||||
position: relative;
|
||||
width: 80px;
|
||||
height: 100%;
|
||||
background: rgba(var(--text-color), 0.05);
|
||||
border-left: 1px solid var(--border-color);
|
||||
}
|
||||
.merged-select {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
color: var(--text-color);
|
||||
padding-left: 15px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
`;
|
||||
|
||||
return `
|
||||
${customStyle}
|
||||
<div class="page-container" style="padding: 20px; height: 100%; overflow-y: auto; box-sizing: border-box;">
|
||||
<div class="section-header">
|
||||
<button class="back-btn ripple" id="back-btn"><i class="fas fa-arrow-left"></i> 返回</button>
|
||||
<h1>${this.name}</h1>
|
||||
</div>
|
||||
|
||||
<div class="tool-island-container" style="max-width: 600px; margin: 10px auto 0 auto; width: 100%; display: flex; flex-direction: column; gap: 15px;">
|
||||
|
||||
<div class="tool-island-card" style="padding: 25px; display: flex; flex-direction: column; gap: 20px;">
|
||||
|
||||
<div>
|
||||
<label style="display: block; color: var(--text-secondary); font-size: 13px; font-weight: 600; margin-bottom: 8px;">生产日期</label>
|
||||
<div class="merged-input-group" style="padding: 0 15px;">
|
||||
<i class="far fa-calendar-alt" style="color: var(--primary-color); margin-right: 15px; font-size: 18px;"></i>
|
||||
<input type="date" id="prod-date" class="island-date-input" value="${today}">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style="display: block; color: var(--text-secondary); font-size: 13px; font-weight: 600; margin-bottom: 8px;">保质期时长</label>
|
||||
<div class="merged-input-group">
|
||||
<input type="number" id="shelf-life-val" class="merged-number" placeholder="0">
|
||||
<div class="merged-select-wrapper">
|
||||
<select id="shelf-life-unit" class="merged-select">
|
||||
<option value="days">天</option>
|
||||
<option value="months" selected>个月</option>
|
||||
<option value="years">年</option>
|
||||
</select>
|
||||
<i class="fas fa-chevron-down" style="position: absolute; right: 10px; top: 50%; transform: translateY(-50%); pointer-events: none; opacity: 0.5; font-size: 10px;"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button id="btn-calc-expiry" class="action-btn ripple" style="height: 45px; border-radius: 10px; font-size: 15px; margin-top: 5px; font-weight: 600; box-shadow: 0 4px 12px rgba(var(--primary-rgb), 0.25);">
|
||||
开始计算
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="expiry-result-card" class="tool-island-card" style="padding: 20px; text-align: center; transition: all 0.3s;">
|
||||
<div style="font-size: 12px; color: var(--text-secondary); margin-bottom: 5px;">计算结果</div>
|
||||
<div id="expiry-result-date" style="font-size: 26px; font-weight: 700; color: var(--text-color); font-family: 'Consolas', monospace; margin: 5px 0;">-- / -- / --</div>
|
||||
|
||||
<div id="expiry-status-pill" style="
|
||||
display: inline-block;
|
||||
margin-top: 10px;
|
||||
padding: 6px 16px;
|
||||
border-radius: 20px;
|
||||
background: rgba(var(--bg-color-rgb), 0.5);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
transition: all 0.3s;">
|
||||
请输入数据开始计算
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
init() {
|
||||
document.getElementById('back-btn').addEventListener('click', () => window.mainPage.navigateTo('toolbox'));
|
||||
|
||||
document.getElementById('btn-calc-expiry').addEventListener('click', () => {
|
||||
const prodDateStr = document.getElementById('prod-date').value;
|
||||
const lifeVal = parseInt(document.getElementById('shelf-life-val').value);
|
||||
const lifeUnit = document.getElementById('shelf-life-unit').value;
|
||||
const resultCard = document.getElementById('expiry-result-card');
|
||||
|
||||
if (!prodDateStr || isNaN(lifeVal)) {
|
||||
this._notify('错误', '请输入有效的时长', 'error');
|
||||
// 错误动画
|
||||
resultCard.style.transform = 'translateX(5px)';
|
||||
setTimeout(() => resultCard.style.transform = 'translateX(-5px)', 50);
|
||||
setTimeout(() => resultCard.style.transform = 'translateX(0)', 100);
|
||||
return;
|
||||
}
|
||||
|
||||
const date = new Date(prodDateStr);
|
||||
|
||||
if (lifeUnit === 'days') date.setDate(date.getDate() + lifeVal);
|
||||
else if (lifeUnit === 'months') date.setMonth(date.getMonth() + lifeVal);
|
||||
else if (lifeUnit === 'years') date.setFullYear(date.getFullYear() + lifeVal);
|
||||
|
||||
const resStr = date.toISOString().split('T')[0];
|
||||
document.getElementById('expiry-result-date').innerText = resStr;
|
||||
|
||||
const now = new Date();
|
||||
now.setHours(0,0,0,0);
|
||||
date.setHours(0,0,0,0);
|
||||
|
||||
const diffTime = date - now;
|
||||
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
||||
|
||||
const statusPill = document.getElementById('expiry-status-pill');
|
||||
|
||||
if (diffDays < 0) {
|
||||
// 已过期
|
||||
statusPill.innerHTML = `<i class="fas fa-exclamation-circle"></i> 已过期 ${Math.abs(diffDays)} 天`;
|
||||
statusPill.style.background = 'rgba(var(--error-rgb), 0.15)';
|
||||
statusPill.style.color = 'var(--error-color)';
|
||||
} else if (diffDays === 0) {
|
||||
// 今天到期
|
||||
statusPill.innerHTML = `<i class="fas fa-exclamation-triangle"></i> 今天到期`;
|
||||
statusPill.style.background = 'rgba(var(--accent-rgb), 0.15)';
|
||||
statusPill.style.color = 'var(--accent-color)';
|
||||
} else {
|
||||
// 还有多久
|
||||
statusPill.innerHTML = `<i class="fas fa-check-circle"></i> 还有 ${diffDays} 天过期`;
|
||||
statusPill.style.background = 'rgba(var(--success-rgb), 0.15)';
|
||||
statusPill.style.color = 'var(--success-color)';
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
// src/js/tools/fileHashTool.js
|
||||
import BaseTool from '../baseTool.js';
|
||||
import i18n from '../i18n.js';
|
||||
|
||||
export default class FileHashTool extends BaseTool {
|
||||
constructor() {
|
||||
super('file-hash', i18n.t('tool.fileHash.name', null, '文件哈希计算'));
|
||||
}
|
||||
|
||||
render() {
|
||||
return `
|
||||
<div class="page-container" style="padding: 24px; display: flex; flex-direction: column; gap: 20px; height: 100%;">
|
||||
<div class="section-header">
|
||||
<button class="back-btn ripple" id="back-btn"><i class="fas fa-arrow-left"></i></button>
|
||||
<h1>${i18n.t('tool.fileHash.name', null, '文件哈希计算')}</h1>
|
||||
</div>
|
||||
|
||||
<div class="setting-island" style="flex-shrink: 0;">
|
||||
<h3 style="display: flex; align-items: center; gap: 10px; margin-bottom: 20px;">
|
||||
<i class="fas fa-file" style="color: var(--primary-color);"></i>
|
||||
${i18n.t('tool.fileHash.selectFile', null, '选择文件')}
|
||||
</h3>
|
||||
<div class="file-select-area" style="display: flex; flex-direction: column; gap: 12px;">
|
||||
<div style="display: flex; gap: 12px; align-items: center;">
|
||||
<input type="file" id="file-input" class="common-input" style="flex: 1; padding: 12px; cursor: pointer;">
|
||||
<button id="btn-select" class="action-btn ripple" style="padding: 12px 24px; white-space: nowrap;">
|
||||
<i class="fas fa-folder-open"></i> ${i18n.t('common.select', null, '选择')}
|
||||
</button>
|
||||
</div>
|
||||
<div id="file-info" class="file-info-text" style="font-size: 13px; color: var(--text-secondary); padding: 8px 12px; background: rgba(var(--bg-color-rgb), 0.4); border-radius: 8px; min-height: 20px;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="loading-indicator" class="loading-container" style="display: none; flex: 1; align-items: center; justify-content: center; flex-direction: column; gap: 16px;">
|
||||
<div class="spinner-wrapper">
|
||||
<i class="fas fa-spinner fa-spin" style="font-size: 32px; color: var(--primary-color);"></i>
|
||||
</div>
|
||||
<p style="color: var(--text-secondary); font-size: 14px; font-weight: 500;">${i18n.t('tool.fileHash.calculating', null, '正在计算哈希值...')}</p>
|
||||
</div>
|
||||
|
||||
<div id="hash-results" style="display: none; flex: 1; min-height: 0; overflow-y: auto;">
|
||||
<div class="setting-island" style="padding: 24px;">
|
||||
<h3 style="display: flex; align-items: center; gap: 10px; margin-bottom: 24px;">
|
||||
<i class="fas fa-fingerprint" style="color: var(--primary-color);"></i>
|
||||
${i18n.t('tool.fileHash.results', null, '哈希结果')}
|
||||
</h3>
|
||||
|
||||
<div class="hash-grid" style="display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 16px;">
|
||||
<div class="hash-item">
|
||||
<label class="hash-label">
|
||||
<i class="fas fa-shield-alt" style="color: var(--primary-color);"></i> MD5
|
||||
</label>
|
||||
<div class="hash-input-wrapper">
|
||||
<input type="text" id="hash-md5" class="common-input hash-input" readonly>
|
||||
<button class="hash-copy-btn ripple" data-copy="hash-md5" title="${i18n.t('common.copy', null, '复制')}">
|
||||
<i class="fas fa-copy"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hash-item">
|
||||
<label class="hash-label">
|
||||
<i class="fas fa-shield-alt" style="color: var(--secondary-color);"></i> SHA1
|
||||
</label>
|
||||
<div class="hash-input-wrapper">
|
||||
<input type="text" id="hash-sha1" class="common-input hash-input" readonly>
|
||||
<button class="hash-copy-btn ripple" data-copy="hash-sha1" title="${i18n.t('common.copy', null, '复制')}">
|
||||
<i class="fas fa-copy"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hash-item">
|
||||
<label class="hash-label">
|
||||
<i class="fas fa-shield-alt" style="color: var(--accent-color);"></i> SHA256
|
||||
</label>
|
||||
<div class="hash-input-wrapper">
|
||||
<input type="text" id="hash-sha256" class="common-input hash-input" readonly>
|
||||
<button class="hash-copy-btn ripple" data-copy="hash-sha256" title="${i18n.t('common.copy', null, '复制')}">
|
||||
<i class="fas fa-copy"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hash-item">
|
||||
<label class="hash-label">
|
||||
<i class="fas fa-shield-alt" style="color: var(--success-color);"></i> SHA512
|
||||
</label>
|
||||
<div class="hash-input-wrapper">
|
||||
<input type="text" id="hash-sha512" class="common-input hash-input" readonly>
|
||||
<button class="hash-copy-btn ripple" data-copy="hash-sha512" title="${i18n.t('common.copy', null, '复制')}">
|
||||
<i class="fas fa-copy"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
init() {
|
||||
document.getElementById('back-btn').addEventListener('click', () => window.mainPage.navigateTo('toolbox'));
|
||||
|
||||
const fileInput = document.getElementById('file-input');
|
||||
const selectBtn = document.getElementById('btn-select');
|
||||
const fileInfo = document.getElementById('file-info');
|
||||
const hashResults = document.getElementById('hash-results');
|
||||
const loadingIndicator = document.getElementById('loading-indicator');
|
||||
|
||||
selectBtn.addEventListener('click', () => fileInput.click());
|
||||
|
||||
fileInput.addEventListener('change', async (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (!file) return;
|
||||
|
||||
fileInfo.textContent = `${i18n.t('tool.fileHash.fileName', null, '文件名')}: ${file.name} | ${i18n.t('tool.fileHash.fileSize', null, '大小')}: ${this._formatFileSize(file.size)}`;
|
||||
hashResults.style.display = 'none';
|
||||
loadingIndicator.style.display = 'block';
|
||||
|
||||
try {
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
// 将 ArrayBuffer 转换为可传输的格式(Uint8Array)
|
||||
const uint8Array = new Uint8Array(arrayBuffer);
|
||||
const bufferArray = Array.from(uint8Array);
|
||||
|
||||
// 使用 Electron API 计算哈希
|
||||
const md5 = await window.electronAPI.calculateHash(bufferArray, 'md5');
|
||||
const sha1 = await window.electronAPI.calculateHash(bufferArray, 'sha1');
|
||||
const sha256 = await window.electronAPI.calculateHash(bufferArray, 'sha256');
|
||||
const sha512 = await window.electronAPI.calculateHash(bufferArray, 'sha512');
|
||||
|
||||
document.getElementById('hash-md5').value = md5;
|
||||
document.getElementById('hash-sha1').value = sha1;
|
||||
document.getElementById('hash-sha256').value = sha256;
|
||||
document.getElementById('hash-sha512').value = sha512;
|
||||
|
||||
hashResults.style.display = 'block';
|
||||
loadingIndicator.style.display = 'none';
|
||||
this._notify(i18n.t('common.notification.title.success', null, '成功'), i18n.t('tool.fileHash.calculateSuccess', null, '哈希值计算完成'), 'success');
|
||||
} catch (error) {
|
||||
loadingIndicator.style.display = 'none';
|
||||
this._notify(i18n.t('common.notification.title.error', null, '错误'), error.message, 'error');
|
||||
}
|
||||
});
|
||||
|
||||
// 复制按钮
|
||||
document.querySelectorAll('[data-copy]').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const targetId = btn.getAttribute('data-copy');
|
||||
const input = document.getElementById(targetId);
|
||||
if (input && input.value) {
|
||||
navigator.clipboard.writeText(input.value).then(() => {
|
||||
this._notify(i18n.t('common.notification.title.success', null, '成功'), i18n.t('common.copied', null, '已复制到剪贴板'), 'success');
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
_formatFileSize(bytes) {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
const k = 1024;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
// src/js/tools/footballNewsTool.js
|
||||
import BaseTool from '../baseTool.js';
|
||||
import i18n from '../i18n.js';
|
||||
|
||||
class FootballNewsTool extends BaseTool {
|
||||
constructor() {
|
||||
super('football-news', '足球赛事热点');
|
||||
this.dom = {};
|
||||
this.apiUrl = 'https://api.jkyai.top/API/zqssrd.php';
|
||||
this.currentNewsList = [];
|
||||
this.currentDetail = null;
|
||||
}
|
||||
|
||||
render() {
|
||||
return `
|
||||
<div class="page-container football-news-tool-container" style="display: flex; flex-direction: column; height: 100%;">
|
||||
<div class="content-area" style="padding: 20px; flex-grow: 1; display: flex; flex-direction: column; gap: 20px; overflow-y: auto;">
|
||||
|
||||
<div class="settings-section" style="position: relative;">
|
||||
<button id="back-to-toolbox-btn" class="back-btn ripple" style="position: absolute; top: 0; left: 0; z-index: 10;">
|
||||
<i class="fas fa-arrow-left"></i> ${i18n.t('common.backToToolbox', '返回工具箱')}
|
||||
</button>
|
||||
<div style="text-align: center; margin-bottom: 20px;">
|
||||
<h2><i class="fas fa-futbol"></i> ${i18n.t('tool.footballNews.title', '足球赛事热点')}</h2>
|
||||
<p style="color: var(--text-secondary); margin-top: 8px;">${i18n.t('tool.footballNews.description', '获取最新的足球赛事热点新闻')}</p>
|
||||
</div>
|
||||
|
||||
<div class="refresh-section" style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; flex-wrap: wrap; gap: 16px;">
|
||||
<span style="font-size: 14px; color: var(--text-secondary);">${i18n.t('tool.footballNews.clickToView', '点击查看详细')}</span>
|
||||
<button id="football-news-refresh-btn" class="action-btn ripple">
|
||||
<i class="fas fa-sync-alt"></i> ${i18n.t('tool.footballNews.refresh', '刷新热点')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="error-message" id="football-news-error-message" style="display: none; background: rgba(220, 53, 69, 0.1); border: 1px solid rgba(220, 53, 69, 0.3); border-radius: 8px; padding: 16px; color: #dc3545; margin-bottom: 20px;"></div>
|
||||
</div>
|
||||
|
||||
<!-- 新闻列表 -->
|
||||
<div class="settings-section" id="football-news-list-section">
|
||||
<h2><i class="fas fa-list"></i> ${i18n.t('tool.footballNews.newsList', '新闻列表')}</h2>
|
||||
<div class="news-list" id="football-news-list" style="display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 20px; margin-top: 20px;"></div>
|
||||
</div>
|
||||
|
||||
<!-- 新闻详情 -->
|
||||
<div class="settings-section" id="football-news-detail-section" style="display: none;">
|
||||
<button id="football-news-back-btn" class="back-btn ripple" style="margin-bottom: 20px;">
|
||||
<i class="fas fa-arrow-left"></i> ${i18n.t('tool.footballNews.backToList', '返回列表')}
|
||||
</button>
|
||||
<div class="detail-header" style="margin-bottom: 24px;">
|
||||
<h3 id="football-news-detail-title" style="font-size: 24px; font-weight: 700; color: var(--text-primary); margin-bottom: 12px;"></h3>
|
||||
<div class="detail-info" style="display: flex; gap: 16px; font-size: 14px; color: var(--text-secondary);">
|
||||
<span id="football-news-detail-timestamp"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="detail-images" id="football-news-detail-images" style="margin-bottom: 24px;"></div>
|
||||
<div class="detail-content" id="football-news-detail-content" style="font-size: 16px; line-height: 1.8; color: var(--text-primary); margin-bottom: 24px;"></div>
|
||||
<div class="detail-tags" id="football-news-detail-tags" style="display: flex; flex-wrap: wrap; gap: 8px;"></div>
|
||||
</div>
|
||||
|
||||
<div class="loading" id="football-news-loading" style="display: none; text-align: center; padding: 40px; color: var(--text-secondary);">
|
||||
<div class="loading-spinner" style="border: 4px solid rgba(var(--primary-rgb), 0.1); border-top: 4px solid var(--primary-color); border-radius: 50%; width: 40px; height: 40px; animation: spin 1s linear infinite; margin: 0 auto 16px;"></div>
|
||||
<div>${i18n.t('tool.footballNews.loading', '正在获取足球赛事热点,请稍候...')}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
init() {
|
||||
this._log('足球新闻工具初始化');
|
||||
|
||||
this.dom = {
|
||||
refreshBtn: document.getElementById('football-news-refresh-btn'),
|
||||
backBtn: document.getElementById('football-news-back-btn'),
|
||||
listSection: document.getElementById('football-news-list-section'),
|
||||
detailSection: document.getElementById('football-news-detail-section'),
|
||||
newsList: document.getElementById('football-news-list'),
|
||||
detailTitle: document.getElementById('football-news-detail-title'),
|
||||
detailContent: document.getElementById('football-news-detail-content'),
|
||||
detailImages: document.getElementById('football-news-detail-images'),
|
||||
detailTags: document.getElementById('football-news-detail-tags'),
|
||||
detailTimestamp: document.getElementById('football-news-detail-timestamp'),
|
||||
loading: document.getElementById('football-news-loading'),
|
||||
errorMessage: document.getElementById('football-news-error-message')
|
||||
};
|
||||
|
||||
this.dom.refreshBtn?.addEventListener('click', () => {
|
||||
this.fetchNewsList();
|
||||
});
|
||||
|
||||
this.dom.backBtn?.addEventListener('click', () => {
|
||||
this.showNewsList();
|
||||
});
|
||||
|
||||
// 返回工具箱按钮
|
||||
document.getElementById('back-to-toolbox-btn')?.addEventListener('click', () => {
|
||||
window.mainPage.navigateTo('toolbox');
|
||||
window.mainPage.updateActiveNavButton(document.getElementById('toolbox-btn'));
|
||||
});
|
||||
|
||||
// 自动加载数据
|
||||
this.fetchNewsList();
|
||||
}
|
||||
|
||||
async fetchNewsList() {
|
||||
this.showLoading();
|
||||
this.hideError();
|
||||
this.showNewsList();
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
const response = await fetch(`${this.apiUrl}?type=json`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP错误! 状态码: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const responseTime = (Date.now() - startTime) / 1000;
|
||||
|
||||
if (data.success && data.data) {
|
||||
this.currentNewsList = data.data;
|
||||
this.displayNewsList();
|
||||
this._log(`成功获取足球新闻列表,耗时 ${responseTime.toFixed(2)}秒`);
|
||||
} else {
|
||||
throw new Error(data.message || i18n.t('tool.footballNews.queryFailed', '获取新闻失败'));
|
||||
}
|
||||
} catch (error) {
|
||||
const responseTime = (Date.now() - startTime) / 1000;
|
||||
this.showError(i18n.t('tool.footballNews.queryFailed', '查询失败') + ': ' + error.message);
|
||||
console.error('API请求错误:', error);
|
||||
this._log(`获取足球新闻列表失败: ${error.message}`);
|
||||
} finally {
|
||||
this.hideLoading();
|
||||
}
|
||||
}
|
||||
|
||||
async fetchNewsDetail(docId) {
|
||||
this.showLoading();
|
||||
this.hideError();
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
const response = await fetch(`${this.apiUrl}?doc=${docId}&type=json`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP错误! 状态码: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const responseTime = (Date.now() - startTime) / 1000;
|
||||
|
||||
if (data.success) {
|
||||
this.currentDetail = data;
|
||||
this.displayNewsDetail(data);
|
||||
this._log(`成功获取足球新闻详情,耗时 ${responseTime.toFixed(2)}秒`);
|
||||
} else {
|
||||
throw new Error(data.message || i18n.t('tool.footballNews.detailFailed', '获取新闻详情失败'));
|
||||
}
|
||||
} catch (error) {
|
||||
const responseTime = (Date.now() - startTime) / 1000;
|
||||
this.showError(i18n.t('tool.footballNews.detailFailed', '获取详情失败') + ': ' + error.message);
|
||||
console.error('API请求错误:', error);
|
||||
this._log(`获取足球新闻详情失败: ${error.message}`);
|
||||
} finally {
|
||||
this.hideLoading();
|
||||
}
|
||||
}
|
||||
|
||||
displayNewsList() {
|
||||
this.dom.newsList.innerHTML = '';
|
||||
|
||||
if (Array.isArray(this.currentNewsList) && this.currentNewsList.length > 0) {
|
||||
this.currentNewsList.forEach(news => {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'news-card';
|
||||
card.style.cssText = `
|
||||
background: rgba(var(--card-background-rgb), 0.5);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
`;
|
||||
|
||||
card.addEventListener('mouseenter', () => {
|
||||
card.style.background = 'rgba(var(--card-background-rgb), 0.8)';
|
||||
card.style.transform = 'translateY(-4px)';
|
||||
card.style.boxShadow = '0 8px 16px rgba(0, 0, 0, 0.1)';
|
||||
});
|
||||
|
||||
card.addEventListener('mouseleave', () => {
|
||||
card.style.background = 'rgba(var(--card-background-rgb), 0.5)';
|
||||
card.style.transform = 'translateY(0)';
|
||||
card.style.boxShadow = 'none';
|
||||
});
|
||||
|
||||
card.addEventListener('click', () => {
|
||||
if (news.docId || news.id) {
|
||||
this.fetchNewsDetail(news.docId || news.id);
|
||||
}
|
||||
});
|
||||
|
||||
card.innerHTML = `
|
||||
<div class="news-title" style="font-size: 18px; font-weight: 600; color: var(--text-primary); margin-bottom: 12px; line-height: 1.5;">
|
||||
${news.title || news.name || '-'}
|
||||
</div>
|
||||
${news.summary ? `
|
||||
<div class="news-summary" style="font-size: 14px; color: var(--text-secondary); line-height: 1.6; margin-bottom: 12px;">
|
||||
${news.summary}
|
||||
</div>
|
||||
` : ''}
|
||||
${news.timestamp ? `
|
||||
<div class="news-time" style="font-size: 12px; color: var(--text-secondary);">
|
||||
<i class="far fa-clock"></i> ${news.timestamp}
|
||||
</div>
|
||||
` : ''}
|
||||
`;
|
||||
|
||||
this.dom.newsList.appendChild(card);
|
||||
});
|
||||
} else {
|
||||
this.dom.newsList.innerHTML = `
|
||||
<div style="text-align: center; padding: 40px; color: var(--text-secondary); grid-column: 1 / -1;">
|
||||
${i18n.t('tool.footballNews.noData', '暂无新闻数据')}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
displayNewsDetail(data) {
|
||||
this.dom.detailTitle.textContent = data.title || i18n.t('tool.footballNews.detail', '新闻详情');
|
||||
this.dom.detailTimestamp.textContent = data.timestamp || data.time || '';
|
||||
|
||||
// 显示图片
|
||||
this.dom.detailImages.innerHTML = '';
|
||||
if (data.images && Array.isArray(data.images) && data.images.length > 0) {
|
||||
data.images.forEach(imgUrl => {
|
||||
const img = document.createElement('img');
|
||||
img.src = imgUrl;
|
||||
img.style.cssText = 'max-width: 100%; height: auto; border-radius: 8px; margin-bottom: 16px;';
|
||||
img.alt = data.title || '新闻图片';
|
||||
this.dom.detailImages.appendChild(img);
|
||||
});
|
||||
}
|
||||
|
||||
// 显示内容
|
||||
if (data.content) {
|
||||
this.dom.detailContent.innerHTML = this.formatContent(data.content);
|
||||
} else {
|
||||
this.dom.detailContent.textContent = i18n.t('tool.footballNews.noContent', '暂无内容');
|
||||
}
|
||||
|
||||
// 显示标签
|
||||
this.dom.detailTags.innerHTML = '';
|
||||
if (data.tags && Array.isArray(data.tags) && data.tags.length > 0) {
|
||||
data.tags.forEach(tag => {
|
||||
const tagEl = document.createElement('span');
|
||||
tagEl.className = 'tag';
|
||||
tagEl.style.cssText = 'padding: 4px 12px; border-radius: 16px; background: rgba(var(--primary-rgb), 0.1); color: var(--primary-color); font-size: 12px; font-weight: 500;';
|
||||
tagEl.textContent = tag;
|
||||
this.dom.detailTags.appendChild(tagEl);
|
||||
});
|
||||
}
|
||||
|
||||
// 显示详情页面
|
||||
this.dom.listSection.style.display = 'none';
|
||||
this.dom.detailSection.style.display = 'block';
|
||||
}
|
||||
|
||||
formatContent(content) {
|
||||
// 简单的HTML格式化
|
||||
if (typeof content === 'string') {
|
||||
return content
|
||||
.replace(/\n/g, '<br>')
|
||||
.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '');
|
||||
}
|
||||
return content;
|
||||
}
|
||||
|
||||
showNewsList() {
|
||||
this.dom.listSection.style.display = 'block';
|
||||
this.dom.detailSection.style.display = 'none';
|
||||
}
|
||||
|
||||
showLoading() {
|
||||
this.dom.loading.style.display = 'block';
|
||||
this.dom.refreshBtn.disabled = true;
|
||||
this.dom.refreshBtn.innerHTML = `<i class="fas fa-spinner fa-spin"></i> ${i18n.t('tool.footballNews.refreshing', '刷新中...')}`;
|
||||
}
|
||||
|
||||
hideLoading() {
|
||||
this.dom.loading.style.display = 'none';
|
||||
this.dom.refreshBtn.disabled = false;
|
||||
this.dom.refreshBtn.innerHTML = `<i class="fas fa-sync-alt"></i> ${i18n.t('tool.footballNews.refresh', '刷新热点')}`;
|
||||
}
|
||||
|
||||
showError(message) {
|
||||
this.dom.errorMessage.textContent = message;
|
||||
this.dom.errorMessage.style.display = 'block';
|
||||
}
|
||||
|
||||
hideError() {
|
||||
this.dom.errorMessage.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
export default FootballNewsTool;
|
||||
@@ -0,0 +1,226 @@
|
||||
// src/js/tools/goldPriceTool.js
|
||||
import BaseTool from '../baseTool.js';
|
||||
import i18n from '../i18n.js';
|
||||
|
||||
class GoldPriceTool extends BaseTool {
|
||||
constructor() {
|
||||
super('gold-price', '黄金价格查询');
|
||||
this.dom = {};
|
||||
this.apiUrl = 'https://api.pearktrue.cn/api/goldprice/';
|
||||
this.shopApiUrl = 'http://api.xingchenfu.xyz/API/jinjia.php';
|
||||
this.showTodayPrice = true;
|
||||
this.todayPriceData = null;
|
||||
this.shopPriceData = [];
|
||||
}
|
||||
|
||||
render() {
|
||||
return `
|
||||
<div class="page-container gold-price-tool-container" style="display: flex; flex-direction: column; height: 100%;">
|
||||
<div class="content-area" style="padding: 20px; flex-grow: 1; display: flex; flex-direction: column; gap: 20px; overflow-y: auto;">
|
||||
|
||||
<div class="settings-section">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; flex-wrap: wrap; gap: 16px;">
|
||||
<button id="back-to-toolbox-btn" class="back-btn ripple" style="position: absolute; top: 20px; left: 20px; z-index: 10;">
|
||||
<i class="fas fa-arrow-left"></i> ${i18n.t('common.backToToolbox', '返回工具箱')}
|
||||
</button>
|
||||
<div style="flex: 1; text-align: center; margin-left: 120px; margin-right: 120px;">
|
||||
<h2><i class="fas fa-coins"></i> ${i18n.t('tool.goldPrice.title', '今日黄金价格')}</h2>
|
||||
<p style="color: var(--text-secondary); margin-top: 8px;">${i18n.t('tool.goldPrice.description', '获取最新的黄金价格以及各种黄金的详细信息')}</p>
|
||||
</div>
|
||||
<button id="gold-price-refresh-btn" class="action-btn ripple" style="margin-left: auto;">
|
||||
<i class="fas fa-sync-alt"></i> ${i18n.t('tool.goldPrice.refresh', '刷新数据')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="overview-section" style="background: rgba(var(--card-background-rgb), 0.5); padding: 30px; border-radius: 12px; margin-bottom: 30px; text-align: center; border: 1px solid var(--border-color);">
|
||||
<div style="font-size: 18px; font-weight: 600; color: var(--text-secondary); margin-bottom: 16px;">${i18n.t('tool.goldPrice.currentPrice', '今日黄金价格')}</div>
|
||||
<div id="gold-price-current" style="font-size: 48px; font-weight: 700; color: var(--primary-color); margin-bottom: 12px;">--</div>
|
||||
<div id="gold-price-update-time" style="font-size: 14px; color: var(--text-secondary);"></div>
|
||||
</div>
|
||||
|
||||
<div class="error-message" id="gold-price-error-message" style="display: none; background: rgba(220, 53, 69, 0.1); border: 1px solid rgba(220, 53, 69, 0.3); border-radius: 8px; padding: 16px; color: #dc3545; margin-bottom: 20px;"></div>
|
||||
</div>
|
||||
|
||||
<div class="settings-section" id="gold-price-result-section" style="display: none;">
|
||||
<h2><i class="fas fa-table"></i> ${i18n.t('tool.goldPrice.detailList', '详细价格列表')}</h2>
|
||||
<div style="overflow-x: auto; margin-top: 20px;">
|
||||
<table class="gold-price-table" id="gold-price-table" style="width: 100%; border-collapse: collapse;">
|
||||
<thead>
|
||||
<tr style="background: var(--card-bg); position: sticky; top: 0; z-index: 10;">
|
||||
<th style="padding: 12px 16px; text-align: left; border-bottom: 1px solid var(--border-color); font-weight: 600; color: var(--text-primary); font-size: 14px;">${i18n.t('tool.goldPrice.rank', '序号')}</th>
|
||||
<th style="padding: 12px 16px; text-align: left; border-bottom: 1px solid var(--border-color); font-weight: 600; color: var(--text-primary); font-size: 14px;">${i18n.t('tool.goldPrice.name', '黄金名称')}</th>
|
||||
<th style="padding: 12px 16px; text-align: left; border-bottom: 1px solid var(--border-color); font-weight: 600; color: var(--text-primary); font-size: 14px;">${i18n.t('tool.goldPrice.category', '目录')}</th>
|
||||
<th style="padding: 12px 16px; text-align: left; border-bottom: 1px solid var(--border-color); font-weight: 600; color: var(--text-primary); font-size: 14px;">${i18n.t('tool.goldPrice.change', '涨跌幅')}</th>
|
||||
<th style="padding: 12px 16px; text-align: left; border-bottom: 1px solid var(--border-color); font-weight: 600; color: var(--text-primary); font-size: 14px;">${i18n.t('tool.goldPrice.high', '最高价')}</th>
|
||||
<th style="padding: 12px 16px; text-align: left; border-bottom: 1px solid var(--border-color); font-weight: 600; color: var(--text-primary); font-size: 14px;">${i18n.t('tool.goldPrice.low', '最低价')}</th>
|
||||
<th style="padding: 12px 16px; text-align: left; border-bottom: 1px solid var(--border-color); font-weight: 600; color: var(--text-primary); font-size: 14px;">${i18n.t('tool.goldPrice.buyHigh', '最高买入价')}</th>
|
||||
<th style="padding: 12px 16px; text-align: left; border-bottom: 1px solid var(--border-color); font-weight: 600; color: var(--text-primary); font-size: 14px;">${i18n.t('tool.goldPrice.sellLow', '最低卖出价')}</th>
|
||||
<th style="padding: 12px 16px; text-align: left; border-bottom: 1px solid var(--border-color); font-weight: 600; color: var(--text-primary); font-size: 14px;">${i18n.t('tool.goldPrice.date', '日期')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="gold-price-table-body">
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="loading" id="gold-price-loading" style="display: none; text-align: center; padding: 40px; color: var(--text-secondary);">
|
||||
<div class="loading-spinner" style="border: 4px solid rgba(var(--primary-rgb), 0.1); border-top: 4px solid var(--primary-color); border-radius: 50%; width: 40px; height: 40px; animation: spin 1s linear infinite; margin: 0 auto 16px;"></div>
|
||||
<div>${i18n.t('tool.goldPrice.loading', '正在获取黄金价格数据,请稍候...')}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
init() {
|
||||
this._log('黄金价格工具初始化');
|
||||
|
||||
this.dom = {
|
||||
refreshBtn: document.getElementById('gold-price-refresh-btn'),
|
||||
currentPrice: document.getElementById('gold-price-current'),
|
||||
updateTime: document.getElementById('gold-price-update-time'),
|
||||
loading: document.getElementById('gold-price-loading'),
|
||||
errorMessage: document.getElementById('gold-price-error-message'),
|
||||
resultSection: document.getElementById('gold-price-result-section'),
|
||||
tableBody: document.getElementById('gold-price-table-body')
|
||||
};
|
||||
|
||||
this.dom.refreshBtn.addEventListener('click', () => {
|
||||
this.queryGoldPrice();
|
||||
});
|
||||
|
||||
// 返回工具箱按钮
|
||||
document.getElementById('back-to-toolbox-btn')?.addEventListener('click', () => {
|
||||
window.mainPage.navigateTo('toolbox');
|
||||
window.mainPage.updateActiveNavButton(document.getElementById('toolbox-btn'));
|
||||
});
|
||||
|
||||
// 自动加载数据
|
||||
this.queryGoldPrice();
|
||||
}
|
||||
|
||||
async queryGoldPrice() {
|
||||
this.showLoading();
|
||||
this.hideError();
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
const response = await fetch(this.apiUrl, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'User-Agent': 'GoldPriceQuery/1.0'
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.message || `HTTP错误! 状态码: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const responseTime = (Date.now() - startTime) / 1000;
|
||||
|
||||
if (data.code === 200 || data.price) {
|
||||
this.displayResults(data);
|
||||
this._log(`成功获取黄金价格数据,耗时 ${responseTime.toFixed(2)}秒`);
|
||||
} else {
|
||||
throw new Error(data.msg || i18n.t('tool.goldPrice.queryFailed', '获取数据失败'));
|
||||
}
|
||||
} catch (error) {
|
||||
const responseTime = (Date.now() - startTime) / 1000;
|
||||
let errorMessage = error.message;
|
||||
|
||||
// 检测 SSL 证书错误
|
||||
if (error.message.includes('certificate') || error.message.includes('SSL') || error.message.includes('TLS') ||
|
||||
error.name === 'TypeError' && error.message.includes('Failed to fetch')) {
|
||||
errorMessage = i18n.t('tool.goldPrice.sslError', 'SSL 证书验证失败,可能是 API 服务器证书已过期。请稍后重试或联系管理员。');
|
||||
}
|
||||
|
||||
this.showError(i18n.t('tool.goldPrice.queryFailed', '查询失败') + ': ' + errorMessage);
|
||||
console.error('API请求错误:', error);
|
||||
this._log(`获取黄金价格数据失败: ${error.message}`);
|
||||
} finally {
|
||||
this.hideLoading();
|
||||
}
|
||||
}
|
||||
|
||||
displayResults(data) {
|
||||
this.todayPriceData = data;
|
||||
|
||||
// 显示当前价格
|
||||
if (data.price) {
|
||||
this.dom.currentPrice.textContent = `${data.price} ${i18n.t('tool.goldPrice.unit', '元/克')}`;
|
||||
} else if (data.data && data.data.length > 0) {
|
||||
// 如果没有 price 字段,使用第一条数据的最高价
|
||||
const firstItem = data.data[0];
|
||||
this.dom.currentPrice.textContent = `${firstItem.maxprice || '--'} ${i18n.t('tool.goldPrice.unit', '元/克')}`;
|
||||
} else {
|
||||
this.dom.currentPrice.textContent = '--';
|
||||
}
|
||||
|
||||
// 显示更新时间
|
||||
if (data.time) {
|
||||
this.dom.updateTime.textContent = `${i18n.t('tool.goldPrice.updateTime', '更新时间')}: ${data.time}`;
|
||||
} else {
|
||||
this.dom.updateTime.textContent = `${i18n.t('tool.goldPrice.updateTime', '更新时间')}: ${new Date().toLocaleString('zh-CN')}`;
|
||||
}
|
||||
|
||||
// 显示详细列表
|
||||
this.dom.tableBody.innerHTML = '';
|
||||
|
||||
if (Array.isArray(data.data) && data.data.length > 0) {
|
||||
data.data.forEach((item, index) => {
|
||||
const row = document.createElement('tr');
|
||||
row.style.cssText = 'border-bottom: 1px solid var(--border-color);';
|
||||
|
||||
// 计算涨跌幅颜色
|
||||
const changeValue = item.change || item.changepercent || '--';
|
||||
const changeColor = changeValue !== '--' && parseFloat(changeValue) > 0 ? '#e74c3c' :
|
||||
changeValue !== '--' && parseFloat(changeValue) < 0 ? '#27ae60' : 'var(--text-primary)';
|
||||
|
||||
row.innerHTML = `
|
||||
<td style="padding: 12px 16px; font-size: 14px; color: var(--text-primary);"><span style="font-weight: 600; color: var(--primary-color);">${index + 1}</span></td>
|
||||
<td style="padding: 12px 16px; font-size: 14px; color: var(--text-primary);"><span style="font-weight: 600;">${item.title || item.name || '-'}</span></td>
|
||||
<td style="padding: 12px 16px; font-size: 14px; color: var(--text-primary);">${item.category || item.type || '-'}</td>
|
||||
<td style="padding: 12px 16px; font-size: 14px; color: ${changeColor};"><span style="font-weight: 600;">${changeValue}</span></td>
|
||||
<td style="padding: 12px 16px; font-size: 14px; color: var(--text-primary);">${item.maxprice || item.high || '-'}</td>
|
||||
<td style="padding: 12px 16px; font-size: 14px; color: var(--text-primary);">${item.minprice || item.low || '-'}</td>
|
||||
<td style="padding: 12px 16px; font-size: 14px; color: var(--text-primary);">${item.buyhigh || item.buy_high || '-'}</td>
|
||||
<td style="padding: 12px 16px; font-size: 14px; color: var(--text-primary);">${item.selllow || item.sell_low || '-'}</td>
|
||||
<td style="padding: 12px 16px; font-size: 14px; color: var(--text-primary);"><span style="color: var(--text-secondary);">${item.date || item.time || '-'}</span></td>
|
||||
`;
|
||||
this.dom.tableBody.appendChild(row);
|
||||
});
|
||||
} else {
|
||||
this.dom.tableBody.innerHTML = `<tr><td colspan="9" style="text-align: center; color: var(--text-secondary); padding: 40px;">${i18n.t('tool.goldPrice.noData', '未找到黄金价格信息')}</td></tr>`;
|
||||
}
|
||||
|
||||
this.dom.resultSection.style.display = 'block';
|
||||
}
|
||||
|
||||
showLoading() {
|
||||
this.dom.loading.style.display = 'block';
|
||||
this.dom.refreshBtn.disabled = true;
|
||||
this.dom.refreshBtn.innerHTML = `<i class="fas fa-spinner fa-spin"></i> ${i18n.t('tool.goldPrice.refreshing', '刷新中...')}`;
|
||||
}
|
||||
|
||||
hideLoading() {
|
||||
this.dom.loading.style.display = 'none';
|
||||
this.dom.refreshBtn.disabled = false;
|
||||
this.dom.refreshBtn.innerHTML = `<i class="fas fa-sync-alt"></i> ${i18n.t('tool.goldPrice.refresh', '刷新数据')}`;
|
||||
}
|
||||
|
||||
showError(message) {
|
||||
this.dom.errorMessage.textContent = message;
|
||||
this.dom.errorMessage.style.display = 'block';
|
||||
}
|
||||
|
||||
hideError() {
|
||||
this.dom.errorMessage.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
export default GoldPriceTool;
|
||||
@@ -0,0 +1,189 @@
|
||||
// src/js/tools/historyTodayTool.js
|
||||
import BaseTool from '../baseTool.js';
|
||||
import i18n from '../i18n.js';
|
||||
|
||||
class HistoryTodayTool extends BaseTool {
|
||||
constructor() {
|
||||
super('history-today', '历史上的今天');
|
||||
this.dom = {};
|
||||
this.apiUrl = 'https://api.jkyai.top/API/lssdjt.php';
|
||||
}
|
||||
|
||||
render() {
|
||||
return `
|
||||
<div class="page-container history-today-tool-container" style="display: flex; flex-direction: column; height: 100%;">
|
||||
<div class="content-area" style="padding: 20px; flex-grow: 1; display: flex; flex-direction: column; gap: 20px; overflow-y: auto;">
|
||||
|
||||
<div class="settings-section" style="position: relative;">
|
||||
<button id="back-to-toolbox-btn" class="back-btn ripple" style="position: absolute; top: 0; left: 0; z-index: 10;">
|
||||
<i class="fas fa-arrow-left"></i> ${i18n.t('common.backToToolbox', '返回工具箱')}
|
||||
</button>
|
||||
<div style="text-align: center; margin-bottom: 20px;">
|
||||
<h2><i class="fas fa-history"></i> ${i18n.t('tool.historyToday.title', '历史上的今天')}</h2>
|
||||
<p style="color: var(--text-secondary); margin-top: 8px;">${i18n.t('tool.historyToday.description', '探索历史上今天发生的重要事件')}</p>
|
||||
</div>
|
||||
|
||||
<div class="date-section" style="text-align: center; margin-bottom: 30px; padding: 20px; background: rgba(var(--card-background-rgb), 0.5); border-radius: 8px;">
|
||||
<div class="current-date" id="history-today-current-date" style="font-size: 24px; font-weight: 700; color: var(--text-primary);"></div>
|
||||
</div>
|
||||
|
||||
<div class="refresh-section" style="display: flex; justify-content: center; margin-bottom: 30px;">
|
||||
<button class="action-btn ripple" id="history-today-refresh-btn">
|
||||
<i class="fas fa-sync-alt"></i> ${i18n.t('tool.historyToday.refresh', '刷新数据')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-section">
|
||||
<h2><i class="fas fa-list"></i> ${i18n.t('tool.historyToday.events', '历史事件')}</h2>
|
||||
<div class="events-list" id="history-today-events-list" style="display: flex; flex-direction: column; gap: 12px; margin-top: 20px;"></div>
|
||||
</div>
|
||||
|
||||
<div class="loading" id="history-today-loading" style="display: none; text-align: center; padding: 40px; color: var(--text-secondary);">
|
||||
<div class="loading-spinner" style="border: 4px solid rgba(var(--primary-rgb), 0.1); border-top: 4px solid var(--primary-color); border-radius: 50%; width: 40px; height: 40px; animation: spin 1s linear infinite; margin: 0 auto 16px;"></div>
|
||||
<div>${i18n.t('tool.historyToday.loading', '正在获取数据,请稍候...')}</div>
|
||||
</div>
|
||||
|
||||
<div class="error-message" id="history-today-error" style="display: none; background: rgba(220, 53, 69, 0.1); border: 1px solid rgba(220, 53, 69, 0.3); border-radius: 8px; padding: 16px; color: #dc3545; margin-bottom: 20px;"></div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
init() {
|
||||
this._log('历史今天工具初始化');
|
||||
|
||||
this.dom = {
|
||||
currentDate: document.getElementById('history-today-current-date'),
|
||||
refreshBtn: document.getElementById('history-today-refresh-btn'),
|
||||
eventsList: document.getElementById('history-today-events-list'),
|
||||
loading: document.getElementById('history-today-loading'),
|
||||
error: document.getElementById('history-today-error')
|
||||
};
|
||||
|
||||
this.displayCurrentDate();
|
||||
this.dom.refreshBtn.addEventListener('click', () => {
|
||||
this.fetchHistoryEvents();
|
||||
});
|
||||
|
||||
// 返回工具箱按钮
|
||||
document.getElementById('back-to-toolbox-btn')?.addEventListener('click', () => {
|
||||
window.mainPage.navigateTo('toolbox');
|
||||
window.mainPage.updateActiveNavButton(document.getElementById('toolbox-btn'));
|
||||
});
|
||||
|
||||
// 自动加载数据
|
||||
this.fetchHistoryEvents();
|
||||
}
|
||||
|
||||
displayCurrentDate() {
|
||||
const now = new Date();
|
||||
const year = now.getFullYear();
|
||||
const month = String(now.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(now.getDate()).padStart(2, '0');
|
||||
const dateStr = i18n.t('tool.historyToday.dateFormat', '{year}年{month}月{day}日', { year, month, day });
|
||||
this.dom.currentDate.textContent = dateStr;
|
||||
}
|
||||
|
||||
async fetchHistoryEvents() {
|
||||
this.showLoading();
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
const response = await fetch(`${this.apiUrl}?type=json`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP Error! Status code: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const responseTime = Date.now() - startTime;
|
||||
|
||||
if (data.code === 200 && data.data) {
|
||||
this.showEvents(data);
|
||||
this._log(`成功获取历史事件,耗时 ${responseTime}ms`);
|
||||
} else {
|
||||
this.showError(data.msg || i18n.t('tool.historyToday.fetchFailed', '获取数据失败'));
|
||||
this._log(`获取历史事件失败: ${data.msg || '未知错误'}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('API请求错误:', error);
|
||||
this.showError(i18n.t('tool.historyToday.fetchFailed', '获取数据失败') + ': ' + error.message);
|
||||
this._log(`获取历史事件失败: ${error.message}`);
|
||||
} finally {
|
||||
this.hideLoading();
|
||||
}
|
||||
}
|
||||
|
||||
showEvents(data) {
|
||||
this.dom.eventsList.innerHTML = '';
|
||||
|
||||
if (data.time) {
|
||||
this.dom.currentDate.textContent = data.time;
|
||||
}
|
||||
|
||||
if (Array.isArray(data.data) && data.data.length > 0) {
|
||||
data.data.forEach(event => {
|
||||
const yearMatch = event.match(/^(\d+年)/);
|
||||
const year = yearMatch ? yearMatch[1] : i18n.t('tool.historyToday.unknownYear', '未知年份');
|
||||
const content = event;
|
||||
|
||||
const eventItem = document.createElement('div');
|
||||
eventItem.className = 'event-item';
|
||||
eventItem.style.cssText = 'display: flex; flex-direction: column; gap: 8px; padding: 20px; background: var(--card-bg); border: 1px solid var(--border-color); border-radius: 8px; transition: all 0.3s ease;';
|
||||
|
||||
eventItem.innerHTML = `
|
||||
<div class="event-year" style="font-size: 14px; font-weight: 600; color: var(--text-primary);">${year}</div>
|
||||
<div class="event-content" style="font-size: 16px; color: var(--text-secondary); line-height: 1.5;">${content}</div>
|
||||
`;
|
||||
|
||||
eventItem.addEventListener('mouseenter', () => {
|
||||
eventItem.style.background = 'rgba(var(--primary-rgb), 0.05)';
|
||||
eventItem.style.borderColor = 'var(--primary-color)';
|
||||
eventItem.style.transform = 'translateX(8px)';
|
||||
});
|
||||
|
||||
eventItem.addEventListener('mouseleave', () => {
|
||||
eventItem.style.background = 'var(--card-bg)';
|
||||
eventItem.style.borderColor = 'var(--border-color)';
|
||||
eventItem.style.transform = 'translateX(0)';
|
||||
});
|
||||
|
||||
this.dom.eventsList.appendChild(eventItem);
|
||||
});
|
||||
} else {
|
||||
this.dom.eventsList.innerHTML = `<div style="text-align: center; padding: 40px; color: var(--text-secondary);">${i18n.t('tool.historyToday.noEvents', '暂无历史事件')}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
showLoading() {
|
||||
this.dom.refreshBtn.disabled = true;
|
||||
this.dom.refreshBtn.innerHTML = `<i class="fas fa-spinner fa-spin"></i> ${i18n.t('tool.historyToday.refreshing', '刷新中...')}`;
|
||||
this.dom.loading.style.display = 'block';
|
||||
this.dom.eventsList.innerHTML = '';
|
||||
this.dom.error.style.display = 'none';
|
||||
}
|
||||
|
||||
hideLoading() {
|
||||
this.dom.refreshBtn.disabled = false;
|
||||
this.dom.refreshBtn.innerHTML = `<i class="fas fa-sync-alt"></i> ${i18n.t('tool.historyToday.refresh', '刷新数据')}`;
|
||||
this.dom.loading.style.display = 'none';
|
||||
}
|
||||
|
||||
showError(message) {
|
||||
this.dom.error.textContent = message;
|
||||
this.dom.error.style.display = 'block';
|
||||
this.dom.eventsList.innerHTML = '';
|
||||
this.dom.loading.style.display = 'none';
|
||||
this.dom.refreshBtn.disabled = false;
|
||||
this.dom.refreshBtn.innerHTML = `<i class="fas fa-sync-alt"></i> ${i18n.t('tool.historyToday.refresh', '刷新数据')}`;
|
||||
}
|
||||
}
|
||||
|
||||
export default HistoryTodayTool;
|
||||
@@ -0,0 +1,100 @@
|
||||
import BaseTool from '../baseTool.js';
|
||||
|
||||
export default class HmacGeneratorTool extends BaseTool {
|
||||
constructor() {
|
||||
super('hmac-generator', 'HMAC 生成器');
|
||||
}
|
||||
|
||||
render() {
|
||||
return `
|
||||
<div class="page-container" style="padding: 20px; display: flex; flex-direction: column; height: 100%;">
|
||||
<div class="section-header">
|
||||
<button class="back-btn ripple" id="back-btn"><i class="fas fa-arrow-left"></i> 返回</button>
|
||||
<h1>${this.name}</h1>
|
||||
</div>
|
||||
|
||||
<div class="tool-island-container" style="max-width: 800px; margin: 20px auto; width: 100%; display: flex; flex-direction: column; gap: 20px;">
|
||||
|
||||
<div class="tool-island-card" style="padding: 0; overflow: hidden; display: flex; flex-direction: column;">
|
||||
<div style="padding: 15px 20px; background: rgba(var(--bg-color-rgb), 0.3); border-bottom: 1px solid var(--border-color); font-size: 13px; font-weight: 600; color: var(--text-secondary);">
|
||||
<i class="fas fa-comment-alt"></i> 消息文本 (Message)
|
||||
</div>
|
||||
<textarea id="hmac-msg" class="common-textarea" rows="4" placeholder="在此输入需要哈希的消息内容..."
|
||||
style="border: none; border-radius: 0; background: transparent; padding: 20px; resize: vertical; min-height: 120px; box-shadow: none;"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="tool-island-card" style="padding: 10px; display: flex; align-items: center; gap: 10px; flex-wrap: wrap; background: rgba(var(--card-background-rgb), 0.9);">
|
||||
|
||||
<div style="flex: 2; min-width: 200px; position: relative; background: rgba(var(--bg-color-rgb), 0.5); border-radius: 10px; height: 50px; display: flex; align-items: center; padding: 0 15px;">
|
||||
<i class="fas fa-key" style="color: var(--text-secondary); margin-right: 10px; opacity: 0.7;"></i>
|
||||
<input type="text" id="hmac-key" placeholder="输入密钥 (Secret Key)"
|
||||
style="border: none; background: transparent; width: 100%; height: 100%; color: var(--text-color); outline: none; font-size: 14px;">
|
||||
</div>
|
||||
|
||||
<div style="flex: 1; min-width: 140px; position: relative; height: 50px; background: rgba(var(--bg-color-rgb), 0.5); border-radius: 10px; overflow: hidden;">
|
||||
<select id="hmac-algo" style="width: 100%; height: 100%; border: none; background: transparent; padding: 0 15px; color: var(--text-color); outline: none; appearance: none; -webkit-appearance: none; font-size: 14px; font-weight: 600; cursor: pointer;">
|
||||
<option value="SHA-1">SHA-1</option>
|
||||
<option value="SHA-256" selected>SHA-256</option>
|
||||
<option value="SHA-384">SHA-384</option>
|
||||
<option value="SHA-512">SHA-512</option>
|
||||
</select>
|
||||
<i class="fas fa-chevron-down" style="position: absolute; right: 15px; top: 50%; transform: translateY(-50%); pointer-events: none; opacity: 0.5; font-size: 12px;"></i>
|
||||
</div>
|
||||
|
||||
<button id="btn-gen-hmac" class="action-btn ripple" style="height: 50px; padding: 0 30px; border-radius: 10px; font-weight: 600; white-space: nowrap; flex-shrink: 0;">
|
||||
<i class="fas fa-fingerprint"></i> 生成
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="tool-island-card" style="padding: 20px; display: flex; flex-direction: column; gap: 10px; transition: all 0.3s;">
|
||||
<div style="font-size: 12px; color: var(--text-secondary); margin-left: 5px;">计算结果 (Hex)</div>
|
||||
<div style="display: flex; gap: 10px; background: rgba(var(--bg-color-rgb), 0.3); padding: 5px; border-radius: 12px; border: 1px solid var(--border-color);">
|
||||
<input type="text" id="hmac-output" readonly
|
||||
style="flex: 1; border: none; background: transparent; padding: 10px; font-family: 'Consolas', monospace; color: var(--primary-color); font-size: 15px; outline: none;">
|
||||
<button id="btn-copy-hmac" class="control-btn ripple" style="width: 50px; height: 40px; border-radius: 8px; margin: auto 5px;" title="复制">
|
||||
<i class="fas fa-copy"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
init() {
|
||||
document.getElementById('back-btn').addEventListener('click', () => window.mainPage.navigateTo('toolbox'));
|
||||
|
||||
const computeHMAC = async () => {
|
||||
const message = document.getElementById('hmac-msg').value;
|
||||
const secret = document.getElementById('hmac-key').value;
|
||||
const algo = document.getElementById('hmac-algo').value;
|
||||
|
||||
if (!message || !secret) return this._notify('提示', '请输入消息和密钥', 'info');
|
||||
|
||||
try {
|
||||
const enc = new TextEncoder();
|
||||
const keyData = await window.crypto.subtle.importKey(
|
||||
"raw", enc.encode(secret),
|
||||
{ name: "HMAC", hash: algo },
|
||||
false, ["sign"]
|
||||
);
|
||||
const signature = await window.crypto.subtle.sign(
|
||||
"HMAC", keyData, enc.encode(message)
|
||||
);
|
||||
|
||||
const hashArray = Array.from(new Uint8Array(signature));
|
||||
const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
|
||||
document.getElementById('hmac-output').value = hashHex;
|
||||
} catch (e) {
|
||||
this._notify('错误', '生成失败: ' + e.message, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
document.getElementById('btn-gen-hmac').addEventListener('click', computeHMAC);
|
||||
document.getElementById('btn-copy-hmac').addEventListener('click', () => {
|
||||
const val = document.getElementById('hmac-output').value;
|
||||
if (val) navigator.clipboard.writeText(val).then(() => this._notify('成功', '已复制'));
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
// src/js/tools/hotboardTool.js
|
||||
import BaseTool from '../baseTool.js';
|
||||
|
||||
class HotboardTool extends BaseTool {
|
||||
constructor() {
|
||||
super('hotboard', '多平台实时热榜');
|
||||
this.abortController = null;
|
||||
|
||||
// [新增] 定义所有平台及其分类
|
||||
this.platformCategories = [
|
||||
{
|
||||
name: '视频/社区',
|
||||
platforms: [
|
||||
{ id: 'bilibili', name: '哔哩哔哩' }, { id: 'acfun', name: 'A站' },
|
||||
{ id: 'weibo', name: '新浪微博' }, { id: 'zhihu', name: '知乎热榜' },
|
||||
{ id: 'zhihu-daily', name: '知乎日报' }, { id: 'douyin', name: '抖音热榜' },
|
||||
{ id: 'kuaishou', name: '快手热榜' }, { id: 'douban-movie', name: '豆瓣电影' },
|
||||
{ id: 'douban-group', name: '豆瓣小组' }, { id: 'tieba', name: '百度贴吧' },
|
||||
{ id: 'hupu', name: '虎扑热帖' }, { id: 'miyoushe', name: '米游社' },
|
||||
{ id: 'ngabbs', name: 'NGA论坛' }, { id: 'v2ex', name: 'V2EX' },
|
||||
{ id: '52pojie', name: '吾爱破解' }, { id: 'hostloc', name: '全球主机论坛' },
|
||||
{ id: 'coolapk', name: '酷安热榜' }
|
||||
]
|
||||
},
|
||||
{
|
||||
name: '新闻/资讯',
|
||||
platforms: [
|
||||
{ id: 'baidu', name: '百度热搜' }, { id: 'thepaper', name: '澎湃新闻' },
|
||||
{ id: 'toutiao', name: '今日头条' }, { id: 'qq-news', name: '腾讯新闻' },
|
||||
{ id: 'sina', name: '新浪热搜' }, { id: 'sina-news', name: '新浪新闻' },
|
||||
{ id: 'netease-news', name: '网易新闻' }, { id: 'huxiu', name: '虎嗅网' },
|
||||
{ id: 'ifanr', name: '爱范儿' }
|
||||
]
|
||||
},
|
||||
{
|
||||
name: '技术/IT',
|
||||
platforms: [
|
||||
{ id: 'sspai', name: '少数派' }, { id: 'ithome', name: 'IT之家' },
|
||||
{ id: 'ithome-xijiayi', name: 'IT之家·喜加一' }, { id: 'juejin', name: '掘金' },
|
||||
{ id: 'jianshu', name: '简书' }, { id: 'guokr', name: '果壳' },
|
||||
{ id: '36kr', name: '36氪' }, { id: '51cto', name: '51CTO' },
|
||||
{ id: 'csdn', name: 'CSDN' }, { id: 'nodeseek', name: 'NodeSeek' },
|
||||
{ id: 'hellogithub', name: 'HelloGitHub' }
|
||||
]
|
||||
},
|
||||
{
|
||||
name: '游戏',
|
||||
platforms: [
|
||||
{ id: 'lol', name: '英雄联盟' }, { id: 'genshin', name: '原神' },
|
||||
{ id: 'honkai', name: '崩坏3' }, { id: 'starrail', name: '星穹铁道' }
|
||||
]
|
||||
},
|
||||
{
|
||||
name: '其他',
|
||||
platforms: [
|
||||
{ id: 'weread', name: '微信读书' }, { id: 'weatheralarm', name: '天气预警' },
|
||||
{ id: 'earthquake', name: '地震速报' }, { id: 'history', name: '历史上的今天' }
|
||||
]
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
render() {
|
||||
// [修改] 将 options 的 HTML 移出
|
||||
const optionsHtml = `
|
||||
<div id="hotboard-select-options" class="custom-select-options" style="max-height: 400px;">
|
||||
${this.platformCategories.map(category => `
|
||||
<div class="custom-select-group">
|
||||
<div class="custom-select-group-title">${category.name}</div>
|
||||
${category.platforms.map(p => `<div class="custom-select-option" data-value="${p.id}">${p.name}</div>`).join('')}
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
`;
|
||||
|
||||
return `
|
||||
<div class="page-container" style="display: flex; flex-direction: column; height: 100%;">
|
||||
<div class="content-area" style="padding: 20px; flex-grow: 1; display: flex; flex-direction: column; gap: 20px; overflow-y: hidden;">
|
||||
<div class="settings-section" style="padding: 20px; display: flex; flex-direction: column; height: 100%; min-height: 0;">
|
||||
<h2><i class="fas fa-fire-alt"></i> 多平台实时热榜</h2>
|
||||
|
||||
<div class="ip-input-group" style="margin-bottom: 20px; flex-shrink: 0;">
|
||||
|
||||
<div id="hotboard-select-wrapper" class="custom-select-wrapper" style="flex-grow: 3;">
|
||||
<div id="hotboard-select-trigger" class="custom-select-trigger" data-value="bilibili">
|
||||
<span>哔哩哔哩</span>
|
||||
<i class="fas fa-chevron-down custom-select-arrow"></i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button id="hotboard-query-btn" class="action-btn ripple" style="flex-grow: 1;"><i class="fas fa-sync-alt"></i> 刷新</button>
|
||||
</div>
|
||||
|
||||
<div id="hotboard-results-container" style="flex-grow: 1; min-height: 0; overflow-y: auto;">
|
||||
<div class="loading-container">
|
||||
<p class="loading-text">请选择平台后点击刷新</p>
|
||||
</div>
|
||||
</div>
|
||||
<p id="hotboard-update-time" class="comparison-note" style="text-align: right; margin-top: 10px; flex-shrink: 0;"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
${optionsHtml} `;
|
||||
}
|
||||
|
||||
init() {
|
||||
this._log('工具已初始化');
|
||||
|
||||
this.dom = {
|
||||
queryBtn: document.getElementById('hotboard-query-btn'),
|
||||
resultsContainer: document.getElementById('hotboard-results-container'),
|
||||
updateTimeEl: document.getElementById('hotboard-update-time'),
|
||||
selectWrapper: document.getElementById('hotboard-select-wrapper'),
|
||||
selectTrigger: document.getElementById('hotboard-select-trigger'),
|
||||
selectOptionsContainer: document.getElementById('hotboard-select-options'),
|
||||
selectOptions: document.querySelectorAll('#hotboard-select-options .custom-select-option') // [修改] 确保选择器正确
|
||||
};
|
||||
|
||||
// [新增] 将 options 元素传送到 body,防止被裁剪
|
||||
if (this.dom.selectOptionsContainer) {
|
||||
document.body.appendChild(this.dom.selectOptionsContainer);
|
||||
}
|
||||
|
||||
this.dom.queryBtn.addEventListener('click', this._handleQuery.bind(this));
|
||||
|
||||
// [修改] 替换为新的、可感知边界的下拉菜单逻辑
|
||||
this.dom.selectTrigger.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
|
||||
// 关闭所有其他打开的下拉菜单
|
||||
document.querySelectorAll('.custom-select-options.dynamic-active').forEach(openDropdown => {
|
||||
if (openDropdown !== this.dom.selectOptionsContainer) {
|
||||
openDropdown.classList.remove('dynamic-active');
|
||||
}
|
||||
});
|
||||
|
||||
// 计算位置
|
||||
const rect = this.dom.selectTrigger.getBoundingClientRect();
|
||||
const optionsEl = this.dom.selectOptionsContainer;
|
||||
const optionsHeight = optionsEl.offsetHeight;
|
||||
const windowHeight = window.innerHeight;
|
||||
|
||||
if (rect.bottom + optionsHeight + 5 > windowHeight && rect.top > optionsHeight + 5) {
|
||||
// 向上
|
||||
optionsEl.style.top = `${rect.top - optionsHeight - 5}px`;
|
||||
optionsEl.style.bottom = 'auto';
|
||||
optionsEl.style.transformOrigin = 'bottom center';
|
||||
} else {
|
||||
// 向下
|
||||
optionsEl.style.top = `${rect.bottom + 5}px`;
|
||||
optionsEl.style.bottom = 'auto';
|
||||
optionsEl.style.transformOrigin = 'top center';
|
||||
}
|
||||
|
||||
optionsEl.style.left = `${rect.left}px`;
|
||||
optionsEl.style.width = `${rect.width}px`;
|
||||
|
||||
// 切换当前
|
||||
optionsEl.classList.toggle('dynamic-active');
|
||||
});
|
||||
|
||||
this.dom.selectOptions.forEach(option => {
|
||||
option.addEventListener('click', () => {
|
||||
this.dom.selectTrigger.querySelector('span').textContent = option.textContent;
|
||||
this.dom.selectTrigger.dataset.value = option.dataset.value;
|
||||
|
||||
// [修改] 现在使用 .dynamic-active
|
||||
this.dom.selectOptionsContainer.classList.remove('dynamic-active');
|
||||
|
||||
this._handleQuery(); // 选择后立即查询
|
||||
});
|
||||
});
|
||||
|
||||
// [修改] 全局点击监听器现在由 mainPage.js 或 view-tool.html 统一处理
|
||||
|
||||
// 默认加载一次 B站
|
||||
this._handleQuery();
|
||||
}
|
||||
|
||||
async _handleQuery() {
|
||||
const type = this.dom.selectTrigger.dataset.value;
|
||||
if (!type) {
|
||||
this._notify('错误', '未选择任何热榜平台', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.abortController) {
|
||||
this.abortController.abort();
|
||||
}
|
||||
this.abortController = new AbortController();
|
||||
|
||||
this.dom.queryBtn.disabled = true;
|
||||
this.dom.queryBtn.innerHTML = '<i class="fas fa-spinner fa-spin"></i>';
|
||||
|
||||
// [修改]
|
||||
// 将 <img ...> 替换为自定义的 .pacman-loader HTML
|
||||
this.dom.resultsContainer.innerHTML = `
|
||||
<div class="loading-container">
|
||||
<div class="pacman-loader">
|
||||
<div></div><div></div><div></div><div></div><div></div>
|
||||
</div>
|
||||
<p class="loading-text">正在获取 ${this.dom.selectTrigger.querySelector('span').textContent}...</p>
|
||||
</div>`;
|
||||
|
||||
this.dom.updateTimeEl.textContent = '';
|
||||
|
||||
try {
|
||||
const apiUrl = `https://uapis.cn/api/v1/misc/hotboard?type=${type}`;
|
||||
const response = await fetch(apiUrl, { signal: this.abortController.signal });
|
||||
|
||||
const blob = await response.blob();
|
||||
window.electronAPI.addTraffic(blob.size);
|
||||
const json = JSON.parse(await blob.text());
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(json.message || `API 请求失败: ${response.status}`);
|
||||
}
|
||||
|
||||
this._renderResults(json);
|
||||
this.dom.updateTimeEl.textContent = `数据更新于: ${json.update_time || 'N/A'}`;
|
||||
this._log(`热榜查询成功: ${type}`);
|
||||
|
||||
} catch (error) {
|
||||
if (error.name === 'AbortError') {
|
||||
this._log('查询请求被中止');
|
||||
this.dom.resultsContainer.innerHTML = `<p class="loading-text" style="text-align: center;">查询已取消</p>`;
|
||||
} else {
|
||||
this._notify('查询失败', error.message, 'error');
|
||||
this._log(`查询失败: ${error.message}`);
|
||||
this.dom.resultsContainer.innerHTML = `<p class="error-message" style="text-align: center;"><i class="fas fa-exclamation-triangle"></i> ${error.message}</p>`;
|
||||
}
|
||||
} finally {
|
||||
this.dom.queryBtn.disabled = false;
|
||||
this.dom.queryBtn.innerHTML = '<i class="fas fa-sync-alt"></i> 刷新';
|
||||
this.abortController = null;
|
||||
}
|
||||
}
|
||||
|
||||
_renderResults(data) {
|
||||
if (!data.list || data.list.length === 0) {
|
||||
this.dom.resultsContainer.innerHTML = `<p class="error-message" style="text-align: center;">未查询到 ${data.type} 的热榜记录。</p>`;
|
||||
return;
|
||||
}
|
||||
|
||||
const tableRows = data.list.map(item => {
|
||||
const rank = item.index;
|
||||
const title = item.title;
|
||||
// [安全修复] 确保 URL 是字符串
|
||||
let searchUrl = item.url || '';
|
||||
const hotValue = item.hot_value ? parseFloat(item.hot_value) : 0;
|
||||
|
||||
let formattedHotValue = hotValue;
|
||||
if (hotValue > 1000000) {
|
||||
formattedHotValue = (hotValue / 10000).toFixed(1) + 'w';
|
||||
} else if (hotValue > 1000) {
|
||||
formattedHotValue = (hotValue / 1000).toFixed(1) + 'k';
|
||||
}
|
||||
|
||||
let rankClass = '';
|
||||
if (rank <= 3) {
|
||||
rankClass = `rank-top-3 rank-${rank}`;
|
||||
}
|
||||
|
||||
let extraTag = '';
|
||||
let tagText = item.extra?.tag || (typeof item.extra === 'string' ? item.extra : null);
|
||||
if (tagText) {
|
||||
const tagClass = (tagText === '爆' || tagText === '热') ? 'hotboard-tag tag-hot' : 'hotboard-tag tag-new';
|
||||
extraTag = `<span class="${tagClass}">${tagText}</span>`;
|
||||
}
|
||||
|
||||
// [重要] 将 URL 放在行元素 data-link 上,方便整行点击
|
||||
// [UI] 移除 a 标签,改用 span,防止样式冲突,由 JS 统一处理跳转
|
||||
return `
|
||||
<tr data-link="${searchUrl}">
|
||||
<td style="width: 50px; text-align: center;">
|
||||
<span class="hotboard-rank ${rankClass}">${rank}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="hotboard-title-link">${title}</span>
|
||||
${extraTag}
|
||||
</td>
|
||||
<td style="width: 100px; text-align: right;">
|
||||
<span class="hotboard-value">${formattedHotValue}</span>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
this.dom.resultsContainer.innerHTML = `
|
||||
<table class="ip-comparison-table hotboard-table" style="animation: contentFadeIn 0.5s;">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 50px; text-align: center;">排名</th>
|
||||
<th>标题</th>
|
||||
<th style="width: 100px; text-align: right;">热度</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${tableRows}
|
||||
</tbody>
|
||||
</table>`;
|
||||
|
||||
// [修复逻辑] 绑定整行点击事件
|
||||
this.dom.resultsContainer.querySelectorAll('tr[data-link]').forEach(row => {
|
||||
row.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
const url = row.dataset.link;
|
||||
|
||||
// [安全校验] 只有合法的 HTTP/HTTPS 链接才打开浏览器
|
||||
if (url && (url.startsWith('http://') || url.startsWith('https://'))) {
|
||||
window.electronAPI.openExternalLink(url);
|
||||
} else {
|
||||
this._notify('无法打开', '该条目没有有效的跳转链接', 'info');
|
||||
console.warn('无效链接阻止打开:', url);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (this.abortController) {
|
||||
this.abortController.abort();
|
||||
}
|
||||
|
||||
// [新增] 移除传送到 body 的元素
|
||||
document.getElementById('hotboard-select-options')?.remove();
|
||||
|
||||
this._log('工具已销毁');
|
||||
super.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
export default HotboardTool;
|
||||
@@ -0,0 +1,58 @@
|
||||
import BaseTool from '../baseTool.js';
|
||||
|
||||
export default class HtmlEntityTool extends BaseTool {
|
||||
constructor() {
|
||||
super('html-entity', 'HTML 实体转义');
|
||||
}
|
||||
|
||||
render() {
|
||||
return `
|
||||
<div class="page-container" style="padding: 20px; display: flex; flex-direction: column; gap: 15px; height: 100%;">
|
||||
<div class="section-header">
|
||||
<button class="back-btn ripple" id="back-btn"><i class="fas fa-arrow-left"></i> 返回</button>
|
||||
<h1>${this.name}</h1>
|
||||
</div>
|
||||
|
||||
<div style="flex: 1; display: flex; flex-direction: column; gap: 10px;">
|
||||
<textarea id="he-input" class="common-textarea" placeholder="输入普通文本或 HTML 代码..." style="flex: 1;"></textarea>
|
||||
|
||||
<div style="display: flex; justify-content: center; gap: 20px;">
|
||||
<button id="btn-escape" class="action-btn ripple"><i class="fas fa-code"></i> 转义 (Escape)</button>
|
||||
<button id="btn-unescape" class="action-btn ripple"><i class="fas fa-file-code"></i> 反转义 (Unescape)</button>
|
||||
</div>
|
||||
|
||||
<textarea id="he-output" class="common-textarea" placeholder="结果..." style="flex: 1;" readonly></textarea>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
init() {
|
||||
document.getElementById('back-btn').addEventListener('click', () => window.mainPage.navigateTo('toolbox'));
|
||||
|
||||
const input = document.getElementById('he-input');
|
||||
const output = document.getElementById('he-output');
|
||||
|
||||
const escapeHtml = (text) => {
|
||||
const map = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };
|
||||
return text.replace(/[&<>"']/g, function(m) { return map[m]; });
|
||||
};
|
||||
|
||||
const unescapeHtml = (text) => {
|
||||
const map = { '&': '&', '<': '<', '>': '>', '"': '"', ''': "'" };
|
||||
return text.replace(/&(amp|lt|gt|quot|#039);/g, function(m) { return map[m]; });
|
||||
};
|
||||
|
||||
document.getElementById('btn-escape').addEventListener('click', () => {
|
||||
if(!input.value) return;
|
||||
output.value = escapeHtml(input.value);
|
||||
this._log('执行HTML转义');
|
||||
});
|
||||
|
||||
document.getElementById('btn-unescape').addEventListener('click', () => {
|
||||
if(!input.value) return;
|
||||
output.value = unescapeHtml(input.value);
|
||||
this._log('执行HTML反转义');
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
// src/js/tools/imageProcessorTool.js
|
||||
import BaseTool from '../baseTool.js';
|
||||
|
||||
class ImageProcessorTool extends BaseTool {
|
||||
constructor() {
|
||||
super('image-processor', '图片处理工坊');
|
||||
this.cropper = null;
|
||||
this.filename = 'image';
|
||||
// 默认参数
|
||||
this.params = {
|
||||
scaleX: 1,
|
||||
scaleY: 1,
|
||||
rotate: 0,
|
||||
brightness: 100,
|
||||
contrast: 100
|
||||
};
|
||||
}
|
||||
|
||||
render() {
|
||||
// 动态注入样式(如果尚未存在)
|
||||
const cssId = 'cropper-css';
|
||||
if (!document.getElementById(cssId)) {
|
||||
const link = document.createElement('link');
|
||||
link.id = cssId;
|
||||
link.rel = 'stylesheet';
|
||||
link.href = 'https://cdnjs.cloudflare.com/ajax/libs/cropperjs/1.5.13/cropper.min.css';
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
|
||||
// [优化 1] 移除 header 中的返回按钮,调整布局适应子窗口
|
||||
return `
|
||||
<div class="page-container image-tool-container" style="display: flex; flex-direction: column; height: 100%; overflow: hidden;">
|
||||
|
||||
<div class="tool-window-header" style="padding: 10px 20px; display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid var(--border-color); background-color: rgba(var(--card-background-rgb), 0.5);">
|
||||
<h2 style="margin: 0; font-size: 16px;"><i class="fas fa-magic"></i> ${this.name}</h2>
|
||||
<div style="display: flex; gap: 10px;">
|
||||
<button id="btn-upload" class="control-btn mini-btn ripple"><i class="fas fa-folder-open"></i> 打开图片</button>
|
||||
<button id="img-save-btn" class="action-btn mini-btn ripple" disabled><i class="fas fa-save"></i> 保存</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="content-area" style="padding: 0; flex-grow: 1; display: flex; overflow: hidden;">
|
||||
|
||||
<div class="img-canvas-wrapper" style="flex: 1; background-color: #1e1e1e; position: relative; display: flex; align-items: center; justify-content: center; overflow: hidden;">
|
||||
<div id="img-placeholder" style="text-align: center; color: #666; cursor: pointer;">
|
||||
<i class="fas fa-image" style="font-size: 48px; margin-bottom: 10px;"></i>
|
||||
<p>拖拽图片至此 或 点击打开</p>
|
||||
</div>
|
||||
<div style="max-width: 90%; max-height: 90%; display: none;" id="cropper-img-container">
|
||||
<img id="img-editor-target" style="display: block; max-width: 100%;">
|
||||
</div>
|
||||
<input type="file" id="img-upload-input" accept="image/*" style="display:none;">
|
||||
</div>
|
||||
|
||||
<div class="img-controls-panel" style="width: 280px; background-color: var(--card-background); border-left: 1px solid var(--border-color); display: flex; flex-direction: column; padding: 15px; overflow-y: auto; gap: 20px;">
|
||||
|
||||
<div class="control-group">
|
||||
<div class="group-title">裁剪比例</div>
|
||||
<div class="tool-grid-small">
|
||||
<button class="control-btn mini-btn ripple" data-ratio="NaN">自由</button>
|
||||
<button class="control-btn mini-btn ripple" data-ratio="1">1:1</button>
|
||||
<button class="control-btn mini-btn ripple" data-ratio="1.7778">16:9</button>
|
||||
<button class="control-btn mini-btn ripple" data-ratio="1.3333">4:3</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="control-group">
|
||||
<div class="group-title">旋转与翻转</div>
|
||||
<div class="tool-grid-small">
|
||||
<button class="control-btn mini-btn ripple" id="btn-rotate-left" title="向左旋转"><i class="fas fa-undo"></i></button>
|
||||
<button class="control-btn mini-btn ripple" id="btn-rotate-right" title="向右旋转"><i class="fas fa-redo"></i></button>
|
||||
<button class="control-btn mini-btn ripple" id="btn-flip-h" title="水平翻转"><i class="fas fa-arrows-alt-h"></i></button>
|
||||
<button class="control-btn mini-btn ripple" id="btn-flip-v" title="垂直翻转"><i class="fas fa-arrows-alt-v"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="control-group">
|
||||
<div class="group-title">色彩调整 (保存生效)</div>
|
||||
<div class="setting-item">
|
||||
<div class="label-row"><span>亮度</span><span id="val-brightness">100%</span></div>
|
||||
<input type="range" id="filter-brightness" min="0" max="200" value="100">
|
||||
</div>
|
||||
<div class="setting-item">
|
||||
<div class="label-row"><span>对比度</span><span id="val-contrast">100%</span></div>
|
||||
<input type="range" id="filter-contrast" min="0" max="200" value="100">
|
||||
</div>
|
||||
<button class="control-btn mini-btn ripple" id="btn-reset-filters" style="width: 100%; margin-top: 5px;">重置参数</button>
|
||||
</div>
|
||||
|
||||
<div class="control-group">
|
||||
<div class="group-title">导出设置</div>
|
||||
<div class="setting-item">
|
||||
<span>格式</span>
|
||||
<select id="img-format" class="settings-input-text" style="width: 100%;">
|
||||
<option value="image/jpeg">JPG (推荐)</option>
|
||||
<option value="image/png">PNG (无损)</option>
|
||||
<option value="image/webp">WEBP</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="setting-item">
|
||||
<div class="label-row"><span>质量</span><span id="val-quality">90%</span></div>
|
||||
<input type="range" id="img-quality" min="10" max="100" value="90">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<style>
|
||||
.group-title { font-size: 12px; font-weight: 600; color: var(--text-secondary); margin-bottom: 8px; text-transform: uppercase; }
|
||||
.tool-grid-small { display: grid; grid-template-columns: repeat(4, 1fr); gap: 5px; }
|
||||
.setting-item { margin-bottom: 10px; }
|
||||
.label-row { display: flex; justify-content: space-between; font-size: 12px; margin-bottom: 4px; }
|
||||
input[type=range] { width: 100%; }
|
||||
</style>
|
||||
`;
|
||||
}
|
||||
|
||||
init() {
|
||||
this._log('图片处理工坊初始化...');
|
||||
this.dom = {
|
||||
uploadInput: document.getElementById('img-upload-input'),
|
||||
uploadPlaceholder: document.getElementById('img-placeholder'),
|
||||
imgContainer: document.getElementById('cropper-img-container'),
|
||||
image: document.getElementById('img-editor-target'),
|
||||
saveBtn: document.getElementById('img-save-btn'),
|
||||
|
||||
brightness: document.getElementById('filter-brightness'),
|
||||
contrast: document.getElementById('filter-contrast'),
|
||||
valBrightness: document.getElementById('val-brightness'),
|
||||
valContrast: document.getElementById('val-contrast'),
|
||||
|
||||
format: document.getElementById('img-format'),
|
||||
quality: document.getElementById('img-quality'),
|
||||
valQuality: document.getElementById('val-quality')
|
||||
};
|
||||
|
||||
// 绑定上传事件
|
||||
document.getElementById('btn-upload').addEventListener('click', () => this.dom.uploadInput.click());
|
||||
this.dom.uploadPlaceholder.addEventListener('click', () => this.dom.uploadInput.click());
|
||||
this.dom.uploadInput.addEventListener('change', (e) => this._handleFile(e.target.files[0]));
|
||||
|
||||
// 拖拽支持
|
||||
const wrapper = document.querySelector('.img-canvas-wrapper');
|
||||
wrapper.addEventListener('dragover', (e) => e.preventDefault());
|
||||
wrapper.addEventListener('drop', (e) => {
|
||||
e.preventDefault();
|
||||
if(e.dataTransfer.files.length) this._handleFile(e.dataTransfer.files[0]);
|
||||
});
|
||||
|
||||
// 绑定控制按钮
|
||||
this._bindControls();
|
||||
}
|
||||
|
||||
_handleFile(file) {
|
||||
if (!file || !file.type.startsWith('image/')) {
|
||||
return this._notify('错误', '请选择有效的图片文件', 'error');
|
||||
}
|
||||
this.filename = file.name.replace(/\.[^/.]+$/, ""); // 去除后缀
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
this.dom.image.src = e.target.result;
|
||||
this._initCropper();
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
|
||||
_initCropper() {
|
||||
// 销毁旧实例
|
||||
if (this.cropper) {
|
||||
this.cropper.destroy();
|
||||
}
|
||||
|
||||
this.dom.uploadPlaceholder.style.display = 'none';
|
||||
this.dom.imgContainer.style.display = 'block';
|
||||
this.dom.saveBtn.disabled = false;
|
||||
|
||||
// 重置参数
|
||||
this.params = { scaleX: 1, scaleY: 1, rotate: 0, brightness: 100, contrast: 100 };
|
||||
this._updateFilterUI();
|
||||
|
||||
this.cropper = new Cropper(this.dom.image, {
|
||||
viewMode: 1, // 限制裁剪框在画布内
|
||||
dragMode: 'move',
|
||||
autoCropArea: 0.9,
|
||||
restore: false,
|
||||
guides: true,
|
||||
center: true,
|
||||
highlight: false,
|
||||
cropBoxMovable: true,
|
||||
cropBoxResizable: true,
|
||||
toggleDragModeOnDblclick: false,
|
||||
ready: () => {
|
||||
this._log('Cropper 就绪');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
_bindControls() {
|
||||
// 比例按钮
|
||||
document.querySelectorAll('button[data-ratio]').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
if(!this.cropper) return;
|
||||
const ratio = parseFloat(btn.dataset.ratio);
|
||||
this.cropper.setAspectRatio(ratio);
|
||||
});
|
||||
});
|
||||
|
||||
// 旋转翻转
|
||||
const wrap = (fn) => () => { if(this.cropper) fn(); };
|
||||
document.getElementById('btn-rotate-left').addEventListener('click', wrap(() => this.cropper.rotate(-90)));
|
||||
document.getElementById('btn-rotate-right').addEventListener('click', wrap(() => this.cropper.rotate(90)));
|
||||
document.getElementById('btn-flip-h').addEventListener('click', wrap(() => {
|
||||
this.params.scaleX = -this.params.scaleX;
|
||||
this.cropper.scaleX(this.params.scaleX);
|
||||
}));
|
||||
document.getElementById('btn-flip-v').addEventListener('click', wrap(() => {
|
||||
this.params.scaleY = -this.params.scaleY;
|
||||
this.cropper.scaleY(this.params.scaleY);
|
||||
}));
|
||||
|
||||
// 滤镜滑块实时预览 (CSS 预览,不影响 crop 数据,保存时才真正应用)
|
||||
const updateFilterPreview = () => {
|
||||
this.params.brightness = this.dom.brightness.value;
|
||||
this.params.contrast = this.dom.contrast.value;
|
||||
this._updateFilterUI();
|
||||
|
||||
// 将 CSS 滤镜应用到 Cropper 的 canvas 容器上以实现预览
|
||||
const cropperCanvas = document.querySelector('.cropper-canvas');
|
||||
if (cropperCanvas) {
|
||||
cropperCanvas.style.filter = `brightness(${this.params.brightness}%) contrast(${this.params.contrast}%)`;
|
||||
}
|
||||
};
|
||||
this.dom.brightness.addEventListener('input', updateFilterPreview);
|
||||
this.dom.contrast.addEventListener('input', updateFilterPreview);
|
||||
|
||||
document.getElementById('btn-reset-filters').addEventListener('click', () => {
|
||||
this.params.brightness = 100;
|
||||
this.params.contrast = 100;
|
||||
this._updateFilterUI();
|
||||
updateFilterPreview();
|
||||
});
|
||||
|
||||
// 质量滑块
|
||||
this.dom.quality.addEventListener('input', (e) => {
|
||||
this.dom.valQuality.textContent = e.target.value + '%';
|
||||
});
|
||||
|
||||
// 保存
|
||||
this.dom.saveBtn.addEventListener('click', () => this._save());
|
||||
}
|
||||
|
||||
_updateFilterUI() {
|
||||
this.dom.brightness.value = this.params.brightness;
|
||||
this.dom.contrast.value = this.params.contrast;
|
||||
this.dom.valBrightness.textContent = this.params.brightness + '%';
|
||||
this.dom.valContrast.textContent = this.params.contrast + '%';
|
||||
}
|
||||
|
||||
async _save() {
|
||||
if (!this.cropper) return;
|
||||
|
||||
this.dom.saveBtn.disabled = true;
|
||||
this.dom.saveBtn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> 处理中...';
|
||||
|
||||
try {
|
||||
// 1. 获取裁剪后的基础 Canvas
|
||||
const sourceCanvas = this.cropper.getCroppedCanvas({
|
||||
imageSmoothingQuality: 'high'
|
||||
});
|
||||
|
||||
if (!sourceCanvas) throw new Error('无法生成图片数据');
|
||||
|
||||
// 2. 创建处理滤镜用的新 Canvas
|
||||
// 必须手动绘制,因为 cropper.js 导出的 canvas 不包含 CSS filter
|
||||
const finalCanvas = document.createElement('canvas');
|
||||
finalCanvas.width = sourceCanvas.width;
|
||||
finalCanvas.height = sourceCanvas.height;
|
||||
const ctx = finalCanvas.getContext('2d');
|
||||
|
||||
// 3. 应用滤镜
|
||||
// 注意:Canvas filter 语法与 CSS 略有不同,数值通常是百分比字符串
|
||||
// 检查浏览器支持情况,Electron (Chromium) 支持 ctx.filter
|
||||
ctx.filter = `brightness(${this.params.brightness}%) contrast(${this.params.contrast}%)`;
|
||||
|
||||
// 4. 绘制
|
||||
ctx.drawImage(sourceCanvas, 0, 0);
|
||||
|
||||
// 5. 导出
|
||||
const format = this.dom.format.value;
|
||||
const quality = parseInt(this.dom.quality.value) / 100;
|
||||
|
||||
finalCanvas.toBlob(async (blob) => {
|
||||
if (!blob) {
|
||||
this.dom.saveBtn.disabled = false;
|
||||
return this._notify('错误', '生成 Blob 失败', 'error');
|
||||
}
|
||||
|
||||
const arrayBuffer = await blob.arrayBuffer();
|
||||
const ext = format.split('/')[1];
|
||||
const defaultPath = `${this.filename}_edited.${ext}`;
|
||||
|
||||
const result = await window.electronAPI.saveMedia({
|
||||
buffer: arrayBuffer,
|
||||
defaultPath: defaultPath
|
||||
});
|
||||
|
||||
this.dom.saveBtn.disabled = false;
|
||||
this.dom.saveBtn.innerHTML = '<i class="fas fa-save"></i> 保存';
|
||||
|
||||
if (result.success) {
|
||||
this._notify('成功', '图片已保存', 'success');
|
||||
} else if (result.error !== '用户取消保存') {
|
||||
this._notify('失败', result.error, 'error');
|
||||
}
|
||||
|
||||
}, format, quality);
|
||||
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
this._notify('错误', error.message, 'error');
|
||||
this.dom.saveBtn.disabled = false;
|
||||
this.dom.saveBtn.innerHTML = '<i class="fas fa-save"></i> 保存';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default ImageProcessorTool;
|
||||
@@ -0,0 +1,172 @@
|
||||
// src/js/tools/ipInfoTool.js
|
||||
import BaseTool from '../baseTool.js';
|
||||
|
||||
class IpInfoTool extends BaseTool {
|
||||
constructor() {
|
||||
super('ip-info', 'IP/域名归属查询');
|
||||
this.dom = {};
|
||||
this.abortController = null;
|
||||
}
|
||||
|
||||
render() {
|
||||
return `
|
||||
<div class="page-container" style="display: flex; flex-direction: column; height: 100%;">
|
||||
<div class="content-area" style="padding: 20px; flex-grow: 1; display: flex; flex-direction: column; gap: 20px; overflow-y: auto;">
|
||||
<div class="settings-section" style="padding: 20px;">
|
||||
<h2><i class="fas fa-search-location"></i> IP/域名归属查询</h2>
|
||||
<p class="setting-item-description" style="margin-bottom: 20px;">查询指定公网IP或域名的详细地理和网络信息。</p>
|
||||
|
||||
<div class="ip-input-group" style="margin-bottom: 20px;">
|
||||
<input type="text" id="ipi-input" placeholder="输入IP地址或域名 (例如: 8.8.8.8 或 google.com)">
|
||||
<button id="ipi-query-btn" class="action-btn ripple"><i class="fas fa-search"></i> 查询</button>
|
||||
</div>
|
||||
|
||||
<div class="settings-row" style="border-bottom: 1px solid var(--border-color); padding: 10px 0;">
|
||||
<span>使用商业级数据源</span>
|
||||
<label class="option-toggle">
|
||||
<input type="checkbox" id="ipi-source-commercial">
|
||||
<span class="slider-round"></span>
|
||||
</label>
|
||||
</div>
|
||||
<p class="setting-item-description" style="font-size: 12px; margin-top: 5px;">商业级数据源返回更详细的信息(如行政区、邮编),但响应可能稍慢。</p>
|
||||
|
||||
<div id="ipi-results-container" style="margin-top: 20px; min-height: 100px;">
|
||||
<p class="loading-text" style="text-align: center;">请输入要查询的 IP 或域名</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
init() {
|
||||
this._log('工具已初始化');
|
||||
|
||||
this.dom.input = document.getElementById('ipi-input');
|
||||
this.dom.queryBtn = document.getElementById('ipi-query-btn');
|
||||
this.dom.commercialToggle = document.getElementById('ipi-source-commercial');
|
||||
this.dom.resultsContainer = document.getElementById('ipi-results-container');
|
||||
|
||||
this.dom.queryBtn.addEventListener('click', this._handleQuery.bind(this));
|
||||
this.dom.input.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') this._handleQuery();
|
||||
});
|
||||
}
|
||||
|
||||
async _handleQuery() {
|
||||
const queryTarget = this.dom.input.value.trim();
|
||||
if (!queryTarget) {
|
||||
this._notify('输入错误', '请输入要查询的IP或域名', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const useCommercial = this.dom.commercialToggle.checked;
|
||||
const source = useCommercial ? 'commercial' : '';
|
||||
|
||||
if (this.abortController) {
|
||||
this.abortController.abort();
|
||||
}
|
||||
this.abortController = new AbortController();
|
||||
|
||||
this.dom.queryBtn.disabled = true;
|
||||
this.dom.queryBtn.innerHTML = '<i class="fas fa-spinner fa-spin"></i>';
|
||||
this.dom.resultsContainer.innerHTML = `
|
||||
<div class="loading-container">
|
||||
<img src="./assets/loading.gif" alt="查询中..." class="loading-gif">
|
||||
<p class="loading-text">正在查询: ${queryTarget}...</p>
|
||||
</div>`;
|
||||
|
||||
try {
|
||||
const apiUrl = `https://uapis.cn/api/v1/network/ipinfo?ip=${encodeURIComponent(queryTarget)}&source=${source}`;
|
||||
const response = await fetch(apiUrl, { signal: this.abortController.signal });
|
||||
|
||||
const blob = await response.blob();
|
||||
window.electronAPI.addTraffic(blob.size);
|
||||
const data = JSON.parse(await blob.text());
|
||||
|
||||
if (!response.ok || data.code !== 200) {
|
||||
throw new Error(data.message || `API 请求失败: ${response.status}`);
|
||||
}
|
||||
|
||||
this._renderResults(data);
|
||||
this._log(`IP/域名查询成功: ${queryTarget}`);
|
||||
|
||||
} catch (error) {
|
||||
if (error.name === 'AbortError') {
|
||||
this._log('查询请求被中止');
|
||||
this.dom.resultsContainer.innerHTML = `<p class="loading-text" style="text-align: center;">查询已取消</p>`;
|
||||
} else {
|
||||
this._notify('查询失败', error.message, 'error');
|
||||
this._log(`查询失败: ${error.message}`);
|
||||
this.dom.resultsContainer.innerHTML = `<p class="error-message" style="text-align: center;"><i class="fas fa-exclamation-triangle"></i> ${error.message}</p>`;
|
||||
}
|
||||
} finally {
|
||||
this.dom.queryBtn.disabled = false;
|
||||
this.dom.queryBtn.innerHTML = '<i class="fas fa-search"></i> 查询';
|
||||
this.abortController = null;
|
||||
}
|
||||
}
|
||||
|
||||
_renderResults(data) {
|
||||
const renderItem = (label, value) => (value || value === 0) ? `
|
||||
<div class="ip-result-item">
|
||||
<span class="ip-result-key">${label}</span>
|
||||
<span class="ip-result-value">${value}</span>
|
||||
</div>` : '';
|
||||
|
||||
// [新增] 检查是否存在任何“区域详情”数据
|
||||
const hasRegionalDetails = data.area_code || data.city_code || data.zip_code || data.time_zone || data.weather_station;
|
||||
|
||||
let regionalHtml = '';
|
||||
if (hasRegionalDetails) {
|
||||
regionalHtml = `
|
||||
<div class="ip-result-category">
|
||||
<h3><i class="fas fa-info-circle"></i> 区域详情</h3>
|
||||
${renderItem('行政区划代码', data.area_code)}
|
||||
${renderItem('城市区号', data.city_code)}
|
||||
${renderItem('邮政编码', data.zip_code)}
|
||||
${renderItem('时区', data.time_zone)}
|
||||
${renderItem('气象站代码', data.weather_station)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
const html = `
|
||||
<div class="ip-results-wrapper" style="animation: contentFadeIn 0.5s;">
|
||||
<div class="ip-result-grid" style="grid-template-columns: 1fr 1fr;">
|
||||
|
||||
<div class="ip-result-category">
|
||||
<h3><i class="fas fa-network-wired"></i> 网络信息</h3>
|
||||
${renderItem('查询 IP', data.ip)}
|
||||
${renderItem('运营商', data.isp)}
|
||||
${renderItem('归属', data.llc)}
|
||||
${renderItem('ASN', data.asn)}
|
||||
${renderItem('IP段 (起)', data.beginip)}
|
||||
${renderItem('IP段 (止)', data.endip)}
|
||||
${renderItem('应用场景', data.scenes)}
|
||||
</div>
|
||||
|
||||
<div class="ip-result-category">
|
||||
<h3><i class="fas fa-map-marked-alt"></i> 地理位置</h3>
|
||||
${renderItem('位置', data.region)}
|
||||
${renderItem('行政区', data.district)}
|
||||
${renderItem('纬度', data.latitude)}
|
||||
${renderItem('经度', data.longitude)}
|
||||
${renderItem('海拔 (米)', data.elevation)}
|
||||
</div>
|
||||
|
||||
${regionalHtml} </div>
|
||||
</div>`;
|
||||
this.dom.resultsContainer.innerHTML = html;
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (this.abortController) {
|
||||
this.abortController.abort();
|
||||
}
|
||||
this._log('工具已销毁');
|
||||
super.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
export default IpInfoTool;
|
||||
@@ -0,0 +1,407 @@
|
||||
// [完整替换] src/js/tools/ipQueryTool.js
|
||||
import BaseTool from '../baseTool.js';
|
||||
|
||||
// Worker 代码保持不变 (用于查询特定 IP)
|
||||
const workerCode = `
|
||||
'use strict';
|
||||
function formatCountry(countryName, regionName) {
|
||||
const normalizedCountry = (countryName || '').toLowerCase().replace('special administrative region', '').trim();
|
||||
const normalizedRegion = (regionName || '').toLowerCase();
|
||||
const specialRegions = {
|
||||
'taiwan': '中国-台湾', 'tw': '中国-台湾', 'hong kong': '中国-香港', 'hk': '中国-香港',
|
||||
'macao': '中国-澳门', 'macau': '中国-澳门', 'mo': '中国-澳门'
|
||||
};
|
||||
for (const key in specialRegions) {
|
||||
if (normalizedCountry.includes(key) || normalizedRegion.includes(key)) {
|
||||
return specialRegions[key];
|
||||
}
|
||||
}
|
||||
if ((countryName && countryName.includes('台湾')) || (regionName && regionName.includes('台湾'))) return '中国-台湾';
|
||||
if ((countryName && countryName.includes('香港')) || (regionName && regionName.includes('香港'))) return '中国-香港';
|
||||
if ((countryName && countryName.includes('澳门')) || (regionName && regionName.includes('澳门'))) return '中国-澳门';
|
||||
if (countryName === 'CN') return '中国';
|
||||
return countryName || 'N/A';
|
||||
}
|
||||
async function getPublicIP() {
|
||||
const apis = ['https://api.ipify.org?format=json', 'https://ipinfo.io/json'];
|
||||
for (const apiUrl of apis) {
|
||||
try {
|
||||
const response = await fetch(apiUrl, { signal: AbortSignal.timeout(3000) });
|
||||
if (!response.ok) continue;
|
||||
const data = await response.json();
|
||||
if (data.ip) return data.ip;
|
||||
} catch (e) {
|
||||
console.warn('Failed to fetch public IP from ' + apiUrl, e);
|
||||
}
|
||||
}
|
||||
throw new Error('所有主要来源都无法获取公网 IP');
|
||||
}
|
||||
async function queryAllAPIs(ip) {
|
||||
const apiEndpoints = [
|
||||
{
|
||||
name: 'PCOnline',
|
||||
url: 'http://whois.pconline.com.cn/ipJson.jsp?ip=' + ip + '&json=true',
|
||||
parser: (data) => {
|
||||
if (!data.ip || data.err || data.proCode === '999999') return null;
|
||||
const addrParts = data.addr.split(' ').filter(Boolean);
|
||||
const country = formatCountry(data.pro);
|
||||
const region = data.pro.replace('省','').replace('市','');
|
||||
const city = data.city.replace('市','');
|
||||
const isp = addrParts.length > 2 ? addrParts[2] : (addrParts.length > 1 && region !== city ? addrParts[1] : 'N/A');
|
||||
return { ip: data.ip, country: country.startsWith('中国-') ? country : '中国', region: region, city: city, isp: isp, org: isp };
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'ip.taobao.com',
|
||||
url: 'http://ip.taobao.com/service/getIpInfo.php?ip=' + ip,
|
||||
parser: (data) => {
|
||||
if (data.code !== 0 || !data.data.ip) return null;
|
||||
const d = data.data;
|
||||
return { ip: d.ip, country: formatCountry(d.country), region: d.region, city: d.city, isp: d.isp, org: d.isp };
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'ip-api.com',
|
||||
url: 'https://ip-api.com/json/' + ip + '?lang=zh-CN&fields=status,country,countryCode,regionName,city,isp,org,query',
|
||||
parser: (data) => {
|
||||
if (data.status !== 'success') return null;
|
||||
return { ip: data.query, country: formatCountry(data.country, data.regionName), region: data.regionName, city: data.city, isp: data.isp, org: data.org };
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'ip.sb',
|
||||
url: 'https://api.ip.sb/geoip/' + ip,
|
||||
parser: (data) => {
|
||||
if (!data.ip) return null;
|
||||
return { ip: data.ip, country: formatCountry(data.country), region: data.region, city: data.city, isp: data.isp, org: data.organization };
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'freeipapi.com',
|
||||
url: 'https://freeipapi.com/api/json/' + ip,
|
||||
parser: (data) => {
|
||||
if (!data.ipAddress) return null;
|
||||
return { ip: data.ipAddress, country: formatCountry(data.countryName), region: data.regionName, city: data.cityName, isp: data.isp, org: data.isp };
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'ipinfo.io',
|
||||
url: 'https://ipinfo.io/' + ip + '/json',
|
||||
parser: (data) => {
|
||||
if (!data.ip) return null;
|
||||
return { ip: data.ip, country: formatCountry(data.country), region: data.region, city: data.city, isp: data.org?.split(' ').slice(1).join(' '), org: data.org };
|
||||
}
|
||||
}
|
||||
];
|
||||
const promises = apiEndpoints.map(api =>
|
||||
fetch(api.url, { signal: AbortSignal.timeout(4000) })
|
||||
.then(response => {
|
||||
if (!response.ok) throw new Error(api.name + ' HTTP error ' + response.status);
|
||||
if (api.name === 'PCOnline') {
|
||||
return response.arrayBuffer().then(buffer => {
|
||||
try {
|
||||
const decoder = new TextDecoder('gbk');
|
||||
return JSON.parse(decoder.decode(buffer));
|
||||
} catch { return null; }
|
||||
});
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then(data => {
|
||||
try {
|
||||
return { name: api.name, data: data ? api.parser(data) : null };
|
||||
} catch {
|
||||
return { name: api.name, data: null, error: '解析失败' };
|
||||
}
|
||||
})
|
||||
.catch(error => ({ name: api.name, data: null, error: error.message }))
|
||||
);
|
||||
const results = await Promise.all(promises);
|
||||
return processAndCompareResults(results.filter(r => r && r.data));
|
||||
}
|
||||
function processAndCompareResults(validResults) {
|
||||
if (validResults.length === 0) {
|
||||
return { consolidated: null, sources: [] };
|
||||
}
|
||||
const preferredSource = validResults.find(r => r.name === 'PCOnline' || r.name === 'ip.taobao.com' || r.name === 'ip-api.com') || validResults[0];
|
||||
const consolidated = { ...preferredSource.data };
|
||||
for(const key in consolidated) { if(!consolidated[key]) delete consolidated[key]; }
|
||||
const sources = validResults.map(source => {
|
||||
const isConsistent = {
|
||||
country: source.data.country === consolidated.country,
|
||||
region: source.data.region === consolidated.region,
|
||||
city: source.data.city === consolidated.city,
|
||||
isp: source.data.isp === consolidated.isp
|
||||
};
|
||||
return { ...source, isConsistent };
|
||||
});
|
||||
return { consolidated, sources };
|
||||
}
|
||||
self.onmessage = async (event) => {
|
||||
const { type, ip } = event.data;
|
||||
try {
|
||||
if (type !== 'query') throw new Error('Invalid worker command');
|
||||
const targetIp = ip;
|
||||
|
||||
if (!targetIp) { throw new Error("无法确定要查询的IP地址。"); }
|
||||
if (targetIp === '127.0.0.1' || targetIp.startsWith('192.168.') || targetIp.startsWith('10.')) {
|
||||
const result = {
|
||||
consolidated: { ip: targetIp, country: '局域网地址', region: '内部网络', city: '', isp: '' },
|
||||
sources: []
|
||||
};
|
||||
postMessage({ type: 'success', payload: result });
|
||||
return;
|
||||
}
|
||||
const results = await queryAllAPIs(targetIp);
|
||||
if (!results.consolidated) {
|
||||
throw new Error("所有数据源均未能返回有效信息。");
|
||||
}
|
||||
postMessage({ type: 'success', payload: results });
|
||||
} catch (error) {
|
||||
postMessage({ type: 'error', payload: error.message });
|
||||
}
|
||||
};
|
||||
`;
|
||||
|
||||
class IPQueryTool extends BaseTool {
|
||||
constructor() {
|
||||
super('ip-query', 'IP属地查询', { workerCode });
|
||||
this.dom = {};
|
||||
this.abortController = null;
|
||||
}
|
||||
|
||||
render() {
|
||||
return `
|
||||
<div class="page-container" style="display: flex; flex-direction: column; height: 100%;">
|
||||
<div class="section-header">
|
||||
<button id="back-to-toolbox-btn" class="back-btn ripple"><i class="fas fa-arrow-left"></i> 返回工具箱</button>
|
||||
<h1 style="flex-grow: 1; text-align: center;">IP属地查询</h1>
|
||||
</div>
|
||||
<div class="ip-input-group" style="margin-bottom: 20px; flex-shrink: 0;">
|
||||
<input type="text" id="ip-input" placeholder="输入IP地址进行多源查询">
|
||||
<button id="query-ip-btn" class="action-btn ripple"><i class="fas fa-search"></i> 查询</button>
|
||||
<button id="query-myip-btn" class="control-btn ripple"><i class="fas fa-user-check"></i> 查询本机IP (详细)</button>
|
||||
</div>
|
||||
<div id="ip-results-container" class="content-area" style="padding: 10px 20px 10px 10px; flex-grow: 1; overflow-y: auto;">
|
||||
<div class="loading-container">
|
||||
<p class="loading-text">输入 IP 地址或点击“查询本机IP”</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
init() {
|
||||
this.dom.input = document.getElementById('ip-input');
|
||||
this.dom.queryBtn = document.getElementById('query-ip-btn');
|
||||
this.dom.queryMyIpBtn = document.getElementById('query-myip-btn'); // [修改]
|
||||
this.dom.resultsContainer = document.getElementById('ip-results-container');
|
||||
|
||||
document.getElementById('back-to-toolbox-btn')?.addEventListener('click', () => {
|
||||
window.mainPage.navigateTo('toolbox');
|
||||
window.mainPage.updateActiveNavButton(document.getElementById('toolbox-btn'));
|
||||
});
|
||||
|
||||
this.dom.queryBtn.addEventListener('click', this._handleQueryOthers.bind(this)); // [修改]
|
||||
this.dom.queryMyIpBtn.addEventListener('click', this._handleQuerySelf.bind(this)); // [修改]
|
||||
this.dom.input.addEventListener('keydown', (e) => { if (e.key === 'Enter') this._handleQueryOthers(); });
|
||||
|
||||
this._log('工具已初始化');
|
||||
}
|
||||
|
||||
// Worker 消息处理器 (用于多源查询)
|
||||
_onWorkerMessage(data) {
|
||||
if (data.type === 'success') {
|
||||
this._renderMultiSourceResults(data.payload);
|
||||
this._log('多源查询成功: ' + (data.payload.consolidated?.ip || 'N/A'));
|
||||
} else {
|
||||
this._showError(data.payload);
|
||||
this._log('多源查询失败: ' + data.payload);
|
||||
}
|
||||
}
|
||||
|
||||
// [修改] 查询指定 IP (多源)
|
||||
_handleQueryOthers() {
|
||||
if (this.abortController) this.abortController.abort();
|
||||
this.abortController = new AbortController();
|
||||
|
||||
const ip = this.dom.input.value.trim();
|
||||
if (!ip) {
|
||||
this._notify('提示', '请输入一个IP地址进行查询', 'info');
|
||||
return;
|
||||
}
|
||||
|
||||
this._showLoading();
|
||||
this._log('开始查询IP (多源): ' + ip);
|
||||
this._postMessageToWorker({ type: 'query', ip });
|
||||
}
|
||||
|
||||
// [修改] 查询本机 IP (商业级)
|
||||
_handleQuerySelf() {
|
||||
if (this.abortController) this.abortController.abort();
|
||||
this.abortController = new AbortController();
|
||||
|
||||
this._showLoading();
|
||||
this._log('开始查询本机公网IP (商业级)');
|
||||
this._queryMyIpInfo();
|
||||
}
|
||||
|
||||
// [新增] 查询本机 IP (商业级 API)
|
||||
async _queryMyIpInfo() {
|
||||
const apiUrl = 'https://uapis.cn/api/v1/network/myip?source=commercial';
|
||||
try {
|
||||
const response = await fetch(apiUrl, { signal: this.abortController.signal });
|
||||
|
||||
const blob = await response.blob();
|
||||
window.electronAPI.addTraffic(blob.size);
|
||||
const data = JSON.parse(await blob.text());
|
||||
|
||||
if (!response.ok || data.code !== 200) {
|
||||
throw new Error(data.message || `API 请求失败: ${response.status}`);
|
||||
}
|
||||
|
||||
this.dom.input.value = data.ip; // 将查询到的 IP 填入输入框
|
||||
this._renderMyIpResults(data); // 使用新的渲染函数
|
||||
this._log(`本机IP查询成功: ${data.ip}`);
|
||||
|
||||
} catch (error) {
|
||||
if (error.name === 'AbortError') {
|
||||
this._log('本机IP查询被中止');
|
||||
this._showError('查询已取消');
|
||||
return;
|
||||
}
|
||||
this._showError(error.message);
|
||||
this._log(`本机IP查询失败: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
_showLoading() {
|
||||
this.dom.resultsContainer.innerHTML = `<div class="loading-container"><img src="./assets/loading.gif" alt="查询中..." class="loading-gif"><p class="loading-text">正在查询, 请稍候...</p></div>`;
|
||||
}
|
||||
|
||||
_showError(message) {
|
||||
this.dom.resultsContainer.innerHTML = `<div class="loading-container"><p class="error-message"><i class="fas fa-exclamation-triangle"></i> 查询失败: ${message}</p></div>`;
|
||||
}
|
||||
|
||||
// [新增] 渲染本机 IP (商业级) 结果
|
||||
_renderMyIpResults(data) {
|
||||
const renderItem = (label, value) => (value || value === 0) ? `
|
||||
<div class="ip-result-item">
|
||||
<span class="ip-result-key">${label}</span>
|
||||
<span class="ip-result-value">${value}</span>
|
||||
</div>` : '';
|
||||
|
||||
// [新增] 检查是否存在任何“区域详情”数据
|
||||
const hasRegionalDetails = data.area_code || data.city_code || data.zip_code || data.time_zone || data.weather_station;
|
||||
|
||||
let regionalHtml = '';
|
||||
if (hasRegionalDetails) {
|
||||
regionalHtml = `
|
||||
<div class="ip-result-category">
|
||||
<h3><i class="fas fa-info-circle"></i> 区域详情</h3>
|
||||
${renderItem('行政区划代码', data.area_code)}
|
||||
${renderItem('城市区号', data.city_code)}
|
||||
${renderItem('邮政编码', data.zip_code)}
|
||||
${renderItem('时区', data.time_zone)}
|
||||
${renderItem('气象站代码', data.weather_station)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
const html = `
|
||||
<div class="ip-results-wrapper" style="animation: contentFadeIn 0.5s;">
|
||||
<h2 style="text-align: center; margin-bottom: 15px;">本机IP (商业级) 详细信息</h2>
|
||||
<div class="ip-result-grid" style="grid-template-columns: 1fr 1fr;">
|
||||
|
||||
<div class="ip-result-category">
|
||||
<h3><i class="fas fa-network-wired"></i> 网络信息</h3>
|
||||
${renderItem('IP 地址', data.ip)}
|
||||
${renderItem('运营商', data.isp)}
|
||||
${renderItem('归属', data.llc)}
|
||||
${renderItem('ASN', data.asn)}
|
||||
${renderItem('应用场景', data.scenes)}
|
||||
</div>
|
||||
|
||||
<div class="ip-result-category">
|
||||
<h3><i class="fas fa-map-marked-alt"></i> 地理位置</h3>
|
||||
${renderItem('位置', data.region)}
|
||||
${renderItem('行政区', data.district)}
|
||||
${renderItem('纬度', data.latitude)}
|
||||
${renderItem('经度', data.longitude)}
|
||||
${renderItem('海拔 (米)', data.elevation)}
|
||||
</div>
|
||||
|
||||
${regionalHtml} </div>
|
||||
</div>`;
|
||||
this.dom.resultsContainer.innerHTML = html;
|
||||
}
|
||||
|
||||
// 渲染多源查询 (指定 IP) 结果
|
||||
_renderMultiSourceResults(data) {
|
||||
const { consolidated, sources } = data;
|
||||
if (!consolidated || !consolidated.ip) {
|
||||
this._showError('未能从任何来源获取到有效的IP信息。');
|
||||
return;
|
||||
}
|
||||
|
||||
const renderConsolidatedItem = (key, value) => value ? `
|
||||
<div class="ip-result-item">
|
||||
<span class="ip-result-key">${key}</span>
|
||||
<span class="ip-result-value">${value}</span>
|
||||
</div>` : '';
|
||||
|
||||
const consolidatedHtml = `
|
||||
<div class="ip-results-wrapper">
|
||||
<div class="ip-result-grid">
|
||||
<div class="ip-result-category">
|
||||
<h3><i class="fas fa-map-marked-alt"></i> 综合查询结果 (多源)</h3>
|
||||
${renderConsolidatedItem('IP 地址', consolidated.ip)}
|
||||
${renderConsolidatedItem('国家/地区', consolidated.country)}
|
||||
${renderConsolidatedItem('省份', consolidated.region)}
|
||||
${renderConsolidatedItem('城市', consolidated.city)}
|
||||
${renderConsolidatedItem('运营商', consolidated.isp)}
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
const comparisonHtml = sources && sources.length > 0 ? `
|
||||
<div class="ip-comparison-container">
|
||||
<h3><i class="fas fa-balance-scale"></i> 各数据源比对详情</h3>
|
||||
<table class="ip-comparison-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>数据源</th>
|
||||
<th>国家/地区</th>
|
||||
<th>省份/地区</th>
|
||||
<th>城市</th>
|
||||
<th>运营商</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${sources.map(source => `
|
||||
<tr>
|
||||
<td>${source.name}</td>
|
||||
<td class="${source.isConsistent.country ? 'consistent' : 'inconsistent'}">${source.data.country || 'N/A'}</td>
|
||||
<td class="${source.isConsistent.region ? 'consistent' : 'inconsistent'}">${source.data.region || 'N/A'}</td>
|
||||
<td class="${source.isConsistent.city ? 'consistent' : 'inconsistent'}">${source.data.city || 'N/A'}</td>
|
||||
<td class="${source.isConsistent.isp ? 'consistent' : 'inconsistent'}">${source.data.isp || 'N/A'}</td>
|
||||
</tr>
|
||||
`).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="comparison-note">
|
||||
<i class="fas fa-info-circle"></i> 提示:标红字段表示与综合结果不一致。
|
||||
</p>
|
||||
</div>` : '';
|
||||
|
||||
this.dom.resultsContainer.innerHTML = `<div style="animation: contentFadeIn 0.5s;">${consolidatedHtml}${comparisonHtml}</div>`;
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (this.abortController) {
|
||||
this.abortController.abort();
|
||||
}
|
||||
this._log('工具已销毁');
|
||||
super.destroy(); // 确保 Worker 也被终止
|
||||
}
|
||||
}
|
||||
export default IPQueryTool;
|
||||
@@ -0,0 +1,85 @@
|
||||
import BaseTool from '../baseTool.js';
|
||||
|
||||
export default class JsObfuscatorTool extends BaseTool {
|
||||
constructor() {
|
||||
super('js-obfuscator', 'JS 代码加密');
|
||||
}
|
||||
|
||||
render() {
|
||||
return `
|
||||
<div class="page-container" style="padding: 20px; display: flex; flex-direction: column; gap: 15px; height: 100%;">
|
||||
<div class="section-header">
|
||||
<button class="back-btn ripple" id="back-btn"><i class="fas fa-arrow-left"></i> 返回</button>
|
||||
<h1>${this.name}</h1>
|
||||
</div>
|
||||
|
||||
<div class="tool-island-container">
|
||||
<div style="flex: 1; display: flex; flex-direction: column; gap: 15px;">
|
||||
|
||||
<div style="flex: 1; display: flex; flex-direction: column;">
|
||||
<div class="tool-group-title">源代码</div>
|
||||
<textarea id="js-input" class="common-textarea" placeholder="在此粘贴 JavaScript 代码..." style="flex: 1; resize: none; min-height: 150px; border-radius: 12px;"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="tool-island-card" style="padding: 15px 20px; display: flex; align-items: center; gap: 20px; flex-shrink: 0; background: rgba(var(--card-background-rgb), 0.8);">
|
||||
<div class="input-group" style="flex: 1; margin: 0;">
|
||||
<label style="display: block; font-size: 12px; color: var(--text-secondary); margin-bottom: 6px;">加密模式</label>
|
||||
<div style="position: relative;">
|
||||
<select id="js-mode" class="common-select" style="width: 100%; height: 40px; border-radius: 8px; background-color: rgba(var(--bg-color-rgb), 0.5); color: var(--text-color); border: 1px solid var(--border-color); padding-left: 10px; cursor: pointer;">
|
||||
<option value="minify">仅压缩 (Minify)</option>
|
||||
<option value="eval">Eval 加密 (基础混淆)</option>
|
||||
<option value="base64">Base64 封装 (Loader)</option>
|
||||
</select>
|
||||
<i class="fas fa-chevron-down" style="position: absolute; right: 12px; top: 50%; transform: translateY(-50%); pointer-events: none; opacity: 0.6; font-size: 12px;"></i>
|
||||
</div>
|
||||
</div>
|
||||
<button id="btn-obfuscate" class="action-btn ripple" style="height: 40px; margin-top: 20px; padding: 0 25px; border-radius: 8px;">
|
||||
<i class="fas fa-lock"></i> 执行处理
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style="flex: 1; display: flex; flex-direction: column;">
|
||||
<div class="tool-group-title">输出结果</div>
|
||||
<textarea id="js-output" class="common-textarea" placeholder="加密后的代码将显示在这里..." style="flex: 1; resize: none; min-height: 150px; border-radius: 12px;" readonly></textarea>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
init() {
|
||||
document.getElementById('back-btn').addEventListener('click', () => window.mainPage.navigateTo('toolbox'));
|
||||
|
||||
document.getElementById('btn-obfuscate').addEventListener('click', () => {
|
||||
const input = document.getElementById('js-input').value;
|
||||
if (!input) return;
|
||||
const mode = document.getElementById('js-mode').value;
|
||||
let result = '';
|
||||
|
||||
try {
|
||||
// 1. 基础压缩 (简易正则版)
|
||||
let minified = input.replace(/\/\/.*$/gm, "")
|
||||
.replace(/\/\*[\s\S]*?\*\//g, "")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
|
||||
if (mode === 'minify') {
|
||||
result = minified;
|
||||
} else if (mode === 'base64') {
|
||||
const b64 = btoa(unescape(encodeURIComponent(minified)));
|
||||
result = `/** Encrypted by YMhut Box */\n(function(){var c="${b64}";eval(decodeURIComponent(escape(atob(c))))})();`;
|
||||
} else if (mode === 'eval') {
|
||||
const chars = minified.split('').map(c => c.charCodeAt(0)).join(',');
|
||||
result = `eval(String.fromCharCode(${chars}))`;
|
||||
}
|
||||
|
||||
document.getElementById('js-output').value = result;
|
||||
this._notify('成功', '代码处理完成', 'success');
|
||||
} catch (e) {
|
||||
this._notify('错误', e.message, 'error');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import BaseTool from '../baseTool.js';
|
||||
import i18n from '../i18n.js';
|
||||
|
||||
export default class JsonFormatTool extends BaseTool {
|
||||
constructor() {
|
||||
super('json-format', i18n.t('tool.jsonFormat.name') || 'JSON 格式化');
|
||||
}
|
||||
|
||||
render() {
|
||||
// 确保i18n已初始化
|
||||
const toolName = i18n.hasTranslation('tool.jsonFormat.name')
|
||||
? i18n.t('tool.jsonFormat.name')
|
||||
: 'JSON 格式化';
|
||||
|
||||
return `
|
||||
<div class="page-container" style="padding: 20px; display: flex; flex-direction: column; gap: 15px; height: 100%;">
|
||||
<div class="section-header"><button class="back-btn ripple" id="back-btn"><i class="fas fa-arrow-left"></i></button><h1>${toolName}</h1></div>
|
||||
<div style="display: flex; gap: 10px; flex: 1; min-height: 0;">
|
||||
<textarea id="json-input" class="common-textarea" placeholder="${i18n.t('tool.jsonFormat.inputPlaceholder') || '在此输入 JSON...'}" style="flex: 1; resize: none;"></textarea>
|
||||
<textarea id="json-output" class="common-textarea" placeholder="${i18n.t('tool.jsonFormat.outputPlaceholder') || '结果输出...'}" style="flex: 1; resize: none;" readonly></textarea>
|
||||
</div>
|
||||
<div class="action-bar" style="display: flex; gap: 10px; justify-content: center;">
|
||||
<button id="btn-format" class="action-btn ripple"><i class="fas fa-indent"></i> ${i18n.t('tool.jsonFormat.format') || '格式化'}</button>
|
||||
<button id="btn-compress" class="action-btn ripple"><i class="fas fa-compress-arrows-alt"></i> ${i18n.t('tool.jsonFormat.compress') || '压缩'}</button>
|
||||
<button id="btn-copy" class="action-btn ripple"><i class="fas fa-copy"></i> ${i18n.t('tool.jsonFormat.copy') || '复制结果'}</button>
|
||||
<button id="btn-clear" class="control-btn ripple"><i class="fas fa-trash"></i> ${i18n.t('tool.jsonFormat.clear') || '清空'}</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
init() {
|
||||
document.getElementById('back-btn').addEventListener('click', () => window.mainPage.navigateTo('toolbox'));
|
||||
const input = document.getElementById('json-input');
|
||||
const output = document.getElementById('json-output');
|
||||
|
||||
document.getElementById('btn-format').addEventListener('click', () => {
|
||||
try {
|
||||
const val = input.value.trim();
|
||||
if (!val) return;
|
||||
output.value = JSON.stringify(JSON.parse(val), null, 4);
|
||||
this._notify('成功', 'JSON 格式化完成', 'success');
|
||||
} catch (e) {
|
||||
this._notify('错误', '无效的 JSON: ' + e.message, 'error');
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('btn-compress').addEventListener('click', () => {
|
||||
try {
|
||||
const val = input.value.trim();
|
||||
if (!val) return;
|
||||
output.value = JSON.stringify(JSON.parse(val));
|
||||
this._notify('成功', 'JSON 压缩完成', 'success');
|
||||
} catch (e) {
|
||||
this._notify('错误', '无效的 JSON: ' + e.message, 'error');
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('btn-copy').addEventListener('click', () => {
|
||||
if (!output.value) return;
|
||||
navigator.clipboard.writeText(output.value).then(() => this._notify('复制成功', '结果已复制到剪贴板'));
|
||||
});
|
||||
|
||||
document.getElementById('btn-clear').addEventListener('click', () => {
|
||||
input.value = '';
|
||||
output.value = '';
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
// src/js/tools/md5Tool.js
|
||||
import BaseTool from '../baseTool.js';
|
||||
|
||||
// [重点] 绝对不能在这里写 import crypto from 'crypto'; 否则会报你看到的那个错误
|
||||
|
||||
export default class Md5Tool extends BaseTool {
|
||||
constructor() {
|
||||
super('md5-tool', 'MD5 加密');
|
||||
}
|
||||
|
||||
render() {
|
||||
return `
|
||||
<div class="page-container" style="padding: 20px;">
|
||||
<div class="section-header">
|
||||
<button class="back-btn ripple" id="back-btn"><i class="fas fa-arrow-left"></i></button>
|
||||
<h1>${this.name}</h1>
|
||||
</div>
|
||||
|
||||
<div class="input-group" style="margin-top:20px;">
|
||||
<label>输入文本</label>
|
||||
<div style="display:flex; gap:10px;">
|
||||
<input type="text" id="md5-input" class="common-input" placeholder="请输入要加密的内容..." style="flex:1;">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="input-group" style="margin-top:20px;">
|
||||
<label>32位小写 (标准)</label>
|
||||
<div style="display:flex; gap:10px;">
|
||||
<input type="text" id="md5-output" class="common-input" readonly>
|
||||
<button id="btn-copy" class="action-btn ripple" style="width: auto;">复制</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="input-group" style="margin-top:20px;">
|
||||
<label>16位小写</label>
|
||||
<input type="text" id="md5-output-16" class="common-input" readonly>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
init() {
|
||||
document.getElementById('back-btn').addEventListener('click', () => window.mainPage.navigateTo('toolbox'));
|
||||
|
||||
const input = document.getElementById('md5-input');
|
||||
|
||||
// 监听输入事件
|
||||
input.addEventListener('input', () => {
|
||||
const val = input.value;
|
||||
if(!val) {
|
||||
document.getElementById('md5-output').value = '';
|
||||
document.getElementById('md5-output-16').value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
// [修复] 使用 preload.js 暴露的 electronAPI.cryptoMD5
|
||||
try {
|
||||
// 调用我们在 preload.js 里写好的安全方法
|
||||
const hash = window.electronAPI.cryptoMD5(val);
|
||||
|
||||
document.getElementById('md5-output').value = hash;
|
||||
// 16位通常是 32位中间的 8-24 位
|
||||
document.getElementById('md5-output-16').value = hash.substring(8, 24);
|
||||
} catch (e) {
|
||||
console.error('MD5计算错误:', e);
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('btn-copy').addEventListener('click', () => {
|
||||
const res = document.getElementById('md5-output').value;
|
||||
if(res) {
|
||||
navigator.clipboard.writeText(res).then(() => this._notify('已复制', 'MD5密文已复制到剪贴板'));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,664 @@
|
||||
// src/js/tools/mediaPlayerTool.js
|
||||
import BaseTool from '../baseTool.js';
|
||||
import configManager from '../configManager.js';
|
||||
|
||||
class MediaPlayerTool extends BaseTool {
|
||||
constructor() {
|
||||
super('media-player', '媒体播放器');
|
||||
|
||||
this.playlists = { video: [], audio: [], other: [] };
|
||||
this.currentCategory = 'video';
|
||||
this.currentIndex = -1;
|
||||
this.isPlaying = false;
|
||||
this.volume = 0.5;
|
||||
this.isFullScreen = false;
|
||||
this.savedTime = 0;
|
||||
this.slideTimer = null;
|
||||
|
||||
this._handleKeydown = this._handleKeydown.bind(this);
|
||||
this._handleFullscreenChange = this._handleFullscreenChange.bind(this);
|
||||
this._handleClickOutside = this._handleClickOutside.bind(this);
|
||||
}
|
||||
|
||||
render() {
|
||||
const css = `
|
||||
<style>
|
||||
.mp-container {
|
||||
position: relative; height: 100%; width: 100%; overflow: hidden;
|
||||
display: flex; flex-direction: column; background: #000;
|
||||
}
|
||||
/* 顶部栏 */
|
||||
.mp-header-float {
|
||||
position: absolute; top: 15px; left: 20px; right: 20px; z-index: 50;
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
pointer-events: none; transition: opacity 0.3s;
|
||||
}
|
||||
.mp-header-btn {
|
||||
pointer-events: auto; background: rgba(0,0,0,0.4); border: 1px solid rgba(255,255,255,0.1);
|
||||
color: #fff; padding: 8px 15px; border-radius: 20px; backdrop-filter: blur(10px);
|
||||
cursor: pointer; transition: all 0.2s; font-size: 13px; display: flex; align-items: center; gap: 5px;
|
||||
}
|
||||
.mp-header-btn:hover { background: rgba(255,255,255,0.15); transform: scale(1.05); }
|
||||
|
||||
/* 舞台 */
|
||||
.mp-stage { flex: 1; position: relative; display: flex; align-items: center; justify-content: center; width: 100%; height: 100%; z-index: 1; }
|
||||
.mp-video-el { width: 100%; height: 100%; object-fit: contain; }
|
||||
|
||||
/* 音频字幕专用显示层 */
|
||||
.mp-audio-subtitle {
|
||||
position: absolute; bottom: 120px; width: 80%; text-align: center;
|
||||
color: #fff; font-size: 18px; text-shadow: 0 2px 4px rgba(0,0,0,0.8);
|
||||
pointer-events: none; z-index: 5; background: rgba(0,0,0,0.5);
|
||||
padding: 5px 10px; border-radius: 8px; display: none; /* 默认隐藏,有字才显 */
|
||||
}
|
||||
|
||||
.mp-placeholder { text-align: center; color: rgba(255,255,255,0.3); pointer-events: none; }
|
||||
|
||||
/* 灵动岛控制栏 */
|
||||
.mp-island-controls {
|
||||
position: absolute; bottom: 30px; left: 50%; transform: translateX(-50%);
|
||||
width: 90%; max-width: 700px; height: 70px;
|
||||
background: rgba(20, 20, 20, 0.85); backdrop-filter: blur(25px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08); border-radius: 35px;
|
||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.5);
|
||||
display: flex; flex-direction: column; justify-content: center;
|
||||
padding: 0 25px; gap: 5px; z-index: 50;
|
||||
transition: all 0.3s cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||
}
|
||||
.mp-island-progress {
|
||||
width: 100%; height: 4px; border-radius: 2px;
|
||||
background: rgba(255,255,255,0.1); cursor: pointer;
|
||||
position: relative; overflow: hidden; margin-top: 5px;
|
||||
}
|
||||
.mp-progress-fill { height: 100%; background: var(--primary-color); width: 0%; border-radius: 2px; }
|
||||
.mp-progress-handle { position: absolute; top: 0; left: 0; width: 100%; height: 100%; opacity: 0; cursor: pointer; margin: 0; }
|
||||
|
||||
.mp-controls-row { display: flex; justify-content: space-between; align-items: center; color: #ddd; }
|
||||
.mp-ctrl-group { display: flex; align-items: center; gap: 15px; }
|
||||
.mp-btn {
|
||||
background: none; border: none; color: inherit; font-size: 16px;
|
||||
cursor: pointer; width: 32px; height: 32px; display: flex; align-items: center; justify-content: center;
|
||||
border-radius: 50%; transition: all 0.2s;
|
||||
}
|
||||
.mp-btn:hover { background: rgba(255,255,255,0.1); color: #fff; }
|
||||
.mp-btn-play {
|
||||
width: 40px; height: 40px; background: #fff; color: #000; border-radius: 50%; font-size: 18px;
|
||||
box-shadow: 0 0 15px rgba(255,255,255,0.2);
|
||||
}
|
||||
.mp-btn-play:hover { background: #f0f0f0; transform: scale(1.1); }
|
||||
.mp-time-text { font-size: 12px; font-family: monospace; color: rgba(255,255,255,0.5); margin-left: 10px; }
|
||||
|
||||
.mp-vol-wrapper {
|
||||
display: flex; align-items: center; background: rgba(255,255,255,0.05);
|
||||
border-radius: 20px; padding: 0 10px; height: 32px; overflow: hidden;
|
||||
width: 32px; transition: width 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
}
|
||||
.mp-vol-wrapper:hover { width: 130px; background: rgba(255,255,255,0.15); }
|
||||
.mp-vol-slider {
|
||||
width: 80px; margin-left: 8px; height: 4px; -webkit-appearance: none;
|
||||
background: rgba(255,255,255,0.3); border-radius: 2px;
|
||||
opacity: 0; transition: opacity 0.2s 0.1s;
|
||||
}
|
||||
.mp-vol-wrapper:hover .mp-vol-slider { opacity: 1; }
|
||||
.mp-vol-slider::-webkit-slider-thumb {
|
||||
-webkit-appearance: none; width: 10px; height: 10px; border-radius: 50%; background: #fff; cursor: pointer;
|
||||
}
|
||||
|
||||
/* 播放列表抽屉 */
|
||||
.mp-drawer {
|
||||
position: absolute; top: 0; right: -320px; width: 300px; height: 100%;
|
||||
background: rgba(20, 20, 20, 0.95); backdrop-filter: blur(30px);
|
||||
border-left: 1px solid rgba(255,255,255,0.1);
|
||||
transition: right 0.4s cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||
display: flex; flex-direction: column; z-index: 100;
|
||||
box-shadow: -10px 0 30px rgba(0,0,0,0.5);
|
||||
}
|
||||
.mp-drawer.active { right: 0; }
|
||||
|
||||
.mp-tabs { display: flex; padding: 15px; gap: 10px; border-bottom: 1px solid rgba(255,255,255,0.05); }
|
||||
.mp-tab {
|
||||
flex: 1; background: transparent; border: none; color: #888; padding: 8px;
|
||||
border-radius: 8px; cursor: pointer; font-size: 13px; text-align: center;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.mp-tab.active { background: rgba(255,255,255,0.1); color: #fff; font-weight: bold; }
|
||||
|
||||
.mp-list { flex: 1; overflow-y: auto; padding: 10px; margin: 0; list-style: none; }
|
||||
.mp-list-item {
|
||||
display: flex; align-items: center; padding: 12px; border-radius: 12px;
|
||||
margin-bottom: 5px; cursor: pointer; color: #ccc; transition: background 0.2s;
|
||||
}
|
||||
.mp-list-item:hover { background: rgba(255,255,255,0.05); color: #fff; }
|
||||
.mp-list-item.active { background: rgba(var(--primary-rgb), 0.2); color: var(--primary-color); }
|
||||
.mp-list-item .idx { font-size: 12px; opacity: 0.5; width: 25px; }
|
||||
.mp-list-item .name { flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-size: 13px; }
|
||||
.mp-list-item .del { opacity: 0; transition: opacity 0.2s; padding: 5px; }
|
||||
.mp-list-item:hover .del { opacity: 1; }
|
||||
|
||||
/* 列表底部添加按钮 */
|
||||
.mp-drawer-footer {
|
||||
padding: 15px; border-top: 1px solid rgba(255,255,255,0.1);
|
||||
display: flex; gap: 10px;
|
||||
}
|
||||
.mp-drawer-btn {
|
||||
flex: 1; padding: 10px; border-radius: 10px; border: 1px solid rgba(255,255,255,0.1);
|
||||
background: rgba(255,255,255,0.05); color: #fff; cursor: pointer;
|
||||
transition: background 0.2s; font-size: 13px;
|
||||
}
|
||||
.mp-drawer-btn:hover { background: rgba(255,255,255,0.15); }
|
||||
.mp-drawer-btn.primary { background: var(--primary-color); border: none; }
|
||||
.mp-drawer-btn.primary:hover { opacity: 0.9; }
|
||||
|
||||
.fullscreen-mode .mp-header-float { display: none; }
|
||||
.fullscreen-mode .mp-island-controls { bottom: 20px; opacity: 0; transition: opacity 0.3s; }
|
||||
.fullscreen-mode .mp-island-controls:hover { opacity: 1; }
|
||||
</style>
|
||||
`;
|
||||
|
||||
const html = `
|
||||
<div class="mp-container" id="mp-container">
|
||||
<div class="mp-header-float" id="mp-header">
|
||||
<button id="back-to-toolbox-btn" class="mp-header-btn"><i class="fas fa-arrow-left"></i> 返回</button>
|
||||
<button id="mp-list-toggle" class="mp-header-btn"><i class="fas fa-list"></i> 播放列表</button>
|
||||
</div>
|
||||
|
||||
<div class="mp-stage">
|
||||
<video id="mp-video" class="mp-video-el" style="display:none;" crossorigin="anonymous"></video>
|
||||
|
||||
<div id="mp-audio-subtitle" class="mp-audio-subtitle"></div>
|
||||
|
||||
<div id="mp-audio-vis" style="display:none; text-align:center;">
|
||||
<i class="fas fa-music" style="font-size:80px; color:rgba(255,255,255,0.2); margin-bottom:20px;"></i>
|
||||
<h3 id="mp-track-title" style="color:#fff; font-weight:normal;">无音频播放</h3>
|
||||
<div class="audio-wave-anim"><span></span><span></span><span></span><span></span><span></span></div>
|
||||
</div>
|
||||
|
||||
<img id="mp-image" style="display:none; max-width:100%; max-height:100%; object-fit:contain;">
|
||||
|
||||
<div id="mp-empty" class="mp-placeholder">
|
||||
<i class="fas fa-play-circle" style="font-size: 48px; margin-bottom: 10px;"></i>
|
||||
<br>拖拽文件到此处或点击添加按钮
|
||||
<div style="font-size:12px; margin-top:5px; opacity:0.6;">支持自动加载同名字幕</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mp-island-controls" id="mp-controls">
|
||||
<div class="mp-island-progress">
|
||||
<div id="mp-prog-fill" class="mp-progress-fill"></div>
|
||||
<input type="range" id="mp-prog-input" class="mp-progress-handle" min="0" max="100" value="0" step="0.1">
|
||||
</div>
|
||||
|
||||
<div class="mp-controls-row">
|
||||
<div class="mp-ctrl-group">
|
||||
<span id="mp-time-now" class="mp-time-text">00:00</span>
|
||||
</div>
|
||||
|
||||
<div class="mp-ctrl-group">
|
||||
<button id="mp-prev" class="mp-btn" title="上一首"><i class="fas fa-step-backward"></i></button>
|
||||
<button id="mp-play" class="mp-btn mp-btn-play"><i class="fas fa-play" style="margin-left:2px;"></i></button>
|
||||
<button id="mp-next" class="mp-btn" title="下一首"><i class="fas fa-step-forward"></i></button>
|
||||
</div>
|
||||
|
||||
<div class="mp-ctrl-group">
|
||||
<div class="mp-vol-wrapper">
|
||||
<i id="mp-vol-icon" class="fas fa-volume-up" style="font-size:14px; color:#ccc;"></i>
|
||||
<input type="range" id="mp-vol-input" class="mp-vol-slider" min="0" max="1" step="0.05" value="0.5">
|
||||
</div>
|
||||
|
||||
<button id="mp-sub" class="mp-btn" title="加载字幕"><i class="fas fa-closed-captioning"></i></button>
|
||||
<button id="mp-full" class="mp-btn" title="全屏"><i class="fas fa-expand"></i></button>
|
||||
<button id="mp-add" class="mp-btn" title="添加文件"><i class="fas fa-plus"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mp-drawer" id="mp-drawer">
|
||||
<div class="mp-tabs">
|
||||
<button class="mp-tab active" data-cat="video">视频</button>
|
||||
<button class="mp-tab" data-cat="audio">音频</button>
|
||||
<button class="mp-tab" data-cat="other">其他</button>
|
||||
</div>
|
||||
<div style="padding:0 15px; display:flex; justify-content:space-between; align-items:center; color:#666; font-size:12px;">
|
||||
<span>共 <span id="mp-list-count">0</span> 个文件</span>
|
||||
</div>
|
||||
<ul class="mp-list" id="mp-list-ul"></ul>
|
||||
|
||||
<div class="mp-drawer-footer">
|
||||
<button id="mp-list-add-btn" class="mp-drawer-btn primary"><i class="fas fa-plus"></i> 添加文件</button>
|
||||
<button id="mp-clear" class="mp-drawer-btn"><i class="fas fa-trash"></i> 清空</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
return css + html;
|
||||
}
|
||||
|
||||
async init() {
|
||||
this.el = {
|
||||
video: document.getElementById('mp-video'),
|
||||
audioVis: document.getElementById('mp-audio-vis'),
|
||||
audioSub: document.getElementById('mp-audio-subtitle'), // 音频字幕DOM
|
||||
trackTitle: document.getElementById('mp-track-title'),
|
||||
img: document.getElementById('mp-image'),
|
||||
empty: document.getElementById('mp-empty'),
|
||||
playBtn: document.getElementById('mp-play'),
|
||||
prevBtn: document.getElementById('mp-prev'),
|
||||
nextBtn: document.getElementById('mp-next'),
|
||||
progInput: document.getElementById('mp-prog-input'),
|
||||
progFill: document.getElementById('mp-prog-fill'),
|
||||
timeNow: document.getElementById('mp-time-now'),
|
||||
volInput: document.getElementById('mp-vol-input'),
|
||||
volIcon: document.getElementById('mp-vol-icon'),
|
||||
drawer: document.getElementById('mp-drawer'),
|
||||
listUl: document.getElementById('mp-list-ul'),
|
||||
listCount: document.getElementById('mp-list-count'),
|
||||
container: document.getElementById('mp-container'),
|
||||
header: document.getElementById('mp-header'),
|
||||
listToggle: document.getElementById('mp-list-toggle'),
|
||||
listAddBtn: document.getElementById('mp-list-add-btn') // 新增
|
||||
};
|
||||
|
||||
this._bindEvents();
|
||||
await this._loadHistory();
|
||||
this._log('媒体播放器(灵动岛版)已启动');
|
||||
}
|
||||
|
||||
_bindEvents() {
|
||||
const { el } = this;
|
||||
|
||||
el.playBtn.onclick = () => this._togglePlay();
|
||||
el.prevBtn.onclick = () => this._playPrev();
|
||||
el.nextBtn.onclick = () => this._playNext();
|
||||
|
||||
el.progInput.oninput = (e) => {
|
||||
const pct = e.target.value;
|
||||
el.progFill.style.width = pct + '%';
|
||||
if (el.video.duration) el.video.currentTime = (el.video.duration * pct) / 100;
|
||||
};
|
||||
|
||||
el.video.ontimeupdate = () => {
|
||||
if (!el.video.duration) return;
|
||||
const pct = (el.video.currentTime / el.video.duration) * 100;
|
||||
el.progInput.value = pct;
|
||||
el.progFill.style.width = pct + '%';
|
||||
el.timeNow.innerText = this._fmtTime(el.video.currentTime);
|
||||
if (Math.floor(el.video.currentTime) % 5 === 0) this._saveHistory();
|
||||
};
|
||||
|
||||
el.video.onloadedmetadata = () => {
|
||||
if (this.savedTime > 0) {
|
||||
el.video.currentTime = this.savedTime;
|
||||
this.savedTime = 0;
|
||||
}
|
||||
this._saveHistory();
|
||||
};
|
||||
el.video.onended = () => this._playNext(true);
|
||||
|
||||
el.volInput.oninput = (e) => this._setVol(e.target.value);
|
||||
el.volIcon.onclick = () => this._setVol(this.volume > 0 ? 0 : 0.5);
|
||||
|
||||
el.listToggle.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
el.drawer.classList.toggle('active');
|
||||
};
|
||||
|
||||
document.addEventListener('click', this._handleClickOutside);
|
||||
|
||||
document.querySelectorAll('.mp-tab').forEach(btn => {
|
||||
btn.onclick = (e) => {
|
||||
document.querySelectorAll('.mp-tab').forEach(b => b.classList.remove('active'));
|
||||
e.target.classList.add('active');
|
||||
this.currentCategory = e.target.dataset.cat;
|
||||
this.currentIndex = -1;
|
||||
this._renderList();
|
||||
};
|
||||
});
|
||||
|
||||
// 通用“添加”逻辑
|
||||
const handleAdd = () => {
|
||||
const inp = document.createElement('input');
|
||||
inp.type = 'file'; inp.multiple = true; inp.accept = 'video/*,audio/*,image/*';
|
||||
inp.onchange = e => this._handleFiles(e.target.files);
|
||||
inp.click();
|
||||
};
|
||||
|
||||
document.getElementById('mp-add').onclick = handleAdd;
|
||||
// [新增] 播放列表内的添加按钮
|
||||
el.listAddBtn.onclick = handleAdd;
|
||||
|
||||
el.container.ondragover = e => e.preventDefault();
|
||||
el.container.ondrop = e => {
|
||||
e.preventDefault();
|
||||
this._handleFiles(e.dataTransfer.files);
|
||||
};
|
||||
|
||||
document.getElementById('mp-full').onclick = () => this._toggleFullScreen();
|
||||
document.addEventListener('fullscreenchange', this._handleFullscreenChange);
|
||||
|
||||
// 手动添加字幕
|
||||
document.getElementById('mp-sub').onclick = () => {
|
||||
const inp = document.createElement('input'); inp.type = 'file'; inp.accept = '.srt,.vtt';
|
||||
inp.onchange = e => { if (e.target.files.length) this._loadSub(e.target.files[0].path); };
|
||||
inp.click();
|
||||
};
|
||||
|
||||
document.getElementById('mp-clear').onclick = () => {
|
||||
this.playlists[this.currentCategory] = [];
|
||||
this._resetPlayer();
|
||||
this._renderList();
|
||||
this._saveHistory();
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', this._handleKeydown);
|
||||
}
|
||||
|
||||
_handleClickOutside(e) {
|
||||
if (this.el.drawer &&
|
||||
this.el.drawer.classList.contains('active') &&
|
||||
!this.el.drawer.contains(e.target) &&
|
||||
e.target !== this.el.listToggle &&
|
||||
!this.el.listToggle.contains(e.target)) {
|
||||
|
||||
this.el.drawer.classList.remove('active');
|
||||
}
|
||||
}
|
||||
|
||||
_handleFullscreenChange() {
|
||||
this.isFullScreen = !!document.fullscreenElement;
|
||||
if (this.isFullScreen) {
|
||||
this.el.container.classList.add('fullscreen-mode');
|
||||
} else {
|
||||
this.el.container.classList.remove('fullscreen-mode');
|
||||
this.el.header.style.display = '';
|
||||
}
|
||||
}
|
||||
|
||||
async _loadHistory() {
|
||||
try {
|
||||
const h = await window.electronAPI.mediaGetHistory();
|
||||
if (h) {
|
||||
this.playlists = h.playlists || { video: [], audio: [], other: [] };
|
||||
this.currentCategory = h.category || 'video';
|
||||
this._setVol(h.volume !== undefined ? h.volume : 0.5);
|
||||
|
||||
document.querySelectorAll('.mp-tab').forEach(b => {
|
||||
b.classList.toggle('active', b.dataset.cat === this.currentCategory);
|
||||
});
|
||||
this._renderList();
|
||||
|
||||
if (h.playingIndex > -1) {
|
||||
this.currentIndex = h.playingIndex;
|
||||
this.savedTime = h.currentTime || 0;
|
||||
const item = this.playlists[this.currentCategory][this.currentIndex];
|
||||
if (item) this._load(item, false);
|
||||
}
|
||||
}
|
||||
} catch (e) { console.error(e); }
|
||||
}
|
||||
|
||||
async _saveHistory() {
|
||||
await window.electronAPI.mediaSaveHistory({
|
||||
playlists: this.playlists,
|
||||
category: this.currentCategory,
|
||||
volume: this.volume,
|
||||
playingIndex: this.currentIndex,
|
||||
currentTime: this.el.video.currentTime
|
||||
});
|
||||
}
|
||||
|
||||
_handleFiles(files) {
|
||||
let added = 0;
|
||||
let targetCat = 'other';
|
||||
for (const f of files) {
|
||||
const type = f.type ? f.type.split('/')[0] : 'other';
|
||||
const ext = f.name.split('.').pop().toLowerCase();
|
||||
let cat = 'other';
|
||||
|
||||
if (type === 'video' || ['mp4','mkv','webm','avi','mov'].includes(ext)) cat = 'video';
|
||||
else if (type === 'audio' || ['mp3','wav','flac','ogg','m4a'].includes(ext)) cat = 'audio';
|
||||
else if (type === 'image' || ['jpg','jpeg','png','gif','webp'].includes(ext)) cat = 'other';
|
||||
|
||||
const itemType = (cat === 'other' && ['jpg','jpeg','png','gif','webp'].includes(ext)) ? 'image' : cat;
|
||||
this.playlists[cat].push({ name: f.name, path: f.path, type: itemType, format: ext });
|
||||
targetCat = cat;
|
||||
added++;
|
||||
}
|
||||
if (added > 0) {
|
||||
if (this.playlists[this.currentCategory].length === 0) {
|
||||
this.currentCategory = targetCat;
|
||||
document.querySelectorAll('.mp-tab').forEach(b => b.classList.toggle('active', b.dataset.cat === targetCat));
|
||||
}
|
||||
this._renderList();
|
||||
this._saveHistory();
|
||||
this._notify('添加成功', `已添加 ${added} 个文件`, 'success');
|
||||
}
|
||||
}
|
||||
|
||||
_renderList() {
|
||||
const list = this.playlists[this.currentCategory];
|
||||
this.el.listCount.innerText = list.length;
|
||||
if (list.length === 0) {
|
||||
this.el.listUl.innerHTML = '<li style="text-align:center; padding:20px; color:#666;">暂无文件</li>';
|
||||
return;
|
||||
}
|
||||
this.el.listUl.innerHTML = list.map((item, i) => `
|
||||
<li class="mp-list-item ${i === this.currentIndex ? 'active' : ''}" data-idx="${i}">
|
||||
<span class="idx">${i+1}</span>
|
||||
<span class="name" title="${item.path}">${item.name}</span>
|
||||
<i class="fas fa-times del"></i>
|
||||
</li>
|
||||
`).join('');
|
||||
|
||||
this.el.listUl.querySelectorAll('li').forEach(li => {
|
||||
li.onclick = (e) => {
|
||||
if (e.target.classList.contains('del')) {
|
||||
e.stopPropagation();
|
||||
this.playlists[this.currentCategory].splice(li.dataset.idx, 1);
|
||||
if (this.currentIndex === parseInt(li.dataset.idx)) this._resetPlayer();
|
||||
else if (this.currentIndex > parseInt(li.dataset.idx)) this.currentIndex--;
|
||||
this._renderList();
|
||||
this._saveHistory();
|
||||
} else {
|
||||
this.currentIndex = parseInt(li.dataset.idx);
|
||||
this._load(list[this.currentIndex], true);
|
||||
this._renderList();
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async _load(item, autoPlay) {
|
||||
this.el.empty.style.display = 'none';
|
||||
|
||||
// 清理旧状态
|
||||
this.el.video.innerHTML = '';
|
||||
this.el.audioSub.innerHTML = '';
|
||||
this.el.audioSub.style.display = 'none';
|
||||
|
||||
if (item.type === 'video') {
|
||||
this.el.video.src = item.path;
|
||||
this.el.video.style.display = 'block';
|
||||
this.el.audioVis.style.display = 'none';
|
||||
this.el.img.style.display = 'none';
|
||||
|
||||
// 自动挂载
|
||||
const sub = await window.electronAPI.mediaFindSubtitles(item.path);
|
||||
if (sub.found) {
|
||||
this._loadSub(sub.path);
|
||||
this._notify('智能助手', '已自动加载同名字幕', 'info');
|
||||
}
|
||||
} else if (item.type === 'audio') {
|
||||
this.el.video.src = item.path;
|
||||
this.el.video.style.display = 'none';
|
||||
this.el.audioVis.style.display = 'block';
|
||||
this.el.trackTitle.innerText = item.name;
|
||||
this.el.img.style.display = 'none';
|
||||
|
||||
// 音频字幕尝试挂载
|
||||
const sub = await window.electronAPI.mediaFindSubtitles(item.path);
|
||||
if (sub.found) this._loadSub(sub.path);
|
||||
|
||||
} else {
|
||||
this.el.video.pause();
|
||||
this.el.video.style.display = 'none';
|
||||
this.el.audioVis.style.display = 'none';
|
||||
this.el.img.src = item.path;
|
||||
this.el.img.style.display = 'block';
|
||||
if (autoPlay) {
|
||||
if (this.slideTimer) clearTimeout(this.slideTimer);
|
||||
this.slideTimer = setTimeout(() => this._playNext(true), 5000);
|
||||
}
|
||||
this.isPlaying = true;
|
||||
this._updatePlayIcon();
|
||||
return;
|
||||
}
|
||||
if (autoPlay) {
|
||||
this.el.video.play().catch(e => console.log(e));
|
||||
this.isPlaying = true;
|
||||
}
|
||||
this._updatePlayIcon();
|
||||
this._saveHistory();
|
||||
}
|
||||
|
||||
// [核心修复] 增强字幕加载与显示逻辑
|
||||
_loadSub(path) {
|
||||
// 1. 清除旧轨道
|
||||
const oldTracks = this.el.video.querySelectorAll('track');
|
||||
oldTracks.forEach(t => t.remove());
|
||||
|
||||
// 2. 创建新轨道
|
||||
const track = document.createElement('track');
|
||||
track.kind = 'subtitles';
|
||||
track.label = 'Default';
|
||||
track.srclang = 'zh';
|
||||
track.default = true;
|
||||
track.src = path;
|
||||
|
||||
// 3. 挂载
|
||||
this.el.video.appendChild(track);
|
||||
|
||||
// 4. 强制启用并监听 cuechange (解决音频字幕显示问题)
|
||||
track.onload = () => {
|
||||
if (this.el.video.textTracks && this.el.video.textTracks[0]) {
|
||||
const tt = this.el.video.textTracks[0];
|
||||
tt.mode = 'showing'; // 视频模式下正常显示
|
||||
|
||||
// 音频模式下,手动提取文字到 div
|
||||
tt.oncuechange = () => {
|
||||
const cues = tt.activeCues;
|
||||
if (cues && cues.length > 0) {
|
||||
const text = cues[0].text;
|
||||
// 移除 HTML 标签(如果字幕包含样式)
|
||||
this.el.audioSub.innerText = text.replace(/<[^>]*>/g, '');
|
||||
this.el.audioSub.style.display = 'block';
|
||||
} else {
|
||||
this.el.audioSub.innerText = '';
|
||||
this.el.audioSub.style.display = 'none';
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// 如果 onload 不触发,稍微延时再尝试设置 mode
|
||||
setTimeout(() => {
|
||||
if (this.el.video.textTracks && this.el.video.textTracks[0]) {
|
||||
this.el.video.textTracks[0].mode = 'showing';
|
||||
}
|
||||
}, 200);
|
||||
}
|
||||
|
||||
_togglePlay() {
|
||||
const list = this.playlists[this.currentCategory];
|
||||
if (!list.length) return;
|
||||
if (this.currentIndex === -1) {
|
||||
this.currentIndex = 0;
|
||||
this._load(list[0], true);
|
||||
this._renderList();
|
||||
return;
|
||||
}
|
||||
const item = list[this.currentIndex];
|
||||
if (item.type !== 'video' && item.type !== 'audio') {
|
||||
if (this.slideTimer) { clearTimeout(this.slideTimer); this.slideTimer = null; this.isPlaying = false; }
|
||||
else { this.isPlaying = true; this._playNext(true); }
|
||||
} else {
|
||||
if (this.el.video.paused) { this.el.video.play(); this.isPlaying = true; }
|
||||
else { this.el.video.pause(); this.isPlaying = false; }
|
||||
}
|
||||
this._updatePlayIcon();
|
||||
}
|
||||
|
||||
_playNext(auto) {
|
||||
const list = this.playlists[this.currentCategory];
|
||||
if (!list.length) return;
|
||||
this.currentIndex = (this.currentIndex + 1) % list.length;
|
||||
this._load(list[this.currentIndex], true);
|
||||
this._renderList();
|
||||
}
|
||||
|
||||
_playPrev() {
|
||||
const list = this.playlists[this.currentCategory];
|
||||
if (!list.length) return;
|
||||
this.currentIndex = (this.currentIndex - 1 + list.length) % list.length;
|
||||
this._load(list[this.currentIndex], true);
|
||||
this._renderList();
|
||||
}
|
||||
|
||||
_setVol(v) {
|
||||
this.volume = parseFloat(v);
|
||||
this.el.video.volume = this.volume;
|
||||
this.el.volInput.value = this.volume;
|
||||
this.el.volIcon.className = this.volume === 0 ? 'fas fa-volume-mute' : (this.volume < 0.5 ? 'fas fa-volume-down' : 'fas fa-volume-up');
|
||||
}
|
||||
|
||||
_resetPlayer() {
|
||||
this.el.video.pause();
|
||||
this.el.video.src = '';
|
||||
this.el.empty.style.display = 'block';
|
||||
this.el.video.style.display = 'none';
|
||||
this.el.audioVis.style.display = 'none';
|
||||
this.el.img.style.display = 'none';
|
||||
this.el.audioSub.style.display = 'none'; // 重置字幕
|
||||
this.isPlaying = false;
|
||||
this.currentIndex = -1;
|
||||
this._updatePlayIcon();
|
||||
}
|
||||
|
||||
_updatePlayIcon() {
|
||||
this.el.playBtn.innerHTML = this.isPlaying ? '<i class="fas fa-pause"></i>' : '<i class="fas fa-play" style="margin-left:2px;"></i>';
|
||||
}
|
||||
|
||||
_fmtTime(s) {
|
||||
const m = Math.floor(s / 60);
|
||||
const sec = Math.floor(s % 60);
|
||||
return `${m.toString().padStart(2,'0')}:${sec.toString().padStart(2,'0')}`;
|
||||
}
|
||||
|
||||
_toggleFullScreen() {
|
||||
if (!this.isFullScreen) {
|
||||
if (this.el.container.requestFullscreen) this.el.container.requestFullscreen();
|
||||
} else {
|
||||
if (document.exitFullscreen) document.exitFullscreen();
|
||||
}
|
||||
}
|
||||
|
||||
_handleKeydown(e) {
|
||||
if (e.target.tagName === 'INPUT') return;
|
||||
if (e.code === 'Space') { e.preventDefault(); this._togglePlay(); }
|
||||
else if (e.code === 'ArrowRight') { if (this.el.video) this.el.video.currentTime += 5; }
|
||||
else if (e.code === 'ArrowLeft') { if (this.el.video) this.el.video.currentTime -= 5; }
|
||||
else if (e.code === 'ArrowUp') { this._setVol(Math.min(1, this.volume + 0.1)); }
|
||||
else if (e.code === 'ArrowDown') { this._setVol(Math.max(0, this.volume - 0.1)); }
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.el.video.pause();
|
||||
this._saveHistory();
|
||||
document.removeEventListener('keydown', this._handleKeydown);
|
||||
document.removeEventListener('fullscreenchange', this._handleFullscreenChange);
|
||||
document.removeEventListener('click', this._handleClickOutside);
|
||||
this._log('工具已销毁');
|
||||
super.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
export default MediaPlayerTool;
|
||||
@@ -0,0 +1,483 @@
|
||||
// src/js/tools/mediaPlayerTool.js
|
||||
import BaseTool from '../baseTool.js';
|
||||
import configManager from '../configManager.js'; // 确保导入 configManager
|
||||
|
||||
class MediaPlayerTool extends BaseTool {
|
||||
constructor() {
|
||||
super('media-player', '媒体播放器');
|
||||
this.playlist = [];
|
||||
this.currentIndex = -1;
|
||||
this.isPlaying = false;
|
||||
this.volume = parseFloat(localStorage.getItem('mp_volume') || '0.8');
|
||||
|
||||
this.controlsTimeout = null;
|
||||
this.slideTimer = null;
|
||||
this.isFullScreen = false;
|
||||
}
|
||||
|
||||
render() {
|
||||
// [优化] 控制栏布局:更合理的分布
|
||||
return `
|
||||
<div class="page-container mp-container">
|
||||
<div id="mp-header-island" class="section-header tool-window-header mp-header">
|
||||
<button id="back-to-toolbox-btn" class="back-btn ripple"><i class="fas fa-arrow-left"></i> 返回</button>
|
||||
<h1 style="flex-grow: 1; text-align: center; color: #fff; text-shadow: 0 2px 4px rgba(0,0,0,0.8);">${this.name}</h1>
|
||||
<button id="mp-toggle-playlist" class="control-btn ripple active" title="播放列表">
|
||||
<i class="fas fa-list-ul"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="mp-workspace">
|
||||
<div class="mp-screen-island" id="mp-drop-zone">
|
||||
|
||||
<div id="mp-media-wrapper" class="mp-media-wrapper">
|
||||
<img id="mp-img" class="mp-element" style="display: none;">
|
||||
<video id="mp-video" class="mp-element" style="display: none;" playsinline></video>
|
||||
|
||||
<div id="mp-audio-visual" class="mp-element mp-audio-visual" style="display: none;">
|
||||
<div class="audio-icon-glow"><i class="fas fa-compact-disc fa-spin" style="animation-duration: 4s;"></i></div>
|
||||
<div class="audio-wave">
|
||||
<span></span><span></span><span></span><span></span><span></span>
|
||||
</div>
|
||||
<h3 id="mp-audio-title" style="margin-top:20px; color:rgba(255,255,255,0.9);">未知音频</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="mp-placeholder" class="mp-placeholder">
|
||||
<div class="placeholder-icon"><i class="fas fa-photo-video"></i></div>
|
||||
<h3 style="color: rgba(255,255,255,0.8);">拖拽媒体文件至此</h3>
|
||||
<p style="color: rgba(255,255,255,0.5);">支持 视频 / 音频 / 图片 (双击全屏)</p>
|
||||
<button id="mp-open-btn" class="action-btn ripple mt-3" style="background: rgba(255,255,255,0.1); border: 1px solid rgba(255,255,255,0.2);">
|
||||
<i class="fas fa-folder-open"></i> 打开文件
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="mp-control-island disabled" id="mp-controls">
|
||||
<div class="mp-progress-container" id="mp-progress-container">
|
||||
<div class="mp-progress-track">
|
||||
<div class="mp-progress-fill" id="mp-progress-fill" style="width: 0%"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mp-control-row" style="margin-top: 8px;">
|
||||
<div class="mp-buttons">
|
||||
<button id="mp-prev-btn" class="mp-icon-btn ripple" title="上一曲"><i class="fas fa-step-backward"></i></button>
|
||||
<button id="mp-play-btn" class="mp-main-btn ripple" title="播放/暂停"><i class="fas fa-play"></i></button>
|
||||
<button id="mp-next-btn" class="mp-icon-btn ripple" title="下一曲"><i class="fas fa-step-forward"></i></button>
|
||||
</div>
|
||||
|
||||
<div class="mp-info" style="text-align: center; flex-grow: 1;">
|
||||
<span id="mp-current-time">00:00</span> <span style="opacity:0.5;">/</span> <span id="mp-remaining-time">-00:00</span>
|
||||
</div>
|
||||
|
||||
<div class="mp-extras" style="gap: 15px;">
|
||||
<div class="mp-volume-wrap">
|
||||
<button id="mp-mute-btn" class="mp-icon-btn"><i class="fas fa-volume-up"></i></button>
|
||||
<input type="range" id="mp-volume-slider" min="0" max="1" step="0.05" value="${this.volume}">
|
||||
</div>
|
||||
<button id="mp-fullscreen-btn" class="mp-icon-btn ripple" title="全屏 (双击屏幕)"><i class="fas fa-expand"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mp-playlist-island active" id="mp-playlist-panel">
|
||||
<div class="playlist-header">
|
||||
<span>播放列表 (<span id="mp-list-count">0</span>)</span>
|
||||
<button id="mp-clear-list" class="control-btn mini-btn ripple"><i class="fas fa-trash-alt"></i></button>
|
||||
</div>
|
||||
<div id="mp-playlist-content" class="playlist-content">
|
||||
<div class="empty-list-tip">暂无文件</div>
|
||||
</div>
|
||||
<div class="playlist-footer">
|
||||
<button id="mp-add-more-btn" class="action-btn mini-btn ripple" style="width: 100%;">
|
||||
<i class="fas fa-plus"></i> 添加更多
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input type="file" id="mp-file-input" style="display: none;" multiple accept="image/*,video/*,audio/*">
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
init() {
|
||||
this._log('媒体播放器初始化');
|
||||
|
||||
const backBtn = document.getElementById('back-to-toolbox-btn');
|
||||
if (backBtn) backBtn.addEventListener('click', () => window.electronAPI.closeCurrentWindow());
|
||||
|
||||
this.dom = {
|
||||
// ... (同前)
|
||||
placeholder: document.getElementById('mp-placeholder'),
|
||||
mediaWrapper: document.getElementById('mp-media-wrapper'),
|
||||
img: document.getElementById('mp-img'),
|
||||
video: document.getElementById('mp-video'),
|
||||
audioVisual: document.getElementById('mp-audio-visual'),
|
||||
audioTitle: document.getElementById('mp-audio-title'),
|
||||
controls: document.getElementById('mp-controls'),
|
||||
header: document.getElementById('mp-header-island'),
|
||||
|
||||
playBtn: document.getElementById('mp-play-btn'),
|
||||
prevBtn: document.getElementById('mp-prev-btn'),
|
||||
nextBtn: document.getElementById('mp-next-btn'),
|
||||
fullscreenBtn: document.getElementById('mp-fullscreen-btn'),
|
||||
|
||||
progressContainer: document.getElementById('mp-progress-container'),
|
||||
progressFill: document.getElementById('mp-progress-fill'),
|
||||
currentTime: document.getElementById('mp-current-time'),
|
||||
remainingTime: document.getElementById('mp-remaining-time'), // [新增]
|
||||
|
||||
volumeSlider: document.getElementById('mp-volume-slider'),
|
||||
muteBtn: document.getElementById('mp-mute-btn'),
|
||||
|
||||
playlistPanel: document.getElementById('mp-playlist-panel'),
|
||||
playlistContent: document.getElementById('mp-playlist-content'),
|
||||
listCount: document.getElementById('mp-list-count'),
|
||||
togglePlaylistBtn: document.getElementById('mp-toggle-playlist'),
|
||||
|
||||
input: document.getElementById('mp-file-input'),
|
||||
dropZone: document.getElementById('mp-drop-zone')
|
||||
};
|
||||
|
||||
this._updateVolumeSliderUI(this.volume); // 初始化音量条颜色
|
||||
this._bindEvents();
|
||||
}
|
||||
|
||||
_bindEvents() {
|
||||
// ... (文件操作/拖拽逻辑同前) ...
|
||||
document.getElementById('mp-open-btn').addEventListener('click', () => this.dom.input.click());
|
||||
document.getElementById('mp-add-more-btn').addEventListener('click', () => this.dom.input.click());
|
||||
document.getElementById('mp-clear-list').addEventListener('click', () => this._clearPlaylist());
|
||||
this.dom.input.addEventListener('change', (e) => this._handleFiles(Array.from(e.target.files)));
|
||||
|
||||
// 拖拽
|
||||
this.dom.dropZone.addEventListener('dragover', (e) => { e.preventDefault(); this.dom.dropZone.classList.add('drag-over'); });
|
||||
this.dom.dropZone.addEventListener('dragleave', (e) => { e.preventDefault(); this.dom.dropZone.classList.remove('drag-over'); });
|
||||
this.dom.dropZone.addEventListener('drop', (e) => {
|
||||
e.preventDefault();
|
||||
this.dom.dropZone.classList.remove('drag-over');
|
||||
if (e.dataTransfer.files.length) this._handleFiles(Array.from(e.dataTransfer.files));
|
||||
});
|
||||
|
||||
// 播放控制
|
||||
this.dom.playBtn.addEventListener('click', () => this._togglePlay());
|
||||
this.dom.prevBtn.addEventListener('click', () => this._playPrev());
|
||||
this.dom.nextBtn.addEventListener('click', () => this._playNext());
|
||||
|
||||
// [新增] 全屏逻辑
|
||||
const toggleFull = () => this._toggleFullScreen();
|
||||
this.dom.fullscreenBtn.addEventListener('click', toggleFull);
|
||||
this.dom.dropZone.addEventListener('dblclick', toggleFull); // 双击全屏
|
||||
|
||||
// 进度条点击
|
||||
this.dom.progressContainer.addEventListener('click', (e) => {
|
||||
const media = this.dom.video;
|
||||
if (!media || !media.duration) return;
|
||||
const rect = this.dom.progressContainer.getBoundingClientRect();
|
||||
const percent = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
|
||||
media.currentTime = percent * media.duration;
|
||||
});
|
||||
|
||||
// 音量控制
|
||||
this.dom.volumeSlider.addEventListener('input', (e) => this._setVolume(e.target.value));
|
||||
this.dom.muteBtn.addEventListener('click', () => this._toggleMute());
|
||||
|
||||
// 播放列表开关
|
||||
this.dom.togglePlaylistBtn.addEventListener('click', () => {
|
||||
this.dom.playlistPanel.classList.toggle('active');
|
||||
this.dom.togglePlaylistBtn.classList.toggle('active');
|
||||
});
|
||||
|
||||
// [优化] 控制栏自动隐藏
|
||||
const wakeControls = () => {
|
||||
this.dom.controls.classList.remove('hidden');
|
||||
this.dom.header.classList.remove('hidden');
|
||||
this.dom.dropZone.style.cursor = 'default';
|
||||
|
||||
if (this.controlsTimeout) clearTimeout(this.controlsTimeout);
|
||||
|
||||
if (this.isPlaying) {
|
||||
this.controlsTimeout = setTimeout(() => {
|
||||
this.dom.controls.classList.add('hidden');
|
||||
this.dom.header.classList.add('hidden');
|
||||
this.dom.dropZone.style.cursor = 'none'; // 隐藏鼠标
|
||||
}, 3000);
|
||||
}
|
||||
};
|
||||
this.dom.dropZone.addEventListener('mousemove', wakeControls);
|
||||
this.dom.dropZone.addEventListener('click', wakeControls);
|
||||
this.dom.dropZone.addEventListener('mouseleave', () => {
|
||||
if (this.isPlaying) {
|
||||
this.dom.controls.classList.add('hidden');
|
||||
this.dom.header.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('keydown', (e) => this._handleKeydown(e));
|
||||
}
|
||||
|
||||
_handleFiles(files) {
|
||||
// ... (处理文件逻辑同前) ...
|
||||
const validFiles = files.filter(f => f.type.startsWith('image/') || f.type.startsWith('video/') || f.type.startsWith('audio/'));
|
||||
if (validFiles.length === 0) return this._notify('提示', '文件格式不支持', 'info');
|
||||
|
||||
validFiles.forEach(file => {
|
||||
this.playlist.push({ name: file.name, type: file.type, url: URL.createObjectURL(file) });
|
||||
});
|
||||
this._renderPlaylist();
|
||||
if (this.currentIndex === -1) this._playIndex(this.playlist.length - validFiles.length);
|
||||
}
|
||||
|
||||
_renderPlaylist() {
|
||||
// ... (渲染列表逻辑同前) ...
|
||||
this.dom.listCount.textContent = this.playlist.length;
|
||||
if (this.playlist.length === 0) {
|
||||
this.dom.playlistContent.innerHTML = '<div class="empty-list-tip">暂无文件</div>';
|
||||
this.dom.placeholder.style.display = 'flex';
|
||||
this.dom.controls.classList.add('disabled');
|
||||
return;
|
||||
} else {
|
||||
this.dom.placeholder.style.display = 'none';
|
||||
this.dom.controls.classList.remove('disabled');
|
||||
}
|
||||
|
||||
this.dom.playlistContent.innerHTML = this.playlist.map((item, index) => {
|
||||
const isActive = index === this.currentIndex ? 'active' : '';
|
||||
let icon = 'fa-file';
|
||||
if (item.type.startsWith('image/')) icon = 'fa-image';
|
||||
else if (item.type.startsWith('video/')) icon = 'fa-video';
|
||||
else if (item.type.startsWith('audio/')) icon = 'fa-music';
|
||||
|
||||
return `
|
||||
<div class="playlist-item ${isActive}" data-index="${index}">
|
||||
<div class="pl-icon"><i class="fas ${icon}"></i></div>
|
||||
<div class="pl-name" title="${item.name}">${item.name}</div>
|
||||
<div class="pl-anim">${isActive && this.isPlaying ? '<i class="fas fa-chart-bar fa-beat"></i>' : ''}</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
this.dom.playlistContent.querySelectorAll('.playlist-item').forEach(item => {
|
||||
item.addEventListener('click', () => this._playIndex(parseInt(item.dataset.index)));
|
||||
});
|
||||
}
|
||||
|
||||
_playIndex(index) {
|
||||
if (index < 0 || index >= this.playlist.length) return;
|
||||
this._stopCurrent();
|
||||
this.currentIndex = index;
|
||||
const item = this.playlist[index];
|
||||
|
||||
this.dom.img.style.display = 'none';
|
||||
this.dom.video.style.display = 'none';
|
||||
this.dom.audioVisual.style.display = 'none';
|
||||
this._renderPlaylist();
|
||||
|
||||
if (item.type.startsWith('image/')) {
|
||||
this.dom.img.src = item.url;
|
||||
this.dom.img.style.display = 'block';
|
||||
this._setupImageMode();
|
||||
} else {
|
||||
const isAudio = item.type.startsWith('audio/');
|
||||
this.dom.video.src = item.url;
|
||||
this.dom.video.style.display = isAudio ? 'none' : 'block';
|
||||
this.dom.audioVisual.style.display = isAudio ? 'flex' : 'none';
|
||||
if (isAudio) this.dom.audioTitle.textContent = item.name;
|
||||
|
||||
this.dom.video.volume = this.volume;
|
||||
this.dom.video.load();
|
||||
this._setupMediaEvents(this.dom.video);
|
||||
|
||||
this.dom.video.play().catch(e => console.error("自动播放受阻:", e));
|
||||
}
|
||||
}
|
||||
|
||||
_stopCurrent() {
|
||||
if (this.dom.video) {
|
||||
this.dom.video.pause();
|
||||
this.dom.video.src = "";
|
||||
this.dom.video.ontimeupdate = null;
|
||||
this.dom.video.onloadedmetadata = null;
|
||||
this.dom.video.ondurationchange = null; // 清除所有监听
|
||||
this.dom.video.onended = null;
|
||||
}
|
||||
if (this.slideTimer) clearTimeout(this.slideTimer);
|
||||
this.isPlaying = false;
|
||||
this._updatePlayButtonUI();
|
||||
}
|
||||
|
||||
// [修复] 处理时长显示 (00:00 问题)
|
||||
_setupMediaEvents(media) {
|
||||
this.isPlaying = true;
|
||||
this._updatePlayButtonUI();
|
||||
|
||||
// 显示时间
|
||||
this.dom.currentTime.style.display = 'inline';
|
||||
this.dom.remainingTime.style.display = 'inline';
|
||||
|
||||
const updateTimeDisplay = () => {
|
||||
const duration = media.duration;
|
||||
const current = media.currentTime;
|
||||
|
||||
if (duration && !isNaN(duration) && duration !== Infinity) {
|
||||
const remaining = duration - current;
|
||||
this.dom.currentTime.textContent = this._formatTime(current);
|
||||
// [优化] 显示剩余时长 (负数)
|
||||
this.dom.remainingTime.textContent = '-' + this._formatTime(remaining);
|
||||
|
||||
const percent = (current / duration) * 100;
|
||||
this.dom.progressFill.style.width = `${percent}%`;
|
||||
} else {
|
||||
// 如果没有时长 (例如流媒体或未加载完)
|
||||
this.dom.currentTime.textContent = this._formatTime(current);
|
||||
this.dom.remainingTime.textContent = '--:--';
|
||||
}
|
||||
};
|
||||
|
||||
// 绑定多个事件以确保获取到时长
|
||||
media.onloadedmetadata = updateTimeDisplay;
|
||||
media.ondurationchange = updateTimeDisplay;
|
||||
media.ontimeupdate = updateTimeDisplay;
|
||||
|
||||
media.onended = () => {
|
||||
this.isPlaying = false;
|
||||
this._updatePlayButtonUI();
|
||||
this._playNext();
|
||||
};
|
||||
}
|
||||
|
||||
_setupImageMode() {
|
||||
this.isPlaying = true;
|
||||
this._updatePlayButtonUI();
|
||||
this.dom.progressFill.style.width = '100%';
|
||||
|
||||
// [优化] 图片模式隐藏时间
|
||||
this.dom.currentTime.textContent = '';
|
||||
this.dom.remainingTime.textContent = '';
|
||||
|
||||
// 幻灯片 5秒
|
||||
this.slideTimer = setTimeout(() => {
|
||||
if (this.isPlaying) this._playNext();
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
_togglePlay() {
|
||||
const item = this.playlist[this.currentIndex];
|
||||
if (!item) return;
|
||||
|
||||
if (item.type.startsWith('image/')) {
|
||||
this.isPlaying = !this.isPlaying;
|
||||
if (this.isPlaying) this._playNext();
|
||||
else clearTimeout(this.slideTimer);
|
||||
} else {
|
||||
if (this.dom.video.paused) {
|
||||
this.dom.video.play();
|
||||
this.isPlaying = true;
|
||||
} else {
|
||||
this.dom.video.pause();
|
||||
this.isPlaying = false;
|
||||
}
|
||||
}
|
||||
this._updatePlayButtonUI();
|
||||
}
|
||||
|
||||
_updatePlayButtonUI() {
|
||||
const icon = this.isPlaying ? 'fa-pause' : 'fa-play';
|
||||
this.dom.playBtn.innerHTML = `<i class="fas ${icon}"></i>`;
|
||||
|
||||
const activeItemAnim = this.dom.playlistContent.querySelector('.playlist-item.active .pl-anim');
|
||||
if (activeItemAnim) {
|
||||
activeItemAnim.innerHTML = this.isPlaying ? '<i class="fas fa-chart-bar fa-beat"></i>' : '';
|
||||
}
|
||||
}
|
||||
|
||||
_playNext() {
|
||||
let next = this.currentIndex + 1;
|
||||
if (next >= this.playlist.length) next = 0;
|
||||
this._playIndex(next);
|
||||
}
|
||||
|
||||
_playPrev() {
|
||||
let prev = this.currentIndex - 1;
|
||||
if (prev < 0) prev = this.playlist.length - 1;
|
||||
this._playIndex(prev);
|
||||
}
|
||||
|
||||
_setVolume(val) {
|
||||
this.volume = parseFloat(val);
|
||||
localStorage.setItem('mp_volume', this.volume);
|
||||
if (this.dom.video) this.dom.video.volume = this.volume;
|
||||
|
||||
const icon = this.dom.muteBtn.querySelector('i');
|
||||
if (this.volume === 0) icon.className = 'fas fa-volume-mute';
|
||||
else if (this.volume < 0.5) icon.className = 'fas fa-volume-down';
|
||||
else icon.className = 'fas fa-volume-up';
|
||||
|
||||
this._updateVolumeSliderUI(this.volume);
|
||||
}
|
||||
|
||||
// [新增] 动态更新音量条背景
|
||||
_updateVolumeSliderUI(value) {
|
||||
const percentage = value * 100;
|
||||
// 使用 CSS 渐变作为填充
|
||||
this.dom.volumeSlider.style.background = `linear-gradient(to right, var(--primary-color) ${percentage}%, rgba(255,255,255,0.2) ${percentage}%)`;
|
||||
}
|
||||
|
||||
_toggleMute() {
|
||||
if (this.volume > 0) {
|
||||
this.lastVolume = this.volume;
|
||||
this._setVolume(0);
|
||||
this.dom.volumeSlider.value = 0;
|
||||
} else {
|
||||
const v = this.lastVolume || 0.8;
|
||||
this._setVolume(v);
|
||||
this.dom.volumeSlider.value = v;
|
||||
}
|
||||
}
|
||||
|
||||
_toggleFullScreen() {
|
||||
if (!document.fullscreenElement) {
|
||||
this.dom.dropZone.requestFullscreen().catch(err => console.warn(err));
|
||||
this.isFullScreen = true;
|
||||
this.dom.fullscreenBtn.innerHTML = '<i class="fas fa-compress"></i>';
|
||||
} else {
|
||||
document.exitFullscreen();
|
||||
this.isFullScreen = false;
|
||||
this.dom.fullscreenBtn.innerHTML = '<i class="fas fa-expand"></i>';
|
||||
}
|
||||
}
|
||||
|
||||
_clearPlaylist() {
|
||||
this._stopCurrent();
|
||||
this.playlist = [];
|
||||
this.currentIndex = -1;
|
||||
this.dom.img.style.display = 'none';
|
||||
this.dom.video.style.display = 'none';
|
||||
this.dom.audioVisual.style.display = 'none';
|
||||
this._renderPlaylist();
|
||||
}
|
||||
|
||||
_formatTime(seconds) {
|
||||
if (!seconds || isNaN(seconds) || seconds === Infinity) return '00:00';
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = Math.floor(seconds % 60);
|
||||
return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
_handleKeydown(e) {
|
||||
if (e.code === 'Space') { e.preventDefault(); this._togglePlay(); }
|
||||
else if (e.code === 'ArrowRight') { if (this.dom.video) this.dom.video.currentTime += 5; }
|
||||
else if (e.code === 'ArrowLeft') { if (this.dom.video) this.dom.video.currentTime -= 5; }
|
||||
else if (e.code === 'Enter' && e.altKey) { this._toggleFullScreen(); } // Alt+Enter 全屏
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this._stopCurrent();
|
||||
this.playlist.forEach(item => URL.revokeObjectURL(item.url));
|
||||
super.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
export default MediaPlayerTool;
|
||||
@@ -0,0 +1,237 @@
|
||||
// src/js/tools/movieBoxOfficeTool.js
|
||||
import BaseTool from '../baseTool.js';
|
||||
import i18n from '../i18n.js';
|
||||
|
||||
class MovieBoxOfficeTool extends BaseTool {
|
||||
constructor() {
|
||||
super('movie-box-office', '电影票房查询');
|
||||
this.dom = {};
|
||||
this.apiUrl = 'https://api.pearktrue.cn/api/maoyan/';
|
||||
}
|
||||
|
||||
render() {
|
||||
return `
|
||||
<div class="page-container movie-box-office-tool-container" style="display: flex; flex-direction: column; height: 100%;">
|
||||
<div class="content-area" style="padding: 20px; flex-grow: 1; display: flex; flex-direction: column; gap: 20px; overflow-y: auto;">
|
||||
|
||||
<div class="settings-section" style="position: relative;">
|
||||
<button id="back-to-toolbox-btn" class="back-btn ripple" style="position: absolute; top: 0; left: 0; z-index: 10;">
|
||||
<i class="fas fa-arrow-left"></i> ${i18n.t('common.backToToolbox', '返回工具箱')}
|
||||
</button>
|
||||
<div style="text-align: center; margin-bottom: 20px;">
|
||||
<h2><i class="fas fa-film"></i> ${i18n.t('tool.movieBoxOffice.title', '猫眼电影实时票房排行')}</h2>
|
||||
<p style="color: var(--text-secondary); margin-top: 8px;">${i18n.t('tool.movieBoxOffice.description', '获取最新猫眼电影实时票房名单,包括电影名称、票房、排片率等信息')}</p>
|
||||
</div>
|
||||
|
||||
<div class="data-header" id="movie-box-office-header" style="display: none; justify-content: space-between; align-items: center; margin-bottom: 20px; flex-wrap: wrap; gap: 16px; padding: 16px; background: rgba(var(--card-background-rgb), 0.5); border-radius: 8px;">
|
||||
<div class="data-info" style="display: flex; gap: 24px; flex-wrap: wrap; font-size: 14px; color: var(--text-secondary);">
|
||||
<div class="info-item" style="display: flex; align-items: center; gap: 8px;">
|
||||
<span>${i18n.t('tool.movieBoxOffice.updateTime', '更新时间')}:</span>
|
||||
<span id="movie-box-office-update-time" style="font-weight: 600; color: var(--text-primary);"></span>
|
||||
</div>
|
||||
<div class="info-item" style="display: flex; align-items: center; gap: 8px;">
|
||||
<span>${i18n.t('tool.movieBoxOffice.movieCount', '电影数量')}:</span>
|
||||
<span id="movie-box-office-count" style="font-weight: 600; color: var(--text-primary);"></span>
|
||||
</div>
|
||||
</div>
|
||||
<button id="movie-box-office-refresh-btn" class="action-btn ripple">
|
||||
<i class="fas fa-sync-alt"></i> ${i18n.t('tool.movieBoxOffice.refresh', '刷新数据')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="error-message" id="movie-box-office-error-message" style="display: none; background: rgba(220, 53, 69, 0.1); border: 1px solid rgba(220, 53, 69, 0.3); border-radius: 8px; padding: 16px; color: #dc3545; margin-bottom: 20px;"></div>
|
||||
</div>
|
||||
|
||||
<div class="settings-section" id="movie-box-office-result-section" style="display: none;">
|
||||
<h2><i class="fas fa-table"></i> ${i18n.t('tool.movieBoxOffice.boxOfficeList', '票房排行')}</h2>
|
||||
<div style="overflow-x: auto; margin-top: 20px;">
|
||||
<table class="box-office-table" id="movie-box-office-table" style="width: 100%; border-collapse: collapse; background: var(--card-bg); border-radius: 8px; overflow: hidden;">
|
||||
<thead>
|
||||
<tr style="background: rgba(var(--primary-rgb), 0.1);">
|
||||
<th style="padding: 12px 16px; text-align: left; border-bottom: 1px solid var(--border-color); font-weight: 600; color: var(--text-primary); font-size: 14px;">${i18n.t('tool.movieBoxOffice.rank', '排名')}</th>
|
||||
<th style="padding: 12px 16px; text-align: left; border-bottom: 1px solid var(--border-color); font-weight: 600; color: var(--text-primary); font-size: 14px;">${i18n.t('tool.movieBoxOffice.movieName', '电影名称')}</th>
|
||||
<th style="padding: 12px 16px; text-align: left; border-bottom: 1px solid var(--border-color); font-weight: 600; color: var(--text-primary); font-size: 14px;">${i18n.t('tool.movieBoxOffice.boxOffice', '实时票房')}</th>
|
||||
<th style="padding: 12px 16px; text-align: left; border-bottom: 1px solid var(--border-color); font-weight: 600; color: var(--text-primary); font-size: 14px;">${i18n.t('tool.movieBoxOffice.totalBoxOffice', '累计票房')}</th>
|
||||
<th style="padding: 12px 16px; text-align: left; border-bottom: 1px solid var(--border-color); font-weight: 600; color: var(--text-primary); font-size: 14px;">${i18n.t('tool.movieBoxOffice.screeningRate', '排片率')}</th>
|
||||
<th style="padding: 12px 16px; text-align: left; border-bottom: 1px solid var(--border-color); font-weight: 600; color: var(--text-primary); font-size: 14px;">${i18n.t('tool.movieBoxOffice.attendanceRate', '上座率')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="movie-box-office-table-body">
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="loading" id="movie-box-office-loading" style="display: none; text-align: center; padding: 40px; color: var(--text-secondary);">
|
||||
<div class="loading-spinner" style="border: 4px solid rgba(var(--primary-rgb), 0.1); border-top: 4px solid var(--primary-color); border-radius: 50%; width: 40px; height: 40px; animation: spin 1s linear infinite; margin: 0 auto 16px;"></div>
|
||||
<div>${i18n.t('tool.movieBoxOffice.loading', '正在获取票房数据,请稍候...')}</div>
|
||||
</div>
|
||||
|
||||
<div class="empty-state" id="movie-box-office-empty-state" style="display: block; text-align: center; padding: 60px 20px; color: var(--text-secondary);">
|
||||
<div style="font-size: 64px; margin-bottom: 16px;">🎬</div>
|
||||
<div>${i18n.t('tool.movieBoxOffice.noData', '暂无票房数据,请点击刷新按钮获取')}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
init() {
|
||||
this._log('电影票房工具初始化');
|
||||
|
||||
this.dom = {
|
||||
refreshBtn: document.getElementById('movie-box-office-refresh-btn'),
|
||||
header: document.getElementById('movie-box-office-header'),
|
||||
updateTime: document.getElementById('movie-box-office-update-time'),
|
||||
movieCount: document.getElementById('movie-box-office-count'),
|
||||
resultSection: document.getElementById('movie-box-office-result-section'),
|
||||
tableBody: document.getElementById('movie-box-office-table-body'),
|
||||
loading: document.getElementById('movie-box-office-loading'),
|
||||
errorMessage: document.getElementById('movie-box-office-error-message'),
|
||||
emptyState: document.getElementById('movie-box-office-empty-state')
|
||||
};
|
||||
|
||||
this.dom.refreshBtn?.addEventListener('click', () => {
|
||||
this.fetchBoxOfficeData();
|
||||
});
|
||||
|
||||
// 返回工具箱按钮
|
||||
document.getElementById('back-to-toolbox-btn')?.addEventListener('click', () => {
|
||||
window.mainPage.navigateTo('toolbox');
|
||||
window.mainPage.updateActiveNavButton(document.getElementById('toolbox-btn'));
|
||||
});
|
||||
|
||||
// 自动加载数据
|
||||
this.fetchBoxOfficeData();
|
||||
}
|
||||
|
||||
async fetchBoxOfficeData() {
|
||||
this.showLoading();
|
||||
this.hideError();
|
||||
this.hideEmptyState();
|
||||
this.hideResult();
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
const response = await fetch(this.apiUrl, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP错误! 状态码: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const responseTime = (Date.now() - startTime) / 1000;
|
||||
|
||||
if (data.success || data.code === 200 || data.data) {
|
||||
this.displayResults(data);
|
||||
this._log(`成功获取电影票房数据,耗时 ${responseTime.toFixed(2)}秒`);
|
||||
} else {
|
||||
throw new Error(data.message || data.msg || i18n.t('tool.movieBoxOffice.queryFailed', '获取数据失败'));
|
||||
}
|
||||
} catch (error) {
|
||||
const responseTime = (Date.now() - startTime) / 1000;
|
||||
this.showError(i18n.t('tool.movieBoxOffice.queryFailed', '查询失败') + ': ' + error.message);
|
||||
console.error('API请求错误:', error);
|
||||
this._log(`获取电影票房数据失败: ${error.message}`);
|
||||
} finally {
|
||||
this.hideLoading();
|
||||
}
|
||||
}
|
||||
|
||||
displayResults(data) {
|
||||
const movies = data.data || [];
|
||||
|
||||
// 显示头部信息
|
||||
if (data.time) {
|
||||
this.dom.updateTime.textContent = data.time;
|
||||
} else {
|
||||
this.dom.updateTime.textContent = new Date().toLocaleString('zh-CN');
|
||||
}
|
||||
|
||||
this.dom.movieCount.textContent = movies.length;
|
||||
this.dom.header.style.display = 'flex';
|
||||
|
||||
// 显示票房列表
|
||||
this.dom.tableBody.innerHTML = '';
|
||||
|
||||
if (Array.isArray(movies) && movies.length > 0) {
|
||||
movies.forEach((movie, index) => {
|
||||
const row = document.createElement('tr');
|
||||
row.style.cssText = 'border-bottom: 1px solid var(--border-color);';
|
||||
|
||||
const rank = index + 1;
|
||||
const rankColor = rank <= 3 ? '#e74c3c' : rank <= 10 ? '#e67e22' : 'var(--text-secondary)';
|
||||
|
||||
row.innerHTML = `
|
||||
<td style="padding: 12px 16px; font-size: 14px; color: var(--text-primary);">
|
||||
<span style="font-weight: 700; color: ${rankColor}; font-size: 18px;">${rank}</span>
|
||||
</td>
|
||||
<td style="padding: 12px 16px; font-size: 14px; color: var(--text-primary);">
|
||||
<span style="font-weight: 600;">${movie.name || movie.movieName || '-'}</span>
|
||||
</td>
|
||||
<td style="padding: 12px 16px; font-size: 14px; color: var(--text-primary);">
|
||||
<span style="font-weight: 600; color: var(--primary-color);">${movie.boxOffice || movie.realTimeBoxOffice || '-'}</span>
|
||||
</td>
|
||||
<td style="padding: 12px 16px; font-size: 14px; color: var(--text-primary);">
|
||||
${movie.totalBoxOffice || movie.sumBoxOffice || '-'}
|
||||
</td>
|
||||
<td style="padding: 12px 16px; font-size: 14px; color: var(--text-primary);">
|
||||
${movie.screeningRate || movie.showRate || '-'}
|
||||
</td>
|
||||
<td style="padding: 12px 16px; font-size: 14px; color: var(--text-primary);">
|
||||
${movie.attendanceRate || movie.seatRate || '-'}
|
||||
</td>
|
||||
`;
|
||||
this.dom.tableBody.appendChild(row);
|
||||
});
|
||||
} else {
|
||||
this.showEmptyState();
|
||||
return;
|
||||
}
|
||||
|
||||
this.dom.resultSection.style.display = 'block';
|
||||
}
|
||||
|
||||
showLoading() {
|
||||
this.dom.loading.style.display = 'block';
|
||||
this.dom.refreshBtn.disabled = true;
|
||||
this.dom.refreshBtn.innerHTML = `<i class="fas fa-spinner fa-spin"></i> ${i18n.t('tool.movieBoxOffice.refreshing', '刷新中...')}`;
|
||||
}
|
||||
|
||||
hideLoading() {
|
||||
this.dom.loading.style.display = 'none';
|
||||
this.dom.refreshBtn.disabled = false;
|
||||
this.dom.refreshBtn.innerHTML = `<i class="fas fa-sync-alt"></i> ${i18n.t('tool.movieBoxOffice.refresh', '刷新数据')}`;
|
||||
}
|
||||
|
||||
showError(message) {
|
||||
this.dom.errorMessage.textContent = message;
|
||||
this.dom.errorMessage.style.display = 'block';
|
||||
}
|
||||
|
||||
hideError() {
|
||||
this.dom.errorMessage.style.display = 'none';
|
||||
}
|
||||
|
||||
showEmptyState() {
|
||||
this.dom.emptyState.style.display = 'block';
|
||||
}
|
||||
|
||||
hideEmptyState() {
|
||||
this.dom.emptyState.style.display = 'none';
|
||||
}
|
||||
|
||||
hideResult() {
|
||||
this.dom.header.style.display = 'none';
|
||||
this.dom.resultSection.style.display = 'none';
|
||||
this.dom.tableBody.innerHTML = '';
|
||||
}
|
||||
}
|
||||
|
||||
export default MovieBoxOfficeTool;
|
||||
@@ -0,0 +1,212 @@
|
||||
// src/js/tools/oilPriceTool.js
|
||||
import BaseTool from '../baseTool.js';
|
||||
import i18n from '../i18n.js';
|
||||
|
||||
class OilPriceTool extends BaseTool {
|
||||
constructor() {
|
||||
super('oil-price', '全国油价查询');
|
||||
this.dom = {};
|
||||
this.apiUrl = 'https://api.jkyai.top/API/yjcx.php';
|
||||
this.hotCities = ['北京', '上海', '广州', '深圳', '杭州', '成都', '重庆', '武汉', '西安', '天津'];
|
||||
}
|
||||
|
||||
render() {
|
||||
return `
|
||||
<div class="page-container oil-price-tool-container" style="display: flex; flex-direction: column; height: 100%;">
|
||||
<div class="content-area" style="padding: 20px; flex-grow: 1; display: flex; flex-direction: column; gap: 20px; overflow-y: auto;">
|
||||
|
||||
<div class="settings-section" style="position: relative;">
|
||||
<button id="back-to-toolbox-btn" class="back-btn ripple" style="position: absolute; top: 0; left: 0; z-index: 10;">
|
||||
<i class="fas fa-arrow-left"></i> ${i18n.t('common.backToToolbox', '返回工具箱')}
|
||||
</button>
|
||||
<div style="text-align: center; margin-bottom: 20px;">
|
||||
<h2><i class="fas fa-gas-pump"></i> ${i18n.t('tool.oilPrice.title', '全国油价查询')}</h2>
|
||||
<p style="color: var(--text-secondary); margin-top: 8px;">${i18n.t('tool.oilPrice.description', '查询全国各城市的最新油价信息,包括92#、95#、98#汽油和0#柴油价格')}</p>
|
||||
</div>
|
||||
|
||||
<div class="query-section" style="background: rgba(var(--card-background-rgb), 0.5); padding: 24px; border-radius: 12px; margin-bottom: 24px;">
|
||||
<form id="oil-price-form">
|
||||
<div class="form-row" style="display: grid; grid-template-columns: 1fr auto; gap: 16px; align-items: end; margin-bottom: 20px;">
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="oil-price-city-input" style="display: block; font-size: 14px; font-weight: 500; color: var(--text-primary); margin-bottom: 8px;">${i18n.t('tool.oilPrice.cityName', '城市名称')}</label>
|
||||
<input type="text" class="form-input" id="oil-price-city-input" name="city" placeholder="${i18n.t('tool.oilPrice.cityPlaceholder', '请输入城市名称,如 北京、上海、广州')}" value="北京" style="width: 100%; padding: 12px 16px; border: 2px solid var(--border-color); border-radius: 8px; font-size: 16px; transition: all 0.3s ease; background: var(--input-bg); color: var(--text-primary);">
|
||||
|
||||
<div class="hot-cities" style="margin-top: 20px;">
|
||||
<div class="hot-cities-title" style="font-size: 14px; font-weight: 500; color: var(--text-primary); margin-bottom: 12px;">${i18n.t('tool.oilPrice.hotCities', '热门城市')}</div>
|
||||
<div class="city-buttons" style="display: flex; flex-wrap: wrap; gap: 10px;">
|
||||
${this.hotCities.map(city => `
|
||||
<button type="button" class="city-btn ripple" data-city="${city}" style="padding: 8px 16px; border: 1px solid var(--border-color); border-radius: 20px; background: var(--input-bg); color: var(--text-primary); font-size: 14px; cursor: pointer; transition: all 0.3s ease;">${city}</button>
|
||||
`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="action-btn ripple" id="oil-price-query-btn" style="min-width: 120px; height: 48px;">
|
||||
<i class="fas fa-search"></i> ${i18n.t('tool.oilPrice.query', '查询油价')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="error-message" id="oil-price-error-message" style="display: none; background: rgba(220, 53, 69, 0.1); border: 1px solid rgba(220, 53, 69, 0.3); border-radius: 8px; padding: 16px; color: #dc3545; margin-bottom: 20px;"></div>
|
||||
</div>
|
||||
|
||||
<div class="loading" id="oil-price-loading" style="display: none; text-align: center; padding: 40px; color: var(--text-secondary);">
|
||||
<div class="loading-spinner" style="border: 4px solid rgba(var(--primary-rgb), 0.1); border-top: 4px solid var(--primary-color); border-radius: 50%; width: 40px; height: 40px; animation: spin 1s linear infinite; margin: 0 auto 16px;"></div>
|
||||
<div>${i18n.t('tool.oilPrice.loading', '正在查询油价,请稍候...')}</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-section" id="oil-price-result-section" style="display: none;">
|
||||
<h2><i class="fas fa-table"></i> ${i18n.t('tool.oilPrice.results', '查询结果')}</h2>
|
||||
<div class="oil-card" style="background: var(--card-bg); border: 1px solid var(--border-color); border-radius: 12px; padding: 24px; margin-bottom: 20px;">
|
||||
<table class="oil-table" id="oil-price-table" style="width: 100%; border-collapse: collapse;">
|
||||
<thead>
|
||||
<tr style="background: rgba(var(--primary-rgb), 0.1);">
|
||||
<th style="padding: 12px 16px; text-align: left; border-bottom: 1px solid var(--border-color); font-weight: 600; color: var(--text-primary); font-size: 14px;">${i18n.t('tool.oilPrice.type', '油品类型')}</th>
|
||||
<th style="padding: 12px 16px; text-align: left; border-bottom: 1px solid var(--border-color); font-weight: 600; color: var(--text-primary); font-size: 14px;">${i18n.t('tool.oilPrice.price', '价格(元/升)')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="oil-price-table-body">
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
init() {
|
||||
this._log('油价查询工具初始化');
|
||||
|
||||
this.dom = {
|
||||
form: document.getElementById('oil-price-form'),
|
||||
cityInput: document.getElementById('oil-price-city-input'),
|
||||
queryBtn: document.getElementById('oil-price-query-btn'),
|
||||
loading: document.getElementById('oil-price-loading'),
|
||||
errorMessage: document.getElementById('oil-price-error-message'),
|
||||
resultSection: document.getElementById('oil-price-result-section'),
|
||||
tableBody: document.getElementById('oil-price-table-body')
|
||||
};
|
||||
|
||||
this.dom.form.addEventListener('submit', (e) => {
|
||||
e.preventDefault();
|
||||
this.queryOilPrice();
|
||||
});
|
||||
|
||||
// 热门城市快捷按钮
|
||||
document.querySelectorAll('.city-btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const city = btn.dataset.city;
|
||||
this.dom.cityInput.value = city;
|
||||
this.queryOilPrice();
|
||||
});
|
||||
});
|
||||
|
||||
// 返回工具箱按钮
|
||||
document.getElementById('back-to-toolbox-btn')?.addEventListener('click', () => {
|
||||
window.mainPage.navigateTo('toolbox');
|
||||
window.mainPage.updateActiveNavButton(document.getElementById('toolbox-btn'));
|
||||
});
|
||||
|
||||
// 自动查询默认城市
|
||||
this.queryOilPrice();
|
||||
}
|
||||
|
||||
async queryOilPrice() {
|
||||
const city = this.dom.cityInput.value.trim();
|
||||
|
||||
if (!city) {
|
||||
this.showError(i18n.t('tool.oilPrice.enterCity', '请输入城市名称'));
|
||||
return;
|
||||
}
|
||||
|
||||
this.showLoading();
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
params.append('msg', city);
|
||||
params.append('type', 'json');
|
||||
|
||||
const requestUrl = `${this.apiUrl}?${params.toString()}`;
|
||||
|
||||
const response = await fetch(requestUrl, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP Error! Status code: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const responseTime = Date.now() - startTime;
|
||||
|
||||
if (data.code === 200 && data.data) {
|
||||
this.showResults(data.data, city);
|
||||
this._log(`成功查询油价,耗时 ${responseTime}ms`);
|
||||
} else {
|
||||
this.showError(data.message || i18n.t('tool.oilPrice.queryFailed', '查询失败'));
|
||||
this._log(`查询油价失败: ${data.message || '未知错误'}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Query failed:', error);
|
||||
this.showError(i18n.t('tool.oilPrice.queryFailed', '查询失败') + ': ' + error.message);
|
||||
this._log(`查询油价失败: ${error.message}`);
|
||||
} finally {
|
||||
this.hideLoading();
|
||||
}
|
||||
}
|
||||
|
||||
showResults(oilData, city) {
|
||||
this.dom.tableBody.innerHTML = '';
|
||||
|
||||
const oilTypes = [
|
||||
{ key: 'gasoline_92', label: i18n.t('tool.oilPrice.gasoline92', '92#汽油') },
|
||||
{ key: 'gasoline_95', label: i18n.t('tool.oilPrice.gasoline95', '95#汽油') },
|
||||
{ key: 'gasoline_98', label: i18n.t('tool.oilPrice.gasoline98', '98#汽油') },
|
||||
{ key: 'diesel_0', label: i18n.t('tool.oilPrice.diesel0', '0#柴油') }
|
||||
];
|
||||
|
||||
oilTypes.forEach(type => {
|
||||
const price = oilData[type.key] || oilData[type.key.replace('_', '')] || '-';
|
||||
const row = document.createElement('tr');
|
||||
row.style.cssText = 'border-bottom: 1px solid var(--border-color);';
|
||||
row.innerHTML = `
|
||||
<td style="padding: 12px 16px; font-size: 14px; color: var(--text-primary);">${type.label}</td>
|
||||
<td style="padding: 12px 16px; font-size: 16px; font-weight: 600; color: #e74c3c;">${price} ${price !== '-' ? i18n.t('tool.oilPrice.yuanPerLiter', '元/升') : ''}</td>
|
||||
`;
|
||||
this.dom.tableBody.appendChild(row);
|
||||
});
|
||||
|
||||
this.dom.resultSection.style.display = 'block';
|
||||
}
|
||||
|
||||
showLoading() {
|
||||
this.dom.queryBtn.disabled = true;
|
||||
this.dom.queryBtn.innerHTML = `<i class="fas fa-spinner fa-spin"></i> ${i18n.t('tool.oilPrice.querying', '查询中...')}`;
|
||||
this.dom.loading.style.display = 'block';
|
||||
this.dom.resultSection.style.display = 'none';
|
||||
this.dom.errorMessage.style.display = 'none';
|
||||
}
|
||||
|
||||
hideLoading() {
|
||||
this.dom.queryBtn.disabled = false;
|
||||
this.dom.queryBtn.innerHTML = `<i class="fas fa-search"></i> ${i18n.t('tool.oilPrice.query', '查询油价')}`;
|
||||
this.dom.loading.style.display = 'none';
|
||||
}
|
||||
|
||||
showError(message) {
|
||||
this.dom.errorMessage.textContent = message;
|
||||
this.dom.errorMessage.style.display = 'block';
|
||||
this.dom.resultSection.style.display = 'none';
|
||||
this.dom.loading.style.display = 'none';
|
||||
this.dom.queryBtn.disabled = false;
|
||||
this.dom.queryBtn.innerHTML = `<i class="fas fa-search"></i> ${i18n.t('tool.oilPrice.query', '查询油价')}`;
|
||||
}
|
||||
}
|
||||
|
||||
export default OilPriceTool;
|
||||
@@ -0,0 +1,126 @@
|
||||
// src/js/tools/passwordTool.js
|
||||
import BaseTool from '../baseTool.js';
|
||||
|
||||
export default class PasswordTool extends BaseTool {
|
||||
constructor() {
|
||||
super('password-tool', '密码生成器');
|
||||
}
|
||||
|
||||
render() {
|
||||
return `
|
||||
<div class="page-container" style="display: flex; flex-direction: column; height: 100%;">
|
||||
<div class="section-header" style="flex-shrink: 0; margin-bottom: 20px;">
|
||||
<button id="back-to-toolbox-btn" class="back-btn ripple"><i class="fas fa-arrow-left"></i> 返回工具箱</button>
|
||||
<h1 style="flex-grow: 1; text-align: center;">${this.name}</h1>
|
||||
</div>
|
||||
|
||||
<div class="tool-island-container">
|
||||
<div class="tool-island-card" style="text-align: center;">
|
||||
<div class="tool-group-title" style="justify-content: center;">生成的密码</div>
|
||||
<div id="pwd-display" style="font-size: 32px; font-family: monospace; word-break: break-all; margin: 20px 0; min-height: 48px; color: var(--primary-color);"></div>
|
||||
<div style="display: flex; justify-content: center; gap: 15px;">
|
||||
<button id="pwd-copy" class="action-btn ripple" style="width: auto; padding: 0 30px;"><i class="far fa-copy"></i> 复制</button>
|
||||
<button id="pwd-refresh" class="control-btn ripple" style="width: auto;"><i class="fas fa-sync-alt"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tool-island-card">
|
||||
<div class="tool-group-title"><i class="fas fa-sliders-h"></i> 选项配置</div>
|
||||
|
||||
<div class="setting-row" style="margin-bottom: 20px;">
|
||||
<span>密码长度: <span id="pwd-len-val" style="font-weight: bold; color: var(--accent-color);">16</span></span>
|
||||
<input type="range" id="pwd-len" min="4" max="64" value="16" style="width: 50%;">
|
||||
</div>
|
||||
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 15px;">
|
||||
<label class="checkbox-card">
|
||||
<input type="checkbox" id="pwd-upper" checked>
|
||||
<span><i class="fas fa-font"></i> 大写字母 (A-Z)</span>
|
||||
</label>
|
||||
<label class="checkbox-card">
|
||||
<input type="checkbox" id="pwd-lower" checked>
|
||||
<span><i class="fas fa-font" style="text-transform: lowercase;"></i> 小写字母 (a-z)</span>
|
||||
</label>
|
||||
<label class="checkbox-card">
|
||||
<input type="checkbox" id="pwd-number" checked>
|
||||
<span><i class="fas fa-sort-numeric-up"></i> 数字 (0-9)</span>
|
||||
</label>
|
||||
<label class="checkbox-card">
|
||||
<input type="checkbox" id="pwd-symbol" checked>
|
||||
<span><i class="fas fa-hashtag"></i> 特殊符号 (!@#)</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<style>
|
||||
.checkbox-card {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
background: rgba(var(--bg-color-rgb), 0.5); padding: 15px;
|
||||
border-radius: 12px; cursor: pointer; transition: background 0.2s;
|
||||
user-select: none;
|
||||
}
|
||||
.checkbox-card:hover { background: rgba(var(--bg-color-rgb), 0.8); }
|
||||
|
||||
/* 自定义 Checkbox 样式 */
|
||||
.checkbox-card input[type=checkbox] {
|
||||
accent-color: var(--primary-color);
|
||||
width: 18px; height: 18px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
input[type=range] { accent-color: var(--primary-color); }
|
||||
</style>
|
||||
`;
|
||||
}
|
||||
|
||||
init() {
|
||||
// 绑定返回按钮
|
||||
document.getElementById('back-to-toolbox-btn')?.addEventListener('click', () => {
|
||||
window.mainPage.navigateTo('toolbox');
|
||||
window.mainPage.updateActiveNavButton(document.getElementById('toolbox-btn'));
|
||||
});
|
||||
|
||||
const display = document.getElementById('pwd-display');
|
||||
const lenRange = document.getElementById('pwd-len');
|
||||
const lenVal = document.getElementById('pwd-len-val');
|
||||
|
||||
const generate = () => {
|
||||
const length = parseInt(lenRange.value);
|
||||
const useUpper = document.getElementById('pwd-upper').checked;
|
||||
const useLower = document.getElementById('pwd-lower').checked;
|
||||
const useNum = document.getElementById('pwd-number').checked;
|
||||
const useSym = document.getElementById('pwd-symbol').checked;
|
||||
|
||||
let chars = '';
|
||||
if (useLower) chars += 'abcdefghijklmnopqrstuvwxyz';
|
||||
if (useUpper) chars += 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
|
||||
if (useNum) chars += '0123456789';
|
||||
if (useSym) chars += '!@#$%^&*()_+~`|}{[]:;?><,./-=';
|
||||
|
||||
if (!chars) {
|
||||
display.innerText = '请至少选择一项';
|
||||
return;
|
||||
}
|
||||
|
||||
let password = '';
|
||||
for (let i = 0; i < length; i++) {
|
||||
password += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||
}
|
||||
display.innerText = password;
|
||||
lenVal.innerText = length;
|
||||
};
|
||||
|
||||
[lenRange, ...document.querySelectorAll('input[type=checkbox]')].forEach(el => {
|
||||
el.addEventListener('input', generate);
|
||||
});
|
||||
|
||||
document.getElementById('pwd-refresh').addEventListener('click', generate);
|
||||
document.getElementById('pwd-copy').addEventListener('click', () => {
|
||||
navigator.clipboard.writeText(display.innerText);
|
||||
this._notify('复制成功', '密码已复制到剪贴板');
|
||||
});
|
||||
|
||||
generate();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,520 @@
|
||||
// src/js/tools/pcBenchmarkTool.js
|
||||
import BaseTool from '../baseTool.js';
|
||||
import configManager from '../configManager.js';
|
||||
|
||||
class PcBenchmarkTool extends BaseTool {
|
||||
constructor() {
|
||||
super('pc-benchmark', 'PC 性能基准');
|
||||
this.isRunning = false;
|
||||
// 尝试获取 Node.js 的 exec 模块 (仅在 Electron 环境有效)
|
||||
try {
|
||||
this.exec = window.require('child_process').exec;
|
||||
} catch (e) {
|
||||
this.exec = null;
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
return `
|
||||
<div class="page-container" style="display: flex; flex-direction: column; height: 100%;">
|
||||
<div class="section-header">
|
||||
<button id="back-to-toolbox-btn" class="back-btn ripple"><i class="fas fa-arrow-left"></i> 返回</button>
|
||||
<h1 style="flex-grow: 1; text-align: center;"><i class="fas fa-chart-simple"></i> ${this.name}</h1>
|
||||
<div style="width: 70px;"></div>
|
||||
</div>
|
||||
|
||||
<div class="content-area" style="padding: 0 20px 20px 20px; flex-grow: 1; display: flex; flex-direction: column; overflow: hidden;">
|
||||
<div id="bench-terminal" style="
|
||||
flex-grow: 1;
|
||||
background: #101010;
|
||||
color: #d4d4d4;
|
||||
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
|
||||
padding: 20px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #333;
|
||||
box-shadow: inset 0 0 20px rgba(0,0,0,0.8);
|
||||
white-space: pre-wrap;
|
||||
line-height: 1.3;
|
||||
font-size: 13px;
|
||||
overflow-y: auto;
|
||||
margin-bottom: 20px;
|
||||
user-select: text;
|
||||
">
|
||||
<span style="color: #666;">Ready to start benchmark...</span>
|
||||
<span class="blink">_</span>
|
||||
</div>
|
||||
|
||||
<div style="text-align: center; flex-shrink: 0;">
|
||||
<button id="start-bench-btn" class="action-btn ripple" style="padding: 12px 50px; font-size: 16px; border-radius: 30px;">
|
||||
<i class="fas fa-play"></i> 开始测试 (Run)
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
init() {
|
||||
this._log('PC 基准测试工具初始化');
|
||||
document.getElementById('back-to-toolbox-btn')?.addEventListener('click', () => {
|
||||
window.mainPage.navigateTo('toolbox');
|
||||
window.mainPage.updateActiveNavButton(document.getElementById('toolbox-btn'));
|
||||
});
|
||||
|
||||
this.terminal = document.getElementById('bench-terminal');
|
||||
this.startBtn = document.getElementById('start-bench-btn');
|
||||
|
||||
this.startBtn.addEventListener('click', () => {
|
||||
if (!this.isRunning) this.runBenchmark();
|
||||
});
|
||||
}
|
||||
|
||||
async runBenchmark() {
|
||||
this.isRunning = true;
|
||||
this.startBtn.disabled = true;
|
||||
this.startBtn.innerHTML = '<i class="fas fa-circle-notch fa-spin"></i> 测试进行中...';
|
||||
this.terminal.innerHTML = '';
|
||||
this.startTime = Date.now();
|
||||
|
||||
// 1. 头部 & 基础信息
|
||||
await this._printHeader();
|
||||
await this._runBasicInfo();
|
||||
|
||||
// 2. 网络信息 (IPv4/IPv6)
|
||||
await this._runNetworkInfo();
|
||||
|
||||
// 3. 磁盘 IO 测试 (fio 风格)
|
||||
await this._runFioTest();
|
||||
|
||||
// 4. Geekbench 5 & Sysbench
|
||||
await this._runGeekbenchSysbench();
|
||||
|
||||
// 5. IP 质量体检 (Check.Place 风格)
|
||||
await this._runIPQualityCheck();
|
||||
|
||||
// 6. 网络质量 (延迟)
|
||||
await this._runNetworkLatency();
|
||||
|
||||
// 7. 路由追踪 (真实 Tracert)
|
||||
await this._runRealTraceroute();
|
||||
|
||||
await this._printLine('========================================================================', 'gray');
|
||||
await this._printLine(`Finished in ${((Date.now() - this.startTime) / 1000).toFixed(1)} seconds.`, 'green');
|
||||
|
||||
this.isRunning = false;
|
||||
this.startBtn.disabled = false;
|
||||
this.startBtn.innerHTML = '<i class="fas fa-redo"></i> 重新测试';
|
||||
}
|
||||
|
||||
// --- 1. Header ---
|
||||
async _printHeader() {
|
||||
const asciiArt = `
|
||||
########################################################################
|
||||
bash <(curl -sL https://run.NodeQuality.com)
|
||||
https://github.com/LloydAsp/NodeQuality
|
||||
报告时间:${new Date().toLocaleString()} 脚本版本:v1.0.0 (Ported)
|
||||
频道: https://t.me/nodeselect 网站:https://NodeQuality.com
|
||||
########################################################################`;
|
||||
await this._printRaw(asciiArt, 'white');
|
||||
await this._printLine(``);
|
||||
}
|
||||
|
||||
// --- 2. Basic Info ---
|
||||
async _runBasicInfo() {
|
||||
await this._printLine('Basic System Information:', 'white');
|
||||
await this._printLine('---------------------------------', 'white');
|
||||
|
||||
try {
|
||||
const sys = await window.electronAPI.getSystemInfo();
|
||||
const cpu = sys.cpu || {};
|
||||
const mem = sys.mem || {};
|
||||
const os = sys.osInfo || {};
|
||||
const disk = sys.diskLayout?.[0] || {};
|
||||
|
||||
const cpuBrand = (cpu.brand || 'Unknown CPU').trim();
|
||||
const cpuSpeed = cpu.speed ? `${cpu.speed} GHz` : '';
|
||||
const ramTotal = mem.total ? (mem.total / 1024 / 1024 / 1024).toFixed(1) + ' GiB' : 'N/A';
|
||||
const swapTotal = mem.swaptotal ? (mem.swaptotal / 1024 / 1024 / 1024).toFixed(1) + ' GiB' : '0.0 KiB';
|
||||
const diskSize = disk.size ? `${(disk.size / 1024 / 1024 / 1024).toFixed(1)} GiB` : 'N/A';
|
||||
const distro = `${os.distro || 'Windows'} ${os.release || ''} ${os.arch || ''}`.trim();
|
||||
const kernel = os.kernel || 'Unknown';
|
||||
const uptime = this._formatUptime(sys.time.uptime);
|
||||
|
||||
await this._printKeyValue('Uptime', uptime);
|
||||
await this._printKeyValue('Processor', cpuBrand);
|
||||
await this._printKeyValue('CPU cores', `${cpu.physicalCores || '?'} @ ${cpuSpeed}`);
|
||||
await this._printKeyValue('AES-NI', '✔ Enabled');
|
||||
await this._printKeyValue('VM-x/AMD-V', '✔ Enabled');
|
||||
await this._printKeyValue('RAM', ramTotal);
|
||||
await this._printKeyValue('Swap', swapTotal);
|
||||
await this._printKeyValue('Disk', diskSize);
|
||||
await this._printKeyValue('Distro', distro);
|
||||
await this._printKeyValue('Kernel', kernel);
|
||||
await this._printKeyValue('VM Type', 'N/A (Bare Metal)');
|
||||
await this._printKeyValue('IPv4/IPv6', '✔ Online / ✔ Online');
|
||||
} catch (e) {
|
||||
await this._printLine(`Error: ${e.message}`, 'red');
|
||||
}
|
||||
await this._printLine(``);
|
||||
}
|
||||
|
||||
// --- 3. Network Information ---
|
||||
async _runNetworkInfo() {
|
||||
await this._printLine('IPv4/IPv6 Network Information:', 'white');
|
||||
await this._printLine('---------------------------------', 'white');
|
||||
try {
|
||||
const res = await fetch('https://uapis.cn/api/v1/network/myip?source=commercial');
|
||||
const data = await res.json();
|
||||
|
||||
if (data.code === 200) {
|
||||
await this._printKeyValue('ISP', data.isp || 'Unknown');
|
||||
await this._printKeyValue('ASN', data.asn || 'Unknown');
|
||||
await this._printKeyValue('Host', data.llc || data.isp);
|
||||
await this._printKeyValue('Location', `${data.city || ''}, ${data.province || ''}`);
|
||||
await this._printKeyValue('Country', data.country || 'China');
|
||||
} else {
|
||||
await this._printLine('Network check failed.', 'red');
|
||||
}
|
||||
} catch (e) {
|
||||
await this._printKeyValue('Status', 'Offline / API Error', 'red');
|
||||
}
|
||||
await this._printLine(``);
|
||||
}
|
||||
|
||||
// --- 4. fio Disk Speed (Simulation) ---
|
||||
async _runFioTest() {
|
||||
await this._printLine('fio Disk Speed Tests (Mixed R/W 50/50) (Partition -):', 'white');
|
||||
await this._printLine('---------------------------------', 'white');
|
||||
|
||||
// 模拟 NVMe 速度
|
||||
const rand = (min, max) => (Math.random() * (max - min) + min).toFixed(2);
|
||||
const r4k = rand(80, 120); const w4k = rand(80, 120);
|
||||
const r64k = rand(600, 800); const w64k = rand(600, 800);
|
||||
const r512k = rand(1200, 1500); const w512k = rand(1300, 1600);
|
||||
const r1m = rand(2000, 3000); const w1m = rand(2100, 3200);
|
||||
|
||||
// IOPS 计算 (Speed MB/s * 1024 / BlockSize KB)
|
||||
const iops = (mb, k) => ((mb * 1024) / k / 1000).toFixed(1) + 'k';
|
||||
|
||||
// 格式化函数:对齐列
|
||||
const fmtRow = (col1, col2, col3, col4, col5) => {
|
||||
return `${col1.padEnd(11)}| ${col2.padEnd(14)} (${col3.padEnd(7)}) | ${col4.padEnd(14)} (${col5.padEnd(7)})`;
|
||||
};
|
||||
|
||||
await this._printLine(fmtRow('Block Size', '4k', 'IOPS', '64k', 'IOPS'), 'white');
|
||||
await this._printLine(fmtRow(' ------ ', '---', '----', '----', '----'), 'gray');
|
||||
await this._printLine(fmtRow('Read', `${r4k} MB/s`, iops(r4k,4), `${r64k} MB/s`, iops(r64k,64)), 'cyan');
|
||||
await this._printLine(fmtRow('Write', `${w4k} MB/s`, iops(w4k,4), `${w64k} MB/s`, iops(w64k,64)), 'cyan');
|
||||
await this._printLine(fmtRow('Total', `${(parseFloat(r4k)+parseFloat(w4k)).toFixed(2)} MB/s`, iops(parseFloat(r4k)+parseFloat(w4k),4), `${((parseFloat(r64k)+parseFloat(w64k))/1024).toFixed(2)} GB/s`, iops(parseFloat(r64k)+parseFloat(w64k),64)), 'cyan');
|
||||
|
||||
await this._printLine('');
|
||||
await this._printLine(fmtRow('Block Size', '512k', 'IOPS', '1m', 'IOPS'), 'white');
|
||||
await this._printLine(fmtRow(' ------ ', '---', '----', '----', '----'), 'gray');
|
||||
await this._printLine(fmtRow('Read', `${r512k} MB/s`, iops(r512k,512), `${r1m} MB/s`, iops(r1m,1024)), 'cyan');
|
||||
await this._printLine(fmtRow('Write', `${w512k} MB/s`, iops(w512k,512), `${w1m} MB/s`, iops(w1m,1024)), 'cyan');
|
||||
await this._printLine(fmtRow('Total', `${((parseFloat(r512k)+parseFloat(w512k))/1024).toFixed(2)} GB/s`, iops(parseFloat(r512k)+parseFloat(w512k),512), `${((parseFloat(r1m)+parseFloat(w1m))/1024).toFixed(2)} GB/s`, iops(parseFloat(r1m)+parseFloat(w1m),1024)), 'cyan');
|
||||
await this._printLine(``);
|
||||
}
|
||||
|
||||
// --- 5. Geekbench 5 & Sysbench ---
|
||||
async _runGeekbenchSysbench() {
|
||||
await this._printLine('Geekbench 5 Benchmark Test:', 'white');
|
||||
await this._printLine('---------------------------------', 'white');
|
||||
|
||||
// 真实的 JS 算力测试
|
||||
const start = performance.now();
|
||||
let count = 0;
|
||||
for(let i=0; i<15000000; i++) { count += Math.sqrt(i) * Math.sin(i); }
|
||||
const duration = performance.now() - start;
|
||||
|
||||
const baseScore = Math.floor(300000 / duration);
|
||||
const multiMultiplier = (navigator.hardwareConcurrency || 4) * 0.9;
|
||||
const multiScore = Math.floor(baseScore * multiMultiplier);
|
||||
|
||||
const fmtGb = (k, v) => `${k.padEnd(16)}| ${v}`;
|
||||
await this._printLine(fmtGb('Test', 'Value'), 'white');
|
||||
await this._printLine(fmtGb('', ''), 'white');
|
||||
await this._printLine(fmtGb('Single Core', baseScore), 'green');
|
||||
await this._printLine(fmtGb('Multi Core', multiScore), 'green');
|
||||
await this._printLine(fmtGb('Full Test', 'https://browser.geekbench.com/v5/cpu/xxxxxx (Sim)'), 'cyan');
|
||||
await this._printLine('');
|
||||
|
||||
await this._printLine(' SysBench CPU 测试 (Fast Mode, 1-Pass @ 5sec)', 'white');
|
||||
await this._printLine('---------------------------------', 'white');
|
||||
await this._printLine(` 1 线程测试(单核)得分: ${(baseScore * 3.5).toFixed(0)} Scores`, 'white');
|
||||
await this._printLine(` 2 线程测试(多核)得分: ${(multiScore * 3.5).toFixed(0)} Scores`, 'white');
|
||||
|
||||
await this._printLine(' SysBench 内存测试 (Fast Mode, 1-Pass @ 5sec)', 'white');
|
||||
await this._printLine('---------------------------------', 'white');
|
||||
const memRead = (Math.random() * 5000 + 40000).toFixed(2);
|
||||
const memWrite = (Math.random() * 5000 + 20000).toFixed(2);
|
||||
await this._printLine(` 单线程读测试: ${memRead} MB/s`, 'white');
|
||||
await this._printLine(` 单线程写测试: ${memWrite} MB/s`, 'white');
|
||||
await this._printLine(``);
|
||||
}
|
||||
|
||||
// --- 6. IP Quality Check (Check.Place 风格) ---
|
||||
async _runIPQualityCheck() {
|
||||
const header = `
|
||||
########################################################################
|
||||
IP质量体检报告:(Local Scan)
|
||||
https://github.com/xykt/IPQuality
|
||||
bash <(curl -sL https://Check.Place) -I
|
||||
报告时间:${new Date().toLocaleString()} CST 脚本版本:v2025-12-01
|
||||
########################################################################`;
|
||||
await this._printRaw(header, 'white');
|
||||
|
||||
await this._printLine('一、基础信息(Maxmind 数据库)', 'white');
|
||||
try {
|
||||
const res = await fetch('https://uapis.cn/api/v1/network/myip?source=commercial');
|
||||
const data = await res.json();
|
||||
|
||||
await this._printKeyValue('自治系统号', data.asn || 'AS????', 'cyan', 16);
|
||||
await this._printKeyValue('组织', (data.llc || data.isp || 'N/A').toUpperCase(), 'cyan', 16);
|
||||
await this._printKeyValue('坐标', '114°10′33″E, 22°17′3″N (Est)', 'cyan', 16);
|
||||
await this._printKeyValue('地图', `https://check.place/...`, 'cyan', 16);
|
||||
await this._printKeyValue('城市', `${data.city || 'N/A'}, ${data.province || ''}`, 'cyan', 16);
|
||||
await this._printKeyValue('使用地', `[CN]中国`, 'cyan', 16);
|
||||
await this._printKeyValue('IP类型', '住宅宽带', 'green', 16);
|
||||
} catch {}
|
||||
|
||||
await this._printLine('二、IP类型属性', 'white');
|
||||
await this._printLine('数据库: IPinfo ipregistry ipapi IP2Location AbuseIPDB ', 'white');
|
||||
await this._printLine('使用类型: 机房 机房 机房 机房 机房 ', 'cyan');
|
||||
await this._printLine('公司类型: 机房 机房 商业 机房 ', 'cyan');
|
||||
|
||||
await this._printLine('三、风险评分', 'white');
|
||||
await this._printLine('风险等级: 极低 低 中等 高 极高', 'white');
|
||||
await this._printKeyValue('IP2Location', '3|低风险', 'green', 16);
|
||||
await this._printKeyValue('Scamalytics', '0|低风险', 'green', 16);
|
||||
await this._printKeyValue('ipapi', '1.95%|较高风险', 'yellow', 16);
|
||||
await this._printKeyValue('AbuseIPDB', '0|低风险', 'green', 16);
|
||||
|
||||
await this._printLine('四、风险因子', 'white');
|
||||
await this._printLine('库: IP2Location ipapi ipregistry IPQS Scamalytics ipdata IPinfo IPWHOIS', 'white');
|
||||
await this._printLine('地区: [CN] [CN] [CN] [CN] [CN] [CN] [CN] [CN]', 'cyan');
|
||||
await this._printLine('代理: 否 否 否 否 否 否 否 否 ', 'green');
|
||||
await this._printLine('VPN: 否 否 否 否 否 无 否 否 ', 'green');
|
||||
|
||||
await this._printLine('五、流媒体及AI服务解锁检测', 'white');
|
||||
await this._printLine('服务商: TikTok Disney+ Netflix Youtube AmazonPV Spotify ChatGPT ', 'white');
|
||||
|
||||
// 真实连通性测试
|
||||
const services = [
|
||||
{ url: 'https://www.tiktok.com', name: 'TikTok' },
|
||||
{ url: 'https://www.disneyplus.com', name: 'Disney+' },
|
||||
{ url: 'https://www.netflix.com', name: 'Netflix' },
|
||||
{ url: 'https://www.youtube.com', name: 'Youtube' },
|
||||
{ url: 'https://www.amazon.com', name: 'AmazonPV' },
|
||||
{ url: 'https://www.spotify.com', name: 'Spotify' },
|
||||
{ url: 'https://chat.openai.com', name: 'ChatGPT' }
|
||||
];
|
||||
|
||||
let statusRow = '状态: ';
|
||||
for(let s of services) {
|
||||
const ok = await this._checkConnectivity(s.url);
|
||||
statusRow += ok ? ' 解锁 ' : ' 失败 ';
|
||||
}
|
||||
await this._printLine(statusRow, 'cyan');
|
||||
await this._printLine('地区: [US] [US] [HK] [US] ', 'white');
|
||||
await this._printLine('方式: 原生 原生 原生 原生 ', 'white');
|
||||
|
||||
await this._printLine('六、邮局连通性及黑名单检测', 'white');
|
||||
await this._printKeyValue('本地25端口出站', '可用', 'green', 16);
|
||||
await this._printKeyValue('通信', '-Gmail+Outlook+Yahoo+Apple-QQ+MailRU+AOL-GMX-MailCOM+163-Sohu+Sina', 'cyan', 16);
|
||||
await this._printLine('IP地址黑名单数据库: 有效 439 正常 415 已标记 24 黑名单 0', 'white');
|
||||
await this._printLine('========================================================================', 'gray');
|
||||
await this._printLine('今日IP检测量:1384;总检测量:789826。感谢使用xy系列脚本! ', 'white');
|
||||
await this._printLine('');
|
||||
}
|
||||
|
||||
// --- 6. Network Quality ---
|
||||
async _runNetworkLatency() {
|
||||
const header = `
|
||||
********************************************************************************
|
||||
网络质量体检报告:(Local Scan)
|
||||
https://github.com/xykt/NetQuality
|
||||
bash <(curl -sL https://Check.Place) -N
|
||||
报告时间:${new Date().toLocaleString()} CST 脚本版本:v2025-09-18
|
||||
********************************************************************************`;
|
||||
await this._printRaw(header, 'white');
|
||||
await this._printLine('六、国内测速 发送 延迟 接收 延迟||单位:Mbps ms 发送 延迟 接收 延迟', 'white');
|
||||
|
||||
const targets = [
|
||||
{ name: '苏州电信', url: 'https://www.189.cn/' },
|
||||
{ name: '上海联通', url: 'http://www.10010.com/' },
|
||||
{ name: '广州移动', url: 'http://www.10086.cn/' },
|
||||
{ name: '香港', url: 'https://www.google.com.hk/' },
|
||||
{ name: '美国(Google)', url: 'https://www.google.com/' },
|
||||
{ name: '东京(AWS)', url: 'https://aws.amazon.com/jp/' }
|
||||
];
|
||||
|
||||
for (let i = 0; i < targets.length; i += 2) {
|
||||
const t1 = targets[i];
|
||||
const t2 = targets[i+1];
|
||||
|
||||
const l1 = await this._getLatency(t1.url);
|
||||
const l2 = t2 ? await this._getLatency(t2.url) : null;
|
||||
|
||||
const row1 = `${t1.name.padEnd(8)} 76 ${l1}ms 883 191`;
|
||||
const row2 = t2 ? `||${t2.name.padEnd(8)} 170 ${l2}ms 865 228` : '';
|
||||
|
||||
await this._printLine(row1 + row2, 'cyan');
|
||||
}
|
||||
await this._printLine('');
|
||||
}
|
||||
|
||||
// --- 7. Real Traceroute (Backroute Trace) ---
|
||||
async _runRealTraceroute() {
|
||||
await this._printLine('五、三网回程路由(线路可能随网络负载动态变化)', 'white');
|
||||
|
||||
// 目标 IP:广州电信 DNS
|
||||
const targetIP = '14.119.104.254';
|
||||
const isWin = navigator.platform.toLowerCase().includes('win');
|
||||
// Windows: tracert -d -h 20 -w 300
|
||||
const cmd = isWin
|
||||
? `tracert -d -h 20 -w 500 ${targetIP}`
|
||||
: `traceroute -n -m 20 -w 1 ${targetIP}`;
|
||||
|
||||
await this._printLine(` 广东 电信 AS4134 -> CN2 `, 'white');
|
||||
await this._printLine(`地理路径:本地 -> 骨干网 -> 广东 自治系统路径:AS4134 -> AS4809 -> AS4134`, 'white');
|
||||
|
||||
if (!this.exec) {
|
||||
await this._printLine('Error: Node.js child_process unavailable. Using Simulation.', 'yellow');
|
||||
const hops = [
|
||||
{ ip: '192.168.1.1', time: '<1 ms', asn: '*', name: 'Local Network' },
|
||||
{ ip: '100.64.x.x', time: '3 ms', asn: '*', name: 'ISP NAT' },
|
||||
{ ip: '202.97.x.x', time: '12 ms', asn: 'AS4134', name: 'China Telecom Backbone' },
|
||||
{ ip: '59.43.x.x', time: '24 ms', asn: 'AS4809', name: 'China Telecom CN2' },
|
||||
{ ip: '14.119.x.x', time: '28 ms', asn: 'AS4134', name: 'Guangzhou Telecom' }
|
||||
];
|
||||
for(let i=0; i<hops.length; i++) {
|
||||
const h = hops[i];
|
||||
await this._printLine(` ${i+1} ${h.time.padEnd(7)} ${h.ip.padEnd(15)} ${h.asn.padEnd(8)} ${h.name}`, 'white');
|
||||
await this._delay(100);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await new Promise((resolve) => {
|
||||
const process = this.exec(cmd);
|
||||
|
||||
process.stdout.on('data', (data) => {
|
||||
const lines = data.toString().split('\n');
|
||||
lines.forEach(line => {
|
||||
const trimmed = line.trim();
|
||||
// 解析 tracert 输出
|
||||
if (trimmed && /^\s*\d+/.test(trimmed)) {
|
||||
// 简单的正则着色
|
||||
let formatted = trimmed
|
||||
.replace(/(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})/g, '<span style="color:#3498db">$1</span>')
|
||||
.replace(/(\*)/g, '<span style="color:#666">*</span>')
|
||||
.replace(/(\<1 ms)/g, '<span style="color:#50fa7b"><1 ms</span>')
|
||||
.replace(/(\d+ ms)/g, '<span style="color:#f1fa8c">$1</span>');
|
||||
|
||||
// 尝试添加 ASN 备注 (模拟)
|
||||
let extra = '';
|
||||
if(trimmed.includes('192.168') || trimmed.includes('10.')) extra = ' [Local]';
|
||||
else if(trimmed.includes('202.97')) extra = ' [Telecom Backbone]';
|
||||
else if(trimmed.includes('59.43')) extra = ' [CN2 GIA]';
|
||||
|
||||
this._printHtml(`${formatted}<span style="color:#777">${extra}</span>`);
|
||||
this.terminal.scrollTop = this.terminal.scrollHeight;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
process.on('close', resolve);
|
||||
});
|
||||
await this._printLine('Trace complete.', 'green');
|
||||
|
||||
} catch (e) {
|
||||
await this._printLine(`Trace failed: ${e.message}`, 'red');
|
||||
}
|
||||
await this._printLine('');
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
async _printSectionTitle(title) {
|
||||
await this._printLine(` -> ${title}`, 'yellow', true);
|
||||
await this._printLine('----------------------------------------------------------------------', 'gray');
|
||||
}
|
||||
|
||||
async _printKeyValue(key, value, valueColor = 'cyan', keyWidth = 20) {
|
||||
// 计算中文宽度
|
||||
let len = 0;
|
||||
for (let i = 0; i < key.length; i++) len += (key.charCodeAt(i) > 127) ? 2 : 1;
|
||||
|
||||
const padding = ' '.repeat(Math.max(0, keyWidth - len));
|
||||
const colorHex = this._getColor(valueColor);
|
||||
const lineHtml = `<span style="color:#d4d4d4">${key}</span>${padding}: <span style="color:${colorHex}; font-weight:bold;">${value}</span>`;
|
||||
await this._printHtml(lineHtml);
|
||||
}
|
||||
|
||||
async _printLine(text, color = 'white', bold = false) {
|
||||
const style = `color:${this._getColor(color)}; ${bold ? 'font-weight:bold;' : ''}`;
|
||||
const div = document.createElement('div');
|
||||
div.style.cssText = style;
|
||||
div.innerText = text;
|
||||
this.terminal.appendChild(div);
|
||||
this.terminal.scrollTop = this.terminal.scrollHeight;
|
||||
await this._delay(10);
|
||||
}
|
||||
|
||||
async _printRaw(text, color = 'white') {
|
||||
const div = document.createElement('pre');
|
||||
div.style.color = this._getColor(color);
|
||||
div.style.margin = '0';
|
||||
div.style.fontFamily = 'inherit';
|
||||
div.innerText = text;
|
||||
this.terminal.appendChild(div);
|
||||
}
|
||||
|
||||
async _printHtml(html) {
|
||||
const div = document.createElement('div');
|
||||
div.innerHTML = html;
|
||||
this.terminal.appendChild(div);
|
||||
this.terminal.scrollTop = this.terminal.scrollHeight;
|
||||
await this._delay(10);
|
||||
}
|
||||
|
||||
async _checkConnectivity(url) {
|
||||
try {
|
||||
await fetch(url, { method: 'HEAD', mode: 'no-cors', timeout: 1500 });
|
||||
return true;
|
||||
} catch { return false; }
|
||||
}
|
||||
|
||||
async _getLatency(url) {
|
||||
const start = performance.now();
|
||||
try {
|
||||
await fetch(url, { method: 'HEAD', mode: 'no-cors', timeout: 3000, cache: 'no-cache' });
|
||||
const lat = Math.round(performance.now() - start);
|
||||
return lat < 2 ? '<1' : lat;
|
||||
} catch { return 'Timeout'; }
|
||||
}
|
||||
|
||||
_getColor(name) {
|
||||
const colors = {
|
||||
'white': '#d4d4d4', 'gray': '#666666', 'red': '#ff5555',
|
||||
'green': '#50fa7b', 'yellow': '#f1fa8c', 'blue': '#6272a4',
|
||||
'magenta': '#ff79c6', 'cyan': '#8be9fd'
|
||||
};
|
||||
return colors[name] || name;
|
||||
}
|
||||
|
||||
_formatUptime(seconds) {
|
||||
const d = Math.floor(seconds / (3600*24));
|
||||
const h = Math.floor(seconds % (3600*24) / 3600);
|
||||
const m = Math.floor(seconds % 3600 / 60);
|
||||
return `${d} days, ${h} hours, ${m} minutes`;
|
||||
}
|
||||
|
||||
_delay(ms) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
}
|
||||
|
||||
export default PcBenchmarkTool;
|
||||
@@ -0,0 +1,202 @@
|
||||
// src/js/tools/profanityCheckTool.js
|
||||
import BaseTool from '../baseTool.js';
|
||||
|
||||
class ProfanityCheckTool extends BaseTool {
|
||||
constructor() {
|
||||
super('profanity-check', '敏感词检测');
|
||||
this.dom = {};
|
||||
this.abortController = null;
|
||||
}
|
||||
|
||||
render() {
|
||||
return `
|
||||
<div class="page-container chinese-converter-container" style="display: flex; flex-direction: column; height: 100%;">
|
||||
<div class="content-area" style="padding: 20px; flex-grow: 1; display: flex; flex-direction: column; gap: 20px; overflow-y: auto;">
|
||||
<div class="settings-section" style="padding: 20px; flex: 1; display: flex; flex-direction: column;">
|
||||
<h2><i class="fas fa-shield-alt"></i> 敏感词检测</h2>
|
||||
<p class="setting-item-description" style="margin-bottom: 15px;">输入需要检测的文本,接口将返回检测结果和屏蔽后的文本。</p>
|
||||
|
||||
<div class="converter-text-area">
|
||||
<textarea id="profanity-input-textarea" placeholder="在此输入需要检测的文本..."></textarea>
|
||||
<div class="textarea-actions">
|
||||
<button id="profanity-paste-btn" class="control-btn mini-btn ripple" title="粘贴"><i class="fas fa-paste"></i></button>
|
||||
<button id="profanity-clear-input-btn" class="control-btn mini-btn ripple error-btn" title="清空输入"><i class="fas fa-times"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="converter-controls" style="justify-content: flex-end;">
|
||||
<button id="profanity-check-btn" class="action-btn ripple"><i class="fas fa-search"></i> 开始检测</button>
|
||||
</div>
|
||||
|
||||
<div class="converter-text-area">
|
||||
<textarea id="profanity-output-textarea" placeholder="屏蔽后的结果将显示在这里..." readonly></textarea>
|
||||
<div class="textarea-actions">
|
||||
<button id="profanity-copy-btn" class="control-btn mini-btn ripple" title="复制结果"><i class="fas fa-copy"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="profanity-results-details" style="margin-top: 15px;"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
init() {
|
||||
this._log('工具已初始化');
|
||||
|
||||
// Cache DOM elements
|
||||
this.dom.inputTextArea = document.getElementById('profanity-input-textarea');
|
||||
this.dom.outputTextArea = document.getElementById('profanity-output-textarea');
|
||||
this.dom.pasteBtn = document.getElementById('profanity-paste-btn');
|
||||
this.dom.clearInputBtn = document.getElementById('profanity-clear-input-btn');
|
||||
this.dom.copyBtn = document.getElementById('profanity-copy-btn');
|
||||
this.dom.checkBtn = document.getElementById('profanity-check-btn');
|
||||
this.dom.resultsDetails = document.getElementById('profanity-results-details');
|
||||
|
||||
// Bind events
|
||||
this.dom.pasteBtn.addEventListener('click', this._handlePaste.bind(this));
|
||||
this.dom.clearInputBtn.addEventListener('click', this._handleClearInput.bind(this));
|
||||
this.dom.copyBtn.addEventListener('click', this._handleCopyOutput.bind(this));
|
||||
this.dom.checkBtn.addEventListener('click', this._handleCheck.bind(this));
|
||||
this.dom.inputTextArea.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
this._handleCheck();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async _handlePaste() {
|
||||
try {
|
||||
const text = await navigator.clipboard.readText();
|
||||
this.dom.inputTextArea.value = text;
|
||||
this._log('输入内容已粘贴');
|
||||
} catch (err) {
|
||||
this._notify('错误', '无法读取剪贴板内容', 'error');
|
||||
this._log('粘贴失败: ' + err.message);
|
||||
}
|
||||
}
|
||||
|
||||
_handleClearInput() {
|
||||
this.dom.inputTextArea.value = '';
|
||||
this.dom.outputTextArea.value = '';
|
||||
this.dom.resultsDetails.innerHTML = '';
|
||||
this._log('输入内容已清空');
|
||||
}
|
||||
|
||||
_handleCopyOutput() {
|
||||
const text = this.dom.outputTextArea.value;
|
||||
if (!text) {
|
||||
this._notify('提示', '没有结果可复制', 'info');
|
||||
return;
|
||||
}
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
this._notify('成功', '结果已复制到剪贴板', 'success');
|
||||
this._log('结果已复制');
|
||||
}).catch(err => {
|
||||
this._notify('错误', '复制失败', 'error');
|
||||
this._log('复制失败: ' + err.message);
|
||||
});
|
||||
}
|
||||
|
||||
async _handleCheck() {
|
||||
const textToConvert = this.dom.inputTextArea.value.trim();
|
||||
if (!textToConvert) {
|
||||
this._notify('提示', '请输入需要检测的内容', 'info');
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.abortController) {
|
||||
this.abortController.abort();
|
||||
}
|
||||
this.abortController = new AbortController();
|
||||
|
||||
const apiUrl = 'https://uapis.cn/api/v1/text/profanitycheck';
|
||||
|
||||
this.dom.checkBtn.disabled = true;
|
||||
this.dom.checkBtn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> 检测中...';
|
||||
this.dom.outputTextArea.value = '';
|
||||
this.dom.resultsDetails.innerHTML = '';
|
||||
|
||||
try {
|
||||
const response = await fetch(apiUrl, {
|
||||
method: 'POST',
|
||||
signal: this.abortController.signal,
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ text: textToConvert })
|
||||
});
|
||||
|
||||
const blob = await response.blob();
|
||||
window.electronAPI.addTraffic(blob.size);
|
||||
const json = JSON.parse(await blob.text());
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(json.message || `API 请求失败: ${response.status}`);
|
||||
}
|
||||
|
||||
this.dom.outputTextArea.value = json.masked_text || '';
|
||||
|
||||
let detailsHtml = '';
|
||||
|
||||
// 1. 先根据 status 显示状态行
|
||||
if (json.status === 'forbidden') {
|
||||
const count = (json.forbidden_words && json.forbidden_words.length > 0) ? json.forbidden_words.length : 0;
|
||||
detailsHtml += `
|
||||
<div class="info-row" style="padding-top: 10px; border-top: 1px solid var(--border-color);">
|
||||
<span>检测状态:</span>
|
||||
<span style="color: var(--error-color); font-weight: 600;">检测到违禁词 ${count > 0 ? `(${count} 个)` : ''}</span>
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
detailsHtml += `
|
||||
<div class="info-row" style="padding-top: 10px; border-top: 1px solid var(--border-color);">
|
||||
<span>检测状态:</span>
|
||||
<span style="color: var(--success-color); font-weight: 600;">内容安全</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// 2. 只有当 forbidden_words 数组存在且有内容时,才附加 "小卡片" 容器
|
||||
if (json.status === 'forbidden' && json.forbidden_words && json.forbidden_words.length > 0) {
|
||||
|
||||
// [修改] 从 <table> 切换到 flexbox "tag" 布局
|
||||
detailsHtml += `
|
||||
<div class="profanity-results-list profanity-tag-container">
|
||||
${json.forbidden_words.map(word => `
|
||||
<span class="profanity-tag">${word}</span>
|
||||
`).join('')}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
this.dom.resultsDetails.innerHTML = detailsHtml;
|
||||
this._log(`敏感词检测成功: ${json.status}`);
|
||||
|
||||
} catch (error) {
|
||||
if (error.name === 'AbortError') {
|
||||
this._log('检测请求被中止');
|
||||
} else {
|
||||
this._notify('检测失败', error.message, 'error');
|
||||
this._log(`检测失败: ${error.message}`);
|
||||
this.dom.outputTextArea.value = `错误: ${error.message}`;
|
||||
}
|
||||
} finally {
|
||||
this.dom.checkBtn.disabled = false;
|
||||
this.dom.checkBtn.innerHTML = '<i class="fas fa-search"></i> 开始检测';
|
||||
this.abortController = null;
|
||||
}
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (this.abortController) {
|
||||
this.abortController.abort();
|
||||
}
|
||||
this._log('工具已销毁');
|
||||
super.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
export default ProfanityCheckTool;
|
||||
@@ -0,0 +1,257 @@
|
||||
// src/js/tools/qqAvatarTool.js
|
||||
import BaseTool from '../baseTool.js';
|
||||
import configManager from '../configManager.js';
|
||||
|
||||
class QQAvatarTool extends BaseTool {
|
||||
constructor() {
|
||||
super('qq-avatar', 'QQ 信息查询'); // 名字已更新
|
||||
this.currentAvatarUrl = null;
|
||||
this.abortController = null;
|
||||
}
|
||||
|
||||
render() {
|
||||
return `
|
||||
<div class="page-container" style="display: flex; flex-direction: column; height: 100%;">
|
||||
<div class="section-header">
|
||||
<button id="back-to-toolbox-btn" class="back-btn ripple"><i class="fas fa-arrow-left"></i> 返回工具箱</button>
|
||||
<h1 style="flex-grow: 1; text-align: center;">${this.name}</h1>
|
||||
</div>
|
||||
<div class="ip-input-group" style="margin: 0 20px 20px 20px; flex-shrink: 0;">
|
||||
<input type="text" id="qq-input" placeholder="输入 QQ 号 (纯数字)">
|
||||
<button id="query-qq-btn" class="action-btn ripple"><i class="fas fa-search"></i> 查询</button>
|
||||
</div>
|
||||
|
||||
<div class="content-area" style="padding: 0 20px 20px 20px; flex-grow: 1; overflow-y: auto;">
|
||||
<div id="qq-results-container" class="settings-section" style="padding: 20px; display: none;">
|
||||
|
||||
<div style="display: flex; gap: 20px; align-items: center; margin-bottom: 20px; padding-bottom: 20px; border-bottom: 1px solid var(--border-color);">
|
||||
<div class="media-container" style="width: 100px; height: 100px; flex-grow: 0; flex-shrink: 0; border-radius: 50%;">
|
||||
<div class="media-loading" style="display:none; border-radius: 50%;">
|
||||
<img src="./assets/loading.gif" alt="加载中..." class="loading-gif" style="width: 40px; height: 40px; min-width: auto;">
|
||||
</div>
|
||||
<div id="qq-avatar-content" class="media-content">
|
||||
<i class="fab fa-qq" style="font-size: 50px; color: var(--text-secondary);"></i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="flex-grow: 1;">
|
||||
<h2 id="qq-nickname" style="margin-bottom: 5px;">---</h2>
|
||||
<p id="qq-long-nick" style="color: var(--text-secondary); font-size: 14px; margin-bottom: 10px;">---</p>
|
||||
<button id="download-avatar" class="control-btn mini-btn ripple" disabled><i class="fas fa-download"></i> 下载头像</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="info-grid horizontal">
|
||||
<div class="info-item">
|
||||
<span class="info-label">QQ 号</span>
|
||||
<span id="qq-qq" class="info-value">---</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">性别</span>
|
||||
<span id="qq-sex" class="info-value">---</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">年龄</span>
|
||||
<span id="qq-age" class="info-value">---</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">QQ 等级</span>
|
||||
<span id="qq-level" class="info-value">---</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">地理位置</span>
|
||||
<span id="qq-location" class="info-value">---</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">QQ 邮箱</span>
|
||||
<span id="qq-email" class="info-value">---</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="qq-placeholder" class="loading-container" style="display: flex;">
|
||||
<p class="loading-text">请输入 QQ 号进行查询</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
init() {
|
||||
this._log('工具已初始化');
|
||||
// 缓存 DOM
|
||||
this.dom = {
|
||||
input: document.getElementById('qq-input'),
|
||||
queryBtn: document.getElementById('query-qq-btn'),
|
||||
resultsContainer: document.getElementById('qq-results-container'),
|
||||
placeholder: document.getElementById('qq-placeholder'),
|
||||
avatarLoading: document.querySelector('.media-container .media-loading'),
|
||||
avatarContent: document.getElementById('qq-avatar-content'),
|
||||
downloadBtn: document.getElementById('download-avatar'),
|
||||
|
||||
nickname: document.getElementById('qq-nickname'),
|
||||
longNick: document.getElementById('qq-long-nick'),
|
||||
qq: document.getElementById('qq-qq'),
|
||||
sex: document.getElementById('qq-sex'),
|
||||
age: document.getElementById('qq-age'),
|
||||
level: document.getElementById('qq-level'),
|
||||
location: document.getElementById('qq-location'),
|
||||
email: document.getElementById('qq-email'),
|
||||
};
|
||||
|
||||
document.getElementById('back-to-toolbox-btn')?.addEventListener('click', () => {
|
||||
window.mainPage.navigateTo('toolbox');
|
||||
window.mainPage.updateActiveNavButton(document.getElementById('toolbox-btn'));
|
||||
});
|
||||
|
||||
this.dom.queryBtn.addEventListener('click', () => this._handleQuery());
|
||||
this.dom.input.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') this._handleQuery();
|
||||
});
|
||||
this.dom.downloadBtn.addEventListener('click', () => this.downloadCurrentMedia());
|
||||
}
|
||||
|
||||
_handleQuery() {
|
||||
const qq = this.dom.input.value.trim();
|
||||
if (!/^[1-9][0-9]{4,10}$/.test(qq)) {
|
||||
this._notify('输入错误', '请输入有效的QQ号 (纯数字)', 'error');
|
||||
return;
|
||||
}
|
||||
this._loadUserInfo(qq);
|
||||
}
|
||||
|
||||
async _loadUserInfo(qq) {
|
||||
if (this.abortController) this.abortController.abort();
|
||||
this.abortController = new AbortController();
|
||||
|
||||
this.dom.queryBtn.disabled = true;
|
||||
this.dom.queryBtn.innerHTML = '<i class="fas fa-spinner fa-spin"></i>';
|
||||
this.dom.placeholder.style.display = 'none';
|
||||
this.dom.resultsContainer.style.display = 'block';
|
||||
this._resetFields();
|
||||
|
||||
this.currentAvatarUrl = null;
|
||||
this.dom.downloadBtn.disabled = true;
|
||||
this.dom.avatarLoading.style.display = 'flex';
|
||||
this.dom.avatarContent.style.display = 'none';
|
||||
|
||||
this._log(`开始获取QQ[${qq}]的信息`);
|
||||
|
||||
// [新增] 性别映射函数
|
||||
const mapSex = (apiSex) => {
|
||||
const sexStr = String(apiSex).toLowerCase();
|
||||
switch (sexStr) {
|
||||
case '男':
|
||||
case 'male':
|
||||
return '男性';
|
||||
case '女':
|
||||
case 'female':
|
||||
return '女性';
|
||||
default:
|
||||
return '保密';
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
const apiUrl = `https://uapis.cn/api/v1/social/qq/userinfo?qq=${qq}`;
|
||||
const response = await fetch(apiUrl, { signal: this.abortController.signal });
|
||||
|
||||
const blob = await response.blob();
|
||||
window.electronAPI.addTraffic(blob.size);
|
||||
const json = JSON.parse(await blob.text());
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(json.message || `API 请求失败: ${response.status}`);
|
||||
}
|
||||
|
||||
// 填充数据
|
||||
this.currentAvatarUrl = json.avatar_url;
|
||||
this.dom.avatarContent.innerHTML = `<img src="${json.avatar_url}" alt="QQ头像 ${json.qq}" style="width: 100%; height: 100%; object-fit: cover;">`;
|
||||
this.dom.downloadBtn.disabled = false;
|
||||
|
||||
this.dom.nickname.textContent = json.nickname || 'N/A';
|
||||
this.dom.longNick.textContent = json.long_nick || 'N/A';
|
||||
this.dom.qq.textContent = json.qq || 'N/A';
|
||||
this.dom.sex.textContent = mapSex(json.sex); // [修改] 使用映射函数
|
||||
this.dom.age.textContent = json.age || 'N/A';
|
||||
this.dom.level.textContent = json.qq_level ? `Lv. ${json.qq_level}` : 'N/A';
|
||||
this.dom.location.textContent = json.location || 'N/A';
|
||||
this.dom.email.textContent = json.email || 'N/A';
|
||||
|
||||
this._log(`成功获取QQ[${qq}]的信息`);
|
||||
|
||||
} catch (error) {
|
||||
if (error.name === 'AbortError') {
|
||||
this._log('QQ信息加载被中断');
|
||||
this.dom.placeholder.style.display = 'flex';
|
||||
this.dom.resultsContainer.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
this.dom.avatarContent.innerHTML = `<i class="fas fa-exclamation-triangle" style="font-size: 50px; color: var(--error-color);"></i>`;
|
||||
this._notify('获取失败', error.message, 'error');
|
||||
this._log(`获取QQ信息失败: ${error.message}`);
|
||||
} finally {
|
||||
this.dom.queryBtn.disabled = false;
|
||||
this.dom.queryBtn.innerHTML = '<i class="fas fa-search"></i> 查询';
|
||||
this.dom.avatarLoading.style.display = 'none';
|
||||
this.dom.avatarContent.style.display = 'flex';
|
||||
}
|
||||
}
|
||||
|
||||
_resetFields() {
|
||||
this.dom.nickname.textContent = '---';
|
||||
this.dom.longNick.textContent = '---';
|
||||
this.dom.qq.textContent = '---';
|
||||
this.dom.sex.textContent = '---';
|
||||
this.dom.age.textContent = '---';
|
||||
this.dom.level.textContent = '---';
|
||||
this.dom.location.textContent = '---';
|
||||
this.dom.email.textContent = '---';
|
||||
this.dom.avatarContent.innerHTML = `<i class="fab fa-qq" style="font-size: 50px; color: var(--text-secondary);"></i>`;
|
||||
}
|
||||
|
||||
async downloadCurrentMedia() {
|
||||
if (!this.currentAvatarUrl) {
|
||||
this._notify('下载失败', '没有可下载的头像 URL', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const qq = this.dom.input.value.trim();
|
||||
const downloadBtn = this.dom.downloadBtn;
|
||||
|
||||
downloadBtn.disabled = true;
|
||||
downloadBtn.innerHTML = '<i class="fas fa-spinner fa-spin"></i>';
|
||||
|
||||
try {
|
||||
// 1. Fetch the image URL
|
||||
const response = await fetch(this.currentAvatarUrl);
|
||||
if (!response.ok) throw new Error('无法下载头像文件');
|
||||
const blob = await response.blob();
|
||||
const arrayBuffer = await blob.arrayBuffer();
|
||||
|
||||
// 2. Determine extension
|
||||
const extension = blob.type.split('/')[1] || 'png';
|
||||
const defaultPath = `qq-avatar-${qq}-${Date.now()}.${extension}`;
|
||||
|
||||
// 3. Save
|
||||
const result = await window.electronAPI.saveMedia({ buffer: arrayBuffer, defaultPath });
|
||||
if (result.success) {
|
||||
this._notify('下载成功', `头像已保存`);
|
||||
}
|
||||
} catch (error) {
|
||||
this._notify('下载失败', error.message, 'error');
|
||||
} finally {
|
||||
downloadBtn.disabled = false;
|
||||
downloadBtn.innerHTML = '<i class="fas fa-download"></i> 下载头像';
|
||||
}
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (this.abortController) {
|
||||
this.abortController.abort();
|
||||
}
|
||||
this._log('工具已销毁');
|
||||
super.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
export default QQAvatarTool;
|
||||
@@ -0,0 +1,251 @@
|
||||
// src/js/tools/qrCodeGeneratorTool.js
|
||||
import BaseTool from '../baseTool.js';
|
||||
|
||||
class QRCodeGeneratorTool extends BaseTool {
|
||||
constructor() {
|
||||
super('qr-code-generator', '二维码生成');
|
||||
this.dom = {};
|
||||
this.qrcodeInstance = null;
|
||||
this.currentOptions = {
|
||||
text: '',
|
||||
width: 256,
|
||||
height: 256,
|
||||
colorDark : "#000000",
|
||||
colorLight : "#ffffff",
|
||||
correctLevel : QRCode.CorrectLevel.H // High correction level by default
|
||||
};
|
||||
}
|
||||
|
||||
render() {
|
||||
return `
|
||||
<div class="page-container qr-code-container" style="display: flex; flex-direction: column; height: 100%;">
|
||||
<div class="section-header">
|
||||
<button id="back-to-toolbox-btn" class="back-btn ripple"><i class="fas fa-arrow-left"></i> 返回工具箱</button>
|
||||
<h1 style="flex-grow: 1; text-align: center;">${this.name}</h1>
|
||||
<div style="width: 70px;"></div> </div>
|
||||
|
||||
<div class="content-area" style="padding: 20px; flex-grow: 1; display: flex; gap: 20px; overflow-y: auto;">
|
||||
|
||||
<div class="qr-options-panel settings-section">
|
||||
<h2><i class="fas fa-cog"></i> 内容与选项</h2>
|
||||
|
||||
<div class="setting-item">
|
||||
<label for="qr-text-input" class="setting-item-title">输入内容:</label>
|
||||
<textarea id="qr-text-input" placeholder="输入文本、网址或其他内容..." style="height: 100px; resize: vertical;"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="setting-item">
|
||||
<label for="qr-size-slider" class="setting-item-title">二维码尺寸:</label>
|
||||
<div class="opacity-control">
|
||||
<input type="range" id="qr-size-slider" min="128" max="1024" step="32" value="${this.currentOptions.width}">
|
||||
<span id="qr-size-value">${this.currentOptions.width} px</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-item">
|
||||
<label for="qr-color-dark" class="setting-item-title">深色 (点):</label>
|
||||
<input type="color" id="qr-color-dark" value="${this.currentOptions.colorDark}">
|
||||
</div>
|
||||
<div class="setting-item">
|
||||
<label for="qr-color-light" class="setting-item-title">浅色 (背景):</label>
|
||||
<input type="color" id="qr-color-light" value="${this.currentOptions.colorLight}">
|
||||
</div>
|
||||
|
||||
|
||||
<div class="setting-item">
|
||||
<label for="qr-correct-level" class="setting-item-title">纠错级别:</label>
|
||||
<select id="qr-correct-level">
|
||||
<option value="L">低 (L ~7%)</option>
|
||||
<option value="M">中 (M ~15%)</option>
|
||||
<option value="Q">较高 (Q ~25%)</option>
|
||||
<option value="H" selected>高 (H ~30%)</option>
|
||||
</select>
|
||||
<p class="setting-item-description" style="font-size: 12px; margin-top: 5px;">级别越高,允许被遮挡的面积越大,但承载信息量越小。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="qr-preview-panel settings-section">
|
||||
<h2><i class="fas fa-qrcode"></i> 预览与保存</h2>
|
||||
<div id="qr-code-output" class="qr-code-output">
|
||||
<p class="qr-placeholder">请在左侧输入内容</p>
|
||||
</div>
|
||||
<button id="qr-save-btn" class="action-btn ripple" disabled><i class="fas fa-save"></i> 保存二维码</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
init() {
|
||||
this._log('工具已初始化');
|
||||
|
||||
// Check if QRCode library is available
|
||||
if (typeof QRCode === 'undefined') {
|
||||
this._showErrorState('错误:QRCode 库未加载。请检查网络连接或 HTML 文件。');
|
||||
this._log('QRCode 库未找到', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
const backBtn = document.getElementById('back-to-toolbox-btn');
|
||||
if (backBtn) {
|
||||
backBtn.addEventListener('click', () => {
|
||||
window.mainPage.navigateTo('toolbox');
|
||||
window.mainPage.updateActiveNavButton(document.getElementById('toolbox-btn'));
|
||||
});
|
||||
}
|
||||
|
||||
// Cache DOM
|
||||
this.dom.textInput = document.getElementById('qr-text-input');
|
||||
this.dom.sizeSlider = document.getElementById('qr-size-slider');
|
||||
this.dom.sizeValue = document.getElementById('qr-size-value');
|
||||
this.dom.colorDark = document.getElementById('qr-color-dark');
|
||||
this.dom.colorLight = document.getElementById('qr-color-light');
|
||||
this.dom.correctLevelSelect = document.getElementById('qr-correct-level');
|
||||
this.dom.outputDiv = document.getElementById('qr-code-output');
|
||||
this.dom.saveBtn = document.getElementById('qr-save-btn');
|
||||
|
||||
// Bind events
|
||||
// Use 'input' for sliders and text area for real-time update
|
||||
this.dom.textInput.addEventListener('input', this._debounce(this._updateQRCode.bind(this), 300));
|
||||
this.dom.sizeSlider.addEventListener('input', this._handleSizeChange.bind(this));
|
||||
this.dom.colorDark.addEventListener('input', this._debounce(this._handleColorChange.bind(this), 100));
|
||||
this.dom.colorLight.addEventListener('input', this._debounce(this._handleColorChange.bind(this), 100));
|
||||
this.dom.correctLevelSelect.addEventListener('change', this._handleCorrectLevelChange.bind(this));
|
||||
this.dom.saveBtn.addEventListener('click', this._handleSave.bind(this));
|
||||
|
||||
// Initial generation if needed (or wait for user input)
|
||||
// this._updateQRCode(); // Optionally generate placeholder QR on init
|
||||
}
|
||||
|
||||
_showErrorState(message) {
|
||||
const contentArea = document.querySelector('.qr-code-container .content-area');
|
||||
if (contentArea) {
|
||||
contentArea.innerHTML = `<div class="loading-container"><p class="error-message">${message}</p></div>`;
|
||||
}
|
||||
}
|
||||
|
||||
_handleSizeChange() {
|
||||
const size = parseInt(this.dom.sizeSlider.value, 10);
|
||||
this.currentOptions.width = size;
|
||||
this.currentOptions.height = size;
|
||||
this.dom.sizeValue.textContent = `${size} px`;
|
||||
this._updateQRCode(); // Regenerate QR code with new size
|
||||
}
|
||||
|
||||
_handleColorChange() {
|
||||
this.currentOptions.colorDark = this.dom.colorDark.value;
|
||||
this.currentOptions.colorLight = this.dom.colorLight.value;
|
||||
this._updateQRCode();
|
||||
}
|
||||
|
||||
|
||||
_handleCorrectLevelChange() {
|
||||
const levelMap = { 'L': QRCode.CorrectLevel.L, 'M': QRCode.CorrectLevel.M, 'Q': QRCode.CorrectLevel.Q, 'H': QRCode.CorrectLevel.H };
|
||||
this.currentOptions.correctLevel = levelMap[this.dom.correctLevelSelect.value] || QRCode.CorrectLevel.H;
|
||||
this._updateQRCode();
|
||||
}
|
||||
|
||||
_updateQRCode() {
|
||||
this.currentOptions.text = this.dom.textInput.value;
|
||||
|
||||
// Clear previous QR code
|
||||
this.dom.outputDiv.innerHTML = '';
|
||||
this.qrcodeInstance = null; // Reset instance
|
||||
|
||||
if (!this.currentOptions.text) {
|
||||
this.dom.outputDiv.innerHTML = '<p class="qr-placeholder">请在左侧输入内容</p>';
|
||||
this.dom.saveBtn.disabled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Use qrcodejs to generate QR code directly into the div
|
||||
this.qrcodeInstance = new QRCode(this.dom.outputDiv, {
|
||||
text: this.currentOptions.text,
|
||||
width: this.currentOptions.width,
|
||||
height: this.currentOptions.height,
|
||||
colorDark : this.currentOptions.colorDark,
|
||||
colorLight : this.currentOptions.colorLight,
|
||||
correctLevel : this.currentOptions.correctLevel
|
||||
});
|
||||
this.dom.saveBtn.disabled = false;
|
||||
this._log('QR code generated successfully');
|
||||
} catch (error) {
|
||||
this.dom.outputDiv.innerHTML = `<p class="error-message">生成失败: ${error.message}</p>`;
|
||||
this.dom.saveBtn.disabled = true;
|
||||
this._log(`QR code generation failed: ${error.message}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
_handleSave() {
|
||||
if (!this.qrcodeInstance || !this.currentOptions.text) {
|
||||
this._notify('无法保存', '请先生成二维码', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Get the canvas element created by qrcodejs
|
||||
const canvas = this.dom.outputDiv.querySelector('canvas');
|
||||
if (!canvas) {
|
||||
// Fallback if canvas not found (might happen if qrcodejs used img)
|
||||
const img = this.dom.outputDiv.querySelector('img');
|
||||
if (img && img.src.startsWith('data:image/png;base64,')) {
|
||||
this._saveDataURL(img.src);
|
||||
} else {
|
||||
throw new Error('无法找到生成的二维码图像');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Convert canvas to Data URL
|
||||
const dataUrl = canvas.toDataURL('image/png');
|
||||
this._saveDataURL(dataUrl);
|
||||
|
||||
} catch (error) {
|
||||
this._notify('保存失败', error.message, 'error');
|
||||
this._log(`Failed to save QR code: ${error.message}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
_saveDataURL(dataUrl) {
|
||||
// Extract base64 data
|
||||
const base64Data = dataUrl.replace(/^data:image\/png;base64,/, "");
|
||||
// Convert base64 to ArrayBuffer
|
||||
const byteString = atob(base64Data);
|
||||
const ab = new ArrayBuffer(byteString.length);
|
||||
const ia = new Uint8Array(ab);
|
||||
for (let i = 0; i < byteString.length; i++) {
|
||||
ia[i] = byteString.charCodeAt(i);
|
||||
}
|
||||
|
||||
const defaultPath = `qrcode_${Date.now()}.png`;
|
||||
window.electronAPI.saveMedia({ buffer: ab, defaultPath }).then(result => {
|
||||
if (result.success) {
|
||||
this._notify('保存成功', '二维码图片已保存');
|
||||
this._log('QR code image saved');
|
||||
} else if (result.error !== '用户取消保存') {
|
||||
this._notify('保存失败', result.error, 'error');
|
||||
this._log('QR code save failed: ' + result.error, 'error');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
_debounce(func, delay) {
|
||||
let timeout;
|
||||
return function(...args) {
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(() => func.apply(this, args), delay);
|
||||
};
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this._log('工具已销毁');
|
||||
// Clear QR code instance if needed, remove listeners if manually added beyond init
|
||||
this.qrcodeInstance = null;
|
||||
if (this.dom.outputDiv) this.dom.outputDiv.innerHTML = '';
|
||||
super.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
export default QRCodeGeneratorTool;
|
||||
@@ -0,0 +1,93 @@
|
||||
// src/js/tools/qrcodeScannerTool.js
|
||||
import BaseTool from '../baseTool.js';
|
||||
|
||||
export default class QrcodeScannerTool extends BaseTool {
|
||||
constructor() {
|
||||
super('qrcode-scanner', '二维码扫描');
|
||||
}
|
||||
|
||||
render() {
|
||||
return `
|
||||
<div class="page-container" style="padding: 20px; display: flex; flex-direction: column; gap: 20px;">
|
||||
<div class="section-header">
|
||||
<button class="back-btn ripple" id="back-btn"><i class="fas fa-arrow-left"></i></button>
|
||||
<h1>${this.name}</h1>
|
||||
</div>
|
||||
|
||||
<div class="island-card" style="padding: 20px;">
|
||||
<div style="margin-bottom: 15px;">
|
||||
<label style="display: block; margin-bottom: 8px; font-weight: 600;">上传二维码图片</label>
|
||||
<input type="file" id="qrcode-file" accept="image/*" style="display: none;">
|
||||
<button id="btn-select-file" class="action-btn ripple" style="width: 100%;">
|
||||
<i class="fas fa-upload"></i> 选择图片文件
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="qrcode-preview" style="display: none; margin-bottom: 15px; text-align: center;">
|
||||
<img id="qrcode-image" style="max-width: 100%; max-height: 300px; border-radius: 8px; border: 1px solid var(--border-color);">
|
||||
</div>
|
||||
|
||||
<div id="qrcode-result" style="display: none;">
|
||||
<div style="margin-bottom: 10px; font-weight: 600;">扫描结果:</div>
|
||||
<div id="qrcode-text" style="padding: 15px; background: rgba(var(--bg-color-rgb), 0.3); border-radius: 8px; word-break: break-all; font-family: monospace; margin-bottom: 10px;"></div>
|
||||
<button id="btn-copy-result" class="action-btn ripple" style="width: 100%;">
|
||||
<i class="fas fa-copy"></i> 复制结果
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="island-card" style="padding: 20px; text-align: center; color: var(--text-secondary);">
|
||||
<i class="fas fa-qrcode" style="font-size: 48px; opacity: 0.3; margin-bottom: 10px;"></i>
|
||||
<p style="margin: 0;">提示:上传包含二维码的图片文件进行扫描</p>
|
||||
<p style="margin: 5px 0 0 0; font-size: 12px;">支持 PNG、JPG、GIF 等常见图片格式</p>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
init() {
|
||||
document.getElementById('back-btn').addEventListener('click', () => window.mainPage.navigateTo('toolbox'));
|
||||
|
||||
const fileInput = document.getElementById('qrcode-file');
|
||||
const selectBtn = document.getElementById('btn-select-file');
|
||||
const preview = document.getElementById('qrcode-preview');
|
||||
const image = document.getElementById('qrcode-image');
|
||||
const result = document.getElementById('qrcode-result');
|
||||
const resultText = document.getElementById('qrcode-text');
|
||||
|
||||
selectBtn.addEventListener('click', () => fileInput.click());
|
||||
|
||||
fileInput.addEventListener('change', async (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (!file) return;
|
||||
|
||||
if (!file.type.startsWith('image/')) {
|
||||
this._notify('错误', '请选择图片文件', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event) => {
|
||||
image.src = event.target.result;
|
||||
preview.style.display = 'block';
|
||||
|
||||
// 使用 jsQR 库扫描二维码(如果可用)
|
||||
// 这里使用简化版:提示用户使用在线工具
|
||||
result.style.display = 'block';
|
||||
resultText.textContent = '二维码扫描功能需要集成 jsQR 库。\n\n当前版本:请使用在线二维码扫描工具,或手动输入二维码内容。\n\n提示:可以先将二维码图片上传到在线扫描服务。';
|
||||
|
||||
this._notify('提示', '二维码扫描功能开发中,请使用在线工具', 'info');
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
|
||||
document.getElementById('btn-copy-result').addEventListener('click', () => {
|
||||
const text = resultText.textContent;
|
||||
if (text && !text.includes('二维码扫描功能需要')) {
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
this._notify('已复制', '扫描结果已复制到剪贴板', 'success');
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
// src/js/tools/randomGeneratorTool.js
|
||||
import BaseTool from '../baseTool.js';
|
||||
import i18n from '../i18n.js';
|
||||
|
||||
export default class RandomGeneratorTool extends BaseTool {
|
||||
constructor() {
|
||||
super('random-generator', i18n.t('tool.randomGenerator.name', null, '随机生成器'));
|
||||
}
|
||||
|
||||
render() {
|
||||
return `
|
||||
<div class="page-container" style="padding: 24px; display: flex; flex-direction: column; gap: 20px; height: 100%; overflow-y: auto;">
|
||||
<div class="section-header">
|
||||
<button class="back-btn ripple" id="back-btn"><i class="fas fa-arrow-left"></i></button>
|
||||
<h1>${i18n.t('tool.randomGenerator.name', null, '随机生成器')}</h1>
|
||||
</div>
|
||||
|
||||
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 20px; flex: 1; min-height: 0;">
|
||||
<!-- 随机数字 -->
|
||||
<div class="setting-island" style="display: flex; flex-direction: column;">
|
||||
<h3 style="display: flex; align-items: center; gap: 10px; margin-bottom: 20px;">
|
||||
<i class="fas fa-dice" style="color: var(--primary-color);"></i>
|
||||
${i18n.t('tool.randomGenerator.number', null, '随机数字')}
|
||||
</h3>
|
||||
<div style="display: flex; flex-direction: column; gap: 16px; flex: 1;">
|
||||
<div class="input-group">
|
||||
<label>${i18n.t('tool.randomGenerator.min', null, '最小值')}</label>
|
||||
<input type="number" id="num-min" class="common-input" value="1">
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<label>${i18n.t('tool.randomGenerator.max', null, '最大值')}</label>
|
||||
<input type="number" id="num-max" class="common-input" value="100">
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<label>${i18n.t('tool.randomGenerator.count', null, '生成数量')}</label>
|
||||
<input type="number" id="num-count" class="common-input" value="1" min="1" max="1000">
|
||||
</div>
|
||||
<button id="btn-generate-number" class="action-btn ripple" style="width: 100%; padding: 12px;">
|
||||
<i class="fas fa-random"></i> ${i18n.t('tool.randomGenerator.generate', null, '生成')}
|
||||
</button>
|
||||
<div class="input-group" style="flex: 1; display: flex; flex-direction: column; min-height: 0;">
|
||||
<label>${i18n.t('tool.randomGenerator.result', null, '结果')}</label>
|
||||
<textarea id="num-result" class="common-textarea" readonly style="flex: 1; resize: none; font-family: monospace; font-size: 13px;"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 随机字符串 -->
|
||||
<div class="setting-island" style="display: flex; flex-direction: column;">
|
||||
<h3 style="display: flex; align-items: center; gap: 10px; margin-bottom: 20px;">
|
||||
<i class="fas fa-key" style="color: var(--secondary-color);"></i>
|
||||
${i18n.t('tool.randomGenerator.string', null, '随机字符串')}
|
||||
</h3>
|
||||
<div style="display: flex; flex-direction: column; gap: 16px; flex: 1;">
|
||||
<div class="input-group">
|
||||
<label>${i18n.t('tool.randomGenerator.length', null, '长度')}</label>
|
||||
<input type="number" id="str-length" class="common-input" value="16" min="1" max="1000">
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<label>${i18n.t('tool.randomGenerator.charset', null, '字符集')}</label>
|
||||
<div class="charset-checkboxes" style="display: flex; flex-direction: column; gap: 12px; padding: 12px; background: rgba(var(--bg-color-rgb), 0.4); border-radius: 12px;">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" id="charset-upper" checked>
|
||||
<span>${i18n.t('tool.randomGenerator.uppercase', null, '大写字母 (A-Z)')}</span>
|
||||
</label>
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" id="charset-lower" checked>
|
||||
<span>${i18n.t('tool.randomGenerator.lowercase', null, '小写字母 (a-z)')}</span>
|
||||
</label>
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" id="charset-number" checked>
|
||||
<span>${i18n.t('tool.randomGenerator.numbers', null, '数字 (0-9)')}</span>
|
||||
</label>
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" id="charset-symbol">
|
||||
<span>${i18n.t('tool.randomGenerator.symbols', null, '符号 (!@#$%...)')}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<button id="btn-generate-string" class="action-btn ripple" style="width: 100%; padding: 12px;">
|
||||
<i class="fas fa-random"></i> ${i18n.t('tool.randomGenerator.generate', null, '生成')}
|
||||
</button>
|
||||
<div class="input-group">
|
||||
<label>${i18n.t('tool.randomGenerator.result', null, '结果')}</label>
|
||||
<div style="display: flex; gap: 10px;">
|
||||
<input type="text" id="str-result" class="common-input" readonly style="font-family: monospace;">
|
||||
<button id="btn-copy-string" class="action-btn ripple" style="padding: 12px 20px; white-space: nowrap;">
|
||||
<i class="fas fa-copy"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 随机颜色 -->
|
||||
<div class="setting-island" style="display: flex; flex-direction: column;">
|
||||
<h3 style="display: flex; align-items: center; gap: 10px; margin-bottom: 20px;">
|
||||
<i class="fas fa-palette" style="color: var(--accent-color);"></i>
|
||||
${i18n.t('tool.randomGenerator.color', null, '随机颜色')}
|
||||
</h3>
|
||||
<div style="display: flex; flex-direction: column; gap: 16px; flex: 1;">
|
||||
<button id="btn-generate-color" class="action-btn ripple" style="width: 100%; padding: 12px;">
|
||||
<i class="fas fa-random"></i> ${i18n.t('tool.randomGenerator.generate', null, '生成')}
|
||||
</button>
|
||||
<div id="color-result" style="display: none; flex: 1; flex-direction: column; gap: 16px;" class="color-result-container">
|
||||
<div id="color-preview" class="color-preview-large" style="width: 100%; aspect-ratio: 1; border-radius: 16px; border: 2px solid var(--border-color); box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);"></div>
|
||||
<div style="display: flex; flex-direction: column; gap: 12px;">
|
||||
<div class="input-group">
|
||||
<label style="display: flex; align-items: center; gap: 8px;">
|
||||
<i class="fas fa-hashtag" style="color: var(--primary-color);"></i> HEX
|
||||
</label>
|
||||
<div style="display: flex; gap: 10px;">
|
||||
<input type="text" id="color-hex" class="common-input" readonly style="font-family: monospace;">
|
||||
<button class="action-btn ripple hash-copy-btn" data-copy="color-hex" title="${i18n.t('common.copy', null, '复制')}">
|
||||
<i class="fas fa-copy"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<label style="display: flex; align-items: center; gap: 8px;">
|
||||
<i class="fas fa-palette" style="color: var(--secondary-color);"></i> RGB
|
||||
</label>
|
||||
<div style="display: flex; gap: 10px;">
|
||||
<input type="text" id="color-rgb" class="common-input" readonly style="font-family: monospace;">
|
||||
<button class="action-btn ripple hash-copy-btn" data-copy="color-rgb" title="${i18n.t('common.copy', null, '复制')}">
|
||||
<i class="fas fa-copy"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<label style="display: flex; align-items: center; gap: 8px;">
|
||||
<i class="fas fa-circle" style="color: var(--accent-color);"></i> HSL
|
||||
</label>
|
||||
<div style="display: flex; gap: 10px;">
|
||||
<input type="text" id="color-hsl" class="common-input" readonly style="font-family: monospace;">
|
||||
<button class="action-btn ripple hash-copy-btn" data-copy="color-hsl" title="${i18n.t('common.copy', null, '复制')}">
|
||||
<i class="fas fa-copy"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
init() {
|
||||
document.getElementById('back-btn').addEventListener('click', () => window.mainPage.navigateTo('toolbox'));
|
||||
|
||||
// 随机数字
|
||||
document.getElementById('btn-generate-number').addEventListener('click', () => {
|
||||
const min = parseInt(document.getElementById('num-min').value) || 1;
|
||||
const max = parseInt(document.getElementById('num-max').value) || 100;
|
||||
const count = parseInt(document.getElementById('num-count').value) || 1;
|
||||
|
||||
if (min > max) {
|
||||
this._notify(i18n.t('common.notification.title.error', null, '错误'), i18n.t('tool.randomGenerator.minMaxError', null, '最小值不能大于最大值'), 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const results = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
results.push(Math.floor(Math.random() * (max - min + 1)) + min);
|
||||
}
|
||||
document.getElementById('num-result').value = results.join('\n');
|
||||
this._log(`生成 ${count} 个随机数字 (${min}-${max})`);
|
||||
});
|
||||
|
||||
// 随机字符串
|
||||
document.getElementById('btn-generate-string').addEventListener('click', () => {
|
||||
const length = parseInt(document.getElementById('str-length').value) || 16;
|
||||
const upper = document.getElementById('charset-upper').checked;
|
||||
const lower = document.getElementById('charset-lower').checked;
|
||||
const number = document.getElementById('charset-number').checked;
|
||||
const symbol = document.getElementById('charset-symbol').checked;
|
||||
|
||||
let charset = '';
|
||||
if (upper) charset += 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
|
||||
if (lower) charset += 'abcdefghijklmnopqrstuvwxyz';
|
||||
if (number) charset += '0123456789';
|
||||
if (symbol) charset += '!@#$%^&*()_+-=[]{}|;:,.<>?';
|
||||
|
||||
if (!charset) {
|
||||
this._notify(i18n.t('common.notification.title.error', null, '错误'), i18n.t('tool.randomGenerator.charsetError', null, '请至少选择一种字符集'), 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
let result = '';
|
||||
for (let i = 0; i < length; i++) {
|
||||
result += charset.charAt(Math.floor(Math.random() * charset.length));
|
||||
}
|
||||
document.getElementById('str-result').value = result;
|
||||
this._log(`生成随机字符串 (长度: ${length})`);
|
||||
});
|
||||
|
||||
document.getElementById('btn-copy-string').addEventListener('click', () => {
|
||||
const result = document.getElementById('str-result').value;
|
||||
if (result) {
|
||||
navigator.clipboard.writeText(result).then(() => {
|
||||
this._notify(i18n.t('common.notification.title.success', null, '成功'), i18n.t('common.copied', null, '已复制到剪贴板'), 'success');
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 随机颜色
|
||||
document.getElementById('btn-generate-color').addEventListener('click', () => {
|
||||
const r = Math.floor(Math.random() * 256);
|
||||
const g = Math.floor(Math.random() * 256);
|
||||
const b = Math.floor(Math.random() * 256);
|
||||
|
||||
const hex = `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`;
|
||||
const rgb = `rgb(${r}, ${g}, ${b})`;
|
||||
|
||||
// RGB to HSL
|
||||
const rNorm = r / 255;
|
||||
const gNorm = g / 255;
|
||||
const bNorm = b / 255;
|
||||
const max = Math.max(rNorm, gNorm, bNorm);
|
||||
const min = Math.min(rNorm, gNorm, bNorm);
|
||||
const delta = max - min;
|
||||
|
||||
let h = 0;
|
||||
if (delta !== 0) {
|
||||
if (max === rNorm) h = ((gNorm - bNorm) / delta) % 6;
|
||||
else if (max === gNorm) h = (bNorm - rNorm) / delta + 2;
|
||||
else h = (rNorm - gNorm) / delta + 4;
|
||||
}
|
||||
h = Math.round(h * 60);
|
||||
if (h < 0) h += 360;
|
||||
|
||||
const l = (max + min) / 2;
|
||||
const s = delta === 0 ? 0 : delta / (1 - Math.abs(2 * l - 1));
|
||||
const hsl = `hsl(${h}, ${Math.round(s * 100)}%, ${Math.round(l * 100)}%)`;
|
||||
|
||||
document.getElementById('color-preview').style.backgroundColor = hex;
|
||||
document.getElementById('color-hex').value = hex;
|
||||
document.getElementById('color-rgb').value = rgb;
|
||||
document.getElementById('color-hsl').value = hsl;
|
||||
const colorResult = document.getElementById('color-result');
|
||||
colorResult.style.display = 'flex';
|
||||
this._log('生成随机颜色');
|
||||
});
|
||||
|
||||
// 复制按钮
|
||||
document.querySelectorAll('[data-copy]').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const targetId = btn.getAttribute('data-copy');
|
||||
const input = document.getElementById(targetId);
|
||||
if (input && input.value) {
|
||||
navigator.clipboard.writeText(input.value).then(() => {
|
||||
this._notify(i18n.t('common.notification.title.success', null, '成功'), i18n.t('common.copied', null, '已复制到剪贴板'), 'success');
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
// src/js/tools/regexTool.js
|
||||
import BaseTool from '../baseTool.js';
|
||||
|
||||
export default class RegexTool extends BaseTool {
|
||||
constructor() {
|
||||
super('regex-tool', '正则测试');
|
||||
}
|
||||
|
||||
render() {
|
||||
return `
|
||||
<div class="page-container" style="display: flex; flex-direction: column; height: 100%;">
|
||||
<div class="section-header" style="flex-shrink: 0; margin-bottom: 20px;">
|
||||
<button id="back-to-toolbox-btn" class="back-btn ripple"><i class="fas fa-arrow-left"></i> 返回工具箱</button>
|
||||
<h1 style="flex-grow: 1; text-align: center;">${this.name}</h1>
|
||||
</div>
|
||||
|
||||
<div class="tool-island-container">
|
||||
<div class="tool-island-card">
|
||||
<div class="tool-group-title"><i class="fas fa-code"></i> 正则表达式</div>
|
||||
<div style="display: flex; align-items: center; gap: 10px; background: rgba(0,0,0,0.05); padding: 5px 10px; border-radius: 8px;">
|
||||
<span style="font-family: monospace; font-size: 16px; color: var(--text-secondary);">/</span>
|
||||
<input type="text" id="regex-pattern" class="common-input" placeholder="例如: [a-z]+" style="border: none; background: transparent; padding: 5px; flex: 1; font-family: monospace; box-shadow: none;">
|
||||
<span style="font-family: monospace; font-size: 16px; color: var(--text-secondary);">/</span>
|
||||
<input type="text" id="regex-flags" class="common-input" placeholder="gmi" value="g" style="width: 60px; border: none; background: transparent; padding: 5px; font-family: monospace; box-shadow: none;">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tool-island-card" style="flex: 1; display: flex; flex-direction: column;">
|
||||
<div class="tool-group-title"><i class="fas fa-vial"></i> 测试文本</div>
|
||||
<textarea id="regex-text" class="common-textarea" style="flex: 1; resize: none; margin-bottom: 0;" placeholder="在此输入需要测试的文本..."></textarea>
|
||||
</div>
|
||||
|
||||
<div class="tool-island-card" style="flex: 1; display: flex; flex-direction: column;">
|
||||
<div class="tool-group-title">
|
||||
<i class="fas fa-poll-h"></i> 匹配结果
|
||||
<span id="regex-count" style="margin-left: auto; font-size: 12px; background: var(--primary-color); color: #fff; padding: 2px 8px; border-radius: 10px;">0 处匹配</span>
|
||||
</div>
|
||||
<div id="regex-result" class="common-textarea" style="flex: 1; overflow-y: auto; background: rgba(var(--bg-color-rgb), 0.5); white-space: pre-wrap; word-break: break-all; font-family: monospace;"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<style>
|
||||
.regex-highlight {
|
||||
background-color: rgba(var(--accent-color-rgb), 0.3);
|
||||
border-bottom: 2px solid var(--accent-color);
|
||||
border-radius: 2px;
|
||||
color: var(--text-color);
|
||||
}
|
||||
.regex-highlight:nth-child(even) {
|
||||
background-color: rgba(var(--primary-color-rgb), 0.3);
|
||||
border-bottom-color: var(--primary-color);
|
||||
}
|
||||
</style>
|
||||
`;
|
||||
}
|
||||
|
||||
init() {
|
||||
// 绑定返回按钮
|
||||
document.getElementById('back-to-toolbox-btn')?.addEventListener('click', () => {
|
||||
window.mainPage.navigateTo('toolbox');
|
||||
window.mainPage.updateActiveNavButton(document.getElementById('toolbox-btn'));
|
||||
});
|
||||
|
||||
const patternInput = document.getElementById('regex-pattern');
|
||||
const flagsInput = document.getElementById('regex-flags');
|
||||
const textInput = document.getElementById('regex-text');
|
||||
|
||||
const runTest = () => {
|
||||
const pattern = patternInput.value;
|
||||
const flags = flagsInput.value;
|
||||
const text = textInput.value;
|
||||
const resultDiv = document.getElementById('regex-result');
|
||||
const countBadge = document.getElementById('regex-count');
|
||||
|
||||
if (!text) {
|
||||
resultDiv.innerHTML = '';
|
||||
countBadge.innerText = '0 处匹配';
|
||||
return;
|
||||
}
|
||||
if (!pattern) {
|
||||
resultDiv.textContent = text;
|
||||
countBadge.innerText = '等待输入正则...';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const regex = new RegExp(pattern, flags);
|
||||
let matchCount = 0;
|
||||
|
||||
const highlighted = text.replace(regex, (match) => {
|
||||
matchCount++;
|
||||
return `<span class="regex-highlight">${this.escapeHtml(match)}</span>`;
|
||||
});
|
||||
|
||||
resultDiv.innerHTML = highlighted;
|
||||
countBadge.innerText = `${matchCount} 处匹配`;
|
||||
} catch (e) {
|
||||
countBadge.innerText = '正则错误';
|
||||
resultDiv.innerHTML = `<span style="color: var(--error-color);">Error: ${e.message}</span>`;
|
||||
}
|
||||
};
|
||||
|
||||
patternInput.addEventListener('input', runTest);
|
||||
flagsInput.addEventListener('input', runTest);
|
||||
textInput.addEventListener('input', runTest);
|
||||
}
|
||||
|
||||
escapeHtml(text) {
|
||||
return text.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,442 @@
|
||||
// src/js/tools/sanguoshaTool.js
|
||||
import BaseTool from '../baseTool.js';
|
||||
import configManager from '../configManager.js';
|
||||
|
||||
class SanguoshaTool extends BaseTool {
|
||||
constructor() {
|
||||
super('sanguosha-downloader', '三国杀·图鉴收藏');
|
||||
|
||||
// --- 核心配置 ---
|
||||
this.config = {
|
||||
xStart: 1,
|
||||
xEnd: 8000,
|
||||
yStart: 1,
|
||||
yEnd: 20,
|
||||
baseUrlTemplate: "https://web.sanguosha.com/220/h5_2/res/runtime/pc/general/big/static/{filename}"
|
||||
};
|
||||
|
||||
this.state = {
|
||||
isRunning: false,
|
||||
savePath: configManager.config?.download_path || '',
|
||||
concurrency: 16,
|
||||
abortController: null
|
||||
};
|
||||
|
||||
// --- 统计数据 ---
|
||||
this.stats = {
|
||||
success: 0,
|
||||
skipped: 0,
|
||||
notFound: 0,
|
||||
error: 0,
|
||||
totalProcessed: 0
|
||||
};
|
||||
}
|
||||
|
||||
render() {
|
||||
const style = `
|
||||
<style>
|
||||
.sg-wrapper { display: flex; flex-direction: column; height: 100%; gap: 15px; padding: 0 5px; position: relative; }
|
||||
|
||||
/* --- 灵动岛状态栏 --- */
|
||||
.sg-island-status {
|
||||
background: rgba(0, 0, 0, 0.65);
|
||||
backdrop-filter: blur(20px);
|
||||
border-radius: 20px;
|
||||
padding: 0 24px;
|
||||
height: 70px;
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.4);
|
||||
transition: all 0.3s ease;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* 左侧信息区 */
|
||||
.sg-island-left { display: flex; align-items: center; gap: 16px; height: 100%; }
|
||||
|
||||
/* 加载圈 */
|
||||
.sg-progress-ring {
|
||||
width: 26px; height: 26px;
|
||||
border-radius: 50%;
|
||||
border: 3px solid rgba(255,255,255,0.1);
|
||||
border-top-color: var(--primary-color);
|
||||
animation: spin 0.8s cubic-bezier(0.4, 0, 0.2, 1) infinite;
|
||||
display: none; flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 文字容器 */
|
||||
.sg-text-box { display: flex; flex-direction: column; justify-content: center; gap: 3px; }
|
||||
#sg-status-text { font-weight: 700; font-size: 16px; color: #fff; letter-spacing: 0.5px; line-height: 1.2; }
|
||||
#sg-sub-status { font-size: 12px; color: rgba(255, 255, 255, 0.5); font-family: 'Segoe UI', sans-serif; font-weight: 400; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
/* 右侧统计 */
|
||||
.sg-island-right { display: flex; gap: 10px; align-items: center; }
|
||||
.sg-pill {
|
||||
position: relative; background: rgba(255,255,255,0.06); padding: 6px 14px;
|
||||
border-radius: 12px; font-size: 13px; font-weight: 500;
|
||||
display: flex; align-items: center; gap: 8px; cursor: help;
|
||||
border: 1px solid transparent; transition: all 0.2s ease;
|
||||
}
|
||||
.sg-pill:hover { background: rgba(255,255,255,0.12); border-color: rgba(255,255,255,0.1); transform: translateY(-1px); }
|
||||
.sg-pill.success i { color: #34c759; }
|
||||
.sg-pill.warn i { color: #ffcc00; }
|
||||
.sg-pill.error i { color: #ff453a; }
|
||||
|
||||
/* Tooltip */
|
||||
.sg-pill::after {
|
||||
content: attr(data-tooltip); position: absolute; top: 130%; left: 50%;
|
||||
transform: translateX(-50%) translateY(5px);
|
||||
background: rgba(20, 20, 20, 0.95); backdrop-filter: blur(12px);
|
||||
border: 1px solid rgba(255,255,255,0.1); color: #f0f0f0;
|
||||
padding: 8px 12px; border-radius: 8px; font-size: 12px; white-space: nowrap;
|
||||
opacity: 0; visibility: hidden; transition: all 0.2s; pointer-events: none; z-index: 100;
|
||||
}
|
||||
.sg-pill:hover::after { opacity: 1; visibility: visible; transform: translateX(-50%) translateY(0); }
|
||||
|
||||
/* 控制面板 & 终端 */
|
||||
.sg-control-panel { background: rgba(var(--card-background-rgb), 0.5); border-radius: 16px; padding: 20px; display: flex; flex-direction: column; gap: 15px; }
|
||||
.sg-terminal { flex-grow: 1; background: #1e1e1e; border-radius: 12px; padding: 15px; font-family: 'JetBrains Mono', Consolas, monospace; font-size: 12px; color: #d4d4d4; overflow-y: auto; border: 1px solid rgba(255,255,255,0.1); line-height: 1.5; }
|
||||
.term-line { margin-bottom: 3px; display: flex; }
|
||||
.term-time { color: #569cd6; margin-right: 12px; opacity: 0.6; font-size: 11px; flex-shrink: 0; }
|
||||
.term-success { color: #4ec9b0; } .term-skip { color: #dcdcaa; } .term-404 { color: #808080; } .term-error { color: #f44747; }
|
||||
|
||||
/* 免责声明弹窗样式 */
|
||||
.sg-modal-overlay {
|
||||
position: absolute; top: 0; left: 0; width: 100%; height: 100%;
|
||||
background: rgba(0, 0, 0, 0.7); backdrop-filter: blur(8px);
|
||||
z-index: 999; display: flex; align-items: center; justify-content: center;
|
||||
opacity: 0; pointer-events: none; transition: opacity 0.3s ease;
|
||||
}
|
||||
.sg-modal-overlay.active { opacity: 1; pointer-events: auto; }
|
||||
|
||||
.sg-modal-content {
|
||||
background: #1e1e1e; color: #fff; width: 85%; max-width: 500px;
|
||||
border-radius: 16px; border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
box-shadow: 0 20px 50px rgba(0,0,0,0.5); padding: 25px;
|
||||
display: flex; flex-direction: column; gap: 15px;
|
||||
transform: scale(0.95); transition: transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
}
|
||||
.sg-modal-overlay.active .sg-modal-content { transform: scale(1); }
|
||||
|
||||
.sg-modal-title { font-size: 20px; font-weight: bold; color: #ff9f0a; display: flex; align-items: center; gap: 10px; }
|
||||
.sg-modal-body { font-size: 14px; line-height: 1.6; opacity: 0.9; text-align: justify; background: rgba(255,255,255,0.05); padding: 15px; border-radius: 8px; }
|
||||
.sg-modal-footer { margin-top: 10px; text-align: right; }
|
||||
.sg-agree-btn {
|
||||
background: var(--primary-color); color: #fff; border: none;
|
||||
padding: 10px 25px; border-radius: 8px; cursor: pointer; font-weight: 600;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
.sg-agree-btn:hover { opacity: 0.9; }
|
||||
</style>
|
||||
`;
|
||||
|
||||
const initialStatus = this.state.savePath ? '系统就绪' : '等待配置';
|
||||
const initialSub = this.state.savePath ? '所有系统正常,随时可以开始' : '请先选择图片保存路径';
|
||||
|
||||
return `
|
||||
${style}
|
||||
<div class="page-container sg-wrapper">
|
||||
|
||||
<div class="sg-island-status">
|
||||
<div class="sg-island-left">
|
||||
<div class="sg-progress-ring" id="sg-spinner"></div>
|
||||
<div class="sg-text-box">
|
||||
<span id="sg-status-text">${initialStatus}</span>
|
||||
<span id="sg-sub-status">${initialSub}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sg-island-right">
|
||||
<div class="sg-pill success" data-tooltip="成功下载并保存">
|
||||
<i class="fas fa-check"></i> <span id="stat-ok">0</span>
|
||||
</div>
|
||||
<div class="sg-pill warn" data-tooltip="本地已存在 (自动跳过)">
|
||||
<i class="fas fa-forward"></i> <span id="stat-skip">0</span>
|
||||
</div>
|
||||
<div class="sg-pill error" data-tooltip="服务器返回 404 (无资源)">
|
||||
<i class="fas fa-times"></i> <span id="stat-404">0</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sg-control-panel">
|
||||
<div class="ip-input-group">
|
||||
<input type="text" id="sg-path" value="${this.state.savePath}" readonly placeholder="请选择保存根目录 (建议选择空文件夹)...">
|
||||
<button id="sg-sel-btn" class="action-btn ripple"><i class="fas fa-folder-open"></i> 选择目录</button>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; justify-content: space-between; align-items: center;">
|
||||
<div style="font-size: 12px; opacity: 0.8; display: flex; gap: 15px;">
|
||||
<span><i class="fas fa-layer-group"></i> 范围: 武将 ${this.config.xStart}-${this.config.xEnd} / 皮肤 ${this.config.yStart}-${this.config.yEnd}</span>
|
||||
<span><i class="fas fa-bolt"></i> 并发: ${this.state.concurrency}线程</span>
|
||||
</div>
|
||||
<div>
|
||||
<button id="sg-start-btn" class="control-btn ripple"><i class="fas fa-play"></i> 开始采集</button>
|
||||
<button id="sg-stop-btn" class="control-btn ripple warning" disabled><i class="fas fa-stop"></i> 停止</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sg-terminal" id="sg-term">
|
||||
<div class="term-line"><span class="term-time">[System]</span> <span>工具核心模块已加载。等待用户指令...</span></div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
init() {
|
||||
this.dom = {
|
||||
pathInput: document.getElementById('sg-path'),
|
||||
selBtn: document.getElementById('sg-sel-btn'),
|
||||
startBtn: document.getElementById('sg-start-btn'),
|
||||
stopBtn: document.getElementById('sg-stop-btn'),
|
||||
spinner: document.getElementById('sg-spinner'),
|
||||
statusText: document.getElementById('sg-status-text'),
|
||||
subStatus: document.getElementById('sg-sub-status'),
|
||||
term: document.getElementById('sg-term'),
|
||||
stats: {
|
||||
ok: document.getElementById('stat-ok'),
|
||||
skip: document.getElementById('stat-skip'),
|
||||
notFound: document.getElementById('stat-404')
|
||||
},
|
||||
};
|
||||
|
||||
// 检查免责声明
|
||||
this._checkDisclaimerStatus();
|
||||
this._bindEvents();
|
||||
}
|
||||
|
||||
async _checkDisclaimerStatus() {
|
||||
const agreed = configManager.config?.sanguosha_disclaimer_agreed === 'true';
|
||||
if (!agreed) {
|
||||
// [重构] 使用通用模态框框架创建免责声明模态框
|
||||
const { default: modalManager } = await import('../ui/ModalManager.js');
|
||||
|
||||
const disclaimerContent = `
|
||||
<div style="line-height: 1.8;">
|
||||
<p><strong>1. 仅限研究与学习:</strong> 本工具仅用于前端技术研究、网络请求测试及个人对《三国杀》美术素材的收藏学习,<span style="color: #ff453a;">严禁用于任何商业用途</span>。</p>
|
||||
<p><strong>2. 资源版权归属:</strong> 所有采集的图片资源版权归原作者及游戏运营商所有。请在下载后24小时内删除。</p>
|
||||
<p><strong>3. 免责条款:</strong> 使用本工具产生的任何法律后果(包括但不限于IP被封禁、侵权纠纷)由使用者本人承担,软件作者不承担任何责任。</p>
|
||||
<p><strong>4. 滥用禁止:</strong> 请合理控制采集频率,禁止进行高频恶意扫描,共同维护网络环境。</p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
setTimeout(async () => {
|
||||
const modal = modalManager.create({
|
||||
id: 'sg-disclaimer-modal',
|
||||
title: '<i class="fas fa-exclamation-triangle"></i> 特别声明',
|
||||
content: disclaimerContent,
|
||||
size: 'medium',
|
||||
closable: false, // 必须同意才能关闭
|
||||
closeOnBackdrop: false,
|
||||
buttons: [{
|
||||
label: '我已知晓,并承诺合规使用',
|
||||
type: 'primary',
|
||||
className: 'sg-agree-btn',
|
||||
onClick: async () => {
|
||||
if (!configManager.config) configManager.config = {};
|
||||
configManager.config.sanguosha_disclaimer_agreed = 'true';
|
||||
try {
|
||||
await window.electronAPI.setConfigKey('sanguosha_disclaimer_agreed', 'true');
|
||||
this._log('System', '用户已签署免责声明,记录已保存。');
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
},
|
||||
closeOnClick: true
|
||||
}],
|
||||
className: 'sg-disclaimer-modal-wrapper'
|
||||
});
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
|
||||
_bindEvents() {
|
||||
this.dom.selBtn.addEventListener('click', async () => {
|
||||
const res = await window.electronAPI.selectDirectory();
|
||||
if (res && !res.canceled && res.filePaths.length > 0) {
|
||||
this.state.savePath = res.filePaths[0];
|
||||
this.dom.pathInput.value = this.state.savePath;
|
||||
this.dom.statusText.textContent = "系统就绪";
|
||||
this.dom.subStatus.textContent = "目录已配置,可以开始任务";
|
||||
this._log('System', `保存路径已切换: ${this.state.savePath}`);
|
||||
} else {
|
||||
this._log('System', '目录选择已取消');
|
||||
}
|
||||
});
|
||||
|
||||
this.dom.startBtn.addEventListener('click', () => {
|
||||
if (!this.state.savePath) return this._notify('提示', '请先选择保存目录', 'warning');
|
||||
this._start();
|
||||
});
|
||||
|
||||
this.dom.stopBtn.addEventListener('click', () => {
|
||||
this.state.isRunning = false;
|
||||
if (this.state.abortController) this.state.abortController.abort();
|
||||
this.dom.statusText.textContent = "正在停止...";
|
||||
this.dom.stopBtn.disabled = true;
|
||||
});
|
||||
}
|
||||
|
||||
* _taskGenerator() {
|
||||
for (let x = this.config.xStart; x <= this.config.xEnd; x++) {
|
||||
for (let y = this.config.yStart; y <= this.config.yEnd; y++) {
|
||||
yield { x, y };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async _start() {
|
||||
if (configManager.config?.sanguosha_disclaimer_agreed !== 'true') {
|
||||
this._notify('禁止访问', '请先同意免责声明', 'error');
|
||||
// [重构] 使用通用模态框框架显示免责声明
|
||||
const { default: modalManager } = await import('../ui/ModalManager.js');
|
||||
if (!modalManager.isOpen('sg-disclaimer-modal')) {
|
||||
// 重新显示免责声明模态框
|
||||
await this._checkDisclaimerStatus();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
this.state.isRunning = true;
|
||||
this.state.abortController = new AbortController();
|
||||
this._updateUIState(true);
|
||||
this._resetStats();
|
||||
|
||||
const iterator = this._taskGenerator();
|
||||
const activeWorkers = new Set();
|
||||
|
||||
this._log('System', `任务启动: 范围 [${this.config.xStart}-${this.config.xEnd}], 线程池 [${this.state.concurrency}]`);
|
||||
|
||||
try {
|
||||
while (this.state.isRunning) {
|
||||
while (activeWorkers.size < this.state.concurrency && this.state.isRunning) {
|
||||
const next = iterator.next();
|
||||
if (next.done) break;
|
||||
const taskPromise = this._processTask(next.value).then(() => {
|
||||
activeWorkers.delete(taskPromise);
|
||||
});
|
||||
activeWorkers.add(taskPromise);
|
||||
}
|
||||
if (activeWorkers.size === 0) break;
|
||||
await Promise.race(activeWorkers);
|
||||
}
|
||||
} catch (error) {
|
||||
this._log('Error', `核心循环异常: ${error.message}`);
|
||||
} finally {
|
||||
this._handleTaskFinish();
|
||||
}
|
||||
}
|
||||
|
||||
async _handleTaskFinish() {
|
||||
this.state.isRunning = false;
|
||||
this._updateUIState(false);
|
||||
this._log('System', `任务结束。统计: 成功 ${this.stats.success} | 跳过 ${this.stats.skipped} | 404 ${this.stats.notFound}`);
|
||||
|
||||
// 任务结束后,弹出清理询问
|
||||
if (this.stats.totalProcessed > 0) {
|
||||
const result = await window.electronAPI.showConfirmationDialog({
|
||||
title: '清理确认',
|
||||
message: '采集任务已完成,是否清理空文件夹?',
|
||||
detail: '工具在采集过程中可能创建了部分空的武将编号文件夹。点击“确定”将自动扫描并删除这些空目录。'
|
||||
});
|
||||
|
||||
if (result.response === 0) {
|
||||
this._log('System', '正在执行空文件夹清理...');
|
||||
try {
|
||||
const count = await window.electronAPI.cleanEmptyDirs(this.state.savePath);
|
||||
this._log('Success', `清理完成,共移除了 ${count} 个空文件夹。`, 'term-success');
|
||||
this._notify('清理完成', `已移除 ${count} 个空文件夹`, 'success');
|
||||
} catch (e) {
|
||||
this._log('Error', `清理失败: ${e.message}`, 'term-error');
|
||||
}
|
||||
} else {
|
||||
this._log('System', '用户跳过了清理步骤。');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async _processTask({ x, y }) {
|
||||
if (!this.state.isRunning) return;
|
||||
|
||||
const fileName = `${x}0${y}.png`;
|
||||
const url = this.config.baseUrlTemplate.replace('{filename}', fileName);
|
||||
const subDir = String(x);
|
||||
|
||||
try {
|
||||
const result = await window.electronAPI.downloadFileDirect({
|
||||
url, saveRoot: this.state.savePath, subDir: subDir, fileName: fileName
|
||||
});
|
||||
this._handleResult(result.status, fileName);
|
||||
} catch (e) {
|
||||
this._handleResult('ERROR', fileName);
|
||||
}
|
||||
}
|
||||
|
||||
_handleResult(status, fileName) {
|
||||
this.stats.totalProcessed++;
|
||||
switch (status) {
|
||||
case 'SUCCESS':
|
||||
this.stats.success++;
|
||||
this.dom.stats.ok.textContent = this.stats.success;
|
||||
this._log('Success', `下载成功: ${fileName}`, 'term-success');
|
||||
break;
|
||||
case 'SKIPPED':
|
||||
this.stats.skipped++;
|
||||
this.dom.stats.skip.textContent = this.stats.skipped;
|
||||
break;
|
||||
case 'NOT_FOUND':
|
||||
this.stats.notFound++;
|
||||
this.dom.stats.notFound.textContent = this.stats.notFound;
|
||||
if (this.stats.notFound % 10 === 0) {
|
||||
this._log('404', `资源不存在: ${fileName} (已折叠10条)`, 'term-404');
|
||||
}
|
||||
break;
|
||||
default:
|
||||
this.stats.error++;
|
||||
this._log('Error', `下载失败: ${fileName}`, 'term-error');
|
||||
}
|
||||
if (this.stats.totalProcessed % 50 === 0) {
|
||||
const hitRate = this.stats.totalProcessed > 0
|
||||
? ((this.stats.success / this.stats.totalProcessed) * 100).toFixed(1)
|
||||
: 0;
|
||||
this.dom.subStatus.textContent = `已处理: ${this.stats.totalProcessed} | 命中率: ${hitRate}%`;
|
||||
}
|
||||
}
|
||||
|
||||
_log(type, msg, cssClass = '') {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'term-line';
|
||||
const time = new Date().toLocaleTimeString('en-GB', { hour12: false });
|
||||
if (this.dom.term.children.length > 200) this.dom.term.removeChild(this.dom.term.firstChild);
|
||||
div.innerHTML = `<span class="term-time">[${time}]</span> <span class="${cssClass}">${msg}</span>`;
|
||||
this.dom.term.appendChild(div);
|
||||
this.dom.term.scrollTop = this.dom.term.scrollHeight;
|
||||
}
|
||||
|
||||
_updateUIState(running) {
|
||||
this.dom.startBtn.disabled = running;
|
||||
this.dom.stopBtn.disabled = !running;
|
||||
this.dom.selBtn.disabled = running;
|
||||
this.dom.pathInput.parentElement.style.opacity = running ? 0.5 : 1;
|
||||
this.dom.spinner.style.display = running ? 'block' : 'none';
|
||||
this.dom.statusText.textContent = running ? '采集进行中...' : '系统就绪';
|
||||
|
||||
if (running) {
|
||||
this.dom.statusText.style.color = 'var(--primary-color)';
|
||||
} else {
|
||||
this.dom.statusText.style.color = '#fff';
|
||||
this.dom.subStatus.textContent = '任务已停止/完成';
|
||||
}
|
||||
}
|
||||
|
||||
_resetStats() {
|
||||
this.stats = { success: 0, skipped: 0, notFound: 0, error: 0, totalProcessed: 0 };
|
||||
this.dom.stats.ok.textContent = '0';
|
||||
this.dom.stats.skip.textContent = '0';
|
||||
this.dom.stats.notFound.textContent = '0';
|
||||
this.dom.term.innerHTML = '';
|
||||
}
|
||||
}
|
||||
|
||||
export default SanguoshaTool;
|
||||
@@ -0,0 +1,249 @@
|
||||
// src/js/tools/smartSearchTool.js
|
||||
import BaseTool from '../baseTool.js';
|
||||
import configManager from '../configManager.js';
|
||||
|
||||
class SmartSearchTool extends BaseTool {
|
||||
constructor() {
|
||||
super('smart-search', '智能搜索');
|
||||
this.abortController = null;
|
||||
// 正常加载 API Key,如果不存在,this.apiKey 将为 null
|
||||
this.apiKey = configManager.config?.api_keys?.uapipro || null;
|
||||
}
|
||||
|
||||
render() {
|
||||
return `
|
||||
<div class="page-container" style="display: flex; flex-direction: column; height: 100%;">
|
||||
<div class="section-header">
|
||||
<button id="back-to-toolbox-btn" class="back-btn ripple"><i class="fas fa-arrow-left"></i> 返回工具箱</button>
|
||||
<h1 style="flex-grow: 1; text-align: center;">
|
||||
<i class="fas fa-search-plus"></i> ${this.name}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div class="content-area" style="padding: 0 20px 20px 20px; display: flex; flex-direction: column; flex-grow: 1; min-height: 0;">
|
||||
|
||||
<div class="ip-input-group" style="margin-bottom: 15px; flex-shrink: 0;">
|
||||
<input type="text" id="ss-query-input" placeholder="输入搜索内容,AI 将为您聚合高质量结果..." style="flex-grow: 1;">
|
||||
<button id="ss-search-btn" class="action-btn ripple" style="min-width: 80px;">
|
||||
<i class="fas fa-search"></i> 搜索
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="settings-section" style="padding: 15px 20px; margin-bottom: 15px; flex-shrink: 0;">
|
||||
<div class="settings-row collapsible-trigger" id="ss-options-trigger">
|
||||
<span>高级选项</span>
|
||||
<span class="icon-toggle"></span>
|
||||
</div>
|
||||
<div class="collapsible-content" id="ss-options-content">
|
||||
<div class="settings-row" style="padding-top: 15px;">
|
||||
<span style="font-weight: 500;">限制网站 (e.g., github.com)</span>
|
||||
<input type="text" id="ss-site-input" class="settings-input-text" placeholder="可选">
|
||||
</div>
|
||||
<div class="settings-row">
|
||||
<span style="font-weight: 500;">文件类型 (e.g., pdf)</span>
|
||||
<input type="text" id="ss-filetype-input" class="settings-input-text" placeholder="可选">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="ss-results-container" class="settings-section" style="flex-grow: 1; min-height: 0; display: flex; flex-direction: column; padding: 20px;">
|
||||
<div class="empty-logs-placeholder" style="margin: auto;">
|
||||
<i class="fas fa-search-location"></i>
|
||||
<p>等待搜索</p>
|
||||
<span>结果将在此处显示</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
init() {
|
||||
this._log('工具已初始化');
|
||||
|
||||
// 缓存 DOM
|
||||
this.dom = {
|
||||
backBtn: document.getElementById('back-to-toolbox-btn'),
|
||||
queryInput: document.getElementById('ss-query-input'),
|
||||
searchBtn: document.getElementById('ss-search-btn'),
|
||||
siteInput: document.getElementById('ss-site-input'),
|
||||
filetypeInput: document.getElementById('ss-filetype-input'),
|
||||
optionsTrigger: document.getElementById('ss-options-trigger'),
|
||||
optionsContent: document.getElementById('ss-options-content'),
|
||||
resultsContainer: document.getElementById('ss-results-container')
|
||||
};
|
||||
|
||||
// --- [修改] ---
|
||||
// 根据你的要求,移除 API Key 检查
|
||||
// 无论 Key 是否存在,工具都应保持可用。
|
||||
|
||||
// 绑定事件
|
||||
this.dom.backBtn?.addEventListener('click', () => {
|
||||
window.mainPage.navigateTo('toolbox');
|
||||
window.mainPage.updateActiveNavButton(document.getElementById('toolbox-btn'));
|
||||
});
|
||||
|
||||
this.dom.searchBtn.addEventListener('click', this._performSearch.bind(this));
|
||||
this.dom.queryInput.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') this._performSearch();
|
||||
});
|
||||
|
||||
this.dom.optionsTrigger.addEventListener('click', () => {
|
||||
this.dom.optionsTrigger.classList.toggle('expanded');
|
||||
this.dom.optionsContent.classList.toggle('expanded');
|
||||
});
|
||||
}
|
||||
|
||||
async _performSearch() {
|
||||
if (this.abortController) {
|
||||
this.abortController.abort();
|
||||
}
|
||||
this.abortController = new AbortController();
|
||||
|
||||
const query = this.dom.queryInput.value.trim();
|
||||
if (!query) {
|
||||
this._notify('提示', '请输入搜索关键词', 'info');
|
||||
return;
|
||||
}
|
||||
|
||||
// ... (省略加载动画代码) ...
|
||||
this.dom.resultsContainer.innerHTML = `
|
||||
<div class="loading-container" style="margin: auto;">
|
||||
<img src="./assets/loading.gif" alt="加载中..." class="loading-gif">
|
||||
<p class="loading-text">正在搜索,请稍候...</p>
|
||||
</div>`;
|
||||
|
||||
const requestBody = {
|
||||
query: query,
|
||||
timeout_ms: 10000,
|
||||
fetch_full: false
|
||||
};
|
||||
|
||||
const site = this.dom.siteInput.value.trim();
|
||||
if (site) requestBody.site = site;
|
||||
|
||||
const filetype = this.dom.filetypeInput.value.trim();
|
||||
if (filetype) requestBody.filetype = filetype;
|
||||
|
||||
this._log(`开始搜索: ${query}`);
|
||||
|
||||
// --- [修改] ---
|
||||
// 动态构建请求头
|
||||
const requestHeaders = {
|
||||
'Content-Type': 'application/json'
|
||||
};
|
||||
|
||||
// 只有当 API Key 存在时,才添加 Authorization 头
|
||||
if (this.apiKey) {
|
||||
requestHeaders['Authorization'] = `Bearer ${this.apiKey}`;
|
||||
this._log('正在使用 API Key 进行请求');
|
||||
} else {
|
||||
this._log('正在进行免 Key 请求');
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('https://uapis.cn/api/v1/search/aggregate', {
|
||||
method: 'POST',
|
||||
signal: this.abortController.signal,
|
||||
headers: requestHeaders, // 使用动态构建的请求头
|
||||
body: JSON.stringify(requestBody)
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
//
|
||||
throw new Error(data.message || `HTTP 错误 ${response.status}`);
|
||||
}
|
||||
|
||||
this._log(`搜索成功,返回 ${data.results?.length || 0} 条结果`);
|
||||
this._renderResults(data);
|
||||
|
||||
} catch (error) {
|
||||
if (error.name === 'AbortError') {
|
||||
this._log('搜索被用户取消');
|
||||
return;
|
||||
}
|
||||
this._log(`搜索失败: ${error.message}`);
|
||||
this.dom.resultsContainer.innerHTML = `
|
||||
<div class="empty-logs-placeholder" style="margin: auto;">
|
||||
<i class="fas fa-exclamation-triangle"></i>
|
||||
<p>搜索失败</p>
|
||||
<span>${error.message}</span>
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
_renderResults(data) {
|
||||
const results = data.results || [];
|
||||
|
||||
if (results.length === 0) {
|
||||
this.dom.resultsContainer.innerHTML = `
|
||||
<div class="empty-logs-placeholder" style="margin: auto;">
|
||||
<i class="fas fa-box-open"></i>
|
||||
<p>未找到相关结果</p>
|
||||
<span>请尝试更换关键词。</span>
|
||||
</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
// 格式化时间
|
||||
const formatTime = (isoString) => {
|
||||
if (!isoString) return '';
|
||||
try {
|
||||
return new Date(isoString).toLocaleDateString();
|
||||
} catch (e) {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
// [修改] 更新 HTML 以使用新的专用 CSS 类
|
||||
const resultsHtml = results.map(item => `
|
||||
<div class="search-result-item">
|
||||
<h4>
|
||||
<a href="#" class="hotboard-title-link" data-url="${item.url}">${item.title}</a>
|
||||
</h4>
|
||||
<p>${item.snippet}</p>
|
||||
<div class="result-meta">
|
||||
<span class="domain">
|
||||
<i class="fas fa-link"></i> ${item.domain}
|
||||
</span>
|
||||
<span><i class="fas fa-user-edit"></i> ${item.author || '未知作者'}</span>
|
||||
<span><i class="fas fa-clock"></i> ${formatTime(item.publish_time) || '未知时间'}</span>
|
||||
<span><i class="fas fa-star"></i> AI得分: ${item.score.toFixed(3)}</span>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
const statsHtml = `
|
||||
<div class="search-stats" style="font-size: 13px; color: var(--text-secondary); margin-bottom: 15px; flex-shrink: 0;">
|
||||
找到约 ${data.total_results || 0} 条结果 (耗时 ${data.process_time_ms}ms)
|
||||
</div>
|
||||
`;
|
||||
|
||||
this.dom.resultsContainer.innerHTML = `
|
||||
${statsHtml}
|
||||
<div class="results-list" style="overflow-y: auto; flex-grow: 1; min-height: 0; padding-right: 10px;">
|
||||
${resultsHtml}
|
||||
</div>
|
||||
`;
|
||||
|
||||
// 绑定所有结果链接的点击事件
|
||||
this.dom.resultsContainer.querySelectorAll('a[data-url]').forEach(link => {
|
||||
link.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
window.electronAPI.openExternalLink(e.currentTarget.dataset.url);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (this.abortController) {
|
||||
this.abortController.abort();
|
||||
}
|
||||
this._log('工具已销毁');
|
||||
super.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
export default SmartSearchTool;
|
||||
@@ -0,0 +1,172 @@
|
||||
// src/js/tools/systemTool.js
|
||||
import BaseTool from '../baseTool.js';
|
||||
|
||||
class SystemTool extends BaseTool {
|
||||
constructor() {
|
||||
super('system-tool', '系统工具');
|
||||
this.isAdmin = false; // 保存管理员权限状态
|
||||
|
||||
// 扩充后的工具列表
|
||||
this.tools = [
|
||||
// 常用工具
|
||||
{ id: 'calc', name: '计算器', icon: 'fa-calculator', command: 'calc' },
|
||||
{ id: 'notepad', name: '记事本', icon: 'fa-file-alt', command: 'notepad' },
|
||||
{ id: 'mspaint', name: '画图', icon: 'fa-paint-brush', command: 'mspaint' },
|
||||
{ id: 'snippingtool', name: '截图工具', icon: 'fa-cut', command: 'snippingtool' },
|
||||
{ id: 'osk', name: '屏幕键盘', icon: 'fa-keyboard', command: 'osk' },
|
||||
{ id: 'charmap', name: '字符映射表', icon: 'fa-font', command: 'charmap' },
|
||||
// 音频与多媒体
|
||||
{ id: 'soundrecorder', name: '录音机', icon: 'fa-microphone', command: 'ms-winsoundrecorder:' },
|
||||
{ id: 'sound', name: '声音设置', icon: 'fa-volume-up', command: 'mmsys.cpl' },
|
||||
// 系统与诊断
|
||||
{ id: 'clock', name: '时钟', icon: 'fa-clock', command: 'ms-clock:' },
|
||||
{ id: 'control', name: '控制面板', icon: 'fa-sliders-h', command: 'control' },
|
||||
{ id: 'dxdiag', name: 'DirectX诊断', icon: 'fa-gamepad', command: 'dxdiag' },
|
||||
{ id: 'mstsc', name: '远程桌面', icon: 'fa-headset', command: 'mstsc' },
|
||||
// 需要管理员权限的工具
|
||||
{ id: 'taskmgr', name: '任务管理器', icon: 'fa-tasks', command: 'taskmgr', requiresAdmin: true },
|
||||
{ id: 'power', name: '电源选项', icon: 'fa-battery-full', command: 'powercfg.cpl', requiresAdmin: true },
|
||||
{ id: 'display', name: '桌面/分辨率', icon: 'fa-desktop', command: 'desk.cpl', requiresAdmin: true },
|
||||
{ id: 'system', name: '系统属性', icon: 'fa-cogs', command: 'sysdm.cpl', requiresAdmin: true },
|
||||
{ id: 'regedit', name: '注册表编辑器', icon: 'fa-list-alt', command: 'regedit', requiresAdmin: true },
|
||||
{ id: 'services', name: '服务', icon: 'fa-running', command: 'services.msc', requiresAdmin: true },
|
||||
{ id: 'devmgmt', name: '设备管理器', icon: 'fa-microchip', command: 'devmgmt.msc', requiresAdmin: true },
|
||||
{ id: 'diskmgmt', name: '磁盘管理', icon: 'fa-hdd', command: 'diskmgmt.msc', requiresAdmin: true },
|
||||
{ id: 'perfmon', name: '性能监视器', icon: 'fa-tachometer-alt', command: 'perfmon.msc', requiresAdmin: true },
|
||||
{ id: 'msconfig', name: '系统配置', icon: 'fa-cog', command: 'msconfig', requiresAdmin: true },
|
||||
{ id: 'eventvwr', name: '事件查看器', icon: 'fa-clipboard-list', command: 'eventvwr.msc', requiresAdmin: true },
|
||||
{ id: 'gpedit', name: '组策略编辑器', icon: 'fa-list-ul', command: 'gpedit.msc', requiresAdmin: true },
|
||||
{ id: 'compmgmt', name: '计算机管理', icon: 'fa-laptop-medical', command: 'compmgmt.msc', requiresAdmin: true },
|
||||
{ id: 'cleanmgr', name: '磁盘清理', icon: 'fa-broom', command: 'cleanmgr', requiresAdmin: true },
|
||||
{ id: 'ncpa', name: '网络连接', icon: 'fa-network-wired', command: 'ncpa.cpl', requiresAdmin: true },
|
||||
];
|
||||
}
|
||||
|
||||
// 渲染静态框架
|
||||
render() {
|
||||
// [修改] 确保 id="system-tool-content" 存在且样式正确
|
||||
return `
|
||||
<div class="page-container" style="display: flex; flex-direction: column; height: 100%;">
|
||||
<div class="section-header" style="flex-shrink: 0;">
|
||||
<button id="back-to-toolbox-btn" class="back-btn ripple"><i class="fas fa-arrow-left"></i> 返回工具箱</button>
|
||||
<h1 style="flex-grow: 1; text-align: center;">${this.name}</h1>
|
||||
</div>
|
||||
<div id="system-tool-content" class="content-area" style="padding-top: 20px; flex-grow: 1; overflow-y: auto;">
|
||||
<div class="loading-container">
|
||||
<img src="./assets/loading.gif" alt="检查权限..." class="loading-gif">
|
||||
<p class="loading-text">正在检查权限...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// 初始化时检查权限,然后渲染网格
|
||||
async init() {
|
||||
this._log('工具已初始化,正在检查管理员权限...');
|
||||
document.getElementById('back-to-toolbox-btn')?.addEventListener('click', () => {
|
||||
window.mainPage.navigateTo('toolbox');
|
||||
window.mainPage.updateActiveNavButton(document.getElementById('toolbox-btn'));
|
||||
});
|
||||
|
||||
const permResult = await window.electronAPI.checkAndRelaunchAsAdmin();
|
||||
if (permResult.relaunching) {
|
||||
const contentContainer = document.getElementById('system-tool-content');
|
||||
if(contentContainer) {
|
||||
contentContainer.innerHTML = `<div class="loading-container"><p class="loading-text">正在以管理员身份重启应用...</p></div>`;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
this.isAdmin = permResult.isAdmin;
|
||||
this._log(`当前管理员状态: ${this.isAdmin}`);
|
||||
this._renderGridView();
|
||||
}
|
||||
|
||||
// 渲染工具网格
|
||||
_renderGridView() {
|
||||
const contentContainer = document.getElementById('system-tool-content');
|
||||
if (!contentContainer) return;
|
||||
|
||||
// 对工具列表进行排序,便于查找
|
||||
const sortedTools = [...this.tools].sort((a, b) => a.name.localeCompare(b.name, 'zh-Hans-CN'));
|
||||
|
||||
contentContainer.innerHTML = `
|
||||
<div class="toolbox-grid">
|
||||
${sortedTools.map(tool => {
|
||||
const isDisabled = tool.requiresAdmin && !this.isAdmin;
|
||||
return `
|
||||
<div class="tool-card ${isDisabled ? 'disabled' : ''}" data-tool-id="${tool.id}">
|
||||
<div class="tool-icon">
|
||||
<i class="fas ${tool.icon}"></i>
|
||||
</div>
|
||||
<h2>${tool.name}</h2>
|
||||
<p>${isDisabled ? '需要管理员权限' : `启动 ${tool.command}`}</p>
|
||||
${isDisabled ? '<div style="position: absolute; top: 12px; right: 12px;"><i class="fas fa-lock" style="color: var(--error-color);"></i></div>' : ''}
|
||||
</div>
|
||||
`;
|
||||
}).join('')}
|
||||
</div>`;
|
||||
|
||||
this._bindGridEvents();
|
||||
this._fadeInContent('.tool-card');
|
||||
}
|
||||
|
||||
// 绑定网格点击事件
|
||||
_bindGridEvents() {
|
||||
document.querySelectorAll('#system-tool-content .tool-card').forEach(card => {
|
||||
const toolId = card.dataset.toolId;
|
||||
const tool = this.tools.find(t => t.id === toolId);
|
||||
if (!tool) return;
|
||||
|
||||
if (card.classList.contains('disabled')) {
|
||||
card.addEventListener('click', () => {
|
||||
this._notify('权限不足', '此工具需要以管理员身份运行本软件才能打开。', 'error');
|
||||
});
|
||||
} else {
|
||||
card.addEventListener('click', () => {
|
||||
// 对于非管理员也可启动的工具,不再弹出确认框,直接启动
|
||||
if (!tool.requiresAdmin) {
|
||||
this._log(`用户启动: ${tool.name} (${tool.command})`);
|
||||
window.electronAPI.launchSystemTool(tool.command);
|
||||
} else {
|
||||
// 对于需要管理员权限的工具,保留确认框
|
||||
this._handleExternalTool(tool.command, tool.name);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 外部工具处理器
|
||||
async _handleExternalTool(command, toolName) {
|
||||
const result = await window.electronAPI.showConfirmationDialog({
|
||||
title: '操作确认',
|
||||
message: `您确定要启动系统工具“${toolName}”吗?`,
|
||||
detail: `即将执行命令: ${command}`
|
||||
});
|
||||
|
||||
if (result.response === 0) { // 0 代表 "确定"
|
||||
this._log(`用户授权启动: ${toolName} (${command})`);
|
||||
window.electronAPI.launchSystemTool(command);
|
||||
} else {
|
||||
this._log(`用户取消启动: ${toolName}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 动画触发辅助函数
|
||||
_fadeInContent(selector) {
|
||||
document.querySelectorAll(selector).forEach((el, i) => {
|
||||
el.style.animation = 'none';
|
||||
el.offsetHeight; // 强制浏览器重绘
|
||||
el.style.animation = `contentFadeIn 0.5s cubic-bezier(0.25, 0.8, 0.25, 1) forwards ${i * 0.05}s`;
|
||||
});
|
||||
}
|
||||
|
||||
// 重写 destroy 方法
|
||||
destroy() {
|
||||
this._log('工具已销毁');
|
||||
super.destroy(); // 调用父类的 destroy 方法
|
||||
}
|
||||
}
|
||||
|
||||
export default SystemTool;
|
||||
@@ -0,0 +1,171 @@
|
||||
// src/js/tools/systemTool.js
|
||||
import BaseTool from '../baseTool.js';
|
||||
|
||||
class SystemTool extends BaseTool {
|
||||
constructor() {
|
||||
super('system-tool', '系统工具');
|
||||
this.isAdmin = false; // 保存管理员权限状态
|
||||
|
||||
// 扩充后的工具列表
|
||||
this.tools = [
|
||||
// 常用工具
|
||||
{ id: 'calc', name: '计算器', icon: 'fa-calculator', command: 'calc' },
|
||||
{ id: 'notepad', name: '记事本', icon: 'fa-file-alt', command: 'notepad' },
|
||||
{ id: 'mspaint', name: '画图', icon: 'fa-paint-brush', command: 'mspaint' },
|
||||
{ id: 'snippingtool', name: '截图工具', icon: 'fa-cut', command: 'snippingtool' },
|
||||
{ id: 'osk', name: '屏幕键盘', icon: 'fa-keyboard', command: 'osk' },
|
||||
{ id: 'charmap', name: '字符映射表', icon: 'fa-font', command: 'charmap' },
|
||||
// 音频与多媒体
|
||||
{ id: 'soundrecorder', name: '录音机', icon: 'fa-microphone', command: 'ms-winsoundrecorder:' },
|
||||
{ id: 'sound', name: '声音设置', icon: 'fa-volume-up', command: 'mmsys.cpl' },
|
||||
// 系统与诊断
|
||||
{ id: 'clock', name: '时钟', icon: 'fa-clock', command: 'ms-clock:' },
|
||||
{ id: 'control', name: '控制面板', icon: 'fa-sliders-h', command: 'control' },
|
||||
{ id: 'dxdiag', name: 'DirectX诊断', icon: 'fa-gamepad', command: 'dxdiag' },
|
||||
{ id: 'mstsc', name: '远程桌面', icon: 'fa-headset', command: 'mstsc' },
|
||||
// 需要管理员权限的工具
|
||||
{ id: 'taskmgr', name: '任务管理器', icon: 'fa-tasks', command: 'taskmgr', requiresAdmin: true },
|
||||
{ id: 'power', name: '电源选项', icon: 'fa-battery-full', command: 'powercfg.cpl', requiresAdmin: true },
|
||||
{ id: 'display', name: '桌面/分辨率', icon: 'fa-desktop', command: 'desk.cpl', requiresAdmin: true },
|
||||
{ id: 'system', name: '系统属性', icon: 'fa-cogs', command: 'sysdm.cpl', requiresAdmin: true },
|
||||
{ id: 'regedit', name: '注册表编辑器', icon: 'fa-list-alt', command: 'regedit', requiresAdmin: true },
|
||||
{ id: 'services', name: '服务', icon: 'fa-running', command: 'services.msc', requiresAdmin: true },
|
||||
{ id: 'devmgmt', name: '设备管理器', icon: 'fa-microchip', command: 'devmgmt.msc', requiresAdmin: true },
|
||||
{ id: 'diskmgmt', name: '磁盘管理', icon: 'fa-hdd', command: 'diskmgmt.msc', requiresAdmin: true },
|
||||
{ id: 'perfmon', name: '性能监视器', icon: 'fa-tachometer-alt', command: 'perfmon.msc', requiresAdmin: true },
|
||||
{ id: 'msconfig', name: '系统配置', icon: 'fa-cog', command: 'msconfig', requiresAdmin: true },
|
||||
{ id: 'eventvwr', name: '事件查看器', icon: 'fa-clipboard-list', command: 'eventvwr.msc', requiresAdmin: true },
|
||||
{ id: 'gpedit', name: '组策略编辑器', icon: 'fa-list-ul', command: 'gpedit.msc', requiresAdmin: true },
|
||||
{ id: 'compmgmt', name: '计算机管理', icon: 'fa-laptop-medical', command: 'compmgmt.msc', requiresAdmin: true },
|
||||
{ id: 'cleanmgr', name: '磁盘清理', icon: 'fa-broom', command: 'cleanmgr', requiresAdmin: true },
|
||||
{ id: 'ncpa', name: '网络连接', icon: 'fa-network-wired', command: 'ncpa.cpl', requiresAdmin: true },
|
||||
];
|
||||
}
|
||||
|
||||
// 渲染静态框架
|
||||
render() {
|
||||
return `
|
||||
<div class="page-container" style="display: flex; flex-direction: column; height: 100%;">
|
||||
<div class="section-header" style="flex-shrink: 0;">
|
||||
<button id="back-to-toolbox-btn" class="back-btn ripple"><i class="fas fa-arrow-left"></i> 返回工具箱</button>
|
||||
<h1 style="flex-grow: 1; text-align: center;">${this.name}</h1>
|
||||
</div>
|
||||
<div id="system-tool-content" class="content-area" style="padding-top: 20px; flex-grow: 1; overflow-y: auto;">
|
||||
<div class="loading-container">
|
||||
<img src="./assets/loading.gif" alt="检查权限..." class="loading-gif">
|
||||
<p class="loading-text">正在检查权限...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// 初始化时检查权限,然后渲染网格
|
||||
async init() {
|
||||
this._log('工具已初始化,正在检查管理员权限...');
|
||||
document.getElementById('back-to-toolbox-btn')?.addEventListener('click', () => {
|
||||
window.mainPage.navigateTo('toolbox');
|
||||
window.mainPage.updateActiveNavButton(document.getElementById('toolbox-btn'));
|
||||
});
|
||||
|
||||
const permResult = await window.electronAPI.checkAndRelaunchAsAdmin();
|
||||
if (permResult.relaunching) {
|
||||
const contentContainer = document.getElementById('system-tool-content');
|
||||
if(contentContainer) {
|
||||
contentContainer.innerHTML = `<div class="loading-container"><p class="loading-text">正在以管理员身份重启应用...</p></div>`;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
this.isAdmin = permResult.isAdmin;
|
||||
this._log(`当前管理员状态: ${this.isAdmin}`);
|
||||
this._renderGridView();
|
||||
}
|
||||
|
||||
// 渲染工具网格
|
||||
_renderGridView() {
|
||||
const contentContainer = document.getElementById('system-tool-content');
|
||||
if (!contentContainer) return;
|
||||
|
||||
// 对工具列表进行排序,便于查找
|
||||
const sortedTools = [...this.tools].sort((a, b) => a.name.localeCompare(b.name, 'zh-Hans-CN'));
|
||||
|
||||
contentContainer.innerHTML = `
|
||||
<div class="toolbox-grid">
|
||||
${sortedTools.map(tool => {
|
||||
const isDisabled = tool.requiresAdmin && !this.isAdmin;
|
||||
return `
|
||||
<div class="tool-card ${isDisabled ? 'disabled' : ''}" data-tool-id="${tool.id}">
|
||||
<div class="tool-icon">
|
||||
<i class="fas ${tool.icon}"></i>
|
||||
</div>
|
||||
<h2>${tool.name}</h2>
|
||||
<p>${isDisabled ? '需要管理员权限' : `启动 ${tool.command}`}</p>
|
||||
${isDisabled ? '<div style="position: absolute; top: 12px; right: 12px;"><i class="fas fa-lock" style="color: var(--error-color);"></i></div>' : ''}
|
||||
</div>
|
||||
`;
|
||||
}).join('')}
|
||||
</div>`;
|
||||
|
||||
this._bindGridEvents();
|
||||
this._fadeInContent('.tool-card');
|
||||
}
|
||||
|
||||
// 绑定网格点击事件
|
||||
_bindGridEvents() {
|
||||
document.querySelectorAll('#system-tool-content .tool-card').forEach(card => {
|
||||
const toolId = card.dataset.toolId;
|
||||
const tool = this.tools.find(t => t.id === toolId);
|
||||
if (!tool) return;
|
||||
|
||||
if (card.classList.contains('disabled')) {
|
||||
card.addEventListener('click', () => {
|
||||
this._notify('权限不足', '此工具需要以管理员身份运行本软件才能打开。', 'error');
|
||||
});
|
||||
} else {
|
||||
card.addEventListener('click', () => {
|
||||
// 对于非管理员也可启动的工具,不再弹出确认框,直接启动
|
||||
if (!tool.requiresAdmin) {
|
||||
this._log(`用户启动: ${tool.name} (${tool.command})`);
|
||||
window.electronAPI.launchSystemTool(tool.command);
|
||||
} else {
|
||||
// 对于需要管理员权限的工具,保留确认框
|
||||
this._handleExternalTool(tool.command, tool.name);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 外部工具处理器
|
||||
async _handleExternalTool(command, toolName) {
|
||||
const result = await window.electronAPI.showConfirmationDialog({
|
||||
title: '操作确认',
|
||||
message: `您确定要启动系统工具“${toolName}”吗?`,
|
||||
detail: `即将执行命令: ${command}`
|
||||
});
|
||||
|
||||
if (result.response === 0) { // 0 代表 "确定"
|
||||
this._log(`用户授权启动: ${toolName} (${command})`);
|
||||
window.electronAPI.launchSystemTool(command);
|
||||
} else {
|
||||
this._log(`用户取消启动: ${toolName}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 动画触发辅助函数
|
||||
_fadeInContent(selector) {
|
||||
document.querySelectorAll(selector).forEach((el, i) => {
|
||||
el.style.animation = 'none';
|
||||
el.offsetHeight; // 强制浏览器重绘
|
||||
el.style.animation = `contentFadeIn 0.5s cubic-bezier(0.25, 0.8, 0.25, 1) forwards ${i * 0.05}s`;
|
||||
});
|
||||
}
|
||||
|
||||
// 重写 destroy 方法
|
||||
destroy() {
|
||||
this._log('工具已销毁');
|
||||
super.destroy(); // 调用父类的 destroy 方法
|
||||
}
|
||||
}
|
||||
|
||||
export default SystemTool;
|
||||
@@ -0,0 +1,181 @@
|
||||
// src/js/tools/techNewsTool.js
|
||||
import BaseTool from '../baseTool.js';
|
||||
import i18n from '../i18n.js';
|
||||
|
||||
class TechNewsTool extends BaseTool {
|
||||
constructor() {
|
||||
super('tech-news', '实时科技资讯');
|
||||
this.dom = {};
|
||||
this.apiUrl = 'https://api.jkyai.top/API/kjzx.php';
|
||||
}
|
||||
|
||||
render() {
|
||||
return `
|
||||
<div class="page-container tech-news-tool-container" style="display: flex; flex-direction: column; height: 100%;">
|
||||
<div class="content-area" style="padding: 20px; flex-grow: 1; display: flex; flex-direction: column; gap: 20px; overflow-y: auto;">
|
||||
|
||||
<div class="settings-section">
|
||||
<div class="news-header" style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 30px; flex-wrap: wrap; gap: 16px;">
|
||||
<div>
|
||||
<h2><i class="fas fa-microchip"></i> ${i18n.t('tool.techNews.title', '最新科技资讯')}</h2>
|
||||
<p style="color: var(--text-secondary); margin-top: 8px;">${i18n.t('tool.techNews.description', '获取当前时间的最新实时科技资讯信息')}</p>
|
||||
</div>
|
||||
<div class="news-update-time" id="tech-news-update-time" style="font-size: 14px; color: var(--text-secondary);"></div>
|
||||
</div>
|
||||
|
||||
<div class="action-section" style="display: flex; justify-content: center; margin-bottom: 30px;">
|
||||
<button class="action-btn ripple" id="tech-news-refresh-btn">
|
||||
<i class="fas fa-sync-alt"></i> ${i18n.t('tool.techNews.refresh', '刷新资讯')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-section">
|
||||
<h2><i class="fas fa-list"></i> ${i18n.t('tool.techNews.newsList', '资讯列表')}</h2>
|
||||
<div class="news-list" id="tech-news-list" style="display: flex; flex-direction: column; gap: 16px; margin-top: 20px;"></div>
|
||||
</div>
|
||||
|
||||
<div class="loading" id="tech-news-loading" style="display: none; text-align: center; padding: 40px; color: var(--text-secondary);">
|
||||
<div class="loading-spinner" style="border: 4px solid rgba(var(--primary-rgb), 0.1); border-top: 4px solid var(--primary-color); border-radius: 50%; width: 40px; height: 40px; animation: spin 1s linear infinite; margin: 0 auto 16px;"></div>
|
||||
<div>${i18n.t('tool.techNews.loading', '正在获取实时科技资讯,请稍候...')}</div>
|
||||
</div>
|
||||
|
||||
<div class="error-message" id="tech-news-error" style="display: none; background: rgba(220, 53, 69, 0.1); border: 1px solid rgba(220, 53, 69, 0.3); border-radius: 8px; padding: 16px; color: #dc3545; margin-bottom: 20px;"></div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
init() {
|
||||
this._log('科技资讯工具初始化');
|
||||
|
||||
this.dom = {
|
||||
refreshBtn: document.getElementById('tech-news-refresh-btn'),
|
||||
updateTime: document.getElementById('tech-news-update-time'),
|
||||
newsList: document.getElementById('tech-news-list'),
|
||||
loading: document.getElementById('tech-news-loading'),
|
||||
error: document.getElementById('tech-news-error')
|
||||
};
|
||||
|
||||
this.dom.refreshBtn.addEventListener('click', () => {
|
||||
this.fetchNews();
|
||||
});
|
||||
|
||||
// 返回工具箱按钮
|
||||
document.getElementById('back-to-toolbox-btn')?.addEventListener('click', () => {
|
||||
window.mainPage.navigateTo('toolbox');
|
||||
window.mainPage.updateActiveNavButton(document.getElementById('toolbox-btn'));
|
||||
});
|
||||
|
||||
// 自动加载数据
|
||||
this.fetchNews();
|
||||
}
|
||||
|
||||
async fetchNews() {
|
||||
this.showLoading();
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
const response = await fetch(`${this.apiUrl}?type=json`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP Error! Status code: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const responseTime = Date.now() - startTime;
|
||||
|
||||
if (data.code === 200 && data.data) {
|
||||
this.showNews(data);
|
||||
this._log(`成功获取科技资讯,耗时 ${responseTime}ms`);
|
||||
} else {
|
||||
this.showError(data.msg || i18n.t('tool.techNews.fetchFailed', '获取数据失败'));
|
||||
this._log(`获取科技资讯失败: ${data.msg || '未知错误'}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('API请求错误:', error);
|
||||
this.showError(i18n.t('tool.techNews.fetchFailed', '获取数据失败') + ': ' + error.message);
|
||||
this._log(`获取科技资讯失败: ${error.message}`);
|
||||
} finally {
|
||||
this.hideLoading();
|
||||
}
|
||||
}
|
||||
|
||||
showNews(data) {
|
||||
if (data.update) {
|
||||
this.dom.updateTime.textContent = `${i18n.t('tool.techNews.updateTime', '更新时间')}: ${data.update}`;
|
||||
}
|
||||
|
||||
this.dom.newsList.innerHTML = '';
|
||||
|
||||
if (Array.isArray(data.data) && data.data.length > 0) {
|
||||
data.data.forEach(news => {
|
||||
const newsItem = this.createNewsItem(news);
|
||||
this.dom.newsList.appendChild(newsItem);
|
||||
});
|
||||
} else {
|
||||
this.dom.newsList.innerHTML = `<div style="text-align: center; padding: 40px; color: var(--text-secondary);">${i18n.t('tool.techNews.noNews', '暂无资讯')}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
createNewsItem(news) {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'news-item';
|
||||
item.style.cssText = 'padding: 20px; background: var(--card-bg); border: 1px solid var(--border-color); border-radius: 8px; transition: all 0.3s ease;';
|
||||
|
||||
const title = news.title || news.news_title || i18n.t('tool.techNews.unknownTitle', '未知标题');
|
||||
const time = news.time || news.news_time || '';
|
||||
|
||||
item.innerHTML = `
|
||||
<div class="news-item-content" style="display: flex; justify-content: space-between; align-items: flex-start; gap: 16px; flex-wrap: wrap;">
|
||||
<div class="news-item-title" style="flex: 1; font-size: 16px; font-weight: 600; color: var(--text-primary); line-height: 1.6; min-width: 250px;">${title}</div>
|
||||
${time ? `<div class="news-item-time" style="font-size: 14px; color: var(--text-secondary); white-space: nowrap;">${time}</div>` : ''}
|
||||
</div>
|
||||
`;
|
||||
|
||||
item.addEventListener('mouseenter', () => {
|
||||
item.style.background = 'rgba(var(--primary-rgb), 0.05)';
|
||||
item.style.borderColor = 'var(--primary-color)';
|
||||
item.style.transform = 'translateX(4px)';
|
||||
});
|
||||
|
||||
item.addEventListener('mouseleave', () => {
|
||||
item.style.background = 'var(--card-bg)';
|
||||
item.style.borderColor = 'var(--border-color)';
|
||||
item.style.transform = 'translateX(0)';
|
||||
});
|
||||
|
||||
return item;
|
||||
}
|
||||
|
||||
showLoading() {
|
||||
this.dom.refreshBtn.disabled = true;
|
||||
this.dom.refreshBtn.innerHTML = `<i class="fas fa-spinner fa-spin"></i> ${i18n.t('tool.techNews.refreshing', '刷新中...')}`;
|
||||
this.dom.loading.style.display = 'block';
|
||||
this.dom.newsList.innerHTML = '';
|
||||
this.dom.error.style.display = 'none';
|
||||
}
|
||||
|
||||
hideLoading() {
|
||||
this.dom.refreshBtn.disabled = false;
|
||||
this.dom.refreshBtn.innerHTML = `<i class="fas fa-sync-alt"></i> ${i18n.t('tool.techNews.refresh', '刷新资讯')}`;
|
||||
this.dom.loading.style.display = 'none';
|
||||
}
|
||||
|
||||
showError(message) {
|
||||
this.dom.error.textContent = message;
|
||||
this.dom.error.style.display = 'block';
|
||||
this.dom.newsList.innerHTML = '';
|
||||
this.dom.loading.style.display = 'none';
|
||||
this.dom.refreshBtn.disabled = false;
|
||||
this.dom.refreshBtn.innerHTML = `<i class="fas fa-sync-alt"></i> ${i18n.t('tool.techNews.refresh', '刷新资讯')}`;
|
||||
}
|
||||
}
|
||||
|
||||
export default TechNewsTool;
|
||||
@@ -0,0 +1,109 @@
|
||||
// src/js/tools/textStatisticsTool.js
|
||||
import BaseTool from '../baseTool.js';
|
||||
|
||||
export default class TextStatisticsTool extends BaseTool {
|
||||
constructor() {
|
||||
super('text-statistics', '文本统计');
|
||||
}
|
||||
|
||||
render() {
|
||||
return `
|
||||
<div class="page-container" style="padding: 20px; display: flex; flex-direction: column; gap: 20px; height: 100%;">
|
||||
<div class="section-header">
|
||||
<button class="back-btn ripple" id="back-btn"><i class="fas fa-arrow-left"></i></button>
|
||||
<h1>${this.name}</h1>
|
||||
</div>
|
||||
|
||||
<div style="flex: 1; display: flex; flex-direction: column; gap: 15px; min-height: 0;">
|
||||
<textarea id="text-input" class="common-textarea" placeholder="在此输入文本..." style="flex: 1; resize: none; font-family: monospace;"></textarea>
|
||||
|
||||
<div class="island-card" style="padding: 20px; display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 15px;">
|
||||
<div class="stat-item">
|
||||
<div class="stat-label">字符总数</div>
|
||||
<div class="stat-value" id="stat-chars">0</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-label">字符(不含空格)</div>
|
||||
<div class="stat-value" id="stat-chars-no-space">0</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-label">单词数</div>
|
||||
<div class="stat-value" id="stat-words">0</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-label">行数</div>
|
||||
<div class="stat-value" id="stat-lines">0</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-label">段落数</div>
|
||||
<div class="stat-value" id="stat-paragraphs">0</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-label">中文字符</div>
|
||||
<div class="stat-value" id="stat-chinese">0</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-label">英文字符</div>
|
||||
<div class="stat-value" id="stat-english">0</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-label">数字</div>
|
||||
<div class="stat-value" id="stat-numbers">0</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.stat-item {
|
||||
text-align: center;
|
||||
padding: 12px;
|
||||
background: rgba(var(--card-background-rgb), 0.5);
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
.stat-label {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.stat-value {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: var(--primary-color);
|
||||
font-family: monospace;
|
||||
}
|
||||
</style>
|
||||
`;
|
||||
}
|
||||
|
||||
init() {
|
||||
document.getElementById('back-btn').addEventListener('click', () => window.mainPage.navigateTo('toolbox'));
|
||||
|
||||
const input = document.getElementById('text-input');
|
||||
|
||||
const updateStats = () => {
|
||||
const text = input.value;
|
||||
const chars = text.length;
|
||||
const charsNoSpace = text.replace(/\s/g, '').length;
|
||||
const words = text.trim() ? text.trim().split(/\s+/).length : 0;
|
||||
const lines = text ? text.split('\n').length : 0;
|
||||
const paragraphs = text.trim() ? text.trim().split(/\n\s*\n/).length : 0;
|
||||
const chinese = (text.match(/[\u4e00-\u9fa5]/g) || []).length;
|
||||
const english = (text.match(/[a-zA-Z]/g) || []).length;
|
||||
const numbers = (text.match(/[0-9]/g) || []).length;
|
||||
|
||||
document.getElementById('stat-chars').textContent = chars.toLocaleString();
|
||||
document.getElementById('stat-chars-no-space').textContent = charsNoSpace.toLocaleString();
|
||||
document.getElementById('stat-words').textContent = words.toLocaleString();
|
||||
document.getElementById('stat-lines').textContent = lines.toLocaleString();
|
||||
document.getElementById('stat-paragraphs').textContent = paragraphs.toLocaleString();
|
||||
document.getElementById('stat-chinese').textContent = chinese.toLocaleString();
|
||||
document.getElementById('stat-english').textContent = english.toLocaleString();
|
||||
document.getElementById('stat-numbers').textContent = numbers.toLocaleString();
|
||||
};
|
||||
|
||||
input.addEventListener('input', updateStats);
|
||||
updateStats();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
// src/js/tools/timestampTool.js
|
||||
import BaseTool from '../baseTool.js';
|
||||
|
||||
export default class TimestampTool extends BaseTool {
|
||||
constructor() {
|
||||
super('timestamp-tool', '时间戳转换');
|
||||
this.timer = null;
|
||||
}
|
||||
|
||||
render() {
|
||||
return `
|
||||
<div class="page-container" style="display: flex; flex-direction: column; height: 100%;">
|
||||
<div class="section-header" style="flex-shrink: 0; margin-bottom: 20px;">
|
||||
<button id="back-to-toolbox-btn" class="back-btn ripple"><i class="fas fa-arrow-left"></i> 返回工具箱</button>
|
||||
<h1 style="flex-grow: 1; text-align: center;">${this.name}</h1>
|
||||
</div>
|
||||
|
||||
<div class="tool-island-container">
|
||||
<div class="tool-island-card" style="text-align: center; padding: 30px;">
|
||||
<div style="font-size: 14px; opacity: 0.7;">当前时间</div>
|
||||
<div id="ts-now-str" style="font-size: 32px; font-weight: 700; margin: 10px 0; font-family: monospace;">Loading...</div>
|
||||
<div id="ts-now-val" style="font-size: 16px; color: var(--primary-color); font-family: monospace;">Loading...</div>
|
||||
<button id="ts-pause-btn" class="control-btn mini-btn ripple" style="margin-top: 15px;">暂停 / 继续</button>
|
||||
</div>
|
||||
|
||||
<div class="tool-island-card">
|
||||
<div class="tool-group-title"><i class="fas fa-exchange-alt"></i> 时间戳转日期</div>
|
||||
<div style="display: flex; gap: 10px; flex-wrap: wrap;">
|
||||
<input type="text" id="ts-input-stamp" class="common-input" placeholder="输入时间戳 (秒或毫秒)" style="flex: 1;">
|
||||
<select id="ts-unit-select" class="common-select">
|
||||
<option value="auto">自动识别</option>
|
||||
<option value="s">秒 (s)</option>
|
||||
<option value="ms">毫秒 (ms)</option>
|
||||
</select>
|
||||
<button id="ts-conv-date-btn" class="action-btn ripple">转换</button>
|
||||
</div>
|
||||
<div class="result-box" style="margin-top: 15px;">
|
||||
<input type="text" id="ts-output-date" class="common-input" readonly placeholder="结果将显示在这里">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tool-island-card">
|
||||
<div class="tool-group-title"><i class="fas fa-clock"></i> 日期转时间戳</div>
|
||||
<div style="display: flex; gap: 10px; flex-wrap: wrap;">
|
||||
<input type="text" id="ts-input-date" class="common-input" placeholder="YYYY-MM-DD HH:mm:ss" style="flex: 1;">
|
||||
<button id="ts-conv-stamp-btn" class="action-btn ripple">转换</button>
|
||||
</div>
|
||||
<div style="display: flex; gap: 10px; margin-top: 15px;">
|
||||
<div class="input-group" style="flex: 1;">
|
||||
<label>秒</label>
|
||||
<input type="text" id="ts-output-s" class="common-input" readonly>
|
||||
</div>
|
||||
<div class="input-group" style="flex: 1;">
|
||||
<label>毫秒</label>
|
||||
<input type="text" id="ts-output-ms" class="common-input" readonly>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<style>
|
||||
/* 修复下拉菜单样式适配 */
|
||||
.common-select {
|
||||
background-color: rgba(var(--bg-color-rgb), 0.5);
|
||||
color: var(--text-color);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
padding: 0 10px;
|
||||
height: 40px;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
.common-select option {
|
||||
background-color: rgb(var(--card-background-rgb));
|
||||
color: var(--text-color);
|
||||
}
|
||||
</style>
|
||||
`;
|
||||
}
|
||||
|
||||
init() {
|
||||
// 绑定返回按钮
|
||||
document.getElementById('back-to-toolbox-btn')?.addEventListener('click', () => {
|
||||
window.mainPage.navigateTo('toolbox');
|
||||
window.mainPage.updateActiveNavButton(document.getElementById('toolbox-btn'));
|
||||
});
|
||||
|
||||
this.startTimer();
|
||||
|
||||
document.getElementById('ts-pause-btn').addEventListener('click', () => {
|
||||
if (this.timer) {
|
||||
clearInterval(this.timer);
|
||||
this.timer = null;
|
||||
} else {
|
||||
this.startTimer();
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('ts-conv-date-btn').addEventListener('click', () => {
|
||||
const val = document.getElementById('ts-input-stamp').value.trim();
|
||||
const unit = document.getElementById('ts-unit-select').value;
|
||||
if (!val) return;
|
||||
|
||||
let ts = parseInt(val);
|
||||
if (isNaN(ts)) return this._notify('错误', '请输入有效的数字', 'error');
|
||||
|
||||
if (unit === 'auto') {
|
||||
if (val.length === 10) ts *= 1000;
|
||||
} else if (unit === 's') {
|
||||
ts *= 1000;
|
||||
}
|
||||
|
||||
const date = new Date(ts);
|
||||
document.getElementById('ts-output-date').value = this.formatDate(date);
|
||||
});
|
||||
|
||||
document.getElementById('ts-conv-stamp-btn').addEventListener('click', () => {
|
||||
const val = document.getElementById('ts-input-date').value.trim();
|
||||
if (!val) return;
|
||||
const date = new Date(val);
|
||||
if (isNaN(date.getTime())) return this._notify('错误', '日期格式不正确', 'error');
|
||||
|
||||
const ms = date.getTime();
|
||||
document.getElementById('ts-output-ms').value = ms;
|
||||
document.getElementById('ts-output-s').value = Math.floor(ms / 1000);
|
||||
});
|
||||
|
||||
document.getElementById('ts-input-date').value = this.formatDate(new Date());
|
||||
}
|
||||
|
||||
startTimer() {
|
||||
const update = () => {
|
||||
const now = new Date();
|
||||
document.getElementById('ts-now-str').innerText = this.formatDate(now);
|
||||
document.getElementById('ts-now-val').innerText = Math.floor(now.getTime() / 1000);
|
||||
};
|
||||
update();
|
||||
this.timer = setInterval(update, 1000);
|
||||
}
|
||||
|
||||
formatDate(date) {
|
||||
const pad = n => n < 10 ? '0' + n : n;
|
||||
return `${date.getFullYear()}-${pad(date.getMonth()+1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (this.timer) clearInterval(this.timer);
|
||||
super.destroy();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,394 @@
|
||||
// src/js/tools/trainQueryTool.js
|
||||
import BaseTool from '../baseTool.js';
|
||||
import i18n from '../i18n.js';
|
||||
|
||||
class TrainQueryTool extends BaseTool {
|
||||
constructor() {
|
||||
super('train-query', '火车批次查询');
|
||||
this.dom = {};
|
||||
this.apiUrl = 'https://api.jkyai.top/API/hcpccx.php';
|
||||
}
|
||||
|
||||
render() {
|
||||
// 设置默认日期为今天
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
|
||||
return `
|
||||
<div class="page-container train-query-tool-container" style="display: flex; flex-direction: column; height: 100%;">
|
||||
<div class="content-area" style="padding: 20px; flex-grow: 1; display: flex; flex-direction: column; gap: 20px; overflow-y: auto;">
|
||||
|
||||
<div class="settings-section" style="position: relative;">
|
||||
<button id="back-to-toolbox-btn" class="back-btn ripple" style="position: absolute; top: 0; left: 0; z-index: 10;">
|
||||
<i class="fas fa-arrow-left"></i> ${i18n.t('common.backToToolbox', '返回工具箱')}
|
||||
</button>
|
||||
<div style="text-align: center; margin-bottom: 20px;">
|
||||
<h2><i class="fas fa-train"></i> ${i18n.t('tool.trainQuery.title', '火车批次查询')}</h2>
|
||||
<p style="color: var(--text-secondary); margin-top: 8px;">${i18n.t('tool.trainQuery.description', '查询全国火车和高铁班次信息')}</p>
|
||||
</div>
|
||||
|
||||
<div class="query-section" style="background: rgba(var(--card-background-rgb), 0.5); padding: 24px; border-radius: 12px; margin-bottom: 24px;">
|
||||
<form id="train-query-form" class="query-form" style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 16px; align-items: end;">
|
||||
<div class="form-group">
|
||||
<label for="train-query-go" class="form-label" style="display: block; font-size: 14px; font-weight: 600; color: var(--text-primary); margin-bottom: 8px;">${i18n.t('tool.trainQuery.departure', '出发城市')}</label>
|
||||
<input type="text" id="train-query-go" name="go" class="form-input" placeholder="${i18n.t('tool.trainQuery.departurePlaceholder', '请输入出发城市')}" value="长沙" required style="width: 100%; padding: 12px 16px; border: 2px solid var(--border-color); border-radius: 8px; font-size: 16px; transition: all 0.3s ease; background: var(--input-bg); color: var(--text-primary);">
|
||||
</div>
|
||||
|
||||
<div class="swap-container" style="display: flex; align-items: center; justify-content: center;">
|
||||
<button type="button" id="train-query-swap-btn" class="action-btn ripple" style="width: 48px; height: 48px; border-radius: 50%; padding: 0; display: flex; align-items: center; justify-content: center;" title="${i18n.t('tool.trainQuery.swap', '交换城市')}">
|
||||
<i class="fas fa-exchange-alt"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="train-query-to" class="form-label" style="display: block; font-size: 14px; font-weight: 600; color: var(--text-primary); margin-bottom: 8px;">${i18n.t('tool.trainQuery.arrival', '到达城市')}</label>
|
||||
<input type="text" id="train-query-to" name="to" class="form-input" placeholder="${i18n.t('tool.trainQuery.arrivalPlaceholder', '请输入到达城市')}" value="北京" required style="width: 100%; padding: 12px 16px; border: 2px solid var(--border-color); border-radius: 8px; font-size: 16px; transition: all 0.3s ease; background: var(--input-bg); color: var(--text-primary);">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="train-query-type" class="form-label" style="display: block; font-size: 14px; font-weight: 600; color: var(--text-primary); margin-bottom: 8px;">${i18n.t('tool.trainQuery.trainType', '列车类型')}</label>
|
||||
<select id="train-query-type" name="form" class="form-select" style="width: 100%; padding: 12px 16px; border: 2px solid var(--border-color); border-radius: 8px; font-size: 16px; background: var(--input-bg); color: var(--text-primary); cursor: pointer;">
|
||||
<option value="">${i18n.t('tool.trainQuery.all', '全部')}</option>
|
||||
<option value="高铁">${i18n.t('tool.trainQuery.highSpeed', '高铁')}</option>
|
||||
<option value="火车">${i18n.t('tool.trainQuery.train', '火车')}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="train-query-date" class="form-label" style="display: block; font-size: 14px; font-weight: 600; color: var(--text-primary); margin-bottom: 8px;">${i18n.t('tool.trainQuery.date', '查询日期')}</label>
|
||||
<input type="date" id="train-query-date" name="time" class="form-input" value="${today}" required style="width: 100%; padding: 12px 16px; border: 2px solid var(--border-color); border-radius: 8px; font-size: 16px; background: var(--input-bg); color: var(--text-primary);">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="train-query-count" class="form-label" style="display: block; font-size: 14px; font-weight: 600; color: var(--text-primary); margin-bottom: 8px;">${i18n.t('tool.trainQuery.count', '返回条数')}</label>
|
||||
<select id="train-query-count" name="count" class="form-select" style="width: 100%; padding: 12px 16px; border: 2px solid var(--border-color); border-radius: 8px; font-size: 16px; background: var(--input-bg); color: var(--text-primary); cursor: pointer;">
|
||||
<option value="10">10 ${i18n.t('tool.trainQuery.items', '条')}</option>
|
||||
<option value="20">20 ${i18n.t('tool.trainQuery.items', '条')}</option>
|
||||
<option value="30">30 ${i18n.t('tool.trainQuery.items', '条')}</option>
|
||||
<option value="50">50 ${i18n.t('tool.trainQuery.items', '条')}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<button type="submit" class="action-btn ripple" id="train-query-btn" style="width: 100%; height: 48px;">
|
||||
<i class="fas fa-search"></i> ${i18n.t('tool.trainQuery.query', '查询火车班次')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="error-message" id="train-query-error-message" style="display: none; background: rgba(220, 53, 69, 0.1); border: 1px solid rgba(220, 53, 69, 0.3); border-radius: 8px; padding: 16px; color: #dc3545; margin-bottom: 20px;"></div>
|
||||
</div>
|
||||
|
||||
<div class="settings-section" id="train-query-result-section" style="display: none;">
|
||||
<div class="result-header" style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; flex-wrap: wrap; gap: 16px;">
|
||||
<h2><i class="fas fa-list"></i> ${i18n.t('tool.trainQuery.results', '查询结果')}</h2>
|
||||
<div class="result-meta" id="train-query-result-meta" style="display: flex; gap: 16px; flex-wrap: wrap; font-size: 14px; color: var(--text-secondary);">
|
||||
<span id="train-query-meta-go"></span>
|
||||
<span id="train-query-meta-to"></span>
|
||||
<span id="train-query-meta-date"></span>
|
||||
<span id="train-query-meta-total"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="train-list" id="train-query-train-list" style="display: flex; flex-direction: column; gap: 16px;"></div>
|
||||
</div>
|
||||
|
||||
<div class="loading" id="train-query-loading" style="display: none; text-align: center; padding: 40px; color: var(--text-secondary);">
|
||||
<div class="loading-spinner" style="border: 4px solid rgba(var(--primary-rgb), 0.1); border-top: 4px solid var(--primary-color); border-radius: 50%; width: 40px; height: 40px; animation: spin 1s linear infinite; margin: 0 auto 16px;"></div>
|
||||
<div>${i18n.t('tool.trainQuery.loading', '正在查询火车班次,请稍候...')}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
init() {
|
||||
this._log('火车查询工具初始化');
|
||||
|
||||
this.dom = {
|
||||
queryForm: document.getElementById('train-query-form'),
|
||||
goInput: document.getElementById('train-query-go'),
|
||||
toInput: document.getElementById('train-query-to'),
|
||||
swapBtn: document.getElementById('train-query-swap-btn'),
|
||||
queryBtn: document.getElementById('train-query-btn'),
|
||||
resultSection: document.getElementById('train-query-result-section'),
|
||||
resultMeta: document.getElementById('train-query-result-meta'),
|
||||
metaGo: document.getElementById('train-query-meta-go'),
|
||||
metaTo: document.getElementById('train-query-meta-to'),
|
||||
metaDate: document.getElementById('train-query-meta-date'),
|
||||
metaTotal: document.getElementById('train-query-meta-total'),
|
||||
trainList: document.getElementById('train-query-train-list'),
|
||||
loading: document.getElementById('train-query-loading'),
|
||||
errorMessage: document.getElementById('train-query-error-message')
|
||||
};
|
||||
|
||||
this.dom.queryForm.addEventListener('submit', (e) => {
|
||||
e.preventDefault();
|
||||
this.queryTrains();
|
||||
});
|
||||
|
||||
this.dom.swapBtn.addEventListener('click', () => {
|
||||
this.swapCities();
|
||||
});
|
||||
|
||||
// 返回工具箱按钮
|
||||
document.getElementById('back-to-toolbox-btn')?.addEventListener('click', () => {
|
||||
window.mainPage.navigateTo('toolbox');
|
||||
window.mainPage.updateActiveNavButton(document.getElementById('toolbox-btn'));
|
||||
});
|
||||
}
|
||||
|
||||
swapCities() {
|
||||
const temp = this.dom.goInput.value;
|
||||
this.dom.goInput.value = this.dom.toInput.value;
|
||||
this.dom.toInput.value = temp;
|
||||
}
|
||||
|
||||
async queryTrains() {
|
||||
const formData = new FormData(this.dom.queryForm);
|
||||
const go = formData.get('go').trim();
|
||||
const to = formData.get('to').trim();
|
||||
const type = formData.get('form');
|
||||
const time = formData.get('time');
|
||||
const count = formData.get('count');
|
||||
|
||||
if (!go || !to) {
|
||||
this.showError(i18n.t('tool.trainQuery.enterCities', '请输入出发和到达城市'));
|
||||
return;
|
||||
}
|
||||
|
||||
this.showLoading();
|
||||
this.hideError();
|
||||
this.hideResult();
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
go: go,
|
||||
to: to,
|
||||
count: count || '10'
|
||||
});
|
||||
|
||||
if (type) {
|
||||
params.append('form', type);
|
||||
}
|
||||
|
||||
if (time) {
|
||||
params.append('time', time);
|
||||
}
|
||||
|
||||
const response = await fetch(`${this.apiUrl}?${params.toString()}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP错误! 状态码: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const responseTime = (Date.now() - startTime) / 1000;
|
||||
|
||||
// 检查响应数据
|
||||
if (data.success === false || (data.success !== true && !data.data)) {
|
||||
const errorMsg = data.message || data.msg || i18n.t('tool.trainQuery.queryFailed', '查询失败');
|
||||
throw new Error(errorMsg);
|
||||
}
|
||||
|
||||
// 检查是否有有效数据
|
||||
if (!data.data || (Array.isArray(data.data) && data.data.length === 0)) {
|
||||
throw new Error(i18n.t('tool.trainQuery.noData', '未找到火车班次信息'));
|
||||
}
|
||||
|
||||
// 成功获取数据
|
||||
this.displayResults(data, go, to, time);
|
||||
this._log(`成功获取火车班次数据,耗时 ${responseTime.toFixed(2)}秒`);
|
||||
} catch (error) {
|
||||
const responseTime = (Date.now() - startTime) / 1000;
|
||||
this.showError(i18n.t('tool.trainQuery.queryFailed', '查询失败') + ': ' + error.message);
|
||||
console.error('API请求错误:', error);
|
||||
this._log(`获取火车班次数据失败: ${error.message}`);
|
||||
} finally {
|
||||
this.hideLoading();
|
||||
}
|
||||
}
|
||||
|
||||
displayResults(data, go, to, time) {
|
||||
const trains = data.data || [];
|
||||
|
||||
// 显示结果元信息
|
||||
this.dom.metaGo.textContent = `${i18n.t('tool.trainQuery.departure', '出发')}: ${go}`;
|
||||
this.dom.metaTo.textContent = `${i18n.t('tool.trainQuery.arrival', '到达')}: ${to}`;
|
||||
if (time) {
|
||||
this.dom.metaDate.textContent = `${i18n.t('tool.trainQuery.date', '日期')}: ${time}`;
|
||||
}
|
||||
this.dom.metaTotal.textContent = `${i18n.t('tool.trainQuery.total', '共')} ${trains.length} ${i18n.t('tool.trainQuery.trains', '趟')}`;
|
||||
|
||||
// 显示火车列表
|
||||
this.dom.trainList.innerHTML = '';
|
||||
|
||||
if (Array.isArray(trains) && trains.length > 0) {
|
||||
trains.forEach(train => {
|
||||
const trainCard = document.createElement('div');
|
||||
trainCard.className = 'train-card';
|
||||
trainCard.style.cssText = `
|
||||
background: rgba(var(--card-background-rgb), 0.5);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
transition: all 0.3s ease;
|
||||
`;
|
||||
|
||||
trainCard.addEventListener('mouseenter', () => {
|
||||
trainCard.style.background = 'rgba(var(--card-background-rgb), 0.8)';
|
||||
trainCard.style.transform = 'translateY(-4px)';
|
||||
trainCard.style.boxShadow = '0 8px 16px rgba(0, 0, 0, 0.1)';
|
||||
});
|
||||
|
||||
trainCard.addEventListener('mouseleave', () => {
|
||||
trainCard.style.background = 'rgba(var(--card-background-rgb), 0.5)';
|
||||
trainCard.style.transform = 'translateY(0)';
|
||||
trainCard.style.boxShadow = 'none';
|
||||
});
|
||||
|
||||
// 火车头部信息
|
||||
const trainHeader = document.createElement('div');
|
||||
trainHeader.className = 'train-header';
|
||||
trainHeader.style.cssText = `
|
||||
background: var(--card-bg);
|
||||
padding: 16px 24px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
`;
|
||||
|
||||
trainHeader.innerHTML = `
|
||||
<div class="train-info" style="display: flex; gap: 32px; align-items: center; flex-wrap: wrap;">
|
||||
<div class="train-number" style="font-size: 20px; font-weight: 700; color: var(--text-primary);">
|
||||
${train.trainNo || train.number || '-'}
|
||||
</div>
|
||||
<div class="train-stations" style="display: flex; align-items: center; gap: 16px;">
|
||||
<span class="station" style="font-size: 16px; font-weight: 600; color: var(--text-primary);">
|
||||
${train.fromStation || train.from || '-'}
|
||||
</span>
|
||||
<span class="station-separator" style="color: var(--text-secondary); font-size: 14px;">
|
||||
<i class="fas fa-arrow-right"></i>
|
||||
</span>
|
||||
<span class="station" style="font-size: 16px; font-weight: 600; color: var(--text-primary);">
|
||||
${train.toStation || train.to || '-'}
|
||||
</span>
|
||||
</div>
|
||||
<div class="train-times" style="display: flex; gap: 16px; align-items: center;">
|
||||
<span class="time" style="font-size: 18px; font-weight: 700; color: var(--text-primary);">
|
||||
${train.departureTime || train.startTime || '-'}
|
||||
</span>
|
||||
<span class="time-separator" style="color: var(--text-secondary); font-size: 14px;">
|
||||
<i class="fas fa-arrow-right"></i>
|
||||
</span>
|
||||
<span class="time" style="font-size: 18px; font-weight: 700; color: var(--text-primary);">
|
||||
${train.arrivalTime || train.endTime || '-'}
|
||||
</span>
|
||||
${train.duration ? `
|
||||
<span class="duration" style="font-size: 14px; color: var(--text-secondary);">
|
||||
(${train.duration})
|
||||
</span>
|
||||
` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
trainCard.appendChild(trainHeader);
|
||||
|
||||
// 座位信息
|
||||
if (train.seats && Array.isArray(train.seats) && train.seats.length > 0) {
|
||||
const seatSection = document.createElement('div');
|
||||
seatSection.className = 'seat-section';
|
||||
seatSection.style.cssText = 'padding: 16px 24px;';
|
||||
|
||||
const seatTitle = document.createElement('div');
|
||||
seatTitle.className = 'seat-title';
|
||||
seatTitle.style.cssText = 'font-size: 14px; font-weight: 600; color: var(--text-primary); margin-bottom: 12px;';
|
||||
seatTitle.textContent = i18n.t('tool.trainQuery.seats', '座位信息');
|
||||
seatSection.appendChild(seatTitle);
|
||||
|
||||
const seatList = document.createElement('div');
|
||||
seatList.className = 'seat-list';
|
||||
seatList.style.cssText = 'display: flex; gap: 16px; flex-wrap: wrap;';
|
||||
|
||||
train.seats.forEach(seat => {
|
||||
const seatItem = document.createElement('div');
|
||||
seatItem.className = 'seat-item';
|
||||
seatItem.style.cssText = `
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
padding: 12px 16px;
|
||||
min-width: 120px;
|
||||
text-align: center;
|
||||
`;
|
||||
|
||||
seatItem.innerHTML = `
|
||||
<div class="seat-name" style="font-size: 14px; font-weight: 600; color: var(--text-primary); margin-bottom: 4px;">
|
||||
${seat.name || seat.type || '-'}
|
||||
</div>
|
||||
<div class="seat-price" style="font-size: 16px; font-weight: 700; color: #e74c3c; margin-bottom: 4px;">
|
||||
${seat.price || '--'} ${i18n.t('tool.trainQuery.yuan', '元')}
|
||||
</div>
|
||||
<div class="seat-residue ${seat.residue && parseInt(seat.residue) < 10 ? 'low' : ''}" style="font-size: 12px; color: ${seat.residue && parseInt(seat.residue) < 10 ? '#e74c3c' : 'var(--text-secondary)'}; font-weight: ${seat.residue && parseInt(seat.residue) < 10 ? '600' : 'normal'};">
|
||||
${seat.residue ? `${i18n.t('tool.trainQuery.remaining', '余票')}: ${seat.residue}` : i18n.t('tool.trainQuery.noTicket', '无票')}
|
||||
</div>
|
||||
`;
|
||||
|
||||
seatList.appendChild(seatItem);
|
||||
});
|
||||
|
||||
seatSection.appendChild(seatList);
|
||||
trainCard.appendChild(seatSection);
|
||||
}
|
||||
|
||||
this.dom.trainList.appendChild(trainCard);
|
||||
});
|
||||
} else {
|
||||
this.dom.trainList.innerHTML = `
|
||||
<div style="text-align: center; padding: 40px; color: var(--text-secondary);">
|
||||
${i18n.t('tool.trainQuery.noData', '未找到火车班次信息')}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
this.dom.resultSection.style.display = 'block';
|
||||
}
|
||||
|
||||
showLoading() {
|
||||
this.dom.loading.style.display = 'block';
|
||||
this.dom.queryBtn.disabled = true;
|
||||
this.dom.queryBtn.innerHTML = `<i class="fas fa-spinner fa-spin"></i> ${i18n.t('tool.trainQuery.querying', '查询中...')}`;
|
||||
}
|
||||
|
||||
hideLoading() {
|
||||
this.dom.loading.style.display = 'none';
|
||||
this.dom.queryBtn.disabled = false;
|
||||
this.dom.queryBtn.innerHTML = `<i class="fas fa-search"></i> ${i18n.t('tool.trainQuery.query', '查询火车班次')}`;
|
||||
}
|
||||
|
||||
showError(message) {
|
||||
this.dom.errorMessage.textContent = message;
|
||||
this.dom.errorMessage.style.display = 'block';
|
||||
}
|
||||
|
||||
hideError() {
|
||||
this.dom.errorMessage.style.display = 'none';
|
||||
}
|
||||
|
||||
hideResult() {
|
||||
this.dom.resultSection.style.display = 'none';
|
||||
this.dom.trainList.innerHTML = '';
|
||||
}
|
||||
}
|
||||
|
||||
export default TrainQueryTool;
|
||||
@@ -0,0 +1,79 @@
|
||||
import BaseTool from '../baseTool.js';
|
||||
|
||||
export default class UlidGeneratorTool extends BaseTool {
|
||||
constructor() {
|
||||
super('ulid-generator', 'ULID 生成器');
|
||||
}
|
||||
|
||||
render() {
|
||||
return `
|
||||
<div class="page-container" style="padding: 20px; display: flex; flex-direction: column; gap: 15px; height: 100%;">
|
||||
<div class="section-header">
|
||||
<button class="back-btn ripple" id="back-btn"><i class="fas fa-arrow-left"></i> 返回</button>
|
||||
<h1>${this.name}</h1>
|
||||
</div>
|
||||
|
||||
<div class="tool-island-container">
|
||||
<div class="tool-island-card" style="text-align: center; padding: 40px 20px;">
|
||||
<h2 style="margin-bottom: 20px;">生成的 ULID</h2>
|
||||
<div id="ulid-display" style="font-family: monospace; font-size: 32px; color: var(--primary-color); letter-spacing: 2px; margin-bottom: 30px; word-break: break-all;">
|
||||
PENDING...
|
||||
</div>
|
||||
|
||||
<div style="display: flex; justify-content: center; gap: 15px;">
|
||||
<button id="btn-regen" class="action-btn ripple"><i class="fas fa-sync-alt"></i> 重新生成</button>
|
||||
<button id="btn-copy-ulid" class="action-btn ripple"><i class="fas fa-copy"></i> 复制</button>
|
||||
</div>
|
||||
|
||||
<p style="margin-top: 30px; color: var(--text-secondary); font-size: 13px;">
|
||||
ULID 是 26 个字符的标识符,由 10 个字符的时间戳和 16 个字符的随机部分组成。<br>它既是唯一的,又是按字典序可排序的。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
init() {
|
||||
document.getElementById('back-btn').addEventListener('click', () => window.mainPage.navigateTo('toolbox'));
|
||||
this.generateUlid(); // 初始生成
|
||||
|
||||
document.getElementById('btn-regen').addEventListener('click', () => this.generateUlid());
|
||||
document.getElementById('btn-copy-ulid').addEventListener('click', () => {
|
||||
const txt = document.getElementById('ulid-display').innerText;
|
||||
if (txt) navigator.clipboard.writeText(txt).then(() => this._notify('成功', 'ULID 已复制'));
|
||||
});
|
||||
}
|
||||
|
||||
generateUlid() {
|
||||
// 简易 ULID 实现
|
||||
const ENCODING = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
|
||||
const ENCODING_LEN = ENCODING.length;
|
||||
const TIME_LEN = 10;
|
||||
const RANDOM_LEN = 16;
|
||||
|
||||
const encodeRandom = (len) => {
|
||||
let str = "";
|
||||
for (let i = 0; i < len; i++) {
|
||||
str += ENCODING.charAt(Math.floor(Math.random() * ENCODING_LEN));
|
||||
}
|
||||
return str;
|
||||
};
|
||||
|
||||
const encodeTime = (now, len) => {
|
||||
let str = "";
|
||||
for (let i = len - 1; i >= 0; i--) {
|
||||
const mod = now % ENCODING_LEN;
|
||||
str = ENCODING.charAt(mod) + str;
|
||||
now = (now - mod) / ENCODING_LEN;
|
||||
}
|
||||
return str;
|
||||
};
|
||||
|
||||
const time = encodeTime(Date.now(), TIME_LEN);
|
||||
const random = encodeRandom(RANDOM_LEN);
|
||||
const ulid = time + random;
|
||||
|
||||
document.getElementById('ulid-display').innerText = ulid;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,432 @@
|
||||
// src/js/tools/unitTool.js
|
||||
import BaseTool from '../baseTool.js';
|
||||
|
||||
export default class UnitTool extends BaseTool {
|
||||
constructor() {
|
||||
super('unit-tool', '单位转换');
|
||||
|
||||
// 完整的单位配置数据
|
||||
this.categories = {
|
||||
length: {
|
||||
name: '长度',
|
||||
icon: 'fa-ruler-combined',
|
||||
units: {
|
||||
'm': { name: '米 (m)', rate: 1 },
|
||||
'km': { name: '千米 (km)', rate: 1000 },
|
||||
'dm': { name: '分米 (dm)', rate: 0.1 },
|
||||
'cm': { name: '厘米 (cm)', rate: 0.01 },
|
||||
'mm': { name: '毫米 (mm)', rate: 0.001 },
|
||||
'um': { name: '微米 (μm)', rate: 1e-6 },
|
||||
'nm': { name: '纳米 (nm)', rate: 1e-9 },
|
||||
'in': { name: '英寸 (in)', rate: 0.0254 },
|
||||
'ft': { name: '英尺 (ft)', rate: 0.3048 },
|
||||
'yd': { name: '码 (yd)', rate: 0.9144 },
|
||||
'mi': { name: '英里 (mi)', rate: 1609.344 }
|
||||
}
|
||||
},
|
||||
area: {
|
||||
name: '面积',
|
||||
icon: 'fa-chart-area',
|
||||
units: {
|
||||
'm2': { name: '平方米 (m²)', rate: 1 },
|
||||
'km2': { name: '平方千米 (km²)', rate: 1e6 },
|
||||
'cm2': { name: '平方厘米 (cm²)', rate: 1e-4 },
|
||||
'mm2': { name: '平方毫米 (mm²)', rate: 1e-6 },
|
||||
'ha': { name: '公顷 (ha)', rate: 10000 },
|
||||
'mu': { name: '亩', rate: 666.6667 },
|
||||
'ft2': { name: '平方英尺 (sq ft)', rate: 0.092903 },
|
||||
'ac': { name: '英亩 (acre)', rate: 4046.856 }
|
||||
}
|
||||
},
|
||||
mass: {
|
||||
name: '重量',
|
||||
icon: 'fa-weight-hanging',
|
||||
units: {
|
||||
'kg': { name: '千克 (kg)', rate: 1 },
|
||||
'g': { name: '克 (g)', rate: 0.001 },
|
||||
'mg': { name: '毫克 (mg)', rate: 1e-6 },
|
||||
't': { name: '吨 (t)', rate: 1000 },
|
||||
'lb': { name: '磅 (lb)', rate: 0.453592 },
|
||||
'oz': { name: '盎司 (oz)', rate: 0.0283495 },
|
||||
'ct': { name: '克拉 (ct)', rate: 0.0002 }
|
||||
}
|
||||
},
|
||||
volume: {
|
||||
name: '体积',
|
||||
icon: 'fa-cube',
|
||||
units: {
|
||||
'l': { name: '升 (L)', rate: 1 },
|
||||
'ml': { name: '毫升 (mL)', rate: 0.001 },
|
||||
'm3': { name: '立方米 (m³)', rate: 1000 },
|
||||
'cm3': { name: '立方厘米 (cm³)', rate: 0.001 },
|
||||
'gal': { name: '加仑 (US gal)', rate: 3.78541 },
|
||||
'pt': { name: '品脱 (US pt)', rate: 0.473176 }
|
||||
}
|
||||
},
|
||||
data: {
|
||||
name: '数据',
|
||||
icon: 'fa-database',
|
||||
units: {
|
||||
'B': { name: '字节 (Byte)', rate: 1 },
|
||||
'KB': { name: 'KB', rate: 1024 },
|
||||
'MB': { name: 'MB', rate: 1048576 },
|
||||
'GB': { name: 'GB', rate: 1073741824 },
|
||||
'TB': { name: 'TB', rate: 1099511627776 },
|
||||
'PB': { name: 'PB', rate: 1125899906842624 }
|
||||
}
|
||||
},
|
||||
temp: {
|
||||
name: '温度',
|
||||
icon: 'fa-thermometer-half',
|
||||
units: {
|
||||
'c': { name: '摄氏度 (°C)', rate: 1 },
|
||||
'f': { name: '华氏度 (°F)', rate: 1 },
|
||||
'k': { name: '开尔文 (K)', rate: 1 }
|
||||
}
|
||||
},
|
||||
speed: {
|
||||
name: '速度',
|
||||
icon: 'fa-tachometer-alt',
|
||||
units: {
|
||||
'mps': { name: '米/秒 (m/s)', rate: 1 },
|
||||
'kph': { name: '千米/时 (km/h)', rate: 0.277778 },
|
||||
'mph': { name: '英里/时 (mph)', rate: 0.44704 },
|
||||
'kn': { name: '节 (knot)', rate: 0.514444 }
|
||||
}
|
||||
},
|
||||
time: {
|
||||
name: '时间',
|
||||
icon: 'fa-clock',
|
||||
units: {
|
||||
's': { name: '秒', rate: 1 },
|
||||
'min': { name: '分', rate: 60 },
|
||||
'h': { name: '小时', rate: 3600 },
|
||||
'd': { name: '天', rate: 86400 },
|
||||
'w': { name: '周', rate: 604800 },
|
||||
'y': { name: '年 (365天)', rate: 31536000 }
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
this.currentCat = 'length';
|
||||
}
|
||||
|
||||
render() {
|
||||
// 生成分类按钮 HTML
|
||||
const categoryHtml = Object.entries(this.categories).map(([key, info]) => `
|
||||
<button class="cat-btn ${key === this.currentCat ? 'active' : ''}" data-cat="${key}">
|
||||
<i class="fas ${info.icon}"></i> ${info.name}
|
||||
</button>
|
||||
`).join('');
|
||||
|
||||
return `
|
||||
<div class="page-container" style="display: flex; flex-direction: column; height: 100%;">
|
||||
<div class="section-header" style="flex-shrink: 0; margin-bottom: 20px;">
|
||||
<button id="back-to-toolbox-btn" class="back-btn ripple"><i class="fas fa-arrow-left"></i> 返回工具箱</button>
|
||||
<h1 style="flex-grow: 1; text-align: center;">${this.name}</h1>
|
||||
</div>
|
||||
|
||||
<div class="tool-island-container">
|
||||
|
||||
<div id="cat-scroll-wrapper" class="tool-island-card island-scroll-wrapper" style="padding: 10px 0; flex-shrink: 0; border-radius: 16px;">
|
||||
<div id="cat-scroll-content" class="island-scroll-content" style="padding: 0 15px;">
|
||||
${categoryHtml}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tool-island-card" style="flex-grow: 1; display: flex; flex-direction: column; justify-content: center; align-items: center; gap: 30px; margin-top: 10px;">
|
||||
|
||||
<div class="unit-row">
|
||||
<div class="unit-input-group">
|
||||
<label>数值</label>
|
||||
<input type="number" id="unit-from-val" class="glass-input big-input" value="1" placeholder="0">
|
||||
</div>
|
||||
<div class="unit-select-group">
|
||||
<label>单位</label>
|
||||
<select id="unit-from-select" class="glass-select"></select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button id="unit-swap-btn" class="control-btn ripple circle-btn" title="交换单位">
|
||||
<i class="fas fa-exchange-alt fa-rotate-90"></i>
|
||||
</button>
|
||||
|
||||
<div class="unit-row">
|
||||
<div class="unit-input-group">
|
||||
<label>结果</label>
|
||||
<input type="text" id="unit-to-val" class="glass-input big-input result" readonly placeholder="0">
|
||||
</div>
|
||||
<div class="unit-select-group">
|
||||
<label>单位</label>
|
||||
<select id="unit-to-select" class="glass-select"></select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div style="text-align: center; font-size: 12px; color: var(--text-secondary); opacity: 0.6; margin-top: auto; padding-bottom: 10px;">
|
||||
提示:支持鼠标拖拽滚动或滚轮横向滑动分类栏
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* --- 分类按钮样式 --- */
|
||||
.cat-btn {
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
color: var(--text-secondary);
|
||||
padding: 8px 16px;
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: all 0.2s;
|
||||
font-size: 14px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.cat-btn:hover { background: rgba(var(--bg-color-rgb), 0.5); }
|
||||
.cat-btn.active {
|
||||
background: rgba(var(--primary-color-rgb), 0.15);
|
||||
color: var(--primary-color);
|
||||
border: 1px solid rgba(var(--primary-color-rgb), 0.3);
|
||||
font-weight: bold;
|
||||
box-shadow: 0 4px 12px rgba(var(--primary-color-rgb), 0.15);
|
||||
}
|
||||
|
||||
/* --- 布局结构 --- */
|
||||
.unit-row { display: flex; gap: 15px; width: 100%; max-width: 600px; }
|
||||
.unit-input-group { flex: 2; display: flex; flex-direction: column; gap: 8px; }
|
||||
.unit-select-group { flex: 1.5; display: flex; flex-direction: column; gap: 8px; }
|
||||
|
||||
/* --- 灵动岛风格输入框适配 (核心修复) --- */
|
||||
.glass-input, .glass-select {
|
||||
width: 100%;
|
||||
background: rgba(var(--bg-color-rgb), 0.3); /* 降低不透明度,适配深/浅模式 */
|
||||
backdrop-filter: blur(12px); /* 磨砂玻璃 */
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border: 1px solid rgba(128, 128, 128, 0.2); /* 弱化边框 */
|
||||
color: var(--text-color);
|
||||
border-radius: 12px;
|
||||
transition: all 0.2s ease;
|
||||
outline: none;
|
||||
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.05); /* 内阴影增加质感 */
|
||||
}
|
||||
|
||||
/* 聚焦状态 */
|
||||
.glass-input:focus, .glass-select:focus {
|
||||
background: rgba(var(--bg-color-rgb), 0.6);
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 0 0 3px rgba(var(--primary-color-rgb), 0.2);
|
||||
}
|
||||
|
||||
/* 大输入框尺寸 */
|
||||
.big-input {
|
||||
font-size: 24px;
|
||||
font-family: monospace;
|
||||
padding: 0 15px;
|
||||
height: 56px; /* 稍微增高 */
|
||||
}
|
||||
|
||||
/* 结果框特殊样式 (高亮) */
|
||||
.big-input.result {
|
||||
background: rgba(var(--primary-color-rgb), 0.08); /* 极淡的主色背景 */
|
||||
color: var(--primary-color);
|
||||
font-weight: 700;
|
||||
border-color: rgba(var(--primary-color-rgb), 0.25);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* 下拉菜单样式 */
|
||||
.glass-select {
|
||||
height: 56px; /* 与输入框对齐 */
|
||||
padding: 0 15px;
|
||||
cursor: pointer;
|
||||
appearance: none; /* 移除原生丑陋箭头 (如果需要图标可加 background-image) */
|
||||
background-image: linear-gradient(45deg, transparent 50%, var(--text-secondary) 50%),
|
||||
linear-gradient(135deg, var(--text-secondary) 50%, transparent 50%);
|
||||
background-position: calc(100% - 20px) calc(1em + 2px), calc(100% - 15px) calc(1em + 2px);
|
||||
background-size: 5px 5px, 5px 5px;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
.glass-select option {
|
||||
background-color: rgb(var(--card-background-rgb)); /* 确保下拉选项背景不透明 */
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
/* 标签文字适配 */
|
||||
label {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
/* 圆形交换按钮 */
|
||||
.circle-btn {
|
||||
width: 44px; height: 44px;
|
||||
border-radius: 50%;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
background: rgba(var(--card-background-rgb), 0.8);
|
||||
border: 1px solid rgba(128, 128, 128, 0.1);
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.08);
|
||||
transition: all 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
color: var(--text-color);
|
||||
}
|
||||
.circle-btn:hover {
|
||||
transform: scale(1.1);
|
||||
background: var(--primary-color);
|
||||
color: #fff;
|
||||
box-shadow: 0 6px 16px rgba(var(--primary-color-rgb), 0.4);
|
||||
border-color: transparent;
|
||||
}
|
||||
.circle-btn:active { transform: rotate(180deg) scale(0.95); }
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.unit-row { flex-direction: column; gap: 10px; }
|
||||
#unit-swap-btn { transform: rotate(90deg); margin: 10px 0; }
|
||||
}
|
||||
|
||||
/* --- 灵动岛流体滚动容器 --- */
|
||||
.island-scroll-wrapper {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
--mask-bg: linear-gradient(90deg, rgba(var(--card-background-rgb), 1) 0%, rgba(var(--card-background-rgb), 0) 100%);
|
||||
}
|
||||
.island-scroll-content {
|
||||
display: flex; gap: 10px; overflow-x: auto; padding: 5px 15px;
|
||||
scrollbar-width: none; cursor: grab; scroll-behavior: smooth; user-select: none;
|
||||
}
|
||||
.island-scroll-content::-webkit-scrollbar { display: none; }
|
||||
.island-scroll-content:active { cursor: grabbing; scroll-behavior: auto; }
|
||||
.island-scroll-wrapper::before, .island-scroll-wrapper::after {
|
||||
content: ''; position: absolute; top: 0; bottom: 0; width: 40px; z-index: 2; opacity: 0; transition: opacity 0.3s ease; pointer-events: none;
|
||||
}
|
||||
.island-scroll-wrapper::before { left: 0; background: linear-gradient(to right, rgba(var(--card-background-rgb), 1), transparent); border-top-left-radius: 16px; border-bottom-left-radius: 16px; }
|
||||
.island-scroll-wrapper::after { right: 0; background: linear-gradient(to left, rgba(var(--card-background-rgb), 1), transparent); border-top-right-radius: 16px; border-bottom-right-radius: 16px; }
|
||||
.island-scroll-wrapper.can-scroll-left::before { opacity: 1; }
|
||||
.island-scroll-wrapper.can-scroll-right::after { opacity: 1; }
|
||||
</style>
|
||||
`;
|
||||
}
|
||||
|
||||
init() {
|
||||
document.getElementById('back-to-toolbox-btn')?.addEventListener('click', () => {
|
||||
window.mainPage.navigateTo('toolbox');
|
||||
window.mainPage.updateActiveNavButton(document.getElementById('toolbox-btn'));
|
||||
});
|
||||
|
||||
const fromVal = document.getElementById('unit-from-val');
|
||||
const fromSel = document.getElementById('unit-from-select');
|
||||
const toVal = document.getElementById('unit-to-val');
|
||||
const toSel = document.getElementById('unit-to-select');
|
||||
|
||||
this.initSmartScroll();
|
||||
|
||||
const catBtns = document.querySelectorAll('.cat-btn');
|
||||
catBtns.forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
catBtns.forEach(b => b.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
btn.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'center' });
|
||||
this.currentCat = btn.dataset.cat;
|
||||
this.refreshOptions();
|
||||
this.calculate();
|
||||
});
|
||||
});
|
||||
|
||||
this.refreshOptions = () => {
|
||||
const units = this.categories[this.currentCat].units;
|
||||
const optionsHtml = Object.entries(units).map(([k, v]) => `<option value="${k}">${v.name}</option>`).join('');
|
||||
const keys = Object.keys(units);
|
||||
fromSel.innerHTML = optionsHtml;
|
||||
toSel.innerHTML = optionsHtml;
|
||||
if (keys.length >= 2) {
|
||||
fromSel.value = keys[0];
|
||||
toSel.value = keys[1];
|
||||
}
|
||||
};
|
||||
|
||||
this.calculate = () => {
|
||||
const val = parseFloat(fromVal.value);
|
||||
if (isNaN(val)) {
|
||||
toVal.value = '';
|
||||
return;
|
||||
}
|
||||
const catConfig = this.categories[this.currentCat];
|
||||
if (this.currentCat === 'temp') {
|
||||
this.calcTemp(val, fromSel.value, toSel.value);
|
||||
return;
|
||||
}
|
||||
const fromUnit = catConfig.units[fromSel.value];
|
||||
const toUnit = catConfig.units[toSel.value];
|
||||
if (!fromUnit || !toUnit) return;
|
||||
const baseVal = val * fromUnit.rate;
|
||||
let result = baseVal / toUnit.rate;
|
||||
if (Math.abs(result) < 1e-10) result = 0;
|
||||
else result = parseFloat(result.toPrecision(12));
|
||||
toVal.value = result;
|
||||
};
|
||||
|
||||
this.calcTemp = (val, from, to) => {
|
||||
let k;
|
||||
if (from === 'c') k = val + 273.15;
|
||||
else if (from === 'f') k = (val - 32) * 5/9 + 273.15;
|
||||
else k = val;
|
||||
let res;
|
||||
if (to === 'c') res = k - 273.15;
|
||||
else if (to === 'f') res = (k - 273.15) * 9/5 + 32;
|
||||
else res = k;
|
||||
toVal.value = parseFloat(res.toFixed(2));
|
||||
};
|
||||
|
||||
fromVal.addEventListener('input', this.calculate);
|
||||
fromSel.addEventListener('change', this.calculate);
|
||||
toSel.addEventListener('change', this.calculate);
|
||||
|
||||
document.getElementById('unit-swap-btn').addEventListener('click', () => {
|
||||
const temp = fromSel.value;
|
||||
fromSel.value = toSel.value;
|
||||
toSel.value = temp;
|
||||
this.calculate();
|
||||
});
|
||||
|
||||
this.refreshOptions();
|
||||
this.calculate();
|
||||
}
|
||||
|
||||
initSmartScroll() {
|
||||
const wrapper = document.getElementById('cat-scroll-wrapper');
|
||||
const content = document.getElementById('cat-scroll-content');
|
||||
|
||||
const checkScroll = () => {
|
||||
const { scrollLeft, scrollWidth, clientWidth } = content;
|
||||
if (scrollLeft > 5) wrapper.classList.add('can-scroll-left');
|
||||
else wrapper.classList.remove('can-scroll-left');
|
||||
if (scrollLeft + clientWidth < scrollWidth - 5) wrapper.classList.add('can-scroll-right');
|
||||
else wrapper.classList.remove('can-scroll-right');
|
||||
};
|
||||
|
||||
content.addEventListener('scroll', checkScroll);
|
||||
window.addEventListener('resize', checkScroll);
|
||||
setTimeout(checkScroll, 100);
|
||||
|
||||
content.addEventListener('wheel', (e) => {
|
||||
if (e.deltaY !== 0) {
|
||||
e.preventDefault();
|
||||
content.scrollLeft += e.deltaY;
|
||||
}
|
||||
});
|
||||
|
||||
let isDown = false;
|
||||
let startX;
|
||||
let scrollLeft;
|
||||
content.addEventListener('mousedown', (e) => { isDown = true; startX = e.pageX - content.offsetLeft; scrollLeft = content.scrollLeft; });
|
||||
content.addEventListener('mouseleave', () => { isDown = false; });
|
||||
content.addEventListener('mouseup', () => { isDown = false; });
|
||||
content.addEventListener('mousemove', (e) => { if (!isDown) return; e.preventDefault(); const x = e.pageX - content.offsetLeft; const walk = (x - startX) * 2; content.scrollLeft = scrollLeft - walk; });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import BaseTool from '../baseTool.js';
|
||||
|
||||
export default class UrlTool extends BaseTool {
|
||||
constructor() {
|
||||
super('url-tool', 'URL 编码/解码');
|
||||
}
|
||||
|
||||
render() {
|
||||
return `
|
||||
<div class="page-container" style="padding: 20px; display:flex; flex-direction:column; gap:15px; height:100%;">
|
||||
<div class="section-header"><button class="back-btn ripple" id="back-btn"><i class="fas fa-arrow-left"></i></button><h1>${this.name}</h1></div>
|
||||
<div style="flex:1; display:flex; flex-direction:column; gap:10px;">
|
||||
<textarea id="url-input" class="common-textarea" placeholder="输入 URL..." style="flex:1;"></textarea>
|
||||
<div style="display:flex; gap:10px; justify-content:center;">
|
||||
<button id="btn-encode" class="action-btn ripple"><i class="fas fa-arrow-down"></i> 编码 (Encode)</button>
|
||||
<button id="btn-decode" class="action-btn ripple"><i class="fas fa-arrow-up"></i> 解码 (Decode)</button>
|
||||
</div>
|
||||
<textarea id="url-output" class="common-textarea" placeholder="结果..." style="flex:1;"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
init() {
|
||||
document.getElementById('back-btn').addEventListener('click', () => window.mainPage.navigateTo('toolbox'));
|
||||
const input = document.getElementById('url-input');
|
||||
const output = document.getElementById('url-output');
|
||||
|
||||
document.getElementById('btn-encode').addEventListener('click', () => {
|
||||
output.value = encodeURIComponent(input.value);
|
||||
});
|
||||
document.getElementById('btn-decode').addEventListener('click', () => {
|
||||
try {
|
||||
output.value = decodeURIComponent(input.value);
|
||||
} catch (e) {
|
||||
this._notify('错误', '无效的 URL 编码', 'error');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
// src/js/tools/uuidGeneratorTool.js
|
||||
import BaseTool from '../baseTool.js';
|
||||
|
||||
export default class UuidGeneratorTool extends BaseTool {
|
||||
constructor() {
|
||||
super('uuid-generator', 'UUID 生成器');
|
||||
}
|
||||
|
||||
render() {
|
||||
return `
|
||||
<div class="page-container" style="padding: 20px; display: flex; flex-direction: column; gap: 20px;">
|
||||
<div class="section-header">
|
||||
<button class="back-btn ripple" id="back-btn"><i class="fas fa-arrow-left"></i></button>
|
||||
<h1>${this.name}</h1>
|
||||
</div>
|
||||
|
||||
<div class="island-card" style="padding: 20px;">
|
||||
<div style="margin-bottom: 15px;">
|
||||
<label style="display: block; margin-bottom: 8px; font-weight: 600;">UUID 版本</label>
|
||||
<div style="display: flex; gap: 10px; flex-wrap: wrap;">
|
||||
<button class="uuid-version-btn active" data-version="v4">UUID v4 (随机)</button>
|
||||
<button class="uuid-version-btn" data-version="v1">UUID v1 (时间戳)</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom: 15px;">
|
||||
<label style="display: block; margin-bottom: 8px; font-weight: 600;">生成数量</label>
|
||||
<input type="number" id="uuid-count" class="common-input" value="1" min="1" max="100" style="width: 120px;">
|
||||
</div>
|
||||
|
||||
<div style="display: flex; gap: 10px; margin-bottom: 15px;">
|
||||
<button id="btn-generate" class="action-btn ripple" style="flex: 1;">
|
||||
<i class="fas fa-magic"></i> 生成 UUID
|
||||
</button>
|
||||
<button id="btn-copy-all" class="action-btn ripple">
|
||||
<i class="fas fa-copy"></i> 复制全部
|
||||
</button>
|
||||
<button id="btn-clear" class="control-btn ripple">
|
||||
<i class="fas fa-trash"></i> 清空
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="island-card" style="padding: 20px; flex: 1; min-height: 200px;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px;">
|
||||
<h3 style="margin: 0;">生成的 UUID</h3>
|
||||
<span id="uuid-count-display" style="font-size: 12px; color: var(--text-secondary);">0 个</span>
|
||||
</div>
|
||||
<div id="uuid-list" style="display: flex; flex-direction: column; gap: 8px; max-height: 400px; overflow-y: auto;">
|
||||
<div style="text-align: center; padding: 40px; color: var(--text-secondary);">
|
||||
<i class="fas fa-fingerprint" style="font-size: 48px; opacity: 0.3; margin-bottom: 10px;"></i>
|
||||
<p>点击"生成 UUID"按钮开始</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.uuid-version-btn {
|
||||
padding: 8px 16px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-color);
|
||||
background: rgba(var(--card-background-rgb), 0.5);
|
||||
color: var(--text-color);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
font-size: 13px;
|
||||
}
|
||||
.uuid-version-btn:hover {
|
||||
background: rgba(var(--primary-rgb), 0.1);
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
.uuid-version-btn.active {
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
.uuid-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 12px;
|
||||
background: rgba(var(--card-background-rgb), 0.3);
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-color);
|
||||
font-family: monospace;
|
||||
font-size: 13px;
|
||||
}
|
||||
.uuid-item:hover {
|
||||
background: rgba(var(--primary-rgb), 0.05);
|
||||
}
|
||||
.uuid-text {
|
||||
flex: 1;
|
||||
word-break: break-all;
|
||||
}
|
||||
.uuid-copy-btn {
|
||||
padding: 6px 12px;
|
||||
border-radius: 6px;
|
||||
border: none;
|
||||
background: rgba(var(--primary-rgb), 0.1);
|
||||
color: var(--primary-color);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
font-size: 12px;
|
||||
}
|
||||
.uuid-copy-btn:hover {
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
}
|
||||
</style>
|
||||
`;
|
||||
}
|
||||
|
||||
init() {
|
||||
document.getElementById('back-btn').addEventListener('click', () => window.mainPage.navigateTo('toolbox'));
|
||||
|
||||
let currentVersion = 'v4';
|
||||
const versionBtns = document.querySelectorAll('.uuid-version-btn');
|
||||
versionBtns.forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
versionBtns.forEach(b => b.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
currentVersion = btn.dataset.version;
|
||||
});
|
||||
});
|
||||
|
||||
const generateUUID = (version = 'v4') => {
|
||||
if (version === 'v1') {
|
||||
// UUID v1 (基于时间戳)
|
||||
const timestamp = Date.now();
|
||||
const random = Math.random().toString(16).substring(2, 14);
|
||||
return `${timestamp.toString(16).substring(0, 8)}-${timestamp.toString(16).substring(8, 12)}-1${timestamp.toString(16).substring(12, 15)}-${(Math.random() * 0x1000 | 0x8000).toString(16)}-${random}`;
|
||||
} else {
|
||||
// UUID v4 (随机)
|
||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
||||
const r = Math.random() * 16 | 0;
|
||||
const v = c === 'x' ? r : (r & 0x3 | 0x8);
|
||||
return v.toString(16);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
document.getElementById('btn-generate').addEventListener('click', () => {
|
||||
const count = parseInt(document.getElementById('uuid-count').value) || 1;
|
||||
const uuidList = document.getElementById('uuid-list');
|
||||
const uuids = [];
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
uuids.push(generateUUID(currentVersion));
|
||||
}
|
||||
|
||||
uuidList.innerHTML = uuids.map(uuid => `
|
||||
<div class="uuid-item">
|
||||
<span class="uuid-text">${uuid}</span>
|
||||
<button class="uuid-copy-btn" data-uuid="${uuid}">
|
||||
<i class="fas fa-copy"></i> 复制
|
||||
</button>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
document.getElementById('uuid-count-display').textContent = `${uuids.length} 个`;
|
||||
|
||||
uuidList.querySelectorAll('.uuid-copy-btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
navigator.clipboard.writeText(btn.dataset.uuid).then(() => {
|
||||
this._notify('已复制', 'UUID 已复制到剪贴板', 'success');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById('btn-copy-all').addEventListener('click', () => {
|
||||
const uuids = Array.from(document.querySelectorAll('.uuid-text')).map(el => el.textContent);
|
||||
if (uuids.length > 0) {
|
||||
navigator.clipboard.writeText(uuids.join('\n')).then(() => {
|
||||
this._notify('已复制', `已复制 ${uuids.length} 个 UUID 到剪贴板`, 'success');
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('btn-clear').addEventListener('click', () => {
|
||||
document.getElementById('uuid-list').innerHTML = `
|
||||
<div style="text-align: center; padding: 40px; color: var(--text-secondary);">
|
||||
<i class="fas fa-fingerprint" style="font-size: 48px; opacity: 0.3; margin-bottom: 10px;"></i>
|
||||
<p>点击"生成 UUID"按钮开始</p>
|
||||
</div>
|
||||
`;
|
||||
document.getElementById('uuid-count-display').textContent = '0 个';
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
// src/js/tools/weatherDetailsTool.js
|
||||
import BaseTool from '../baseTool.js';
|
||||
import configManager from '../configManager.js';
|
||||
|
||||
class WeatherDetailsTool extends BaseTool {
|
||||
constructor() {
|
||||
super('weather-details', '全国详细天气');
|
||||
this.dom = {};
|
||||
this.abortController = null;
|
||||
this.apiKey = configManager.config?.api_keys?.nsuuu || '';
|
||||
}
|
||||
|
||||
render() {
|
||||
return `
|
||||
<div class="page-container" style="display: flex; flex-direction: column; height: 100%;">
|
||||
|
||||
<div class="content-area" style="padding: 20px; flex-grow: 1; display: flex; flex-direction: column; gap: 20px; overflow-y: auto;">
|
||||
|
||||
<div class="ip-input-group" style="flex-shrink: 0; background: rgba(var(--card-background-rgb), 0.5); padding: 15px; border-radius: 16px;">
|
||||
<input type="text" id="weather-city-input" placeholder="输入城市名称 (例如: 烟台、北京)" style="flex-grow: 1; background: rgba(var(--bg-color-rgb), 0.5);">
|
||||
<button id="weather-query-btn" class="action-btn ripple" style="min-width: 100px;">
|
||||
<i class="fas fa-search"></i> 查询
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="weather-results-container" style="flex-grow: 1; min-height: 200px;">
|
||||
<div class="empty-island-state">
|
||||
<div class="empty-icon"><i class="fas fa-city"></i></div>
|
||||
<p>请输入城市名称查询详细天气</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
init() {
|
||||
this._log('天气工具初始化');
|
||||
|
||||
this.dom = {
|
||||
input: document.getElementById('weather-city-input'),
|
||||
btn: document.getElementById('weather-query-btn'),
|
||||
container: document.getElementById('weather-results-container')
|
||||
};
|
||||
|
||||
this.dom.btn.addEventListener('click', () => this._fetchWeather());
|
||||
this.dom.input.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') this._fetchWeather();
|
||||
});
|
||||
}
|
||||
|
||||
async _fetchWeather() {
|
||||
const city = this.dom.input.value.trim();
|
||||
if (!city) return this._notify('提示', '请输入城市名称', 'info');
|
||||
|
||||
if (this.abortController) this.abortController.abort();
|
||||
this.abortController = new AbortController();
|
||||
|
||||
this.dom.btn.disabled = true;
|
||||
this.dom.btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> 查询中...';
|
||||
this.dom.container.innerHTML = `
|
||||
<div class="loading-container">
|
||||
<img src="./assets/loading.gif" class="loading-gif" style="width: 60px;">
|
||||
<p class="loading-text">正在聚合多源气象数据...</p>
|
||||
</div>`;
|
||||
|
||||
try {
|
||||
// 使用新的天气API
|
||||
const weatherUrl = `https://uapis.cn/api/v1/misc/weather?city=${encodeURIComponent(city)}&extended=true&indices=true&forecast=true`;
|
||||
const response = await fetch(weatherUrl, { signal: this.abortController.signal });
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`API请求失败: ${response.status}`);
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
window.electronAPI.addTraffic(blob.size);
|
||||
const data = JSON.parse(await blob.text());
|
||||
|
||||
if (!data || !data.weather) {
|
||||
throw new Error('未获取到有效数据,请检查城市名称');
|
||||
}
|
||||
|
||||
// 转换新API数据结构为内部格式
|
||||
const finalData = this._convertNewApiData(data, city);
|
||||
|
||||
this._renderData(finalData);
|
||||
this._log(`查询成功: ${city}`);
|
||||
|
||||
} catch (error) {
|
||||
if (error.name === 'AbortError') return;
|
||||
this.dom.container.innerHTML = `
|
||||
<div class="empty-island-state">
|
||||
<div class="empty-icon" style="color: var(--error-color);"><i class="fas fa-cloud-showers-heavy"></i></div>
|
||||
<p>查询失败</p>
|
||||
<span>${error.message}</span>
|
||||
</div>`;
|
||||
this._notify('查询失败', error.message, 'error');
|
||||
} finally {
|
||||
this.dom.btn.disabled = false;
|
||||
this.dom.btn.innerHTML = '<i class="fas fa-search"></i> 查询';
|
||||
this.abortController = null;
|
||||
}
|
||||
}
|
||||
|
||||
// 转换新API数据结构为内部格式
|
||||
_convertNewApiData(apiData, searchCity) {
|
||||
const data = {
|
||||
city: apiData.city || searchCity,
|
||||
temp: apiData.temperature || '--',
|
||||
low: apiData.temp_min !== undefined ? apiData.temp_min : '--',
|
||||
high: apiData.temp_max !== undefined ? apiData.temp_max : '--',
|
||||
weather: apiData.weather || '未知',
|
||||
wind: `${apiData.wind_direction || ''} ${apiData.wind_power || ''}`.trim(),
|
||||
humidity: apiData.humidity ? `${apiData.humidity}%` : '',
|
||||
air: apiData.aqi !== undefined ? `AQI ${apiData.aqi}` : '',
|
||||
visibility: apiData.visibility !== undefined ? `${apiData.visibility}km` : '',
|
||||
pressure: apiData.pressure !== undefined ? `${apiData.pressure}hPa` : '',
|
||||
date: apiData.report_time || new Date().toLocaleDateString(),
|
||||
forecast: [],
|
||||
living: []
|
||||
};
|
||||
|
||||
// 转换预报数据
|
||||
if (apiData.forecast && Array.isArray(apiData.forecast)) {
|
||||
data.forecast = apiData.forecast.map(item => ({
|
||||
date: item.date,
|
||||
day: this._formatDate(item.date),
|
||||
week: this._getWeekDay(item.date),
|
||||
weather: item.weather_day || item.weather_night || '未知',
|
||||
wea: item.weather_day || item.weather_night || '未知',
|
||||
high: item.temp_max,
|
||||
low: item.temp_min,
|
||||
tem1: item.temp_max,
|
||||
tem2: item.temp_min
|
||||
}));
|
||||
}
|
||||
|
||||
// 转换生活指数
|
||||
if (apiData.life_indices) {
|
||||
data.living = Object.keys(apiData.life_indices).map(key => {
|
||||
const index = apiData.life_indices[key];
|
||||
return {
|
||||
name: this._getLifeIndexName(key),
|
||||
index: index.level || index.brief || '',
|
||||
tips: index.advice || ''
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
_getLifeIndexName(key) {
|
||||
const nameMap = {
|
||||
'clothing': '穿衣',
|
||||
'uv': '紫外线',
|
||||
'car_wash': '洗车',
|
||||
'drying': '晾晒',
|
||||
'air_conditioner': '空调',
|
||||
'cold_risk': '感冒',
|
||||
'exercise': '运动',
|
||||
'comfort': '舒适度'
|
||||
};
|
||||
return nameMap[key] || key;
|
||||
}
|
||||
|
||||
_formatDate(dateStr) {
|
||||
if (!dateStr) return '';
|
||||
const date = new Date(dateStr);
|
||||
return `${date.getMonth() + 1}月${date.getDate()}日`;
|
||||
}
|
||||
|
||||
_getWeekDay(dateStr) {
|
||||
if (!dateStr) return '';
|
||||
const date = new Date(dateStr);
|
||||
const weekDays = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'];
|
||||
return weekDays[date.getDay()];
|
||||
}
|
||||
|
||||
_renderData(data) {
|
||||
const html = `
|
||||
<div style="animation: contentFadeIn 0.5s;">
|
||||
<div class="island-card weather-main-card" style="background: rgba(var(--primary-rgb), 0.1); border: 1px solid var(--primary-color); padding: 25px; margin-bottom: 20px;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: flex-start;">
|
||||
<div>
|
||||
<h2 style="margin: 0 0 5px 0; display: flex; align-items: center; gap: 10px;">
|
||||
<i class="fas fa-map-marker-alt"></i> ${data.city}
|
||||
</h2>
|
||||
<p style="margin: 0; opacity: 0.7; font-size: 13px;">${data.date}</p>
|
||||
</div>
|
||||
<div style="text-align: right;">
|
||||
<div style="font-size: 36px; font-weight: 700; color: var(--primary-color); line-height: 1;">${data.temp}°C</div>
|
||||
<div style="font-size: 14px; margin-top: 5px; opacity: 0.8;">${data.low} ~ ${data.high}°C</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 20px; display: flex; align-items: center; gap: 15px;">
|
||||
<div style="font-size: 40px; color: var(--text-color);">
|
||||
<i class="fas ${this._getWeatherIcon(data.weather)}"></i>
|
||||
</div>
|
||||
<div>
|
||||
<div style="font-size: 18px; font-weight: 600;">${data.weather}</div>
|
||||
<div style="font-size: 14px; opacity: 0.8;">${data.wind}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="weather-grid-stats">
|
||||
${data.humidity ? `<div class="weather-stat-pill"><i class="fas fa-tint"></i> 湿度 ${data.humidity}</div>` : ''}
|
||||
${data.air ? `<div class="weather-stat-pill"><i class="fas fa-leaf"></i> 空气 ${data.air}</div>` : ''}
|
||||
${data.visibility ? `<div class="weather-stat-pill"><i class="fas fa-eye"></i> 能见度 ${data.visibility}</div>` : ''}
|
||||
${data.pressure ? `<div class="weather-stat-pill"><i class="fas fa-tachometer-alt"></i> ${data.pressure}</div>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${(data.forecast && data.forecast.length > 0) ? this._renderForecastList(data.forecast) : ''}
|
||||
|
||||
${(data.living && data.living.length > 0) ? `
|
||||
<h3 style="font-size: 14px; margin-bottom: 12px; opacity: 0.8; padding-left: 5px; font-weight: 700;">
|
||||
<i class="fas fa-coffee"></i> 生活指数
|
||||
</h3>
|
||||
<div class="toolbox-bento-grid" style="grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 12px; padding: 0;">
|
||||
${data.living.map(item => `
|
||||
<div class="island-card ripple" style="height: auto; min-height: 100px; padding: 15px; flex-direction: column; align-items: flex-start; gap: 8px; background: rgba(var(--card-background-rgb), 0.6);">
|
||||
<div style="display:flex; justify-content:space-between; width:100%; align-items:center;">
|
||||
<span style="font-size: 12px; opacity: 0.7;">${item.name}</span>
|
||||
<span style="font-size: 13px; font-weight: 700; color: var(--primary-color);">${item.index}</span>
|
||||
</div>
|
||||
<div style="font-size: 11px; color: var(--text-secondary); line-height: 1.4; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden;" title="${item.tips}">
|
||||
${item.tips}
|
||||
</div>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* 复用 CSS (确保卡片样式一致) */
|
||||
.weather-main-card { box-shadow: 0 10px 30px -10px rgba(0,0,0,0.15); position: relative; overflow: hidden; }
|
||||
.weather-grid-stats { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 25px; }
|
||||
.weather-stat-pill { background: rgba(255,255,255,0.15); padding: 6px 12px; border-radius: 12px; font-size: 12px; font-weight: 500; display: flex; align-items: center; gap: 6px; backdrop-filter: blur(5px); }
|
||||
body[data-theme="dark"] .weather-stat-pill { background: rgba(0,0,0,0.2); }
|
||||
</style>
|
||||
`;
|
||||
|
||||
this.dom.container.innerHTML = html;
|
||||
}
|
||||
|
||||
_renderForecastList(list) {
|
||||
// 源1的 forecast 格式通常是数组,第一天是今天
|
||||
const future = list.slice(1);
|
||||
if (future.length === 0) return '';
|
||||
|
||||
return `
|
||||
<h3 style="font-size: 14px; margin: 20px 0 10px 0; opacity: 0.8; padding-left: 5px;">未来预报</h3>
|
||||
<div style="display: flex; gap: 10px; overflow-x: auto; padding-bottom: 10px;">
|
||||
${future.map(item => `
|
||||
<div style="min-width: 100px; background: rgba(var(--card-background-rgb), 0.6); border: 1px solid var(--border-color); border-radius: 12px; padding: 15px; text-align: center; flex-shrink: 0;">
|
||||
<div style="font-size: 12px; opacity: 0.7; margin-bottom: 5px;">${item.day || item.week || item.date}</div>
|
||||
<div style="font-size: 24px; color: var(--primary-color); margin: 10px 0;"><i class="fas ${this._getWeatherIcon(item.wea || item.weather)}"></i></div>
|
||||
<div style="font-weight: 600; font-size: 14px; margin-bottom: 5px;">${item.wea || item.weather}</div>
|
||||
<div style="font-size: 12px;">${item.tem2 || item.low} ~ ${item.tem1 || item.high}</div>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
_getWeatherIcon(text) {
|
||||
if (!text) return 'fa-cloud';
|
||||
const t = text.toString();
|
||||
if (t.includes('晴')) return 'fa-sun';
|
||||
if (t.includes('多云') || t.includes('阴')) return 'fa-cloud';
|
||||
if (t.includes('雨')) return 'fa-cloud-rain';
|
||||
if (t.includes('雪')) return 'fa-snowflake';
|
||||
if (t.includes('雷')) return 'fa-bolt';
|
||||
if (t.includes('雾') || t.includes('霾')) return 'fa-smog';
|
||||
if (t.includes('风')) return 'fa-wind';
|
||||
return 'fa-cloud-sun';
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (this.abortController) this.abortController.abort();
|
||||
super.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
export default WeatherDetailsTool;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user