Files
HuiBrain/static/index.html
EduBrain Dev e8a861fda2 feat: 企业微信机器人集成到后端服务,网页端可配置和重启
- BotManager: 后端启动时自动检测配置并拉起机器人(后台线程)
- WeComBotService: 新增 start_async() 异步模式,支持嵌入事件循环
- settings API: 增加 bot 配置字段(enabled/bot_id/secret)
- settings API: 增加 bot/status 和 bot/restart 端点
- 前端设置页: 增加企业微信机器人配置卡片(启用开关/ID/Secret)
- 前端设置页: 增加机器人状态徽章和重启按钮
- Secret 脱敏显示,仅展示前4位
2026-04-14 13:42:04 +08:00

2878 lines
139 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>EduBrain - 知识库管理</title>
<style>
/* ═══════════════════════════════════════════════════════════════
EduBrain — Stripe-inspired Design System
系统字体栈,零外部依赖
═══════════════════════════════════════════════════════════════ */
/* ── 全局基础样式 ── */
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", "SF Pro Text",
"Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei",
sans-serif;
background: #ffffff;
color: #061b31;
overflow: hidden;
height: 100vh;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.font-mono {
font-family: "SF Mono", "Fira Code", "JetBrains Mono", Menlo, Monaco,
"Cascadia Code", "Source Code Pro", Consolas, monospace;
}
/* ── 滚动条美化 ── */
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: rgba(6,27,49,0.12); border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: rgba(6,27,49,0.2); }
/* ── 布局 ── */
.app-layout { display: flex; height: 100vh; }
.sidebar {
width: 240px;
min-width: 240px;
height: 100vh;
display: flex;
flex-direction: column;
background: #ffffff;
border-right: 1px solid #e5edf5;
z-index: 10;
}
.main-content {
flex: 1;
overflow: hidden;
background: #f6f9fc;
}
/* ── 侧边栏导航项 ── */
.nav-item {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 16px;
margin: 2px 8px;
border-radius: 6px;
cursor: pointer;
transition: all 0.15s;
color: #64748d;
font-size: 14px;
font-weight: 400;
user-select: none;
}
.nav-item:hover { background: rgba(83,58,253,0.05); color: #061b31; }
.nav-item.active {
background: rgba(83,58,253,0.08);
color: #533afd;
font-weight: 500;
}
.nav-item .icon { font-size: 16px; width: 22px; text-align: center; }
/* ── 页面内容区 ── */
.page { display: none; height: 100%; overflow-y: auto; padding: 32px 40px; }
.page.active { display: block; }
/* ── 页面标题 ── */
.page-title {
font-size: 28px;
font-weight: 300;
letter-spacing: -0.02em;
color: #061b31;
margin-bottom: 24px;
}
/* ── 输入框统一样式 ── */
.input-field {
background: #ffffff;
border: 1px solid #e5edf5;
color: #061b31;
border-radius: 4px;
padding: 10px 14px;
font-size: 14px;
outline: none;
transition: all 0.15s;
width: 100%;
font-family: inherit;
}
.input-field:focus {
border-color: #533afd;
box-shadow: 0 0 0 3px rgba(83,58,253,0.12);
}
.input-field::placeholder { color: #64748d; }
.input-field-lg {
height: 48px;
font-size: 16px;
border-radius: 6px;
padding: 0 16px;
}
/* ── 按钮样式 ── */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 8px 16px;
border-radius: 4px;
font-size: 14px;
font-weight: 400;
cursor: pointer;
border: none;
transition: all 0.15s;
white-space: nowrap;
font-family: inherit;
}
.btn:hover { transform: translateY(-1px); }
.btn:active { transform: translateY(0); }
.btn-primary {
background: #533afd;
color: #ffffff;
box-shadow: 0 1px 3px rgba(83,58,253,0.3);
}
.btn-primary:hover { background: #4434d4; box-shadow: 0 4px 12px rgba(83,58,253,0.35); }
.btn-secondary {
background: transparent;
color: #533afd;
border: 1px solid #b9b9f9;
}
.btn-secondary:hover { background: rgba(83,58,253,0.05); }
.btn-danger { background: #ea2261; color: #ffffff; }
.btn-danger:hover { background: #d41f57; }
.btn-sm { padding: 4px 12px; font-size: 12px; }
.btn:disabled { opacity: 0.4; cursor: not-allowed; transform: none; }
/* ── 卡片样式 ── */
.card {
background: #ffffff;
border-radius: 6px;
padding: 24px;
border: 1px solid #e5edf5;
box-shadow: rgba(50,50,93,0.08) 0px 15px 35px 0px, rgba(0,0,0,0.04) 0px 5px 15px 0px;
transition: box-shadow 0.2s, transform 0.2s;
}
.card:hover {
box-shadow: rgba(50,50,93,0.15) 0px 30px 45px -15px, rgba(0,0,0,0.06) 0px 18px 36px -18px;
transform: translateY(-2px);
}
/* ── 表格样式 ── */
.data-table {
width: 100%;
border-collapse: collapse;
font-size: 14px;
}
.data-table th {
text-align: left;
padding: 12px 16px;
color: #64748d;
font-weight: 500;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.04em;
border-bottom: 1px solid #e5edf5;
white-space: nowrap;
}
.data-table td {
padding: 12px 16px;
border-bottom: 1px solid #f6f9fc;
color: #061b31;
}
.data-table tr:hover td { background: #f6f9fc; }
.data-table tr:last-child td { border-bottom: none; }
/* ── 骨架屏动画 ── */
@keyframes shimmer {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
.skeleton {
background: linear-gradient(90deg, #f6f9fc 25%, #e5edf5 50%, #f6f9fc 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
border-radius: 4px;
height: 16px;
margin: 4px 0;
}
.skeleton-line { width: 100%; }
.skeleton-short { width: 60%; }
.skeleton-title { width: 40%; height: 20px; margin-bottom: 8px; }
/* ── 旋转加载动画 ── */
@keyframes spin { to { transform: rotate(360deg); } }
.spinner {
width: 32px; height: 32px;
border: 3px solid #e5edf5;
border-top-color: #533afd;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
.spinner-sm { width: 16px; height: 16px; border-width: 2px; }
/* ── Toast 通知 ── */
.toast-container {
position: fixed;
top: 16px;
right: 16px;
z-index: 9999;
display: flex;
flex-direction: column;
gap: 8px;
}
.toast {
padding: 14px 22px;
border-radius: 6px;
font-size: 14px;
color: #ffffff;
font-weight: 500;
box-shadow: rgba(50,50,93,0.25) 0px 30px 45px -30px, rgba(0,0,0,0.1) 0px 18px 36px -18px;
animation: toastIn 0.3s ease-out;
max-width: 380px;
word-break: break-word;
}
.toast.removing { animation: toastOut 0.3s ease-in forwards; }
.toast-success { background: #15be53; }
.toast-error { background: #ea2261; }
.toast-info { background: #061b31; }
@keyframes toastIn {
from { opacity: 0; transform: translateX(100px); }
to { opacity: 1; transform: translateX(0); }
}
@keyframes toastOut {
from { opacity: 1; transform: translateX(0); }
to { opacity: 0; transform: translateX(100px); }
}
/* ── 确认弹窗 ── */
.modal-overlay {
position: fixed; inset: 0;
background: rgba(6,27,49,0.4);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
z-index: 9998;
display: flex;
align-items: center;
justify-content: center;
animation: fadeIn 0.2s;
}
.modal-box {
background: #ffffff;
border-radius: 8px;
padding: 28px;
max-width: 420px;
width: 90%;
box-shadow: rgba(50,50,93,0.25) 0px 30px 45px -30px, rgba(0,0,0,0.1) 0px 18px 36px -18px;
animation: scaleIn 0.2s;
}
@keyframes fadeIn { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } }
@keyframes scaleIn { from { opacity: 0; transform: scale(0.95); } to { opacity: 1; transform: scale(1); } }
/* ── 拖拽上传区域 ── */
.drop-zone {
border: 2px dashed #b9b9f9;
border-radius: 8px;
padding: 48px 24px;
text-align: center;
cursor: pointer;
transition: all 0.2s;
background: rgba(83,58,253,0.02);
}
.drop-zone:hover, .drop-zone.drag-over {
border-color: #533afd;
background: rgba(83,58,253,0.05);
}
/* ── Tab 切换 ── */
.tab-bar {
display: flex;
border-bottom: 1px solid #e5edf5;
margin-bottom: 24px;
gap: 0;
}
.tab-btn {
padding: 10px 20px;
font-size: 14px;
border: none;
background: transparent;
color: #64748d;
cursor: pointer;
border-bottom: 2px solid transparent;
transition: all 0.15s;
font-family: inherit;
font-weight: 400;
}
.tab-btn:hover { color: #061b31; }
.tab-btn.active { color: #533afd; border-bottom-color: #533afd; }
/* ── 高亮文本 ── */
.highlight { background: rgba(83,58,253,0.1); color: #061b31; padding: 1px 4px; border-radius: 3px; }
/* ── 分页控件 ── */
.pagination {
display: flex;
align-items: center;
gap: 4px;
}
.page-btn {
width: 32px; height: 32px;
display: flex; align-items: center; justify-content: center;
border-radius: 4px;
border: none;
background: transparent;
color: #061b31;
cursor: pointer;
font-size: 13px;
transition: all 0.15s;
font-family: inherit;
}
.page-btn:hover { background: rgba(83,58,253,0.08); }
.page-btn.active { background: #533afd; color: #ffffff; }
.page-btn:disabled { opacity: 0.3; cursor: not-allowed; }
/* ── 状态标签 ── */
.badge {
display: inline-block;
padding: 2px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 400;
}
.badge-pending { background: #f6f9fc; color: #64748d; border: 1px solid #e5edf5; }
.badge-processing { background: rgba(250,173,20,0.1); color: #c97a0c; border: 1px solid rgba(250,173,20,0.3); }
.badge-completed { background: rgba(21,190,83,0.1); color: #108c3d; border: 1px solid rgba(21,190,83,0.3); }
.badge-failed { background: rgba(234,34,97,0.1); color: #c41e52; border: 1px solid rgba(234,34,97,0.3); }
/* ── 设置分组标题 ── */
.section-title {
font-size: 17px;
font-weight: 600;
color: #061b31;
margin-bottom: 16px;
padding-bottom: 10px;
border-bottom: 1px solid #e5edf5;
letter-spacing: -0.01em;
}
/* ── 表单标签 ── */
.form-label {
display: block;
font-size: 13px;
color: #64748d;
margin-bottom: 6px;
font-weight: 500;
}
.form-group { margin-bottom: 16px; }
/* ── 内联展开详情 ── */
.detail-row { display: none; }
.detail-row.open { display: table-row; }
.detail-content {
background: #f6f9fc;
border-radius: 6px;
padding: 16px;
margin: 8px 0;
}
/* ── 统计数字 ── */
.stat-number {
font-size: 40px;
font-weight: 300;
color: #061b31;
line-height: 1;
letter-spacing: -0.02em;
}
.stat-label {
font-size: 13px;
color: #64748d;
margin-bottom: 4px;
}
/* ── 文件列表项 ── */
.file-list-item {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 14px;
background: #f6f9fc;
border-radius: 6px;
margin-bottom: 6px;
transition: all 0.15s;
}
.file-list-item:hover { background: #e5edf5; }
.file-list-item .file-icon { font-size: 20px; }
.file-list-item .file-info { flex: 1; min-width: 0; }
.file-list-item .file-name {
font-size: 14px;
font-weight: 500;
color: #061b31;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.file-list-item .file-size {
font-size: 12px;
color: #64748d;
}
.file-list-item .file-remove {
width: 24px; height: 24px;
display: flex; align-items: center; justify-content: center;
border-radius: 4px;
border: none;
background: rgba(234,34,97,0.08);
color: #ea2261;
cursor: pointer;
font-size: 14px;
transition: all 0.15s;
}
.file-list-item .file-remove:hover { background: rgba(234,34,97,0.15); }
/* ── 导入进度条 ── */
.import-progress-item {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 0;
font-size: 13px;
}
.import-progress-item .status-icon { font-size: 16px; }
.import-progress-item .file-name { flex: 1; color: #061b31; }
.import-progress-item .status-text { color: #64748d; font-size: 12px; }
.import-progress-item.success .status-icon { color: #15be53; }
.import-progress-item.error .status-icon { color: #ea2261; }
.import-progress-item.importing .status-icon { color: #533afd; }
/* ── 搜索结果卡片 ── */
.search-result-card {
background: #ffffff;
border-radius: 6px;
padding: 20px 24px;
border: 1px solid #e5edf5;
box-shadow: rgba(50,50,93,0.08) 0px 15px 35px 0px, rgba(0,0,0,0.04) 0px 5px 15px 0px;
transition: all 0.2s;
margin-bottom: 12px;
}
.search-result-card:hover {
box-shadow: rgba(50,50,93,0.15) 0px 30px 45px -15px, rgba(0,0,0,0.06) 0px 18px 36px -18px;
transform: translateY(-2px);
border-color: #b9b9f9;
}
/* ── 响应式 ── */
.menu-toggle { display: none; position: fixed; top: 12px; left: 12px; z-index: 20; background: #ffffff; border: 1px solid #e5edf5; border-radius: 6px; width: 40px; height: 40px; cursor: pointer; font-size: 20px; color: #061b31; align-items: center; justify-content: center; box-shadow: rgba(50,50,93,0.1) 0px 4px 12px 0px; }
.sidebar-overlay { display: none; position: fixed; inset: 0; background: rgba(6,27,49,0.3); z-index: 9; }
.sidebar-overlay.active { display: block; }
@media (max-width: 1023px) {
.menu-toggle { display: flex; }
.sidebar { position: fixed; left: -260px; top: 0; z-index: 15; transition: left 0.3s ease; box-shadow: none; }
.sidebar.open { left: 0; box-shadow: rgba(50,50,93,0.2) 4px 0 20px 0px; }
.main-content { margin-left: 0; }
.page { padding: 20px 16px; padding-top: 60px; }
}
/* ── 窄屏适配 ── */
@media (max-width: 768px) {
.card { padding: 16px !important; }
.page-title { font-size: 22px !important; }
.section-title { font-size: 15px !important; margin-bottom: 12px !important; }
.input-field { font-size: 14px !important; padding: 8px 12px !important; }
.btn { padding: 8px 14px !important; font-size: 13px !important; }
.btn-sm { padding: 4px 10px !important; font-size: 12px !important; }
.drop-zone { padding: 32px 16px !important; }
.drop-zone p { font-size: 14px !important; }
.badge { font-size: 11px !important; padding: 2px 8px !important; }
table { font-size: 13px !important; }
th, td { padding: 8px 10px !important; }
#ocr-search-form { flex-wrap: wrap; }
#ocr-search-form > :first-child { width: 100% !important; flex: none !important; min-width: 0 !important; }
#ocr-search-form > :nth-child(3) { display: none; }
#ocr-search-form > :nth-child(2) { flex: 1; }
#ocr-search-form > :nth-child(4) { }
}
@media (max-width: 480px) {
.page { padding: 12px 10px !important; }
.card { padding: 12px !important; border-radius: 6px !important; }
.page-title { font-size: 20px !important; }
.input-field { font-size: 16px !important; } /* 防止 iOS 缩放 */
.btn { width: 100% !important; }
.drop-zone { padding: 24px 12px !important; }
.drop-zone span[style*="font-size:40px"] { font-size: 28px !important; }
}
</style>
</head>
<body>
<!-- ═══════════════════════════════════════════════════════════════
应用主布局:左侧侧边栏 + 右侧主内容区
═══════════════════════════════════════════════════════════════ -->
<div class="app-layout">
<!-- ── 移动端菜单按钮 ── -->
<button class="menu-toggle" onclick="toggleSidebar()">&#x2630;</button>
<div class="sidebar-overlay" id="sidebar-overlay" onclick="toggleSidebar()"></div>
<!-- ── 侧边栏 ── -->
<aside class="sidebar">
<!-- Logo 区域 -->
<div style="padding: 20px 20px 16px; border-bottom: 1px solid #e5edf5;">
<div style="display:flex; align-items:center; gap:8px;">
<span style="font-size:20px; font-weight:600; color:#061b31; letter-spacing:-0.02em;">EduBrain</span>
</div>
</div>
<!-- 导航菜单 -->
<nav style="flex:1; padding:12px 0; overflow-y:auto;">
<div class="nav-item active" data-page="search" onclick="switchPage('search')">
<span class="icon">&#x1F50D;</span>
<span>语义搜索</span>
</div>
<div class="nav-item" data-page="pages" onclick="switchPage('pages')">
<span class="icon">&#x1F4C4;</span>
<span>知识页面</span>
</div>
<div class="nav-item" data-page="import" onclick="switchPage('import')">
<span class="icon">&#x1F4E4;</span>
<span>文件导入</span>
</div>
<div class="nav-item" data-page="ocr" onclick="switchPage('ocr')">
<span class="icon">&#x1F4F7;</span>
<span>图片 OCR</span>
</div>
<div class="nav-item" data-page="settings" onclick="switchPage('settings')">
<span class="icon">&#x2699;&#xFE0F;</span>
<span>系统设置</span>
</div>
</nav>
<!-- 底部版本信息 -->
<div style="padding:12px 20px; border-top:1px solid #e5edf5; color:#64748d; font-size:11px;">
EduBrain v0.1.0
</div>
</aside>
<!-- ── 主内容区 ── -->
<main class="main-content">
<!-- ═══════════════════════════════════════════════════════════
页面 1语义搜索
═══════════════════════════════════════════════════════════ -->
<div id="page-search" class="page active">
<h2 class="page-title">语义搜索</h2>
<!-- 搜索输入区 -->
<div style="margin-bottom:24px;">
<div style="display:flex; gap:12px; align-items:center;">
<input id="search-query" type="text" class="input-field input-field-lg"
placeholder="输入你要查找的知识点..."
style="flex:1;"
onkeydown="if(event.key==='Enter') doSearch()">
<button class="btn btn-primary" onclick="doSearch()" style="height:48px; padding:0 24px;">
搜索
</button>
</div>
<!-- 过滤条件行 -->
<div style="display:flex; gap:12px; margin-top:12px;">
<input id="search-course" type="text" class="input-field" style="max-width:200px"
placeholder="课程名称">
<input id="search-teacher" type="text" class="input-field" style="max-width:200px"
placeholder="讲师名称">
<select id="search-topk" class="input-field" style="max-width:140px">
<option value="5">5 条结果</option>
<option value="10" selected>10 条结果</option>
<option value="20">20 条结果</option>
</select>
</div>
</div>
<!-- 搜索结果区域 -->
<div id="search-results">
<!-- 空状态 -->
<div id="search-empty" style="display:flex; flex-direction:column; align-items:center; justify-content:center; padding:80px 0; color:#64748d;">
<span style="font-size:48px; margin-bottom:16px; opacity:0.5;">&#x1F50D;</span>
<p style="font-size:17px; color:#061b31; font-weight:500;">输入关键词开始搜索</p>
</div>
<!-- 加载状态 -->
<div id="search-loading" style="display:none; flex-direction:column; align-items:center; justify-content:center; padding:80px 0;">
<div class="spinner" style="margin-bottom:16px;"></div>
<p style="color:#64748d;">搜索中...</p>
</div>
<!-- 结果列表 -->
<div id="search-list" style="display:none;"></div>
</div>
</div>
<!-- ═══════════════════════════════════════════════════════════
页面 2知识页面管理
═══════════════════════════════════════════════════════════ -->
<div id="page-pages" class="page">
<!-- 顶部操作栏 -->
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:24px;">
<h2 class="page-title" style="margin-bottom:0;">知识页面管理</h2>
<div style="display:flex; gap:10px;">
<input id="pages-keyword" type="text" class="input-field" style="max-width:240px"
placeholder="搜索页面..."
onkeydown="if(event.key==='Enter') loadPages()">
<button class="btn btn-primary" onclick="loadPages()">搜索</button>
<button class="btn btn-secondary" onclick="showNewPageModal()">+ 新建页面</button>
</div>
</div>
<!-- 统计栏 -->
<div style="display:flex; gap:16px; margin-bottom:24px;">
<div class="card" style="flex:1; display:flex; align-items:center; gap:16px;">
<div>
<div class="stat-label">总页面数</div>
<div id="stat-pages" class="stat-number font-mono">0</div>
</div>
</div>
<div class="card" style="flex:1; display:flex; align-items:center; gap:16px;">
<div>
<div class="stat-label">总分块数</div>
<div id="stat-chunks" class="stat-number font-mono">0</div>
</div>
</div>
</div>
<!-- 表格区域 -->
<div class="card" style="padding:0; overflow:hidden;">
<!-- 骨架屏 -->
<div id="pages-skeleton" style="padding:20px;">
<div class="skeleton skeleton-title"></div>
<div class="skeleton skeleton-line"></div>
<div class="skeleton skeleton-short"></div>
<div class="skeleton skeleton-line"></div>
<div class="skeleton skeleton-title"></div>
<div class="skeleton skeleton-line"></div>
</div>
<!-- 表格 -->
<div id="pages-table-wrap" style="display:none; overflow-x:auto;">
<table class="data-table">
<thead>
<tr>
<th style="width:50px">#</th>
<th>标题</th>
<th>课程</th>
<th>讲师</th>
<th>日期</th>
<th style="width:140px">操作</th>
</tr>
</thead>
<tbody id="pages-tbody"></tbody>
</table>
</div>
</div>
<!-- 分页 -->
<div id="pages-pagination" style="display:none; align-items:center; justify-content:space-between; margin-top:16px;">
<span id="pages-page-info" style="color:#64748d; font-size:13px;"></span>
<div id="pages-pager" class="pagination"></div>
</div>
</div>
<!-- ═══════════════════════════════════════════════════════════
页面 3文件导入
═══════════════════════════════════════════════════════════ -->
<div id="page-import" class="page">
<h2 class="page-title">文件导入</h2>
<!-- Tab 切换 -->
<div class="tab-bar">
<button class="tab-btn active" data-tab="single" onclick="switchImportTab('single')">文件导入</button>
<button class="tab-btn" data-tab="batch" onclick="switchImportTab('batch')">批量导入</button>
</div>
<!-- 文件导入(支持多文件) -->
<div id="import-tab-single">
<div class="card" style="margin-bottom:16px;">
<div id="import-drop-zone" class="drop-zone"
ondragover="event.preventDefault(); this.classList.add('drag-over')"
ondragleave="this.classList.remove('drag-over')"
ondrop="handleFileDrop(event)"
onclick="document.getElementById('import-file-input').click()">
<input id="import-file-input" type="file" accept=".md,.txt,.docx" multiple
style="display:none;"
onchange="handleFileSelect(event)">
<div style="color:#64748d;">
<span style="font-size:40px; display:block; margin-bottom:12px; opacity:0.6;">&#x1F4C1;</span>
<p style="font-size:17px; color:#061b31; font-weight:500; margin-bottom:4px;">拖拽文件到此处,或点击选择</p>
<p style="font-size:14px;">支持 .md、.txt、.docx 格式,可同时选择多个文件</p>
</div>
</div>
<!-- 已选文件列表 -->
<div id="import-file-list" style="display:none; margin-top:16px;"></div>
<div style="display:grid; grid-template-columns:repeat(3,1fr); gap:16px; margin-top:20px;">
<div class="form-group">
<label class="form-label">课程名称</label>
<input id="import-course" type="text" class="input-field" placeholder="可选">
</div>
<div class="form-group">
<label class="form-label">讲师名称</label>
<input id="import-teacher" type="text" class="input-field" placeholder="可选">
</div>
<div class="form-group">
<label class="form-label">直播日期</label>
<input id="import-date" type="date" class="input-field">
</div>
</div>
<div style="display:flex; justify-content:flex-end; margin-top:20px;">
<button id="import-btn" class="btn btn-primary" onclick="importFiles()" disabled>
导入
</button>
</div>
</div>
</div>
<!-- 批量导入 -->
<div id="import-tab-batch" style="display:none;">
<div class="card" style="margin-bottom:16px;">
<div style="display:grid; grid-template-columns:repeat(3,1fr); gap:16px;">
<div class="form-group">
<label class="form-label">目录名称</label>
<input id="batch-dir" type="text" class="input-field"
placeholder="相对于 transcripts 目录">
</div>
<div class="form-group">
<label class="form-label">课程名称</label>
<input id="batch-course" type="text" class="input-field" placeholder="可选">
</div>
<div class="form-group">
<label class="form-label">讲师名称</label>
<input id="batch-teacher" type="text" class="input-field" placeholder="可选">
</div>
</div>
<div style="display:flex; justify-content:flex-end; margin-top:20px;">
<button id="batch-btn" class="btn btn-primary" onclick="batchImport()">
批量导入
</button>
</div>
</div>
</div>
<!-- 导入结果 -->
<div id="import-result" class="card" style="margin-bottom:16px; display:none;">
<div id="import-result-content"></div>
</div>
<!-- 导出功能 -->
<div class="card">
<h3 class="section-title">导出知识库</h3>
<div style="display:flex; align-items:center; gap:12px;">
<select id="export-format" class="input-field" style="max-width:160px">
<option value="json">JSON 格式</option>
<option value="markdown">Markdown 格式</option>
</select>
<input id="export-course" type="text" class="input-field" style="max-width:200px"
placeholder="按课程过滤(可选)">
<button class="btn btn-secondary" onclick="exportData()">
导出
</button>
</div>
<div id="export-result" style="margin-top:12px; font-size:13px; display:none;"></div>
</div>
</div>
<!-- ═══════════════════════════════════════════════════════════
页面 4图片 OCR
═══════════════════════════════════════════════════════════ -->
<div id="page-ocr" class="page">
<h2 class="page-title">图片 OCR</h2>
<!-- Tab 切换 -->
<div class="tab-bar">
<button class="tab-btn active" data-tab="upload" onclick="switchOcrTab('upload')">上传识别</button>
<button class="tab-btn" data-tab="manage" onclick="switchOcrTab('manage')">图片管理</button>
</div>
<!-- ====== Tab: 上传识别 ====== -->
<div id="ocr-tab-upload">
<!-- 图片搜索 -->
<div class="card" style="margin-bottom:16px;">
<h3 class="section-title">图片搜索</h3>
<div id="ocr-search-form" style="display:flex; gap:10px; flex-wrap:wrap; align-items:center;">
<input id="ocr-search-keyword" type="text" class="input-field"
placeholder="输入关键字搜索已识别的图片..."
onkeydown="if(event.key==='Enter') searchImages()"
style="flex:1; min-width:200px;">
<div style="display:flex; align-items:center; gap:4px; background:#fff; border:1px solid #e5edf5; border-radius:4px; padding:0 10px; height:36px;">
<label for="ocr-search-limit" style="font-size:13px; color:#64748d; white-space:nowrap;">最多</label>
<input id="ocr-search-limit" type="number" value="5" min="1" max="100"
class="input-field" style="width:48px; text-align:center; border:none; padding:0; background:transparent;"
title="返回数量">
<span style="font-size:13px; color:#64748d; white-space:nowrap;"></span>
</div>
<input type="hidden" id="ocr-search-sort" value="time_desc">
<button class="btn btn-primary" onclick="searchImages()">搜索</button>
</div>
<div id="ocr-search-results" style="margin-top:16px; display:none;"></div>
</div>
<!-- 拖拽上传区域 -->
<div class="card" style="margin-bottom:16px;">
<input id="ocr-file-input" type="file" accept=".png,.jpg,.jpeg,.bmp,.webp" multiple
style="display:none;" onchange="handleOcrFileChange(event)">
<div id="ocr-drop-zone" class="drop-zone"
ondragover="event.preventDefault(); this.classList.add('drag-over')"
ondragleave="this.classList.remove('drag-over')"
ondrop="handleOcrFileDrop(event)"
onclick="ocrPickFiles()">
<div style="color:#64748d;">
<span style="font-size:40px; display:block; margin-bottom:12px; opacity:0.6;">&#x1F4F7;</span>
<p style="font-size:17px; color:#061b31; font-weight:500; margin-bottom:4px;">拖拽图片到此处,或点击选择文件</p>
<p style="font-size:14px;">支持 .png .jpg .jpeg .bmp .webp &nbsp;|&nbsp; 可选择多个文件</p>
</div>
</div>
<div style="display:flex; gap:10px; margin-top:16px; flex-wrap:wrap;">
<button class="btn btn-secondary" onclick="event.stopPropagation(); ocrPickFiles()">
&#x1F4C1; 选择文件
</button>
<button class="btn btn-secondary" onclick="event.stopPropagation(); ocrPickFolder()">
&#x1F4C2; 选择文件夹
</button>
<button id="ocr-recognize-all-btn" class="btn btn-primary" onclick="startBatchRecognize()" disabled>
识别全部
</button>
<button class="btn btn-secondary" onclick="clearOcrQueue()">
清空列表
</button>
</div>
</div>
<!-- 识别队列 -->
<div id="ocr-queue-card" class="card" style="margin-bottom:16px; display:none;">
<h3 class="section-title">识别队列</h3>
<!-- 进度条 -->
<div id="ocr-progress-wrap" style="margin-bottom:12px; display:none;">
<div style="display:flex; justify-content:space-between; font-size:13px; color:#64748d; margin-bottom:6px;">
<span id="ocr-progress-text">0/0 完成</span>
<span id="ocr-progress-pct">0%</span>
</div>
<div style="background:#e5edf5; border-radius:4px; height:6px; overflow:hidden;">
<div id="ocr-progress-bar" style="height:100%; width:0%; background:linear-gradient(90deg,#533afd,#15be53); border-radius:4px; transition:width 0.3s ease;"></div>
</div>
</div>
<!-- 队列列表 -->
<div id="ocr-queue"></div>
</div>
<!-- 识别结果 -->
<div id="ocr-results-card" class="card" style="margin-bottom:16px; display:none;">
<h3 class="section-title">识别结果</h3>
<div id="ocr-results"></div>
</div>
</div><!-- /ocr-tab-upload -->
<!-- ====== Tab: 图片管理 ====== -->
<div id="ocr-tab-manage" style="display:none;">
<!-- 操作栏 -->
<div class="card" style="margin-bottom:16px;">
<div style="display:flex; align-items:center; justify-content:space-between; flex-wrap:wrap; gap:12px;">
<div style="display:flex; align-items:center; gap:12px;">
<span style="font-size:14px; color:#64748d;"><strong id="manage-total-count" style="color:#1d1d1f;">0</strong> 张图片</span>
<span id="manage-dup-info" style="font-size:13px; color:#f59e0b; display:none;">发现重复图片</span>
</div>
<div style="display:flex; gap:10px;">
<button class="btn btn-primary" onclick="dedupImages()" id="dedup-btn">&#x1F504; 一键去重</button>
<button class="btn btn-secondary" onclick="loadManageImages()">&#x1F504; 刷新</button>
</div>
</div>
</div>
<!-- 图片列表 -->
<div id="manage-images-list"></div>
<!-- 分页 -->
<div id="manage-pagination" style="display:flex; justify-content:center; margin-top:16px;"></div>
</div><!-- /ocr-tab-manage -->
</div>
<!-- ═══════════════════════════════════════════════════════════
页面 5系统设置
═══════════════════════════════════════════════════════════ -->
<div id="page-settings" class="page">
<h2 class="page-title">系统设置</h2>
<!-- 嵌入模型配置 -->
<div class="card" style="margin-bottom:16px;">
<h3 class="section-title">嵌入模型配置</h3>
<div style="display:grid; grid-template-columns:1fr 1fr; gap:0 24px;">
<div class="form-group">
<label class="form-label">提供商</label>
<select id="setting-embed-provider" class="input-field" onchange="toggleEmbedFields()">
<option value="minimax">MiniMax</option>
<option value="openai">OpenAI</option>
<option value="zhipu">智谱</option>
<option value="dashscope">DashScope</option>
<option value="local_bge">本地 BGE</option>
</select>
</div>
<div class="form-group">
<label class="form-label">模型名称</label>
<input id="setting-embed-model" type="text" class="input-field">
</div>
<div class="form-group">
<label class="form-label">向量维度</label>
<input id="setting-embed-dim" type="number" class="input-field">
</div>
<!-- OpenAI API Key -->
<div id="field-openai-key" class="form-group" style="display:none;">
<label class="form-label">OpenAI API Key</label>
<input id="setting-openai-key" type="password" class="input-field" placeholder="sk-...">
</div>
<!-- OpenAI Base URL -->
<div id="field-openai-url" class="form-group" style="display:none;">
<label class="form-label">OpenAI Base URL</label>
<input id="setting-openai-url" type="text" class="input-field">
</div>
<!-- 智谱 API Key -->
<div id="field-zhipu-key" class="form-group" style="display:none;">
<label class="form-label">智谱 API Key</label>
<input id="setting-zhipu-key" type="password" class="input-field">
</div>
<!-- DashScope API Key -->
<div id="field-dashscope-key" class="form-group" style="display:none;">
<label class="form-label">DashScope API Key</label>
<input id="setting-dashscope-key" type="password" class="input-field">
</div>
<!-- MiniMax API Key -->
<div id="field-minimax-key" class="form-group">
<label class="form-label">MiniMax API Key</label>
<input id="setting-minimax-key" type="password" class="input-field">
</div>
<!-- MiniMax Base URL -->
<div id="field-minimax-url" class="form-group">
<label class="form-label">MiniMax Base URL</label>
<input id="setting-minimax-url" type="text" class="input-field">
</div>
<!-- DeepSeek API Key (嵌入) -->
<div id="field-deepseek-key-embed" class="form-group" style="display:none;">
<label class="form-label">DeepSeek API Key</label>
<input id="setting-deepseek-key-embed" type="password" class="input-field">
</div>
<!-- DeepSeek Base URL (嵌入) -->
<div id="field-deepseek-url-embed" class="form-group" style="display:none;">
<label class="form-label">DeepSeek Base URL</label>
<input id="setting-deepseek-url-embed" type="text" class="input-field">
</div>
</div>
<div style="display:flex; gap:10px; margin-top:8px;">
<button class="btn btn-primary" onclick="saveSettings()">保存设置</button>
<button class="btn btn-secondary" onclick="testEmbedding()">测试连接</button>
</div>
</div>
<!-- OCR 配置 -->
<div class="card" style="margin-bottom:16px;">
<h3 class="section-title">OCR 配置</h3>
<div style="display:grid; grid-template-columns:1fr 1fr; gap:0 24px;">
<div class="form-group">
<label class="form-label">提供商</label>
<select id="setting-ocr-provider" class="input-field" onchange="toggleOcrFields()">
<option value="deepseek">DeepSeek</option>
<option value="paddleocr">PaddleOCR</option>
<option value="aliyun">阿里云</option>
<option value="tencent">腾讯云</option>
<option value="auto">Auto</option>
</select>
</div>
<!-- DeepSeek OCR 配置 -->
<div id="ocr-field-deepseek-key" class="form-group">
<label class="form-label">DeepSeek API Key</label>
<input id="setting-ocr-deepseek-key" type="password" class="input-field">
</div>
<div id="ocr-field-deepseek-url" class="form-group">
<label class="form-label">DeepSeek Base URL</label>
<input id="setting-ocr-deepseek-url" type="text" class="input-field">
</div>
<div id="ocr-field-deepseek-model" class="form-group">
<label class="form-label">DeepSeek OCR 模型</label>
<input id="setting-ocr-deepseek-model" type="text" class="input-field">
</div>
<!-- 阿里云 OCR 配置 -->
<div id="ocr-field-aliyun-key" class="form-group" style="display:none;">
<label class="form-label">阿里云 AccessKey ID</label>
<input id="setting-ocr-aliyun-key" type="text" class="input-field">
</div>
<div id="ocr-field-aliyun-secret" class="form-group" style="display:none;">
<label class="form-label">阿里云 AccessKey Secret</label>
<input id="setting-ocr-aliyun-secret" type="password" class="input-field">
</div>
<!-- 腾讯云 OCR 配置 -->
<div id="ocr-field-tencent-id" class="form-group" style="display:none;">
<label class="form-label">腾讯云 SecretId</label>
<input id="setting-ocr-tencent-id" type="text" class="input-field">
</div>
<div id="ocr-field-tencent-key" class="form-group" style="display:none;">
<label class="form-label">腾讯云 SecretKey</label>
<input id="setting-ocr-tencent-key" type="password" class="input-field">
</div>
</div>
<div style="display:flex; gap:10px; margin-top:8px;">
<button class="btn btn-primary" onclick="saveSettings()">保存设置</button>
<button class="btn btn-secondary" onclick="testOcr()">测试 OCR</button>
</div>
</div>
<!-- LLM 配置 -->
<div class="card" style="margin-bottom:16px;">
<h3 class="section-title">LLM 配置</h3>
<div style="display:grid; grid-template-columns:1fr 1fr; gap:0 24px;">
<div class="form-group">
<label class="form-label">提供商</label>
<select id="setting-llm-provider" class="input-field">
<option value="minimax">MiniMax</option>
<option value="deepseek">DeepSeek</option>
<option value="openai">OpenAI</option>
</select>
</div>
<div class="form-group">
<label class="form-label">Chat 模型名称</label>
<input id="setting-llm-model" type="text" class="input-field">
</div>
</div>
<div style="display:flex; gap:10px; margin-top:8px;">
<button class="btn btn-primary" onclick="saveSettings()">保存设置</button>
<button class="btn btn-secondary" onclick="testLlm()">测试 LLM</button>
</div>
</div>
<!-- 企业微信机器人 -->
<div class="card" style="margin-bottom:16px;">
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:16px;">
<h3 class="section-title" style="margin-bottom:0;">企业微信机器人</h3>
<div style="display:flex; align-items:center; gap:8px;">
<span id="bot-status-badge" class="badge badge-pending">检测中</span>
<button class="btn btn-secondary" onclick="restartBot()" id="bot-restart-btn" style="font-size:13px; padding:4px 12px;">重启机器人</button>
</div>
</div>
<div style="display:grid; grid-template-columns:1fr 1fr; gap:0 24px;">
<div class="form-group">
<label class="form-label">启用机器人</label>
<label style="display:flex; align-items:center; gap:8px; cursor:pointer; padding:6px 0;">
<input type="checkbox" id="setting-wework-bot-enabled" onchange="toggleBotEnabled()" style="width:16px; height:16px;">
<span style="font-size:13px; color:var(--text-secondary);">启用后服务启动时自动连接</span>
</label>
</div>
<div class="form-group">
<label class="form-label">Bot ID</label>
<input type="text" id="setting-wework-bot-id" placeholder="企业微信机器人 ID" class="input-field">
</div>
<div class="form-group">
<label class="form-label">Secret</label>
<input type="password" id="setting-wework-bot-secret" placeholder="企业微信机器人 Secret" class="input-field">
</div>
<div class="form-group">
<label class="form-label">服务地址</label>
<input type="text" id="setting-edubrain-base-url" value="http://localhost:8765" class="input-field" style="color:var(--text-secondary); font-size:13px;" readonly>
</div>
</div>
</div>
<!-- 图片搜索设置 -->
<div class="card" style="margin-bottom:16px;">
<h3 class="section-title">图片搜索设置</h3>
<div style="display:grid; grid-template-columns:1fr 1fr; gap:0 24px;">
<div class="form-group">
<label class="form-label">默认搜索返回数量</label>
<div style="display:flex; align-items:center; gap:8px;">
<input type="number" id="setting-search-limit" min="1" max="10" value="3"
style="width:80px;" class="input-field">
<span style="color:var(--text-secondary); font-size:13px;">1-10</span>
</div>
</div>
<div class="form-group">
<label class="form-label">每次 LLM 判断文章数</label>
<div style="display:flex; align-items:center; gap:8px;">
<input type="number" id="setting-judge-batch-size" min="1" max="50" value="10"
style="width:80px;" class="input-field">
<span style="color:var(--text-secondary); font-size:13px;">1-50</span>
</div>
</div>
</div>
</div>
<!-- 系统信息 -->
<div class="card">
<h3 class="section-title">系统信息</h3>
<div style="display:grid; grid-template-columns:repeat(3,1fr); gap:20px;">
<div>
<div class="stat-label" style="margin-bottom:6px;">数据库状态</div>
<div id="sys-db-status" style="display:flex; align-items:center; gap:8px;">
<span class="badge badge-pending">检测中</span>
</div>
</div>
<div>
<div class="stat-label" style="margin-bottom:6px;">知识库页面数</div>
<div id="sys-pages" class="font-mono" style="font-weight:500; font-size:18px;">-</div>
</div>
<div>
<div class="stat-label" style="margin-bottom:6px;">知识库分块数</div>
<div id="sys-chunks" class="font-mono" style="font-weight:500; font-size:18px;">-</div>
</div>
<div>
<div class="stat-label" style="margin-bottom:6px;">图片数量</div>
<div id="sys-images" class="font-mono" style="font-weight:500; font-size:18px;">-</div>
</div>
<div>
<div class="stat-label" style="margin-bottom:6px;">版本</div>
<div class="font-mono" style="font-weight:400;">0.1.0</div>
</div>
<div>
<div class="stat-label" style="margin-bottom:6px;">API 地址</div>
<div id="sys-api-url" class="font-mono" style="font-size:13px; color:#64748d; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;"></div>
</div>
</div>
</div>
</div>
</main>
</div>
<!-- ═══════════════════════════════════════════════════════════════
Toast 通知容器
═══════════════════════════════════════════════════════════════ -->
<div id="toast-container" class="toast-container"></div>
<!-- ═══════════════════════════════════════════════════════════════
确认弹窗(隐藏)
═══════════════════════════════════════════════════════════════ -->
<div id="confirm-modal" class="modal-overlay" style="display:none;">
<div class="modal-box">
<h3 id="confirm-title" style="font-size:17px; font-weight:600; margin-bottom:8px;"></h3>
<p id="confirm-message" style="color:#64748d; margin-bottom:24px; line-height:1.5;"></p>
<div style="display:flex; justify-content:flex-end; gap:10px;">
<button class="btn btn-secondary" onclick="closeConfirm()">取消</button>
<button id="confirm-ok-btn" class="btn btn-danger">确认</button>
</div>
</div>
</div>
<!-- ═══════════════════════════════════════════════════════════════
新建页面弹窗(隐藏)
═══════════════════════════════════════════════════════════════ -->
<div id="new-page-modal" class="modal-overlay" style="display:none;">
<div class="modal-box" style="max-width:600px;">
<h3 style="font-size:17px; font-weight:600; margin-bottom:20px;">新建知识页面</h3>
<div class="form-group">
<label class="form-label">标题 *</label>
<input id="new-page-title" type="text" class="input-field" placeholder="输入页面标题">
</div>
<div class="form-group">
<label class="form-label">内容 *</label>
<textarea id="new-page-content" class="input-field" rows="6"
placeholder="输入页面内容..." style="resize:vertical;"></textarea>
</div>
<div style="display:grid; grid-template-columns:repeat(3,1fr); gap:16px;">
<div class="form-group">
<label class="form-label">课程名称</label>
<input id="new-page-course" type="text" class="input-field">
</div>
<div class="form-group">
<label class="form-label">讲师名称</label>
<input id="new-page-teacher" type="text" class="input-field">
</div>
<div class="form-group">
<label class="form-label">直播日期</label>
<input id="new-page-date" type="date" class="input-field">
</div>
</div>
<div style="display:flex; justify-content:flex-end; gap:10px; margin-top:20px;">
<button class="btn btn-secondary" onclick="closeNewPageModal()">取消</button>
<button class="btn btn-primary" onclick="createNewPage()">创建</button>
</div>
</div>
</div>
<!-- ═══════════════════════════════════════════════════════════════
JavaScript
═══════════════════════════════════════════════════════════════ -->
<script>
// ──────────────────────────── API 基础配置 ────────────────────────────
const API = window.location.origin + '/api/v1';
/**
* 通用 API 请求封装
* @param {string} method - HTTP 方法
* @param {string} path - API 路径(不含 base URL
* @param {object|null} body - 请求体
* @returns {Promise<object>} 响应 JSON
*/
async function api(method, path, body = null) {
const opts = {
method,
headers: { 'Content-Type': 'application/json' },
};
if (body !== null) {
opts.body = JSON.stringify(body);
}
const res = await fetch(API + path, opts);
if (!res.ok) {
let detail = '';
try {
const err = await res.json();
detail = err.detail || err.message || '';
} catch (_) {}
throw new Error(detail || `请求失败 (${res.status})`);
}
// 204 No Content
if (res.status === 204) return null;
return res.json();
}
/**
* 上传文件专用请求multipart/form-data
* @param {string} path - API 路径
* @param {FormData} formData - 表单数据
* @returns {Promise<object>}
*/
async function apiUpload(path, formData) {
const res = await fetch(API + path, {
method: 'POST',
body: formData,
});
if (!res.ok) {
let detail = '';
try {
const err = await res.json();
detail = err.detail || err.message || '';
} catch (_) {}
throw new Error(detail || `上传失败 (${res.status})`);
}
return res.json();
}
// ──────────────────────────── Toast 通知 ────────────────────────────
/**
* 显示 Toast 通知
* @param {string} message - 消息内容
* @param {'success'|'error'|'info'} type - 通知类型
*/
function showToast(message, type = 'info') {
const container = document.getElementById('toast-container');
const toast = document.createElement('div');
toast.className = `toast toast-${type}`;
toast.textContent = message;
container.appendChild(toast);
// 3 秒后自动消失
setTimeout(() => {
toast.classList.add('removing');
setTimeout(() => toast.remove(), 300);
}, 3000);
}
// ──────────────────────────── 确认弹窗 ────────────────────────────
let _confirmCallback = null;
/**
* 显示确认弹窗(替代 confirm()
* @param {string} title - 弹窗标题
* @param {string} message - 弹窗内容
* @param {function} onConfirm - 确认回调
*/
function showConfirm(title, message, onConfirm) {
document.getElementById('confirm-title').textContent = title;
document.getElementById('confirm-message').textContent = message;
document.getElementById('confirm-modal').style.display = 'flex';
_confirmCallback = onConfirm;
document.getElementById('confirm-ok-btn').onclick = () => {
closeConfirm();
if (_confirmCallback) _confirmCallback();
};
}
/** 关闭确认弹窗 */
function closeConfirm() {
document.getElementById('confirm-modal').style.display = 'none';
_confirmCallback = null;
}
// ──────────────────────────── 页面切换 ────────────────────────────
/**
* 切换侧边栏页面
* @param {string} pageId - 页面标识search/pages/import/ocr/settings
*/
function switchPage(pageId) {
// 更新导航高亮
document.querySelectorAll('.nav-item').forEach(item => {
item.classList.toggle('active', item.dataset.page === pageId);
});
// 切换页面显示
document.querySelectorAll('.page').forEach(page => {
page.classList.toggle('active', page.id === `page-${pageId}`);
});
// 窄屏下关闭侧边栏
closeSidebar();
// 页面初始化回调
if (pageId === 'pages') loadPages();
if (pageId === 'settings') { loadSettings(); loadSystemInfo(); }
}
/** 切换侧边栏(移动端) */
function toggleSidebar() {
const sidebar = document.querySelector('.sidebar');
const overlay = document.getElementById('sidebar-overlay');
sidebar.classList.toggle('open');
overlay.classList.toggle('active');
}
function closeSidebar() {
const sidebar = document.querySelector('.sidebar');
const overlay = document.getElementById('sidebar-overlay');
sidebar.classList.remove('open');
overlay.classList.remove('active');
}
// ══════════════════════════════════════════════════════════════════════
// 页面 1语义搜索
// ══════════════════════════════════════════════════════════════════════
/** 执行语义搜索 */
async function doSearch() {
const query = document.getElementById('search-query').value.trim();
if (!query) {
showToast('请输入搜索关键词', 'error');
return;
}
const courseName = document.getElementById('search-course').value.trim() || undefined;
const teacherName = document.getElementById('search-teacher').value.trim() || undefined;
const topK = parseInt(document.getElementById('search-topk').value);
// 显示加载状态
document.getElementById('search-empty').style.display = 'none';
document.getElementById('search-list').style.display = 'none';
document.getElementById('search-loading').style.display = 'flex';
try {
const data = await api('POST', '/search', {
query,
top_k: topK,
course_name: courseName,
teacher_name: teacherName,
threshold: 0.3,
use_fulltext: true,
});
document.getElementById('search-loading').style.display = 'none';
if (data.total === 0) {
document.getElementById('search-empty').style.display = 'flex';
document.getElementById('search-empty').innerHTML = `
<span style="font-size:48px; margin-bottom:16px; opacity:0.5;">&#x1F614;</span>
<p style="font-size:17px; color:#061b31; font-weight:500;">未找到相关结果</p>
<p style="font-size:14px; color:#64748d; margin-top:4px;">试试其他关键词</p>
`;
return;
}
// 渲染搜索结果
const listEl = document.getElementById('search-list');
listEl.innerHTML = data.results.map(r => {
const scorePercent = (r.score * 100).toFixed(1);
// 根据分数选择颜色
let scoreColor = 'badge-failed';
if (r.score >= 0.8) scoreColor = 'badge-completed';
else if (r.score >= 0.6) scoreColor = 'badge-processing';
// 高亮文本
const highlightHtml = r.highlight
? `<div style="margin-top:8px; font-size:13px;">${r.highlight}</div>`
: '';
// 内容摘要(截断到 2-3 行)
const summary = r.content.length > 200
? r.content.substring(0, 200) + '...'
: r.content;
return `
<div class="search-result-card">
<div style="display:flex; align-items:flex-start; justify-content:space-between; margin-bottom:8px;">
<h4 style="font-weight:500; font-size:15px; color:#061b31;">${escapeHtml(r.page_title)}</h4>
<span class="badge ${scoreColor} font-mono">${scorePercent}%</span>
</div>
<div style="display:flex; gap:16px; font-size:12px; color:#64748d; margin-bottom:8px;">
${r.course_name ? `<span>课程: ${escapeHtml(r.course_name)}</span>` : ''}
${r.teacher_name ? `<span>讲师: ${escapeHtml(r.teacher_name)}</span>` : ''}
${r.live_date ? `<span>日期: ${r.live_date}</span>` : ''}
</div>
<p style="font-size:14px; color:#64748d; line-height:1.6;">${escapeHtml(summary)}</p>
${highlightHtml}
</div>
`;
}).join('');
listEl.style.display = 'block';
showToast(`找到 ${data.total} 条结果,耗时 ${data.elapsed_ms.toFixed(0)}ms`, 'success');
} catch (err) {
document.getElementById('search-loading').style.display = 'none';
document.getElementById('search-empty').style.display = 'flex';
document.getElementById('search-empty').innerHTML = `
<span style="font-size:48px; margin-bottom:16px; opacity:0.5;">&#x26A0;&#xFE0F;</span>
<p style="font-size:17px; color:#ea2261; font-weight:500;">搜索失败</p>
<p style="font-size:14px; color:#64748d; margin-top:4px;">${escapeHtml(err.message)}</p>
`;
showToast('搜索失败: ' + err.message, 'error');
}
}
// ══════════════════════════════════════════════════════════════════════
// 页面 2知识页面管理
// ══════════════════════════════════════════════════════════════════════
let pagesCurrentPage = 1;
const pagesPageSize = 20;
/** 加载知识页面列表 */
async function loadPages(page = 1) {
pagesCurrentPage = page;
const keyword = document.getElementById('pages-keyword').value.trim() || undefined;
// 显示骨架屏
document.getElementById('pages-skeleton').style.display = 'block';
document.getElementById('pages-table-wrap').style.display = 'none';
document.getElementById('pages-pagination').style.display = 'none';
try {
const data = await api('GET', `/pages?page=${page}&page_size=${pagesPageSize}&keyword=${keyword || ''}`);
// 更新统计
document.getElementById('stat-pages').textContent = data.total;
// 隐藏骨架屏,显示表格
document.getElementById('pages-skeleton').style.display = 'none';
document.getElementById('pages-table-wrap').style.display = 'block';
const tbody = document.getElementById('pages-tbody');
if (data.items.length === 0) {
tbody.innerHTML = `
<tr><td colspan="6" style="text-align:center; color:#64748d; padding:32px 0;">
暂无数据
</td></tr>
`;
} else {
tbody.innerHTML = data.items.map((item, idx) => {
const num = (page - 1) * pagesPageSize + idx + 1;
return `
<tr>
<td class="font-mono" style="color:#64748d;">${num}</td>
<td style="font-weight:500; max-width:300px;">
<div style="overflow:hidden; text-overflow:ellipsis; white-space:nowrap;">${escapeHtml(item.title)}</div>
</td>
<td style="color:#64748d;">${escapeHtml(item.course_name || '-')}</td>
<td style="color:#64748d;">${escapeHtml(item.teacher_name || '-')}</td>
<td class="font-mono" style="color:#64748d; font-size:13px;">${item.live_date || '-'}</td>
<td>
<div style="display:flex; gap:6px;">
<button class="btn btn-sm btn-secondary" onclick="viewPageDetail(${item.id})">查看</button>
<button class="btn btn-sm btn-danger" onclick="deletePage(${item.id}, '${escapeHtml(item.title)}')">删除</button>
</div>
</td>
</tr>
<tr class="detail-row" id="detail-${item.id}">
<td colspan="6">
<div class="detail-content" id="detail-content-${item.id}">
<div class="spinner-sm" style="display:inline-block; margin-right:8px;"></div> 加载中...
</div>
</td>
</tr>
`;
}).join('');
}
// 分页
const totalPages = Math.ceil(data.total / pagesPageSize);
if (totalPages > 1) {
document.getElementById('pages-pagination').style.display = 'flex';
document.getElementById('pages-page-info').textContent =
`${data.total} 条,第 ${page}/${totalPages}`;
renderPagination('pages-pager', totalPages, page, (p) => loadPages(p));
} else {
document.getElementById('pages-pagination').style.display = 'none';
}
} catch (err) {
document.getElementById('pages-skeleton').style.display = 'none';
showToast('加载页面列表失败: ' + err.message, 'error');
}
}
/** 查看页面详情(内联展开) */
async function viewPageDetail(pageId) {
const detailRow = document.getElementById(`detail-${pageId}`);
const isOpen = detailRow.classList.contains('open');
// 关闭其他已展开的详情
document.querySelectorAll('.detail-row.open').forEach(row => {
row.classList.remove('open');
});
if (isOpen) return;
detailRow.classList.add('open');
const contentEl = document.getElementById(`detail-content-${pageId}`);
contentEl.innerHTML = '<div class="spinner-sm" style="display:inline-block; margin-right:8px;"></div> 加载中...';
try {
const data = await api('GET', `/pages/${pageId}`);
let chunksHtml = '';
if (data.chunks && data.chunks.length > 0) {
document.getElementById('stat-chunks').textContent =
parseInt(document.getElementById('stat-chunks').textContent || '0') + data.chunks.length;
chunksHtml = `
<h4 style="font-size:13px; font-weight:500; color:#64748d; margin-bottom:8px; margin-top:16px;">
分块列表 (${data.chunks.length})
</h4>
<div style="display:flex; flex-direction:column; gap:8px;">
${data.chunks.map((c, i) => `
<div style="background:#f6f9fc; border-radius:6px; padding:12px; font-size:14px;">
<span class="font-mono" style="color:#64748d; font-size:12px;">#${i + 1}</span>
<p style="margin-top:4px; color:#061b31; line-height:1.6;">${escapeHtml(c.content || c.text || '')}</p>
</div>
`).join('')}
</div>
`;
}
contentEl.innerHTML = `
<div style="margin-bottom:8px;">
<h4 style="font-weight:500; color:#061b31;">${escapeHtml(data.title)}</h4>
<div style="display:flex; gap:16px; font-size:12px; color:#64748d; margin-top:4px;">
${data.course_name ? `<span>课程: ${escapeHtml(data.course_name)}</span>` : ''}
${data.teacher_name ? `<span>讲师: ${escapeHtml(data.teacher_name)}</span>` : ''}
${data.live_date ? `<span>日期: ${data.live_date}</span>` : ''}
${data.source_file ? `<span>来源: ${escapeHtml(data.source_file)}</span>` : ''}
</div>
</div>
<div style="background:#f6f9fc; border-radius:6px; padding:12px; font-size:14px; margin-top:12px; max-height:200px; overflow-y:auto;">
<pre style="white-space:pre-wrap; color:#061b31; font-family:inherit; margin:0;">${escapeHtml(data.content)}</pre>
</div>
${chunksHtml}
`;
} catch (err) {
contentEl.innerHTML = `<span style="color:#ea2261;">加载失败: ${escapeHtml(err.message)}</span>`;
}
}
/** 删除知识页面 */
async function deletePage(pageId, title) {
showConfirm('确认删除', `确定要删除页面「${title}」吗?此操作不可恢复。`, async () => {
try {
await api('DELETE', `/pages/${pageId}`);
showToast('删除成功', 'success');
loadPages(pagesCurrentPage);
} catch (err) {
showToast('删除失败: ' + err.message, 'error');
}
});
}
/** 显示新建页面弹窗 */
function showNewPageModal() {
document.getElementById('new-page-modal').style.display = 'flex';
document.getElementById('new-page-title').value = '';
document.getElementById('new-page-content').value = '';
document.getElementById('new-page-course').value = '';
document.getElementById('new-page-teacher').value = '';
document.getElementById('new-page-date').value = '';
}
/** 关闭新建页面弹窗 */
function closeNewPageModal() {
document.getElementById('new-page-modal').style.display = 'none';
}
/** 创建新页面 */
async function createNewPage() {
const title = document.getElementById('new-page-title').value.trim();
const content = document.getElementById('new-page-content').value.trim();
if (!title || !content) {
showToast('标题和内容不能为空', 'error');
return;
}
try {
await api('POST', '/pages', {
title,
content,
course_name: document.getElementById('new-page-course').value.trim() || undefined,
teacher_name: document.getElementById('new-page-teacher').value.trim() || undefined,
live_date: document.getElementById('new-page-date').value || undefined,
});
closeNewPageModal();
showToast('页面创建成功', 'success');
loadPages();
} catch (err) {
showToast('创建失败: ' + err.message, 'error');
}
}
// ══════════════════════════════════════════════════════════════════════
// 页面 3文件导入支持多文件
// ══════════════════════════════════════════════════════════════════════
let selectedFiles = [];
/** 切换导入 Tab */
function switchImportTab(tab) {
document.querySelectorAll('[data-tab]').forEach(btn => {
btn.classList.toggle('active', btn.dataset.tab === tab);
});
document.getElementById('import-tab-single').style.display = tab === 'single' ? 'block' : 'none';
document.getElementById('import-tab-batch').style.display = tab === 'batch' ? 'block' : 'none';
}
/** 切换图片 OCR 页面的 Tab */
function switchOcrTab(tab) {
document.querySelectorAll('#page-ocr .tab-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.tab === tab);
});
document.getElementById('ocr-tab-upload').style.display = tab === 'upload' ? 'block' : 'none';
document.getElementById('ocr-tab-manage').style.display = tab === 'manage' ? 'block' : 'none';
if (tab === 'manage') loadManageImages();
}
/** 图片管理 - 当前页码 */
let _managePage = 1;
const _managePageSize = 20;
let _manageImagesCache = {};
/** 加载图片管理列表 */
async function loadManageImages(page) {
if (page !== undefined) _managePage = page;
const container = document.getElementById('manage-images-list');
container.innerHTML = '<div style="text-align:center; padding:40px; color:#64748d;">加载中...</div>';
try {
const data = await api('GET', `/images?page=${_managePage}&page_size=${_managePageSize}`);
const images = data.items || [];
const total = data.total || 0;
document.getElementById('manage-total-count').textContent = total;
if (images.length === 0) {
container.innerHTML = '<div style="text-align:center; padding:40px; color:#64748d;">暂无图片</div>';
document.getElementById('manage-pagination').innerHTML = '';
return;
}
let html = '<div style="display:grid; grid-template-columns:repeat(auto-fill, minmax(200px, 1fr)); gap:12px;">';
images.forEach(img => {
const fileName = (img.file_path || '').split('/').pop();
const preview = img.file_path ? `/data/images/${encodeURIComponent(fileName)}` : '';
const ocrPreview = (img.ocr_text || '').substring(0, 50).replace(/\n/g, ' ');
html += `
<div class="card" style="padding:0; overflow:hidden; position:relative; cursor:pointer;" id="manage-img-${img.id}" data-manage-img-id="${img.id}" onclick="viewManageImageDetail(${img.id})">
<div style="height:140px; background:#f5f5f7; display:flex; align-items:center; justify-content:center; overflow:hidden;">
${preview
? `<img src="${preview}" style="width:100%; height:100%; object-fit:cover;" onerror="this.outerHTML='<span style=color:#86868b;font-size:13px>加载失败</span>'">`
: '<span style="color:#86868b; font-size:13px;">无图片</span>'}
</div>
<div style="padding:10px;">
<p style="font-size:12px; color:#424245; margin:0 0 4px 0; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;" title="${escapeHtml(ocrPreview)}">${escapeHtml(ocrPreview) || '无识别内容'}</p>
<p style="font-size:11px; color:#86868b; margin:0;">${escapeHtml(fileName)} · ID:${img.id}</p>
</div>
<button onclick="event.stopPropagation(); confirmDeleteImage(${img.id}, '${escapeHtml(fileName)}')"
style="position:absolute; top:6px; right:6px; background:rgba(0,0,0,0.5); color:#fff; border:none; border-radius:50%; width:26px; height:26px; font-size:14px; cursor:pointer; display:flex; align-items:center; justify-content:center; opacity:0; transition:opacity 0.15s;"
onmouseenter="this.style.opacity=1" onmouseleave="this.style.opacity=0"
title="删除图片">&times;</button>
</div>`;
});
html += '</div>';
container.innerHTML = html;
// 缓存图片数据供详情弹窗使用
_manageImagesCache = {};
images.forEach(img => { _manageImagesCache[img.id] = img; });
// 分页
const totalPages = Math.ceil(total / _managePageSize);
if (totalPages > 1) {
let pagHtml = '';
for (let i = 1; i <= totalPages; i++) {
pagHtml += `<button class="page-btn ${i === _managePage ? 'active' : ''}" onclick="loadManageImages(${i})" style="${i === _managePage ? 'background:#533afd;color:#fff;' : ''}">${i}</button>`;
}
document.getElementById('manage-pagination').innerHTML = pagHtml;
} else {
document.getElementById('manage-pagination').innerHTML = '';
}
} catch (err) {
container.innerHTML = `<div style="text-align:center; padding:40px; color:#e53e3e;">加载失败: ${escapeHtml(err.message)}</div>`;
}
}
/** 确认删除图片 */
/** 查看管理图片详情(复用 showOcrDetailModal */
function viewManageImageDetail(id) {
const img = _manageImagesCache[id];
if (img) showOcrDetailModal(img);
}
/** 删除图片(通用,不依赖特定 DOM */
async function deleteImageById(id) {
await api('DELETE', `/images/${id}`);
showToast('已删除', 'success');
}
function confirmDeleteImage(id, fileName) {
showConfirmModal(`确定要删除图片「${fileName}」吗?此操作不可恢复。`, async () => {
try {
await api('DELETE', `/images/${id}`);
const el = document.getElementById(`manage-img-${id}`);
if (el) { el.style.opacity = '0.3'; el.style.transition = 'opacity 0.3s'; setTimeout(() => el.remove(), 300); }
showToast('已删除', 'success');
// 延迟刷新以更新计数
setTimeout(() => loadManageImages(), 400);
} catch (err) {
showToast('删除失败: ' + err.message, 'error');
}
});
}
/** 一键去重 */
async function dedupImages() {
const btn = document.getElementById('dedup-btn');
btn.disabled = true;
btn.textContent = '去重中...';
try {
const result = await api('POST', '/images/dedup');
if (result.deleted > 0) {
showToast(`去重完成:检查 ${result.total_checked} 张,发现 ${result.duplicates_found} 组重复,删除 ${result.deleted}`, 'success');
} else {
showToast(`去重完成:检查 ${result.total_checked} 张,未发现重复图片`, 'info');
}
loadManageImages();
} catch (err) {
showToast('去重失败: ' + err.message, 'error');
} finally {
btn.disabled = false;
btn.innerHTML = '&#x1F504; 一键去重';
}
}
/** 通用确认弹窗 */
function showConfirmModal(message, onConfirm) {
const overlay = document.createElement('div');
overlay.style.cssText = 'position:fixed; inset:0; background:rgba(0,0,0,0.4); z-index:2000; display:flex; align-items:center; justify-content:center; padding:20px;';
overlay.innerHTML = `
<div style="background:#fff; border-radius:16px; padding:24px; max-width:400px; width:100%; box-shadow:0 20px 60px rgba(0,0,0,0.15);">
<p style="font-size:15px; color:#1d1d1f; margin:0 0 20px 0; line-height:1.6;">${message}</p>
<div style="display:flex; gap:10px; justify-content:flex-end;">
<button class="btn btn-secondary" id="confirm-cancel">取消</button>
<button class="btn" style="background:#e53e3e; color:#fff;" id="confirm-ok">删除</button>
</div>
</div>`;
overlay.querySelector('#confirm-cancel').onclick = () => overlay.remove();
overlay.querySelector('#confirm-ok').onclick = () => { overlay.remove(); onConfirm(); };
overlay.onclick = (e) => { if (e.target === overlay) overlay.remove(); };
document.body.appendChild(overlay);
}
/** 处理拖拽文件(支持多文件) */
function handleFileDrop(event) {
event.preventDefault();
event.currentTarget.classList.remove('drag-over');
const files = Array.from(event.dataTransfer.files);
addFiles(files);
}
/** 处理选择文件(支持多文件) */
function handleFileSelect(event) {
const files = Array.from(event.target.files);
addFiles(files);
// 重置 input 以便再次选择相同文件
event.target.value = '';
}
/** 添加文件到已选列表 */
function addFiles(files) {
const allowedExts = ['md', 'txt', 'docx'];
for (const file of files) {
const ext = file.name.split('.').pop().toLowerCase();
if (!allowedExts.includes(ext)) {
showToast(`不支持的文件格式: ${file.name}`, 'error');
continue;
}
// 避免重复添加
const isDuplicate = selectedFiles.some(f => f.name === file.name && f.size === file.size);
if (!isDuplicate) {
selectedFiles.push(file);
}
}
updateFileList();
}
/** 格式化文件大小 */
function formatFileSize(bytes) {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
}
/** 更新已选文件列表 UI */
function updateFileList() {
const listEl = document.getElementById('import-file-list');
const importBtn = document.getElementById('import-btn');
if (selectedFiles.length === 0) {
listEl.style.display = 'none';
importBtn.disabled = true;
importBtn.textContent = '导入';
return;
}
listEl.style.display = 'block';
importBtn.disabled = false;
importBtn.textContent = `导入 ${selectedFiles.length} 个文件`;
listEl.innerHTML = selectedFiles.map((file, index) => `
<div class="file-list-item">
<span class="file-icon">&#x1F4C4;</span>
<div class="file-info">
<div class="file-name">${escapeHtml(file.name)}</div>
<div class="file-size">${formatFileSize(file.size)}</div>
</div>
<button class="file-remove" onclick="removeFile(${index})" title="移除">&times;</button>
</div>
`).join('');
}
/** 移除指定文件 */
function removeFile(index) {
selectedFiles.splice(index, 1);
updateFileList();
}
/** 多文件导入 */
async function importFiles() {
if (selectedFiles.length === 0) {
showToast('请先选择文件', 'error');
return;
}
const courseName = document.getElementById('import-course').value.trim();
const teacherName = document.getElementById('import-teacher').value.trim();
const liveDate = document.getElementById('import-date').value;
const importBtn = document.getElementById('import-btn');
const resultEl = document.getElementById('import-result');
const resultContent = document.getElementById('import-result-content');
importBtn.disabled = true;
importBtn.innerHTML = '<div class="spinner-sm" style="display:inline-block; margin-right:6px;"></div> 导入中...';
// 显示进度区域
resultEl.style.display = 'block';
let progressHtml = '<h3 class="section-title">导入进度</h3>';
selectedFiles.forEach((file, i) => {
progressHtml += `
<div class="import-progress-item" id="import-progress-${i}">
<span class="status-icon">&#x23F3;</span>
<span class="file-name">${escapeHtml(file.name)}</span>
<span class="status-text">等待中</span>
</div>
`;
});
resultContent.innerHTML = progressHtml;
let successCount = 0;
let failCount = 0;
let totalChunks = 0;
let totalProcessed = 0;
// 逐个导入文件
for (let i = 0; i < selectedFiles.length; i++) {
const file = selectedFiles[i];
const progressItem = document.getElementById(`import-progress-${i}`);
progressItem.className = 'import-progress-item importing';
progressItem.querySelector('.status-icon').innerHTML = '&#x1F504;';
progressItem.querySelector('.status-text').textContent = '导入中...';
try {
const formData = new FormData();
formData.append('file', file);
if (courseName) formData.append('course_name', courseName);
if (teacherName) formData.append('teacher_name', teacherName);
if (liveDate) formData.append('live_date', liveDate);
const result = await apiUpload('/import/file', formData);
progressItem.className = 'import-progress-item success';
progressItem.querySelector('.status-icon').innerHTML = '&#x2705;';
progressItem.querySelector('.status-text').textContent = '成功';
successCount++;
totalChunks += (result.chunks || result.total_chunks || 0);
totalProcessed += (result.files_processed || 1);
} catch (err) {
progressItem.className = 'import-progress-item error';
progressItem.querySelector('.status-icon').innerHTML = '&#x274C;';
progressItem.querySelector('.status-text').textContent = err.message;
failCount++;
}
}
// 显示汇总统计
const summaryHtml = `
<div style="margin-top:16px; padding:16px; background:#f5f5f7; border-radius:12px;">
<div style="font-weight:600; margin-bottom:8px;">导入完成</div>
<div style="display:flex; gap:24px; font-size:14px;">
<span>成功: <strong style="color:#248a3d;">${successCount}</strong></span>
<span>失败: <strong style="color:#d70015;">${failCount}</strong></span>
<span>分块: <strong>${totalChunks}</strong></span>
</div>
</div>
`;
resultContent.innerHTML += summaryHtml;
// 重置文件列表
selectedFiles = [];
updateFileList();
importBtn.disabled = false;
importBtn.textContent = '导入';
if (failCount === 0) {
showToast(`成功导入 ${successCount} 个文件`, 'success');
} else {
showToast(`导入完成:${successCount} 成功,${failCount} 失败`, failCount > 0 ? 'error' : 'success');
}
}
/** 批量导入 */
async function batchImport() {
const directory = document.getElementById('batch-dir').value.trim();
if (!directory) {
showToast('请输入目录名称', 'error');
return;
}
const formData = new FormData();
formData.append('directory', directory);
const courseName = document.getElementById('batch-course').value.trim();
const teacherName = document.getElementById('batch-teacher').value.trim();
if (courseName) formData.append('course_name', courseName);
if (teacherName) formData.append('teacher_name', teacherName);
document.getElementById('batch-btn').disabled = true;
document.getElementById('batch-btn').innerHTML = '<div class="spinner-sm" style="display:inline-block; margin-right:6px;"></div> 导入中...';
try {
const result = await apiUpload('/import/directory', formData);
showImportResult('success', result);
showToast('批量导入完成', 'success');
} catch (err) {
showImportResult('error', { message: err.message });
showToast('批量导入失败: ' + err.message, 'error');
} finally {
document.getElementById('batch-btn').disabled = false;
document.getElementById('batch-btn').textContent = '批量导入';
}
}
/** 显示导入结果 */
function showImportResult(type, result) {
const el = document.getElementById('import-result');
const content = document.getElementById('import-result-content');
el.style.display = 'block';
if (type === 'success') {
content.innerHTML = `
<div style="display:flex; align-items:center; gap:8px; margin-bottom:12px;">
<span style="color:#34c759; font-size:18px;">&#x2705;</span>
<span style="font-weight:600;">${escapeHtml(result.message)}</span>
</div>
<div style="display:grid; grid-template-columns:repeat(3,1fr); gap:16px; font-size:14px;">
<div><span style="color:#86868b;">文件:</span> <span class="font-mono">${result.files_processed || 1}</span></div>
<div><span style="color:#86868b;">页面:</span> <span class="font-mono">${result.pages || result.total_pages || 0}</span></div>
<div><span style="color:#86868b;">分块:</span> <span class="font-mono">${result.chunks || result.total_chunks || 0}</span></div>
</div>
${result.errors ? `
<div style="margin-top:12px; font-size:14px; color:#ff3b30;">
<p style="font-weight:600; margin-bottom:4px;">错误:</p>
${result.errors.map(e => `<p>${escapeHtml(e)}</p>`).join('')}
</div>
` : ''}
`;
} else {
content.innerHTML = `
<div style="display:flex; align-items:center; gap:8px; color:#ff3b30;">
<span style="font-size:18px;">&#x274C;</span>
<span>${escapeHtml(result.message)}</span>
</div>
`;
}
}
/** 导出知识库 */
async function exportData() {
const format = document.getElementById('export-format').value;
const courseName = document.getElementById('export-course').value.trim();
const resultEl = document.getElementById('export-result');
resultEl.style.display = 'block';
resultEl.innerHTML = '<div class="spinner-sm" style="display:inline-block; margin-right:6px;"></div> 导出中...';
try {
const params = new URLSearchParams({ format });
if (courseName) params.append('course_name', courseName);
const result = await api('GET', `/import/export?${params}`);
resultEl.innerHTML = `
<span style="color:#34c759;">&#x2705;</span>
导出成功: <span class="font-mono">${escapeHtml(result.file)}</span>
(${result.pages} 页, ${result.format} 格式)
`;
showToast('导出成功', 'success');
} catch (err) {
resultEl.innerHTML = `<span style="color:#ff3b30;">导出失败: ${escapeHtml(err.message)}</span>`;
showToast('导出失败: ' + err.message, 'error');
}
}
// ══════════════════════════════════════════════════════════════════════
// 页面 4图片 OCR
// ══════════════════════════════════════════════════════════════════════
// 图片 OCR 相关状态
let ocrFiles = []; // 待识别的文件列表 {file, status, result, elapsed}
let ocrResults = []; // 识别结果列表
let ocrInProgress = false;
/** 处理 OCR 图片拖拽(支持多文件) */
function handleOcrFileDrop(event) {
event.preventDefault();
event.currentTarget.classList.remove('drag-over');
const files = Array.from(event.dataTransfer.files);
addOcrFiles(files);
}
/** 处理 OCR 图片选择(支持多文件) */
/** 选择文件(非文件夹模式) */
function ocrPickFiles() {
const input = document.getElementById('ocr-file-input');
input.removeAttribute('webkitdirectory');
input.click();
}
/** 选择文件夹模式 */
function ocrPickFolder() {
const input = document.getElementById('ocr-file-input');
input.setAttribute('webkitdirectory', '');
input.click();
}
/** 统一处理文件选择(文件或文件夹) */
function handleOcrFileChange(event) {
const files = Array.from(event.target.files);
const isFolder = event.target.hasAttribute('webkitdirectory');
if (isFolder) {
const imageFiles = files.filter(f => {
const ext = '.' + f.name.split('.').pop().toLowerCase();
return ['.png', '.jpg', '.jpeg', '.bmp', '.webp'].includes(ext);
});
if (imageFiles.length === 0) {
showToast('所选文件夹中没有图片文件', 'error');
event.target.value = '';
return;
}
addOcrFiles(imageFiles);
} else {
addOcrFiles(files);
}
event.target.value = '';
}
/** 添加文件到队列(去重:按文件名) */
function addOcrFiles(files) {
const allowed = ['.png', '.jpg', '.jpeg', '.bmp', '.webp'];
let added = 0;
for (const file of files) {
const ext = '.' + file.name.split('.').pop().toLowerCase();
if (!allowed.includes(ext)) continue;
const dup = ocrFiles.find(f => f.file.name === file.name);
if (dup) continue;
ocrFiles.push({ file, status: 'pending', result: null, elapsed: null });
added++;
}
if (added > 0) {
showToast(`已添加 ${added} 个文件`, 'success');
} else {
showToast('没有新文件可添加(可能已存在或不支持的格式)', 'info');
}
renderOcrQueue();
}
/** 渲染识别队列 */
function renderOcrQueue() {
const card = document.getElementById('ocr-queue-card');
const queue = document.getElementById('ocr-queue');
const btn = document.getElementById('ocr-recognize-all-btn');
if (ocrFiles.length === 0) {
card.style.display = 'none';
btn.disabled = true;
return;
}
card.style.display = 'block';
btn.disabled = ocrInProgress;
const statusIcon = (s) => {
switch (s) {
case 'completed': return '<span style="color:#34c759;">&#x2705;</span>';
case 'processing': return '<span style="color:#0071e3;">&#x23F3;</span>';
case 'failed': return '<span style="color:#ff3b30;">&#x274C;</span>';
default: return '<span style="color:#86868b;">&#x23F8;</span>';
}
};
const statusText = (s) => {
switch (s) {
case 'completed': return '完成';
case 'processing': return '识别中...';
case 'failed': return '失败';
default: return '等待中';
}
};
queue.innerHTML = `
<div style="display:flex; flex-direction:column; gap:6px;">
${ocrFiles.map((item, i) => {
const keywords = (item.result && item.result.tags) ? item.result.tags : [];
const kwHtml = keywords.length > 0
? `<div style="display:flex; flex-wrap:wrap; gap:4px; margin-top:4px;">${keywords.map(k => `<span style="display:inline-block; padding:1px 8px; background:#e8f4fd; color:#0071e3; border-radius:980px; font-size:11px;">${escapeHtml(k)}</span>`).join('')}</div>`
: '';
const storyHtml = (item.result && item.result.story_summary)
? `<p style="font-size:12px; color:#6e6e73; margin-top:2px; font-style:italic;">${escapeHtml(item.result.story_summary)}</p>`
: '';
return `
<div style="padding:8px 12px; background:#f5f5f7; border-radius:10px;">
<div style="display:flex; align-items:center; gap:10px;">
${statusIcon(item.status)}
<span style="flex:1; font-size:14px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;">${escapeHtml(item.file.name)}</span>
<span style="font-size:12px; color:#86868b; white-space:nowrap;">${statusText(item.status)}${item.elapsed ? ' (' + item.elapsed.toFixed(1) + 's)' : ''}</span>
${item.status === 'pending' && !ocrInProgress ? `<button class="btn btn-sm btn-secondary" onclick="removeOcrFile(${i})" style="padding:2px 8px; font-size:12px;">移除</button>` : ''}
</div>
${kwHtml}
${storyHtml}
</div>`;
}).join('')}
</div>
`;
// 更新进度条
const completed = ocrFiles.filter(f => f.status === 'completed' || f.status === 'failed').length;
const total = ocrFiles.length;
const pct = total > 0 ? Math.round((completed / total) * 100) : 0;
const progressWrap = document.getElementById('ocr-progress-wrap');
if (ocrInProgress || completed > 0) {
progressWrap.style.display = 'block';
document.getElementById('ocr-progress-text').textContent = `${completed}/${total} 完成`;
document.getElementById('ocr-progress-pct').textContent = `${pct}%`;
document.getElementById('ocr-progress-bar').style.width = `${pct}%`;
} else {
progressWrap.style.display = 'none';
}
}
/** 移除队列中的文件 */
function removeOcrFile(index) {
if (ocrInProgress) return;
ocrFiles.splice(index, 1);
renderOcrQueue();
}
/** 开始批量识别(串行,逐个调用 recognize-direct */
async function startBatchRecognize() {
if (ocrFiles.length === 0 || ocrInProgress) return;
ocrInProgress = true;
ocrResults = [];
const btn = document.getElementById('ocr-recognize-all-btn');
btn.disabled = true;
btn.innerHTML = '<div class="spinner-sm" style="display:inline-block; margin-right:6px;"></div> 识别中...';
// 隐藏之前的结果
document.getElementById('ocr-results-card').style.display = 'none';
for (let i = 0; i < ocrFiles.length; i++) {
if (ocrFiles[i].status === 'completed') continue;
ocrFiles[i].status = 'processing';
renderOcrQueue();
const fd = new FormData();
fd.append('file', ocrFiles[i].file);
const startTime = performance.now();
try {
const resp = await apiUpload('/images/recognize-direct', fd);
const elapsed = (performance.now() - startTime) / 1000;
ocrFiles[i].status = 'completed';
ocrFiles[i].result = resp;
ocrFiles[i].elapsed = elapsed;
ocrResults.push(resp);
} catch (e) {
const elapsed = (performance.now() - startTime) / 1000;
ocrFiles[i].status = 'failed';
ocrFiles[i].result = { error: e.message };
ocrFiles[i].elapsed = elapsed;
}
renderOcrQueue();
}
ocrInProgress = false;
btn.disabled = false;
btn.textContent = '识别全部';
const successCount = ocrResults.length;
const failCount = ocrFiles.filter(f => f.status === 'failed').length;
if (successCount > 0) {
showToast(`识别完成:${successCount} 成功${failCount > 0 ? '' + failCount + ' 失败' : ''}`, 'success');
} else {
showToast(`全部失败(${failCount} 个文件)`, 'error');
}
renderOcrResults();
}
/** 渲染识别结果 */
function renderOcrResults() {
const card = document.getElementById('ocr-results-card');
const container = document.getElementById('ocr-results');
if (ocrResults.length === 0) {
card.style.display = 'none';
return;
}
card.style.display = 'block';
container.innerHTML = `
<div style="display:flex; flex-direction:column; gap:16px;">
${ocrResults.map((r, i) => {
const confidence = r.confidence
? `<span class="badge badge-completed font-mono">${(r.confidence * 100).toFixed(1)}%</span>`
: '';
const provider = r.provider
? `<span class="badge badge-processing">${escapeHtml(r.provider)}</span>`
: '';
const filename = r.original_filename || r.file_path ? r.file_path.split('/').pop() : '未知文件';
return `
<div style="background:#f5f5f7; border-radius:12px; padding:16px;">
<div style="display:flex; align-items:center; gap:8px; margin-bottom:8px; flex-wrap:wrap;">
<span style="font-size:15px; font-weight:600;">&#x1F4C4; ${escapeHtml(filename)}</span>
${confidence}
${provider}
</div>
<p style="font-size:12px; color:#86868b; margin-bottom:10px; word-break:break-all;">
路径: ${escapeHtml(r.file_path || '')}
</p>
<div style="background:#fff; border-radius:10px; padding:14px; max-height:200px; overflow-y:auto; border:1px solid #e8e8ed;">
<pre style="white-space:pre-wrap; font-size:14px; color:#424245; line-height:1.6; font-family:inherit; margin:0;">${escapeHtml(r.ocr_text || '无识别结果')}</pre>
</div>
<div style="display:flex; gap:8px; margin-top:10px;">
<button class="btn btn-sm btn-secondary" onclick="copyText(this, \`${escapeHtml(r.ocr_text || '').replace(/`/g, '\\`').replace(/\\/g, '\\\\')}\`)">复制文本</button>
${r.id ? `<button class="btn btn-sm btn-secondary" onclick="viewOcrResult(${r.id})">查看详情</button>` : ''}
</div>
</div>
`;
}).join('')}
</div>
`;
}
/** 复制文本到剪贴板 */
function copyText(btn, text) {
navigator.clipboard.writeText(text).then(() => {
const orig = btn.textContent;
btn.textContent = '已复制';
btn.style.color = '#34c759';
setTimeout(() => { btn.textContent = orig; btn.style.color = ''; }, 1500);
}).catch(() => {
showToast('复制失败', 'error');
});
}
/** 清空队列和结果 */
function clearOcrQueue() {
if (ocrInProgress) {
showToast('识别进行中,请等待完成', 'info');
return;
}
ocrFiles = [];
ocrResults = [];
renderOcrQueue();
renderOcrResults();
document.getElementById('ocr-progress-wrap').style.display = 'none';
}
/** 查看图片识别结果(详情) */
async function viewOcrResult(imageId) {
try {
const result = await api('GET', `/images/${imageId}`);
// 弹出模态框显示详情
showOcrDetailModal(result);
} catch (err) {
showToast('获取结果失败: ' + err.message, 'error');
}
}
/** 大图查看器(缩放 + 拖拽 + 下载) */
function openImageViewer(src, fileName) {
const overlay = document.createElement('div');
overlay.id = 'img-viewer-overlay';
overlay.style.cssText = 'position:fixed; inset:0; background:rgba(0,0,0,0.85); z-index:2000; display:flex; flex-direction:column; align-items:center; justify-content:center; cursor:zoom-out;';
let scale = 1;
let tx = 0, ty = 0;
let dragging = false;
let didDrag = false;
let startX, startY;
const toolbar = document.createElement('div');
toolbar.style.cssText = 'position:absolute; top:16px; right:16px; display:flex; gap:8px; z-index:2001;';
toolbar.innerHTML = `
<button onclick="event.stopPropagation(); downloadImage('${src}', '${escapeHtml(fileName)}')"
style="background:rgba(255,255,255,0.15); border:none; color:#fff; padding:8px 16px; border-radius:8px; cursor:pointer; font-size:14px; backdrop-filter:blur(10px);">
&#x1F4E5; 下载
</button>
<button onclick="event.stopPropagation(); document.getElementById('img-viewer-overlay').remove()"
style="background:rgba(255,255,255,0.15); border:none; color:#fff; padding:8px 16px; border-radius:8px; cursor:pointer; font-size:18px; backdrop-filter:blur(10px);">
&times;
</button>
`;
const img = document.createElement('img');
img.src = src;
img.style.cssText = 'max-width:90vw; max-height:85vh; object-fit:contain; border-radius:8px; transition:transform 0.15s ease; user-select:none; pointer-events:none;';
img.draggable = false;
const zoomInfo = document.createElement('div');
zoomInfo.style.cssText = 'position:absolute; bottom:16px; left:50%; transform:translateX(-50%); color:rgba(255,255,255,0.6); font-size:13px; pointer-events:none;';
zoomInfo.textContent = '滚轮缩放 | 拖拽移动 | 点击空白关闭';
overlay.appendChild(toolbar);
overlay.appendChild(img);
overlay.appendChild(zoomInfo);
overlay.onwheel = (e) => {
e.preventDefault();
const delta = e.deltaY > 0 ? 0.9 : 1.1;
scale = Math.max(0.1, Math.min(10, scale * delta));
updateTransform();
};
overlay.onmousedown = (e) => {
if (e.target !== overlay && e.target !== zoomInfo) return;
if (scale <= 1) { overlay.remove(); return; }
dragging = true;
didDrag = false;
startX = e.clientX - tx;
startY = e.clientY - ty;
overlay.style.cursor = 'grabbing';
};
overlay.onmousemove = (e) => {
if (!dragging) return;
didDrag = true;
tx = e.clientX - startX;
ty = e.clientY - startY;
updateTransform();
};
overlay.onmouseup = () => { dragging = false; overlay.style.cursor = 'zoom-out'; };
overlay.onmouseleave = () => { dragging = false; };
// 触摸支持
let lastTouchDist = 0;
overlay.ontouchstart = (e) => {
if (e.touches.length === 2) {
lastTouchDist = Math.hypot(e.touches[0].clientX - e.touches[1].clientX, e.touches[0].clientY - e.touches[1].clientY);
} else if (e.touches.length === 1) {
if (scale <= 1) { overlay.remove(); return; }
dragging = true;
startX = e.touches[0].clientX - tx;
startY = e.touches[0].clientY - ty;
}
};
overlay.ontouchmove = (e) => {
e.preventDefault();
if (e.touches.length === 2) {
const dist = Math.hypot(e.touches[0].clientX - e.touches[1].clientX, e.touches[0].clientY - e.touches[1].clientY);
if (lastTouchDist > 0) {
scale = Math.max(0.1, Math.min(10, scale * (dist / lastTouchDist)));
updateTransform();
}
lastTouchDist = dist;
} else if (dragging && e.touches.length === 1) {
tx = e.touches[0].clientX - startX;
ty = e.touches[0].clientY - startY;
updateTransform();
}
};
overlay.ontouchend = () => { dragging = false; lastTouchDist = 0; };
function updateTransform() {
img.style.transform = `translate(${tx}px, ${ty}px) scale(${scale})`;
zoomInfo.textContent = scale > 1 ? `${Math.round(scale * 100)}%` : '滚轮缩放 | 拖拽移动 | 点击空白关闭';
}
overlay.onclick = (e) => {
if (didDrag) { didDrag = false; return; }
if (e.target === overlay || e.target === zoomInfo) overlay.remove();
};
document.addEventListener('keydown', function handler(e) {
if (e.key === 'Escape') {
overlay.remove();
document.removeEventListener('keydown', handler);
}
});
document.body.appendChild(overlay);
}
function downloadImage(src, fileName) {
const a = document.createElement('a');
a.href = src;
a.download = fileName || 'image.png';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
}
/** 显示 OCR 详情模态框 */
function showOcrDetailModal(result) {
const confidence = result.confidence
? `<span class="badge badge-completed font-mono">${(result.confidence * 100).toFixed(1)}%</span>`
: '';
const provider = result.provider
? `<span class="badge badge-processing">${escapeHtml(result.provider)}</span>`
: '';
// 构建图片预览 URL从 file_path 中提取文件名)
let imgHtml = '';
if (result.file_path) {
const fileName = result.file_path.split('/').pop();
imgHtml = `
<div style="margin-bottom:16px; text-align:center;">
<img src="/data/images/${encodeURIComponent(fileName)}"
style="max-width:100%; max-height:360px; border-radius:12px; border:1px solid rgba(0,0,0,0.08); object-fit:contain; cursor:zoom-in;"
onclick="openImageViewer('/data/images/${encodeURIComponent(fileName)}', '${escapeHtml(fileName)}')"
onerror="this.parentElement.innerHTML='<p style=\\'color:#86868b;font-size:13px;\\'>图片无法加载</p>'"
/>
</div>`;
}
const overlay = document.createElement('div');
overlay.id = 'ocr-detail-overlay';
overlay.style.cssText = 'position:fixed; inset:0; background:rgba(0,0,0,0.4); z-index:1000; display:flex; align-items:center; justify-content:center; padding:20px;';
overlay.onclick = (e) => { if (e.target === overlay) overlay.remove(); };
overlay.innerHTML = `
<div style="background:#fff; border-radius:16px; padding:24px; max-width:720px; width:100%; max-height:85vh; overflow-y:auto; box-shadow:0 20px 60px rgba(0,0,0,0.15);">
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:16px;">
<h3 style="font-size:18px; font-weight:600; color:#1d1d1f; margin:0;">OCR 识别详情</h3>
<button onclick="document.getElementById('ocr-detail-overlay').remove()" style="background:none; border:none; font-size:24px; cursor:pointer; color:#86868b; padding:0 4px;">&times;</button>
</div>
<div style="display:flex; align-items:center; gap:8px; margin-bottom:12px; flex-wrap:wrap;">
${confidence}
${provider}
<span style="font-size:13px; color:#86868b;">ID: ${result.id}</span>
</div>
<p style="font-size:12px; color:#86868b; margin-bottom:12px; word-break:break-all;">
路径: ${escapeHtml(result.file_path || '')}
</p>
${imgHtml}
${result.story_summary ? `<div style="background:#f0f5ff; border-radius:10px; padding:12px; margin-bottom:12px; border-left:3px solid #0071e3;"><p style="font-size:13px; color:#1d1d1f; margin:0; font-weight:500;">故事摘要</p><p style="font-size:13px; color:#424245; margin:4px 0 0 0; line-height:1.5;">${escapeHtml(result.story_summary)}</p></div>` : ''}
<div style="background:#f5f5f7; border-radius:12px; padding:16px; max-height:300px; overflow-y:auto;">
<pre style="white-space:pre-wrap; font-size:14px; color:#424245; line-height:1.6; font-family:inherit; margin:0;">${escapeHtml(result.ocr_text || '无识别结果')}</pre>
</div>
<div style="display:flex; gap:8px; margin-top:16px;">
<button class="btn btn-sm btn-secondary" onclick="navigator.clipboard.writeText(decodeURIComponent('${encodeURIComponent(result.ocr_text || '')}')).then(()=>showToast('已复制','success'))">复制文本</button>
<button class="btn btn-sm" style="background:#ff3b30; color:#fff; border:none; margin-left:auto;" onclick="showConfirmModal('确定要删除这张图片吗?删除后不可恢复。', async ()=>{ await deleteImageById(${result.id}); document.getElementById('ocr-detail-overlay').remove(); })">删除图片</button>
</div>
</div>
`;
document.body.appendChild(overlay);
}
/** 搜索图片LLM 精排 SSE 流式) */
async function searchImages() {
const keyword = document.getElementById('ocr-search-keyword').value.trim();
if (!keyword) {
showToast('请输入搜索关键字', 'error');
return;
}
const limit = document.getElementById('ocr-search-limit').value || '5';
const sort = document.getElementById('ocr-search-sort').value || 'time_desc';
const resultsEl = document.getElementById('ocr-search-results');
resultsEl.style.display = 'block';
resultsEl.innerHTML = '<div class="spinner-sm" style="display:inline-block; margin-right:6px;"></div> AI 搜索中...';
let foundCount = 0;
const evtSource = new EventSource(
`/api/v1/images/search?keyword=${encodeURIComponent(keyword)}&limit=${encodeURIComponent(limit)}&sort=${encodeURIComponent(sort)}`
);
evtSource.onmessage = function(event) {
const data = JSON.parse(event.data);
if (data.type === 'start') {
resultsEl.innerHTML = `
<p id="ocr-search-status" style="font-size:14px; color:#86868b; margin-bottom:12px;">
正在搜索: "${escapeHtml(keyword)}",需要 ${data.limit} 个结果...
</p>
<div id="ocr-search-items" style="display:flex; flex-direction:column; gap:8px;"></div>
`;
} else if (data.type === 'progress') {
const statusEl = document.getElementById('ocr-search-status');
if (statusEl) {
statusEl.textContent = `已检查 ${data.checked}/${data.total} 张图片,已找到 ${data.found} 个匹配`;
}
} else if (data.type === 'result') {
foundCount++;
const tagsHtml = (tags) => {
if (!tags || tags.length === 0) return '';
return '<div style="display:flex; flex-wrap:wrap; gap:4px; margin-top:4px;">' +
tags.map(t => `<span style="display:inline-block; padding:2px 8px; border-radius:12px; font-size:11px; background:rgba(0,122,255,0.1); color:#007aff; white-space:nowrap;">${escapeHtml(t)}</span>`).join('') +
'</div>';
};
const itemHtml = `
<div style="background:#f5f5f7; border-radius:10px; padding:12px; cursor:pointer; animation:fadeIn 0.3s;" onclick="viewOcrResult(${data.id})">
<div style="display:flex; align-items:center; gap:8px; margin-bottom:4px;">
<span style="font-size:14px; font-weight:500;">${escapeHtml(data.file_path)}</span>
<span style="font-size:12px; color:#86868b;">#${foundCount}</span>
</div>
<p style="font-size:13px; color:#86868b;">${escapeHtml(data.ocr_text_preview || '')}</p>
${tagsHtml(data.tags)}
</div>`;
const itemsContainer = document.getElementById('ocr-search-items');
if (itemsContainer) {
itemsContainer.insertAdjacentHTML('beforeend', itemHtml);
}
const statusEl = document.getElementById('ocr-search-status');
if (statusEl) {
statusEl.textContent = `已找到 ${foundCount} 个匹配结果`;
}
} else if (data.type === 'done') {
evtSource.close();
const statusEl = document.getElementById('ocr-search-status');
if (statusEl) {
if (foundCount === 0) {
statusEl.textContent = `搜索完成,未找到匹配图片(共检查 ${data.total_checked || 0} 张)`;
} else {
statusEl.textContent = `搜索完成,共找到 ${foundCount} 个匹配图片(共检查 ${data.total_checked || 0} 张)`;
}
}
}
};
evtSource.onerror = function() {
evtSource.close();
if (foundCount === 0) {
const statusEl = document.getElementById('ocr-search-status');
if (statusEl) {
statusEl.textContent = '搜索连接中断';
statusEl.style.color = '#ff3b30';
} else {
resultsEl.innerHTML = '<p style="color:#ff3b30; font-size:14px;">搜索连接中断</p>';
}
}
};
}
// ══════════════════════════════════════════════════════════════════════
// 页面 5系统设置
// ══════════════════════════════════════════════════════════════════════
/** 切换嵌入模型配置字段显示 */
function toggleEmbedFields() {
const provider = document.getElementById('setting-embed-provider').value;
// 隐藏所有提供商特有字段
['openai', 'zhipu', 'dashscope', 'minimax', 'deepseek'].forEach(p => {
document.querySelectorAll(`[id^="field-${p}"]`).forEach(el => el.style.display = 'none');
});
// 显示对应字段
if (provider === 'openai') {
document.getElementById('field-openai-key').style.display = 'block';
document.getElementById('field-openai-url').style.display = 'block';
} else if (provider === 'zhipu') {
document.getElementById('field-zhipu-key').style.display = 'block';
} else if (provider === 'dashscope') {
document.getElementById('field-dashscope-key').style.display = 'block';
} else if (provider === 'minimax') {
// MiniMax 字段默认显示
document.getElementById('field-minimax-key').style.display = 'block';
document.getElementById('field-minimax-url').style.display = 'block';
}
}
/** 切换 OCR 配置字段显示 */
function toggleOcrFields() {
const provider = document.getElementById('setting-ocr-provider').value;
// 隐藏所有 OCR 提供商字段
['deepseek', 'aliyun', 'tencent'].forEach(p => {
document.querySelectorAll(`[id^="ocr-field-${p}"]`).forEach(el => el.style.display = 'none');
});
// 显示对应字段
if (provider === 'deepseek') {
document.getElementById('ocr-field-deepseek-key').style.display = 'block';
document.getElementById('ocr-field-deepseek-url').style.display = 'block';
document.getElementById('ocr-field-deepseek-model').style.display = 'block';
} else if (provider === 'aliyun') {
document.getElementById('ocr-field-aliyun-key').style.display = 'block';
document.getElementById('ocr-field-aliyun-secret').style.display = 'block';
} else if (provider === 'tencent') {
document.getElementById('ocr-field-tencent-id').style.display = 'block';
document.getElementById('ocr-field-tencent-key').style.display = 'block';
}
// paddleocr 和 auto 不需要额外配置
}
/** 加载设置(从后端获取当前配置) */
async function loadSettings() {
try {
const data = await api('GET', '/settings');
if (data.search_limit != null) {
document.getElementById('setting-search-limit').value = data.search_limit;
}
if (data.judge_batch_size != null) {
document.getElementById('setting-judge-batch-size').value = data.judge_batch_size;
}
// 企业微信机器人
document.getElementById('setting-wework-bot-enabled').checked = data.wework_bot_enabled;
document.getElementById('setting-wework-bot-id').value = data.wework_bot_id || '';
// secret 只显示脱敏值作为 placeholder
const secretInput = document.getElementById('setting-wework-bot-secret');
if (data.wework_bot_secret_masked) {
secretInput.placeholder = '当前: ' + data.wework_bot_secret_masked;
}
} catch (_) {
// 静默处理
}
// 初始化字段显示状态
toggleEmbedFields();
toggleOcrFields();
// 加载机器人状态
loadBotStatus();
}
/** 保存设置 */
async function saveSettings() {
const searchLimit = parseInt(document.getElementById('setting-search-limit').value);
const judgeBatchSize = parseInt(document.getElementById('setting-judge-batch-size').value);
const body = {};
if (!isNaN(searchLimit) && searchLimit >= 1 && searchLimit <= 10) {
body.search_limit = searchLimit;
}
if (!isNaN(judgeBatchSize) && judgeBatchSize >= 1 && judgeBatchSize <= 50) {
body.judge_batch_size = judgeBatchSize;
}
// 企业微信机器人
body.wework_bot_enabled = document.getElementById('setting-wework-bot-enabled').checked;
const botId = document.getElementById('setting-wework-bot-id').value.trim();
if (botId) body.wework_bot_id = botId;
const botSecret = document.getElementById('setting-wework-bot-secret').value.trim();
if (botSecret) body.wework_bot_secret = botSecret;
if (Object.keys(body).length === 0) {
showToast('没有需要保存的更改', 'info');
return;
}
try {
await api('PUT', '/settings', body);
showToast('设置已保存', 'success');
} catch (err) {
showToast('保存失败: ' + err.message, 'error');
}
}
/** 加载机器人状态 */
async function loadBotStatus() {
try {
const data = await api('GET', '/settings/bot/status');
const badge = document.getElementById('bot-status-badge');
if (data.running) {
badge.className = 'badge badge-completed';
badge.textContent = '运行中';
} else if (data.configured) {
badge.className = 'badge badge-failed';
badge.textContent = '已停止';
} else {
badge.className = 'badge badge-pending';
badge.textContent = '未配置';
}
} catch (_) {
document.getElementById('bot-status-badge').className = 'badge badge-pending';
document.getElementById('bot-status-badge').textContent = '未知';
}
}
/** 重启企业微信机器人 */
async function restartBot() {
const btn = document.getElementById('bot-restart-btn');
btn.disabled = true;
btn.textContent = '重启中...';
try {
const result = await api('POST', '/settings/bot/restart');
if (result.success) {
showToast(result.message, 'success');
} else {
showToast(result.message, 'error');
}
setTimeout(() => loadBotStatus(), 2000);
} catch (err) {
showToast('重启失败: ' + err.message, 'error');
} finally {
btn.disabled = false;
btn.textContent = '重启机器人';
}
}
/** 切换机器人启用状态 */
function toggleBotEnabled() {
// 仅切换 UI 状态,实际保存需要点"保存设置"
}
/** 测试嵌入模型连接 */
async function testEmbedding() {
showToast('正在测试嵌入模型连接...', 'info');
try {
// 使用搜索接口间接测试嵌入服务是否可用
await api('POST', '/search', {
query: '测试',
top_k: 1,
threshold: 0.0,
});
showToast('嵌入模型连接正常', 'success');
} catch (err) {
showToast('连接测试失败: ' + err.message, 'error');
}
}
/** 测试 OCR */
async function testOcr() {
showToast('请上传一张图片来测试 OCR 功能', 'info');
// 跳转到 OCR 页面
switchPage('ocr');
}
/** 测试 LLM */
async function testLlm() {
showToast('正在测试 LLM 连接...', 'info');
try {
// 使用搜索接口间接测试(搜索内部可能使用 LLM 进行查询扩展)
await api('POST', '/search', {
query: '你好',
top_k: 1,
threshold: 0.0,
});
showToast('服务连接正常', 'success');
} catch (err) {
showToast('测试失败: ' + err.message, 'error');
}
}
/** 加载系统信息 */
async function loadSystemInfo() {
// 数据库状态检测
try {
await api('GET', '/../health');
document.getElementById('sys-db-status').innerHTML =
'<span class="badge badge-completed">正常</span>';
} catch (_) {
document.getElementById('sys-db-status').innerHTML =
'<span class="badge badge-failed">异常</span>';
}
// 知识库统计
try {
const pagesData = await api('GET', '/pages?page=1&page_size=1');
document.getElementById('sys-pages').textContent = pagesData.total;
} catch (_) {
document.getElementById('sys-pages').textContent = '-';
}
try {
const imagesData = await api('GET', '/images?page=1&page_size=1');
document.getElementById('sys-images').textContent = imagesData.total;
} catch (_) {
document.getElementById('sys-images').textContent = '-';
}
document.getElementById('sys-api-url').textContent = API;
}
// ──────────────────────────── 工具函数 ────────────────────────────
/**
* HTML 转义,防止 XSS
* @param {string} str - 原始字符串
* @returns {string} 转义后的字符串
*/
function escapeHtml(str) {
if (!str) return '';
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
/**
* 渲染分页控件
* @param {string} containerId - 容器元素 ID
* @param {number} totalPages - 总页数
* @param {number} currentPage - 当前页
* @param {function} onPageChange - 页码变化回调
*/
function renderPagination(containerId, totalPages, currentPage, onPageChange) {
const container = document.getElementById(containerId);
let html = '';
// 上一页
html += `<button class="page-btn" ${currentPage <= 1 ? 'disabled' : ''}
onclick="(${onPageChange.toString()})(${currentPage - 1})">&laquo;</button>`;
// 页码按钮(最多显示 7 个)
const pages = [];
if (totalPages <= 7) {
for (let i = 1; i <= totalPages; i++) pages.push(i);
} else {
pages.push(1);
if (currentPage > 3) pages.push('...');
for (let i = Math.max(2, currentPage - 1); i <= Math.min(totalPages - 1, currentPage + 1); i++) {
pages.push(i);
}
if (currentPage < totalPages - 2) pages.push('...');
pages.push(totalPages);
}
pages.forEach(p => {
if (p === '...') {
html += '<span style="color:#86868b; padding:0 4px;">...</span>';
} else {
html += `<button class="page-btn ${p === currentPage ? 'active' : ''}"
onclick="(${onPageChange.toString()})(${p})">${p}</button>`;
}
});
// 下一页
html += `<button class="page-btn" ${currentPage >= totalPages ? 'disabled' : ''}
onclick="(${onPageChange.toString()})(${currentPage + 1})">&raquo;</button>`;
container.innerHTML = html;
}
// ──────────────────────────── 初始化 ────────────────────────────
document.addEventListener('DOMContentLoaded', () => {
// 初始化嵌入模型字段显示
toggleEmbedFields();
toggleOcrFields();
});
</script>
</body>
</html>