Files

1384 lines
61 KiB
HTML
Raw Permalink Normal View History

2026-08-14 21:50:48 +08:00
<!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>