1384 lines
61 KiB
HTML
1384 lines
61 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>本地知识库 · 萧康云医 TCM Agent</title>
|
||
|
||
<link rel="stylesheet" href="https://unpkg.com/element-plus@2.7.6/dist/index.css" />
|
||
<script src="https://unpkg.com/vue@3.4.38/dist/vue.global.prod.js"></script>
|
||
<script src="https://unpkg.com/element-plus@2.7.6/dist/index.full.min.js"></script>
|
||
<script src="https://unpkg.com/element-plus@2.7.6/dist/locale/zh-cn.min.js"></script>
|
||
<script src="https://unpkg.com/@element-plus/icons-vue@2.3.1/dist/index.iife.min.js"></script>
|
||
<!-- 暗色模式 CSS(Element Plus 官方) -->
|
||
<link rel="stylesheet" href="https://unpkg.com/element-plus@2.7.6/theme-chalk/dark/css-vars.css" />
|
||
|
||
<style>
|
||
/* ============================================================
|
||
* 主题系统:CSS 变量驱动,亮/暗双主题
|
||
* ============================================================
|
||
* - html 上挂 data-theme="light" / "dark"
|
||
* - 不挂时跟随系统 prefers-color-scheme
|
||
* - 用户手动切换时写 localStorage
|
||
*/
|
||
|
||
/* ========== 亮色主题(默认) ========== */
|
||
/* 配色定位:亮底 + 冷色调(蓝紫为主,营造专业、冷静、可信感) */
|
||
:root,
|
||
[data-theme="light"] {
|
||
--bg-gradient: radial-gradient(ellipse at top left, #e8edff 0%, #f5f7ff 35%, #fafbfff 100%);
|
||
--bg-base: #fafbfff;
|
||
--text-primary: #0f1729;
|
||
--text-secondary: #4e5969;
|
||
--text-tertiary: #86909c;
|
||
--text-quaternary: #c9cdd4;
|
||
/* 玻璃质感 */
|
||
--glass-bg: rgba(255, 255, 255, 0.65);
|
||
--glass-bg-strong: rgba(255, 255, 255, 0.85);
|
||
--glass-bg-hover: rgba(245, 247, 255, 0.8);
|
||
--glass-border: rgba(255, 255, 255, 0.7);
|
||
--glass-border-subtle: rgba(64, 80, 180, 0.08);
|
||
--glass-blur: saturate(180%) blur(20px);
|
||
--glass-shadow: 0 4px 24px rgba(64, 80, 180, 0.06);
|
||
--glass-shadow-hover: 0 12px 32px rgba(64, 80, 180, 0.12);
|
||
/* 强调色:冷蓝紫(亮色主调) */
|
||
--accent: #4361ee; /* 冷蓝紫,比纯蓝更有质感 */
|
||
--accent-hover: #3a4fd4;
|
||
--accent-soft: rgba(67, 97, 238, 0.12);
|
||
--accent-bg: rgba(67, 97, 238, 0.07);
|
||
--accent-grad: linear-gradient(135deg, #4361ee 0%, #7209b7 100%); /* 蓝紫渐变 */
|
||
/* 状态色 */
|
||
--success: #009a55;
|
||
--warning: #d46b08;
|
||
--danger: #f5222d;
|
||
--border: rgba(64, 80, 180, 0.12);
|
||
--fill-quaternary: rgba(64, 80, 180, 0.04);
|
||
--skeleton-from: #eaecf7;
|
||
--skeleton-to: #d8dcef;
|
||
}
|
||
|
||
/* ========== 暗色主题 ========== */
|
||
/* 配色定位:深底 + 暖色调(琥珀橙为主,营造温暖、专注、夜读感) */
|
||
[data-theme="dark"] {
|
||
--bg-gradient: radial-gradient(ellipse at top left, #2a1f15 0%, #181210 35%, #0e0a08 100%);
|
||
--bg-base: #0e0a08;
|
||
--text-primary: #fdf6e3; /* 暖白(Solarized 风格) */
|
||
--text-secondary: #d4c5a9;
|
||
--text-tertiary: #998871;
|
||
--text-quaternary: #5a4d3c;
|
||
/* 暗色玻璃:深棕底 + 模糊 */
|
||
--glass-bg: rgba(36, 28, 22, 0.55);
|
||
--glass-bg-strong: rgba(52, 40, 30, 0.78);
|
||
--glass-bg-hover: rgba(64, 50, 38, 0.65);
|
||
--glass-border: rgba(255, 200, 130, 0.1);
|
||
--glass-border-subtle: rgba(255, 180, 100, 0.05);
|
||
--glass-blur: saturate(150%) blur(20px);
|
||
--glass-shadow: 0 4px 24px rgba(0, 0, 0, 0.4);
|
||
--glass-shadow-hover: 0 12px 32px rgba(0, 0, 0, 0.55);
|
||
/* 强调色:琥珀橙(暗色主调,夜读护眼) */
|
||
--accent: #f59e0b; /* 琥珀橙 */
|
||
--accent-hover: #d97706;
|
||
--accent-soft: rgba(245, 158, 11, 0.18);
|
||
--accent-bg: rgba(245, 158, 11, 0.1);
|
||
--accent-grad: linear-gradient(135deg, #f59e0b 0%, #dc2626 100%); /* 橙红渐变 */
|
||
/* 状态色 */
|
||
--success: #84cc16; /* 暖绿 */
|
||
--warning: #fb923c;
|
||
--danger: #ef4444;
|
||
--border: rgba(255, 180, 100, 0.15);
|
||
--fill-quaternary: rgba(255, 200, 130, 0.05);
|
||
--skeleton-from: #2a2018;
|
||
--skeleton-to: #3a2c20;
|
||
}
|
||
|
||
/* 跟随系统(仅当未手动设置时生效) */
|
||
@media (prefers-color-scheme: dark) {
|
||
html:not([data-theme="light"]):not([data-theme="dark"]) {
|
||
color-scheme: dark;
|
||
}
|
||
html:not([data-theme="light"]):not([data-theme="dark"]) body {
|
||
background: radial-gradient(ellipse at top left, #2a1f15 0%, #181210 35%, #0e0a08 100%);
|
||
}
|
||
}
|
||
|
||
* { box-sizing: border-box; }
|
||
html, body {
|
||
margin: 0; padding: 0; min-height: 100%;
|
||
color: var(--text-primary);
|
||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
|
||
font-size: 14px;
|
||
transition: background 0.3s ease, color 0.3s ease;
|
||
color-scheme: light;
|
||
}
|
||
[data-theme="dark"] body { color-scheme: dark; }
|
||
body {
|
||
background: var(--bg-gradient);
|
||
background-attachment: fixed;
|
||
}
|
||
#app { min-height: 100vh; padding: 0; }
|
||
|
||
/* ============================================================
|
||
* 顶部毛玻璃导航栏
|
||
* ============================================================ */
|
||
.navbar {
|
||
background: var(--glass-bg);
|
||
backdrop-filter: var(--glass-blur);
|
||
-webkit-backdrop-filter: var(--glass-blur);
|
||
border-bottom: 1px solid var(--glass-border-subtle);
|
||
padding: 0 32px;
|
||
height: 60px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
position: sticky; top: 0; z-index: 100;
|
||
}
|
||
.navbar-brand {
|
||
display: flex; align-items: center; gap: 12px;
|
||
}
|
||
.navbar-logo {
|
||
width: 32px; height: 32px;
|
||
background: var(--accent-grad);
|
||
border-radius: 8px;
|
||
display: flex; align-items: center; justify-content: center;
|
||
color: white; font-size: 15px; font-weight: 600;
|
||
}
|
||
.navbar-title { font-size: 15px; font-weight: 600; }
|
||
.navbar-sub { font-size: 11px; color: var(--text-tertiary); margin-top: 1px; }
|
||
.navbar-actions { display: flex; align-items: center; gap: 8px; }
|
||
.navbar-mode-badge {
|
||
font-size: 11px; padding: 3px 10px;
|
||
background: var(--accent-bg);
|
||
color: var(--accent);
|
||
border-radius: 10px;
|
||
border: 1px solid var(--accent-soft);
|
||
}
|
||
|
||
/* ============================================================
|
||
* 通用玻璃面板
|
||
* ============================================================ */
|
||
.container { max-width: 1280px; margin: 0 auto; padding: 32px; }
|
||
|
||
.glass {
|
||
background: var(--glass-bg);
|
||
backdrop-filter: var(--glass-blur);
|
||
-webkit-backdrop-filter: var(--glass-blur);
|
||
border: 1px solid var(--glass-border-subtle);
|
||
box-shadow: var(--glass-shadow);
|
||
border-radius: 14px;
|
||
transition: all 0.25s ease;
|
||
}
|
||
.glass-strong {
|
||
background: var(--glass-bg-strong);
|
||
backdrop-filter: var(--glass-blur);
|
||
-webkit-backdrop-filter: var(--glass-blur);
|
||
border: 1px solid var(--glass-border);
|
||
}
|
||
|
||
/* ============================================================
|
||
* 面包屑
|
||
* ============================================================ */
|
||
.breadcrumb-bar {
|
||
display: flex; align-items: center; justify-content: space-between;
|
||
margin-bottom: 20px;
|
||
}
|
||
|
||
/* ============================================================
|
||
* Tab 切换(玻璃胶囊)
|
||
* ============================================================ */
|
||
.tabs-bar {
|
||
display: inline-flex;
|
||
padding: 4px;
|
||
background: var(--glass-bg);
|
||
backdrop-filter: var(--glass-blur);
|
||
-webkit-backdrop-filter: var(--glass-blur);
|
||
border: 1px solid var(--glass-border-subtle);
|
||
border-radius: 12px;
|
||
gap: 2px;
|
||
margin-bottom: 24px;
|
||
}
|
||
.tab-btn {
|
||
padding: 7px 18px;
|
||
background: transparent;
|
||
border: none;
|
||
border-radius: 9px;
|
||
cursor: pointer;
|
||
font-size: 13px;
|
||
color: var(--text-secondary);
|
||
transition: all 0.2s ease;
|
||
display: inline-flex; align-items: center; gap: 6px;
|
||
}
|
||
.tab-btn:hover { color: var(--text-primary); background: var(--fill-quaternary); }
|
||
.tab-btn.active {
|
||
background: var(--glass-bg-strong);
|
||
color: var(--text-primary);
|
||
box-shadow: 0 1px 3px rgba(0,0,0,0.06);
|
||
}
|
||
[data-theme="dark"] .tab-btn.active { box-shadow: 0 1px 3px rgba(0,0,0,0.3); }
|
||
|
||
/* ============================================================
|
||
* 库卡片网格
|
||
* ============================================================ */
|
||
.lib-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||
gap: 16px;
|
||
}
|
||
.lib-card {
|
||
background: var(--glass-bg);
|
||
backdrop-filter: var(--glass-blur);
|
||
-webkit-backdrop-filter: var(--glass-blur);
|
||
border: 1px solid var(--glass-border-subtle);
|
||
box-shadow: var(--glass-shadow);
|
||
border-radius: 14px;
|
||
padding: 20px;
|
||
cursor: pointer;
|
||
transition: all 0.25s ease;
|
||
}
|
||
.lib-card:hover {
|
||
transform: translateY(-2px);
|
||
background: var(--glass-bg-hover);
|
||
box-shadow: var(--glass-shadow-hover);
|
||
border-color: var(--glass-border);
|
||
}
|
||
.lib-card-header {
|
||
display: flex; justify-content: space-between; align-items: flex-start;
|
||
margin-bottom: 10px;
|
||
}
|
||
.lib-icon-wrap {
|
||
width: 36px; height: 36px;
|
||
background: var(--fill-quaternary);
|
||
border-radius: 9px;
|
||
display: flex; align-items: center; justify-content: center;
|
||
color: var(--text-secondary);
|
||
}
|
||
.lib-title {
|
||
font-size: 15px; font-weight: 600; color: var(--text-primary);
|
||
margin-bottom: 4px;
|
||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||
}
|
||
.lib-desc {
|
||
font-size: 12px; color: var(--text-tertiary);
|
||
line-height: 1.5;
|
||
height: 36px;
|
||
overflow: hidden;
|
||
display: -webkit-box;
|
||
-webkit-line-clamp: 2;
|
||
-webkit-box-orient: vertical;
|
||
}
|
||
.lib-stats {
|
||
display: flex; gap: 20px; margin-top: 14px;
|
||
padding-top: 14px;
|
||
border-top: 1px solid var(--glass-border-subtle);
|
||
}
|
||
.lib-stat .num {
|
||
font-size: 18px; font-weight: 600; color: var(--text-primary);
|
||
line-height: 1; font-variant-numeric: tabular-nums;
|
||
}
|
||
.lib-stat .lbl {
|
||
font-size: 11px; color: var(--text-tertiary); margin-top: 3px;
|
||
}
|
||
.lib-card-footer {
|
||
margin-top: 12px;
|
||
display: flex; justify-content: space-between; align-items: center;
|
||
}
|
||
.source-pill {
|
||
font-size: 10px; padding: 2px 8px; border-radius: 4px;
|
||
background: var(--fill-quaternary); color: var(--text-secondary);
|
||
}
|
||
.meta-tip {
|
||
font-size: 12px; color: var(--text-tertiary); line-height: 1.6;
|
||
}
|
||
|
||
/* ============================================================
|
||
* 空状态
|
||
* ============================================================ */
|
||
.empty-state {
|
||
text-align: center; padding: 80px 20px;
|
||
}
|
||
.empty-state .icon-wrap {
|
||
width: 72px; height: 72px; margin: 0 auto 16px;
|
||
background: var(--fill-quaternary);
|
||
border-radius: 50%;
|
||
display: flex; align-items: center; justify-content: center;
|
||
color: var(--text-tertiary);
|
||
}
|
||
.empty-state h3 {
|
||
margin: 0 0 6px; font-size: 15px; color: var(--text-primary); font-weight: 600;
|
||
}
|
||
.empty-state p { margin: 0; color: var(--text-tertiary); font-size: 13px; }
|
||
|
||
/* ============================================================
|
||
* 文档项
|
||
* ============================================================ */
|
||
.doc-list { display: flex; flex-direction: column; gap: 10px; }
|
||
.doc-item {
|
||
background: var(--glass-bg);
|
||
backdrop-filter: var(--glass-blur);
|
||
-webkit-backdrop-filter: var(--glass-blur);
|
||
border: 1px solid var(--glass-border-subtle);
|
||
border-radius: 11px;
|
||
padding: 14px 18px;
|
||
display: flex; align-items: center; justify-content: space-between;
|
||
transition: all 0.2s ease;
|
||
}
|
||
.doc-item:hover {
|
||
background: var(--glass-bg-hover);
|
||
border-color: var(--glass-border);
|
||
}
|
||
.doc-info { flex: 1; min-width: 0; }
|
||
.doc-title {
|
||
font-size: 14px; font-weight: 500; color: var(--text-primary); margin-bottom: 4px;
|
||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||
}
|
||
.doc-meta { font-size: 12px; color: var(--text-tertiary); display: flex; gap: 12px; align-items: center; }
|
||
.doc-meta .dot { color: var(--text-quaternary); }
|
||
|
||
/* ============================================================
|
||
* 拖拽上传区
|
||
* ============================================================ */
|
||
.drop-zone {
|
||
background: var(--glass-bg);
|
||
backdrop-filter: var(--glass-blur);
|
||
-webkit-backdrop-filter: var(--glass-blur);
|
||
border: 1.5px dashed var(--border);
|
||
border-radius: 12px;
|
||
padding: 36px 20px;
|
||
text-align: center;
|
||
cursor: pointer;
|
||
transition: all 0.25s ease;
|
||
margin-bottom: 16px;
|
||
}
|
||
.drop-zone:hover {
|
||
border-color: var(--accent);
|
||
background: var(--accent-bg);
|
||
}
|
||
.drop-zone.dragover {
|
||
border-color: var(--accent);
|
||
background: var(--accent-soft);
|
||
transform: scale(1.005);
|
||
}
|
||
.drop-icon {
|
||
width: 48px; height: 48px; margin: 0 auto 10px;
|
||
background: var(--fill-quaternary);
|
||
border-radius: 12px;
|
||
display: flex; align-items: center; justify-content: center;
|
||
color: var(--accent);
|
||
}
|
||
.drop-title { font-size: 14px; font-weight: 500; color: var(--text-primary); margin-bottom: 4px; }
|
||
.drop-tip { font-size: 12px; color: var(--text-tertiary); }
|
||
.drop-link { color: var(--accent); font-weight: 500; }
|
||
|
||
/* ============================================================
|
||
* 检索结果卡
|
||
* ============================================================ */
|
||
.result-card {
|
||
background: var(--glass-bg);
|
||
backdrop-filter: var(--glass-blur);
|
||
-webkit-backdrop-filter: var(--glass-blur);
|
||
border: 1px solid var(--glass-border-subtle);
|
||
border-left: 3px solid var(--accent);
|
||
border-radius: 11px;
|
||
padding: 16px 18px;
|
||
margin-bottom: 10px;
|
||
transition: all 0.2s ease;
|
||
}
|
||
.result-card:hover {
|
||
background: var(--glass-bg-hover);
|
||
}
|
||
.result-title { font-size: 13px; font-weight: 600; color: var(--text-primary); margin-bottom: 6px; }
|
||
.result-content {
|
||
font-size: 13px; color: var(--text-secondary); line-height: 1.65;
|
||
white-space: pre-wrap; word-break: break-word;
|
||
max-height: 180px; overflow: auto;
|
||
background: var(--fill-quaternary);
|
||
padding: 10px 12px; border-radius: 7px;
|
||
margin-top: 8px;
|
||
}
|
||
.score-bar {
|
||
margin-top: 10px; display: flex; align-items: center; gap: 8px;
|
||
}
|
||
|
||
/* ============================================================
|
||
* 抽屉里的分段卡
|
||
* ============================================================ */
|
||
.chunk-item {
|
||
padding: 14px;
|
||
background: var(--fill-quaternary);
|
||
border-radius: 9px;
|
||
margin-bottom: 8px;
|
||
}
|
||
.chunk-head { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
|
||
.chunk-idx {
|
||
display: inline-flex; align-items: center; justify-content: center;
|
||
min-width: 22px; height: 22px;
|
||
background: var(--accent); color: white;
|
||
border-radius: 50%; font-size: 10px; font-weight: 600;
|
||
font-variant-numeric: tabular-nums;
|
||
}
|
||
.chunk-title { font-size: 13px; font-weight: 600; color: var(--text-primary); }
|
||
.chunk-content {
|
||
font-size: 12px; color: var(--text-secondary); line-height: 1.65;
|
||
white-space: pre-wrap; word-break: break-word;
|
||
background: var(--glass-bg-strong);
|
||
padding: 8px 10px; border-radius: 6px;
|
||
}
|
||
.chunk-related {
|
||
margin-top: 6px; padding: 6px 10px;
|
||
background: var(--accent-bg); border-radius: 5px;
|
||
font-size: 11px; color: var(--accent);
|
||
}
|
||
|
||
/* ============================================================
|
||
* 浮动新建按钮(FAB)
|
||
* ============================================================ */
|
||
.fab {
|
||
position: fixed; right: 32px; bottom: 32px;
|
||
width: 52px; height: 52px;
|
||
background: var(--accent-grad);
|
||
border-radius: 50%;
|
||
display: flex; align-items: center; justify-content: center;
|
||
color: white;
|
||
cursor: pointer;
|
||
box-shadow: 0 8px 24px var(--accent-soft);
|
||
transition: all 0.25s ease;
|
||
z-index: 50;
|
||
border: none;
|
||
}
|
||
.fab:hover {
|
||
transform: scale(1.08);
|
||
box-shadow: 0 12px 32px var(--accent-soft);
|
||
}
|
||
|
||
/* ============================================================
|
||
* 主题切换按钮
|
||
* ============================================================ */
|
||
.theme-toggle {
|
||
width: 36px; height: 36px;
|
||
background: var(--fill-quaternary);
|
||
border: 1px solid var(--glass-border-subtle);
|
||
border-radius: 50%;
|
||
display: flex; align-items: center; justify-content: center;
|
||
color: var(--text-secondary);
|
||
cursor: pointer;
|
||
transition: all 0.2s ease;
|
||
}
|
||
.theme-toggle:hover {
|
||
background: var(--accent-bg);
|
||
color: var(--accent);
|
||
border-color: var(--accent-soft);
|
||
}
|
||
|
||
/* ============================================================
|
||
* 骨架屏
|
||
* ============================================================ */
|
||
.skeleton-card {
|
||
height: 180px;
|
||
background: linear-gradient(90deg, var(--skeleton-from) 25%, var(--skeleton-to) 37%, var(--skeleton-from) 63%);
|
||
background-size: 400% 100%;
|
||
animation: skeleton-loading 1.4s ease infinite;
|
||
border-radius: 14px;
|
||
}
|
||
@keyframes skeleton-loading {
|
||
0% { background-position: 100% 50%; }
|
||
100% { background-position: 0 50%; }
|
||
}
|
||
|
||
/* ============================================================
|
||
* 过渡动画
|
||
* ============================================================ */
|
||
.fade-enter-active, .fade-leave-active { transition: opacity 0.25s ease; }
|
||
.fade-enter-from, .fade-leave-to { opacity: 0; }
|
||
|
||
/* ============================================================
|
||
* Element Plus 暗色补丁:让组件库跟主题色
|
||
* ============================================================ */
|
||
[data-theme="dark"] .el-input__wrapper,
|
||
[data-theme="dark"] .el-textarea__inner,
|
||
[data-theme="dark"] .el-select__wrapper {
|
||
background-color: rgba(255,255,255,0.05) !important;
|
||
box-shadow: 0 0 0 1px rgba(255,255,255,0.1) inset !important;
|
||
}
|
||
[data-theme="dark"] .el-input__inner,
|
||
[data-theme="dark"] .el-textarea__inner { color: var(--text-primary) !important; }
|
||
[data-theme="dark"] .el-dialog,
|
||
[data-theme="dark"] .el-drawer,
|
||
[data-theme="dark"] .el-message-box {
|
||
background-color: rgba(28, 28, 32, 0.9) !important;
|
||
backdrop-filter: var(--glass-blur);
|
||
-webkit-backdrop-filter: var(--glass-blur);
|
||
border: 1px solid rgba(255,255,255,0.08);
|
||
}
|
||
[data-theme="dark"] .el-message-box__title,
|
||
[data-theme="dark"] .el-dialog__title,
|
||
[data-theme="dark"] .el-drawer__title { color: var(--text-primary) !important; }
|
||
[data-theme="dark"] .el-loading-mask { background-color: rgba(0,0,0,0.5) !important; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div id="app">
|
||
|
||
<!-- ===================== 顶部导航 ===================== -->
|
||
<div class="navbar">
|
||
<div class="navbar-brand">
|
||
<div class="navbar-logo">知</div>
|
||
<div>
|
||
<div class="navbar-title">本地知识库</div>
|
||
<div class="navbar-sub">TCM Agent · V1 全文检索</div>
|
||
</div>
|
||
</div>
|
||
<div class="navbar-actions">
|
||
<span class="navbar-mode-badge">V2 接 BGE-M3</span>
|
||
<button class="theme-toggle" @click="toggleTheme" :title="isDark ? '切换到亮色' : '切换到暗色'">
|
||
<el-icon size="18">
|
||
<Sunny v-if="isDark"></Sunny>
|
||
<Moon v-else></Moon>
|
||
</el-icon>
|
||
</button>
|
||
<el-tooltip content="退出登录" placement="bottom">
|
||
<button class="theme-toggle" @click="logout">
|
||
<el-icon size="18"><SwitchButton /></el-icon>
|
||
</button>
|
||
</el-tooltip>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="container">
|
||
|
||
<!-- ===================== Tab 切换 ===================== -->
|
||
<div class="tabs-bar" v-show="view === 'libraries' || view === 'search'">
|
||
<button class="tab-btn" :class="{active: view === 'libraries'}" @click="view = 'libraries'">
|
||
<el-icon><Collection /></el-icon> 知识库管理
|
||
</button>
|
||
<button class="tab-btn" :class="{active: view === 'search'}" @click="view = 'search'">
|
||
<el-icon><Search /></el-icon> 检索测试
|
||
</button>
|
||
</div>
|
||
|
||
<!-- ============================================================ -->
|
||
<!-- 视图1:知识库列表 -->
|
||
<!-- ============================================================ -->
|
||
<div v-if="view === 'libraries' && !currentLib">
|
||
<div class="breadcrumb-bar">
|
||
<el-breadcrumb separator="/">
|
||
<el-breadcrumb-item>知识库</el-breadcrumb-item>
|
||
</el-breadcrumb>
|
||
<span class="meta-tip">共 {{ libraries.length }} 个知识库</span>
|
||
</div>
|
||
|
||
<div v-if="loading.libs" class="lib-grid">
|
||
<div v-for="i in 3" :key="i" class="skeleton-card"></div>
|
||
</div>
|
||
|
||
<div v-else-if="libraries.length === 0" class="glass empty-state">
|
||
<div class="icon-wrap">
|
||
<el-icon size="32"><FolderAdd /></el-icon>
|
||
</div>
|
||
<h3>还没有任何知识库</h3>
|
||
<p>点击右下角的「+」按钮创建你的第一个知识库</p>
|
||
</div>
|
||
|
||
<div v-else class="lib-grid">
|
||
<div v-for="lib in libraries" :key="lib.id" class="lib-card" @click="openLibrary(lib)">
|
||
<div class="lib-card-header">
|
||
<div class="lib-icon-wrap">
|
||
<el-icon size="20"><Folder /></el-icon>
|
||
</div>
|
||
<el-dropdown trigger="click" @command="cmd => onLibCommand(cmd, lib)" @click.stop>
|
||
<button class="theme-toggle" @click.stop style="width:28px; height:28px; border:none;">
|
||
<el-icon size="16"><MoreFilled /></el-icon>
|
||
</button>
|
||
<template #dropdown>
|
||
<el-dropdown-menu>
|
||
<el-dropdown-item command="search">
|
||
<el-icon><Search /></el-icon> 在此库检索
|
||
</el-dropdown-item>
|
||
<el-dropdown-item command="delete" divided>
|
||
<el-icon><Delete /></el-icon> 删除库
|
||
</el-dropdown-item>
|
||
</el-dropdown-menu>
|
||
</template>
|
||
</el-dropdown>
|
||
</div>
|
||
<div class="lib-title">{{ lib.name }}</div>
|
||
<div class="lib-desc">{{ lib.description || '暂无描述' }}</div>
|
||
<div class="lib-stats">
|
||
<div class="lib-stat">
|
||
<div class="num">{{ lib.doc_count }}</div>
|
||
<div class="lbl">文档</div>
|
||
</div>
|
||
<div class="lib-stat">
|
||
<div class="num">{{ lib.chunk_count }}</div>
|
||
<div class="lbl">分段</div>
|
||
</div>
|
||
<div class="lib-stat">
|
||
<div class="num">{{ lib.vectorized_count }}</div>
|
||
<div class="lbl">已向量</div>
|
||
</div>
|
||
</div>
|
||
<div class="lib-card-footer">
|
||
<span class="source-pill">
|
||
{{ lib.source === 'maxkb_export' ? 'MaxKB 导出' : '手动建立' }}
|
||
</span>
|
||
<span class="meta-tip">{{ formatTime(lib.created_at) }}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ============================================================ -->
|
||
<!-- 视图2:库详情(文档列表) -->
|
||
<!-- ============================================================ -->
|
||
<div v-if="view === 'libraries' && currentLib">
|
||
<div class="breadcrumb-bar">
|
||
<el-breadcrumb separator="/">
|
||
<el-breadcrumb-item><a @click="exitLibrary" style="cursor:pointer;">知识库</a></el-breadcrumb-item>
|
||
<el-breadcrumb-item>{{ currentLib.name }}</el-breadcrumb-item>
|
||
</el-breadcrumb>
|
||
<span class="meta-tip">{{ currentLib.doc_count }} 篇文档 · {{ currentLib.chunk_count }} 个分段</span>
|
||
</div>
|
||
|
||
<!-- 库信息卡 -->
|
||
<div class="glass" style="padding:18px; margin-bottom:16px; display:flex; align-items:center; justify-content:space-between;">
|
||
<div style="flex:1; min-width:0;">
|
||
<div style="font-size:18px; font-weight:600; color:var(--text-primary);">{{ currentLib.name }}</div>
|
||
<div class="meta-tip" style="margin-top:4px;">{{ currentLib.description || '暂无描述' }}</div>
|
||
</div>
|
||
<el-button type="primary" plain @click="view='search'; searchForm.library_id=currentLib.id">
|
||
<el-icon><Search /></el-icon> 检索测试
|
||
</el-button>
|
||
</div>
|
||
|
||
<!-- 拖拽上传 -->
|
||
<div
|
||
class="drop-zone"
|
||
:class="{dragover: isDragOver}"
|
||
@click="triggerFileInput"
|
||
@dragover.prevent="isDragOver = true"
|
||
@dragleave.prevent="isDragOver = false"
|
||
@drop.prevent="handleDrop"
|
||
>
|
||
<input type="file" ref="fileInput" style="display:none"
|
||
accept=".xlsx,.xls,.csv,.md,.txt,.pdf,.docx,.html,.htm"
|
||
@change="handleFileInput" />
|
||
<div v-if="loading.import" style="display:flex; flex-direction:column; align-items:center; gap:12px; padding:8px;">
|
||
<el-icon class="is-loading" size="32" color="var(--accent)"><Loading /></el-icon>
|
||
<div style="color:var(--accent); font-weight:500; font-size:13px;">正在解析与入库…</div>
|
||
</div>
|
||
<template v-else>
|
||
<div class="drop-icon">
|
||
<el-icon size="24"><UploadFilled /></el-icon>
|
||
</div>
|
||
<div class="drop-title">
|
||
点击上传或 <span class="drop-link">拖拽文件到此处</span>
|
||
</div>
|
||
<div class="drop-tip">
|
||
支持 .xlsx / .csv / .md / .txt / .pdf / .docx / .html · 自动分段 · 单文件最大 50MB
|
||
</div>
|
||
<div class="drop-tip">
|
||
PDF 需为文字版(扫描版请先 OCR)· 老版 .doc/.xls 请先另存为 .docx/.xlsx
|
||
</div>
|
||
</template>
|
||
</div>
|
||
|
||
<!-- 文档列表 -->
|
||
<div class="doc-list" v-loading="loading.docs">
|
||
<div v-for="doc in docs" :key="doc.id" class="doc-item">
|
||
<div class="doc-info">
|
||
<div class="doc-title">{{ doc.title }}</div>
|
||
<div class="doc-meta">
|
||
<span>{{ doc.source_file }}</span>
|
||
<span class="dot">·</span>
|
||
<el-tag size="small" effect="plain">{{ doc.source_type }}</el-tag>
|
||
<span class="dot">·</span>
|
||
<span>{{ doc.chunk_count }} 个分段</span>
|
||
<span class="dot">·</span>
|
||
<span>{{ formatTime(doc.created_at) }}</span>
|
||
</div>
|
||
</div>
|
||
<div style="display:flex; gap:6px;">
|
||
<el-button size="small" @click="openChunksDrawer(doc)">
|
||
<el-icon><View /></el-icon> 查看分段
|
||
</el-button>
|
||
<el-button size="small" type="danger" plain circle @click="deleteDoc(doc)">
|
||
<el-icon><Delete /></el-icon>
|
||
</el-button>
|
||
</div>
|
||
</div>
|
||
<div v-if="!loading.docs && docs.length === 0" class="glass empty-state">
|
||
<div class="icon-wrap">
|
||
<el-icon size="32"><Document /></el-icon>
|
||
</div>
|
||
<h3>该库还没有任何文档</h3>
|
||
<p>把文件拖到上面的上传区即可导入</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ============================================================ -->
|
||
<!-- 视图3:检索测试 -->
|
||
<!-- ============================================================ -->
|
||
<div v-if="view === 'search'">
|
||
<div class="breadcrumb-bar">
|
||
<el-breadcrumb separator="/">
|
||
<el-breadcrumb-item>检索测试</el-breadcrumb-item>
|
||
</el-breadcrumb>
|
||
</div>
|
||
|
||
<div class="glass" style="padding:20px; margin-bottom:16px;">
|
||
<el-form label-position="top">
|
||
<el-form-item label="选择知识库">
|
||
<el-select v-model="searchForm.library_id" placeholder="请选择知识库" style="width:100%;">
|
||
<el-option
|
||
v-for="lib in libraries"
|
||
:key="lib.id"
|
||
:label="lib.name + '(' + lib.chunk_count + ' 个分段)'"
|
||
:value="lib.id"
|
||
/>
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item label="检索词">
|
||
<el-input
|
||
v-model="searchForm.query"
|
||
placeholder="例如:痰湿中阻 怎么治"
|
||
size="large"
|
||
clearable
|
||
@keyup.enter="runSearch"
|
||
>
|
||
<template #append>
|
||
<el-button :loading="loading.search" @click="runSearch">
|
||
<el-icon><Search /></el-icon> 检索
|
||
</el-button>
|
||
</template>
|
||
</el-input>
|
||
</el-form-item>
|
||
<el-form-item label="高级">
|
||
<el-row :gutter="16" style="width:100%;">
|
||
<el-col :span="12">
|
||
<span class="meta-tip">返回条数:</span>
|
||
<el-input-number v-model="searchForm.top_k" :min="1" :max="20" size="small" />
|
||
</el-col>
|
||
<el-col :span="12">
|
||
<span class="meta-tip">检索模式:</span>
|
||
<el-radio-group v-model="searchForm.mode" size="small">
|
||
<el-radio-button label="fulltext">全文</el-radio-button>
|
||
<el-radio-button label="vector" disabled>向量</el-radio-button>
|
||
<el-radio-button label="blend" disabled>混合</el-radio-button>
|
||
</el-radio-group>
|
||
</el-col>
|
||
</el-row>
|
||
</el-form-item>
|
||
</el-form>
|
||
</div>
|
||
|
||
<div v-if="searchResults.length > 0">
|
||
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:10px;">
|
||
<h3 style="margin:0; font-size:15px; color:var(--text-primary);">检索结果({{ searchResults.length }} 条)</h3>
|
||
<span class="meta-tip">耗时 {{ searchDurationMs }}ms · 排序按 BM25 相关度</span>
|
||
</div>
|
||
<div v-for="(r, idx) in searchResults" :key="r.chunk_id" class="result-card">
|
||
<div class="result-title">
|
||
<el-tag size="small" type="info" effect="plain">#{{ idx + 1 }}</el-tag>
|
||
{{ r.title || '(无标题)' }}
|
||
</div>
|
||
<div class="result-content">{{ r.content }}</div>
|
||
<div class="score-bar">
|
||
<span class="meta-tip">相关度</span>
|
||
<el-progress
|
||
:percentage="scorePercent(r.score)"
|
||
:stroke-width="5"
|
||
:show-text="false"
|
||
style="flex:1; max-width:180px;"
|
||
color="var(--accent)"
|
||
/>
|
||
<el-tag size="small" type="success">{{ r.score.toFixed(3) }}</el-tag>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-else-if="searchedOnce" class="glass empty-state">
|
||
<div class="icon-wrap">
|
||
<el-icon size="32"><Search /></el-icon>
|
||
</div>
|
||
<h3>没有找到相关内容</h3>
|
||
<p>换个关键词试试,V1 走 2-gram 全文检索,建议关键词 2-4 字效果最好</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<button v-if="view === 'libraries' && !currentLib" class="fab" @click="openCreateLib" title="新建知识库">
|
||
<el-icon size="20"><Plus /></el-icon>
|
||
</button>
|
||
|
||
<!-- ===================== 新建库对话框 ===================== -->
|
||
<el-dialog v-model="dialog.createLib" title="新建知识库" width="460px">
|
||
<el-form label-position="top">
|
||
<el-form-item label="库名" required>
|
||
<el-input v-model="createForm.name" placeholder="例如:中医诊疗资料 v1" />
|
||
</el-form-item>
|
||
<el-form-item label="描述">
|
||
<el-input v-model="createForm.description" type="textarea" :rows="2" placeholder="可选" />
|
||
</el-form-item>
|
||
<el-form-item label="来源">
|
||
<el-radio-group v-model="createForm.source">
|
||
<el-radio label="manual">手动建立</el-radio>
|
||
<el-radio label="maxkb_export">从 MaxKB 导出</el-radio>
|
||
</el-radio-group>
|
||
</el-form-item>
|
||
<el-alert type="info" :closable="false">
|
||
V1 默认不做向量化,检索走 MySQL FULLTEXT(ngram 2-gram 中文分词)。
|
||
</el-alert>
|
||
</el-form>
|
||
<template #footer>
|
||
<el-button @click="dialog.createLib = false">取消</el-button>
|
||
<el-button type="primary" :loading="loading.create" @click="submitCreateLib">创建</el-button>
|
||
</template>
|
||
</el-dialog>
|
||
|
||
<!-- ===================== 分段查看抽屉 ===================== -->
|
||
<el-drawer
|
||
v-model="chunksDrawer.visible"
|
||
:title="chunksDrawer.title"
|
||
direction="rtl"
|
||
size="55%"
|
||
:destroy-on-close="true"
|
||
>
|
||
<div v-loading="loading.chunks" style="padding:0 4px;">
|
||
<div v-for="chunk in chunks" :key="chunk.id" class="chunk-item">
|
||
<div class="chunk-head">
|
||
<span class="chunk-idx">{{ chunk.chunk_index + 1 }}</span>
|
||
<span class="chunk-title">{{ chunk.title || '(无标题)' }}</span>
|
||
<span style="flex:1;"></span>
|
||
<el-button size="small" plain @click="openEditChunk(chunk)">
|
||
<el-icon><Edit /></el-icon> 编辑
|
||
</el-button>
|
||
</div>
|
||
<div class="chunk-content">{{ chunk.content }}</div>
|
||
<div v-if="getRelatedQuestions(chunk.meta_json)" class="chunk-related">
|
||
<strong>关联问题:</strong>{{ getRelatedQuestions(chunk.meta_json) }}
|
||
</div>
|
||
</div>
|
||
<div v-if="!loading.chunks && chunks.length === 0" class="empty-state" style="padding:40px;">
|
||
<div class="icon-wrap" style="width:56px; height:56px;">
|
||
<el-icon size="24"><Document /></el-icon>
|
||
</div>
|
||
<h3 style="font-size:14px;">无分段</h3>
|
||
</div>
|
||
</div>
|
||
</el-drawer>
|
||
|
||
<!-- ===================== 分段编辑对话框 ===================== -->
|
||
<el-dialog
|
||
v-model="editChunkDialog.visible"
|
||
title="编辑分段"
|
||
width="640px"
|
||
:close-on-click-modal="false"
|
||
>
|
||
<el-form label-position="top">
|
||
<el-form-item label="标题">
|
||
<el-input v-model="editChunkForm.title" placeholder="可空(如来自 md 的标题)" />
|
||
</el-form-item>
|
||
<el-form-item label="内容" required>
|
||
<el-input
|
||
v-model="editChunkForm.content"
|
||
type="textarea"
|
||
:rows="10"
|
||
placeholder="分段正文(必填)"
|
||
/>
|
||
</el-form-item>
|
||
<el-form-item label="关联问题">
|
||
<div style="width:100%;">
|
||
<div v-for="(q, idx) in editChunkForm.relatedQuestions" :key="idx" style="display:flex; gap:8px; margin-bottom:8px;">
|
||
<el-input v-model="editChunkForm.relatedQuestions[idx]" placeholder="如:痰湿中阻怎么治" />
|
||
<el-button type="danger" plain circle @click="editChunkForm.relatedQuestions.splice(idx, 1)">
|
||
<el-icon><Delete /></el-icon>
|
||
</el-button>
|
||
</div>
|
||
<el-button size="small" plain @click="editChunkForm.relatedQuestions.push('')">
|
||
<el-icon><Plus /></el-icon> 添加关联问题
|
||
</el-button>
|
||
</div>
|
||
</el-form-item>
|
||
<el-alert v-if="editChunkForm.content.trim() === ''" type="warning" :closable="false">
|
||
内容不能为空
|
||
</el-alert>
|
||
</el-form>
|
||
<template #footer>
|
||
<el-button @click="editChunkDialog.visible = false">取消</el-button>
|
||
<el-button type="primary" :loading="editChunkDialog.saving" @click="saveEditChunk">保存</el-button>
|
||
</template>
|
||
</el-dialog>
|
||
|
||
<!-- ===================== 管理员口令对话框 ===================== -->
|
||
<el-dialog
|
||
v-model="pwdDialog.visible"
|
||
title="管理员口令"
|
||
width="380px"
|
||
:close-on-click-modal="false"
|
||
:close-on-press-escape="false"
|
||
:show-close="false"
|
||
>
|
||
<div style="text-align:center; margin-bottom:18px;">
|
||
<div style="width:56px; height:56px; margin:0 auto 10px; background:var(--fill-quaternary); border-radius:50%; display:flex; align-items:center; justify-content:center;">
|
||
<el-icon size="28" color="var(--accent)"><Lock /></el-icon>
|
||
</div>
|
||
<p style="margin:0; color:var(--text-tertiary); font-size:13px;">请输入管理员口令以继续</p>
|
||
</div>
|
||
<el-input
|
||
v-model="pwdInput"
|
||
type="password"
|
||
placeholder="管理员口令"
|
||
size="large"
|
||
show-password
|
||
@keyup.enter="submitPwd"
|
||
/>
|
||
<div class="meta-tip" style="margin-top:8px;">
|
||
口令在 <code>config.yaml</code> 的 <code>kb.admin_password</code> 配置。
|
||
</div>
|
||
<template #footer>
|
||
<el-button @click="cancelPwd">取消</el-button>
|
||
<el-button type="primary" :loading="pwdDialog.loading" @click="submitPwd">确定</el-button>
|
||
</template>
|
||
</el-dialog>
|
||
</div>
|
||
|
||
<script>
|
||
const { createApp, reactive, ref, computed, watch } = Vue;
|
||
|
||
const app = createApp({
|
||
setup() {
|
||
// ============== 主题管理(核心) ==============
|
||
// 主题来源优先级:localStorage > prefers-color-scheme
|
||
// data-theme 写在 <html> 上,所有 CSS 变量据此切换
|
||
const THEME_KEY = 'kb_theme'; // 'light' | 'dark' | null(跟随系统)
|
||
|
||
// 当前生效主题(computed,便于模板里读 isDark 切换图标)
|
||
const manualTheme = ref(localStorage.getItem(THEME_KEY) || ''); // '' 表示跟随系统
|
||
const systemDark = ref(window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||
|
||
// 监听系统主题变化(仅当用户未手动指定时生效)
|
||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', e => {
|
||
systemDark.value = e.matches;
|
||
});
|
||
|
||
// 当前是否暗色(综合手动 + 系统)
|
||
const isDark = computed(() => {
|
||
if (manualTheme.value === 'dark') return true;
|
||
if (manualTheme.value === 'light') return false;
|
||
return systemDark.value;
|
||
});
|
||
|
||
// 把主题写到 <html data-theme>
|
||
function applyTheme() {
|
||
const html = document.documentElement;
|
||
if (isDark.value) {
|
||
html.setAttribute('data-theme', 'dark');
|
||
} else {
|
||
html.setAttribute('data-theme', 'light');
|
||
}
|
||
}
|
||
// 监听 isDark 变化自动应用(用 Vue 官方 watch,immediate 立即触发一次)
|
||
watch(isDark, applyTheme, { immediate: true });
|
||
|
||
// 点击切换按钮:dark → light → auto 循环
|
||
function toggleTheme() {
|
||
if (manualTheme.value === '') {
|
||
// 当前跟随系统 → 切换到反义
|
||
manualTheme.value = systemDark.value ? 'light' : 'dark';
|
||
} else if (manualTheme.value === 'light') {
|
||
manualTheme.value = 'dark';
|
||
} else {
|
||
manualTheme.value = '';
|
||
}
|
||
localStorage.setItem(THEME_KEY, manualTheme.value);
|
||
// 提示当前模式
|
||
const labels = { 'light': '已切换到亮色', 'dark': '已切换到暗色', '': '跟随系统主题' };
|
||
ElementPlus.ElMessage({ message: labels[manualTheme.value], type: 'info', duration: 1200 });
|
||
}
|
||
|
||
// ============== 通用 ==============
|
||
const API_BASE = '/api/v1/kb/admin';
|
||
const PWD_STORAGE_KEY = 'kb_admin_pwd';
|
||
const view = ref('libraries');
|
||
const isDragOver = ref(false);
|
||
const fileInput = ref(null);
|
||
const searchDurationMs = ref(0);
|
||
|
||
// ============== 口令管理 ==============
|
||
const pwdDialog = reactive({ visible: false, loading: false, _resolve: null, _reject: null });
|
||
const pwdInput = ref('');
|
||
const cachedPwd = ref(localStorage.getItem(PWD_STORAGE_KEY) || '');
|
||
|
||
function requirePwd() {
|
||
return new Promise((resolve, reject) => {
|
||
if (cachedPwd.value) { resolve(cachedPwd.value); return; }
|
||
pwdInput.value = '';
|
||
pwdDialog.visible = true;
|
||
pwdDialog._resolve = resolve;
|
||
pwdDialog._reject = reject;
|
||
});
|
||
}
|
||
async function submitPwd() {
|
||
if (!pwdInput.value.trim()) { ElementPlus.ElMessage.warning('请输入口令'); return; }
|
||
const pwd = pwdInput.value.trim();
|
||
pwdDialog.loading = true;
|
||
try {
|
||
const res = await fetch(API_BASE + '/libraries', {
|
||
headers: { 'Accept': 'application/json', 'X-KB-Admin-Password': pwd },
|
||
});
|
||
if (res.status === 401) { ElementPlus.ElMessage.error('口令错误'); return; }
|
||
cachedPwd.value = pwd;
|
||
localStorage.setItem(PWD_STORAGE_KEY, pwd);
|
||
pwdDialog.visible = false;
|
||
if (pwdDialog._resolve) pwdDialog._resolve(pwd);
|
||
} catch (e) {
|
||
ElementPlus.ElMessage.error('验证失败: ' + e.message);
|
||
} finally {
|
||
pwdDialog.loading = false;
|
||
}
|
||
}
|
||
function cancelPwd() {
|
||
pwdDialog.visible = false;
|
||
if (pwdDialog._reject) pwdDialog._reject(new Error('用户取消'));
|
||
}
|
||
function logout() {
|
||
cachedPwd.value = '';
|
||
localStorage.removeItem(PWD_STORAGE_KEY);
|
||
location.reload();
|
||
}
|
||
|
||
// ============== API 封装 ==============
|
||
async function api(path, options = {}) {
|
||
let pwd = cachedPwd.value || await requirePwd();
|
||
const opts = Object.assign({}, options);
|
||
opts.headers = Object.assign({}, options.headers || {}, {
|
||
'Accept': 'application/json',
|
||
'X-KB-Admin-Password': pwd,
|
||
});
|
||
let res = await fetch(API_BASE + path, opts);
|
||
if (res.status === 401) {
|
||
cachedPwd.value = '';
|
||
localStorage.removeItem(PWD_STORAGE_KEY);
|
||
pwd = await requirePwd();
|
||
opts.headers['X-KB-Admin-Password'] = pwd;
|
||
res = await fetch(API_BASE + path, opts);
|
||
}
|
||
const text = await res.text();
|
||
const json = text ? JSON.parse(text) : {};
|
||
if (json.code !== 200) throw new Error(json.message || '请求失败');
|
||
return json.data;
|
||
}
|
||
|
||
// ============== 数据 ==============
|
||
const libraries = ref([]);
|
||
const loading = reactive({ libs: false, docs: false, chunks: false, import: false, search: false, create: false });
|
||
const currentLib = ref(null);
|
||
const docs = ref([]);
|
||
const chunks = ref([]);
|
||
const chunksDrawer = reactive({ visible: false, title: '' });
|
||
|
||
async function loadLibraries() {
|
||
loading.libs = true;
|
||
try {
|
||
libraries.value = await api('/libraries');
|
||
} catch (e) {
|
||
ElementPlus.ElMessage.error('加载库列表失败: ' + e.message);
|
||
} finally {
|
||
loading.libs = false;
|
||
}
|
||
}
|
||
|
||
// ============== 库操作 ==============
|
||
const dialog = reactive({ createLib: false });
|
||
const createForm = reactive({ name: '', description: '', source: 'manual' });
|
||
|
||
function openCreateLib() {
|
||
createForm.name = ''; createForm.description = ''; createForm.source = 'manual';
|
||
dialog.createLib = true;
|
||
}
|
||
async function submitCreateLib() {
|
||
if (!createForm.name.trim()) { ElementPlus.ElMessage.warning('请填库名'); return; }
|
||
loading.create = true;
|
||
try {
|
||
await api('/libraries', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(createForm),
|
||
});
|
||
ElementPlus.ElMessage({ type: 'success', message: '知识库已创建', duration: 2000 });
|
||
dialog.createLib = false;
|
||
await loadLibraries();
|
||
} catch (e) {
|
||
ElementPlus.ElMessage.error('创建失败: ' + e.message);
|
||
} finally {
|
||
loading.create = false;
|
||
}
|
||
}
|
||
function onLibCommand(cmd, lib) {
|
||
if (cmd === 'search') { searchForm.library_id = lib.id; view.value = 'search'; }
|
||
else if (cmd === 'delete') { deleteLibrary(lib); }
|
||
}
|
||
async function deleteLibrary(lib) {
|
||
try {
|
||
await ElementPlus.ElMessageBox.confirm(
|
||
`确定删除「${lib.name}」?该库下 ${lib.doc_count} 篇文档和 ${lib.chunk_count} 个分段都会被软删除。`,
|
||
'危险操作',
|
||
{ type: 'warning', confirmButtonText: '确认删除', cancelButtonText: '取消', confirmButtonClass: 'el-button--danger' }
|
||
);
|
||
} catch { return; }
|
||
try {
|
||
await api('/libraries/' + lib.id, { method: 'DELETE' });
|
||
ElementPlus.ElMessage.success('已删除');
|
||
await loadLibraries();
|
||
} catch (e) {
|
||
ElementPlus.ElMessage.error('删除失败: ' + e.message);
|
||
}
|
||
}
|
||
|
||
// ============== 进入/退出库 ==============
|
||
async function openLibrary(lib) {
|
||
currentLib.value = lib;
|
||
await loadDocs(lib.id);
|
||
}
|
||
function exitLibrary() {
|
||
currentLib.value = null;
|
||
docs.value = [];
|
||
loadLibraries();
|
||
}
|
||
async function loadDocs(libId) {
|
||
loading.docs = true;
|
||
try { docs.value = await api('/libraries/' + libId + '/docs'); }
|
||
catch (e) { ElementPlus.ElMessage.error('加载文档失败: ' + e.message); }
|
||
finally { loading.docs = false; }
|
||
}
|
||
|
||
// ============== 文件上传 ==============
|
||
function triggerFileInput() {
|
||
if (!loading.import) fileInput.value.click();
|
||
}
|
||
function handleFileInput(e) {
|
||
const file = e.target.files[0];
|
||
if (file) doImport(file);
|
||
e.target.value = '';
|
||
}
|
||
function handleDrop(e) {
|
||
isDragOver.value = false;
|
||
const file = e.dataTransfer.files[0];
|
||
if (file) doImport(file);
|
||
}
|
||
async function doImport(file) {
|
||
if (!currentLib.value) { ElementPlus.ElMessage.warning('请先选择库'); return; }
|
||
const ext = '.' + file.name.split('.').pop().toLowerCase();
|
||
// 与后端 kb.ParseFileFromBytes 支持的格式保持一致
|
||
if (!['.xlsx', '.xls', '.csv', '.md', '.txt', '.pdf', '.docx', '.html', '.htm'].includes(ext)) {
|
||
ElementPlus.ElMessage.error('不支持的文件类型(支持 xlsx/xls/csv/md/txt/pdf/docx/html)');
|
||
return;
|
||
}
|
||
if (file.size > 50 * 1024 * 1024) {
|
||
ElementPlus.ElMessage.error('文件过大(最大 50MB)');
|
||
return;
|
||
}
|
||
loading.import = true;
|
||
try {
|
||
const fd = new FormData();
|
||
fd.append('library_id', String(currentLib.value.id));
|
||
fd.append('file', file);
|
||
let pwd = cachedPwd.value || await requirePwd();
|
||
let res = await fetch(API_BASE + '/docs/import', {
|
||
method: 'POST', body: fd,
|
||
headers: { 'X-KB-Admin-Password': pwd },
|
||
});
|
||
if (res.status === 401) {
|
||
cachedPwd.value = '';
|
||
localStorage.removeItem(PWD_STORAGE_KEY);
|
||
pwd = await requirePwd();
|
||
res = await fetch(API_BASE + '/docs/import', {
|
||
method: 'POST', body: fd,
|
||
headers: { 'X-KB-Admin-Password': pwd },
|
||
});
|
||
}
|
||
const text = await res.text();
|
||
const json = text ? JSON.parse(text) : {};
|
||
if (json.code !== 200) throw new Error(json.message || '上传失败');
|
||
ElementPlus.ElMessage({
|
||
type: 'success',
|
||
message: `导入成功!「${file.name}」共切分为 ${json.data.chunk_count} 个分段`,
|
||
duration: 3500,
|
||
});
|
||
await loadDocs(currentLib.value.id);
|
||
await loadLibraries();
|
||
} catch (e) {
|
||
ElementPlus.ElMessage.error('导入失败: ' + e.message);
|
||
} finally {
|
||
loading.import = false;
|
||
}
|
||
}
|
||
|
||
async function deleteDoc(doc) {
|
||
try {
|
||
await ElementPlus.ElMessageBox.confirm(
|
||
`确定删除文档「${doc.title}」?所有 ${doc.chunk_count} 个分段会一并软删除。`,
|
||
'危险操作',
|
||
{ type: 'warning', confirmButtonClass: 'el-button--danger' }
|
||
);
|
||
} catch { return; }
|
||
try {
|
||
await api('/docs/' + doc.id, { method: 'DELETE' });
|
||
ElementPlus.ElMessage.success('已删除');
|
||
await loadDocs(currentLib.value.id);
|
||
await loadLibraries();
|
||
} catch (e) {
|
||
ElementPlus.ElMessage.error('删除失败: ' + e.message);
|
||
}
|
||
}
|
||
|
||
// ============== 分段查看 ==============
|
||
async function openChunksDrawer(doc) {
|
||
chunksDrawer.title = `「${doc.title}」分段详情(共 ${doc.chunk_count} 个)`;
|
||
chunksDrawer.visible = true;
|
||
loading.chunks = true;
|
||
chunks.value = [];
|
||
try {
|
||
chunks.value = await api('/docs/' + doc.id + '/chunks');
|
||
} catch (e) {
|
||
ElementPlus.ElMessage.error('加载分段失败: ' + e.message);
|
||
} finally {
|
||
loading.chunks = false;
|
||
}
|
||
}
|
||
// 安全解析关联问题
|
||
function getRelatedQuestions(meta) {
|
||
if (!meta) return '';
|
||
try {
|
||
const obj = typeof meta === 'string' ? JSON.parse(meta) : meta;
|
||
return obj.related_questions ? obj.related_questions.join(';') : '';
|
||
} catch { return ''; }
|
||
}
|
||
|
||
// ============== 分段编辑 ==============
|
||
const editChunkDialog = reactive({ visible: false, saving: false, chunkId: null });
|
||
const editChunkForm = reactive({ title: '', content: '', relatedQuestions: [] });
|
||
|
||
function openEditChunk(chunk) {
|
||
editChunkDialog.chunkId = chunk.id;
|
||
editChunkForm.title = chunk.title || '';
|
||
editChunkForm.content = chunk.content || '';
|
||
// 解析关联问题(数组形式)
|
||
let qs = [];
|
||
if (chunk.meta_json) {
|
||
try {
|
||
const obj = typeof chunk.meta_json === 'string' ? JSON.parse(chunk.meta_json) : chunk.meta_json;
|
||
qs = obj.related_questions ? [...obj.related_questions] : [];
|
||
} catch { qs = []; }
|
||
}
|
||
editChunkForm.relatedQuestions = qs;
|
||
editChunkDialog.visible = true;
|
||
}
|
||
async function saveEditChunk() {
|
||
if (!editChunkForm.content.trim()) {
|
||
ElementPlus.ElMessage.warning('内容不能为空');
|
||
return;
|
||
}
|
||
// 过滤空关联问题
|
||
const cleanedQs = editChunkForm.relatedQuestions
|
||
.map(s => (s || '').trim())
|
||
.filter(s => s !== '');
|
||
editChunkDialog.saving = true;
|
||
try {
|
||
await api('/chunks/' + editChunkDialog.chunkId, {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
title: editChunkForm.title,
|
||
content: editChunkForm.content,
|
||
related_questions: cleanedQs,
|
||
}),
|
||
});
|
||
ElementPlus.ElMessage.success('已保存');
|
||
editChunkDialog.visible = false;
|
||
// 刷新当前分段列表(让用户看到改动)
|
||
if (chunksDrawer.visible && chunks.value.length > 0) {
|
||
const docId = chunks.value[0].doc_id;
|
||
chunks.value = await api('/docs/' + docId + '/chunks');
|
||
}
|
||
} catch (e) {
|
||
ElementPlus.ElMessage.error('保存失败: ' + e.message);
|
||
} finally {
|
||
editChunkDialog.saving = false;
|
||
}
|
||
}
|
||
|
||
// ============== 检索 ==============
|
||
const searchForm = reactive({ library_id: null, query: '', top_k: 5, mode: 'fulltext' });
|
||
const searchResults = ref([]);
|
||
const searchedOnce = ref(false);
|
||
async function runSearch() {
|
||
if (!searchForm.library_id) { ElementPlus.ElMessage.warning('请选择知识库'); return; }
|
||
if (!searchForm.query.trim()) { ElementPlus.ElMessage.warning('请输入检索词'); return; }
|
||
loading.search = true;
|
||
const t0 = performance.now();
|
||
try {
|
||
searchResults.value = await api('/search', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(searchForm),
|
||
});
|
||
searchDurationMs.value = Math.round(performance.now() - t0);
|
||
searchedOnce.value = true;
|
||
if (searchResults.value.length === 0) {
|
||
ElementPlus.ElMessage.info('没有命中相关内容');
|
||
}
|
||
} catch (e) {
|
||
ElementPlus.ElMessage.error('检索失败: ' + e.message);
|
||
} finally {
|
||
loading.search = false;
|
||
}
|
||
}
|
||
function scorePercent(score) {
|
||
const max = Math.max(...searchResults.value.map(r => r.score), score, 0.001);
|
||
return Math.round((score / max) * 100);
|
||
}
|
||
|
||
// ============== 工具 ==============
|
||
function formatTime(ts) {
|
||
if (!ts || ts <= 0) return '';
|
||
const d = new Date(ts * 1000);
|
||
const m = String(d.getMonth() + 1).padStart(2, '0');
|
||
const day = String(d.getDate()).padStart(2, '0');
|
||
return `${d.getFullYear()}-${m}-${day}`;
|
||
}
|
||
|
||
// ============== 初始化 ==============
|
||
(async function init() {
|
||
await loadLibraries();
|
||
})();
|
||
|
||
return {
|
||
// 主题
|
||
isDark, toggleTheme,
|
||
// 状态
|
||
view, libraries, loading, currentLib, docs, chunks, chunksDrawer,
|
||
dialog, createForm, searchForm, searchResults, searchedOnce,
|
||
isDragOver, fileInput, searchDurationMs,
|
||
pwdDialog, pwdInput,
|
||
editChunkDialog, editChunkForm,
|
||
// 方法
|
||
loadLibraries, openCreateLib, submitCreateLib, onLibCommand, deleteLibrary,
|
||
openLibrary, exitLibrary, loadDocs,
|
||
triggerFileInput, handleFileInput, handleDrop, deleteDoc,
|
||
openChunksDrawer, getRelatedQuestions,
|
||
openEditChunk, saveEditChunk,
|
||
runSearch, scorePercent, formatTime,
|
||
submitPwd, cancelPwd, logout,
|
||
};
|
||
},
|
||
});
|
||
|
||
app.use(ElementPlus, { locale: ElementPlusLocaleZhCn });
|
||
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
|
||
app.component(key, component);
|
||
}
|
||
app.mount('#app');
|
||
</script>
|
||
</body>
|
||
</html>
|