2025-02-06 21:10:34 +08:00

924 lines
38 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Mirror Flowers</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.7.2/font/bootstrap-icons.css" rel="stylesheet">
<link href="https://fonts.googleapis.com/css2?family=Cinzel+Decorative:wght@700&display=swap" rel="stylesheet">
<script src="https://cdn.jsdelivr.net/npm/@popperjs/core@2.9.2/dist/umd/popper.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.min.js"></script>
<style>
:root {
--bg-color: #ffffff;
--text-color: #212529;
--card-bg: #ffffff;
--border-color: #dee2e6;
--custom-file-bg: #f8f9fa;
--custom-file-border: #ddd;
--custom-file-hover-bg: #f1f8ff;
--custom-file-hover-border: #0d6efd;
--pre-bg: #f8f9fa;
--pre-color: #212529;
}
[data-theme="dark"] {
--bg-color: #212529;
--text-color: #f8f9fa;
--card-bg: #343a40;
--border-color: #495057;
--custom-file-bg: #2b3035;
--custom-file-border: #495057;
--custom-file-hover-bg: #3d4247;
--custom-file-hover-border: #0d6efd;
--pre-bg: #2b3035;
--pre-color: #f8f9fa;
}
body {
background-color: var(--bg-color);
color: var(--text-color);
transition: all 0.3s ease;
}
.card {
background-color: var(--card-bg);
border-color: var(--border-color);
}
.card-header {
background-color: var(--card-bg);
border-bottom-color: var(--border-color);
}
.container { max-width: 1200px; margin-top: 2rem; }
.result-card {
margin: 1rem 0;
padding: 1rem;
border-radius: 8px;
background-color: var(--card-bg);
border: 1px solid var(--border-color);
}
.loading { text-align: center; margin: 2rem 0; }
.file-list { margin-top: 1rem; }
.file-list-item { padding: 0.5rem; border-bottom: 1px solid #eee; }
.file-list-item:last-child { border-bottom: none; }
pre {
white-space: pre-wrap;
word-wrap: break-word;
max-height: 400px;
overflow-y: auto;
background-color: var(--pre-bg) !important;
color: var(--pre-color) !important;
border: 1px solid var(--border-color);
padding: 1rem;
}
.btn-link { text-decoration: none; }
.collapse { transition: all 0.3s ease; }
.upload-container {
position: relative;
min-height: 100px;
margin-bottom: 1rem;
}
.upload-section {
width: 100%;
transition: all 0.3s ease;
}
.upload-section input[type="file"] {
display: block !important;
opacity: 1 !important;
position: relative !important;
width: 100%;
}
.custom-file-upload {
border: 2px dashed var(--custom-file-border);
border-radius: 8px;
padding: 20px;
text-align: center;
background: var(--custom-file-bg);
transition: all 0.3s ease;
}
.custom-file-upload:hover {
border-color: var(--custom-file-hover-border);
background: var(--custom-file-hover-bg);
}
.form-label {
margin-bottom: 10px;
color: #666;
}
.file-list {
margin-top: 1rem;
max-height: 200px;
overflow-y: auto;
}
.file-list-item {
padding: 8px;
border-bottom: 1px solid #eee;
display: flex;
align-items: center;
gap: 8px;
}
.file-list-item:last-child {
border-bottom: none;
}
.progress {
background-color: #e9ecef;
border-radius: 0.25rem;
box-shadow: inset 0 1px 2px rgba(0,0,0,.1);
}
.progress-bar {
display: flex;
flex-direction: column;
justify-content: center;
color: #fff;
text-align: center;
background-color: #007bff;
transition: width .6s ease;
}
.progress-bar-striped {
background-image: linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);
background-size: 1rem 1rem;
}
.progress-bar-animated {
animation: progress-bar-stripes 1s linear infinite;
}
@keyframes progress-bar-stripes {
from { background-position: 1rem 0; }
to { background-position: 0 0; }
}
/* 主题切换按钮样式 */
.theme-toggle {
position: fixed;
top: 1rem;
right: 1rem;
padding: 0.5rem;
border-radius: 50%;
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
background-color: var(--card-bg);
border: 1px solid var(--border-color);
color: var(--text-color);
transition: all 0.3s ease;
}
.theme-toggle:hover {
background-color: var(--custom-file-hover-bg);
}
.vulnerability-item {
background-color: var(--card-bg);
border-color: var(--border-color) !important;
}
.list-group-item {
background-color: var(--card-bg);
border-color: var(--border-color);
color: var(--text-color);
}
.text-muted {
color: #6c757d !important;
}
[data-theme="dark"] .text-muted {
color: #adb5bd !important;
}
.title-container {
text-align: center;
margin-bottom: 2rem;
padding: 2rem 0;
}
.main-title {
font-family: 'Cinzel Decorative', cursive;
font-size: 3rem;
background: linear-gradient(45deg, #1a1a1a, #4a4a4a);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
margin-bottom: 0.5rem;
text-shadow: 2px 2px 4px rgba(0,0,0,0.1);
}
[data-theme="dark"] .main-title {
background: linear-gradient(45deg, #ffffff, #cccccc);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.subtitle {
font-family: "Microsoft YaHei", sans-serif;
color: var(--text-color);
font-size: 1.2rem;
opacity: 0.8;
}
</style>
</head>
<body>
<button class="theme-toggle" onclick="toggleTheme()" title="切换主题">
<i class="bi bi-moon-fill" id="themeIcon"></i>
</button>
<div class="container">
<div class="title-container">
<h1 class="main-title">Mirror Flowers</h1>
<div class="subtitle">镜花 · 代码安全审计工具</div>
</div>
<!-- API配置部分 -->
<div class="card mb-4">
<div class="card-header">
<h5 class="mb-0">API配置</h5>
</div>
<div class="card-body">
<div class="row g-3">
<div class="col-md-4">
<input type="text" id="apiKey" class="form-control" placeholder="OpenAI API Key">
</div>
<div class="col-md-4">
<input type="text" id="apiBase" class="form-control" placeholder="API Base URL可选">
</div>
<div class="col-md-2">
<select id="modelSelect" class="form-select">
<option value="">选择模型</option>
</select>
</div>
<div class="col-md-2">
<button onclick="updateConfig()" class="btn btn-primary w-100">更新配置</button>
</div>
</div>
</div>
</div>
<!-- 文件上传部分 -->
<div class="card mb-4">
<div class="card-header">
<h5 class="mb-0">代码审计</h5>
</div>
<div class="card-body">
<div class="mb-3">
<select id="uploadType" class="form-select mb-3">
<option value="single">单文件审计</option>
<option value="folder">项目文件夹审计</option>
</select>
</div>
<div class="upload-container">
<!-- 单文件上传 -->
<div id="singleFileUpload" class="upload-section">
<div class="custom-file-upload">
<label for="codeFile" class="form-label">选择文件 (.php, .java, .js, .py)</label>
<input type="file" id="codeFile" class="form-control" accept=".php,.java,.js,.py">
</div>
<div id="singleFileList" class="file-list mt-2"></div>
</div>
<!-- 文件夹上传 -->
<div id="folderUpload" class="upload-section" style="display: none;">
<div class="custom-file-upload">
<label for="codeFolder" class="form-label">选择项目文件夹</label>
<input type="file" id="codeFolder" class="form-control" webkitdirectory directory>
</div>
<div id="folderFileList" class="file-list mt-2"></div>
</div>
</div>
<button onclick="startAudit()" id="auditBtn" class="btn btn-success mt-3" disabled>
开始审计
</button>
</div>
</div>
<!-- 结果显示部分 -->
<div id="results" style="display: none;">
<h2>审计结果</h2>
<div class="accordion" id="auditResults">
<!-- 结果将动态添加到这里 -->
</div>
</div>
<!-- 加载提示 -->
<div id="loading" class="loading" style="display: none;">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">分析中...</span>
</div>
<div class="mt-3">
<p id="loadingText" class="mb-2">代码分析中,请稍候...</p>
<div class="progress" style="height: 20px; width: 300px; margin: 0 auto;">
<div id="progressBar" class="progress-bar progress-bar-striped progress-bar-animated"
role="progressbar" style="width: 0%;"
aria-valuenow="0" aria-valuemin="0" aria-valuemax="100">0%</div>
</div>
<p id="currentFile" class="mt-2 text-muted small"></p>
</div>
</div>
</div>
<script>
const apiUrl = 'http://127.0.0.1:8000';
// 获取可用模型列表
async function fetchAvailableModels() {
try {
const response = await fetch(`${apiUrl}/api/models`);
if (response.ok) {
const data = await response.json();
console.log('获取到的模型数据:', data);
const modelSelect = document.getElementById('modelSelect');
modelSelect.innerHTML = '<option value="">选择模型</option>';
if (data.models && typeof data.models === 'object') {
// 遍历每个模型类别
Object.entries(data.models).forEach(([category, models]) => {
if (models.length > 0) {
const optgroup = document.createElement('optgroup');
optgroup.label = category;
// 对模型进行排序
const sortedModels = [...models].sort((a, b) => {
// 将Pro模型排在后面
const aIsPro = a.startsWith('Pro/');
const bIsPro = b.startsWith('Pro/');
if (aIsPro && !bIsPro) return 1;
if (!aIsPro && bIsPro) return -1;
return a.localeCompare(b);
});
sortedModels.forEach(model => {
const option = document.createElement('option');
option.value = model;
// 美化显示名称
option.textContent = model.split('/').pop() || model;
if (model === data.current_model) {
option.selected = true;
}
optgroup.appendChild(option);
});
modelSelect.appendChild(optgroup);
}
});
// 如果没有选中的模型默认选择第一个GPT模型
if (!modelSelect.value && data.models.GPT?.length > 0) {
modelSelect.value = data.models.GPT[0];
}
} else {
console.error('模型数据格式错误:', data);
}
} else {
const error = await response.json();
console.error('获取模型列表失败:', error);
}
} catch (error) {
console.error('获取模型列表失败:', error);
}
}
// 更新配置函数
async function updateConfig() {
const apiKey = document.getElementById('apiKey').value;
let apiBase = document.getElementById('apiBase').value;
const modelSelect = document.getElementById('modelSelect');
const model = modelSelect.value || modelSelect.options[1]?.value; // 如果没有选择,使用第一个有效选项
if (!apiKey) {
alert('请输入 API Key');
return;
}
// 规范化 API 基础 URL
if (apiBase) {
apiBase = apiBase.trim();
if (!apiBase.startsWith('http')) {
apiBase = 'https://' + apiBase;
}
if (!apiBase.endsWith('/v1')) {
apiBase = apiBase.replace(/\/+$/, '') + '/v1';
}
}
try {
console.log('发送配置:', { api_key: apiKey, api_base: apiBase, model }); // 添加调试日志
const response = await fetch(`${apiUrl}/api/configure`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
api_key: apiKey,
api_base: apiBase || null,
model: model || null
})
});
if (response.ok) {
const result = await response.json();
console.log('配置更新结果:', result); // 添加调试日志
alert(result.message);
await fetchAvailableModels();
} else {
const error = await response.json();
throw new Error(error.detail || '配置更新失败');
}
} catch (error) {
console.error('配置更新失败:', error);
alert('配置更新失败: ' + error.message);
}
}
// 初始化上传类型切换
function initializeUploadTypes() {
const uploadType = document.getElementById('uploadType');
const sections = {
single: document.getElementById('singleFileUpload'),
folder: document.getElementById('folderUpload')
};
// 确保初始状态正确
sections.single.style.display = 'block';
sections.folder.style.display = 'none';
uploadType.addEventListener('change', function() {
// 使用简单的显示/隐藏切换
Object.entries(sections).forEach(([type, section]) => {
section.style.display = type === this.value ? 'block' : 'none';
// 如果是隐藏的部分,清除其文件选择
if (type !== this.value) {
const input = section.querySelector('input[type="file"]');
if (input) {
input.value = '';
}
const fileList = section.querySelector('.file-list');
if (fileList) {
fileList.innerHTML = '';
}
}
});
// 更新按钮状态
updateAuditButtonState();
});
}
// 修改文件输入监听器
function initializeFileInputs() {
const fileInputs = {
'codeFile': 'singleFileList',
'codeFolder': 'folderFileList'
};
Object.entries(fileInputs).forEach(([inputId, listId]) => {
const input = document.getElementById(inputId);
if (input) {
input.addEventListener('change', function() {
if (this.files && this.files.length > 0) {
updateFileList(this.files, listId);
document.getElementById('auditBtn').disabled = false;
} else {
document.getElementById(listId).innerHTML = '';
document.getElementById('auditBtn').disabled = true;
}
});
}
});
}
// 页面加载时初始化
document.addEventListener('DOMContentLoaded', function() {
initTheme();
initializeUploadTypes();
initializeFileInputs();
fetchAvailableModels();
});
// API Base URL 改变时更新模型列表
document.getElementById('apiBase').addEventListener('change', async (event) => {
console.log('API Base URL changed:', event.target.value);
await fetchAvailableModels();
});
// 更新文件列表显示
function updateFileList(files, containerId) {
const container = document.getElementById(containerId);
container.innerHTML = '';
if (containerId === 'folderFileList') {
// 对于文件夹上传,使用新的处理逻辑
const supportedExtensions = ['.php', '.java', '.js', '.py'];
const validFiles = Array.from(files).filter(file => {
const ext = '.' + file.name.split('.').pop().toLowerCase();
return supportedExtensions.includes(ext);
});
showProcessingInfo(validFiles, files.length - validFiles.length);
// 只有存在有效文件时才启用审计按钮
document.getElementById('auditBtn').disabled = validFiles.length === 0;
} else {
// 对于单文件上传,保持原有逻辑
Array.from(files).forEach(file => {
const item = document.createElement('div');
item.className = 'file-list-item';
item.innerHTML = `
<i class="bi bi-file-earmark-text"></i>
${file.webkitRelativePath || file.name}
<small class="text-muted">(${formatFileSize(file.size)})</small>
`;
container.appendChild(item);
});
}
}
// 修改审计按钮状态更新函数
function updateAuditButtonState() {
const uploadType = document.getElementById('uploadType').value;
const inputId = uploadType === 'single' ? 'codeFile' : 'codeFolder';
const input = document.getElementById(inputId);
document.getElementById('auditBtn').disabled = !input || !input.files.length;
}
// 格式化文件大小
function 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 parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
// 修改审计函数
async function startAudit() {
const uploadType = document.getElementById('uploadType').value;
let files;
try {
showLoading();
switch(uploadType) {
case 'single':
files = document.getElementById('codeFile').files;
if (files.length > 0) {
await auditSingleFile(files[0]);
} else {
throw new Error('请选择要审计的文件');
}
break;
case 'folder':
files = document.getElementById('codeFolder').files;
if (files.length > 0) {
await auditFolder(files);
} else {
throw new Error('请选择要审计的文件夹');
}
break;
}
} catch (error) {
alert(error.message);
} finally {
hideLoading();
}
}
async function auditSingleFile(file) {
const formData = new FormData();
formData.append('file', file);
const apiKey = document.getElementById('apiKey').value;
const apiBase = document.getElementById('apiBase').value;
if (apiKey) formData.append('api_key', apiKey);
if (apiBase) formData.append('api_base', apiBase);
try {
const response = await fetch(`${apiUrl}/api/audit`, {
method: 'POST',
body: formData
});
if (!response.ok) {
throw new Error('审计请求失败');
}
const result = await response.json();
displayResults([{ file: file.name, result }]);
} catch (error) {
alert('审计失败: ' + error.message);
}
}
// 修改 auditFolder 函数
async function auditFolder(files) {
try {
// 过滤支持的文件类型
const supportedExtensions = ['.php', '.java', '.js', '.py'];
const validFiles = Array.from(files).filter(file => {
const ext = '.' + file.name.split('.').pop().toLowerCase();
return supportedExtensions.includes(ext);
});
if (validFiles.length === 0) {
throw new Error('未找到支持的源代码文件(支持 .php, .java, .js, .py');
}
// 显示初始进度 - 压缩阶段
updateProgress(0, validFiles.length, '准备文件...');
// 创建ZIP文件只包含支持的文件
const zip = new JSZip();
let totalSize = 0;
const maxSize = 10 * 1024 * 1024; // 10MB 限制
for (let i = 0; i < validFiles.length; i++) {
const file = validFiles[i];
totalSize += file.size;
if (totalSize > maxSize) {
throw new Error('项目文件总大小超过限制10MB');
}
const relativePath = file.webkitRelativePath || file.name;
zip.file(relativePath, file);
// 更新压缩进度
updateProgress(i + 1, validFiles.length, `正在处理: ${relativePath}`);
}
// 显示处理信息
showProcessingInfo(validFiles, files.length - validFiles.length);
// 更新进度显示为压缩阶段
updateProgress(validFiles.length, validFiles.length, '正在压缩文件...');
const zipBlob = await zip.generateAsync({ type: 'blob' });
// 准备上传
const formData = new FormData();
formData.append('project', new File([zipBlob], 'project.zip'));
const apiKey = document.getElementById('apiKey').value;
const apiBase = document.getElementById('apiBase').value;
if (apiKey) formData.append('api_key', apiKey);
if (apiBase) formData.append('api_base', apiBase);
// 更新进度显示为分析阶段
resetProgress();
document.getElementById('loadingText').textContent = '正在进行代码分析...';
document.getElementById('currentFile').textContent = '正在初始化分析...';
const response = await fetch(`${apiUrl}/api/audit/project`, {
method: 'POST',
body: formData
});
if (!response.ok) {
throw new Error('项目审计失败');
}
const result = await response.json();
// 分析完成
document.getElementById('loadingText').textContent = '分析完成';
document.getElementById('currentFile').textContent = '';
document.getElementById('progressBar').style.width = '100%';
displayProjectResults(result.results);
} catch (error) {
alert('审计失败: ' + error.message);
}
}
// 添加重置进度条函数
function resetProgress() {
const progressBar = document.getElementById('progressBar');
const currentFileText = document.getElementById('currentFile');
progressBar.style.width = '0%';
progressBar.setAttribute('aria-valuenow', 0);
progressBar.textContent = '0%';
currentFileText.textContent = '';
}
// 修改更新进度条函数
function updateProgress(processed, total, message = '') {
const percentage = Math.round((processed / total) * 100);
const progressBar = document.getElementById('progressBar');
const loadingText = document.getElementById('loadingText');
const currentFileText = document.getElementById('currentFile');
progressBar.style.width = `${percentage}%`;
progressBar.setAttribute('aria-valuenow', percentage);
progressBar.textContent = `${percentage}%`;
if (message) {
currentFileText.textContent = message;
}
loadingText.textContent = `处理进度:${processed}/${total}`;
}
// 添加显示处理信息的函数
function showProcessingInfo(validFiles, skippedCount) {
const container = document.getElementById('folderFileList');
container.innerHTML = `
<div class="alert alert-info">
<h6 class="mb-2">文件处理信息:</h6>
<p class="mb-1">待审计文件数:${validFiles.length}</p>
<p class="mb-1">已跳过文件数:${skippedCount}</p>
<p class="mb-0">支持的文件类型:.php, .java, .js, .py</p>
</div>
<div class="mt-3">
<h6>待审计文件列表:</h6>
${validFiles.map(file => `
<div class="file-list-item">
<i class="bi bi-file-earmark-code"></i>
${file.webkitRelativePath || file.name}
<small class="text-muted">(${formatFileSize(file.size)})</small>
</div>
`).join('')}
</div>
`;
}
function displayResults(results) {
const resultsDiv = document.getElementById('results');
const accordion = document.getElementById('auditResults');
accordion.innerHTML = '';
results.forEach((item, index) => {
const card = document.createElement('div');
card.className = 'card mb-3';
const headerId = `heading${index}`;
const collapseId = `collapse${index}`;
card.innerHTML = `
<div class="card-header" id="${headerId}">
<h5 class="mb-0">
<button class="btn btn-link w-100 text-start" type="button"
data-bs-toggle="collapse" data-bs-target="#${collapseId}"
aria-expanded="false" aria-controls="${collapseId}">
${item.file}
</button>
</h5>
</div>
<div id="${collapseId}" class="collapse"
aria-labelledby="${headerId}" data-bs-parent="#auditResults">
<div class="card-body">
<div class="mb-4">
<h6 class="mb-3">第一轮分析</h6>
<pre class="bg-light p-3 rounded">${item.result.first_analysis}</pre>
</div>
<div>
<h6 class="mb-3">第二轮验证</h6>
<pre class="bg-light p-3 rounded">${item.result.second_analysis}</pre>
</div>
</div>
</div>
`;
accordion.appendChild(card);
});
resultsDiv.style.display = 'block';
// 自动展开第一个结果
const firstCollapse = accordion.querySelector('.collapse');
if (firstCollapse) {
new bootstrap.Collapse(firstCollapse, { show: true });
}
}
function displayProjectResults(results) {
const resultsDiv = document.getElementById('results');
const accordion = document.getElementById('auditResults');
accordion.innerHTML = '';
Object.entries(results).forEach(([filePath, result], index) => {
const card = document.createElement('div');
card.className = 'card mb-3';
const headerId = `heading${index}`;
const collapseId = `collapse${index}`;
// 格式化漏洞信息
const vulnerabilitiesHtml = result.vulnerabilities.length > 0
? result.vulnerabilities.map(vuln => `
<div class="vulnerability-item mb-3 p-3 border rounded">
<h6 class="text-danger">${vuln.type}</h6>
<p><strong>位置:</strong>${vuln.location}</p>
<p><strong>严重程度:</strong>${vuln.severity}</p>
<p><strong>描述:</strong>${vuln.description}</p>
<p><strong>影响:</strong>${vuln.impact}</p>
<p><strong>修复建议:</strong>${vuln.fix}</p>
<p><strong>相关上下文:</strong>${vuln.related_context}</p>
</div>
`).join('')
: '<p class="text-muted">未发现漏洞</p>';
// 格式化相关文件
const relatedFilesHtml = result.related_files.length > 0
? `<ul class="list-group">
${result.related_files.map(file =>
`<li class="list-group-item">${file}</li>`
).join('')}
</ul>`
: '<p class="text-muted">无相关文件</p>';
card.innerHTML = `
<div class="card-header" id="${headerId}">
<h5 class="mb-0">
<button class="btn btn-link w-100 text-start" type="button"
data-bs-toggle="collapse" data-bs-target="#${collapseId}"
aria-expanded="false" aria-controls="${collapseId}">
${filePath}
</button>
</h5>
</div>
<div id="${collapseId}" class="collapse"
aria-labelledby="${headerId}" data-bs-parent="#auditResults">
<div class="card-body">
<div class="mb-4">
<h6 class="mb-3">漏洞分析</h6>
<div class="vulnerabilities-container">
${vulnerabilitiesHtml}
</div>
</div>
<div class="mb-4">
<h6 class="mb-3">上下文分析</h6>
<pre class="rounded">${result.context_analysis}</pre>
</div>
<div>
<h6 class="mb-3">相关文件</h6>
<div class="related-files-container">
${relatedFilesHtml}
</div>
</div>
</div>
</div>
`;
accordion.appendChild(card);
});
resultsDiv.style.display = 'block';
// 自动展开第一个结果
const firstCollapse = accordion.querySelector('.collapse');
if (firstCollapse) {
new bootstrap.Collapse(firstCollapse, { show: true });
}
}
function showLoading() {
document.getElementById('loading').style.display = 'block';
document.getElementById('results').style.display = 'none';
}
function hideLoading() {
document.getElementById('loading').style.display = 'none';
}
// 添加主题切换功能
function toggleTheme() {
const body = document.body;
const themeIcon = document.getElementById('themeIcon');
const currentTheme = body.getAttribute('data-theme');
if (currentTheme === 'dark') {
body.removeAttribute('data-theme');
themeIcon.className = 'bi bi-moon-fill';
localStorage.setItem('theme', 'light');
} else {
body.setAttribute('data-theme', 'dark');
themeIcon.className = 'bi bi-sun-fill';
localStorage.setItem('theme', 'dark');
}
}
// 初始化主题
function initTheme() {
const savedTheme = localStorage.getItem('theme') || 'light';
const themeIcon = document.getElementById('themeIcon');
if (savedTheme === 'dark') {
document.body.setAttribute('data-theme', 'dark');
themeIcon.className = 'bi bi-sun-fill';
} else {
document.body.removeAttribute('data-theme');
themeIcon.className = 'bi bi-moon-fill';
}
}
</script>
<!-- 添加JSZip库 -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/jszip/3.7.1/jszip.min.js"></script>
</body>
</html>