Files
web-capture-api/templates/index.html
T
hz4th_coder 8c0c042649 v1.8.0 智能截图多视觉大模型接口配置(优先级+失败降级) + 提取历史图示统计
1) 智能截图·视觉大模型配置:
   - 支持配置多个视觉大模型接口(均含视觉能力), 按列表顺序=优先级调用
   - 失败/报错/超时自动降级到下一个接口, 全部失败才保守停止滚动
   - 预置两个高优先级接口: 本地18008 Qwen3.8-27B-NVFP4 + SiliconFlow Kimi-K2.6
   - 前端弹窗: 接口增删改/启用开关/⬆⬇调整优先级/逐接口🧪测试连接
   - /api/smart/test 支持 endpoint_id 测试已保存接口; 旧单接口配置自动迁移为 endpoints[0]
   - 每步判断记录所用模型名+接口名(详情可见降级链路)
2) 提取历史图示统计:
   - /api/history/stats?dimension=day|action|backend|status|caller|method&days=N
   - 前端📊统计弹窗: 6个角度切换 + 7/30/90天范围 + 饼图/柱状图切换 + 汇总(总数/成功率) + 数据表 + 下载PNG
   - 按天维度缺0补0, caller Top8+其他
2026-09-11 12:48:25 +08:00

1654 lines
73 KiB
HTML
Raw Permalink 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-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Web Capture - 网页截图/HTML/文本提取</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
padding: 20px;
}
.container {
max-width: 1200px;
margin: 0 auto;
}
.header {
text-align: center;
color: white;
margin-bottom: 30px;
}
.header h1 {
font-size: 2.5em;
margin-bottom: 10px;
}
.header p {
opacity: 0.9;
}
.card {
background: white;
border-radius: 16px;
padding: 30px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
margin-bottom: 20px;
}
.form-group {
margin-bottom: 20px;
}
label {
display: block;
font-weight: 600;
margin-bottom: 8px;
color: #333;
}
input[type="text"],
input[type="number"],
select {
width: 100%;
padding: 12px;
border: 2px solid #e0e0e0;
border-radius: 8px;
font-size: 16px;
transition: border-color 0.3s;
}
input:focus,
select:focus {
outline: none;
border-color: #667eea;
}
.radio-group {
display: flex;
gap: 20px;
flex-wrap: wrap;
}
.radio-label {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
}
.radio-label input[type="radio"] {
width: 20px;
height: 20px;
}
.checkbox-group {
display: flex;
gap: 20px;
flex-wrap: wrap;
}
.checkbox-label {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
}
.checkbox-label input[type="checkbox"] {
width: 20px;
height: 20px;
}
.row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 15px;
}
.btn {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
padding: 15px 40px;
font-size: 18px;
font-weight: 600;
border-radius: 8px;
cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s;
width: 100%;
margin-top: 10px;
}
.btn:hover {
transform: translateY(-2px);
box-shadow: 0 10px 30px rgba(102, 126, 234, 0.4);
}
.btn:active {
transform: translateY(0);
}
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
}
.loading {
display: none;
text-align: center;
padding: 20px;
}
.loading.active {
display: block;
}
.spinner {
border: 4px solid #f3f3f3;
border-top: 4px solid #667eea;
border-radius: 50%;
width: 40px;
height: 40px;
animation: spin 1s linear infinite;
margin: 0 auto 15px;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.result {
display: none;
}
.result.active {
display: block;
}
.result-image {
width: 100%;
border-radius: 8px;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1);
}
.result-code {
background: #1e1e1e;
color: #d4d4d4;
padding: 20px;
border-radius: 8px;
overflow-x: auto;
max-height: 600px;
font-family: 'Courier New', monospace;
font-size: 14px;
line-height: 1.6;
}
.result-code pre {
margin: 0;
white-space: pre-wrap;
word-wrap: break-word;
}
.result-text {
background: #f8f9fa;
border: 1px solid #e0e0e0;
color: #222;
padding: 20px;
border-radius: 8px;
overflow: auto;
max-height: 600px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 14px;
line-height: 1.8;
white-space: pre-wrap;
word-wrap: break-word;
}
.actions {
margin-top: 20px;
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.btn-secondary {
background: #6c757d;
padding: 10px 20px;
font-size: 14px;
width: auto;
margin-top: 0;
}
.btn-small {
background: #667eea;
color: white;
border: none;
padding: 6px 14px;
font-size: 13px;
border-radius: 6px;
cursor: pointer;
transition: background 0.2s;
}
.btn-small:hover {
background: #5568d8;
}
.btn-small.danger {
background: #dc3545;
}
.btn-small.danger:hover {
background: #c82333;
}
.btn-small:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.error {
background: #fee;
border: 1px solid #fcc;
color: #c00;
padding: 15px;
border-radius: 8px;
margin-top: 20px;
display: none;
}
.error.active {
display: block;
}
/* ===== 历史记录区域 ===== */
.history-header {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 15px;
margin-bottom: 20px;
}
.history-header h3 {
font-size: 1.4em;
color: #333;
}
.history-toolbar {
display: flex;
gap: 10px;
flex-wrap: wrap;
align-items: center;
}
.history-toolbar select,
.history-toolbar input[type="text"] {
width: auto;
min-width: 120px;
padding: 8px 12px;
font-size: 14px;
}
.history-toolbar .search-input {
min-width: 220px;
}
.history-count {
color: #666;
font-size: 14px;
}
.history-table {
width: 100%;
border-collapse: collapse;
}
.history-table th,
.history-table td {
padding: 12px 10px;
text-align: left;
border-bottom: 1px solid #eee;
vertical-align: middle;
}
.history-table th {
font-size: 13px;
color: #666;
font-weight: 600;
white-space: nowrap;
}
.history-table td {
font-size: 14px;
}
.history-table tr:hover td {
background: #f8f9ff;
}
.badge {
display: inline-block;
padding: 3px 10px;
border-radius: 20px;
font-size: 12px;
font-weight: 600;
white-space: nowrap;
}
.badge-screenshot {
background: #e3f2fd;
color: #1565c0;
}
.badge-html {
background: #fff3e0;
color: #e65100;
}
.badge-text {
background: #e8f5e9;
color: #2e7d32;
}
.badge-failed {
background: #fdecea;
color: #c62828;
}
.badge-api {
background: #ede7f6;
color: #5e35b1;
}
.badge-web {
background: #e0f7fa;
color: #00695c;
}
.history-url {
max-width: 280px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: #1565c0;
text-decoration: none;
display: inline-block;
vertical-align: bottom;
}
.history-title {
max-width: 220px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: #333;
display: inline-block;
vertical-align: bottom;
}
.history-empty {
text-align: center;
padding: 40px;
color: #999;
font-size: 15px;
}
.pagination {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
margin-top: 20px;
flex-wrap: wrap;
}
.page-btn {
min-width: 36px;
padding: 7px 12px;
border: 1px solid #ddd;
background: white;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
color: #333;
transition: all 0.2s;
}
.page-btn:hover:not(:disabled) {
border-color: #667eea;
color: #667eea;
}
.page-btn.active {
background: #667eea;
color: white;
border-color: #667eea;
}
.page-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.page-info {
color: #666;
font-size: 14px;
white-space: nowrap;
}
/* ===== 查看详情弹窗 ===== */
.modal-overlay {
display: none;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.6);
z-index: 1000;
padding: 30px;
overflow-y: auto;
}
.modal-overlay.active {
display: flex;
}
.modal {
background: white;
border-radius: 14px;
width: 100%;
max-width: 900px;
margin: auto;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.4);
overflow: hidden;
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 18px 24px;
background: #f8f9fa;
border-bottom: 1px solid #eee;
gap: 15px;
flex-wrap: wrap;
}
.modal-header h3 {
font-size: 16px;
color: #333;
word-break: break-all;
}
.modal-meta {
padding: 10px 24px;
background: #f8f9fa;
border-bottom: 1px solid #eee;
font-size: 13px;
color: #666;
display: flex;
gap: 20px;
flex-wrap: wrap;
}
.modal-body {
padding: 20px 24px;
max-height: 70vh;
overflow: auto;
}
.modal-body .result-image {
width: 100%;
}
.modal-close {
background: #6c757d;
color: white;
border: none;
width: 32px;
height: 32px;
border-radius: 50%;
font-size: 18px;
cursor: pointer;
line-height: 1;
flex-shrink: 0;
}
.modal-close:hover {
background: #545b62;
}
.api-docs {
margin-top: 30px;
padding: 20px;
background: #f8f9fa;
border-radius: 8px;
}
.api-docs h3 {
margin-bottom: 15px;
color: #333;
}
.api-docs code {
background: #e9ecef;
padding: 2px 6px;
border-radius: 4px;
font-size: 14px;
}
.api-docs pre {
background: #1e1e1e;
color: #d4d4d4;
padding: 15px;
border-radius: 8px;
overflow-x: auto;
margin-top: 10px;
}
@media (max-width: 700px) {
.history-table th:nth-child(2),
.history-table td:nth-child(2),
.history-table th:nth-child(3),
.history-table td:nth-child(3),
.history-table th:nth-child(4),
.history-table td:nth-child(4) {
display: none;
}
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>🌐 Web Capture</h1>
<p>网页截图 / HTML / 可读文本 提取服务</p>
</div>
<div class="card">
<form id="captureForm">
<div class="form-group">
<label for="url">目标网址</label>
<input type="text" id="url" placeholder="https://example.com" required>
</div>
<div class="form-group">
<label>操作类型</label>
<div class="radio-group">
<label class="radio-label">
<input type="radio" name="action" value="screenshot" checked>
📸 截图
</label>
<label class="radio-label">
<input type="radio" name="action" value="html">
📄 提取HTML
</label>
<label class="radio-label">
<input type="radio" name="action" value="text">
📝 提取文本
</label>
<label class="radio-label">
<input type="radio" name="action" value="smart">
🧠 按需截图(AI判断滚动)
</label>
<button type="button" class="btn-small" onclick="openSmartConfig()" style="margin-top:0;" title="配置视觉大模型接口">⚙️ 智能配置</button>
</div>
</div>
<div class="form-group" id="smartHint" style="display:none; padding:12px 16px; background:#eef2ff; border-radius:8px; color:#333; font-size:14px; line-height:1.8;">
🧠 <strong>按需截图模式</strong>:每次截图后由视觉大模型实时判断——是否已截全主题内容、还需不需要继续往下滚动,自动滚动直到大模型判定完成,最后拼成一张长图。支持配置<strong>多个视觉大模型接口</strong>(按优先级调用,失败自动降级到下一个),在「⚙️ 智能配置」中随时增删改。
</div>
<div class="form-group">
<label>高级选项</label>
<div class="checkbox-group">
<label class="checkbox-label">
<input type="checkbox" id="fullPage">
全页截图
</label>
</div>
</div>
<div class="form-group">
<label>捕获后端</label>
<select id="backend" style="max-width:100%;">
<option value="auto">加载中…</option>
</select>
<small id="backendHint" style="color: #666; display: block; margin-top: 4px;">
自动 = 优先 Playwright,失败自动回退 agent-browser(推荐)
</small>
</div>
<div class="form-group">
<label>等待设置(重要!)</label>
<div class="row">
<div>
<label for="waitTime">页面加载等待(ms) ⏱️</label>
<input type="number" id="waitTime" value="2000" min="0" max="60000" step="500">
<small style="color: #666; display: block; margin-top: 4px;">某些网站需要较长验证过程,可设置为 10000-30000</small>
</div>
<div>
<label for="scrollTimes">滚动次数(可选)</label>
<input type="number" id="scrollTimes" value="0" min="0" max="20">
</div>
<div>
<label for="scrollDelay">滚动间隔(ms)</label>
<input type="number" id="scrollDelay" value="1000" min="500" max="5000" step="100">
</div>
</div>
</div>
<div class="form-group">
<label>视口大小</label>
<div class="row">
<div>
<label for="viewportWidth">宽度</label>
<input type="number" id="viewportWidth" value="1920" min="320" max="3840">
</div>
<div>
<label for="viewportHeight">高度</label>
<input type="number" id="viewportHeight" value="1080" min="240" max="2160">
</div>
</div>
</div>
<button type="submit" class="btn" id="submitBtn">
🚀 开始捕获
</button>
</form>
<div class="loading" id="loading">
<div class="spinner"></div>
<p>正在处理,请稍候...</p>
</div>
<div class="error" id="error"></div>
<div class="result" id="result">
<h3 style="margin-bottom: 15px;">捕获结果</h3>
<div id="resultContent"></div>
<div class="actions">
<button class="btn btn-secondary" onclick="downloadResult()">💾 下载</button>
<button class="btn btn-secondary" onclick="copyResult()">📋 复制</button>
<button class="btn btn-secondary" onclick="resetForm()">🔄 重新捕获</button>
</div>
</div>
</div>
<div class="card">
<div class="history-header">
<h3>📜 提取历史</h3>
<div class="history-toolbar">
<select id="historyActionFilter" onchange="loadHistory(1)">
<option value="all">全部类型</option>
<option value="screenshot">📸 截图</option>
<option value="html">📄 HTML</option>
<option value="text">📝 文本</option>
<option value="smart">🧠 按需截图</option>
</select>
<input type="text" class="search-input" id="historySearch" placeholder="🔍 搜索网址/标题..." onkeydown="if(event.key==='Enter')loadHistory(1)">
<button class="btn-small" onclick="loadHistory(1)">搜索</button>
<button class="btn-small" onclick="refreshHistory()">🔄 刷新</button>
<button class="btn-small" onclick="openStats()">📊 统计</button>
</div>
</div>
<div class="history-count" id="historyCount"></div>
<div style="overflow-x: auto; margin-top: 10px;">
<table class="history-table">
<thead>
<tr>
<th>时间</th>
<th>类型</th>
<th>调用者</th>
<th>方式</th>
<th>网址</th>
<th>标题</th>
<th>状态</th>
<th style="text-align: right;">操作</th>
</tr>
</thead>
<tbody id="historyBody"></tbody>
</table>
</div>
<div class="history-empty" id="historyEmpty" style="display: none;">暂无提取记录,先去上方提取一个网页吧 🚀</div>
<div class="pagination" id="pagination"></div>
</div>
<div class="card api-docs">
<h3>📚 使用说明</h3>
<p><strong>重要提示:</strong></p>
<ul style="margin: 10px 0; line-height: 1.8;">
<li>🧠 <strong>按需截图</strong>:每次截图后用视觉大模型实时判断是否已截全主题内容、是否还需继续滚动,自动滚动到内容结束并拼接成长图;支持<strong>多视觉大模型接口配置</strong>(「⚙️ 智能配置」中增删改,列表顺序=优先级,失败/报错自动降级到下一个接口)</li>
<li>📊 <strong>提取历史统计</strong>:历史区点「📊 统计」,可按天/操作类型/后端/状态/调用者/调用方式等角度切换图表(柱状/饼图可换,支持 7/30/90 天范围,可下载 PNG</li>
<li>📝 <strong>提取文本</strong>:自动剔除脚本/样式/标签,得到干净的前端可读文本,适合直接用于分析/喂给大模型</li>
<li>⏱️ <strong>页面加载等待</strong>:某些网站需要验证过程(如 Cloudflare),请设置较长时间(10000-30000ms</li>
<li>🔄 <strong>滚动次数</strong>:用于加载动态内容(如微博、推特等),建议 3-5 次</li>
<li>🎯 <strong>全页截图</strong>:滚动加载后建议开启此选项</li>
<li>📜 <strong>提取历史</strong>:每次提取自动入库,支持分页浏览、按类型筛选、搜索与删除;每条记录会标注<strong>调用者</strong>(游客/项目名)与<strong>调用方式</strong>web/api),并自动把<strong>最原始 HTML</strong> 按月归档到 <code>data/html/&lt;月份&gt;/</code>(详情里可点「📄 原始HTML文件」查看)</li>
<li>👤 <strong>API 调用方标识</strong>:请求体带 <code>caller</code>(或请求头 <code>X-Caller</code>/<code>X-Project</code>)即可在历史里显示项目名;带 <code>call_method</code><code>X-From</code> 标注调用方式,缺省按 Referer 自动判定 web/api</li>
</ul>
<p style="margin-top: 15px;"><strong>API 调用:</strong></p>
<pre><code>POST /api/capture
{
"url": "https://example.com",
"action": "smart", // "screenshot" | "html" | "text" | "smart"
"wait_time": 15000, // 重要!验证网站需设置较长等待
"scroll_times": 3, // 可选:加载动态内容
"scroll_delay": 1000, // 可选:滚动间隔
"full_page": true, // 可选:全页截图
"backend": "auto", // 可选:auto(默认,优先playwright,失败回退agent-browser) / playwright / agent-browser / chrome-cdp
"caller": "news-tracker", // 可选:调用者标识(历史记录里显示,默认游客)
"call_method": "api", // 可选:调用方式 web/api(缺省自动判定)
"viewport": {"width":1280,"height":700},
"smart_config": { // 可选:临时覆盖按需截图配置(接口列表按顺序=优先级,失败自动降级)
"endpoints": [
{"name":"本地Qwen", "base_url":"http://121.40.164.32:18008/v1", "api_key":"xxxx", "model":"unsloth/Qwen3.8-27B-NVFP4", "enabled":true},
{"name":"Kimi", "base_url":"https://api.siliconflow.cn/v1", "api_key":"sk-xxx", "model":"Pro/moonshotai/Kimi-K2.6", "enabled":true}
],
"max_scrolls": 20,
"scroll_ratio": 0.85
}
}
GET /api/smart/config // 获取智能截图配置(含 endpoints 多接口列表)
POST /api/smart/config // 保存配置(body: {config:{endpoints:[...], prompt, max_scrolls,...}}
POST /api/smart/test // 测试指定接口(body: {endpoint_id} 或 {endpoint:{...}} 或旧版 {config:{...}}
GET /api/history?page=1&page_size=15&action=all&search=关键词 // 历史分页
GET /api/history/stats?dimension=day&days=30 // 历史统计(dimension: day/action/backend/status/caller/method
GET /api/history/&lt;id&gt; // 历史详情
GET /api/history/&lt;id&gt;/file // 历史截图文件
DELETE /api/history/&lt;id&gt; // 删除历史</code></pre>
</div>
</div>
<!-- 智能截图配置弹窗(多视觉大模型接口 + 优先级降级) -->
<div class="modal-overlay" id="smartCfgOverlay" onclick="if(event.target===this)closeSmartConfig()">
<div class="modal">
<div class="modal-header">
<h3>⚙️ 智能截图 · 视觉大模型配置</h3>
<button class="modal-close" onclick="closeSmartConfig()"></button>
</div>
<div class="modal-body">
<div style="padding:12px 16px;background:#eef2ff;border-radius:8px;font-size:13px;line-height:1.8;color:#333;margin-bottom:16px;">
🔗 接口按列表顺序作为 <strong>优先级</strong>(越靠前越优先)。调用时从高到低逐个尝试,<strong>失败/报错/超时自动降级</strong>到下一个接口,全部失败才停止滚动。
</div>
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:10px;">
<strong style="color:#333;">🎯 视觉大模型接口列表(均需含视觉能力)</strong>
<button class="btn-small" onclick="addSmartEndpoint()"> 添加接口</button>
</div>
<div id="epList" style="display:flex;flex-direction:column;gap:10px;"></div>
<div style="margin-top:18px;border-top:1px dashed #ddd;padding-top:16px;">
<div class="form-group" style="margin-bottom:12px;">
<label for="scPrompt">判断提示词(告诉大模型如何判断是否截全)</label>
<textarea id="scPrompt" rows="6" style="width:100%; padding:12px; border:2px solid #e0e0e0; border-radius:8px; font-size:14px; font-family:inherit; line-height:1.6;"></textarea>
</div>
<div class="row">
<div>
<label for="scMaxScrolls">最大滚动次数</label>
<input type="number" id="scMaxScrolls" min="1" max="100" value="20">
</div>
<div>
<label for="scRatio">单次滚动比例(视口高度%)</label>
<input type="number" id="scRatio" min="10" max="100" value="85">
</div>
<div>
<label for="scTimeout">LLM 超时(秒)</label>
<input type="number" id="scTimeout" min="10" max="600" value="120">
</div>
</div>
</div>
<div id="scTestResult" style="margin-top:12px; font-size:14px; line-height:1.8;"></div>
</div>
<div class="actions" style="padding: 0 24px 20px;">
<button class="btn btn-secondary" onclick="saveSmartConfig()">💾 保存配置</button>
<button class="btn btn-secondary" onclick="closeSmartConfig()">关闭</button>
</div>
</div>
</div>
<!-- 历史图示统计弹窗 -->
<div class="modal-overlay" id="statsOverlay" onclick="if(event.target===this)closeStats()">
<div class="modal">
<div class="modal-header">
<h3>📊 提取历史统计</h3>
<button class="modal-close" onclick="closeStats()"></button>
</div>
<div class="modal-body">
<div style="display:flex;flex-wrap:wrap;gap:10px;align-items:center;margin-bottom:14px;">
<span style="font-size:13px;color:#666;font-weight:600;">角度:</span>
<div id="statDimBtns" style="display:flex;gap:6px;flex-wrap:wrap;"></div>
<span style="font-size:13px;color:#666;font-weight:600;margin-left:10px;">范围:</span>
<div id="statDaysBtns" style="display:flex;gap:6px;"></div>
<span style="font-size:13px;color:#666;font-weight:600;margin-left:10px;">图型:</span>
<div id="statTypeBtns" style="display:flex;gap:6px;"></div>
<button class="btn-small" onclick="refreshStats()" style="margin-left:auto;">🔄 刷新</button>
</div>
<div id="statSummary" style="display:flex;gap:18px;flex-wrap:wrap;margin-bottom:12px;font-size:14px;color:#333;"></div>
<div style="background:#fff;border:1px solid #eee;border-radius:10px;padding:10px;">
<canvas id="statsCanvas" style="width:100%;height:340px;display:block;"></canvas>
</div>
<div id="statTable" style="margin-top:14px;"></div>
</div>
<div class="actions" style="padding: 0 24px 20px;">
<button class="btn btn-secondary" onclick="downloadStatsPng()">💾 下载图表</button>
<button class="btn btn-secondary" onclick="closeStats()">关闭</button>
</div>
</div>
</div>
<!-- 历史详情弹窗 -->
<div class="modal-overlay" id="modalOverlay" onclick="if(event.target===this)closeModal()">
<div class="modal">
<div class="modal-header">
<h3 id="modalTitle">记录详情</h3>
<button class="modal-close" onclick="closeModal()"></button>
</div>
<div class="modal-meta" id="modalMeta"></div>
<div class="modal-body" id="modalBody"></div>
<div class="actions" style="padding: 0 24px 20px;">
<button class="btn btn-secondary" onclick="downloadHistoryContent()">💾 下载内容</button>
<button class="btn btn-secondary" onclick="copyHistoryContent()">📋 复制内容</button>
</div>
</div>
</div>
<script>
const form = document.getElementById('captureForm');
const loading = document.getElementById('loading');
const result = document.getElementById('result');
const error = document.getElementById('error');
const submitBtn = document.getElementById('submitBtn');
let currentData = null;
let currentBlob = null;
let currentPage = 1;
let currentHistory = { page: 1, total: 0, total_pages: 1 };
let modalRecord = null;
let smartConfig = null;
let lastSmartHistoryId = null;
// 页面加载时读取历史 + 智能配置 + 可用后端
window.addEventListener('load', () => {
loadHistory(1);
loadSmartConfig();
loadBackends();
});
async function loadBackends() {
const sel = document.getElementById('backend');
const hint = document.getElementById('backendHint');
try {
const res = await fetch('/api/backends');
const data = await res.json();
if (!data.success) throw new Error(data.error || '加载失败');
const labels = data.labels || {};
const opts = ['auto'].concat(data.available || []);
sel.innerHTML = opts.map(v =>
`<option value="${v}">${escapeHtml(v)}${escapeHtml(labels[v] || '')}</option>`
).join('') + (data.unavailable || []).map(v =>
`<option value="${v}" disabled>${escapeHtml(v)}${escapeHtml(labels[v] || '')}(不可用)</option>`
).join('');
sel.value = 'auto';
hint.textContent = labels['auto'] || hint.textContent;
} catch (err) {
sel.innerHTML = '<option value="auto">自动</option>';
hint.textContent = '后端列表加载失败:' + err.message;
}
}
// 操作类型切换:智能模式显示提示条
document.querySelectorAll('input[name="action"]').forEach(r => {
r.addEventListener('change', () => {
const smart = document.querySelector('input[name="action"]:checked').value === 'smart';
document.getElementById('smartHint').style.display = smart ? 'block' : 'none';
});
});
/* ===== 智能截图配置(多接口 + 优先级降级) ===== */
let smartEndpoints = [];
async function loadSmartConfig() {
try {
const res = await fetch('/api/smart/config');
const data = await res.json();
if (data.success) smartConfig = data.config;
} catch (e) {}
}
function smartEndpointNewId() {
return 'ep_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
}
function renderSmartEndpoints() {
const list = document.getElementById('epList');
if (!smartEndpoints.length) {
list.innerHTML = '<div style="padding:20px;text-align:center;color:#999;border:1.5px dashed #ddd;border-radius:8px;">暂无接口,点击「➕ 添加接口」添加(按需截图至少需要一个可用接口)</div>';
return;
}
list.innerHTML = smartEndpoints.map((ep, i) => `
<div style="border:1px solid #e0e0e0;border-radius:10px;padding:12px;background:#fafbff;">
<div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:10px;">
<span style="background:linear-gradient(135deg,#667eea,#764ba2);color:#fff;border-radius:50%;width:26px;height:26px;display:inline-flex;align-items:center;justify-content:center;font-size:13px;font-weight:700;flex-shrink:0;" title="优先级序号(越小越优先)">${i+1}</span>
<label style="display:flex;align-items:center;gap:5px;font-size:13px;cursor:pointer;font-weight:600;color:#333;flex-shrink:0;">
<input type="checkbox" ${ep.enabled ? 'checked' : ''} onchange="toggleSmartEndpoint('${ep.id}')">启用
</label>
<input type="text" value="${escapeHtml(ep.name || '')}" placeholder="接口名称" onchange="smartEndpointField('${ep.id}','name',this.value)" style="flex:1;min-width:140px;padding:8px;border:1.5px solid #e0e0e0;border-radius:6px;font-size:13px;">
<span style="display:flex;gap:4px;flex-shrink:0;">
<button class="btn-small" onclick="moveSmartEndpoint('${ep.id}',-1)" ${i === 0 ? 'disabled' : ''} title="提升优先级">⬆</button>
<button class="btn-small" onclick="moveSmartEndpoint('${ep.id}',1)" ${i === smartEndpoints.length - 1 ? 'disabled' : ''} title="降低优先级">⬇</button>
<button class="btn-small" onclick="removeSmartEndpoint('${ep.id}')" style="background:#dc3545;" title="删除接口">🗑</button>
</span>
</div>
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:8px;">
<div>
<label style="font-size:12px;color:#666;margin-bottom:3px;display:block;">base_url</label>
<input type="text" value="${escapeHtml(ep.base_url || '')}" placeholder="https://.../v1" onchange="smartEndpointField('${ep.id}','base_url',this.value)" style="width:100%;padding:8px;border:1.5px solid #e0e0e0;border-radius:6px;font-size:13px;">
</div>
<div>
<label style="font-size:12px;color:#666;margin-bottom:3px;display:block;">API Key</label>
<input type="text" value="${escapeHtml(ep.api_key || '')}" placeholder="sk-... 或 xxxx" onchange="smartEndpointField('${ep.id}','api_key',this.value)" style="width:100%;padding:8px;border:1.5px solid #e0e0e0;border-radius:6px;font-size:13px;">
</div>
<div>
<label style="font-size:12px;color:#666;margin-bottom:3px;display:block;">模型名称 model</label>
<input type="text" value="${escapeHtml(ep.model || '')}" placeholder="qwen3.8-27B..." onchange="smartEndpointField('${ep.id}','model',this.value)" style="width:100%;padding:8px;border:1.5px solid #e0e0e0;border-radius:6px;font-size:13px;">
</div>
<div style="display:flex;align-items:flex-end;">
<button class="btn-small" onclick="testSmartEndpoint('${ep.id}')" style="width:100%;padding:9px;">🧪 测试连接</button>
</div>
</div>
<div id="epTest_${ep.id}" style="font-size:13px;line-height:1.7;margin-top:6px;"></div>
</div>
`).join('');
}
function smartEndpointField(id, key, val) {
const ep = smartEndpoints.find(e => e.id === id);
if (ep) ep[key] = val;
}
function toggleSmartEndpoint(id) {
const ep = smartEndpoints.find(e => e.id === id);
if (ep) ep.enabled = !ep.enabled;
}
function moveSmartEndpoint(id, dir) {
const i = smartEndpoints.findIndex(e => e.id === id);
const j = i + dir;
if (i < 0 || j < 0 || j >= smartEndpoints.length) return;
[smartEndpoints[i], smartEndpoints[j]] = [smartEndpoints[j], smartEndpoints[i]];
renderSmartEndpoints();
}
function addSmartEndpoint() {
smartEndpoints.push({ id: smartEndpointNewId(), name: '', base_url: '', api_key: '', model: '', enabled: true });
renderSmartEndpoints();
const last = document.getElementById('epList').lastElementChild;
if (last) last.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
function removeSmartEndpoint(id) {
smartEndpoints = smartEndpoints.filter(e => e.id !== id);
renderSmartEndpoints();
}
async function testSmartEndpoint(id) {
const el = document.getElementById('epTest_' + id);
el.innerHTML = '<span style="color:#667eea;">⏳ 正在测试连接...</span>';
try {
const res = await fetch('/api/smart/test', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ endpoint_id: id })
});
const data = await res.json();
if (data.success) {
el.innerHTML = `<span style="color:#2e7d32;">✅ 连接成功!耗时 ${data.latency}s,模型 ${escapeHtml(data.model)} 回复:${escapeHtml(data.reply)}</span>`;
} else {
el.innerHTML = `<span style="color:#c62828;">❌ 测试失败:${escapeHtml(data.error || '未知错误')}</span>`;
}
} catch (err) {
el.innerHTML = `<span style="color:#c62828;">❌ 测试失败:${escapeHtml(err.message)}</span>`;
}
}
function openSmartConfig() {
if (!smartConfig) return;
smartEndpoints = (smartConfig.endpoints || []).map(ep => JSON.parse(JSON.stringify(ep)));
document.getElementById('scPrompt').value = smartConfig.prompt || '';
document.getElementById('scMaxScrolls').value = smartConfig.max_scrolls || 20;
document.getElementById('scRatio').value = Math.round((smartConfig.scroll_ratio || 0.85) * 100);
document.getElementById('scTimeout').value = smartConfig.timeout || 120;
document.getElementById('scTestResult').innerHTML = '';
renderSmartEndpoints();
document.getElementById('smartCfgOverlay').classList.add('active');
}
function closeSmartConfig() {
document.getElementById('smartCfgOverlay').classList.remove('active');
}
function collectSmartConfigForm() {
return {
endpoints: smartEndpoints.map(ep => ({
id: ep.id, name: ep.name, base_url: ep.base_url,
api_key: ep.api_key, model: ep.model, enabled: ep.enabled !== false
})),
prompt: document.getElementById('scPrompt').value.trim(),
max_scrolls: parseInt(document.getElementById('scMaxScrolls').value) || 20,
scroll_ratio: (parseInt(document.getElementById('scRatio').value) || 85) / 100,
timeout: parseInt(document.getElementById('scTimeout').value) || 120
};
}
async function saveSmartConfig() {
const cfg = collectSmartConfigForm();
const el = document.getElementById('scTestResult');
el.innerHTML = '<span style="color:#667eea;">⏳ 正在保存...</span>';
try {
const res = await fetch('/api/smart/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ config: cfg })
});
const data = await res.json();
if (data.success) {
smartConfig = data.config;
const enabled = (data.config.endpoints || []).filter(e => e.enabled !== false).length;
el.innerHTML = `✅ 配置已保存并生效(共 ${(data.config.endpoints || []).length} 个接口,启用 ${enabled} 个,按列表顺序作为优先级)`;
} else {
el.innerHTML = `<span style="color:#c62828;">❌ 保存失败:${escapeHtml(data.error || '未知错误')}</span>`;
}
} catch (err) {
el.innerHTML = `<span style="color:#c62828;">❌ 保存失败:${escapeHtml(err.message)}</span>`;
}
}
/* ===== 历史图示统计 ===== */
let statsDim = 'day';
let statsDays = 30;
let statsType = 'bar';
let statsData = null;
const STAT_COLORS = ['#667eea', '#764ba2', '#f093fb', '#4facfe', '#43e97b', '#fa709a', '#ffd86f', '#5ee7df', '#c471f5', '#f6d365'];
function openStats() {
document.getElementById('statsOverlay').classList.add('active');
renderStatsControls();
refreshStats();
}
function closeStats() {
document.getElementById('statsOverlay').classList.remove('active');
}
function renderStatsControls() {
const dims = [['day', '📅 按天'], ['action', '🎯 操作类型'], ['backend', '🖥 后端'], ['status', '✅ 状态'], ['caller', '👤 调用者'], ['method', '🔀 调用方式']];
document.getElementById('statDimBtns').innerHTML = dims.map(([v, l]) =>
`<button class="btn-small" style="${v === statsDim ? 'background:#667eea;' : ''}" onclick="setStatsDim('${v}')">${l}</button>`
).join('');
document.getElementById('statDaysBtns').innerHTML = [7, 30, 90].map(d =>
`<button class="btn-small" style="${d === statsDays ? 'background:#667eea;' : ''}" onclick="setStatsDays(${d})">${d}天</button>`
).join('');
document.getElementById('statTypeBtns').innerHTML = [['pie', '🍩 饼图'], ['bar', '📊 柱状']].map(([t, l]) =>
`<button class="btn-small" style="${t === statsType ? 'background:#667eea;' : ''}" onclick="setStatsType('${t}')">${l}</button>`
).join('');
}
function setStatsDim(d) {
statsDim = d;
statsType = d === 'day' ? 'bar' : 'pie'; // 维度切换时默认图型
renderStatsControls();
refreshStats();
}
function setStatsDays(d) { statsDays = d; renderStatsControls(); refreshStats(); }
function setStatsType(t) { statsType = t; renderStatsControls(); renderStatsChart(); }
async function refreshStats() {
try {
const res = await fetch(`/api/history/stats?dimension=${statsDim}&days=${statsDays}`);
const data = await res.json();
if (!data.success) throw new Error(data.error || '加载失败');
statsData = data;
renderStatsSummary();
renderStatsChart();
renderStatsTable();
} catch (err) {
document.getElementById('statSummary').innerHTML = `<span style="color:#c62828;">❌ 加载统计失败:${escapeHtml(err.message)}</span>`;
}
}
function renderStatsSummary() {
if (!statsData) return;
const rate = (statsData.success_rate * 100).toFixed(1);
const dimLabel = {day: '近', action: '', backend: '', status: '', caller: '', method: ''}[statsData.dimension];
document.getElementById('statSummary').innerHTML =
`<span>📈 统计总数:<strong>${statsData.total}</strong> 条</span>` +
`<span>✅ 成功:<strong>${statsData.success_count}</strong> 条(成功率 ${rate}%</span>` +
(statsData.dimension === 'day' ? `<span>📅 近 <strong>${statsData.days}</strong> 天提取量</span>` : '');
}
function renderStatsChart() {
const canvas = document.getElementById('statsCanvas');
if (!statsData || !statsData.items || !statsData.items.length) {
const dpr = window.devicePixelRatio || 1;
const rect = canvas.getBoundingClientRect();
canvas.width = rect.width * dpr; canvas.height = 340 * dpr;
const ctx = canvas.getContext('2d');
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, rect.width, 340);
ctx.fillStyle = '#999'; ctx.font = '15px sans-serif'; ctx.textAlign = 'center';
ctx.fillText('暂无数据', rect.width / 2, 170);
return;
}
const dpr = window.devicePixelRatio || 1;
const rect = canvas.getBoundingClientRect();
const W = Math.max(200, rect.width), H = 340;
canvas.width = W * dpr; canvas.height = H * dpr;
const ctx = canvas.getContext('2d');
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, W, H);
if (statsType === 'pie') drawStatsPie(ctx, W, H, statsData.items);
else drawStatsBar(ctx, W, H, statsData.items, statsData.dimension);
}
function drawStatsPie(ctx, W, H, items) {
const total = items.reduce((s, it) => s + it.value, 0);
if (!total) return;
const legendW = 300;
const cx = Math.min(W - legendW, W * 0.42) / 2 + 40, cy = H / 2;
const r = Math.min(H / 2 - 30, (W - legendW) / 2 - 60);
let angle = -Math.PI / 2;
items.forEach((it, i) => {
const a = it.value / total * Math.PI * 2;
ctx.beginPath();
ctx.moveTo(cx, cy);
ctx.arc(cx, cy, r, angle, angle + a);
ctx.closePath();
ctx.fillStyle = STAT_COLORS[i % STAT_COLORS.length];
ctx.fill();
ctx.strokeStyle = '#fff'; ctx.lineWidth = 2; ctx.stroke();
angle += a;
});
// 中心文字
ctx.fillStyle = '#333'; ctx.textAlign = 'center';
ctx.font = 'bold 28px sans-serif'; ctx.fillText(String(total), cx, cy - 4);
ctx.font = '13px sans-serif'; ctx.fillStyle = '#999'; ctx.fillText('总提取量', cx, cy + 22);
// 图例
let lx = cx + r + 28, ly = H / 2 - ((items.length - 1) * 26) / 2;
ctx.textAlign = 'left';
items.forEach((it, i) => {
ctx.fillStyle = STAT_COLORS[i % STAT_COLORS.length];
ctx.fillRect(lx, ly - 8, 14, 14);
const pct = (it.value / total * 100).toFixed(1);
const label = it.label.length > 16 ? it.label.slice(0, 15) + '…' : it.label;
ctx.fillStyle = '#333'; ctx.font = '13px sans-serif';
ctx.fillText(`${label} ${it.value} (${pct}%)`, lx + 20, ly + 3);
ly += 26;
});
}
function drawStatsBar(ctx, W, H, items, dimension) {
if (dimension === 'day') {
drawDayBar(ctx, W, H, items);
} else {
drawCatBar(ctx, W, H, items);
}
}
function drawDayBar(ctx, W, H, items) {
const left = 52, right = 16, top = 18, bottom = 34;
const cw = W - left - right, ch = H - top - bottom;
const max = Math.max(1, ...items.map(it => it.value));
// 网格线 + Y 轴
ctx.strokeStyle = '#eee'; ctx.fillStyle = '#999'; ctx.font = '11px sans-serif'; ctx.textAlign = 'right';
for (let i = 0; i <= 4; i++) {
const v = Math.round(max * i / 4);
const y = top + ch - ch * i / 4;
ctx.beginPath(); ctx.moveTo(left, y); ctx.lineTo(W - right, y); ctx.stroke();
ctx.fillText(String(v), left - 6, y + 4);
}
const n = items.length;
const step = Math.max(1, Math.ceil(n / Math.max(6, Math.floor(cw / 60))));
const bw = cw / n;
items.forEach((it, i) => {
const h = Math.max(0, ch * it.value / max);
const x = left + i * bw + bw * 0.15, w = Math.max(2, bw * 0.7);
const g = ctx.createLinearGradient(0, top + ch - h, 0, top + ch);
g.addColorStop(0, '#667eea'); g.addColorStop(1, '#a78bfa');
ctx.fillStyle = g;
ctx.beginPath();
ctx.roundRect(x, top + ch - h, w, h, 3);
ctx.fill();
if (it.value > 0) {
ctx.fillStyle = '#555'; ctx.font = '10px sans-serif'; ctx.textAlign = 'center';
ctx.fillText(String(it.value), x + w / 2, top + ch - h - 3);
}
// X 轴日期标签(稀疏)
if (i % step === 0) {
ctx.fillStyle = '#888'; ctx.font = '10px sans-serif'; ctx.textAlign = 'center';
ctx.save();
ctx.translate(x + w / 2, top + ch + 10);
ctx.rotate(-0.5);
ctx.fillText(it.label.slice(5), 0, 0); // MM-DD
ctx.restore();
}
});
}
function drawCatBar(ctx, W, H, items) {
const maxLabel = Math.max(...items.map(it => it.label.length));
const left = Math.min(W * 0.4, Math.max(120, maxLabel * 13 + 20));
const right = 70, top = 12, bottom = 16;
const cw = W - left - right, ch = H - top - bottom;
const max = Math.max(1, ...items.map(it => it.value));
const rowH = Math.min(40, ch / items.length);
const startY = top + (ch - rowH * items.length) / 2;
items.forEach((it, i) => {
const y = startY + i * rowH;
const bw = Math.max(2, cw * it.value / max);
ctx.fillStyle = STAT_COLORS[i % STAT_COLORS.length];
ctx.beginPath();
ctx.roundRect(left, y + 6, bw, rowH - 12, 4);
ctx.fill();
const label = it.label.length > 14 ? it.label.slice(0, 13) + '…' : it.label;
ctx.fillStyle = '#333'; ctx.font = '13px sans-serif'; ctx.textAlign = 'right';
ctx.fillText(label, left - 8, y + rowH / 2 + 4);
ctx.fillStyle = '#555'; ctx.textAlign = 'left';
ctx.fillText(String(it.value), left + bw + 8, y + rowH / 2 + 4);
});
}
function renderStatsTable() {
const el = document.getElementById('statTable');
if (!statsData || !statsData.items || !statsData.items.length) { el.innerHTML = ''; return; }
const items = statsData.items;
if (items.length > 12) { el.innerHTML = ''; return; } // 分类多/按天时不渲染表,避免太长
const total = items.reduce((s, it) => s + it.value, 0) || 1;
el.innerHTML = `<table class="history-table" style="font-size:13px;">
<thead><tr><th>项</th><th>数量</th><th>占比</th></tr></thead>
<tbody>${items.map(it =>
`<tr><td>${escapeHtml(it.label)}</td><td><strong>${it.value}</strong></td><td>${(it.value / total * 100).toFixed(1)}%</td></tr>`
).join('')}</tbody></table>`;
}
function downloadStatsPng() {
const canvas = document.getElementById('statsCanvas');
const a = document.createElement('a');
a.href = canvas.toDataURL('image/png');
a.download = `stats_${statsDim}_${statsDays}d_${new Date().toISOString().slice(0, 10)}.png`;
a.click();
}
form.addEventListener('submit', async (e) => {
e.preventDefault();
const url = document.getElementById('url').value;
const action = document.querySelector('input[name="action"]:checked').value;
const fullPage = document.getElementById('fullPage').checked;
const scrollTimes = parseInt(document.getElementById('scrollTimes').value);
const scrollDelay = parseInt(document.getElementById('scrollDelay').value);
const waitTime = parseInt(document.getElementById('waitTime').value);
const viewportWidth = parseInt(document.getElementById('viewportWidth').value);
const viewportHeight = parseInt(document.getElementById('viewportHeight').value);
currentData = {
url,
action,
scroll_times: scrollTimes,
scroll_delay: scrollDelay,
full_page: fullPage,
wait_time: waitTime,
backend: document.getElementById('backend').value || 'auto',
viewport: {
width: viewportWidth,
height: viewportHeight
},
smart_config: action === 'smart' ? (smartConfig || {}) : undefined
};
// 显示加载
loading.classList.add('active');
result.classList.remove('active');
error.classList.remove('active');
submitBtn.disabled = true;
try {
const response = await fetch('/api/capture', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-From': 'web'
},
body: JSON.stringify(currentData)
});
if (!response.ok) {
const errData = await response.json();
throw new Error(errData.error || '请求失败');
}
if (action === 'screenshot') {
currentBlob = await response.blob();
const imageUrl = URL.createObjectURL(currentBlob);
document.getElementById('resultContent').innerHTML =
`<img src="${imageUrl}" class="result-image" alt="截图结果">`;
} else {
const data = await response.json();
if (action === 'html') {
currentBlob = new Blob([data.html], { type: 'text/html' });
document.getElementById('resultContent').innerHTML =
`<div class="result-code"><pre>${escapeHtml(data.html)}</pre></div>`;
} else if (action === 'smart') {
lastSmartHistoryId = data.history_id || null;
currentBlob = new Blob([JSON.stringify(data.steps || [], null, 2)], { type: 'application/json' });
const stepsHtml = (data.steps || []).map(s =>
`<li style="margin:4px 0;">第 <strong>${s.step}</strong> 次截图:${s.complete ? '✅ 已完整,停止滚动' : '⏳ 继续滚动'} —— ${escapeHtml(s.reason || '')}${s.model ? `<span style="color:#888;font-size:12px;">(模型:${escapeHtml(s.model)}</span>${s.endpoint ? `<span style="color:#888;font-size:12px;"> / ${escapeHtml(s.endpoint)}</span>` : ''}<span style="color:#888;font-size:12px;"></span>` : ''}</li>`
).join('');
document.getElementById('resultContent').innerHTML =
`<div style="margin-bottom:10px;color:#666;font-size:13px;">页面标题:${escapeHtml(data.title || '无')} 🧠 AI 共滚动 <strong>${data.total_steps || 0}</strong> 次判定完成,已拼接为长图(下方展示)</div>` +
`<img src="/api/history/${data.history_id}/file" class="result-image" alt="智能截图长图" onerror="this.style.display='none'">` +
`<details style="margin-top:12px;"><summary style="cursor:pointer;color:#667eea;font-weight:600;">📋 查看 AI 判断过程(${(data.steps || []).length} 步)</summary>` +
`<ul style="margin:10px 0 0 20px;line-height:1.8;color:#333;font-size:14px;">${stepsHtml}</ul></details>`;
} else {
currentBlob = new Blob([data.text], { type: 'text/plain;charset=utf-8' });
const lines = (data.text || '').split('\n').length;
document.getElementById('resultContent').innerHTML =
`<div style="margin-bottom:10px;color:#666;font-size:13px;">页面标题:${escapeHtml(data.title || '无')} ${lines} 行,${(data.text || '').length} 字符(已剔除标签与无效字符)</div>` +
`<div class="result-text">${escapeHtml(data.text || '')}</div>`;
}
}
result.classList.add('active');
// 捕获成功后刷新历史(回到第一页看最新)
loadHistory(1);
} catch (err) {
error.textContent = '❌ ' + err.message;
error.classList.add('active');
} finally {
loading.classList.remove('active');
submitBtn.disabled = false;
}
});
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function downloadResult() {
if (!currentData) return;
if (currentData.action === 'smart') {
if (lastSmartHistoryId) {
const a = document.createElement('a');
a.href = `/api/history/${lastSmartHistoryId}/file`;
a.download = 'smart_capture.png';
a.click();
}
return;
}
if (!currentBlob) return;
const url = URL.createObjectURL(currentBlob);
const a = document.createElement('a');
a.href = url;
const name = currentData.action === 'screenshot' ? 'screenshot.png'
: currentData.action === 'html' ? 'page.html' : 'page.txt';
a.download = name;
a.click();
URL.revokeObjectURL(url);
}
function copyResult() {
if (!currentBlob) return;
const reader = new FileReader();
reader.onload = () => {
navigator.clipboard.writeText(reader.result).then(() => {
alert('已复制到剪贴板');
});
};
reader.readAsText(currentBlob);
}
function resetForm() {
result.classList.remove('active');
error.classList.remove('active');
currentData = null;
currentBlob = null;
lastSmartHistoryId = null;
}
/* ===== 历史记录 ===== */
async function loadHistory(page) {
const action = document.getElementById('historyActionFilter').value;
const search = document.getElementById('historySearch').value.trim();
const qs = new URLSearchParams({ page: page, page_size: 15, action, search });
const res = await fetch('/api/history?' + qs.toString());
const data = await res.json();
if (!data.success) {
document.getElementById('historyEmpty').style.display = 'block';
document.getElementById('historyEmpty').textContent = '❌ 加载历史失败:' + (data.error || '未知错误');
return;
}
currentPage = page;
currentHistory = data;
renderHistory(data);
}
function refreshHistory() {
loadHistory(currentPage);
}
function renderHistory(data) {
const body = document.getElementById('historyBody');
const empty = document.getElementById('historyEmpty');
const count = document.getElementById('historyCount');
count.textContent = `共 ${data.total} 条记录`;
if (!data.records.length) {
body.innerHTML = '';
empty.style.display = 'block';
empty.textContent = '暂无提取记录,先去上方提取一个网页吧 🚀';
renderPagination(data);
return;
}
empty.style.display = 'none';
const badges = {
screenshot: '📸 截图',
html: '📄 HTML',
text: '📝 文本',
smart: '🧠 按需截图'
};
body.innerHTML = data.records.map(r => {
const badgeClass = r.status === 'failed' ? 'badge-failed' : ('badge-' + r.action);
const badgeText = r.status === 'failed' ? '❌ 失败' : (badges[r.action] || r.action);
const statusText = r.status === 'failed'
? `<span style="color:#c62828;" title="${escapeHtml(r.error || '')}">失败</span>`
: '<span style="color:#2e7d32;">成功</span>';
const callerText = r.caller || '游客';
const methodText = r.call_method || 'api';
const methodBadge = methodText === 'web' ? 'badge-web' : 'badge-api';
return `<tr>
<td style="white-space:nowrap;">${escapeHtml(r.created_at)}</td>
<td><span class="badge ${escapeHtml(badgeClass)}">${badgeText}</span></td>
<td><span class="history-title" title="${escapeHtml(callerText)}">${escapeHtml(callerText)}</span></td>
<td><span class="badge ${methodBadge}" style="font-size:11px;padding:2px 8px;">${escapeHtml(methodText)}</span></td>
<td><a class="history-url" href="${escapeHtml(r.url)}" target="_blank" rel="noopener">${escapeHtml(r.url)}</a></td>
<td><span class="history-title" title="${escapeHtml(r.title || '')}">${escapeHtml(r.title || '—')}</span></td>
<td>${statusText}</td>
<td style="text-align:right; white-space:nowrap;">
<button class="btn-small" onclick="viewRecord(${r.id})">👁 查看</button>
<button class="btn-small danger" onclick="deleteRecord(${r.id})">🗑</button>
</td>
</tr>`;
}).join('');
renderPagination(data);
}
function renderPagination(data) {
const el = document.getElementById('pagination');
if (!data.records.length) {
el.innerHTML = '';
return;
}
let html = '';
html += `<button class="page-btn" onclick="loadHistory(${data.page - 1})" ${data.page <= 1 ? 'disabled' : ''}> 上一页</button>`;
const total = data.total_pages;
const cur = data.page;
let start = Math.max(1, cur - 2);
let end = Math.min(total, cur + 2);
if (end - start < 4) {
start = Math.max(1, end - 4);
end = Math.min(total, start + 4);
}
if (start > 1) html += `<button class="page-btn" onclick="loadHistory(1)">1</button>`;
if (start > 2) html += `<span style="color:#999;">…</span>`;
for (let p = start; p <= end; p++) {
html += `<button class="page-btn ${p === cur ? 'active' : ''}" onclick="loadHistory(${p})">${p}</button>`;
}
if (end < total - 1) html += `<span style="color:#999;">…</span>`;
if (end < total) html += `<button class="page-btn" onclick="loadHistory(${total})">${total}</button>`;
html += `<button class="page-btn" onclick="loadHistory(${data.page + 1})" ${data.page >= total ? 'disabled' : ''}>下一页 </button>`;
html += `<span class="page-info">第 ${data.page} / ${total} 页</span>`;
el.innerHTML = html;
}
async function viewRecord(id) {
const res = await fetch('/api/history/' + id);
const data = await res.json();
if (!data.success) {
alert('加载失败:' + (data.error || '未知错误'));
return;
}
modalRecord = data.record;
const r = modalRecord;
document.getElementById('modalTitle').textContent = (r.title || r.url || '记录详情');
const badgeClass = r.status === 'failed' ? 'badge-failed' : ('badge-' + r.action);
const badgeText = r.status === 'failed' ? '❌ 失败' : ({screenshot: '📸 截图', html: '📄 HTML', text: '📝 文本'}[r.action] || r.action);
const htmlLink = r.html_path
? `<span><a href="/api/history/${r.id}/html" target="_blank" style="color:#667eea;font-weight:600;" title="${escapeHtml(r.html_path)}">📄 原始HTML文件</a></span>`
: '';
document.getElementById('modalMeta').innerHTML =
`<span>类型:<span class="badge ${badgeClass}">${badgeText}</span></span>` +
`<span>时间:${escapeHtml(r.created_at)}</span>` +
`<span>调用者:${escapeHtml(r.caller || '游客')}</span>` +
`<span>方式:${escapeHtml(r.call_method || 'api')}</span>` +
`<span>后端:${escapeHtml(r.backend || '—')}</span>` +
`<span>状态:${r.status === 'failed' ? '<span style="color:#c62828;">失败</span>' : '<span style="color:#2e7d32;">成功</span>'}</span>` +
htmlLink;
const body = document.getElementById('modalBody');
if (r.status === 'failed') {
body.innerHTML = `<div class="result-text" style="color:#c62828;">❌ 提取失败:${escapeHtml(r.error || '')}</div>`;
} else if (r.action === 'screenshot') {
body.innerHTML = `<img src="/api/history/${r.id}/file" class="result-image" alt="历史截图" onerror="this.parentElement.innerHTML='&lt;p style=&quot;color:#c62828;&quot;&gt;截图文件不存在或已删除&lt;/p&gt;'">`;
} else if (r.action === 'html') {
body.innerHTML = `<div class="result-code"><pre>${escapeHtml(r.content || '')}</pre></div>`;
} else if (r.action === 'smart') {
let stepsHtml = '';
let stepsCount = 0;
try {
const steps = JSON.parse(r.content || '[]');
stepsCount = steps.length;
stepsHtml = steps.map(s =>
`<li style="margin:4px 0;">第 <strong>${s.step}</strong> 次截图:${s.complete ? '✅ 已完整,停止滚动' : '⏳ 继续滚动'} —— ${escapeHtml(s.reason || '')}${s.model ? `<span style="color:#888;font-size:12px;">(模型:${escapeHtml(s.model)}</span>${s.endpoint ? `<span style="color:#888;font-size:12px;"> / ${escapeHtml(s.endpoint)}</span>` : ''}<span style="color:#888;font-size:12px;"></span>` : ''}</li>`
).join('');
} catch (e) {}
body.innerHTML =
`<img src="/api/history/${r.id}/file" class="result-image" alt="智能截图长图" onerror="this.style.display='none'">` +
(stepsHtml ? `<details open style="margin-top:14px;"><summary style="cursor:pointer;color:#667eea;font-weight:600;">📋 AI 判断过程(${stepsCount} 步)</summary><ul style="margin:10px 0 0 20px;line-height:1.8;color:#333;font-size:14px;">${stepsHtml}</ul></details>` : '');
} else {
body.innerHTML = `<div class="result-text">${escapeHtml(r.content || '')}</div>`;
}
document.getElementById('modalOverlay').classList.add('active');
}
function closeModal() {
document.getElementById('modalOverlay').classList.remove('active');
modalRecord = null;
}
async function deleteRecord(id) {
if (!confirm('确定删除这条记录吗?截图文件将一并删除。')) return;
const res = await fetch('/api/history/' + id, { method: 'DELETE' });
const data = await res.json();
if (data.success) {
// 若当前页删空了则回退一页
if (currentHistory.records.length === 1 && currentPage > 1) {
loadHistory(currentPage - 1);
} else {
loadHistory(currentPage);
}
} else {
alert('删除失败:' + (data.error || '未知错误'));
}
}
function currentModalBlob() {
if (!modalRecord) return null;
if (modalRecord.action === 'screenshot') return null; // 图片用链接下载
const type = modalRecord.action === 'html' ? 'text/html' : 'text/plain;charset=utf-8';
return new Blob([modalRecord.content || ''], { type });
}
function downloadHistoryContent() {
if (!modalRecord) return;
if (modalRecord.action === 'screenshot' || modalRecord.action === 'smart') {
const a = document.createElement('a');
a.href = `/api/history/${modalRecord.id}/file`;
a.download = modalRecord.action === 'smart' ? `smart_${modalRecord.id}.png` : `capture_${modalRecord.id}.png`;
a.click();
return;
}
const blob = currentModalBlob();
if (!blob) return;
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = modalRecord.action === 'html' ? `page_${modalRecord.id}.html` : `page_${modalRecord.id}.txt`;
a.click();
URL.revokeObjectURL(url);
}
function copyHistoryContent() {
if (!modalRecord || !modalRecord.content) return;
navigator.clipboard.writeText(modalRecord.content).then(() => {
alert('已复制到剪贴板');
});
}
</script>
</body>
</html>