init: Search Hub - 统一多搜索引擎聚合服务
This commit is contained in:
6
static/css/bootstrap.min.css
vendored
Normal file
6
static/css/bootstrap.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
665
static/css/style.css
Normal file
665
static/css/style.css
Normal file
@@ -0,0 +1,665 @@
|
||||
/* Search Hub — Deepseek 风格 */
|
||||
|
||||
:root {
|
||||
--bg: #f5f5f5;
|
||||
--card-bg: #ffffff;
|
||||
--text: #1a1a1a;
|
||||
--text-secondary: #666666;
|
||||
--border: #e5e5e5;
|
||||
--accent: #4f6ef7;
|
||||
--accent-hover: #3b5de7;
|
||||
--accent-light: #eef1ff;
|
||||
--success: #10b981;
|
||||
--danger: #ef4444;
|
||||
--radius: 12px;
|
||||
--radius-sm: 8px;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-size: 15px;
|
||||
line-height: 1.6;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
/* ===== 布局 ===== */
|
||||
|
||||
.container {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
/* ===== 顶部导航 ===== */
|
||||
|
||||
.navbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px 0;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.navbar-brand {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
text-decoration: none;
|
||||
letter-spacing: -0.3px;
|
||||
}
|
||||
|
||||
.navbar-links {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.navbar-links a {
|
||||
color: var(--text-secondary);
|
||||
text-decoration: none;
|
||||
font-size: 14px;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.navbar-links a:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* ===== 搜索区域 ===== */
|
||||
|
||||
.search-section {
|
||||
text-align: center;
|
||||
margin-bottom: 32px;
|
||||
transition: margin 0.3s ease;
|
||||
}
|
||||
|
||||
.search-section.has-results {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.search-title {
|
||||
font-size: 36px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 24px;
|
||||
letter-spacing: -0.5px;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.search-title.has-results {
|
||||
font-size: 20px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.search-box {
|
||||
display: flex;
|
||||
max-width: 640px;
|
||||
margin: 0 auto;
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
transition: box-shadow 0.2s, border-color 0.2s;
|
||||
}
|
||||
|
||||
.search-box:focus-within {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--accent-light);
|
||||
}
|
||||
|
||||
.search-box input {
|
||||
flex: 1;
|
||||
border: none;
|
||||
padding: 14px 20px;
|
||||
font-size: 15px;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.search-box input::placeholder {
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
.search-box button {
|
||||
border: none;
|
||||
padding: 14px 28px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
#search-btn {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
#search-btn:hover {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
.summarize-btn-inline {
|
||||
background: transparent;
|
||||
color: var(--accent);
|
||||
border-left: 1px solid var(--border) !important;
|
||||
}
|
||||
|
||||
.summarize-btn-inline:hover {
|
||||
background: var(--accent-light);
|
||||
color: var(--accent-hover);
|
||||
}
|
||||
|
||||
.search-box button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ===== 搜索源选择 ===== */
|
||||
|
||||
.source-bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
justify-content: center;
|
||||
margin-top: 16px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.source-label {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.source-tag {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
padding: 4px 14px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 20px;
|
||||
user-select: none;
|
||||
transition: all 0.15s;
|
||||
background: var(--card-bg);
|
||||
}
|
||||
|
||||
.source-tag:hover {
|
||||
color: var(--accent);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.source-active {
|
||||
color: var(--accent);
|
||||
border-color: var(--accent);
|
||||
background: var(--accent-light);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.source-disabled {
|
||||
color: #ccc;
|
||||
cursor: not-allowed;
|
||||
border-color: #eee;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.source-disabled:hover {
|
||||
color: #ccc;
|
||||
border-color: #eee;
|
||||
}
|
||||
|
||||
/* ===== 状态条 ===== */
|
||||
|
||||
#status-bar {
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
/* ===== 搜索历史 ===== */
|
||||
|
||||
#history-section {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
#history-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.history-tag {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
padding: 3px 12px;
|
||||
border-radius: 20px;
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border);
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.history-tag:hover {
|
||||
color: var(--accent);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
/* ===== 搜索结果 ===== */
|
||||
|
||||
#results-section {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.result-card {
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 16px 20px;
|
||||
margin-bottom: 10px;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.result-card:hover {
|
||||
border-color: #ccc;
|
||||
}
|
||||
|
||||
.result-title {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.result-title a {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.result-title a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.result-content {
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.6;
|
||||
margin-bottom: 8px;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.result-meta {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.result-url {
|
||||
color: var(--success);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.source-badge {
|
||||
font-size: 11px;
|
||||
padding: 1px 8px;
|
||||
border-radius: 4px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.badge-ok {
|
||||
color: var(--accent);
|
||||
background: var(--accent-light);
|
||||
}
|
||||
|
||||
/* ===== AI 总结 ===== */
|
||||
|
||||
#summary-section {
|
||||
margin-top: 8px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.summary-card {
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.summary-header {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 12px;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.summary-content {
|
||||
font-size: 14px;
|
||||
line-height: 1.8;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.summary-content ul {
|
||||
padding-left: 20px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.summary-content li {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.summary-content h1, .summary-content h2, .summary-content h3 {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin: 16px 0 8px;
|
||||
}
|
||||
|
||||
.summary-content strong {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.summary-meta {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.summary-input-placeholder {
|
||||
min-height: 60px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #999;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* ===== 总结中动画 ===== */
|
||||
|
||||
.typing-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.typing-indicator span {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
background: var(--accent);
|
||||
border-radius: 50%;
|
||||
animation: typing 1.2s infinite ease-in-out;
|
||||
}
|
||||
|
||||
.typing-indicator span:nth-child(2) { animation-delay: 0.2s; }
|
||||
.typing-indicator span:nth-child(3) { animation-delay: 0.4s; }
|
||||
|
||||
@keyframes typing {
|
||||
0%, 60%, 100% { opacity: 0.3; transform: scale(0.8); }
|
||||
30% { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
|
||||
/* ===== 空 / 加载 ===== */
|
||||
|
||||
#empty-state, #loading {
|
||||
text-align: center;
|
||||
padding: 40px 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* ===== 管理面板 ===== */
|
||||
|
||||
.admin-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 24px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.admin-layout { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
.admin-section {
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 20px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.admin-section-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.info-chip {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
padding: 6px 0;
|
||||
}
|
||||
|
||||
.source-card {
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 14px 16px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.source-name {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* ===== API 文档 ===== */
|
||||
|
||||
.api-endpoint {
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.api-endpoint:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.api-endpoint .method {
|
||||
display: inline-block;
|
||||
font-weight: 600;
|
||||
font-size: 11px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
margin-right: 8px;
|
||||
min-width: 48px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.method-get { color: var(--success); background: #ecfdf5; }
|
||||
.method-post { color: var(--accent); background: var(--accent-light); }
|
||||
|
||||
.api-endpoint .path {
|
||||
font-family: 'SF Mono', 'Monaco', 'Menlo', monospace;
|
||||
font-size: 12px;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.api-endpoint .desc {
|
||||
color: var(--text-secondary);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.api-endpoint .example {
|
||||
margin-top: 6px;
|
||||
font-family: 'SF Mono', 'Monaco', 'Menlo', monospace;
|
||||
font-size: 11px;
|
||||
color: #888;
|
||||
background: #f9f9f9;
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
white-space: pre-wrap;
|
||||
overflow-x: auto;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.api-endpoint .example.visible {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.show-example-btn {
|
||||
font-size: 11px;
|
||||
color: var(--accent);
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
background: none;
|
||||
padding: 2px 0;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* ===== 弹窗 ===== */
|
||||
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0; left: 0; right: 0; bottom: 0;
|
||||
background: rgba(0,0,0,0.3);
|
||||
z-index: 999;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
padding-top: 80px;
|
||||
}
|
||||
|
||||
.modal-box {
|
||||
background: var(--card-bg);
|
||||
border-radius: var(--radius);
|
||||
padding: 24px;
|
||||
max-width: 480px;
|
||||
width: 100%;
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.modal-box h2 {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin: 0 0 16px;
|
||||
}
|
||||
|
||||
/* ===== 表单 ===== */
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 4px;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.form-control {
|
||||
width: 100%;
|
||||
padding: 10px 14px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
background: var(--card-bg);
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.form-control:focus {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--accent-light);
|
||||
}
|
||||
|
||||
.form-hint {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* ===== 按钮(纯文字/幽灵风格) ===== */
|
||||
|
||||
.btn-ghost {
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--accent);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
padding: 0;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.btn-ghost:hover {
|
||||
color: var(--accent-hover);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.btn-ghost.danger {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.btn-ghost.danger:hover {
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
border: none;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
padding: 10px 24px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
.btn-primary:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ===== 按钮行 ===== */
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
justify-content: flex-end;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
/* ===== 响应式 ===== */
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.container { padding: 0 16px; }
|
||||
.search-title { font-size: 28px; }
|
||||
.search-title.has-results { font-size: 18px; }
|
||||
.search-box input { padding: 12px 16px; font-size: 14px; }
|
||||
.search-box button { padding: 12px 20px; font-size: 13px; }
|
||||
}
|
||||
|
||||
/* ===== 管理面板 — API 文档区域 ===== */
|
||||
#api-section .api-endpoint .path {
|
||||
word-break: break-all;
|
||||
}
|
||||
223
static/js/admin.js
Normal file
223
static/js/admin.js
Normal file
@@ -0,0 +1,223 @@
|
||||
/* Search Hub — 管理面板 */
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
let currentEditSource = null;
|
||||
|
||||
loadSources();
|
||||
loadStatus();
|
||||
loadApiDocs();
|
||||
loadUsage();
|
||||
|
||||
// ===== 搜索源 =====
|
||||
function loadSources() {
|
||||
fetch('/api/sources')
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (data) {
|
||||
var sources = data.sources || [];
|
||||
var html = '';
|
||||
sources.forEach(function (s) {
|
||||
var badge = s.available
|
||||
? '<span class="source-badge badge-ok">可用</span>'
|
||||
: '<span class="source-badge badge-fail">不可用</span>';
|
||||
var keyInfo = s.needs_api_key
|
||||
? (s.available ? ' | 密钥已配置' : ' | 需配置密钥')
|
||||
: ' | 无需密钥';
|
||||
var editBtn = s.needs_api_key
|
||||
? '<button class="btn-ghost" onclick="openEditModal(\'' + s.name + '\')">编辑</button>'
|
||||
: '';
|
||||
html += '<div class="source-card">';
|
||||
html += ' <div class="d-flex justify-content-between align-items-center">';
|
||||
html += ' <div><span class="source-name">' + escapeHtml(s.display_name || s.name) + '</span></div>';
|
||||
html += ' <div class="d-flex gap-3 align-items-center">' + badge + editBtn + '</div>';
|
||||
html += ' </div>';
|
||||
html += ' <div class="small text-muted mt-1">' + s.name + keyInfo + '</div>';
|
||||
html += '</div>';
|
||||
});
|
||||
document.getElementById('sources-list').innerHTML = html;
|
||||
});
|
||||
}
|
||||
|
||||
// ===== 服务状态 =====
|
||||
function loadStatus() {
|
||||
fetch('/api/status')
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (data) {
|
||||
document.getElementById('hub-status').textContent = data.status === 'ok' ? '运行中' : '异常';
|
||||
document.getElementById('hub-version').textContent = data.version || '-';
|
||||
document.getElementById('hub-history').textContent = data.history_count || 0;
|
||||
});
|
||||
}
|
||||
|
||||
// ===== API 文档 =====
|
||||
function loadApiDocs() {
|
||||
fetch('/api/docs')
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (data) {
|
||||
var endpoints = data.endpoints || [];
|
||||
var html = '';
|
||||
endpoints.forEach(function (ep) {
|
||||
var methodClass = ep.method === 'GET' ? 'method-get' : 'method-post';
|
||||
html += '<div class="api-endpoint">';
|
||||
html += ' <span class="method ' + methodClass + '">' + ep.method + '</span>';
|
||||
html += ' <span class="path">' + ep.path + '</span>';
|
||||
html += ' <div class="desc">' + escapeHtml(ep.desc) + '</div>';
|
||||
if (ep.example) {
|
||||
html += ' <button class="show-example-btn" onclick="toggleExample(this)">显示示例</button>';
|
||||
html += ' <div class="example">' + escapeHtml(ep.example) + '</div>';
|
||||
}
|
||||
html += '</div>';
|
||||
});
|
||||
document.getElementById('api-docs-list').innerHTML = html;
|
||||
});
|
||||
}
|
||||
|
||||
// ===== 服务用量(异步加载,不阻塞页面) =====
|
||||
function loadUsage() {
|
||||
var el = document.getElementById('usage-list');
|
||||
fetch('/api/admin/usage')
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (data) {
|
||||
var html = '';
|
||||
|
||||
// Tavily(优先显示 account 级别用量)
|
||||
var t = data.tavily || {};
|
||||
if (t.available) {
|
||||
var usage = t.key && t.key.limit > 0 ? t.key.usage : (t.account ? t.account.search_usage : 0);
|
||||
var limit = t.key && t.key.limit > 0 ? t.key.limit : (t.account ? t.account.plan_limit : 0);
|
||||
var remaining = t.key && t.key.limit > 0 ? t.key.remaining : (limit - usage);
|
||||
var pct = limit > 0 ? Math.round(usage / limit * 100) : 0;
|
||||
html += '<div class="info-chip">';
|
||||
html += ' <strong>Tavily</strong>';
|
||||
html += ' <div style="margin-top:4px">已用: ' + usage + ' / ' + limit;
|
||||
html += ' <span style="margin-left:8px;color:' + (pct > 80 ? '#ef4444' : '#10b981') + '">剩余: ' + remaining + '</span></div>';
|
||||
if (t.account) {
|
||||
html += ' <div class="small text-muted" style="margin-top:2px">账户搜索: ' + t.account.search_usage + ' / ' + t.account.plan_limit;
|
||||
if (t.account.paygo_usage > 0) html += ' | 按量: ' + t.account.paygo_usage;
|
||||
html += '</div>';
|
||||
}
|
||||
html += '</div>';
|
||||
} else {
|
||||
html += '<div class="info-chip"><strong>Tavily</strong><br><span class="text-muted">' + (t.error || '未配置') + '</span></div>';
|
||||
}
|
||||
|
||||
// 百度 VDB
|
||||
var b = data.baidu_vdb || {};
|
||||
if (b.available) {
|
||||
html += '<div class="info-chip" style="margin-top:6px">';
|
||||
html += ' <strong>百度 VDB</strong>';
|
||||
html += ' <div style="margin-top:4px">免费额度: ' + b.freeQuota + '</div>';
|
||||
html += '</div>';
|
||||
} else {
|
||||
html += '<div class="info-chip" style="margin-top:6px"><strong>百度 VDB</strong><br><span class="text-muted">' + (b.error || '未配置') + '</span></div>';
|
||||
}
|
||||
|
||||
el.innerHTML = html;
|
||||
})
|
||||
.catch(function () {
|
||||
el.innerHTML = '<div class="info-chip text-muted">用量查询暂不可用</div>';
|
||||
});
|
||||
}
|
||||
|
||||
window.toggleExample = function (btn) {
|
||||
var example = btn.nextElementSibling;
|
||||
if (example) {
|
||||
example.classList.toggle('visible');
|
||||
btn.textContent = example.classList.contains('visible') ? '收起' : '显示示例';
|
||||
}
|
||||
};
|
||||
|
||||
// ===== 编辑弹窗 =====
|
||||
window.openEditModal = function (name) {
|
||||
currentEditSource = name;
|
||||
var modal = document.getElementById('edit-modal');
|
||||
var title = document.getElementById('edit-modal-title');
|
||||
var body = document.getElementById('edit-modal-body');
|
||||
title.textContent = '加载中...';
|
||||
body.innerHTML = '<div class="form-hint">加载中...</div>';
|
||||
modal.style.display = 'flex';
|
||||
|
||||
fetch('/api/admin/sources/' + name)
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (data) {
|
||||
title.textContent = '编辑: ' + (data.display_name || name);
|
||||
var html = '';
|
||||
if (data.fields.length === 0) {
|
||||
html = '<div class="form-hint">该搜索源无需配置</div>';
|
||||
} else {
|
||||
data.fields.forEach(function (f) {
|
||||
html += '<div class="form-group">';
|
||||
html += ' <label>' + escapeHtml(f.label) + (f.required ? ' <span class="text-danger">*</span>' : '') + '</label>';
|
||||
if (f.type === 'password') {
|
||||
html += ' <input type="password" class="form-control" id="cfg-' + f.key + '"';
|
||||
html += ' placeholder="' + (f.has_value ? '已配置,输入新值覆盖' : '请输入') + '">';
|
||||
if (f.has_value) {
|
||||
html += ' <div class="form-hint">当前: ' + escapeHtml(f.value) + '</div>';
|
||||
}
|
||||
} else {
|
||||
html += ' <input type="text" class="form-control" id="cfg-' + f.key + '"';
|
||||
html += ' value="' + escapeHtml(f.value || '') + '">';
|
||||
}
|
||||
html += '</div>';
|
||||
});
|
||||
}
|
||||
body.innerHTML = html;
|
||||
})
|
||||
.catch(function (err) {
|
||||
body.innerHTML = '<div class="text-danger">加载失败: ' + err.message + '</div>';
|
||||
});
|
||||
};
|
||||
|
||||
window.closeEditModal = function () {
|
||||
document.getElementById('edit-modal').style.display = 'none';
|
||||
currentEditSource = null;
|
||||
};
|
||||
|
||||
window.saveConfig = function () {
|
||||
if (!currentEditSource) return;
|
||||
var body = document.getElementById('edit-modal-body');
|
||||
var inputs = body.querySelectorAll('input[id^="cfg-"]');
|
||||
var data = {};
|
||||
inputs.forEach(function (input) {
|
||||
var key = input.id.replace('cfg-', '');
|
||||
var val = input.value.trim();
|
||||
if (val) data[key] = val;
|
||||
});
|
||||
if (Object.keys(data).length === 0) {
|
||||
alert('请至少填写一个字段');
|
||||
return;
|
||||
}
|
||||
var saveBtn = document.getElementById('save-btn');
|
||||
saveBtn.disabled = true;
|
||||
saveBtn.textContent = '保存中...';
|
||||
fetch('/api/admin/sources/' + currentEditSource, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (result) {
|
||||
saveBtn.disabled = false;
|
||||
saveBtn.textContent = '保存';
|
||||
if (result.success) {
|
||||
closeEditModal();
|
||||
loadSources();
|
||||
loadStatus();
|
||||
} else {
|
||||
alert('保存失败: ' + (result.error || '未知错误'));
|
||||
}
|
||||
})
|
||||
.catch(function (err) {
|
||||
saveBtn.disabled = false;
|
||||
saveBtn.textContent = '保存';
|
||||
alert('请求失败: ' + err.message);
|
||||
});
|
||||
};
|
||||
|
||||
// ===== 辅助 =====
|
||||
function escapeHtml(str) {
|
||||
if (!str) return '';
|
||||
return String(str).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
||||
}
|
||||
})();
|
||||
363
static/js/app.js
Normal file
363
static/js/app.js
Normal file
@@ -0,0 +1,363 @@
|
||||
/* Search Hub — Deepseek 风格 UI */
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const searchInput = document.getElementById('search-input');
|
||||
const searchBtn = document.getElementById('search-btn');
|
||||
const summarizeBtn = document.getElementById('summarize-btn');
|
||||
const statusBar = document.getElementById('status-bar');
|
||||
const historyList = document.getElementById('history-list');
|
||||
const loading = document.getElementById('loading');
|
||||
const resultsSection = document.getElementById('results-section');
|
||||
const emptyState = document.getElementById('empty-state');
|
||||
const summarySection = document.getElementById('summary-section');
|
||||
const summaryContent = document.getElementById('summary-content');
|
||||
const summaryMeta = document.getElementById('summary-meta');
|
||||
const searchSection = document.getElementById('search-section');
|
||||
const searchTitle = document.getElementById('search-title');
|
||||
|
||||
let currentResults = [];
|
||||
let currentQuery = '';
|
||||
let currentSource = 'auto';
|
||||
let isSummarizing = false;
|
||||
|
||||
// 初始隐藏总结区域
|
||||
summarySection.style.display = 'none';
|
||||
|
||||
loadHistory();
|
||||
setupSourceSelector();
|
||||
|
||||
searchBtn.addEventListener('click', doSearch);
|
||||
searchInput.addEventListener('keydown', function (e) {
|
||||
if (e.key === 'Enter') doSearch();
|
||||
});
|
||||
summarizeBtn.addEventListener('click', doSummarize);
|
||||
|
||||
// ===== 搜索源选择 =====
|
||||
function setupSourceSelector() {
|
||||
document.querySelectorAll('.source-tag').forEach(function (el) {
|
||||
el.addEventListener('click', function () {
|
||||
var source = el.dataset.source;
|
||||
if (!source) return;
|
||||
if (el.classList.contains('source-disabled')) return;
|
||||
document.querySelectorAll('.source-tag').forEach(function (t) {
|
||||
t.classList.remove('source-active');
|
||||
});
|
||||
el.classList.add('source-active');
|
||||
currentSource = source;
|
||||
if (searchInput.value.trim()) {
|
||||
doSearch();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ===== 搜索(无总结) =====
|
||||
function doSearch() {
|
||||
const query = searchInput.value.trim();
|
||||
if (!query) return;
|
||||
|
||||
hideSummary();
|
||||
currentQuery = query;
|
||||
currentResults = [];
|
||||
showLoading(true);
|
||||
setStatus('');
|
||||
|
||||
searchSection.classList.add('has-results');
|
||||
searchTitle.textContent = query;
|
||||
|
||||
fetch('/api/search', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
query: query,
|
||||
source: currentSource,
|
||||
}),
|
||||
})
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (data) {
|
||||
showLoading(false);
|
||||
if (data.error) {
|
||||
setStatus(data.error);
|
||||
resultsSection.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
currentResults = data.results || [];
|
||||
renderResults(currentResults, data);
|
||||
loadHistory();
|
||||
})
|
||||
.catch(function (err) {
|
||||
showLoading(false);
|
||||
setStatus('请求失败: ' + err.message);
|
||||
});
|
||||
}
|
||||
|
||||
// ===== 渲染结果 =====
|
||||
function renderResults(results, data) {
|
||||
if (!results || results.length === 0) {
|
||||
emptyState.style.display = 'block';
|
||||
resultsSection.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
emptyState.style.display = 'none';
|
||||
|
||||
var sourceLabel = data.source || currentSource;
|
||||
var elapsed = data.elapsed || 0;
|
||||
setStatus('共找到 ' + results.length + ' 条结果(' + sourceLabel + ' | ' + elapsed + 's)');
|
||||
|
||||
document.querySelectorAll('.source-tag').forEach(function (t) {
|
||||
t.classList.remove('source-active');
|
||||
});
|
||||
var match = document.querySelector('.source-tag[data-source="' + sourceLabel + '"]');
|
||||
if (match) {
|
||||
match.classList.add('source-active');
|
||||
} else {
|
||||
var autoTag = document.querySelector('.source-tag[data-source="auto"]');
|
||||
if (autoTag) autoTag.classList.add('source-active');
|
||||
}
|
||||
|
||||
var html = '';
|
||||
results.forEach(function (item) {
|
||||
var dateStr = formatDate(item.published_date || '');
|
||||
var sourceTag = item.source ? '<span class="source-badge badge-ok">' + item.source + '</span>' : '';
|
||||
html += '<div class="result-card">';
|
||||
html += ' <div class="result-title"><a href="' + escapeHtml(item.url) + '" target="_blank" rel="noopener">' + escapeHtml(item.title) + '</a></div>';
|
||||
html += ' <div class="result-content">' + escapeHtml(item.content) + '</div>';
|
||||
html += ' <div class="result-meta">';
|
||||
html += ' <span class="result-url">' + escapeHtml(item.url) + '</span>';
|
||||
if (dateStr) html += ' <span>' + dateStr + '</span>';
|
||||
html += ' ' + sourceTag;
|
||||
html += ' </div>';
|
||||
html += '</div>';
|
||||
});
|
||||
resultsSection.innerHTML = html;
|
||||
}
|
||||
|
||||
// ===== AI 总结 =====
|
||||
function doSummarize() {
|
||||
if (isSummarizing) return;
|
||||
|
||||
// 无搜索词时取输入框内容
|
||||
if (!currentQuery) {
|
||||
var q = searchInput.value.trim();
|
||||
if (!q) return;
|
||||
currentQuery = q;
|
||||
currentSource = document.querySelector('.source-tag.source-active')?.dataset.source || 'auto';
|
||||
}
|
||||
|
||||
// 无搜索结果 → 先搜索再总结
|
||||
if (!currentResults || currentResults.length === 0) {
|
||||
doSearchThenSummarize();
|
||||
return;
|
||||
}
|
||||
|
||||
startSummarize();
|
||||
}
|
||||
|
||||
function doSearchThenSummarize() {
|
||||
var query = currentQuery || searchInput.value.trim();
|
||||
if (!query) return;
|
||||
|
||||
hideSummary();
|
||||
currentQuery = query;
|
||||
currentResults = [];
|
||||
showLoading(true);
|
||||
setStatus('');
|
||||
|
||||
searchSection.classList.add('has-results');
|
||||
searchTitle.textContent = query;
|
||||
summarizeBtn.disabled = true;
|
||||
summarizeBtn.textContent = '搜索中...';
|
||||
|
||||
fetch('/api/search', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
query: query,
|
||||
source: currentSource,
|
||||
}),
|
||||
})
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (data) {
|
||||
showLoading(false);
|
||||
summarizeBtn.disabled = false;
|
||||
summarizeBtn.textContent = 'AI 总结';
|
||||
if (data.error) {
|
||||
setStatus(data.error);
|
||||
resultsSection.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
currentResults = data.results || [];
|
||||
renderResults(currentResults, data);
|
||||
loadHistory();
|
||||
if (currentResults.length > 0) {
|
||||
startSummarize();
|
||||
}
|
||||
})
|
||||
.catch(function (err) {
|
||||
showLoading(false);
|
||||
summarizeBtn.disabled = false;
|
||||
summarizeBtn.textContent = 'AI 总结';
|
||||
setStatus('请求失败: ' + err.message);
|
||||
});
|
||||
}
|
||||
|
||||
function startSummarize() {
|
||||
if (!currentQuery || !currentResults || currentResults.length === 0) return;
|
||||
if (isSummarizing) return;
|
||||
isSummarizing = true;
|
||||
|
||||
summaryContent.innerHTML =
|
||||
'<div class="summary-input-placeholder" id="summary-placeholder">' +
|
||||
' <div class="typing-indicator"><span></span><span></span><span></span></div>' +
|
||||
' AI 总结中...' +
|
||||
'</div>';
|
||||
summaryMeta.textContent = '';
|
||||
summarySection.style.display = 'block';
|
||||
summarySection.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
summarizeBtn.disabled = true;
|
||||
summarizeBtn.textContent = '总结中...';
|
||||
|
||||
var fullSummary = '';
|
||||
|
||||
fetch('/api/ai-summarize/stream', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
query: currentQuery,
|
||||
results: currentResults,
|
||||
}),
|
||||
})
|
||||
.then(function (r) {
|
||||
if (!r.ok) return r.json().then(function (d) { throw new Error(d.error || '请求失败'); });
|
||||
var reader = r.body.getReader();
|
||||
var decoder = new TextDecoder();
|
||||
var buffer = '';
|
||||
|
||||
function read() {
|
||||
return reader.read().then(function (result) {
|
||||
if (result.done) return;
|
||||
buffer += decoder.decode(result.value, { stream: true });
|
||||
var parts = buffer.split('\n\n');
|
||||
buffer = parts.pop() || '';
|
||||
parts.forEach(function (part) {
|
||||
var lines = part.trim().split('\n');
|
||||
var eventType = '';
|
||||
var dataStr = '';
|
||||
lines.forEach(function (line) {
|
||||
if (line.startsWith('event: ')) eventType = line.slice(7).trim();
|
||||
else if (line.startsWith('data: ')) dataStr = line.slice(6).trim();
|
||||
});
|
||||
if (!dataStr) return;
|
||||
try {
|
||||
var parsed = JSON.parse(dataStr);
|
||||
if (eventType === 'delta') {
|
||||
fullSummary += parsed.content || '';
|
||||
summaryContent.innerHTML = markedSummary(fullSummary);
|
||||
} else if (eventType === 'meta') {
|
||||
summaryMeta.textContent = '模型: ' + (parsed.model || '') + ' | 耗时 ' + parsed.elapsed + 's';
|
||||
} else if (eventType === 'error') {
|
||||
setStatus('AI 总结失败: ' + (parsed.error || ''));
|
||||
}
|
||||
} catch (e) {}
|
||||
});
|
||||
return read();
|
||||
});
|
||||
}
|
||||
return read();
|
||||
})
|
||||
.then(function () {
|
||||
isSummarizing = false;
|
||||
summarizeBtn.disabled = false;
|
||||
summarizeBtn.textContent = 'AI 总结';
|
||||
})
|
||||
.catch(function (err) {
|
||||
isSummarizing = false;
|
||||
summarizeBtn.disabled = false;
|
||||
summarizeBtn.textContent = 'AI 总结';
|
||||
summaryContent.innerHTML = '<div class="text-danger">总结失败: ' + escapeHtml(err.message) + '</div>';
|
||||
});
|
||||
}
|
||||
|
||||
function hideSummary() {
|
||||
summarySection.style.display = 'none';
|
||||
summaryContent.innerHTML = '';
|
||||
summaryMeta.textContent = '';
|
||||
isSummarizing = false;
|
||||
}
|
||||
|
||||
// ===== 搜索历史 =====
|
||||
function loadHistory() {
|
||||
fetch('/api/history')
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (data) {
|
||||
var list = data.history || [];
|
||||
if (list.length === 0) {
|
||||
historyList.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
var html = '';
|
||||
list.forEach(function (item) {
|
||||
html += '<span class="history-tag">' + escapeHtml(item) + '</span>';
|
||||
});
|
||||
historyList.innerHTML = html;
|
||||
document.querySelectorAll('.history-tag').forEach(function (el) {
|
||||
el.addEventListener('click', function () {
|
||||
searchInput.value = el.textContent;
|
||||
doSearch();
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ===== 辅助 =====
|
||||
function showLoading(v) { loading.style.display = v ? 'block' : 'none'; }
|
||||
function setStatus(msg) { statusBar.textContent = msg; statusBar.style.display = msg ? 'block' : 'none'; }
|
||||
|
||||
function formatDate(dateStr) {
|
||||
if (!dateStr) return '';
|
||||
try {
|
||||
var d = new Date(dateStr);
|
||||
if (isNaN(d.getTime())) return dateStr;
|
||||
return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0');
|
||||
} catch (e) { return dateStr; }
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
if (!str) return '';
|
||||
return String(str).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function markedSummary(text) {
|
||||
if (!text) return '';
|
||||
var lines = text.split('\n');
|
||||
var html = '';
|
||||
var inList = false;
|
||||
lines.forEach(function (line) {
|
||||
line = escapeHtml(line);
|
||||
var hMatch = line.match(/^(#{1,3})\s+(.+)/);
|
||||
if (hMatch) {
|
||||
if (inList) { html += '</ul>'; inList = false; }
|
||||
html += '<h' + hMatch[1].length + '>' + hMatch[2] + '</h' + hMatch[1].length + '>';
|
||||
return;
|
||||
}
|
||||
var lMatch = line.match(/^[\s]*[-*+]\s+(.+)/);
|
||||
if (lMatch) {
|
||||
if (!inList) { html += '<ul>'; inList = true; }
|
||||
html += '<li>' + lMatch[1] + '</li>';
|
||||
return;
|
||||
}
|
||||
if (line.trim() === '') {
|
||||
if (inList) { html += '</ul>'; inList = false; }
|
||||
return;
|
||||
}
|
||||
if (inList) { html += '</ul>'; inList = false; }
|
||||
line = line.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
|
||||
html += '<p>' + line + '</p>';
|
||||
});
|
||||
if (inList) html += '</ul>';
|
||||
return html;
|
||||
}
|
||||
})();
|
||||
7
static/js/bootstrap.bundle.min.js
vendored
Normal file
7
static/js/bootstrap.bundle.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user