Files
tool-node/public/results.html

1273 lines
39 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>抓取结果查看 - 公告采集工具</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%);
min-height: 100vh;
color: #e0e0e0;
}
/* ===== 顶部导航 ===== */
.topbar {
background: rgba(255, 255, 255, 0.04);
backdrop-filter: blur(12px);
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
padding: 0 32px;
display: flex;
align-items: center;
justify-content: space-between;
height: 60px;
position: sticky;
top: 0;
z-index: 100;
}
.topbar-brand {
display: flex;
align-items: center;
gap: 12px;
text-decoration: none;
}
.topbar-logo {
width: 36px;
height: 36px;
background: linear-gradient(135deg, #667eea, #764ba2);
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
}
.topbar-title {
font-size: 18px;
font-weight: 700;
color: #fff;
letter-spacing: 0.5px;
}
.topbar-nav {
display: flex;
gap: 8px;
}
.topbar-link {
padding: 7px 16px;
border-radius: 8px;
font-size: 14px;
color: #aaa;
text-decoration: none;
transition: all 0.2s;
}
.topbar-link:hover {
background: rgba(255, 255, 255, 0.08);
color: #fff;
}
.topbar-link.active {
background: rgba(102, 126, 234, 0.25);
color: #8fa8f8;
}
/* ===== 主体内容 ===== */
.main {
max-width: 1400px;
margin: 0 auto;
padding: 28px 24px;
}
/* ===== 页面标题 ===== */
.page-header {
margin-bottom: 24px;
}
.page-header h1 {
font-size: 26px;
font-weight: 700;
color: #fff;
margin-bottom: 6px;
}
.page-header p {
color: #888;
font-size: 14px;
}
/* ===== 统计卡片 ===== */
.stats-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 16px;
margin-bottom: 24px;
}
.stat-card {
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 14px;
padding: 18px 20px;
display: flex;
flex-direction: column;
gap: 6px;
transition: all 0.2s;
}
.stat-card:hover {
background: rgba(255, 255, 255, 0.08);
border-color: rgba(102, 126, 234, 0.4);
}
.stat-card .label {
font-size: 12px;
color: #888;
text-transform: uppercase;
letter-spacing: 1px;
}
.stat-card .value {
font-size: 28px;
font-weight: 700;
color: #fff;
}
.stat-card .sub {
font-size: 12px;
color: #aaa;
}
/* ===== 工具栏 ===== */
.toolbar {
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 14px;
padding: 16px 20px;
margin-bottom: 20px;
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: center;
}
.filter-group {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
min-width: 120px;
}
.filter-group label {
font-size: 13px;
color: #888;
white-space: nowrap;
}
.filter-select,
.filter-input {
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 8px;
color: #e0e0e0;
padding: 8px 12px;
font-size: 13px;
font-family: inherit;
flex: 1;
outline: none;
transition: all 0.2s;
}
.filter-select:focus,
.filter-input:focus {
border-color: #667eea;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.15);
}
.filter-select option {
background: #1a1a2e;
color: #e0e0e0;
}
.toolbar-actions {
display: flex;
gap: 8px;
flex-shrink: 0;
}
/* ===== 按钮 ===== */
.btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 18px;
border-radius: 8px;
border: none;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
white-space: nowrap;
font-family: inherit;
}
.btn-primary {
background: linear-gradient(135deg, #667eea, #764ba2);
color: #fff;
}
.btn-primary:hover {
opacity: 0.9;
transform: translateY(-1px);
box-shadow: 0 4px 14px rgba(102, 126, 234, 0.4);
}
.btn-danger {
background: rgba(231, 76, 60, 0.15);
color: #e74c3c;
border: 1px solid rgba(231, 76, 60, 0.25);
}
.btn-danger:hover {
background: #e74c3c;
color: #fff;
}
.btn-ghost {
background: rgba(255, 255, 255, 0.06);
color: #bbb;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.btn-ghost:hover {
background: rgba(255, 255, 255, 0.12);
color: #fff;
}
.btn:disabled {
opacity: 0.45;
cursor: not-allowed;
transform: none !important;
}
/* ===== 结果列表 ===== */
.result-list {
display: flex;
flex-direction: column;
gap: 14px;
}
.result-card {
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 14px;
padding: 20px 24px;
transition: all 0.2s;
position: relative;
overflow: hidden;
}
.result-card::before {
content: '';
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 4px;
background: linear-gradient(180deg, #667eea, #764ba2);
border-radius: 14px 0 0 14px;
}
.result-card.has-error::before {
background: linear-gradient(180deg, #e74c3c, #c0392b);
}
.result-card:hover {
background: rgba(255, 255, 255, 0.07);
border-color: rgba(102, 126, 234, 0.3);
}
.result-card-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
}
.result-meta {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.tag {
display: inline-block;
padding: 3px 10px;
border-radius: 20px;
font-size: 12px;
font-weight: 600;
}
.tag-city {
background: rgba(102, 126, 234, 0.2);
color: #8fa8f8;
border: 1px solid rgba(102, 126, 234, 0.3);
}
.tag-section {
background: rgba(52, 211, 153, 0.15);
color: #4ade80;
border: 1px solid rgba(52, 211, 153, 0.25);
}
.tag-type {
background: rgba(251, 191, 36, 0.15);
color: #fbbf24;
border: 1px solid rgba(251, 191, 36, 0.25);
}
.tag-error {
background: rgba(231, 76, 60, 0.15);
color: #f87171;
border: 1px solid rgba(231, 76, 60, 0.25);
}
.result-time {
font-size: 12px;
color: #666;
white-space: nowrap;
}
.result-url a {
color: #6fa3ff;
text-decoration: none;
font-size: 13px;
word-break: break-all;
}
.result-url a:hover {
text-decoration: underline;
}
/* 数据内容展示 */
.result-data {
margin-top: 12px;
background: rgba(0, 0, 0, 0.25);
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.06);
overflow: hidden;
}
.result-data-toggle {
width: 100%;
background: none;
border: none;
color: #aaa;
font-size: 13px;
padding: 10px 16px;
text-align: left;
cursor: pointer;
display: flex;
justify-content: space-between;
align-items: center;
font-family: inherit;
transition: color 0.2s;
}
.result-data-toggle:hover {
color: #fff;
}
.result-data-body {
display: none;
padding: 0 16px 14px;
max-height: 400px;
overflow-y: auto;
}
.result-data-body.open {
display: block;
}
.result-data-body pre {
font-family: 'Cascadia Code', 'Consolas', monospace;
font-size: 12px;
color: #a8d8a8;
white-space: pre-wrap;
word-break: break-all;
line-height: 1.6;
}
/* 数据表格展示 */
.table-responsive {
width: 100%;
overflow-x: auto;
margin-top: 10px;
margin-bottom: 10px;
}
.data-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
color: #eee;
min-width: 600px;
}
.data-table th,
.data-table td {
border: 1px solid rgba(255, 255, 255, 0.1);
padding: 8px 12px;
text-align: left;
word-break: break-all;
}
.data-table th {
background: rgba(102, 126, 234, 0.15);
color: #8fa8f8;
font-weight: 600;
white-space: nowrap;
}
.data-table tr:nth-child(even) {
background: rgba(255, 255, 255, 0.02);
}
.data-table tr:hover {
background: rgba(255, 255, 255, 0.05);
}
.data-table td a {
color: #6fa3ff;
text-decoration: none;
}
.data-table td a:hover {
text-decoration: underline;
}
.result-data-body h4 {
color: #fff;
margin: 10px 0 6px 0;
font-size: 14px;
}
.detail-body h4 {
color: #fff;
margin: 10px 0 6px 0;
font-size: 15px;
}
.result-error {
color: #f87171;
font-size: 13px;
margin-top: 8px;
padding: 8px 12px;
background: rgba(231, 76, 60, 0.1);
border-radius: 8px;
border: 1px solid rgba(231, 76, 60, 0.2);
}
.result-card-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 14px;
padding-top: 12px;
border-top: 1px solid rgba(255, 255, 255, 0.06);
}
.btn-sm {
padding: 5px 12px;
font-size: 12px;
border-radius: 6px;
}
/* ===== 分页 ===== */
.pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 8px;
margin-top: 28px;
}
.page-btn {
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.1);
color: #bbb;
padding: 8px 14px;
border-radius: 8px;
cursor: pointer;
font-size: 13px;
transition: all 0.2s;
font-family: inherit;
}
.page-btn:hover:not(:disabled) {
background: rgba(102, 126, 234, 0.25);
color: #8fa8f8;
border-color: rgba(102, 126, 234, 0.4);
}
.page-btn.active-page {
background: rgba(102, 126, 234, 0.3);
color: #8fa8f8;
border-color: rgba(102, 126, 234, 0.5);
font-weight: 700;
}
.page-btn:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.page-info {
color: #666;
font-size: 13px;
padding: 0 8px;
}
/* ===== 空状态 ===== */
.empty-state {
text-align: center;
padding: 80px 20px;
color: #555;
}
.empty-state .icon {
font-size: 56px;
margin-bottom: 16px;
opacity: 0.5;
}
.empty-state h3 {
font-size: 18px;
margin-bottom: 8px;
color: #666;
}
.empty-state p {
font-size: 14px;
}
/* ===== 加载状态 ===== */
.loading-mask {
display: none;
text-align: center;
padding: 60px 20px;
}
.loading-mask.show {
display: block;
}
.spinner {
display: inline-block;
width: 40px;
height: 40px;
border: 3px solid rgba(102, 126, 234, 0.2);
border-top-color: #667eea;
border-radius: 50%;
animation: spin 0.8s linear infinite;
margin-bottom: 12px;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* ===== 提示弹窗 ===== */
.toast-wrap {
position: fixed;
top: 20px;
right: 20px;
z-index: 9999;
display: flex;
flex-direction: column;
gap: 8px;
}
.toast {
padding: 12px 18px;
border-radius: 10px;
font-size: 14px;
color: #fff;
animation: toastIn 0.3s ease;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
}
.toast-success {
background: rgba(39, 174, 96, 0.9);
}
.toast-error {
background: rgba(231, 76, 60, 0.9);
}
.toast-info {
background: rgba(52, 152, 219, 0.9);
}
@keyframes toastIn {
from {
opacity: 0;
transform: translateX(20px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
/* ===== 确认对话框 ===== */
.overlay {
display: none;
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
z-index: 5000;
align-items: center;
justify-content: center;
}
.overlay.show {
display: flex;
}
.dialog-box {
background: #1e2235;
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 16px;
padding: 28px;
width: 360px;
max-width: 90vw;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
animation: fadeUp 0.25s ease;
}
@keyframes fadeUp {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.dialog-title {
font-size: 17px;
font-weight: 700;
color: #fff;
margin-bottom: 10px;
}
.dialog-msg {
font-size: 14px;
color: #aaa;
margin-bottom: 24px;
line-height: 1.6;
}
.dialog-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
}
/* 详情弹窗 */
.detail-overlay {
display: none;
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.7);
z-index: 5000;
align-items: center;
justify-content: center;
padding: 20px;
}
.detail-overlay.show {
display: flex;
}
.detail-box {
background: #1e2235;
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 16px;
width: 800px;
max-width: 98vw;
max-height: 90vh;
display: flex;
flex-direction: column;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
animation: fadeUp 0.25s ease;
}
.detail-header {
padding: 20px 24px;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
display: flex;
align-items: center;
justify-content: space-between;
}
.detail-header h3 {
color: #fff;
font-size: 17px;
}
.detail-body {
padding: 20px 24px;
overflow-y: auto;
flex: 1;
}
.detail-body pre {
font-family: 'Cascadia Code', 'Consolas', monospace;
font-size: 13px;
color: #a8d8a8;
white-space: pre-wrap;
word-break: break-all;
line-height: 1.7;
}
.close-btn {
background: rgba(255, 255, 255, 0.08);
border: none;
color: #aaa;
width: 32px;
height: 32px;
border-radius: 8px;
cursor: pointer;
font-size: 18px;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
}
.close-btn:hover {
background: rgba(255, 255, 255, 0.15);
color: #fff;
}
@media (max-width: 700px) {
.main {
padding: 16px 12px;
}
.toolbar {
flex-direction: column;
align-items: stretch;
}
.filter-group {
min-width: unset;
}
.stats-row {
grid-template-columns: repeat(2, 1fr);
}
}
</style>
</head>
<body>
<!-- 顶部导航 -->
<div class="topbar">
<a href="/" class="topbar-brand">
<div class="topbar-logo">📋</div>
<span class="topbar-title">公告采集工具</span>
</a>
<div class="topbar-nav">
<a href="/" class="topbar-link">⚙️ 配置管理</a>
<a href="/results.html" class="topbar-link active">📊 抓取结果</a>
</div>
</div>
<div class="main">
<!-- 页头 -->
<div class="page-header">
<h1>📊 抓取结果</h1>
<p>所有抓取来源的历史结果,按抓取时间倒序展示</p>
</div>
<!-- 统计卡片 -->
<div class="stats-row">
<div class="stat-card">
<div class="label">总记录数</div>
<div class="value" id="statTotal">-</div>
<div class="sub">条抓取记录</div>
</div>
<div class="stat-card">
<div class="label">成功条数</div>
<div class="value" id="statSuccess">-</div>
<div class="sub">已成功抓取</div>
</div>
<div class="stat-card">
<div class="label">失败条数</div>
<div class="value" id="statFailed">-</div>
<div class="sub">抓取出错</div>
</div>
<div class="stat-card">
<div class="label">来源城市</div>
<div class="value" id="statCities">-</div>
<div class="sub">个不同城市</div>
</div>
</div>
<!-- 筛选工具栏 -->
<div class="toolbar">
<div class="filter-group">
<label>城市</label>
<select class="filter-select" id="filterCity" onchange="onFilterChange()">
<option value="">全部</option>
</select>
</div>
<div class="filter-group">
<label>板块</label>
<select class="filter-select" id="filterSection" onchange="onFilterChange()">
<option value="">全部</option>
</select>
</div>
<div class="filter-group">
<label>类型</label>
<select class="filter-select" id="filterType" onchange="onFilterChange()">
<option value="">全部</option>
</select>
</div>
<div class="toolbar-actions">
<button class="btn btn-ghost" onclick="resetFilters()">↺ 重置</button>
<button class="btn btn-primary" onclick="loadResults()">🔄 刷新</button>
<button class="btn btn-danger" onclick="confirmClearAll()">🗑 清空全部</button>
</div>
</div>
<!-- 加载中 -->
<div class="loading-mask" id="loadingMask">
<div class="spinner"></div>
<p style="color:#666;font-size:14px;">加载中...</p>
</div>
<!-- 结果列表 -->
<div class="result-list" id="resultList"></div>
<!-- 分页 -->
<div class="pagination" id="pagination" style="display:none;"></div>
</div>
<!-- Toast 容器 -->
<div class="toast-wrap" id="toastWrap"></div>
<!-- 确认清空弹窗 -->
<div class="overlay" id="confirmOverlay">
<div class="dialog-box">
<div class="dialog-title">⚠️ 确认清空</div>
<div class="dialog-msg" id="confirmMsg">确定要清空所有抓取结果吗?此操作不可撤销。</div>
<div class="dialog-actions">
<button class="btn btn-ghost" onclick="closeConfirm()">取消</button>
<button class="btn btn-danger" id="confirmOkBtn">确认清空</button>
</div>
</div>
</div>
<!-- 详情弹窗 -->
<div class="detail-overlay" id="detailOverlay">
<div class="detail-box">
<div class="detail-header">
<h3 id="detailTitle">抓取结果详情</h3>
<button class="close-btn" onclick="closeDetail()">×</button>
</div>
<div class="detail-body">
<div id="detailContent"></div>
</div>
</div>
</div>
<script>
// ===== 状态 =====
let currentPage = 1;
const PAGE_SIZE = 15;
let totalCount = 0;
let filterCity = '', filterSection = '', filterType = '';
let allResults = []; // 记录所有用计算 stat 的
// ===== 初始化 =====
document.addEventListener('DOMContentLoaded', async () => {
await loadFilters();
await loadResults();
});
// ===== 筛选下拉 =====
async function loadFilters() {
try {
const res = await fetch('/api/results/filters');
const json = await res.json();
if (!json.success) return;
const { cities, sections, types } = json.data;
populateSelect('filterCity', cities);
populateSelect('filterSection', sections);
populateSelect('filterType', types);
} catch (e) {
console.error('加载筛选项失败', e);
}
}
function populateSelect(id, items) {
const el = document.getElementById(id);
const cur = el.value;
while (el.options.length > 1) el.remove(1);
items.forEach(item => {
const opt = new Option(item, item);
el.add(opt);
});
el.value = cur;
}
function onFilterChange() {
filterCity = document.getElementById('filterCity').value;
filterSection = document.getElementById('filterSection').value;
filterType = document.getElementById('filterType').value;
currentPage = 1;
loadResults();
}
function resetFilters() {
document.getElementById('filterCity').value = '';
document.getElementById('filterSection').value = '';
document.getElementById('filterType').value = '';
filterCity = filterSection = filterType = '';
currentPage = 1;
loadResults();
}
// ===== 加载结果 =====
async function loadResults() {
const listEl = document.getElementById('resultList');
const maskEl = document.getElementById('loadingMask');
maskEl.classList.add('show');
listEl.innerHTML = '';
document.getElementById('pagination').style.display = 'none';
try {
const params = new URLSearchParams({
page: currentPage,
pageSize: PAGE_SIZE,
});
if (filterCity) params.set('city', filterCity);
if (filterSection) params.set('section', filterSection);
if (filterType) params.set('type', filterType);
const [res, statsRes] = await Promise.all([
fetch(`/api/results?${params}`),
fetch(`/api/results?pageSize=9999`), // 用于统计
]);
const json = await res.json();
const statsJson = await statsRes.json();
if (!json.success) throw new Error(json.error);
totalCount = json.total;
renderStats(statsJson.data || []);
renderResults(json.data || []);
renderPagination(json.total, json.page, json.pageSize);
} catch (e) {
listEl.innerHTML = `<div class="empty-state"><div class="icon">⚠️</div><h3>加载失败</h3><p>${e.message}</p></div>`;
} finally {
maskEl.classList.remove('show');
}
}
function renderStats(data) {
const success = data.filter(r => !r.error).length;
const failed = data.filter(r => r.error).length;
const cities = new Set(data.map(r => r.city).filter(Boolean)).size;
document.getElementById('statTotal').textContent = data.length;
document.getElementById('statSuccess').textContent = success;
document.getElementById('statFailed').textContent = failed;
document.getElementById('statCities').textContent = cities;
}
// ===== 渲染结果卡片 =====
function renderResults(data) {
const listEl = document.getElementById('resultList');
if (data.length === 0) {
listEl.innerHTML = `
<div class="empty-state">
<div class="icon">🔍</div>
<h3>暂无抓取记录</h3>
<p>运行抓取来源后,结果将自动保存在这里</p>
</div>`;
return;
}
listEl.innerHTML = data.map(r => `
<div class="result-card ${r.error ? 'has-error' : ''}" id="card-${r.id}">
<div class="result-card-header">
<div class="result-meta">
${r.city ? `<span class="tag tag-city">${r.city}</span>` : ''}
${r.section ? `<span class="tag tag-section">${r.section}${r.subsection ? ' · ' + r.subsection : ''}</span>` : ''}
${r.type ? `<span class="tag tag-type">${r.type}</span>` : ''}
${r.error ? `<span class="tag tag-error">❌ 失败</span>` : ''}
</div>
<span class="result-time">${formatTime(r.scrapedAt)}</span>
</div>
<div class="result-url">
<a href="${r.url}" target="_blank" title="${r.url}">🔗 ${r.url}</a>
</div>
${r.error ? `<div class="result-error">错误信息:${r.error}</div>` : ''}
${!r.error && r.data ? `
<div class="result-data">
<button class="result-data-toggle" onclick="toggleData(this)">
<span>📄 查看数据</span>
<span>▼</span>
</button>
<div class="result-data-body">
${renderDataAsTable(r.data)}
</div>
</div>` : ''}
<div class="result-card-footer">
${!r.error && r.data ? `<button class="btn btn-ghost btn-sm" onclick="openDetail('${r.id}')">🔎 全屏查看</button>` : ''}
<button class="btn btn-danger btn-sm" onclick="deleteResult('${r.id}')">删除</button>
</div>
</div>
`).join('');
// 保存数据供详情使用
window._resultData = {};
data.forEach(r => { window._resultData[r.id] = r; });
}
function toggleData(btn) {
const body = btn.nextElementSibling;
const isOpen = body.classList.contains('open');
body.classList.toggle('open', !isOpen);
btn.querySelector('span:last-child').textContent = isOpen ? '▼' : '▲';
}
// ===== 分页 =====
function renderPagination(total, page, pageSize) {
const totalPages = Math.ceil(total / pageSize);
const el = document.getElementById('pagination');
if (totalPages <= 1) { el.style.display = 'none'; return; }
el.style.display = 'flex';
let html = `<button class="page-btn" onclick="goPage(1)" ${page === 1 ? 'disabled' : ''}>首页</button>`;
html += `<button class="page-btn" onclick="goPage(${page - 1})" ${page === 1 ? 'disabled' : ''}>上一页</button>`;
const start = Math.max(1, page - 2);
const end = Math.min(totalPages, page + 2);
for (let i = start; i <= end; i++) {
html += `<button class="page-btn ${i === page ? 'active-page' : ''}" onclick="goPage(${i})">${i}</button>`;
}
html += `<button class="page-btn" onclick="goPage(${page + 1})" ${page === totalPages ? 'disabled' : ''}>下一页</button>`;
html += `<button class="page-btn" onclick="goPage(${totalPages})" ${page === totalPages ? 'disabled' : ''}>末页</button>`;
html += `<span class="page-info">共 ${total} 条 / 第 ${page}/${totalPages} 页</span>`;
el.innerHTML = html;
}
function goPage(p) {
currentPage = p;
loadResults();
window.scrollTo({ top: 0, behavior: 'smooth' });
}
// ===== 删除 =====
async function deleteResult(id) {
try {
const res = await fetch(`/api/results/${id}`, { method: 'DELETE' });
const json = await res.json();
if (!json.success) throw new Error(json.error);
toast('已删除', 'success');
await loadResults();
await loadFilters();
} catch (e) {
toast('删除失败: ' + e.message, 'error');
}
}
// ===== 清空 =====
function confirmClearAll() {
document.getElementById('confirmMsg').textContent = '确定要清空所有抓取结果吗?此操作不可撤销!';
document.getElementById('confirmOkBtn').onclick = async () => {
closeConfirm();
try {
const res = await fetch('/api/results', { method: 'DELETE' });
const json = await res.json();
if (!json.success) throw new Error(json.error);
toast('已清空所有结果', 'success');
await loadResults();
await loadFilters();
} catch (e) {
toast('清空失败: ' + e.message, 'error');
}
};
document.getElementById('confirmOverlay').classList.add('show');
}
function closeConfirm() {
document.getElementById('confirmOverlay').classList.remove('show');
}
// ===== 详情弹窗 =====
function openDetail(id) {
const r = window._resultData && window._resultData[id];
if (!r) return;
document.getElementById('detailTitle').textContent =
`${r.city || ''} ${r.section || ''} - ${r.type || ''} 抓取结果`;
document.getElementById('detailContent').innerHTML = renderDataAsTable(r.data);
document.getElementById('detailOverlay').classList.add('show');
}
function closeDetail() {
document.getElementById('detailOverlay').classList.remove('show');
}
document.addEventListener('keydown', e => {
if (e.key === 'Escape') { closeDetail(); closeConfirm(); }
});
document.getElementById('detailOverlay').addEventListener('click', function (e) {
if (e.target === this) closeDetail();
});
document.getElementById('confirmOverlay').addEventListener('click', function (e) {
if (e.target === this) closeConfirm();
});
// ===== Toast =====
function toast(msg, type = 'info') {
const wrap = document.getElementById('toastWrap');
const el = document.createElement('div');
el.className = `toast toast-${type}`;
el.textContent = msg;
wrap.appendChild(el);
setTimeout(() => el.remove(), 3000);
}
// ===== 数据表格渲染 =====
function renderDataAsTable(dataObj) {
const keyMap = {
'title': '标题',
'amount': '金额',
'date': '日期',
'url': '链接',
'result': '结果'
};
const t = (k) => keyMap[k] || k;
if (!dataObj || typeof dataObj !== 'object') {
return `<pre>${escHtml(JSON.stringify(dataObj, null, 2))}</pre>`;
}
let arraysToRender = [];
if (Array.isArray(dataObj)) {
arraysToRender.push({ title: '', items: dataObj });
} else {
let targetObj = dataObj;
if (dataObj.success !== undefined && dataObj.data) {
targetObj = dataObj.data;
}
if (Array.isArray(targetObj)) {
arraysToRender.push({ title: '', items: targetObj });
} else if (typeof targetObj === 'object') {
for (const key in targetObj) {
if (Array.isArray(targetObj[key])) {
arraysToRender.push({ title: key, items: targetObj[key] });
}
}
}
if (arraysToRender.length === 0 && Object.keys(targetObj).length > 0) {
return `<pre>${escHtml(JSON.stringify(dataObj, null, 2))}</pre>`;
}
}
if (arraysToRender.length === 0) {
return `<pre>${escHtml(JSON.stringify(dataObj, null, 2))}</pre>`;
}
let html = '';
for (const section of arraysToRender) {
const items = section.items;
if (!items || items.length === 0) continue;
// 如果数据中只有一个列表,则直接隐藏它多余的标题名称(如“结果”)
if (section.title && arraysToRender.length > 1) {
html += `<h4>${escHtml(t(section.title))}</h4>`;
}
const cols = new Set();
items.forEach(item => {
if (item && typeof item === 'object') {
Object.keys(item).forEach(k => cols.add(k));
}
});
const colArray = Array.from(cols);
if (colArray.length === 0) continue;
html += '<div class="table-responsive"><table class="data-table"><thead><tr>';
colArray.forEach(col => {
html += `<th>${escHtml(t(col))}</th>`;
});
html += '</tr></thead><tbody>';
items.forEach(item => {
html += '<tr>';
colArray.forEach(col => {
let val = item[col];
if (val === null || val === undefined) val = '-';
else if (typeof val === 'object') val = JSON.stringify(val);
else val = String(val);
if ((val.startsWith('http://') || val.startsWith('https://')) && !val.includes(' ')) {
html += `<td><a href="${escHtml(val)}" target="_blank" title="${escHtml(val)}">🔗 链接</a></td>`;
} else {
html += `<td>${escHtml(val)}</td>`;
}
});
html += '</tr>';
});
html += '</tbody></table></div>';
}
return html;
}
// ===== 工具函数 =====
function formatTime(iso) {
if (!iso) return '-';
const d = new Date(iso);
return d.toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' });
}
function escHtml(s) {
return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
</script>
</body>
</html>