Files
xk-ai-agent/view/index.html
2026-08-14 21:50:48 +08:00

1384 lines
61 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>本地知识库 · 萧康云医 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>
<!-- 暗色模式 CSSElement 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 FULLTEXTngram 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 官方 watchimmediate 立即触发一次)
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>