- BotManager: 后端启动时自动检测配置并拉起机器人(后台线程) - WeComBotService: 新增 start_async() 异步模式,支持嵌入事件循环 - settings API: 增加 bot 配置字段(enabled/bot_id/secret) - settings API: 增加 bot/status 和 bot/restart 端点 - 前端设置页: 增加企业微信机器人配置卡片(启用开关/ID/Secret) - 前端设置页: 增加机器人状态徽章和重启按钮 - Secret 脱敏显示,仅展示前4位
2878 lines
139 KiB
HTML
2878 lines
139 KiB
HTML
<!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()">☰</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">🔍</span>
|
||
<span>语义搜索</span>
|
||
</div>
|
||
<div class="nav-item" data-page="pages" onclick="switchPage('pages')">
|
||
<span class="icon">📄</span>
|
||
<span>知识页面</span>
|
||
</div>
|
||
<div class="nav-item" data-page="import" onclick="switchPage('import')">
|
||
<span class="icon">📤</span>
|
||
<span>文件导入</span>
|
||
</div>
|
||
<div class="nav-item" data-page="ocr" onclick="switchPage('ocr')">
|
||
<span class="icon">📷</span>
|
||
<span>图片 OCR</span>
|
||
</div>
|
||
<div class="nav-item" data-page="settings" onclick="switchPage('settings')">
|
||
<span class="icon">⚙️</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;">🔍</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;">📁</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;">📷</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 | 可选择多个文件</p>
|
||
</div>
|
||
</div>
|
||
<div style="display:flex; gap:10px; margin-top:16px; flex-wrap:wrap;">
|
||
<button class="btn btn-secondary" onclick="event.stopPropagation(); ocrPickFiles()">
|
||
📁 选择文件
|
||
</button>
|
||
<button class="btn btn-secondary" onclick="event.stopPropagation(); ocrPickFolder()">
|
||
📂 选择文件夹
|
||
</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">🔄 一键去重</button>
|
||
<button class="btn btn-secondary" onclick="loadManageImages()">🔄 刷新</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;">😔</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;">⚠️</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="删除图片">×</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 = '🔄 一键去重';
|
||
}
|
||
}
|
||
|
||
/** 通用确认弹窗 */
|
||
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">📄</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="移除">×</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">⏳</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 = '🔄';
|
||
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 = '✅';
|
||
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 = '❌';
|
||
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;">✅</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;">❌</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;">✅</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;">✅</span>';
|
||
case 'processing': return '<span style="color:#0071e3;">⏳</span>';
|
||
case 'failed': return '<span style="color:#ff3b30;">❌</span>';
|
||
default: return '<span style="color:#86868b;">⏸</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;">📄 ${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);">
|
||
📥 下载
|
||
</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);">
|
||
×
|
||
</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;">×</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})">«</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})">»</button>`;
|
||
|
||
container.innerHTML = html;
|
||
}
|
||
|
||
// ──────────────────────────── 初始化 ────────────────────────────
|
||
document.addEventListener('DOMContentLoaded', () => {
|
||
// 初始化嵌入模型字段显示
|
||
toggleEmbedFields();
|
||
toggleOcrFields();
|
||
});
|
||
</script>
|
||
</body>
|
||
</html>
|