feat: 添加 Web 管理界面(Vue 3 SPA 单文件)+ /admin 挂载

This commit is contained in:
2026-07-11 19:57:30 +08:00
parent e71018a9a3
commit 10c5d3d347
3 changed files with 167 additions and 0 deletions
+7
View File
@@ -61,8 +61,15 @@ async def lifespan(app: FastAPI):
yield yield
from fastapi.staticfiles import StaticFiles
app = FastAPI(title="md-vector-db", version="0.1.0", lifespan=lifespan) app = FastAPI(title="md-vector-db", version="0.1.0", lifespan=lifespan)
# 挂载 Web 管理界面
web_dir = Path(__file__).parent.parent / "web"
if web_dir.exists():
app.mount("/admin", StaticFiles(directory=str(web_dir), html=True), name="admin")
# CORS 中间件 # CORS 中间件
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
+147
View File
@@ -0,0 +1,147 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>md-vector-db 管理面板</title>
<script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
<style>
:root { --bg: #f8f9fa; --card-bg: #fff; --text: #212529; --muted: #6c757d; --border: #dee2e6; --primary: #0d6efd; --success: #198754; --danger: #dc3545; }
* { margin:0; padding:0; box-sizing:border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: var(--bg); color: var(--text); line-height: 1.6; }
.container { max-width: 960px; margin: 0 auto; padding: 24px 16px; }
h1 { font-size: 1.5rem; margin-bottom: 24px; }
.card { background: var(--card-bg); border: 1px solid var(--border); border-radius: 8px; padding: 20px; margin-bottom: 16px; }
.card h2 { font-size: 1.1rem; margin-bottom: 12px; }
.form-group { margin-bottom: 12px; }
label { display: block; font-size: .875rem; color: var(--muted); margin-bottom: 4px; }
input, textarea { width: 100%; padding: 8px 12px; border: 1px solid var(--border); border-radius: 4px; font-size: .9rem; font-family: inherit; }
textarea { min-height: 100px; resize: vertical; }
.btn { display: inline-block; padding: 8px 16px; border: none; border-radius: 4px; font-size: .875rem; cursor: pointer; }
.btn-primary { background: var(--primary); color: #fff; }
.btn-sm { padding: 4px 10px; font-size: .8rem; }
.result-item { border: 1px solid var(--border); border-radius: 6px; padding: 12px; margin-bottom: 8px; }
.result-item .score { font-weight: 600; color: var(--primary); }
.result-item .source { font-size: .8rem; color: var(--muted); }
.result-item .section { font-size: .85rem; color: var(--success); margin-bottom: 4px; }
.badge { display: inline-block; padding: 2px 8px; border-radius: 12px; font-size: .75rem; font-weight: 600; }
.badge-ok { background: #d1e7dd; color: #0f5132; }
.badge-err { background: #f8d7da; color: #842029; }
.tabs { display: flex; gap: 4px; margin-bottom: 16px; }
.tab { padding: 8px 16px; border: 1px solid var(--border); border-radius: 6px 6px 0 0; background: var(--bg); cursor: pointer; font-size: .875rem; }
.tab.active { background: var(--card-bg); border-bottom-color: var(--card-bg); font-weight: 600; }
.toast { position: fixed; top: 16px; right: 16px; padding: 12px 20px; border-radius: 6px; color: #fff; font-size: .875rem; z-index: 999; }
.toast-success { background: var(--success); }
.toast-error { background: var(--danger); }
table { width: 100%; border-collapse: collapse; }
th { text-align: left; padding: 8px; border-bottom: 2px solid var(--border); }
td { padding: 8px; border-bottom: 1px solid var(--border); }
</style>
</head>
<body>
<div id="app">
<div class="container">
<h1>📚 md-vector-db 管理面板</h1>
<div class="tabs">
<div :class="['tab', { active: activeTab === 'search' }]" @click="activeTab = 'search'">🔍 搜索</div>
<div :class="['tab', { active: activeTab === 'ingest' }]" @click="activeTab = 'ingest'">📥 入库</div>
<div :class="['tab', { active: activeTab === 'collections' }]" @click="activeTab = 'collections'">📂 集合</div>
</div>
<div v-if="toast" :class="['toast', 'toast-' + toast.type]">{{ toast.message }}</div>
<!-- 搜索 -->
<div v-if="activeTab === 'search'" class="card">
<h2>语义检索</h2>
<div class="form-group"><label>搜索查询</label><input v-model="searchQuery" @keyup.enter="doSearch" placeholder="输入关键词或自然语言..."></div>
<div style="display:flex;gap:12px">
<div class="form-group" style="flex:1"><label>返回条数</label><input v-model.number="topK" type="number" min="1" max="100"></div>
<div class="form-group" style="flex:1"><label>集合名</label><input v-model="collectionName" placeholder="default"></div>
</div>
<button class="btn btn-primary" @click="doSearch" :disabled="searching">{{ searching ? '搜索中...' : '搜索' }}</button>
<div v-if="searchResults.length" style="margin-top:16px">
<h3>{{ searchResults.length }} 条结果</h3>
<div v-for="(r,i) in searchResults" :key="i" class="result-item">
<div class="score">相似度: {{ (r.score || 0).toFixed(4) }}</div>
<div class="source">📄 {{ r.source_file }}</div>
<div v-if="r.section_title" class="section">📑 {{ r.section_title }}</div>
<pre style="white-space:pre-wrap;font-size:.85rem;margin-top:8px">{{ (r.content||'').substring(0,300) }}{{ (r.content||'').length > 300 ? '...' : '' }}</pre>
</div>
</div>
</div>
<!-- 入库 -->
<div v-if="activeTab === 'ingest'" class="card">
<h2>入库文档</h2>
<div class="form-group"><label>内容</label><textarea v-model="ingestContent" placeholder="粘贴 Markdown/文本..."></textarea></div>
<div class="form-group"><label>文件名</label><input v-model="ingestFileName" placeholder="document.md"></div>
<div class="form-group"><label>目标集合</label><input v-model="ingestCollection" placeholder="default"></div>
<button class="btn btn-primary" @click="doIngest" :disabled="ingesting">{{ ingesting ? '入库中...' : '入库' }}</button>
<p v-if="ingestResult" style="margin-top:12px">{{ ingestResult }}</p>
</div>
<!-- 集合 -->
<div v-if="activeTab === 'collections'" class="card">
<h2>集合列表</h2>
<button class="btn btn-primary btn-sm" @click="loadCollections" :disabled="loadingColl">{{ loadingColl ? '加载中...' : '刷新' }}</button>
<table v-if="collections.length" style="margin-top:12px">
<thead><tr><th>名称</th><th style="text-align:right">Chunks</th></tr></thead>
<tbody><tr v-for="c in collections" :key="c.name"><td>{{ c.name }}</td><td style="text-align:right">{{ c.count }}</td></tr></tbody>
</table>
</div>
<!-- 健康状态 -->
<div class="card" v-if="health">
<h2>服务状态 <span :class="['badge', health.status==='ok' ? 'badge-ok' : 'badge-err']">{{ health.status }}</span></h2>
<pre style="font-size:.8rem">{{ JSON.stringify(health.checks, null, 2) }}</pre>
</div>
</div>
</div>
<script>
const { createApp } = Vue
createApp({
data() {
return {
activeTab: 'search', searchQuery: '', topK: 10, collectionName: '',
searchResults: [], searching: false,
ingestContent: '', ingestFileName: '', ingestCollection: '', ingesting: false, ingestResult: null,
collections: [], loadingColl: false, health: null, toast: null, apiBase: window.location.origin,
}
},
mounted() { this.checkHealth() },
methods: {
async api(method, path, body) {
const headers = { 'Content-Type': 'application/json' }
const res = await fetch(`${this.apiBase}${path}`, { method, headers, body: body ? JSON.stringify(body) : undefined })
if (!res.ok) { const err = await res.text(); throw new Error(`HTTP ${res.status}: ${err}`) }
return res.json()
},
showToast(msg, type='success') { this.toast = { message: msg, type }; setTimeout(() => { this.toast = null }, 3000) },
async checkHealth() { try { this.health = await this.api('GET', '/api/v1/health') } catch(e) { this.health = { status: 'error', checks: {} } } },
async doSearch() {
if (!this.searchQuery.trim()) return; this.searching = true; this.searchResults = []
try {
const body = { query: this.searchQuery, top_k: this.topK }; if (this.collectionName) body.collection = this.collectionName
const data = await this.api('POST', '/api/v1/search', body); this.searchResults = data.results || []
} catch(e) { this.showToast(`搜索失败: ${e.message}`, 'error') } finally { this.searching = false }
},
async doIngest() {
if (!this.ingestContent.trim()) return; this.ingesting = true; this.ingestResult = null
try {
const body = { content: this.ingestContent, file_name: this.ingestFileName || 'untitled.md' }; if (this.ingestCollection) body.collection = this.ingestCollection
const data = await this.api('POST', '/api/v1/ingest', body)
this.ingestResult = `✅ 入库成功: ${data.chunks} chunks → ${data.collection}`; this.showToast(this.ingestResult)
} catch(e) { this.ingestResult = `❌ 入库失败: ${e.message}`; this.showToast(this.ingestResult, 'error') } finally { this.ingesting = false }
},
async loadCollections() {
this.loadingColl = true
try { const data = await this.api('GET', '/api/v1/collections'); this.collections = data.collections || [] }
catch(e) { this.showToast(`加载失败: ${e.message}`, 'error') } finally { this.loadingColl = false }
},
}
}).mount('#app')
</script>
</body>
</html>
+13
View File
@@ -138,3 +138,16 @@ class TestDeleteEndpoint:
results = search_resp.json()["results"] results = search_resp.json()["results"]
sources = [r["source_file"] for r in results] sources = [r["source_file"] for r in results]
assert "delete-test.md" not in sources assert "delete-test.md" not in sources
def test_admin_ui_served(client):
"""管理界面可访问."""
response = client.get("/admin")
assert response.status_code == 200
def test_health_skips_rate_limit(client):
"""健康检查多次访问不触发速率限制."""
for _ in range(5):
resp = client.get("/api/v1/health")
assert resp.status_code == 200