@@ -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;
|
||||
@@ -0,0 +1,140 @@
|
||||
// src/js/tools/wxDomainCheckTool.js
|
||||
import BaseTool from '../baseTool.js';
|
||||
|
||||
class WxDomainCheckTool extends BaseTool {
|
||||
constructor() {
|
||||
super('wx-domain-check', '微信域名检测');
|
||||
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="fab fa-weixin"></i> 微信域名访问状态</h2>
|
||||
<p class="setting-item-description" style="margin-bottom: 20px;">检测指定域名是否可以在微信内置浏览器中正常访问。</p>
|
||||
|
||||
<div class="ip-input-group" style="margin-bottom: 20px;">
|
||||
<input type="text" id="wxd-input" placeholder="输入域名 (例如: qq.com)">
|
||||
<button id="wxd-query-btn" class="action-btn ripple"><i class="fas fa-search"></i> 检测</button>
|
||||
</div>
|
||||
|
||||
<div id="wxd-results-container" class="ip-results-container" style="min-height: 100px;">
|
||||
<p class="loading-text">请输入域名后点击检测</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
init() {
|
||||
this._log('工具已初始化');
|
||||
|
||||
// Cache DOM elements
|
||||
this.dom.input = document.getElementById('wxd-input');
|
||||
this.dom.queryBtn = document.getElementById('wxd-query-btn');
|
||||
this.dom.resultsContainer = document.getElementById('wxd-results-container');
|
||||
|
||||
// Bind events
|
||||
this.dom.queryBtn.addEventListener('click', this._handleQuery.bind(this));
|
||||
this.dom.input.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') this._handleQuery();
|
||||
});
|
||||
}
|
||||
|
||||
async _handleQuery() {
|
||||
const domain = this.dom.input.value.trim();
|
||||
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}...</p>
|
||||
</div>`;
|
||||
|
||||
try {
|
||||
const apiUrl = `https://uapis.cn/api/v1/network/wxdomain?domain=${encodeURIComponent(domain)}`;
|
||||
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}`);
|
||||
}
|
||||
|
||||
// [修改] 根据状态码显示不同颜色和图标
|
||||
let statusColor = 'var(--text-color)';
|
||||
let statusIcon = 'fa-question-circle';
|
||||
let statusText = json.msg || '未知状态';
|
||||
|
||||
// 假设 type 为 1 表示正常,其他表示被封禁 (根据之前代码推断)
|
||||
if (json.type === 1 || json.type === '1') {
|
||||
statusColor = 'var(--success-color)';
|
||||
statusIcon = 'fa-check-circle';
|
||||
} else {
|
||||
statusColor = 'var(--error-color)';
|
||||
statusIcon = 'fa-times-circle';
|
||||
}
|
||||
|
||||
this.dom.resultsContainer.innerHTML = `
|
||||
<div class="ip-result-grid" style="animation: contentFadeIn 0.3s;">
|
||||
<div class="ip-result-category" style="border-left-color: ${statusColor};">
|
||||
<h3 style="color: ${statusColor}; font-size: 20px;"><i class="fas ${statusIcon}"></i> ${statusText}</h3>
|
||||
<div class="ip-result-item">
|
||||
<span class="ip-result-key">检测域名</span>
|
||||
<span class="ip-result-value">${domain}</span>
|
||||
</div>
|
||||
<div class="ip-result-item">
|
||||
<span class="ip-result-key">状态码 (Type)</span>
|
||||
<span class="ip-result-value">${json.type}</span>
|
||||
</div>
|
||||
<div class="ip-result-item">
|
||||
<span class="ip-result-key">原始消息</span>
|
||||
<span class="ip-result-value">${json.msg}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
this._log(`微信域名检测成功: ${domain} - ${statusText}`);
|
||||
|
||||
} catch (error) {
|
||||
if (error.name === 'AbortError') {
|
||||
this._log('检测请求被中止');
|
||||
this.dom.resultsContainer.innerHTML = `<p class="loading-text">检测已取消</p>`;
|
||||
} else {
|
||||
this._notify('检测失败', error.message, 'error');
|
||||
this._log(`检测失败: ${error.message}`);
|
||||
this.dom.resultsContainer.innerHTML = `<p class="error-message"><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;
|
||||
}
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (this.abortController) {
|
||||
this.abortController.abort();
|
||||
}
|
||||
this._log('工具已销毁');
|
||||
super.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
export default WxDomainCheckTool;
|
||||
@@ -0,0 +1,254 @@
|
||||
// src/js/tools/zhihuHotTool.js
|
||||
import BaseTool from '../baseTool.js';
|
||||
import i18n from '../i18n.js';
|
||||
|
||||
class ZhihuHotTool extends BaseTool {
|
||||
constructor() {
|
||||
super('zhihu-hot', '知乎热搜');
|
||||
this.dom = {};
|
||||
this.apiUrl = 'http://shanhe.kim/api/za/zhihu.php';
|
||||
}
|
||||
|
||||
render() {
|
||||
return `
|
||||
<div class="page-container zhihu-hot-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="fab fa-zhihu"></i> ${i18n.t('tool.zhihuHot.title', '知乎热搜榜')}</h2>
|
||||
<p style="color: var(--text-secondary); margin-top: 8px;">${i18n.t('tool.zhihuHot.description', '实时获取知乎热搜榜数据,了解热门话题')}</p>
|
||||
</div>
|
||||
|
||||
<div class="hot-header" id="zhihu-hot-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>
|
||||
<div id="zhihu-hot-date" style="font-size: 16px; font-weight: 600; color: var(--text-primary); margin-bottom: 8px;"></div>
|
||||
<div id="zhihu-hot-fresh-text" style="font-size: 14px; color: var(--text-secondary); background: rgba(var(--primary-rgb), 0.1); padding: 6px 12px; border-radius: 16px; display: inline-block;"></div>
|
||||
</div>
|
||||
<button id="zhihu-hot-refresh-btn" class="action-btn ripple">
|
||||
<i class="fas fa-sync-alt"></i> ${i18n.t('tool.zhihuHot.refresh', '刷新数据')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="error-message" id="zhihu-hot-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">
|
||||
<h2><i class="fas fa-list"></i> ${i18n.t('tool.zhihuHot.hotList', '热搜列表')}</h2>
|
||||
<ul class="hot-list" id="zhihu-hot-list" style="list-style: none; padding: 0; margin: 20px 0 0 0; display: flex; flex-direction: column; gap: 12px;"></ul>
|
||||
</div>
|
||||
|
||||
<div class="loading" id="zhihu-hot-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.zhihuHot.loading', '正在获取知乎热搜榜数据,请稍候...')}</div>
|
||||
</div>
|
||||
|
||||
<div class="empty-state" id="zhihu-hot-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.zhihuHot.noData', '暂无热搜数据,请点击刷新按钮获取')}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
init() {
|
||||
this._log('知乎热搜工具初始化');
|
||||
|
||||
this.dom = {
|
||||
refreshBtn: document.getElementById('zhihu-hot-refresh-btn'),
|
||||
header: document.getElementById('zhihu-hot-header'),
|
||||
date: document.getElementById('zhihu-hot-date'),
|
||||
freshText: document.getElementById('zhihu-hot-fresh-text'),
|
||||
hotList: document.getElementById('zhihu-hot-list'),
|
||||
loading: document.getElementById('zhihu-hot-loading'),
|
||||
errorMessage: document.getElementById('zhihu-hot-error-message'),
|
||||
emptyState: document.getElementById('zhihu-hot-empty-state')
|
||||
};
|
||||
|
||||
this.dom.refreshBtn?.addEventListener('click', () => {
|
||||
this.fetchHotData();
|
||||
});
|
||||
|
||||
// 返回工具箱按钮
|
||||
document.getElementById('back-to-toolbox-btn')?.addEventListener('click', () => {
|
||||
window.mainPage.navigateTo('toolbox');
|
||||
window.mainPage.updateActiveNavButton(document.getElementById('toolbox-btn'));
|
||||
});
|
||||
|
||||
// 自动加载数据
|
||||
this.fetchHotData();
|
||||
}
|
||||
|
||||
async fetchHotData() {
|
||||
this.showLoading();
|
||||
this.hideError();
|
||||
this.hideEmptyState();
|
||||
this.hideHotData();
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 30000);
|
||||
|
||||
const response = await fetch(this.apiUrl, {
|
||||
method: 'GET',
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP错误! 状态码: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const responseTime = (Date.now() - startTime) / 1000;
|
||||
|
||||
if (data.code === '200' || data.code === 200 || data.data) {
|
||||
this.displayData(data);
|
||||
this._log(`成功获取知乎热搜数据,耗时 ${responseTime.toFixed(2)}秒`);
|
||||
} else {
|
||||
throw new Error(data.msg || i18n.t('tool.zhihuHot.queryFailed', '获取数据失败'));
|
||||
}
|
||||
} catch (error) {
|
||||
const responseTime = (Date.now() - startTime) / 1000;
|
||||
let errorMessage = error.message;
|
||||
|
||||
if (error.name === 'AbortError') {
|
||||
errorMessage = i18n.t('tool.zhihuHot.timeout', '请求超时,请稍后重试');
|
||||
}
|
||||
|
||||
this.showError(i18n.t('tool.zhihuHot.queryFailed', '查询失败') + ': ' + errorMessage);
|
||||
console.error('API请求错误:', error);
|
||||
this._log(`获取知乎热搜数据失败: ${error.message}`);
|
||||
} finally {
|
||||
this.hideLoading();
|
||||
}
|
||||
}
|
||||
|
||||
displayData(data) {
|
||||
// 显示头部信息
|
||||
if (data.day) {
|
||||
this.dom.date.textContent = data.day;
|
||||
} else {
|
||||
this.dom.date.textContent = new Date().toLocaleDateString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
weekday: 'long'
|
||||
});
|
||||
}
|
||||
|
||||
if (data.fresh_text) {
|
||||
this.dom.freshText.textContent = data.fresh_text;
|
||||
} else {
|
||||
this.dom.freshText.textContent = i18n.t('tool.zhihuHot.fresh', '实时更新');
|
||||
}
|
||||
|
||||
this.dom.header.style.display = 'flex';
|
||||
|
||||
// 显示热搜列表
|
||||
this.dom.hotList.innerHTML = '';
|
||||
|
||||
const hotItems = data.data || data.list || [];
|
||||
|
||||
if (Array.isArray(hotItems) && hotItems.length > 0) {
|
||||
hotItems.forEach((item, index) => {
|
||||
const li = document.createElement('li');
|
||||
li.className = 'hot-item';
|
||||
li.style.cssText = `
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
background: rgba(var(--card-background-rgb), 0.5);
|
||||
transition: all 0.3s ease;
|
||||
cursor: pointer;
|
||||
`;
|
||||
|
||||
li.addEventListener('mouseenter', () => {
|
||||
li.style.background = 'rgba(var(--card-background-rgb), 0.8)';
|
||||
li.style.transform = 'translateX(4px)';
|
||||
});
|
||||
|
||||
li.addEventListener('mouseleave', () => {
|
||||
li.style.background = 'rgba(var(--card-background-rgb), 0.5)';
|
||||
li.style.transform = 'translateX(0)';
|
||||
});
|
||||
|
||||
const rank = index + 1;
|
||||
const rankColor = rank <= 3 ? '#e74c3c' : rank <= 10 ? '#e67e22' : 'var(--text-secondary)';
|
||||
|
||||
li.innerHTML = `
|
||||
<div style="display: flex; align-items: flex-start; gap: 16px;">
|
||||
<div class="hot-rank" style="font-size: 24px; font-weight: 700; color: ${rankColor}; min-width: 40px; text-align: center; line-height: 1.2;">
|
||||
${rank}
|
||||
</div>
|
||||
<div style="flex: 1;">
|
||||
<div class="hot-title" style="font-size: 16px; font-weight: 600; color: var(--text-primary); margin-bottom: 8px; line-height: 1.5;">
|
||||
${item.title || item.question || item.name || '-'}
|
||||
</div>
|
||||
${item.excerpt ? `
|
||||
<div class="hot-excerpt" style="font-size: 14px; color: var(--text-secondary); line-height: 1.6; margin-top: 8px;">
|
||||
${item.excerpt}
|
||||
</div>
|
||||
` : ''}
|
||||
${item.url ? `
|
||||
<a href="${item.url}" target="_blank" rel="noopener noreferrer" style="color: var(--primary-color); text-decoration: none; font-size: 14px; margin-top: 8px; display: inline-block;">
|
||||
<i class="fas fa-external-link-alt"></i> ${i18n.t('tool.zhihuHot.view', '查看详情')}
|
||||
</a>
|
||||
` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
this.dom.hotList.appendChild(li);
|
||||
});
|
||||
} else {
|
||||
this.showEmptyState();
|
||||
}
|
||||
}
|
||||
|
||||
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.zhihuHot.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.zhihuHot.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';
|
||||
}
|
||||
|
||||
hideHotData() {
|
||||
this.dom.header.style.display = 'none';
|
||||
this.dom.hotList.innerHTML = '';
|
||||
}
|
||||
}
|
||||
|
||||
export default ZhihuHotTool;
|
||||
@@ -0,0 +1,118 @@
|
||||
// src/js/translationOverlay.js
|
||||
// 翻译进度遮罩层
|
||||
|
||||
class TranslationOverlay {
|
||||
constructor() {
|
||||
this.overlay = null;
|
||||
this.isVisible = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示翻译进度遮罩层
|
||||
* @param {string} message - 显示的消息
|
||||
* @param {number} progress - 进度(0-100)
|
||||
* @param {string} currentKey - 当前翻译的键
|
||||
*/
|
||||
show(message = '正在翻译...', progress = 0, currentKey = '') {
|
||||
if (!this.overlay) {
|
||||
this.createOverlay();
|
||||
}
|
||||
|
||||
this.updateProgress(message, progress, currentKey);
|
||||
|
||||
if (!this.isVisible) {
|
||||
this.overlay.style.display = 'flex';
|
||||
this.isVisible = true;
|
||||
// 添加淡入动画
|
||||
setTimeout(() => {
|
||||
this.overlay.classList.add('active');
|
||||
}, 10);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 隐藏翻译进度遮罩层
|
||||
*/
|
||||
hide() {
|
||||
if (this.overlay && this.isVisible) {
|
||||
this.overlay.classList.remove('active');
|
||||
setTimeout(() => {
|
||||
if (this.overlay) {
|
||||
this.overlay.style.display = 'none';
|
||||
}
|
||||
this.isVisible = false;
|
||||
}, 300);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新进度
|
||||
* @param {string} message - 显示的消息
|
||||
* @param {number} progress - 进度(0-100)
|
||||
* @param {string} currentKey - 当前翻译的键
|
||||
*/
|
||||
updateProgress(message, progress, currentKey = '') {
|
||||
if (!this.overlay) return;
|
||||
|
||||
const messageEl = this.overlay.querySelector('.translation-message');
|
||||
const progressBar = this.overlay.querySelector('.translation-progress-bar');
|
||||
const progressText = this.overlay.querySelector('.translation-progress-text');
|
||||
const currentKeyEl = this.overlay.querySelector('.translation-current-key');
|
||||
|
||||
if (messageEl) messageEl.textContent = message;
|
||||
if (progressBar) progressBar.style.width = `${Math.min(100, Math.max(0, progress))}%`;
|
||||
if (progressText) progressText.textContent = `${Math.round(progress)}%`;
|
||||
if (currentKeyEl) currentKeyEl.textContent = currentKey ? `正在翻译: ${currentKey}` : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建遮罩层DOM
|
||||
*/
|
||||
createOverlay() {
|
||||
this.overlay = document.createElement('div');
|
||||
this.overlay.className = 'translation-overlay';
|
||||
|
||||
// 动态获取i18n(如果可用)
|
||||
let hintText = '请稍候,正在使用公共翻译接口进行翻译...';
|
||||
try {
|
||||
if (window.i18n) {
|
||||
hintText = window.i18n.t('translation.hint') || hintText;
|
||||
}
|
||||
} catch (e) {
|
||||
// i18n不可用时使用默认文本
|
||||
}
|
||||
|
||||
this.overlay.innerHTML = `
|
||||
<div class="translation-overlay-content">
|
||||
<div class="translation-overlay-icon">
|
||||
<i class="fas fa-language fa-spin"></i>
|
||||
</div>
|
||||
<h3 class="translation-message">正在翻译...</h3>
|
||||
<div class="translation-progress-container">
|
||||
<div class="translation-progress-track">
|
||||
<div class="translation-progress-bar"></div>
|
||||
</div>
|
||||
<div class="translation-progress-text">0%</div>
|
||||
</div>
|
||||
<p class="translation-current-key"></p>
|
||||
<p class="translation-hint">${hintText}</p>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(this.overlay);
|
||||
}
|
||||
|
||||
/**
|
||||
* 销毁遮罩层
|
||||
*/
|
||||
destroy() {
|
||||
if (this.overlay) {
|
||||
this.overlay.remove();
|
||||
this.overlay = null;
|
||||
this.isVisible = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 导出单例
|
||||
const translationOverlay = new TranslationOverlay();
|
||||
export default translationOverlay;
|
||||
@@ -0,0 +1,160 @@
|
||||
// src/js/translationService.js
|
||||
// 翻译服务 - 使用免费的MyMemory Translation API
|
||||
|
||||
class TranslationService {
|
||||
constructor() {
|
||||
this.apiEndpoint = 'https://api.mymemory.translated.net/get';
|
||||
this.rateLimitDelay = 100; // 请求间隔(毫秒),避免触发限流
|
||||
this.maxRetries = 3;
|
||||
this.cache = new Map(); // 翻译缓存
|
||||
}
|
||||
|
||||
/**
|
||||
* 检测语言代码
|
||||
* @param {string} text - 要检测的文本
|
||||
* @returns {Promise<string>} - 语言代码(如 'zh', 'en')
|
||||
*/
|
||||
async detectLanguage(text) {
|
||||
// 简单的中文检测
|
||||
const chineseRegex = /[\u4e00-\u9fa5]/;
|
||||
if (chineseRegex.test(text)) {
|
||||
return 'zh';
|
||||
}
|
||||
// 默认返回英文
|
||||
return 'en';
|
||||
}
|
||||
|
||||
/**
|
||||
* 翻译单个文本
|
||||
* @param {string} text - 要翻译的文本
|
||||
* @param {string} targetLang - 目标语言代码(如 'en', 'zh')
|
||||
* @param {string} sourceLang - 源语言代码(可选,自动检测)
|
||||
* @returns {Promise<string>} - 翻译后的文本
|
||||
*/
|
||||
async translateText(text, targetLang, sourceLang = null) {
|
||||
if (!text || !text.trim()) return text;
|
||||
|
||||
// 检查缓存
|
||||
const cacheKey = `${sourceLang || 'auto'}_${targetLang}_${text}`;
|
||||
if (this.cache.has(cacheKey)) {
|
||||
return this.cache.get(cacheKey);
|
||||
}
|
||||
|
||||
// 如果没有指定源语言,自动检测
|
||||
if (!sourceLang) {
|
||||
sourceLang = await this.detectLanguage(text);
|
||||
}
|
||||
|
||||
// 如果源语言和目标语言相同,直接返回
|
||||
if (sourceLang === targetLang) {
|
||||
this.cache.set(cacheKey, text);
|
||||
return text;
|
||||
}
|
||||
|
||||
// 映射语言代码(MyMemory API使用ISO 639-1)
|
||||
const langMap = {
|
||||
'zh': 'zh-CN',
|
||||
'zh-CN': 'zh-CN',
|
||||
'en': 'en',
|
||||
'en-US': 'en',
|
||||
'en-GB': 'en'
|
||||
};
|
||||
|
||||
const source = langMap[sourceLang] || sourceLang.split('-')[0];
|
||||
const target = langMap[targetLang] || targetLang.split('-')[0];
|
||||
|
||||
if (source === target) {
|
||||
this.cache.set(cacheKey, text);
|
||||
return text;
|
||||
}
|
||||
|
||||
// 调用翻译API
|
||||
let retries = 0;
|
||||
while (retries < this.maxRetries) {
|
||||
try {
|
||||
const url = `${this.apiEndpoint}?q=${encodeURIComponent(text)}&langpair=${source}|${target}`;
|
||||
const response = await fetch(url);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.responseStatus === 200 && data.responseData && data.responseData.translatedText) {
|
||||
const translated = data.responseData.translatedText;
|
||||
this.cache.set(cacheKey, translated);
|
||||
|
||||
// 添加延迟,避免触发API限流
|
||||
await new Promise(resolve => setTimeout(resolve, this.rateLimitDelay));
|
||||
|
||||
return translated;
|
||||
} else {
|
||||
throw new Error(data.responseStatus || 'Translation failed');
|
||||
}
|
||||
} catch (error) {
|
||||
retries++;
|
||||
if (retries >= this.maxRetries) {
|
||||
console.error('[Translation] Translation failed:', error);
|
||||
// 如果翻译失败,返回原文
|
||||
return text;
|
||||
}
|
||||
// 重试前等待
|
||||
await new Promise(resolve => setTimeout(resolve, 1000 * retries));
|
||||
}
|
||||
}
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量翻译对象
|
||||
* @param {object} obj - 要翻译的对象(键值对)
|
||||
* @param {string} targetLang - 目标语言代码
|
||||
* @param {string} sourceLang - 源语言代码(可选)
|
||||
* @param {Function} onProgress - 进度回调函数 (current, total, key)
|
||||
* @returns {Promise<object>} - 翻译后的对象
|
||||
*/
|
||||
async translateObject(obj, targetLang, sourceLang = null, onProgress = null) {
|
||||
const result = {};
|
||||
const keys = Object.keys(obj);
|
||||
const total = keys.length;
|
||||
|
||||
for (let i = 0; i < keys.length; i++) {
|
||||
const key = keys[i];
|
||||
const value = obj[key];
|
||||
|
||||
// 跳过注释和空值
|
||||
if (key === 'comment' || value === null || value === undefined) {
|
||||
result[key] = value;
|
||||
if (onProgress) onProgress(i + 1, total, key);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 如果是字符串,直接翻译
|
||||
if (typeof value === 'string') {
|
||||
result[key] = await this.translateText(value, targetLang, sourceLang);
|
||||
}
|
||||
// 如果是对象,递归翻译
|
||||
else if (typeof value === 'object' && !Array.isArray(value)) {
|
||||
result[key] = await this.translateObject(value, targetLang, sourceLang, null);
|
||||
}
|
||||
// 其他类型直接复制
|
||||
else {
|
||||
result[key] = value;
|
||||
}
|
||||
|
||||
if (onProgress) {
|
||||
onProgress(i + 1, total, key);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空缓存
|
||||
*/
|
||||
clearCache() {
|
||||
this.cache.clear();
|
||||
}
|
||||
}
|
||||
|
||||
// 导出单例
|
||||
const translationService = new TranslationService();
|
||||
export default translationService;
|
||||
@@ -0,0 +1,379 @@
|
||||
// src/js/ui/ModalManager.js
|
||||
/**
|
||||
* [新增] 通用模态框管理器
|
||||
* 提供统一的模态框创建、显示、关闭和管理功能
|
||||
* 解决各个模态框的通用设置问题
|
||||
*/
|
||||
import configManager from '../configManager.js';
|
||||
|
||||
class ModalManager {
|
||||
constructor() {
|
||||
this.activeModals = new Map(); // 存储当前活动的模态框
|
||||
this.modalStack = []; // 模态框堆栈(支持多层模态框)
|
||||
this.defaultZIndex = 30000; // 默认 z-index 起始值
|
||||
this.currentZIndex = this.defaultZIndex;
|
||||
|
||||
// 绑定 ESC 键关闭
|
||||
this._bindEscapeKey();
|
||||
}
|
||||
|
||||
/**
|
||||
* 绑定 ESC 键关闭功能
|
||||
* @private
|
||||
*/
|
||||
_bindEscapeKey() {
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape' && this.modalStack.length > 0) {
|
||||
const topModal = this.modalStack[this.modalStack.length - 1];
|
||||
if (topModal && topModal.closable !== false) {
|
||||
this.close(topModal.id);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建并显示模态框
|
||||
* @param {Object} options - 模态框配置选项
|
||||
* @param {string} options.id - 模态框唯一ID(必填)
|
||||
* @param {string} options.title - 标题(可选)
|
||||
* @param {string|HTMLElement} options.content - 内容(字符串或DOM元素)
|
||||
* @param {Array} options.buttons - 按钮配置数组(可选)
|
||||
* @param {Function} options.onClose - 关闭回调(可选)
|
||||
* @param {Function} options.onOpen - 打开回调(可选)
|
||||
* @param {boolean} options.closable - 是否可关闭(默认true)
|
||||
* @param {boolean} options.closeOnBackdrop - 点击背景是否关闭(默认true)
|
||||
* @param {string} options.size - 尺寸('small'|'medium'|'large'|'fullscreen',默认'medium')
|
||||
* @param {string} options.className - 自定义CSS类名(可选)
|
||||
* @param {Object} options.style - 自定义样式(可选)
|
||||
* @param {number} options.zIndex - 自定义z-index(可选,默认自动递增)
|
||||
* @returns {HTMLElement} 创建的模态框元素
|
||||
*/
|
||||
create(options) {
|
||||
const {
|
||||
id,
|
||||
title = '',
|
||||
content = '',
|
||||
buttons = [],
|
||||
onClose = null,
|
||||
onOpen = null,
|
||||
closable = true,
|
||||
closeOnBackdrop = true,
|
||||
size = 'medium',
|
||||
className = '',
|
||||
style = {},
|
||||
zIndex = null
|
||||
} = options;
|
||||
|
||||
if (!id) {
|
||||
console.error('[ModalManager] 模态框ID是必填的');
|
||||
return null;
|
||||
}
|
||||
|
||||
// 如果已存在相同ID的模态框,先关闭它
|
||||
if (this.activeModals.has(id)) {
|
||||
this.close(id);
|
||||
}
|
||||
|
||||
// 生成唯一ID(如果未提供)
|
||||
const modalId = id || `modal-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
|
||||
// 计算 z-index
|
||||
const modalZIndex = zIndex || this.currentZIndex++;
|
||||
|
||||
// 创建模态框元素
|
||||
const modal = document.createElement('div');
|
||||
modal.id = modalId;
|
||||
modal.className = `universal-modal ${className}`;
|
||||
modal.setAttribute('data-modal-id', modalId);
|
||||
modal.style.zIndex = modalZIndex;
|
||||
|
||||
// 应用自定义样式
|
||||
Object.assign(modal.style, style);
|
||||
|
||||
// 生成按钮HTML
|
||||
const buttonsHtml = buttons.map((button, index) => {
|
||||
const btnType = button.type || 'primary';
|
||||
const btnClass = button.className || '';
|
||||
return `
|
||||
<button class="universal-modal-button universal-modal-button-${btnType} ${btnClass}"
|
||||
data-button-index="${index}">
|
||||
${button.icon ? `<i class="${button.icon}"></i>` : ''}
|
||||
${button.label || ''}
|
||||
</button>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
// 生成内容HTML
|
||||
let contentHtml = '';
|
||||
if (typeof content === 'string') {
|
||||
contentHtml = content;
|
||||
} else if (content instanceof HTMLElement) {
|
||||
contentHtml = content.outerHTML;
|
||||
} else if (content) {
|
||||
contentHtml = String(content);
|
||||
}
|
||||
|
||||
// 构建模态框HTML
|
||||
modal.innerHTML = `
|
||||
<div class="universal-modal-backdrop" data-backdrop="true"></div>
|
||||
<div class="universal-modal-container universal-modal-${size}">
|
||||
${title || closable ? `
|
||||
<div class="universal-modal-header">
|
||||
${title ? `
|
||||
<div class="universal-modal-title">
|
||||
${title}
|
||||
</div>
|
||||
` : ''}
|
||||
${closable ? `
|
||||
<button class="universal-modal-close" data-close="true" aria-label="关闭">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
` : ''}
|
||||
</div>
|
||||
` : ''}
|
||||
<div class="universal-modal-body">
|
||||
${contentHtml}
|
||||
</div>
|
||||
${buttons.length > 0 ? `
|
||||
<div class="universal-modal-footer">
|
||||
${buttonsHtml}
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
`;
|
||||
|
||||
// 添加到DOM
|
||||
document.body.appendChild(modal);
|
||||
|
||||
// 存储模态框信息
|
||||
const modalInfo = {
|
||||
id: modalId,
|
||||
element: modal,
|
||||
closable,
|
||||
closeOnBackdrop,
|
||||
onClose,
|
||||
onOpen,
|
||||
zIndex: modalZIndex,
|
||||
buttons
|
||||
};
|
||||
|
||||
this.activeModals.set(modalId, modalInfo);
|
||||
this.modalStack.push(modalInfo);
|
||||
|
||||
// 绑定事件
|
||||
this._bindModalEvents(modal, modalInfo);
|
||||
|
||||
// 触发打开动画
|
||||
requestAnimationFrame(() => {
|
||||
modal.classList.add('active');
|
||||
document.body.style.overflow = 'hidden';
|
||||
|
||||
// 调用打开回调
|
||||
if (onOpen) {
|
||||
try {
|
||||
onOpen(modal);
|
||||
} catch (error) {
|
||||
console.error('[ModalManager] 打开回调执行失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// [日志] 记录模态框打开
|
||||
if (configManager && configManager.logAction) {
|
||||
configManager.logAction(`打开模态框: ${modalId}${title ? ` (${title})` : ''}`, 'ui');
|
||||
}
|
||||
});
|
||||
|
||||
return modal;
|
||||
}
|
||||
|
||||
/**
|
||||
* 绑定模态框事件
|
||||
* @private
|
||||
*/
|
||||
_bindModalEvents(modal, modalInfo) {
|
||||
const { id, closable, closeOnBackdrop, onClose, buttons } = modalInfo;
|
||||
|
||||
// 关闭按钮
|
||||
if (closable) {
|
||||
const closeBtn = modal.querySelector('[data-close="true"]');
|
||||
if (closeBtn) {
|
||||
closeBtn.addEventListener('click', () => {
|
||||
this.close(id);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 背景点击关闭
|
||||
if (closeOnBackdrop) {
|
||||
const backdrop = modal.querySelector('[data-backdrop="true"]');
|
||||
if (backdrop) {
|
||||
backdrop.addEventListener('click', (e) => {
|
||||
if (e.target === backdrop) {
|
||||
this.close(id);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 按钮事件
|
||||
buttons.forEach((button, index) => {
|
||||
const btn = modal.querySelector(`[data-button-index="${index}"]`);
|
||||
if (btn && button.onClick) {
|
||||
btn.addEventListener('click', () => {
|
||||
try {
|
||||
button.onClick(modal, button);
|
||||
} catch (error) {
|
||||
console.error(`[ModalManager] 按钮 ${index} 点击回调执行失败:`, error);
|
||||
}
|
||||
|
||||
// 如果按钮配置了关闭,则关闭模态框
|
||||
if (button.closeOnClick !== false) {
|
||||
this.close(id);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭模态框
|
||||
* @param {string} id - 模态框ID
|
||||
* @param {boolean} force - 是否强制关闭(忽略closable设置)
|
||||
*/
|
||||
close(id, force = false) {
|
||||
const modalInfo = this.activeModals.get(id);
|
||||
if (!modalInfo) {
|
||||
console.warn(`[ModalManager] 未找到ID为 ${id} 的模态框`);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!force && !modalInfo.closable) {
|
||||
console.warn(`[ModalManager] 模态框 ${id} 不可关闭`);
|
||||
return false;
|
||||
}
|
||||
|
||||
const { element, onClose } = modalInfo;
|
||||
|
||||
// 移除激活状态
|
||||
element.classList.remove('active');
|
||||
element.classList.add('closing');
|
||||
|
||||
// 从堆栈中移除
|
||||
const stackIndex = this.modalStack.findIndex(m => m.id === id);
|
||||
if (stackIndex > -1) {
|
||||
this.modalStack.splice(stackIndex, 1);
|
||||
}
|
||||
|
||||
// 延迟移除DOM(等待动画完成)
|
||||
setTimeout(() => {
|
||||
if (element.parentNode) {
|
||||
element.remove();
|
||||
}
|
||||
|
||||
// 从活动列表中移除
|
||||
this.activeModals.delete(id);
|
||||
|
||||
// 如果所有模态框都关闭了,恢复body滚动
|
||||
if (this.modalStack.length === 0) {
|
||||
document.body.style.overflow = '';
|
||||
this.currentZIndex = this.defaultZIndex; // 重置z-index
|
||||
}
|
||||
|
||||
// 调用关闭回调
|
||||
if (onClose) {
|
||||
try {
|
||||
onClose(element);
|
||||
} catch (error) {
|
||||
console.error('[ModalManager] 关闭回调执行失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// [日志] 记录模态框关闭
|
||||
if (configManager && configManager.logAction) {
|
||||
configManager.logAction(`关闭模态框: ${id}`, 'ui');
|
||||
}
|
||||
}, 300); // 等待动画完成
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭所有模态框
|
||||
*/
|
||||
closeAll() {
|
||||
const modalIds = Array.from(this.activeModals.keys());
|
||||
modalIds.forEach(id => this.close(id, true));
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查模态框是否打开
|
||||
* @param {string} id - 模态框ID
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isOpen(id) {
|
||||
return this.activeModals.has(id) &&
|
||||
this.activeModals.get(id).element.classList.contains('active');
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取模态框元素
|
||||
* @param {string} id - 模态框ID
|
||||
* @returns {HTMLElement|null}
|
||||
*/
|
||||
get(id) {
|
||||
const modalInfo = this.activeModals.get(id);
|
||||
return modalInfo ? modalInfo.element : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新模态框内容
|
||||
* @param {string} id - 模态框ID
|
||||
* @param {string|HTMLElement} content - 新内容
|
||||
*/
|
||||
updateContent(id, content) {
|
||||
const modal = this.get(id);
|
||||
if (!modal) {
|
||||
console.warn(`[ModalManager] 未找到ID为 ${id} 的模态框`);
|
||||
return false;
|
||||
}
|
||||
|
||||
const body = modal.querySelector('.universal-modal-body');
|
||||
if (body) {
|
||||
if (typeof content === 'string') {
|
||||
body.innerHTML = content;
|
||||
} else if (content instanceof HTMLElement) {
|
||||
body.innerHTML = '';
|
||||
body.appendChild(content);
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新模态框标题
|
||||
* @param {string} id - 模态框ID
|
||||
* @param {string} title - 新标题
|
||||
*/
|
||||
updateTitle(id, title) {
|
||||
const modal = this.get(id);
|
||||
if (!modal) {
|
||||
console.warn(`[ModalManager] 未找到ID为 ${id} 的模态框`);
|
||||
return false;
|
||||
}
|
||||
|
||||
const titleEl = modal.querySelector('.universal-modal-title');
|
||||
if (titleEl) {
|
||||
titleEl.textContent = title;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// 创建单例实例
|
||||
const modalManager = new ModalManager();
|
||||
|
||||
// 导出单例
|
||||
export default modalManager;
|
||||
@@ -0,0 +1,316 @@
|
||||
// src/js/ui/ModernUI.js
|
||||
/**
|
||||
* [重构] 现代化 UI 组件系统
|
||||
* 提供统一的 UI 组件和交互体验
|
||||
*/
|
||||
import EventBus from '../core/EventBus.js';
|
||||
|
||||
class ModernUI {
|
||||
constructor() {
|
||||
this.components = new Map();
|
||||
this.themes = {
|
||||
dark: {
|
||||
primary: '#6366f1',
|
||||
secondary: '#8b5cf6',
|
||||
success: '#10b981',
|
||||
warning: '#f59e0b',
|
||||
error: '#ef4444',
|
||||
background: '#0f172a',
|
||||
surface: '#1e293b',
|
||||
text: '#f1f5f9',
|
||||
textSecondary: '#94a3b8'
|
||||
},
|
||||
light: {
|
||||
primary: '#6366f1',
|
||||
secondary: '#8b5cf6',
|
||||
success: '#10b981',
|
||||
warning: '#f59e0b',
|
||||
error: '#ef4444',
|
||||
background: '#ffffff',
|
||||
surface: '#f8fafc',
|
||||
text: '#0f172a',
|
||||
textSecondary: '#64748b'
|
||||
}
|
||||
};
|
||||
|
||||
this.currentTheme = 'dark';
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
// 监听主题变化
|
||||
EventBus.on('theme:changed', (theme) => {
|
||||
this.setTheme(theme);
|
||||
});
|
||||
|
||||
// 应用初始主题
|
||||
const savedTheme = localStorage.getItem('app-theme') || 'dark';
|
||||
this.setTheme(savedTheme);
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置主题
|
||||
* @param {string} theme - 主题名称 ('dark' | 'light')
|
||||
*/
|
||||
setTheme(theme) {
|
||||
if (!this.themes[theme]) {
|
||||
console.warn(`主题 ${theme} 不存在`);
|
||||
return;
|
||||
}
|
||||
|
||||
this.currentTheme = theme;
|
||||
localStorage.setItem('app-theme', theme);
|
||||
|
||||
const themeColors = this.themes[theme];
|
||||
const root = document.documentElement;
|
||||
|
||||
// 应用 CSS 变量
|
||||
Object.entries(themeColors).forEach(([key, value]) => {
|
||||
root.style.setProperty(`--color-${key}`, value);
|
||||
});
|
||||
|
||||
document.body.setAttribute('data-theme', theme);
|
||||
EventBus.emit('ui:theme:changed', theme);
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示通知
|
||||
* @param {string} title - 标题
|
||||
* @param {string} message - 消息
|
||||
* @param {string} type - 类型 ('success' | 'error' | 'warning' | 'info')
|
||||
* @param {number} duration - 显示时长(毫秒)
|
||||
*/
|
||||
showNotification(title, message, type = 'info', duration = 3000) {
|
||||
const notification = this._createNotification(title, message, type);
|
||||
document.body.appendChild(notification);
|
||||
|
||||
// 触发动画
|
||||
requestAnimationFrame(() => {
|
||||
notification.classList.add('show');
|
||||
});
|
||||
|
||||
// 自动关闭
|
||||
if (duration > 0) {
|
||||
setTimeout(() => {
|
||||
this._removeNotification(notification);
|
||||
}, duration);
|
||||
}
|
||||
|
||||
return notification;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建通知元素
|
||||
*/
|
||||
_createNotification(title, message, type) {
|
||||
const notification = document.createElement('div');
|
||||
notification.className = `modern-notification modern-notification-${type}`;
|
||||
|
||||
const icons = {
|
||||
success: 'fa-check-circle',
|
||||
error: 'fa-times-circle',
|
||||
warning: 'fa-exclamation-triangle',
|
||||
info: 'fa-info-circle'
|
||||
};
|
||||
|
||||
notification.innerHTML = `
|
||||
<div class="notification-icon">
|
||||
<i class="fas ${icons[type] || icons.info}"></i>
|
||||
</div>
|
||||
<div class="notification-content">
|
||||
<div class="notification-title">${title}</div>
|
||||
<div class="notification-message">${message}</div>
|
||||
</div>
|
||||
<button class="notification-close" onclick="this.parentElement.remove()">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
`;
|
||||
|
||||
return notification;
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除通知
|
||||
*/
|
||||
_removeNotification(notification) {
|
||||
notification.classList.add('hide');
|
||||
setTimeout(() => {
|
||||
if (notification.parentNode) {
|
||||
notification.remove();
|
||||
}
|
||||
}, 300);
|
||||
}
|
||||
|
||||
/**
|
||||
* [重构] 显示模态框(使用通用模态框框架)
|
||||
* @param {Object} options - 模态框选项
|
||||
*/
|
||||
async showModal(options) {
|
||||
const {
|
||||
id = `modern-ui-modal-${Date.now()}`,
|
||||
title,
|
||||
content,
|
||||
buttons = [],
|
||||
onClose = null,
|
||||
closable = true,
|
||||
closeOnBackdrop = true,
|
||||
size = 'medium',
|
||||
className = 'modern-ui-modal-wrapper'
|
||||
} = options;
|
||||
|
||||
// [重构] 使用通用模态框框架
|
||||
const { default: modalManager } = await import('./ModalManager.js');
|
||||
|
||||
// 转换按钮格式以适配 ModalManager
|
||||
const modalButtons = buttons.map(button => ({
|
||||
label: button.label,
|
||||
type: button.type || 'primary',
|
||||
className: button.className || '',
|
||||
onClick: button.onClick,
|
||||
closeOnClick: button.closeOnClick !== false,
|
||||
icon: button.icon
|
||||
}));
|
||||
|
||||
const modal = modalManager.create({
|
||||
id,
|
||||
title,
|
||||
content: typeof content === 'string' ? content : (content?.outerHTML || ''),
|
||||
buttons: modalButtons,
|
||||
closable,
|
||||
closeOnBackdrop,
|
||||
size,
|
||||
className,
|
||||
onClose
|
||||
});
|
||||
|
||||
return modal;
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示加载指示器
|
||||
* @param {string} message - 加载消息
|
||||
*/
|
||||
showLoading(message = '加载中...') {
|
||||
const loading = document.createElement('div');
|
||||
loading.className = 'modern-loading';
|
||||
loading.id = 'modern-loading-overlay';
|
||||
|
||||
loading.innerHTML = `
|
||||
<div class="loading-spinner"></div>
|
||||
<div class="loading-message">${message}</div>
|
||||
`;
|
||||
|
||||
document.body.appendChild(loading);
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
loading.classList.add('show');
|
||||
});
|
||||
|
||||
return loading;
|
||||
}
|
||||
|
||||
/**
|
||||
* 隐藏加载指示器
|
||||
*/
|
||||
hideLoading() {
|
||||
const loading = document.getElementById('modern-loading-overlay');
|
||||
if (loading) {
|
||||
loading.classList.remove('show');
|
||||
loading.classList.add('hide');
|
||||
setTimeout(() => {
|
||||
if (loading.parentNode) {
|
||||
loading.remove();
|
||||
}
|
||||
}, 300);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建卡片组件
|
||||
* @param {Object} options - 卡片选项
|
||||
*/
|
||||
createCard(options) {
|
||||
const {
|
||||
title,
|
||||
content,
|
||||
footer,
|
||||
actions = [],
|
||||
className = ''
|
||||
} = options;
|
||||
|
||||
const card = document.createElement('div');
|
||||
card.className = `modern-card ${className}`;
|
||||
|
||||
let html = '';
|
||||
|
||||
if (title) {
|
||||
html += `<div class="card-header"><h3 class="card-title">${title}</h3></div>`;
|
||||
}
|
||||
|
||||
html += `<div class="card-body">${typeof content === 'string' ? content : content.outerHTML}</div>`;
|
||||
|
||||
if (footer || actions.length > 0) {
|
||||
html += '<div class="card-footer">';
|
||||
if (footer) {
|
||||
html += `<div class="card-footer-content">${footer}</div>`;
|
||||
}
|
||||
if (actions.length > 0) {
|
||||
html += '<div class="card-actions">';
|
||||
actions.forEach(action => {
|
||||
const btnClass = action.type || 'primary';
|
||||
html += `<button class="card-action-btn card-action-${btnClass}">${action.label}</button>`;
|
||||
});
|
||||
html += '</div>';
|
||||
}
|
||||
html += '</div>';
|
||||
}
|
||||
|
||||
card.innerHTML = html;
|
||||
|
||||
// 绑定动作事件
|
||||
actions.forEach((action, index) => {
|
||||
const btn = card.querySelector(`.card-action-btn:nth-child(${index + 1})`);
|
||||
if (btn && action.onClick) {
|
||||
btn.addEventListener('click', action.onClick);
|
||||
}
|
||||
});
|
||||
|
||||
return card;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建按钮组件
|
||||
* @param {Object} options - 按钮选项
|
||||
*/
|
||||
createButton(options) {
|
||||
const {
|
||||
label,
|
||||
type = 'primary',
|
||||
size = 'medium',
|
||||
icon = null,
|
||||
onClick = null,
|
||||
disabled = false,
|
||||
className = ''
|
||||
} = options;
|
||||
|
||||
const button = document.createElement('button');
|
||||
button.className = `modern-button modern-button-${type} modern-button-${size} ${className}`;
|
||||
button.disabled = disabled;
|
||||
|
||||
let html = '';
|
||||
if (icon) {
|
||||
html += `<i class="fas ${icon}"></i> `;
|
||||
}
|
||||
html += label;
|
||||
|
||||
button.innerHTML = html;
|
||||
|
||||
if (onClick) {
|
||||
button.addEventListener('click', onClick);
|
||||
}
|
||||
|
||||
return button;
|
||||
}
|
||||
}
|
||||
|
||||
export default new ModernUI();
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,69 @@
|
||||
// src/js/view-loader.js
|
||||
import { toolRegistry } from './tool-registry.js';
|
||||
import configManager from './configManager.js';
|
||||
|
||||
let activeTool = null;
|
||||
|
||||
export async function loadToolFromUrl() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const toolId = params.get('tool');
|
||||
const contentArea = document.getElementById('tool-content-area');
|
||||
|
||||
if (!toolId) {
|
||||
const errorMsg = '错误:未指定工具ID (tool=...)';
|
||||
contentArea.innerHTML = `<div class="loading-container"><p class="error-message">${errorMsg}</p></div>`;
|
||||
configManager.logAction(`[view-loader] ${errorMsg}`, 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// [修复点 3] 子窗口初始化配置
|
||||
try {
|
||||
const config = await window.electronAPI.getAppConfig();
|
||||
// 合并配置到 configManager
|
||||
configManager.setConfig({ ...configManager.config, ...config });
|
||||
console.log('[ViewLoader] 子窗口配置已加载', config);
|
||||
} catch (e) {
|
||||
console.warn('Failed to load remote config in child window', e);
|
||||
}
|
||||
|
||||
const ToolClass = toolRegistry[toolId];
|
||||
if (!ToolClass) {
|
||||
const errorMsg = `错误:未在注册表找到工具: ${toolId}`;
|
||||
contentArea.innerHTML = `<div class="loading-container"><p class="error-message">${errorMsg}</p></div>`;
|
||||
configManager.logAction(`[view-loader] ${errorMsg}`, 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// 销毁旧工具 (如果存在)
|
||||
if (activeTool && activeTool.destroy) {
|
||||
activeTool.destroy();
|
||||
}
|
||||
|
||||
// 实例化新工具
|
||||
activeTool = new ToolClass();
|
||||
|
||||
// 设置窗口标题
|
||||
document.getElementById('window-title').innerText = activeTool.name || '工具窗口';
|
||||
document.title = activeTool.name || '工具窗口';
|
||||
|
||||
configManager.logAction(`[${activeTool.name}] 正在新窗口中加载工具`, 'tool');
|
||||
|
||||
// 注入 HTML 并初始化
|
||||
try {
|
||||
contentArea.innerHTML = activeTool.render();
|
||||
activeTool.init();
|
||||
|
||||
// 绑定返回按钮事件 (如果存在)
|
||||
setTimeout(() => {
|
||||
const backBtn = document.getElementById('back-to-toolbox-btn');
|
||||
if (backBtn) {
|
||||
backBtn.style.display = 'none'; // 子窗口模式下隐藏“返回工具箱”按钮,因为已经是独立窗口
|
||||
}
|
||||
}, 100);
|
||||
|
||||
} catch (error) {
|
||||
console.error('工具加载失败:', error);
|
||||
contentArea.innerHTML = `<div class="loading-container"><p class="error-message">工具加载失败: ${error.message}</p></div>`;
|
||||
configManager.logAction(`[view-loader] 工具 ${toolId} 加载异常: ${error.message}`, 'error');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,504 @@
|
||||
// src/js/weatherWidget.js
|
||||
import configManager from './configManager.js';
|
||||
import uiManager from './uiManager.js';
|
||||
|
||||
class WeatherWidget {
|
||||
constructor() {
|
||||
this.dom = {};
|
||||
this.adcodeMap = []; // 内存中缓存行政区划库
|
||||
this.isAdcodeLoaded = false;
|
||||
|
||||
// 缓存池 (用于数据拼接)
|
||||
this.cache = {
|
||||
location: null, // 商业级定位数据
|
||||
details: null, // 源1:生活指数、湿度、空气
|
||||
realtime: null, // 源3:实时温度、天气现象、风力
|
||||
forecast: null // 源1:未来预报(温度范围)
|
||||
};
|
||||
}
|
||||
|
||||
async init(initialData) {
|
||||
this.dom = {
|
||||
widget: document.getElementById('title-weather-widget'),
|
||||
icon: document.getElementById('tw-icon'),
|
||||
location: document.getElementById('tw-location'),
|
||||
temp: document.getElementById('tw-temp'),
|
||||
|
||||
// 下拉卡片
|
||||
cardCity: document.getElementById('tw-card-city'),
|
||||
date: document.getElementById('tw-date'),
|
||||
bigTemp: document.getElementById('tw-big-temp'),
|
||||
weather: document.getElementById('tw-weather'),
|
||||
wind: document.getElementById('tw-wind'),
|
||||
gridContainer: document.querySelector('#title-weather-widget .wd-grid'),
|
||||
|
||||
};
|
||||
|
||||
if (!this.dom.widget) return;
|
||||
this.dom.widget.style.display = 'flex';
|
||||
|
||||
// 绑定事件
|
||||
this.dom.widget.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
// 如果数据不全,点击重试;否则显示模态框
|
||||
if (!this.cache.realtime || this.dom.temp.textContent === '--°') {
|
||||
this.refreshAllData();
|
||||
} else {
|
||||
this.showModal();
|
||||
}
|
||||
});
|
||||
|
||||
// 1. 先加载行政区划库
|
||||
await this.loadAdcodeLibrary();
|
||||
|
||||
// 2. 启动数据获取流程
|
||||
this.refreshAllData();
|
||||
}
|
||||
|
||||
/**
|
||||
* [步骤 0] 加载并解析本地行政区划 CSV
|
||||
* 文件路径: src/assets/AMap_adcode_citycode.csv
|
||||
* CSV格式: 中文名,adcode,citycode
|
||||
*/
|
||||
async loadAdcodeLibrary() {
|
||||
if (this.isAdcodeLoaded) return;
|
||||
try {
|
||||
const response = await fetch('./assets/AMap_adcode_citycode.csv');
|
||||
const text = await response.text();
|
||||
// 解析 CSV:忽略表头,按行读取
|
||||
const lines = text.split('\n');
|
||||
this.adcodeMap = lines
|
||||
.slice(1) // 跳过表头
|
||||
.map(line => {
|
||||
const parts = line.split(',');
|
||||
// CSV 列顺序: 中文名(0), adcode(1), citycode(2)
|
||||
if (parts.length >= 2 && parts[0].trim() && parts[1].trim()) {
|
||||
return {
|
||||
name: parts[0].trim(),
|
||||
adcode: parts[1].trim()
|
||||
};
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
this.isAdcodeLoaded = true;
|
||||
console.log(`[Weather] 行政区划库加载完成,共 ${this.adcodeMap.length} 条数据`);
|
||||
} catch (e) {
|
||||
console.error('[Weather] 行政区划库加载失败:', e);
|
||||
// 失败不阻断,后续逻辑会降级使用城市名查询
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* [主流程] 刷新所有数据
|
||||
*/
|
||||
async refreshAllData() {
|
||||
this._renderLoadingState();
|
||||
|
||||
try {
|
||||
// [步骤 3] 获取定位 (商业级)
|
||||
await this._fetchLocation();
|
||||
|
||||
// [步骤 4 & 5] 获取天气数据
|
||||
// 优先使用新API(包含所有数据),如果失败则使用旧API作为备用
|
||||
const loc = this.cache.location;
|
||||
const cityQuery = encodeURIComponent(loc.district || loc.city || '北京'); // 用于备用API
|
||||
|
||||
// 先尝试新API(包含完整数据)
|
||||
await this._fetchSource3_RealTime(loc);
|
||||
|
||||
// 如果新API失败或数据不完整,使用旧API作为备用
|
||||
if (!this.cache.realtime || !this.cache.details?.living) {
|
||||
await this._fetchSource1_Details(cityQuery);
|
||||
}
|
||||
|
||||
// [步骤 6] 渲染
|
||||
this.render();
|
||||
configManager.logAction(`[标题栏] 天气更新完成: ${loc.district || loc.city}`, 'system');
|
||||
|
||||
} catch (e) {
|
||||
console.error('[Weather] 刷新流程异常:', e);
|
||||
this._renderErrorState();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* [步骤 3] 获取公网IP及归属 (商业级)
|
||||
*/
|
||||
async _fetchLocation() {
|
||||
try {
|
||||
// 强制使用 commercial 源
|
||||
const res = await fetch('https://uapis.cn/api/v1/network/myip?source=commercial', { signal: AbortSignal.timeout(5000) });
|
||||
const data = await res.json();
|
||||
|
||||
// 解析 region 字段 (例如 "中国 陕西省 西安市")
|
||||
let city = '';
|
||||
if (data.region) {
|
||||
const parts = data.region.split(' ').filter(p => p.trim());
|
||||
// 取最后一个非空部分作为市级名称
|
||||
if (parts.length > 0) {
|
||||
city = parts[parts.length - 1];
|
||||
}
|
||||
}
|
||||
|
||||
// 构建用于天气API查询的city参数:市级名称 + 区级名称
|
||||
// 注意:city和district都保留完整名称(包含"市"、"区"、"县"等后缀)
|
||||
let weatherCityParam = '';
|
||||
if (city && data.district) {
|
||||
// 组合:市级名称 + 区级名称(例如:"西安市雁塔区")
|
||||
weatherCityParam = `${city}${data.district}`;
|
||||
} else if (city) {
|
||||
// 如果没有区级,只使用市级(例如:"西安市")
|
||||
weatherCityParam = city;
|
||||
} else if (data.district) {
|
||||
// 如果没有市级,只使用区级(例如:"雁塔区")
|
||||
weatherCityParam = data.district;
|
||||
}
|
||||
|
||||
this.cache.location = {
|
||||
region: data.region,
|
||||
city: city.replace(/市$/, ''), // 去除后缀用于显示
|
||||
district: data.district ? data.district.replace(/区|县$/, '') : null,
|
||||
full_district: data.district, // 保留完整用于匹配
|
||||
weatherCityParam: weatherCityParam, // 用于天气API的city参数
|
||||
area_code: data.area_code || null
|
||||
};
|
||||
} catch (e) {
|
||||
console.warn('[Weather] 定位失败,使用兜底:', e);
|
||||
this.cache.location = {
|
||||
city: '北京',
|
||||
district: '东城',
|
||||
weatherCityParam: '北京市东城区',
|
||||
full_district: '东城区'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* [步骤 4] 获取源3:商业级实时天气 (Real-time)
|
||||
* 策略:优先使用city参数,如果city不可用则使用adcode参数
|
||||
*/
|
||||
async _fetchSource3_RealTime(loc) {
|
||||
let queryParam = '';
|
||||
|
||||
// 优先策略:使用city参数(region的最后部分 + district)
|
||||
if (loc.weatherCityParam) {
|
||||
queryParam = `city=${encodeURIComponent(loc.weatherCityParam)}`;
|
||||
console.log(`[Weather] 使用 City 参数查询: ${loc.weatherCityParam}`);
|
||||
} else {
|
||||
// 降级策略:使用adcode参数
|
||||
let targetAdcode = null;
|
||||
|
||||
// 策略 A: 直接使用 IP 接口返回的 area_code
|
||||
if (loc.area_code) {
|
||||
targetAdcode = loc.area_code;
|
||||
}
|
||||
// 策略 B: 使用 CSV 库反查 (名称匹配)
|
||||
else if (this.isAdcodeLoaded && loc.full_district) {
|
||||
const match = this.adcodeMap.find(item => item.name === loc.full_district);
|
||||
if (match) targetAdcode = match.adcode;
|
||||
}
|
||||
// 策略 C: 如果district匹配失败,尝试匹配市级名称
|
||||
else if (this.isAdcodeLoaded && loc.city) {
|
||||
// 尝试匹配市级(例如:"西安市")
|
||||
const cityName = loc.city + '市';
|
||||
const match = this.adcodeMap.find(item => item.name === cityName || item.name === loc.city);
|
||||
if (match) targetAdcode = match.adcode;
|
||||
}
|
||||
|
||||
if (targetAdcode) {
|
||||
queryParam = `adcode=${targetAdcode}`;
|
||||
console.log(`[Weather] 使用 Adcode 参数查询: ${targetAdcode}`);
|
||||
} else {
|
||||
// 最终降级:使用默认城市名
|
||||
const name = loc.weatherCityParam || loc.full_district || loc.city + '市' || '北京市';
|
||||
queryParam = `city=${encodeURIComponent(name)}`;
|
||||
console.log(`[Weather] 使用默认 City 参数查询: ${name}`);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// 添加完整参数:extended, indices, forecast
|
||||
const fullQueryParam = `${queryParam}&extended=true&indices=true&forecast=true`;
|
||||
const res = await fetch(`https://uapis.cn/api/v1/misc/weather?${fullQueryParam}`, { signal: AbortSignal.timeout(6000) });
|
||||
const data = await res.json();
|
||||
|
||||
// 新API返回结构: { temperature, weather, wind_direction, wind_power, humidity, report_time,
|
||||
// temp_max, temp_min, feels_like, visibility, pressure, uv, aqi,
|
||||
// precipitation, cloud, life_indices, forecast }
|
||||
if (data.weather) {
|
||||
this.cache.realtime = {
|
||||
temp: data.temperature,
|
||||
weather: data.weather,
|
||||
windDir: data.wind_direction,
|
||||
windLevel: data.wind_power,
|
||||
humidity: data.humidity, // 源3也提供湿度,作为源1的备选
|
||||
updateTime: data.report_time
|
||||
};
|
||||
|
||||
// 如果新API返回了扩展数据,也保存到details中
|
||||
if (data.feels_like !== undefined) {
|
||||
this.cache.details = {
|
||||
...this.cache.details,
|
||||
feels_like: data.feels_like,
|
||||
visibility: data.visibility,
|
||||
pressure: data.pressure,
|
||||
uv: data.uv,
|
||||
aqi: data.aqi,
|
||||
precipitation: data.precipitation,
|
||||
cloud: data.cloud
|
||||
};
|
||||
}
|
||||
|
||||
// 如果新API返回了生活指数,保存到details中
|
||||
if (data.life_indices) {
|
||||
const living = [];
|
||||
Object.keys(data.life_indices).forEach(key => {
|
||||
const index = data.life_indices[key];
|
||||
living.push({
|
||||
name: this._getLifeIndexName(key),
|
||||
index: index.level || index.brief || '',
|
||||
tips: index.advice || ''
|
||||
});
|
||||
});
|
||||
this.cache.details = {
|
||||
...this.cache.details,
|
||||
living: living
|
||||
};
|
||||
}
|
||||
|
||||
// 如果新API返回了预报数据,保存到forecast中
|
||||
if (data.forecast && data.forecast.length > 0) {
|
||||
const firstDay = data.forecast[0];
|
||||
this.cache.forecast = {
|
||||
low: firstDay.temp_min,
|
||||
high: firstDay.temp_max
|
||||
};
|
||||
} else if (data.temp_max !== undefined && data.temp_min !== undefined) {
|
||||
this.cache.forecast = {
|
||||
low: data.temp_min,
|
||||
high: data.temp_max
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[Weather] 源3 (实时) 获取失败:', e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* [步骤 1 & 2] 获取源1:生活指数与详情 (Details)
|
||||
* 策略:只保留非温度数据,如果有温度数据则丢弃
|
||||
*/
|
||||
async _fetchSource1_Details(cityQuery) {
|
||||
try {
|
||||
const res = await fetch(`https://api.suyanw.cn/api/weather.php?city=${cityQuery}&type=json`, { signal: AbortSignal.timeout(6000) });
|
||||
const json = await res.json();
|
||||
|
||||
if (json.code === 1 && json.data) {
|
||||
const d = json.data;
|
||||
const c = d.current;
|
||||
|
||||
// 提取详情 (非温度)
|
||||
this.cache.details = {
|
||||
humidity: c.humidity, // 湿度
|
||||
air: c.air, // 空气质量
|
||||
pm25: c.air_pm25, // PM2.5
|
||||
visibility: c.visibility, // 能见度
|
||||
living: d.living || [] // 生活指数
|
||||
};
|
||||
|
||||
// 提取预报中的温度范围 (源1的范围数据通常在 data.temp/tempn)
|
||||
// 也可以结合源3,但这里先用源1的范围
|
||||
this.cache.forecast = {
|
||||
low: d.tempn,
|
||||
high: d.temp // 注意:源1根节点的 temp 通常指最高温
|
||||
};
|
||||
|
||||
// [关键] 绝对不读取 c.temp (实时温度),避免覆盖源3
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[Weather] 源1 (详情) 获取失败:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// --- 渲染逻辑 ---
|
||||
|
||||
_renderLoadingState() {
|
||||
this.dom.location.textContent = '定位中';
|
||||
this.dom.temp.textContent = '--';
|
||||
this.dom.icon.innerHTML = '<div class="css-icon-cloud" style="opacity:0.5; animation:pulse 1s infinite;"></div>';
|
||||
}
|
||||
|
||||
_renderErrorState() {
|
||||
this.dom.location.textContent = '离线';
|
||||
this.dom.temp.textContent = '--';
|
||||
this.dom.icon.innerHTML = '<div class="css-icon-cloud" style="filter:grayscale(1);"></div>';
|
||||
this.dom.weather.textContent = '加载失败';
|
||||
}
|
||||
|
||||
render() {
|
||||
// 数据合并 (Merge)
|
||||
const loc = this.cache.location || { city: '未知', district: '' };
|
||||
const rt = this.cache.realtime || { temp: '--', weather: '未知', windDir: '', windLevel: '' };
|
||||
const det = this.cache.details || { humidity: '', air: '', visibility: '', living: [] };
|
||||
const fc = this.cache.forecast || { low: '--', high: '--' };
|
||||
|
||||
// 1. 胶囊显示
|
||||
// 优先显示 区/县,没有则显示 市
|
||||
const displayName = loc.district || loc.city;
|
||||
this.dom.location.textContent = displayName;
|
||||
this.dom.temp.innerHTML = `${rt.temp}<span class="weather-unit-small">°C</span>`;
|
||||
this._setCssIcon(rt.weather);
|
||||
|
||||
// 2. 下拉卡片
|
||||
this.dom.cardCity.textContent = `${loc.city} ${loc.district || ''}`;
|
||||
|
||||
// 格式化时间
|
||||
const dateObj = new Date();
|
||||
const dateStr = `${dateObj.getMonth()+1}月${dateObj.getDate()}日`;
|
||||
this.dom.date.textContent = dateStr;
|
||||
|
||||
this.dom.bigTemp.innerHTML = `${rt.temp}<span class="weather-unit-small">°C</span>`;
|
||||
this.dom.weather.textContent = rt.weather;
|
||||
this.dom.wind.textContent = `${rt.windDir} ${rt.windLevel}`;
|
||||
|
||||
// 3. 详情网格 (优先源1,源3补漏)
|
||||
const gridItems = [];
|
||||
// 湿度: 源1 > 源3
|
||||
const humidity = det.humidity || (rt.humidity ? rt.humidity + '%' : '');
|
||||
if (humidity) gridItems.push({ i: 'fa-tint', val: humidity, label: '湿度' });
|
||||
|
||||
if (det.air) gridItems.push({ i: 'fa-leaf', val: det.air, label: '空气' });
|
||||
if (det.visibility) gridItems.push({ i: 'fa-eye', val: det.visibility, label: '能见度' });
|
||||
|
||||
// 温度范围
|
||||
if (fc.low !== '--' && fc.high !== '--') {
|
||||
gridItems.push({ i: 'fa-temperature-high', val: `${fc.low}~${fc.high}°C`, label: '今日' });
|
||||
}
|
||||
|
||||
if (this.dom.gridContainer) {
|
||||
this.dom.gridContainer.innerHTML = gridItems.length ? gridItems.map(it => `
|
||||
<div class="wd-grid-item" title="${it.label}">
|
||||
<i class="fas ${it.i}"></i> <span>${it.val}</span>
|
||||
</div>
|
||||
`).join('') : '<div class="wd-hint">暂无详细数据</div>';
|
||||
}
|
||||
}
|
||||
|
||||
async showModal() {
|
||||
// [重构] 使用通用模态框框架
|
||||
const { default: modalManager } = await import('./ui/ModalManager.js');
|
||||
|
||||
// 如果模态框已打开,先关闭
|
||||
if (modalManager.isOpen('weather-widget-modal')) {
|
||||
modalManager.close('weather-widget-modal');
|
||||
return;
|
||||
}
|
||||
|
||||
// 复用 cache 数据渲染模态框
|
||||
const loc = this.cache.location || {};
|
||||
const rt = this.cache.realtime || {};
|
||||
const det = this.cache.details || {};
|
||||
const fc = this.cache.forecast || {};
|
||||
|
||||
let bgGradient = 'linear-gradient(135deg, rgba(52, 199, 89, 0.1), rgba(0, 122, 255, 0.1))';
|
||||
if ((rt.weather || '').includes('雨')) bgGradient = 'linear-gradient(135deg, rgba(99, 102, 241, 0.2), rgba(168, 85, 247, 0.2))';
|
||||
|
||||
// 构造生活指数 HTML (Bento Grid)
|
||||
let livingHtml = '';
|
||||
if (det.living && det.living.length > 0) {
|
||||
livingHtml = `
|
||||
<h3 style="font-size: 14px; margin: 20px 0 12px 0; opacity: 0.8; padding-left: 5px;">生活建议</h3>
|
||||
<div class="toolbox-bento-grid" style="grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 10px; padding: 0;">
|
||||
${det.living.map(item => `
|
||||
<div style="background: rgba(var(--bg-color-rgb), 0.5); border: 1px solid var(--border-color); border-radius: 12px; padding: 12px; display: flex; flex-direction: column;">
|
||||
<span style="font-size: 12px; opacity: 0.7; margin-bottom: 4px;">${item.name}</span>
|
||||
<span style="font-size: 13px; font-weight: 600; color: var(--primary-color);">${item.index}</span>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
const modalContent = `
|
||||
<div style="max-width: 600px;">
|
||||
<div class="island-card" style="background: ${bgGradient}; border-color: rgba(var(--primary-rgb), 0.2); height: auto; display: block; padding: 25px; margin-bottom: 20px;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: flex-start;">
|
||||
<div>
|
||||
<div style="font-size: 24px; font-weight: 800; margin-bottom: 4px;">${loc.city} ${loc.district || ''}</div>
|
||||
<div style="font-size: 12px; opacity: 0.6; font-family: monospace;">商业级数据源校准</div>
|
||||
</div>
|
||||
<div style="font-size: 48px; opacity: 0.9;"><i class="fas ${this._getIconClass(rt.weather)}"></i></div>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; align-items: baseline; margin-top: 15px;">
|
||||
<span style="font-size: 64px; font-weight: 700; line-height: 1;">${rt.temp}</span>
|
||||
<span style="font-size: 24px; font-weight: 600; margin-left: 5px;">°C</span>
|
||||
<div style="margin-left: 20px;">
|
||||
<div style="font-size: 20px; font-weight: 600;">${rt.weather}</div>
|
||||
<div style="font-size: 13px; opacity: 0.8; margin-top: 4px;">${fc.low} ~ ${fc.high}°C</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; flex-wrap: wrap; gap: 8px; margin-top: 25px;">
|
||||
<div class="weather-stat-pill"><i class="fas fa-wind"></i> ${rt.windDir} ${rt.windLevel}</div>
|
||||
${det.humidity ? `<div class="weather-stat-pill"><i class="fas fa-tint"></i> 湿度 ${det.humidity}</div>` : ''}
|
||||
${det.air ? `<div class="weather-stat-pill"><i class="fas fa-leaf"></i> 空气 ${det.air}</div>` : ''}
|
||||
${det.visibility ? `<div class="weather-stat-pill"><i class="fas fa-eye"></i> 能见度 ${det.visibility}</div>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${livingHtml}
|
||||
</div>
|
||||
`;
|
||||
|
||||
// 使用通用模态框框架创建模态框
|
||||
modalManager.create({
|
||||
id: 'weather-widget-modal',
|
||||
title: '<i class="fas fa-satellite-dish"></i> 实时气象站',
|
||||
content: modalContent,
|
||||
size: 'medium',
|
||||
closable: true,
|
||||
closeOnBackdrop: true,
|
||||
className: 'weather-widget-modal-wrapper'
|
||||
});
|
||||
}
|
||||
|
||||
_setCssIcon(weatherText) {
|
||||
const w = (weatherText || '').toString();
|
||||
let iconHtml = '<div class="css-icon-cloud"></div>';
|
||||
if (w.includes('晴')) iconHtml = '<div class="css-icon-sun"></div>';
|
||||
else if (w.includes('雨')) iconHtml = '<div class="css-icon-rain"></div>';
|
||||
else if (w.includes('雪')) iconHtml = '<div class="css-icon-cloud" style="filter: brightness(1.5);"></div>';
|
||||
this.dom.icon.innerHTML = iconHtml;
|
||||
}
|
||||
|
||||
_getIconClass(text) {
|
||||
if (!text) return 'fa-cloud';
|
||||
const t = text.toString();
|
||||
if (t.includes('晴')) return 'fa-sun';
|
||||
if (t.includes('多云') || t.includes('阴')) return 'fa-cloud-sun';
|
||||
if (t.includes('雨')) return 'fa-cloud-showers-heavy';
|
||||
if (t.includes('雪')) return 'fa-snowflake';
|
||||
if (t.includes('雷')) return 'fa-bolt';
|
||||
return 'fa-cloud';
|
||||
}
|
||||
|
||||
_getLifeIndexName(key) {
|
||||
const nameMap = {
|
||||
'clothing': '穿衣',
|
||||
'uv': '紫外线',
|
||||
'car_wash': '洗车',
|
||||
'drying': '晾晒',
|
||||
'air_conditioner': '空调',
|
||||
'cold_risk': '感冒',
|
||||
'exercise': '运动',
|
||||
'comfort': '舒适度'
|
||||
};
|
||||
return nameMap[key] || key;
|
||||
}
|
||||
}
|
||||
|
||||
export default new WeatherWidget();
|
||||
@@ -0,0 +1,173 @@
|
||||
// src/js/workers/compressionWorker.js
|
||||
const { parentPort } = require('worker_threads');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
const archiver = require('archiver');
|
||||
const AdmZip = require('adm-zip');
|
||||
|
||||
// 递归获取目录大小
|
||||
const getDirectorySize = (dirPath) => {
|
||||
let size = 0;
|
||||
try {
|
||||
const files = fs.readdirSync(dirPath);
|
||||
for (const file of files) {
|
||||
const filePath = path.join(dirPath, file);
|
||||
const stats = fs.statSync(filePath);
|
||||
if (stats.isDirectory()) size += getDirectorySize(filePath);
|
||||
else size += stats.size;
|
||||
}
|
||||
} catch(e) {}
|
||||
return size;
|
||||
};
|
||||
|
||||
parentPort.on('message', async (msg) => {
|
||||
try {
|
||||
if (msg.type === 'compress') await handleEncryption(msg.data);
|
||||
else if (msg.type === 'decompress') await handleDecryption(msg.data);
|
||||
} catch (error) {
|
||||
parentPort.postMessage({ status: 'error', error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// --- 加密压缩逻辑 (AES-256-CBC + 独立密钥文件) ---
|
||||
async function handleEncryption({ files, output }) {
|
||||
// 1. 生成随机密钥 (32字节 Key + 16字节 IV)
|
||||
parentPort.postMessage({ status: 'log', message: '正在生成高强度随机密钥...' });
|
||||
const key = crypto.randomBytes(32);
|
||||
const iv = crypto.randomBytes(16);
|
||||
|
||||
// 2. 准备输出 .ymenc 文件
|
||||
const outputStream = fs.createWriteStream(output);
|
||||
const cipher = crypto.createCipheriv('aes-256-cbc', key, iv);
|
||||
|
||||
// 3. 准备密钥文件 .ymkey (与 output 同目录)
|
||||
// 格式:[YMHUT_KEY_HEADER (8 bytes)] + [IV (16 bytes)] + [Key (32 bytes)]
|
||||
// 这是一个简单的二进制混淆格式,非文本明文
|
||||
const keyPath = output.replace(/\.ymenc$/, '.ymkey');
|
||||
const keyHeader = Buffer.from('YMKEY001'); // 8字节头
|
||||
const keyBuffer = Buffer.concat([keyHeader, iv, key]);
|
||||
|
||||
fs.writeFileSync(keyPath, keyBuffer);
|
||||
parentPort.postMessage({ status: 'log', message: `密钥文件已生成: ${path.basename(keyPath)}` });
|
||||
|
||||
// 4. 创建压缩流
|
||||
const archive = archiver('zip', { zlib: { level: 9 } });
|
||||
|
||||
let totalBytes = 0;
|
||||
files.forEach(f => {
|
||||
try {
|
||||
const stat = fs.statSync(f);
|
||||
if (stat.isDirectory()) totalBytes += getDirectorySize(f);
|
||||
else totalBytes += stat.size;
|
||||
} catch(e) {}
|
||||
});
|
||||
|
||||
archive.on('progress', (progress) => {
|
||||
const percent = totalBytes > 0 ? (progress.fs.processedBytes / totalBytes) * 100 : 0;
|
||||
parentPort.postMessage({ status: 'progress', percent: percent.toFixed(1) });
|
||||
});
|
||||
|
||||
archive.on('warning', (err) => parentPort.postMessage({ status: 'log', message: `[警告] ${err.message}` }));
|
||||
archive.on('error', (err) => { throw err; });
|
||||
|
||||
// 5. 管道连接: archiver -> cipher -> outputStream
|
||||
// .ymenc 文件内容全是密文,没有头部信息,必须依赖 .ymkey 解密
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
outputStream.on('close', () => {
|
||||
parentPort.postMessage({ status: 'complete', path: output, keyPath: keyPath });
|
||||
resolve();
|
||||
});
|
||||
|
||||
outputStream.on('error', reject);
|
||||
archive.on('error', reject);
|
||||
cipher.on('error', reject);
|
||||
|
||||
archive.pipe(cipher).pipe(outputStream);
|
||||
|
||||
parentPort.postMessage({ status: 'log', message: '正在打包并加密数据流...' });
|
||||
|
||||
files.forEach(filePath => {
|
||||
const stat = fs.statSync(filePath);
|
||||
const filename = path.basename(filePath);
|
||||
if (stat.isDirectory()) {
|
||||
archive.directory(filePath, filename);
|
||||
} else {
|
||||
archive.file(filePath, { name: filename });
|
||||
}
|
||||
});
|
||||
|
||||
archive.finalize();
|
||||
});
|
||||
}
|
||||
|
||||
// --- 解密还原逻辑 ---
|
||||
async function handleDecryption({ filePath, keyPath, targetDir }) {
|
||||
parentPort.postMessage({ status: 'log', message: '正在读取密钥文件...' });
|
||||
|
||||
// 1. 验证并读取密钥
|
||||
let keyData;
|
||||
try {
|
||||
keyData = fs.readFileSync(keyPath);
|
||||
} catch (e) {
|
||||
throw new Error("无法读取密钥文件");
|
||||
}
|
||||
|
||||
if (keyData.length !== 56) { // 8 + 16 + 32 = 56
|
||||
throw new Error("无效的密钥文件格式");
|
||||
}
|
||||
|
||||
const header = keyData.subarray(0, 8);
|
||||
if (header.toString() !== 'YMKEY001') {
|
||||
throw new Error("密钥文件版本不匹配或已损坏");
|
||||
}
|
||||
|
||||
const iv = keyData.subarray(8, 24);
|
||||
const key = keyData.subarray(24, 56);
|
||||
|
||||
parentPort.postMessage({ status: 'log', message: '密钥验证通过,准备解密...' });
|
||||
|
||||
// 2. 创建解密流
|
||||
const decipher = crypto.createDecipheriv('aes-256-cbc', key, iv);
|
||||
const inputStream = fs.createReadStream(filePath);
|
||||
|
||||
// 3. 解密并解压
|
||||
// 为了处理 ZIP 解压,我们需要先将解密后的数据流还原为完整的 Buffer
|
||||
// 注意:这限制了解密文件的大小不能超过 Node.js 的最大 Buffer 限制 (约2GB)
|
||||
|
||||
const chunks = [];
|
||||
let decryptedLength = 0;
|
||||
const fileStat = fs.statSync(filePath);
|
||||
const totalSize = fileStat.size;
|
||||
|
||||
inputStream.pipe(decipher);
|
||||
|
||||
decipher.on('data', (chunk) => {
|
||||
chunks.push(chunk);
|
||||
decryptedLength += chunk.length;
|
||||
const percent = (decryptedLength / totalSize) * 100;
|
||||
parentPort.postMessage({ status: 'progress', percent: percent.toFixed(1) });
|
||||
});
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
decipher.on('end', () => {
|
||||
parentPort.postMessage({ status: 'log', message: '解密完成,正在展开 ZIP 包...' });
|
||||
try {
|
||||
const fullBuffer = Buffer.concat(chunks);
|
||||
const zip = new AdmZip(fullBuffer);
|
||||
zip.extractAllTo(targetDir, true);
|
||||
parentPort.postMessage({ status: 'complete', path: targetDir });
|
||||
resolve();
|
||||
} catch (e) {
|
||||
reject(new Error("解压失败:文件可能已损坏或密钥不匹配"));
|
||||
}
|
||||
});
|
||||
|
||||
decipher.on('error', (err) => {
|
||||
reject(new Error("解密流错误:密钥可能不匹配"));
|
||||
});
|
||||
|
||||
inputStream.on('error', reject);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
<!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 {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background-color: transparent;
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
}
|
||||
|
||||
body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
/* 使用 Flexbox 垂直布局 */
|
||||
#app-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.title-bar-shell {
|
||||
height: 40px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0 10px;
|
||||
-webkit-app-region: drag;
|
||||
color: var(--text-secondary);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.title-bar-shell .title {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
#view-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
#webview-container {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
webview {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: none;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
.title-bar-shell .title {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
#view-controls {
|
||||
padding: 6px 10px;
|
||||
}
|
||||
|
||||
#view-controls button {
|
||||
padding: 4px;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.title-bar-shell {
|
||||
height: 35px;
|
||||
}
|
||||
|
||||
.title-bar-shell .title {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
#view-controls {
|
||||
padding: 5px 8px;
|
||||
}
|
||||
|
||||
#view-controls button {
|
||||
padding: 3px;
|
||||
font-size: 10px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body id="app-body">
|
||||
<div id="app-wrapper">
|
||||
<div class="title-bar-shell">
|
||||
<span class="title"><i class="fas fa-archive"></i> 神秘档案馆</span>
|
||||
<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="far fa-square"></i>
|
||||
</button>
|
||||
<button id="close-btn" class="window-control-btn" title="关闭">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="view-controls">
|
||||
<button id="back-btn" class="control-btn mini-btn ripple" title="后退" disabled><i class="fas fa-arrow-left"></i></button>
|
||||
<button id="forward-btn" class="control-btn mini-btn ripple" title="前进" disabled><i class="fas fa-arrow-right"></i></button>
|
||||
<button id="reload-btn" class="control-btn mini-btn ripple" title="刷新"><i class="fas fa-sync-alt"></i></button>
|
||||
</div>
|
||||
<div id="webview-container">
|
||||
<webview id="secret-webview" src="https://archive.cdtsf.com"></webview>
|
||||
</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());
|
||||
document.getElementById('minimize-btn').addEventListener('click', () => window.electronAPI.secretWindowMinimize());
|
||||
document.getElementById('maximize-btn').addEventListener('click', () => window.electronAPI.secretWindowMaximize());
|
||||
|
||||
// Webview 控制
|
||||
const webview = document.getElementById('secret-webview');
|
||||
const backBtn = document.getElementById('back-btn');
|
||||
const forwardBtn = document.getElementById('forward-btn');
|
||||
const reloadBtn = document.getElementById('reload-btn');
|
||||
|
||||
const updateNavButtons = () => {
|
||||
backBtn.disabled = !webview.canGoBack();
|
||||
forwardBtn.disabled = !webview.canGoForward();
|
||||
};
|
||||
|
||||
webview.addEventListener('dom-ready', updateNavButtons);
|
||||
webview.addEventListener('did-navigate', updateNavButtons);
|
||||
webview.addEventListener('did-navigate-in-page', updateNavButtons);
|
||||
|
||||
backBtn.addEventListener('click', () => webview.goBack());
|
||||
forwardBtn.addEventListener('click', () => webview.goForward());
|
||||
reloadBtn.addEventListener('click', () => webview.reload());
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,44 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>YMhut Box Launching</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">
|
||||
</head>
|
||||
<body>
|
||||
<script>
|
||||
try {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const theme = params.get('theme') || 'dark';
|
||||
document.body.setAttribute('data-theme', theme);
|
||||
} catch (e) {
|
||||
document.body.setAttribute('data-theme', 'dark');
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="splash-pill">
|
||||
<div class="pill-logo">
|
||||
<div class="logo-rotate-ring"></div>
|
||||
<i class="fas fa-cube logo-icon"></i>
|
||||
</div>
|
||||
|
||||
<div class="pill-content">
|
||||
<div class="pill-header">
|
||||
<span class="app-name">YMhut Box</span>
|
||||
<span class="progress-percent" id="splash-percent">0%</span>
|
||||
</div>
|
||||
|
||||
<div class="progress-track">
|
||||
<div class="progress-bar" id="splash-progress-bar">
|
||||
<div class="progress-light"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="status-text" id="splash-status">系统自检初始化...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module" src="./js/splash.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,10 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>软件更新</title>
|
||||
</head>
|
||||
<body>
|
||||
<h2>软件更新</h2>
|
||||
<p>此窗口用于独立的更新流程。新的更新检查和下载进度已集成到主界面的设置页面中。</p>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,484 @@
|
||||
<!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, #app-body {
|
||||
margin: 0; padding: 0; height: 100%; overflow: hidden; background-color: transparent;
|
||||
}
|
||||
#app-wrapper {
|
||||
display: flex; flex-direction: column; flex: 1; height: 100vh; border-radius: 12px; overflow: hidden; background-color: rgba(var(--bg-color-rgb), 0.95); /* 给窗口一个底色 */
|
||||
}
|
||||
.title-bar-shell {
|
||||
height: 35px; /* 减少高度 */ display: flex; justify-content: space-between; align-items: center; padding: 0 5px 0 10px; -webkit-app-region: drag; color: var(--text-secondary); border-bottom: 1px solid var(--border-color); flex-shrink: 0;
|
||||
}
|
||||
.title-bar-shell .window-controls { height: 100%; } /* 确保按钮垂直居中 */
|
||||
|
||||
/* --- 多标签页样式 --- */
|
||||
#tab-bar {
|
||||
display: flex; align-items: center; height: 40px; background-color: rgba(var(--card-background-rgb), 0.6); padding: 0 5px; flex-shrink: 0; overflow-x: auto; /* 允许标签栏横向滚动 */ -webkit-app-region: drag; /* 标签栏区域也可拖动 */
|
||||
}
|
||||
#tab-bar::-webkit-scrollbar { height: 3px; }
|
||||
#tab-bar::-webkit-scrollbar-thumb { background-color: var(--border-color); border-radius: 1.5px; }
|
||||
|
||||
.tab {
|
||||
display: flex; align-items: center; padding: 0 10px; height: 32px; border-radius: 8px; margin-right: 5px; background-color: transparent; border: 1px solid transparent; cursor: pointer; flex-shrink: 0; max-width: 180px; /* 限制标签最大宽度 */ -webkit-app-region: no-drag; /* 标签本身不可拖动 */ transition: background-color 0.2s ease, border-color 0.2s ease;
|
||||
}
|
||||
.tab:hover { background-color: rgba(var(--card-background-rgb), 0.8); }
|
||||
.tab.active { background-color: rgba(var(--card-background-rgb), 1); border-color: var(--border-color); }
|
||||
|
||||
.tab-icon { width: 16px; height: 16px; margin-right: 8px; object-fit: contain; flex-shrink: 0; /* 防止图标被压缩 */ }
|
||||
.tab-title { font-size: 13px; color: var(--text-color); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; flex-grow: 1; }
|
||||
.tab-close-btn { margin-left: 8px; font-size: 12px; color: var(--text-secondary); border: none; background: none; cursor: pointer; padding: 4px; border-radius: 4px; line-height: 1; flex-shrink: 0; /* 防止关闭按钮被压缩 */ }
|
||||
.tab-close-btn:hover { background-color: var(--border-color); color: var(--error-color); }
|
||||
#add-tab-btn { font-size: 16px; color: var(--text-secondary); border: none; background: none; cursor: pointer; padding: 5px 8px; border-radius: 4px; margin-left: 5px; -webkit-app-region: no-drag; }
|
||||
#add-tab-btn:hover { background-color: var(--border-color); color: var(--primary-color); }
|
||||
/* --- 结束:多标签页样式 --- */
|
||||
|
||||
#loading-progress { /* 移动加载条到导航栏 */
|
||||
position: absolute; top: 0; left: 0; width: 100%; height: 3px; appearance: none; border: none; background: none; display: none;
|
||||
}
|
||||
#loading-progress::-webkit-progress-bar { background: none; }
|
||||
#loading-progress::-webkit-progress-value { background-color: var(--primary-color); transition: width 0.1s linear; }
|
||||
|
||||
#view-controls { /* 导航栏 */
|
||||
position: relative; /* 为加载条定位 */ display: flex; align-items: center; gap: 8px; padding: 6px 10px; border-bottom: 1px solid var(--border-color); flex-shrink: 0; background-color: rgba(var(--bg-color-rgb), 0.8);
|
||||
}
|
||||
#address-bar { /* 地址栏 */
|
||||
flex-grow: 1; padding: 5px 10px; font-size: 13px; border: 1px solid var(--border-color); border-radius: 6px; background-color: rgba(var(--card-background-rgb), 0.7); color: var(--text-color);
|
||||
}
|
||||
#address-bar:focus { outline: none; border-color: var(--primary-color); box-shadow: 0 0 0 1px rgba(var(--primary-rgb), 0.3); }
|
||||
|
||||
#webview-container { flex: 1; min-height: 0; overflow: hidden; background-color: #ffffff; }
|
||||
webview { width: 100%; height: 100%; border: none; display: none; /* 默认隐藏 */ }
|
||||
webview.active { display: inline-flex !important; /* 激活的显示 */ }
|
||||
</style>
|
||||
</head>
|
||||
<body id="app-body" class="browser-window-body">
|
||||
<div id="app-wrapper">
|
||||
<div class="title-bar-shell">
|
||||
<span id="window-title" style="margin-left: 12px; font-weight: 600; -webkit-app-region: drag; flex-grow: 1;">安全浏览器</span>
|
||||
<div class="window-controls">
|
||||
<button id="minimize-btn" class="window-control-btn mini-btn" title="最小化"><i class="fas fa-window-minimize"></i></button>
|
||||
<button id="maximize-btn" class="window-control-btn mini-btn" title="最大化/还原"><i class="far fa-square"></i></button>
|
||||
<button id="close-btn" class="window-control-btn mini-btn" title="关闭"><i class="fas fa-times"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="tab-bar">
|
||||
<button id="add-tab-btn" title="新建标签页"><i class="fas fa-plus"></i></button>
|
||||
</div>
|
||||
|
||||
<div id="view-controls">
|
||||
<progress id="loading-progress" value="0" max="100"></progress>
|
||||
<button id="back-btn" class="control-btn mini-btn ripple" title="后退" disabled><i class="fas fa-arrow-left"></i></button>
|
||||
<button id="forward-btn" class="control-btn mini-btn ripple" title="前进" disabled><i class="fas fa-arrow-right"></i></button>
|
||||
<button id="reload-btn" class="control-btn mini-btn ripple" title="刷新"><i class="fas fa-sync-alt"></i></button>
|
||||
<button id="home-btn" class="control-btn mini-btn ripple" title="主页"><i class="fas fa-home"></i></button>
|
||||
<input type="text" id="address-bar" placeholder="输入 URL 或搜索内容">
|
||||
</div>
|
||||
|
||||
<div id="webview-container">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const theme = params.get('theme') || 'dark';
|
||||
const initialToolId = params.get('tool'); // 'archive' or 'secure-browser' etc.
|
||||
document.body.setAttribute('data-theme', theme);
|
||||
|
||||
// --- 默认 URL 定义 (使用 Bing) ---
|
||||
const HOME_URL = 'https://www.bing.com';
|
||||
const ARCHIVE_URL = 'https://archive.cdtsf.com';
|
||||
const initialURL = (initialToolId === 'archive') ? ARCHIVE_URL : HOME_URL;
|
||||
|
||||
// --- DOM 元素 ---
|
||||
const tabBar = document.getElementById('tab-bar');
|
||||
const addTabBtn = document.getElementById('add-tab-btn');
|
||||
const webviewContainer = document.getElementById('webview-container');
|
||||
const backBtn = document.getElementById('back-btn');
|
||||
const forwardBtn = document.getElementById('forward-btn');
|
||||
const reloadBtn = document.getElementById('reload-btn');
|
||||
const homeBtn = document.getElementById('home-btn');
|
||||
const addressBar = document.getElementById('address-bar');
|
||||
const loadingBar = document.getElementById('loading-progress');
|
||||
|
||||
let tabs = [];
|
||||
let activeTabId = null;
|
||||
let tabCounter = 0;
|
||||
|
||||
// --- 核心函数 ---
|
||||
|
||||
// 创建新标签页
|
||||
function createTab(urlToLoad = HOME_URL, activate = true) {
|
||||
tabCounter++;
|
||||
const tabId = `tab-${tabCounter}`;
|
||||
|
||||
// 1. 创建 Tab UI
|
||||
const tabElement = document.createElement('div');
|
||||
tabElement.className = 'tab';
|
||||
tabElement.dataset.tabId = tabId;
|
||||
tabElement.innerHTML = `
|
||||
<img class="tab-icon" src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=" alt=""> <span class="tab-title">新标签页</span> <button class="tab-close-btn"><i class="fas fa-times"></i></button>
|
||||
`;
|
||||
tabBar.insertBefore(tabElement, addTabBtn);
|
||||
|
||||
// 2. 创建 Webview
|
||||
const webviewElement = document.createElement('webview');
|
||||
webviewElement.dataset.tabId = tabId;
|
||||
webviewElement.setAttribute('allowpopups', ''); // 允许弹出窗口 (可选)
|
||||
webviewElement.setAttribute('src', urlToLoad); // 使用 src 加载初始 URL
|
||||
|
||||
// 3. 存储 Tab 信息
|
||||
const newTab = {
|
||||
id: tabId,
|
||||
element: tabElement,
|
||||
webview: webviewElement,
|
||||
title: '新标签页',
|
||||
url: urlToLoad,
|
||||
isLoading: true,
|
||||
canGoBack: false,
|
||||
canGoForward: false,
|
||||
favicon: null
|
||||
};
|
||||
tabs.push(newTab);
|
||||
|
||||
// 4. 绑定 Tab UI 事件
|
||||
tabElement.addEventListener('click', () => switchTab(tabId));
|
||||
tabElement.querySelector('.tab-close-btn').addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
closeTab(tabId);
|
||||
});
|
||||
|
||||
// 5. 附加 Webview 到 DOM
|
||||
webviewContainer.appendChild(webviewElement);
|
||||
|
||||
// 6. 附加 Webview 的事件监听器
|
||||
attachWebviewListeners(newTab);
|
||||
|
||||
if (activate) {
|
||||
switchTab(tabId);
|
||||
}
|
||||
return newTab;
|
||||
}
|
||||
|
||||
// 切换标签页
|
||||
function switchTab(tabId) {
|
||||
if (activeTabId === tabId) return;
|
||||
|
||||
const previousActiveTab = tabs.find(t => t.id === activeTabId);
|
||||
// ... (暂停/恢复逻辑可以保留) ...
|
||||
|
||||
activeTabId = tabId;
|
||||
tabs.forEach(tab => {
|
||||
const isActive = tab.id === tabId;
|
||||
tab.element.classList.toggle('active', isActive);
|
||||
if (tab.webview) { // 检查 webview 是否存在
|
||||
tab.webview.classList.toggle('active', isActive);
|
||||
}
|
||||
if (isActive) {
|
||||
updateControlsForTab(tab);
|
||||
}
|
||||
});
|
||||
// 将激活的 tab 滚动到视图中
|
||||
const activeTabElement = document.querySelector(`.tab[data-tab-id="${tabId}"]`);
|
||||
if(activeTabElement) {
|
||||
activeTabElement.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'nearest' });
|
||||
}
|
||||
}
|
||||
|
||||
// 关闭标签页
|
||||
function closeTab(tabId) {
|
||||
const tabIndex = tabs.findIndex(tab => tab.id === tabId);
|
||||
if (tabIndex === -1) return;
|
||||
|
||||
const tabToClose = tabs[tabIndex];
|
||||
tabToClose.element.remove();
|
||||
if (tabToClose.webview) {
|
||||
try {
|
||||
if (tabToClose.webview && typeof tabToClose.webview.stop === 'function') {
|
||||
tabToClose.webview.stop();
|
||||
}
|
||||
} catch (e) { console.warn("Error stopping webview before removal:", e.message); }
|
||||
tabToClose.webview.remove();
|
||||
}
|
||||
tabs.splice(tabIndex, 1);
|
||||
|
||||
if (activeTabId === tabId) {
|
||||
activeTabId = null;
|
||||
const nextActiveIndex = Math.max(0, tabIndex - 1);
|
||||
if (tabs.length > 0) {
|
||||
switchTab(tabs[nextActiveIndex].id);
|
||||
} else {
|
||||
createTab(HOME_URL, true); // 改为新建标签页
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 为 Webview 附加事件监听器
|
||||
function attachWebviewListeners(tab) {
|
||||
const webview = tab.webview;
|
||||
|
||||
// 捕获进程启动相关的错误
|
||||
webview.addEventListener('did-start-loading', () => {
|
||||
tab.isLoading = true;
|
||||
updateTabTitle(tab, '正在加载...'); // Use helper
|
||||
tab.element.querySelector('.tab-icon').src = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=';
|
||||
if (tab.id === activeTabId) {
|
||||
loadingBar.style.display = 'block';
|
||||
loadingBar.value = 10; // Start progress
|
||||
reloadBtn.innerHTML = '<i class="fas fa-times"></i>';
|
||||
}
|
||||
});
|
||||
|
||||
webview.addEventListener('did-stop-loading', () => {
|
||||
tab.isLoading = false;
|
||||
// **重要**: 在 stop-loading 时更新导航状态最可靠
|
||||
tab.canGoBack = webview.canGoBack();
|
||||
tab.canGoForward = webview.canGoForward();
|
||||
|
||||
if (tab.id === activeTabId) {
|
||||
loadingBar.style.display = 'none';
|
||||
loadingBar.value = 100;
|
||||
reloadBtn.innerHTML = '<i class="fas fa-sync-alt"></i>';
|
||||
updateControlsForTab(tab); // Update address bar and buttons
|
||||
}
|
||||
// 更新标题 (如果仍然是"正在加载...")
|
||||
const currentTitle = tab.element.querySelector('.tab-title').textContent;
|
||||
if (currentTitle === '正在加载...' || currentTitle === '新标签页') {
|
||||
const pageTitle = webview.getTitle(); // 获取实际标题
|
||||
updateTabTitle(tab, pageTitle || tab.url || '加载完成'); // Use helper with fallback
|
||||
}
|
||||
});
|
||||
|
||||
webview.addEventListener('did-fail-load', (e) => {
|
||||
if (e.errorCode === -3 /* ABORTED */ ) return; // Ignore deliberate stops
|
||||
console.error(`Tab ${tab.id} did-fail-load:`, e.validatedURL, e.errorCode, e.errorDescription);
|
||||
handleWebviewCrash(tab, `加载失败 (${e.errorCode})`);
|
||||
});
|
||||
|
||||
webview.addEventListener('dom-ready', () => {
|
||||
if (theme === 'dark') {
|
||||
tryInjectDarkModeCSS(webview);
|
||||
}
|
||||
// 更新导航状态 (作为备用)
|
||||
try { // Add try-catch as canGoBack/Forward might fail if webview crashed before dom-ready
|
||||
tab.canGoBack = webview.canGoBack();
|
||||
tab.canGoForward = webview.canGoForward();
|
||||
if (tab.id === activeTabId) updateNavButtonsState(tab);
|
||||
} catch (err) {
|
||||
console.warn(`Error updating nav state on dom-ready for ${tab.id}:`, err.message);
|
||||
}
|
||||
});
|
||||
|
||||
webview.addEventListener('did-navigate', (e) => {
|
||||
tab.url = e.url;
|
||||
// did-stop-loading 会处理按钮状态
|
||||
if (tab.id === activeTabId) addressBar.value = tab.url;
|
||||
});
|
||||
|
||||
webview.addEventListener('did-navigate-in-page', (e) => {
|
||||
tab.url = e.url;
|
||||
if (tab.id === activeTabId) addressBar.value = tab.url;
|
||||
// 页内导航后也可能需要更新前进后退状态
|
||||
try { // Add try-catch
|
||||
tab.canGoBack = webview.canGoBack();
|
||||
tab.canGoForward = webview.canGoForward();
|
||||
if (tab.id === activeTabId) updateNavButtonsState(tab);
|
||||
} catch (err) {
|
||||
console.warn(`Error updating nav state on navigate-in-page for ${tab.id}:`, err.message);
|
||||
}
|
||||
});
|
||||
|
||||
webview.addEventListener('page-title-updated', (e) => {
|
||||
updateTabTitle(tab, e.title); // Use helper
|
||||
});
|
||||
|
||||
webview.addEventListener('page-favicon-updated', (e) => {
|
||||
if (e.favicons && e.favicons.length > 0) {
|
||||
const bestIcon = e.favicons.sort((a,b) => {
|
||||
const sizeA = parseInt(a.match(/(\d+)x\d+/)?.[1] || '0');
|
||||
const sizeB = parseInt(b.match(/(\d+)x\d+/)?.[1] || '0');
|
||||
return sizeB - sizeA;
|
||||
})[0] || e.favicons[0];
|
||||
tab.favicon = bestIcon;
|
||||
tab.element.querySelector('.tab-icon').src = tab.favicon;
|
||||
}
|
||||
});
|
||||
|
||||
// 处理新窗口请求 (在浏览器内部打开新标签页)
|
||||
webview.addEventListener('new-window', (e) => {
|
||||
e.preventDefault();
|
||||
console.log("New window requested:", e.url, "Disposition:", e.disposition);
|
||||
|
||||
if (e.disposition === 'new-window' || e.disposition === 'foreground-tab') {
|
||||
createTab(e.url, true); // 在前台新标签页中打开
|
||||
} else if (e.disposition === 'background-tab') {
|
||||
createTab(e.url, false); // 在后台新标签页中打开
|
||||
} else {
|
||||
console.warn("Unhandled new-window disposition:", e.disposition, e.url);
|
||||
// 默认在当前标签页加载
|
||||
performWebviewAction(wv => wv.loadURL(e.url));
|
||||
}
|
||||
});
|
||||
|
||||
// --- 崩溃处理 ---
|
||||
const crashHandler = (reason) => () => handleWebviewCrash(tab, reason);
|
||||
webview.addEventListener('crashed', crashHandler('页面崩溃'));
|
||||
webview.addEventListener('gpu-crashed', crashHandler('GPU 进程崩溃'));
|
||||
webview.addEventListener('plugin-crashed', crashHandler('插件崩溃'));
|
||||
webview.addEventListener('destroyed', () => {
|
||||
console.log(`Webview ${tab.id} destroyed.`);
|
||||
const existingTab = tabs.find(t => t.id === tab.id);
|
||||
if (existingTab) {
|
||||
existingTab.webview = null;
|
||||
if (tab.id === activeTabId) {
|
||||
updateControlsForTab(null);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 更新 Tab 标题的辅助函数
|
||||
function updateTabTitle(tab, newTitle) {
|
||||
const displayTitle = (typeof newTitle === 'string' && newTitle.trim()) ? newTitle.trim() : (tab.url || '无标题');
|
||||
tab.title = displayTitle;
|
||||
const titleElement = tab.element.querySelector('.tab-title');
|
||||
if (titleElement) titleElement.textContent = displayTitle;
|
||||
|
||||
// 同时更新窗口标题栏
|
||||
if (tab.id === activeTabId) {
|
||||
document.title = displayTitle;
|
||||
document.getElementById('window-title').textContent = displayTitle; // 更新标题栏
|
||||
}
|
||||
}
|
||||
|
||||
// 更新导航控件状态
|
||||
function updateControlsForTab(tab) {
|
||||
if (!tab) { // Handle case where active tab might be gone or webview destroyed
|
||||
addressBar.value = '';
|
||||
document.title = '安全浏览器';
|
||||
document.getElementById('window-title').textContent = '安全浏览器'; // 重置标题栏
|
||||
backBtn.disabled = true;
|
||||
forwardBtn.disabled = true;
|
||||
reloadBtn.innerHTML = '<i class="fas fa-sync-alt"></i>';
|
||||
loadingBar.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
addressBar.value = tab.url || '';
|
||||
document.title = tab.title || '浏览器';
|
||||
document.getElementById('window-title').textContent = tab.title || '浏览器'; // 更新标题栏
|
||||
updateNavButtonsState(tab);
|
||||
|
||||
if (tab.webview && tab.isLoading) {
|
||||
loadingBar.style.display = 'block';
|
||||
reloadBtn.innerHTML = '<i class="fas fa-times"></i>'; // Stop button
|
||||
} else {
|
||||
loadingBar.style.display = 'none';
|
||||
reloadBtn.innerHTML = '<i class="fas fa-sync-alt"></i>'; // Refresh button
|
||||
}
|
||||
}
|
||||
|
||||
// 更新前进/后退按钮状态
|
||||
function updateNavButtonsState(tab) {
|
||||
backBtn.disabled = !tab || !tab.webview || !tab.canGoBack;
|
||||
forwardBtn.disabled = !tab || !tab.webview || !tab.canGoForward;
|
||||
}
|
||||
|
||||
// 处理 Webview 崩溃
|
||||
function handleWebviewCrash(tab, reason = '未知错误') {
|
||||
console.error(`Tab ${tab.id} webview crashed or failed: ${reason}`);
|
||||
tab.isLoading = false;
|
||||
updateTabTitle(tab, `⚠ ${reason}`);
|
||||
tab.element.querySelector('.tab-icon').src = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=';
|
||||
tab.webview = null;
|
||||
if (tab.id === activeTabId) {
|
||||
updateControlsForTab(tab);
|
||||
}
|
||||
tab.element.style.opacity = '0.7';
|
||||
tab.element.style.borderLeft = '3px solid var(--error-color)';
|
||||
}
|
||||
|
||||
// 尝试注入暗黑模式 CSS
|
||||
function tryInjectDarkModeCSS(webview) {
|
||||
if (!webview || typeof webview.insertCSS !== 'function') return;
|
||||
const darkModeCSS = `
|
||||
html { filter: invert(1) hue-rotate(180deg); background-color: #1a1a1a !important; }
|
||||
img, video, iframe, canvas, svg, [style*="background-image"], input[type="image"] { filter: invert(1) hue-rotate(180deg); }
|
||||
`;
|
||||
try {
|
||||
webview.insertCSS(darkModeCSS).catch(err => console.warn("Failed to insert CSS:", err.message));
|
||||
} catch (error) {
|
||||
// console.warn('Failed to inject dark mode CSS:', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// --- 事件绑定 ---
|
||||
|
||||
// 添加新标签页按钮
|
||||
addTabBtn.addEventListener('click', () => createTab(HOME_URL, true));
|
||||
|
||||
// --- 统一导航/操作函数 ---
|
||||
function performWebviewAction(action) {
|
||||
const activeTab = tabs.find(tab => tab.id === activeTabId);
|
||||
if (activeTab && activeTab.webview) {
|
||||
try {
|
||||
action(activeTab.webview);
|
||||
} catch (error) {
|
||||
console.error(`Error performing action on webview ${activeTab.id}:`, error.message);
|
||||
handleWebviewCrash(activeTab, `操作失败: ${error.message}`);
|
||||
}
|
||||
} else if (activeTab && !activeTab.webview) {
|
||||
console.warn(`Action ignored: Webview for active tab ${activeTab.id} is already destroyed or invalid.`);
|
||||
handleWebviewCrash(activeTab, "页面已失效");
|
||||
}
|
||||
}
|
||||
|
||||
// 导航按钮
|
||||
backBtn.addEventListener('click', () => performWebviewAction(wv => { if (!backBtn.disabled) wv.goBack(); }));
|
||||
forwardBtn.addEventListener('click', () => performWebviewAction(wv => { if (!forwardBtn.disabled) wv.goForward(); }));
|
||||
reloadBtn.addEventListener('click', () => performWebviewAction(wv => {
|
||||
if (wv.isLoading) wv.stop();
|
||||
else wv.reload();
|
||||
}));
|
||||
homeBtn.addEventListener('click', () => performWebviewAction(wv => wv.loadURL(HOME_URL)));
|
||||
|
||||
// 地址栏
|
||||
addressBar.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
let url = addressBar.value.trim();
|
||||
if (!url) return;
|
||||
if (!/^(https?|file):\/\//i.test(url)) {
|
||||
if (url.includes('.') || url === 'localhost' || /^\d{1,3}(\.\d{1.3}){3}$/.test(url)) {
|
||||
url = 'https://' + url;
|
||||
} else {
|
||||
// 使用 Bing 搜索
|
||||
url = `https://www.bing.com/search?q=${encodeURIComponent(url)}`;
|
||||
}
|
||||
}
|
||||
performWebviewAction(wv => wv.loadURL(url));
|
||||
}
|
||||
});
|
||||
|
||||
// --- 窗口控制 ---
|
||||
document.getElementById('close-btn').addEventListener('click', () => window.electronAPI.closeCurrentWindow());
|
||||
document.getElementById('minimize-btn').addEventListener('click', () => window.electronAPI.secretWindowMinimize());
|
||||
document.getElementById('maximize-btn').addEventListener('click', () => window.electronAPI.secretWindowMaximize());
|
||||
|
||||
// --- 窗口焦点追踪 ---
|
||||
window.addEventListener('focus', () => document.body.classList.add('window-focused'));
|
||||
window.addEventListener('blur', () => document.body.classList.remove('window-focused'));
|
||||
document.body.classList.add('window-focused'); // 默认激活
|
||||
|
||||
// --- 初始加载 ---
|
||||
createTab(initialURL, true); // 创建第一个标签页
|
||||
|
||||
}); // End of DOMContentLoaded
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,112 @@
|
||||
<!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">
|
||||
</head>
|
||||
<body id="app-body" class="tool-window-body">
|
||||
|
||||
<div class="app-glass-frame">
|
||||
|
||||
<div class="glass-title-bar">
|
||||
<div class="window-status-dot active"></div>
|
||||
|
||||
<span id="window-title" class="glass-title-text">加载中...</span>
|
||||
|
||||
<div class="window-controls island-controls">
|
||||
<button id="minimize-btn" class="control-pill" title="最小化">
|
||||
<div class="icon-line"></div>
|
||||
</button>
|
||||
<button id="maximize-btn" class="control-pill" title="最大化/还原">
|
||||
<div class="icon-rect"></div>
|
||||
</button>
|
||||
<button id="close-btn" class="control-pill close" title="关闭">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="tool-content-area" class="secret-content" style="flex-grow: 1; overflow: hidden; display: flex; flex-direction: column;">
|
||||
<div class="loading-container">
|
||||
<img src="../assets/loading.gif" alt="加载中..." class="loading-gif">
|
||||
<p id="tool-loading-text" class="loading-text">正在初始化灵动岛...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div id="toast-container"></div>
|
||||
|
||||
<script type="module">
|
||||
import configManager from '../js/configManager.js';
|
||||
import uiManager from '../js/uiManager.js';
|
||||
import { loadToolFromUrl } from '../js/view-loader.js';
|
||||
import i18n from '../js/i18n.js';
|
||||
|
||||
window.uiManager = uiManager;
|
||||
window.configManager = configManager;
|
||||
|
||||
// 初始化主题
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const theme = params.get('theme') || 'dark';
|
||||
document.body.setAttribute('data-theme', theme);
|
||||
|
||||
async function start() {
|
||||
try {
|
||||
// 加载语言配置
|
||||
const langConfig = await window.electronAPI.getLanguageConfig();
|
||||
i18n.init(langConfig.pack, langConfig.fallback);
|
||||
window.i18n = i18n;
|
||||
|
||||
const loadingText = document.getElementById('tool-loading-text');
|
||||
if(loadingText) loadingText.textContent = i18n.t('common.loading.tool');
|
||||
} catch (e) {
|
||||
console.warn('语言加载失败,使用默认配置');
|
||||
i18n.init(null, {});
|
||||
}
|
||||
|
||||
// 绑定窗口控制事件
|
||||
document.getElementById('close-btn').addEventListener('click', () => window.electronAPI.closeCurrentWindow());
|
||||
document.getElementById('minimize-btn').addEventListener('click', () => window.electronAPI.secretWindowMinimize());
|
||||
document.getElementById('maximize-btn').addEventListener('click', () => window.electronAPI.secretWindowMaximize());
|
||||
|
||||
// 加载具体工具
|
||||
loadToolFromUrl();
|
||||
|
||||
// [新增] 焦点追踪:实现窗口激活时的“呼吸发光”效果
|
||||
const updateFocus = (isFocused) => {
|
||||
const frame = document.querySelector('.app-glass-frame');
|
||||
const dot = document.querySelector('.window-status-dot');
|
||||
if (isFocused) {
|
||||
frame.classList.add('focused');
|
||||
dot.classList.add('active'); // 激活时亮绿灯
|
||||
} else {
|
||||
frame.classList.remove('focused');
|
||||
dot.classList.remove('active'); // 失焦时变暗
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('focus', () => updateFocus(true));
|
||||
window.addEventListener('blur', () => updateFocus(false));
|
||||
|
||||
// 默认初始状态为激活
|
||||
updateFocus(true);
|
||||
}
|
||||
|
||||
start();
|
||||
|
||||
// 全局点击监听,处理自定义下拉菜单的关闭逻辑
|
||||
document.addEventListener('click', (e) => {
|
||||
document.querySelectorAll('.custom-select-options.dynamic-active').forEach(openDropdown => {
|
||||
const wrapperId = openDropdown.id.replace('dd-options-', 'dd-wrapper-');
|
||||
const trigger = document.getElementById(wrapperId);
|
||||
if (openDropdown && !openDropdown.contains(e.target) && trigger && !trigger.contains(e.target)) {
|
||||
openDropdown.classList.remove('dynamic-active');
|
||||
}
|
||||
});
|
||||
}, true);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user