feat: 完成全量功能开发,包括前端管理后台与后端服务优化

此提交实现了完整的知识库管理系统:
1. 新增Vue3 + Antd Vue前端管理后台,包含登录、文档管理、检索、类目设置等完整页面
2. 重构后端LLM调用抽象层,支持Ollama与OpenAI兼容服务动态切换
3. 调整默认嵌入模型配置为本地bge-m3模式
4. 优化入库任务去重逻辑与缓存清理机制
5. 完善Docker镜像构建与docker-compose部署配置
6. 修复多项测试用例与兼容性问题
7. 新增运行时配置API,支持动态调整系统参数
This commit is contained in:
2026-07-31 12:05:25 +08:00
parent fdb664e546
commit 2ab8b56a01
52 changed files with 7030 additions and 171 deletions
+41
View File
@@ -0,0 +1,41 @@
<script setup>
import { RouterView } from 'vue-router'
import { ConfigProvider, theme as antdTheme } from 'ant-design-vue'
const themeConfig = {
algorithm: antdTheme.defaultAlgorithm,
token: {
colorPrimary: '#1677ff',
colorInfo: '#1677ff',
colorSuccess: '#52c41a',
colorWarning: '#faad14',
colorError: '#ff4d4f',
borderRadius: 6,
fontSize: 14,
fontFamily:
"-apple-system, BlinkMacSystemFont, 'PingFang SC', 'Microsoft YaHei', 'Segoe UI', Roboto, Helvetica, Arial, sans-serif"
},
components: {
Layout: {
headerBg: '#ffffff',
headerPadding: '0 16px',
headerHeight: 52,
siderBg: '#001529'
},
Menu: {
darkItemBg: '#001529',
darkSubMenuItemBg: '#000c17'
}
}
}
</script>
<template>
<ConfigProvider :theme="themeConfig" :locale="undefined">
<RouterView />
</ConfigProvider>
</template>
<style scoped>
/* 全局根容器无额外样式,由各布局/页面自行处理 */
</style>
+19
View File
@@ -0,0 +1,19 @@
import http from './client'
/**
* 用户名密码登录
* @param {string} username
* @param {string} password
* @returns {Promise<{access_token:string, expires_in:number, user:{username:string, role:string, created_at:string}}>}
*/
export function login(username, password) {
return http.post('/api/v1/auth/login', { username, password })
}
/**
* 获取当前登录用户信息
* @returns {Promise<{username:string, role:string, created_at:string}>}
*/
export function me() {
return http.get('/api/v1/auth/me')
}
+108
View File
@@ -0,0 +1,108 @@
import axios from 'axios'
import { message } from 'ant-design-vue'
const TOKEN_STORAGE_KEY = 'qmd_token'
/** 认证相关错误码:触发清 token + 跳登录 */
const AUTH_ERROR_CODES = new Set([1003, 1005])
const httpClient = axios.create({
// 不设 baseURL,使用相对路径,由 vite proxy / nginx 转发
timeout: 60000,
headers: {
'Content-Type': 'application/json'
}
})
// 请求拦截器:注入 Bearer token
httpClient.interceptors.request.use((config) => {
const token = localStorage.getItem(TOKEN_STORAGE_KEY) || ''
if (token) {
config.headers = config.headers || {}
config.headers.Authorization = `Bearer ${token}`
}
return config
})
let unauthorizedHandler = null
/**
* 注册 401 / 认证错误处理回调(由 router/store 注入,避免循环依赖)
* @param {() => void} handler
*/
export function setUnauthorizedHandler(handler) {
unauthorizedHandler = handler
}
function triggerUnauthorized() {
localStorage.removeItem(TOKEN_STORAGE_KEY)
if (typeof unauthorizedHandler === 'function') {
unauthorizedHandler()
}
}
// 响应拦截器:统一处理 code !== 0 与 401
httpClient.interceptors.response.use(
(response) => {
const body = response.data
if (body && typeof body === 'object' && 'code' in body) {
if (body.code === 0) {
return body.data
}
// 业务错误
if (AUTH_ERROR_CODES.has(body.code)) {
triggerUnauthorized()
}
const err = new Error(body.message || '请求失败')
err.code = body.code
err.message = body.message || '请求失败'
return Promise.reject(err)
}
// 非标准结构,原样返回
return body
},
(error) => {
const status = error?.response?.status
if (status === 401) {
triggerUnauthorized()
const err = new Error('未认证或登录已过期,请重新登录')
err.code = 1003
return Promise.reject(err)
}
// 后端返回了 body 但 HTTP 错误
const body = error?.response?.data
if (body && typeof body === 'object' && 'code' in body) {
if (AUTH_ERROR_CODES.has(body.code)) {
triggerUnauthorized()
}
const err = new Error(body.message || `请求失败 (HTTP ${status ?? '?'})`)
err.code = body.code
return Promise.reject(err)
}
const err = new Error(error?.message || '网络请求失败')
err.code = `HTTP_${status ?? 'NETWORK'}`
return Promise.reject(err)
}
)
/**
* 统一发起请求,捕获异常并弹出 antd message
* @param {() => Promise<any>} fn
* @param {{ silent?: boolean, errorText?: string }} [options]
* @returns {Promise<any>}
*/
export async function callApi(fn, options = {}) {
const { silent = false, errorText = '操作失败' } = options
try {
return await fn()
} catch (err) {
const text = err?.message || errorText
if (!silent) {
message.error(text)
}
throw err
}
}
export { TOKEN_STORAGE_KEY }
export default httpClient
+62
View File
@@ -0,0 +1,62 @@
import http from './client'
/**
* 分页列出文档
* @param {number} [limit=20]
* @param {string|null} [offset=null]
* @returns {Promise<{items: Array, next_offset: string|null}>}
*/
export function list(limit = 20, offset = null) {
const params = { limit }
if (offset !== null && offset !== undefined && offset !== '') {
params.offset = offset
}
return http.get('/api/v1/documents', { params })
}
/**
* 获取文档详情
* @param {string} docId
* @returns {Promise<object>}
*/
export function detail(docId) {
return http.get(`/api/v1/documents/${encodeURIComponent(docId)}`)
}
/**
* 删除文档(幂等)
* @param {string} docId
* @returns {Promise<{doc_id:string, deleted: object, deleted_total:number}>}
*/
export function remove(docId) {
return http.delete(`/api/v1/documents/${encodeURIComponent(docId)}`)
}
/**
* JSON 文本入库(异步)
* @param {{title:string, source?:string, text:string, metadata?:object}} payload
* @returns {Promise<{task_id:string, status:string}>}
*/
export function ingest(payload) {
return http.post('/api/v1/documents', payload)
}
/**
* multipart 文件上传入库
* @param {FormData} formData
* @returns {Promise<{task_id:string, status:string, saved_path?:string}>}
*/
export function upload(formData) {
return http.post('/api/v1/documents/upload', formData, {
headers: { 'Content-Type': 'multipart/form-data' }
})
}
/**
* 查询入库任务状态
* @param {string} taskId
* @returns {Promise<object>}
*/
export function taskStatus(taskId) {
return http.get(`/api/v1/documents/tasks/${encodeURIComponent(taskId)}`)
}
+17
View File
@@ -0,0 +1,17 @@
import http from './client'
/**
* 获取知识分类类目集
* @returns {Promise<{categories: Array<{name:string, description:string}>, count: number}>}
*/
export function categories() {
return http.get('/api/v1/knowledge/categories')
}
/**
* 获取知识库统计(四层点数 + 类目分布 + uncategorized 数)
* @returns {Promise<{collections: {doc_l1:number, doc_l2:number, doc_l3:number, chunks:number}, documents_total:number, uncategorized_count:number, categories: Record<string, number>}>}
*/
export function stats() {
return http.get('/api/v1/knowledge/stats')
}
+10
View File
@@ -0,0 +1,10 @@
import http from './client'
/**
* 分层检索
* @param {{query:string, top_k?:number, summarize?:boolean}} payload
* @returns {Promise<object>}
*/
export function search(payload) {
return http.post('/api/v1/search', payload)
}
+34
View File
@@ -0,0 +1,34 @@
import http from './client'
/**
* 获取当前 RuntimeSettings
* @returns {Promise<object>}
*/
export function get() {
return http.get('/api/v1/settings')
}
/**
* 部分更新 RuntimeSettings(仅 admin
* @param {{models?:object, parsers?:object, dedup?:object}} payload
* @returns {Promise<object>}
*/
export function update(payload) {
return http.put('/api/v1/settings', payload)
}
/**
* 获取可选项 schema
* @returns {Promise<{llm_providers:string[], pdf_plugins:string[], docx_plugins:string[], ocr_plugins:string[], dedup_strategies:string[]}>}
*/
export function schema() {
return http.get('/api/v1/settings/schema')
}
/**
* 重置为默认值(仅 admin
* @returns {Promise<object>}
*/
export function reset() {
return http.post('/api/v1/settings/reset')
}
@@ -0,0 +1,93 @@
import { onBeforeUnmount, reactive, ref } from 'vue'
import { taskStatus } from '@/api/documents'
import {
INGEST_POLL_INTERVAL_MS,
INGEST_POLL_MAX_ATTEMPTS,
INGEST_TERMINAL_STATUS
} from '@/constants/ingest'
/**
* 入库任务轮询 composable
*
* 调用 startPolling(taskId) 启动轮询;到达 done/failed/超时/出错 时自动停止并
* 写入 state。组件卸载时自动清理定时器。
*
* @returns {{
* state: { taskId: string|null, task: object|null, status: string|null, error: object|null, isTimeout: boolean, isPolling: boolean },
* startPolling: (taskId: string) => void,
* stopPolling: () => void
* }}
*/
export function useIngestPolling() {
const state = reactive({
taskId: null,
task: null,
status: null,
error: null,
isTimeout: false,
isPolling: false
})
const timerRef = ref(null)
let attempts = 0
function stopPolling() {
if (timerRef.value !== null) {
clearInterval(timerRef.value)
timerRef.value = null
}
state.isPolling = false
}
function reset() {
stopPolling()
state.taskId = null
state.task = null
state.status = null
state.error = null
state.isTimeout = false
state.isPolling = false
attempts = 0
}
/**
* 启动轮询
* @param {string} taskId
*/
function startPolling(taskId) {
reset()
state.taskId = taskId
state.isPolling = true
attempts = 0
timerRef.value = setInterval(async () => {
attempts += 1
if (attempts > INGEST_POLL_MAX_ATTEMPTS) {
stopPolling()
state.isTimeout = true
return
}
try {
const task = await taskStatus(taskId)
state.task = task
state.status = task?.status || null
if (INGEST_TERMINAL_STATUS.includes(task?.status)) {
stopPolling()
}
} catch (err) {
state.error = err
stopPolling()
}
}, INGEST_POLL_INTERVAL_MS)
}
onBeforeUnmount(() => {
stopPolling()
})
return {
state,
startPolling,
stopPolling
}
}
+32
View File
@@ -0,0 +1,32 @@
/**
* 入库任务状态映射:后端 status → 中文展示
*/
export const INGEST_STATUS_TEXT = Object.freeze({
pending: '排队中',
summarizing: '总结中',
classifying: '分类中',
embedding: '向量化中',
writing: '写入中',
done: '完成',
failed: '失败'
})
/** 轮询间隔(毫秒) */
export const INGEST_POLL_INTERVAL_MS = 2000
/** 轮询最大次数:150 次 × 2s = 5 分钟超时 */
export const INGEST_POLL_MAX_ATTEMPTS = 150
/** 入库状态徽标颜色映射(antd Badge / Tag 状态) */
export const INGEST_STATUS_COLOR = Object.freeze({
pending: 'default',
summarizing: 'processing',
classifying: 'processing',
embedding: 'processing',
writing: 'processing',
done: 'success',
failed: 'error'
})
/** 终态集合:到达这些状态后停止轮询 */
export const INGEST_TERMINAL_STATUS = Object.freeze(['done', 'failed'])
+232
View File
@@ -0,0 +1,232 @@
<script setup>
import { computed, h, ref } from 'vue'
import { useRoute, useRouter, RouterView } from 'vue-router'
import { storeToRefs } from 'pinia'
import { Modal } from 'ant-design-vue'
import {
DashboardOutlined,
FileTextOutlined,
UploadOutlined,
SearchOutlined,
AppstoreOutlined,
SettingOutlined,
MenuFoldOutlined,
MenuUnfoldOutlined,
LogoutOutlined,
DatabaseOutlined
} from '@ant-design/icons-vue'
import { useAuthStore } from '@/stores/useAuthStore'
const route = useRoute()
const router = useRouter()
const authStore = useAuthStore()
const { user, displayName } = storeToRefs(authStore)
const collapsed = ref(false)
const selectedKeys = computed(() => {
return [route.name ? String(route.name) : '']
})
const openKeys = ref(['main'])
const menuItems = [
{ key: 'overview', icon: () => h(DashboardOutlined), label: '概览' },
{ key: 'documents', icon: () => h(FileTextOutlined), label: '文档管理' },
{ key: 'ingest', icon: () => h(UploadOutlined), label: '文档入库' },
{ key: 'search', icon: () => h(SearchOutlined), label: '检索测试台' },
{ key: 'categories', icon: () => h(AppstoreOutlined), label: '类目列表' },
{ key: 'settings', icon: () => h(SettingOutlined), label: '设置' }
]
function handleMenuClick({ key }) {
if (key && key !== route.name) {
router.push({ name: key })
}
}
function handleLogout() {
Modal.confirm({
title: '确认登出',
content: '确定要退出登录吗?',
okText: '登出',
cancelText: '取消',
onOk() {
authStore.logout()
router.replace({ name: 'login' })
}
})
}
</script>
<template>
<a-layout class="main-layout">
<a-layout-sider
v-model:collapsed="collapsed"
collapsible
:trigger="null"
breakpoint="lg"
class="main-layout__sider"
>
<div class="main-layout__logo">
<span class="main-layout__logo-icon">
<DatabaseOutlined />
</span>
<span v-if="!collapsed" class="main-layout__logo-text">QMDSearch</span>
</div>
<a-menu
theme="dark"
mode="inline"
:selected-keys="selectedKeys"
:open-keys="openKeys"
:items="menuItems"
@click="handleMenuClick"
/>
</a-layout-sider>
<a-layout>
<a-layout-header class="main-layout__header">
<a-button
type="text"
class="main-layout__collapse"
@click="collapsed = !collapsed"
>
<MenuUnfoldOutlined v-if="collapsed" />
<MenuFoldOutlined v-else />
</a-button>
<div class="main-layout__title">知识库管理后台</div>
<div class="main-layout__user">
<span class="main-layout__user-name">{{ displayName }}</span>
<a-divider type="vertical" />
<a-button
type="text"
size="small"
class="main-layout__logout"
@click="handleLogout"
>
<template #icon><LogoutOutlined /></template>
登出
</a-button>
</div>
</a-layout-header>
<a-layout-content class="main-layout__content">
<RouterView />
</a-layout-content>
<a-layout-footer class="main-layout__footer">
QMDSearch Admin · 当前用户{{ user?.username || '-' }}
</a-layout-footer>
</a-layout>
</a-layout>
</template>
<style scoped>
.main-layout {
min-height: 100vh;
}
.main-layout__sider {
position: sticky;
top: 0;
height: 100vh;
overflow: auto;
box-shadow: 2px 0 8px rgba(0, 21, 41, 0.15);
}
.main-layout__logo {
height: 52px;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
padding: 0 16px;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
background: linear-gradient(135deg, #001529 0%, #002140 100%);
}
.main-layout__logo-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 6px;
background: linear-gradient(135deg, #1677ff 0%, #4096ff 100%);
color: #fff;
font-size: 16px;
box-shadow: 0 2px 6px rgba(22, 119, 255, 0.4);
}
.main-layout__logo-text {
color: #fff;
font-size: 17px;
font-weight: 700;
letter-spacing: 0.5px;
white-space: nowrap;
}
.main-layout__header {
display: flex;
align-items: center;
background: #fff;
padding: 0 20px;
height: 52px;
line-height: 52px;
box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08);
z-index: 10;
}
.main-layout__collapse {
flex: 0 0 auto;
font-size: 16px;
color: #4b5563;
}
.main-layout__collapse:hover {
color: #1677ff;
}
.main-layout__title {
flex: 1 1 auto;
margin-left: 12px;
font-size: 16px;
font-weight: 600;
color: #1f2937;
}
.main-layout__user {
flex: 0 0 auto;
display: flex;
align-items: center;
gap: 4px;
}
.main-layout__user-name {
color: #4b5563;
font-size: 13px;
}
.main-layout__logout {
color: #6b7280;
}
.main-layout__logout:hover {
color: #ff4d4f;
}
.main-layout__content {
margin: 16px;
padding: 0;
background: transparent;
overflow: auto;
}
.main-layout__footer {
text-align: center;
color: #6b7280;
font-size: 12px;
background: transparent;
padding: 12px 16px;
}
</style>
+30
View File
@@ -0,0 +1,30 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import Antd from 'ant-design-vue'
import 'ant-design-vue/dist/reset.css'
import App from './App.vue'
import router from './router'
import { setUnauthorizedHandler } from './api/client'
import { useAuthStore } from './stores/useAuthStore'
import './styles/main.css'
const app = createApp(App)
const pinia = createPinia()
app.use(pinia)
// 注册 401 处理:清 store + 跳 /login
const authStore = useAuthStore()
setUnauthorizedHandler(() => {
authStore.clearAuth()
if (router.currentRoute.value.name !== 'login') {
router.replace({
name: 'login',
query: { redirect: router.currentRoute.value.fullPath }
})
}
})
app.use(router)
app.use(Antd)
app.mount('#app')
+91
View File
@@ -0,0 +1,91 @@
import { createRouter, createWebHistory } from 'vue-router'
import { useAuthStore } from '@/stores/useAuthStore'
const routes = [
{
path: '/login',
name: 'login',
component: () => import('@/views/Login.vue'),
meta: { title: '登录', requiresAuth: false }
},
{
path: '/',
component: () => import('@/layouts/MainLayout.vue'),
redirect: '/overview',
meta: { requiresAuth: true },
children: [
{
path: 'overview',
name: 'overview',
component: () => import('@/views/Overview.vue'),
meta: { title: '概览', requiresAuth: true }
},
{
path: 'documents',
name: 'documents',
component: () => import('@/views/Documents.vue'),
meta: { title: '文档管理', requiresAuth: true }
},
{
path: 'ingest',
name: 'ingest',
component: () => import('@/views/Ingest.vue'),
meta: { title: '文档入库', requiresAuth: true }
},
{
path: 'search',
name: 'search',
component: () => import('@/views/Search.vue'),
meta: { title: '检索测试台', requiresAuth: true }
},
{
path: 'categories',
name: 'categories',
component: () => import('@/views/Categories.vue'),
meta: { title: '类目列表', requiresAuth: true }
},
{
path: 'settings',
name: 'settings',
component: () => import('@/views/Settings.vue'),
meta: { title: '设置', requiresAuth: true }
}
]
},
{
path: '/:pathMatch(.*)*',
name: 'not-found',
redirect: '/overview'
}
]
const router = createRouter({
history: createWebHistory('/admin/'),
routes,
scrollBehavior() {
return { top: 0 }
}
})
// 全局前置守卫:未登录跳 /login;已登录访问 /login 跳 /overview
router.beforeEach((to) => {
const authStore = useAuthStore()
const title = to.meta?.title
if (title) {
document.title = `${title} - QMDSearch 知识库后台`
} else {
document.title = 'QMDSearch 知识库后台'
}
if (to.meta?.requiresAuth && !authStore.isAuthenticated) {
return { name: 'login', query: { redirect: to.fullPath } }
}
if (to.name === 'login' && authStore.isAuthenticated) {
return { name: 'overview' }
}
return true
})
export default router
+66
View File
@@ -0,0 +1,66 @@
import { defineStore } from 'pinia'
const TOKEN_STORAGE_KEY = 'qmd_token'
const USER_STORAGE_KEY = 'qmd_user'
/**
* 从 localStorage 读取用户信息
* @returns {{username:string, role:string, created_at?:string} | null}
*/
function loadUserFromStorage() {
try {
const raw = localStorage.getItem(USER_STORAGE_KEY)
if (!raw) return null
const parsed = JSON.parse(raw)
if (parsed && typeof parsed === 'object' && parsed.username) {
return parsed
}
return null
} catch {
return null
}
}
export const useAuthStore = defineStore('auth', {
state: () => ({
token: localStorage.getItem(TOKEN_STORAGE_KEY) || '',
user: loadUserFromStorage()
}),
getters: {
isAuthenticated: (state) => Boolean(state.token),
isAdmin: (state) => state.user?.role === 'admin',
displayName: (state) => {
if (!state.user) return ''
return `${state.user.username} (${state.user.role})`
}
},
actions: {
/**
* 登录成功后保存 token + user
* @param {{access_token:string, user:object}} data
*/
setAuth(data) {
this.token = data.access_token
this.user = data.user
localStorage.setItem(TOKEN_STORAGE_KEY, data.access_token)
localStorage.setItem(USER_STORAGE_KEY, JSON.stringify(data.user))
},
/** 清除登录态(登出 / 401) */
clearAuth() {
this.token = ''
this.user = null
localStorage.removeItem(TOKEN_STORAGE_KEY)
localStorage.removeItem(USER_STORAGE_KEY)
},
/**
* 登出
*/
logout() {
this.clearAuth()
}
}
})
+79
View File
@@ -0,0 +1,79 @@
html,
body,
#app {
height: 100%;
margin: 0;
padding: 0;
}
#app {
font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Microsoft YaHei',
'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
color: #1f2937;
background: #f0f2f5;
}
/* 统一文本工具类 */
.text-muted {
color: #6b7280;
font-size: 12px;
}
.text-break {
word-break: break-word;
white-space: pre-wrap;
}
/* 页面通用 section 容器 */
.page-section {
background: #fff;
border-radius: 8px;
padding: 20px 24px;
box-shadow: 0 1px 2px rgba(0, 21, 41, 0.04);
}
/* flex-gap:横排卡片/标签 */
.flex-gap {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.toolbar {
margin: 12px 0;
}
/* 统一页面标题样式 */
.page-title {
font-size: 18px;
font-weight: 600;
margin: 0;
color: #1f2937;
}
/* 统一卡片标题样式 */
.section-subtitle {
font-size: 14px;
font-weight: 600;
color: #374151;
margin: 16px 0 12px;
}
/* 滚动条美化 */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.2);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(0, 0, 0, 0.35);
}
::-webkit-scrollbar-track {
background: transparent;
}
+38
View File
@@ -0,0 +1,38 @@
/**
* 截断文本,超过最大长度追加省略号
* @param {string} text
* @param {number} [maxLen=80]
* @returns {string}
*/
export function truncate(text, maxLen = 80) {
if (!text) return ''
const s = String(text)
return s.length > maxLen ? `${s.slice(0, maxLen)}` : s
}
/**
* 安全拼接类名
* @param {...(string | false | null | undefined)} args
* @returns {string}
*/
export function classnames(...args) {
return args.filter(Boolean).join(' ')
}
/**
* 防抖
* @param {Function} fn
* @param {number} [wait=300]
* @returns {Function}
*/
export function debounce(fn, wait = 300) {
let timer = null
return function debounced(...args) {
if (timer) {
clearTimeout(timer)
}
timer = setTimeout(() => {
fn.apply(this, args)
}, wait)
}
}
+70
View File
@@ -0,0 +1,70 @@
<script setup>
import { onMounted, ref } from 'vue'
import { message } from 'ant-design-vue'
import { categories as fetchCategories } from '@/api/knowledge'
const isLoading = ref(false)
const categoriesData = ref([])
const columns = [
{ title: '名称', dataIndex: 'name', key: 'name' },
{ title: '描述', dataIndex: 'description', key: 'description' }
]
async function loadCategories() {
isLoading.value = true
try {
const data = await fetchCategories()
categoriesData.value = data.categories || []
} catch (err) {
message.error(err?.message || '加载类目列表失败')
} finally {
isLoading.value = false
}
}
onMounted(() => {
loadCategories()
})
</script>
<template>
<div class="categories page-section">
<div class="categories__header">
<h2 class="categories__title">类目列表</h2>
<a-button :loading="isLoading" @click="loadCategories">刷新</a-button>
</div>
<a-table
:columns="columns"
:data-source="categoriesData"
:pagination="false"
:loading="isLoading"
row-key="name"
size="small"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'name'">
<a-tag color="blue">{{ record.name }}</a-tag>
</template>
<template v-else-if="column.key === 'description'">
<span>{{ record.description || '-' }}</span>
</template>
</template>
</a-table>
</div>
</template>
<style scoped>
.categories__header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.categories__title {
font-size: 16px;
margin: 0;
}
</style>
+299
View File
@@ -0,0 +1,299 @@
<script setup>
import { onMounted, reactive, ref } from 'vue'
import { message, Modal } from 'ant-design-vue'
import {
list as fetchDocuments,
detail as fetchDocumentDetail,
remove as deleteDocument
} from '@/api/documents'
import { truncate } from '@/utils/format'
const isLoading = ref(false)
const isLoadingDetail = ref(false)
const isDeleting = ref(false)
const documents = ref([])
const nextOffset = ref(null)
const detailVisible = ref(false)
const detailData = ref(null)
const statusText = reactive({
text: ''
})
function updateStatusText() {
statusText.text =
nextOffset.value === null ? '已加载全部' : '还有更多,可继续加载'
}
async function loadDocuments(reset = false) {
isLoading.value = true
try {
const offset = reset ? null : nextOffset.value
const data = await fetchDocuments(20, offset)
if (reset) {
documents.value = data.items || []
} else {
documents.value = documents.value.concat(data.items || [])
}
nextOffset.value = data.next_offset ?? null
updateStatusText()
} catch (err) {
message.error(err?.message || '加载文档列表失败')
} finally {
isLoading.value = false
}
}
async function handleLoadMore() {
await loadDocuments(false)
}
function handleViewDetail(docId) {
detailVisible.value = true
detailData.value = null
loadDocDetail(docId)
}
async function loadDocDetail(docId) {
isLoadingDetail.value = true
try {
detailData.value = await fetchDocumentDetail(docId)
} catch (err) {
message.error(err?.message || '加载文档详情失败')
} finally {
isLoadingDetail.value = false
}
}
function handleDelete(doc) {
const title = doc.title || doc.doc_id
Modal.confirm({
title: '确认删除',
content: `确定删除文档「${title}」(${doc.doc_id}) 吗?该操作将删除四层集合中的全部数据,不可恢复。`,
okText: '删除',
okType: 'danger',
cancelText: '取消',
async onOk() {
isDeleting.value = true
try {
const data = await deleteDocument(doc.doc_id)
message.success(`已删除 ${data.deleted_total ?? 0} 条数据`)
detailVisible.value = false
await loadDocuments(true)
} catch (err) {
message.error(err?.message || '删除文档失败')
} finally {
isDeleting.value = false
}
}
})
}
function handleCloseDetail() {
detailVisible.value = false
detailData.value = null
}
const columns = [
{ title: '标题', dataIndex: 'title', key: 'title', ellipsis: true },
{ title: '类目', dataIndex: 'category', key: 'category', width: 140 },
{ title: '标签', dataIndex: 'tags', key: 'tags', width: 220 },
{ title: 'L1 摘要', dataIndex: 'summary', key: 'summary', ellipsis: true },
{ title: '操作', key: 'action', width: 160, fixed: 'right' }
]
function getDocTitle(record) {
return record?.title || '(无标题)'
}
onMounted(() => {
loadDocuments(true)
})
</script>
<template>
<div class="documents page-section">
<div class="documents__header">
<h2 class="documents__title">文档管理</h2>
</div>
<a-table
:columns="columns"
:data-source="documents"
:pagination="false"
:loading="isLoading"
row-key="doc_id"
size="small"
:scroll="{ x: 900 }"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'title'">
<span :title="record.title">{{ getDocTitle(record) }}</span>
</template>
<template v-else-if="column.key === 'category'">
<a-tag v-if="record.category" color="blue">{{ record.category }}</a-tag>
<span v-else class="text-muted">-</span>
</template>
<template v-else-if="column.key === 'tags'">
<template v-if="record.tags && record.tags.length">
<a-tag v-for="tag in record.tags" :key="tag">{{ tag }}</a-tag>
</template>
<span v-else class="text-muted">-</span>
</template>
<template v-else-if="column.key === 'summary'">
<span :title="record.summary">{{ truncate(record.summary, 80) }}</span>
</template>
<template v-else-if="column.key === 'action'">
<a-space>
<a-button type="link" size="small" @click="handleViewDetail(record.doc_id)">
详情
</a-button>
<a-button
type="link"
size="small"
danger
:loading="isDeleting"
@click="handleDelete(record)"
>
删除
</a-button>
</a-space>
</template>
</template>
</a-table>
<div class="toolbar documents__toolbar">
<a-button
:loading="isLoading"
:disabled="nextOffset === null"
@click="handleLoadMore"
>
加载更多
</a-button>
<span class="text-muted" style="margin-left: 12px">{{ statusText.text }}</span>
</div>
<a-drawer
:open="detailVisible"
title="文档详情"
placement="right"
width="640"
:destroy-on-close="true"
@close="handleCloseDetail"
>
<a-spin :spinning="isLoadingDetail">
<div v-if="detailData">
<a-descriptions :column="1" size="small" bordered>
<a-descriptions-item label="doc_id">
{{ detailData.l1?.doc_id || '-' }}
</a-descriptions-item>
<a-descriptions-item label="标题">
{{ detailData.l1?.title || '-' }}
</a-descriptions-item>
<a-descriptions-item label="类目">
{{ detailData.l1?.category || '-' }}
</a-descriptions-item>
<a-descriptions-item label="标签">
<template v-if="detailData.l1?.tags && detailData.l1.tags.length">
<a-tag v-for="tag in detailData.l1.tags" :key="tag">{{ tag }}</a-tag>
</template>
<span v-else class="text-muted">-</span>
</a-descriptions-item>
<a-descriptions-item label="chunks_count">
{{ detailData.chunks_count ?? 0 }}
</a-descriptions-item>
</a-descriptions>
<h3 class="documents__section-title">L1 全文</h3>
<pre class="documents__pre">{{ detailData.l1?.text || '' }}</pre>
<h3 class="documents__section-title">
L2 节点{{ (detailData.l2_nodes || []).length }}
</h3>
<div v-if="(detailData.l2_nodes || []).length === 0" class="text-muted"></div>
<div
v-for="(node, idx) in detailData.l2_nodes || []"
:key="`l2-${idx}`"
class="documents__node"
>
<div class="documents__node-path">{{ node.section_path || '' }}</div>
<div class="documents__node-text text-break">{{ node.text || '' }}</div>
</div>
<h3 class="documents__section-title">
L3 节点{{ (detailData.l3_nodes || []).length }}
</h3>
<div v-if="(detailData.l3_nodes || []).length === 0" class="text-muted"></div>
<div
v-for="(node, idx) in detailData.l3_nodes || []"
:key="`l3-${idx}`"
class="documents__node"
>
<div class="documents__node-path">{{ node.section_path || '' }}</div>
<div class="documents__node-text text-break">{{ node.text || '' }}</div>
</div>
</div>
<div v-else-if="!isLoadingDetail" class="text-muted">暂无数据</div>
</a-spin>
</a-drawer>
</div>
</template>
<style scoped>
.documents__header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.documents__title {
font-size: 16px;
margin: 0;
}
.documents__toolbar {
display: flex;
align-items: center;
}
.documents__section-title {
font-size: 14px;
margin: 16px 0 8px;
color: #374151;
}
.documents__pre {
background: #f9fafb;
border: 1px solid #e5e7eb;
border-radius: 4px;
padding: 8px;
white-space: pre-wrap;
word-break: break-word;
font-size: 12px;
max-height: 260px;
overflow: auto;
margin: 0;
}
.documents__node {
border-left: 3px solid #93c5fd;
padding: 6px 10px;
margin-bottom: 6px;
background: #f8fafc;
border-radius: 0 4px 4px 0;
}
.documents__node-path {
font-size: 12px;
color: #6b7280;
margin-bottom: 4px;
}
.documents__node-text {
font-size: 13px;
color: #1f2937;
}
</style>
+352
View File
@@ -0,0 +1,352 @@
<script setup>
import { computed, reactive, ref } from 'vue'
import { message } from 'ant-design-vue'
import { ingest as ingestDocument, upload as uploadDocument } from '@/api/documents'
import { useIngestPolling } from '@/composables/useIngestPolling'
import {
INGEST_STATUS_TEXT,
INGEST_STATUS_COLOR
} from '@/constants/ingest'
const { state: pollState, startPolling, stopPolling } = useIngestPolling()
const activeTab = ref('text')
const isSubmittingText = ref(false)
const isSubmittingFile = ref(false)
const textFormRef = ref(null)
const textForm = reactive({
title: '',
source: '',
text: ''
})
const textRules = {
title: [{ required: true, message: '请输入标题', trigger: 'blur' }],
text: [{ required: true, message: '请输入正文', trigger: 'blur' }]
}
const fileForm = reactive({
title: '',
source: ''
})
const fileList = ref([])
const rawFile = ref(null)
const ACCEPTED_EXTENSIONS = '.txt,.md,.html,.htm,.pdf,.docx'
const statusBadgeColor = computed(() => {
return INGEST_STATUS_COLOR[pollState.status] || 'default'
})
const statusBadgeText = computed(() => {
return INGEST_STATUS_TEXT[pollState.status] || pollState.status || '-'
})
const isTerminal = computed(() =>
['done', 'failed'].includes(pollState.status)
)
const resultData = computed(() => pollState.task?.result || null)
const errorData = computed(() => pollState.task?.error || pollState.error || null)
const summaryData = computed(() => resultData.value?.summary || {})
const partialSummary = computed(() => errorData.value?.partial_summary || null)
function handleFileChange(file) {
// a-upload before-upload 返回 false 表示不自动上传;保存原始 File 供 FormData 使用
rawFile.value = file
fileList.value = [file]
return false
}
function handleFileRemove() {
rawFile.value = null
fileList.value = []
}
async function handleSubmitText() {
try {
await textFormRef.value.validate()
} catch {
return
}
isSubmittingText.value = true
try {
const payload = {
title: textForm.title,
text: textForm.text
}
if (textForm.source) {
payload.source = textForm.source
}
const data = await ingestDocument(payload)
message.success('任务已提交')
startPolling(data.task_id)
} catch (err) {
message.error(err?.message || '提交入库失败')
} finally {
isSubmittingText.value = false
}
}
async function handleSubmitFile() {
if (!rawFile.value) {
message.warning('请选择文件')
return
}
isSubmittingFile.value = true
try {
const formData = new FormData()
formData.append('file', rawFile.value)
if (fileForm.title) {
formData.append('title', fileForm.title)
}
if (fileForm.source) {
formData.append('source', fileForm.source)
}
const data = await uploadDocument(formData)
message.success('任务已提交')
startPolling(data.task_id)
} catch (err) {
message.error(err?.message || '上传入库失败')
} finally {
isSubmittingFile.value = false
}
}
function handleCancelPolling() {
stopPolling()
message.info('已停止轮询')
}
</script>
<template>
<div class="ingest page-section">
<div class="ingest__header">
<h2 class="ingest__title">文档入库</h2>
</div>
<a-tabs v-model:activeKey="activeTab">
<a-tab-pane key="text" tab="文本入库">
<a-form
ref="textFormRef"
:model="textForm"
:rules="textRules"
layout="vertical"
>
<a-form-item label="标题" name="title">
<a-input v-model:value="textForm.title" placeholder="请输入标题" allow-clear />
</a-form-item>
<a-form-item label="来源" name="source">
<a-input
v-model:value="textForm.source"
placeholder="例如:manual / web / file"
allow-clear
/>
</a-form-item>
<a-form-item label="正文" name="text">
<a-textarea
v-model:value="textForm.text"
placeholder="请输入文档正文"
:auto-size="{ minRows: 8, maxRows: 18 }"
/>
</a-form-item>
<a-form-item>
<a-button
type="primary"
:loading="isSubmittingText || pollState.isPolling"
@click="handleSubmitText"
>
提交入库
</a-button>
</a-form-item>
</a-form>
</a-tab-pane>
<a-tab-pane key="file" tab="文件上传">
<a-form
:model="fileForm"
layout="vertical"
>
<a-form-item label="选择文件">
<a-upload
:file-list="fileList"
:accept="ACCEPTED_EXTENSIONS"
:max-count="1"
:before-upload="handleFileChange"
@remove="handleFileRemove"
>
<a-button :disabled="fileList.length >= 1">选择文件</a-button>
</a-upload>
<div class="text-muted" style="margin-top: 4px">
支持 .txt/.md/.html/.htm/.pdf/.docx
</div>
</a-form-item>
<a-form-item label="标题(可选,默认取文件名)" name="title">
<a-input
v-model:value="fileForm.title"
placeholder="留空则使用文件名去扩展"
allow-clear
/>
</a-form-item>
<a-form-item label="来源(可选,默认 file:原文件名)" name="source">
<a-input
v-model:value="fileForm.source"
placeholder="例如:manual / web"
allow-clear
/>
</a-form-item>
<a-form-item>
<a-button
type="primary"
:loading="isSubmittingFile || pollState.isPolling"
@click="handleSubmitFile"
>
上传入库
</a-button>
</a-form-item>
</a-form>
</a-tab-pane>
</a-tabs>
<div v-if="pollState.taskId" class="ingest__result">
<div class="ingest__result-header">
<span>任务已提交{{ pollState.taskId }}</span>
<a-tag :color="statusBadgeColor">{{ statusBadgeText }}</a-tag>
<a-button
v-if="pollState.isPolling"
type="link"
size="small"
@click="handleCancelPolling"
>
停止轮询
</a-button>
</div>
<a-alert
v-if="pollState.isTimeout"
class="ingest__alert"
type="warning"
show-icon
:message="`任务仍在进行,可稍后凭 task_id 查询:${pollState.taskId}`"
/>
<a-alert
v-if="pollState.error && !pollState.task"
class="ingest__alert"
type="error"
show-icon
:message="pollState.error?.message || '轮询失败'"
/>
<div v-if="isTerminal && pollState.status === 'done' && resultData" class="ingest__done">
<a-descriptions :column="1" size="small" bordered>
<a-descriptions-item label="document_id">
{{ resultData.document_id || '-' }}
</a-descriptions-item>
<a-descriptions-item label="类目">
{{ resultData.category || '-' }}置信度 {{ resultData.category_confidence ?? '-' }}
</a-descriptions-item>
<a-descriptions-item label="标签">
<template v-if="resultData.tags && resultData.tags.length">
<a-tag v-for="tag in resultData.tags" :key="tag">{{ tag }}</a-tag>
</template>
<span v-else class="text-muted">-</span>
</a-descriptions-item>
<a-descriptions-item label="总结层级">
{{ summaryData.level ?? '-' }}
</a-descriptions-item>
<a-descriptions-item label="写入集合">
{{ resultData.collection || '-' }}
</a-descriptions-item>
<a-descriptions-item label="chunks_count">
{{ resultData.chunks_count ?? 0 }}
</a-descriptions-item>
</a-descriptions>
<h3 class="ingest__section-title">L1 总结</h3>
<pre class="ingest__pre">{{ summaryData.l1_summary || '' }}</pre>
</div>
<div v-if="isTerminal && pollState.status === 'failed'" class="ingest__failed">
<a-descriptions :column="1" size="small" bordered>
<a-descriptions-item label="失败阶段">
{{ errorData?.stage || '-' }}
</a-descriptions-item>
<a-descriptions-item label="错误信息">
<span class="text-break">{{ errorData?.message || '-' }}</span>
</a-descriptions-item>
</a-descriptions>
<template v-if="partialSummary && partialSummary.l1_summary">
<a-alert
class="ingest__alert"
type="info"
show-icon
message="已产出总结保留:任务失败前已生成 L1 摘要"
/>
<h3 class="ingest__section-title">L1 总结</h3>
<pre class="ingest__pre">{{ partialSummary.l1_summary }}</pre>
</template>
</div>
</div>
</div>
</template>
<style scoped>
.ingest__header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.ingest__title {
font-size: 16px;
margin: 0;
}
.ingest__result {
margin-top: 16px;
border: 1px solid #e5e7eb;
border-radius: 6px;
background: #fafafa;
padding: 12px 16px;
}
.ingest__result-header {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
font-size: 13px;
margin-bottom: 8px;
}
.ingest__alert {
margin: 8px 0;
}
.ingest__done,
.ingest__failed {
margin-top: 8px;
}
.ingest__section-title {
font-size: 14px;
margin: 12px 0 8px;
color: #374151;
}
.ingest__pre {
background: #f9fafb;
border: 1px solid #e5e7eb;
border-radius: 4px;
padding: 8px;
white-space: pre-wrap;
word-break: break-word;
font-size: 12px;
max-height: 260px;
overflow: auto;
margin: 0;
}
</style>
+199
View File
@@ -0,0 +1,199 @@
<script setup>
import { reactive, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { message } from 'ant-design-vue'
import { UserOutlined, LockOutlined, DatabaseOutlined } from '@ant-design/icons-vue'
import { login as loginApi } from '@/api/auth'
import { useAuthStore } from '@/stores/useAuthStore'
const route = useRoute()
const router = useRouter()
const authStore = useAuthStore()
const isLoading = ref(false)
const formRef = ref(null)
const formState = reactive({
username: '',
password: ''
})
const rules = {
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
password: [{ required: true, message: '请输入密码', trigger: 'blur' }]
}
async function handleSubmit() {
try {
await formRef.value.validate()
} catch {
return
}
isLoading.value = true
try {
const data = await loginApi(formState.username, formState.password)
authStore.setAuth(data)
message.success('登录成功')
const redirect = typeof route.query.redirect === 'string' ? route.query.redirect : '/overview'
router.replace(redirect)
} catch (err) {
message.error(err?.message || '登录失败')
} finally {
isLoading.value = false
}
}
</script>
<template>
<div class="login-page">
<div class="login-page__bg-deco login-page__bg-deco--1" />
<div class="login-page__bg-deco login-page__bg-deco--2" />
<div class="login-page__box">
<div class="login-page__brand">
<div class="login-page__logo">
<DatabaseOutlined />
</div>
<div class="login-page__brand-text">
<div class="login-page__brand-name">QMDSearch</div>
<div class="login-page__brand-sub">知识库管理后台</div>
</div>
</div>
<a-form
ref="formRef"
:model="formState"
:rules="rules"
layout="vertical"
@finish="handleSubmit"
>
<a-form-item label="用户名" name="username">
<a-input
v-model:value="formState.username"
placeholder="请输入用户名"
autocomplete="username"
allow-clear
size="large"
>
<template #prefix><UserOutlined /></template>
</a-input>
</a-form-item>
<a-form-item label="密码" name="password">
<a-input-password
v-model:value="formState.password"
placeholder="请输入密码"
autocomplete="current-password"
size="large"
@pressEnter="handleSubmit"
>
<template #prefix><LockOutlined /></template>
</a-input-password>
</a-form-item>
<a-form-item>
<a-button
type="primary"
html-type="submit"
block
size="large"
:loading="isLoading"
>
登录
</a-button>
</a-form-item>
</a-form>
<div class="login-page__footer">
QMDSearch Admin · 分层信息检索服务
</div>
</div>
</div>
</template>
<style scoped>
.login-page {
position: relative;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
background: linear-gradient(135deg, #1e3a8a 0%, #1e40af 35%, #0c4a6e 100%);
}
.login-page__bg-deco {
position: absolute;
border-radius: 50%;
filter: blur(80px);
opacity: 0.4;
pointer-events: none;
}
.login-page__bg-deco--1 {
width: 360px;
height: 360px;
background: #4096ff;
top: -120px;
right: -80px;
}
.login-page__bg-deco--2 {
width: 320px;
height: 320px;
background: #13c2c2;
bottom: -100px;
left: -60px;
}
.login-page__box {
position: relative;
z-index: 1;
width: 380px;
background: #fff;
border-radius: 12px;
padding: 32px 36px 24px;
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.25);
}
.login-page__brand {
display: flex;
align-items: center;
gap: 14px;
margin-bottom: 28px;
}
.login-page__logo {
display: inline-flex;
align-items: center;
justify-content: center;
width: 48px;
height: 48px;
border-radius: 10px;
background: linear-gradient(135deg, #1677ff 0%, #4096ff 100%);
color: #fff;
font-size: 26px;
box-shadow: 0 6px 16px rgba(22, 119, 255, 0.4);
flex: 0 0 auto;
}
.login-page__brand-text {
display: flex;
flex-direction: column;
gap: 2px;
}
.login-page__brand-name {
font-size: 22px;
font-weight: 700;
color: #1f2937;
line-height: 1.1;
}
.login-page__brand-sub {
font-size: 13px;
color: #6b7280;
}
.login-page__footer {
text-align: center;
font-size: 12px;
color: #9ca3af;
margin-top: 8px;
}
</style>
+349
View File
@@ -0,0 +1,349 @@
<script setup>
import { computed, onMounted, ref } from 'vue'
import { message } from 'ant-design-vue'
import {
DatabaseOutlined,
ApartmentOutlined,
FileSearchOutlined,
BlockOutlined,
FileTextOutlined,
QuestionCircleOutlined,
ReloadOutlined,
AppstoreOutlined
} from '@ant-design/icons-vue'
import { stats as fetchStats } from '@/api/knowledge'
const isLoading = ref(false)
const statsData = ref(null)
const cardMeta = [
{
key: 'doc_l1',
label: 'L1 文档总结',
icon: DatabaseOutlined,
color: '#1677ff',
bg: 'rgba(22, 119, 255, 0.12)'
},
{
key: 'doc_l2',
label: 'L2 大纲节点',
icon: ApartmentOutlined,
color: '#722ed1',
bg: 'rgba(114, 46, 209, 0.12)'
},
{
key: 'doc_l3',
label: 'L3 内容大纲',
icon: FileSearchOutlined,
color: '#13c2c2',
bg: 'rgba(19, 194, 194, 0.12)'
},
{
key: 'chunks',
label: 'Chunks',
icon: BlockOutlined,
color: '#fa8c16',
bg: 'rgba(250, 140, 22, 0.12)'
},
{
key: '__documents_total',
label: '文档总数',
icon: FileTextOutlined,
color: '#52c41a',
bg: 'rgba(82, 196, 26, 0.12)'
},
{
key: '__uncategorized',
label: '未分类文档',
icon: QuestionCircleOutlined,
color: '#ff4d4f',
bg: 'rgba(255, 77, 79, 0.12)'
}
]
const cards = computed(() => {
const collections = statsData.value?.collections || {}
return cardMeta.map((m) => {
let value = 0
if (m.key === '__documents_total') {
value = statsData.value?.documents_total ?? 0
} else if (m.key === '__uncategorized') {
value = statsData.value?.uncategorized_count ?? 0
} else {
value = collections[m.key] ?? 0
}
return { ...m, value }
})
})
const categoryBars = computed(() => {
const categories = statsData.value?.categories || {}
const entries = Object.entries(categories).map(([name, count]) => ({ name, count }))
entries.sort((a, b) => b.count - a.count)
const max = entries.reduce((acc, item) => Math.max(acc, item.count), 1)
const total = entries.reduce((acc, item) => acc + item.count, 0)
return entries.map((item) => ({
...item,
percent: Math.round((item.count / max) * 100),
ratio: total ? Math.round((item.count / total) * 100) : 0
}))
})
const gradientColors = [
'linear-gradient(90deg, #1677ff 0%, #4096ff 100%)',
'linear-gradient(90deg, #722ed1 0%, #9254de 100%)',
'linear-gradient(90deg, #13c2c2 0%, #36cfc9 100%)',
'linear-gradient(90deg, #fa8c16 0%, #ffa940 100%)',
'linear-gradient(90deg, #52c41a 0%, #73d13d 100%)',
'linear-gradient(90deg, #eb2f96 0%, #f759ab 100%)',
'linear-gradient(90deg, #fa541c 0%, #ff7a45 100%)',
'linear-gradient(90deg, #2f54eb 0%, #597ef7 100%)',
'linear-gradient(90deg, #08979c 0%, #13c2c2 100%)',
'linear-gradient(90deg, #c41d7f 0%, #eb2f96 100%)'
]
function barGradient(index) {
return gradientColors[index % gradientColors.length]
}
async function loadStats() {
isLoading.value = true
try {
statsData.value = await fetchStats()
} catch (err) {
message.error(err?.message || '获取统计失败')
} finally {
isLoading.value = false
}
}
onMounted(() => {
loadStats()
})
</script>
<template>
<div class="overview page-section">
<div class="overview__header">
<h2 class="page-title">概览</h2>
<a-space>
<a-button :loading="isLoading" @click="loadStats">
<template #icon><ReloadOutlined /></template>
刷新
</a-button>
</a-space>
</div>
<a-spin :spinning="isLoading">
<div class="overview__cards">
<div
v-for="card in cards"
:key="card.key"
class="overview__card"
>
<div class="overview__card-body">
<div
class="overview__card-icon"
:style="{ background: card.bg, color: card.color }"
>
<component :is="card.icon" />
</div>
<div class="overview__card-info">
<div class="overview__card-num">{{ card.value }}</div>
<div class="overview__card-label">{{ card.label }}</div>
</div>
</div>
</div>
</div>
<div class="overview__section">
<div class="overview__section-head">
<h3 class="overview__subtitle">
<AppstoreOutlined />
<span>类目分布</span>
</h3>
<span v-if="categoryBars.length" class="text-muted overview__section-meta">
{{ categoryBars.length }} 个类目
</span>
</div>
<div v-if="categoryBars.length === 0" class="overview__empty text-muted">
暂无数据
</div>
<div v-else class="overview__bars">
<div
v-for="(bar, idx) in categoryBars"
:key="bar.name"
class="overview__bar-row"
:title="`${bar.name}: ${bar.count} (${bar.ratio}%)`"
>
<span class="overview__bar-name" :title="bar.name">{{ bar.name }}</span>
<div class="overview__bar-track">
<div
class="overview__bar-fill"
:style="{ width: bar.percent + '%', background: barGradient(idx) }"
/>
</div>
<span class="overview__bar-count">{{ bar.count }}</span>
<span class="overview__bar-ratio">{{ bar.ratio }}%</span>
</div>
</div>
</div>
</a-spin>
</div>
</template>
<style scoped>
.overview__header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.overview__cards {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 16px;
margin-bottom: 24px;
}
.overview__card {
background: #fff;
border: 1px solid #f0f0f0;
border-radius: 8px;
padding: 16px 18px;
transition: all 0.2s;
box-shadow: 0 1px 2px rgba(0, 21, 41, 0.04);
}
.overview__card:hover {
box-shadow: 0 4px 12px rgba(0, 21, 41, 0.08);
transform: translateY(-2px);
}
.overview__card-body {
display: flex;
align-items: center;
gap: 14px;
}
.overview__card-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
border-radius: 8px;
font-size: 22px;
flex: 0 0 auto;
}
.overview__card-info {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.overview__card-num {
font-size: 26px;
font-weight: 700;
color: #1f2937;
line-height: 1.1;
}
.overview__card-label {
font-size: 12px;
color: #6b7280;
}
.overview__section {
margin-top: 8px;
}
.overview__section-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.overview__subtitle {
font-size: 14px;
font-weight: 600;
color: #374151;
margin: 0;
display: flex;
align-items: center;
gap: 6px;
}
.overview__section-meta {
font-size: 12px;
}
.overview__empty {
text-align: center;
padding: 32px 0;
}
.overview__bars {
display: flex;
flex-direction: column;
gap: 8px;
}
.overview__bar-row {
display: flex;
align-items: center;
font-size: 13px;
cursor: default;
}
.overview__bar-name {
width: 180px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: #374151;
flex: 0 0 auto;
}
.overview__bar-track {
flex: 1;
background: #f3f4f6;
border-radius: 4px;
height: 18px;
margin: 0 10px;
overflow: hidden;
}
.overview__bar-fill {
height: 100%;
border-radius: 4px;
min-width: 2px;
transition: width 0.4s ease;
}
.overview__bar-count {
width: 48px;
text-align: right;
color: #1f2937;
font-weight: 600;
flex: 0 0 auto;
}
.overview__bar-ratio {
width: 48px;
text-align: right;
color: #6b7280;
font-size: 12px;
flex: 0 0 auto;
}
@media (max-width: 768px) {
.overview__bar-name {
width: 120px;
}
}
</style>
+234
View File
@@ -0,0 +1,234 @@
<script setup>
import { reactive, ref } from 'vue'
import { message } from 'ant-design-vue'
import { search as searchApi } from '@/api/search'
const isLoading = ref(false)
const resultData = ref(null)
const formRef = ref(null)
const formState = reactive({
query: '',
top_k: 5,
summarize: false
})
const rules = {
query: [{ required: true, message: '请输入查询语句', trigger: 'blur' }],
top_k: [{ type: 'number', min: 1, max: 50, message: 'top_k 范围 1~50', trigger: 'change' }]
}
const routedCategoriesText = (cats) => {
if (!cats || !cats.length) return '(无)'
return cats.join(', ')
}
async function handleSearch() {
try {
await formRef.value.validate()
} catch {
return
}
isLoading.value = true
resultData.value = null
try {
const payload = {
query: formState.query,
top_k: formState.top_k,
summarize: formState.summarize
}
resultData.value = await searchApi(payload)
} catch (err) {
message.error(err?.message || '检索失败')
} finally {
isLoading.value = false
}
}
function eiValue(value) {
if (value === undefined || value === null || value === '') return '(无)'
if (Array.isArray(value)) {
return value.length ? value.join(', ') : '(无)'
}
return String(value)
}
const hits = (data) => data?.hits || []
</script>
<template>
<div class="search page-section">
<div class="search__header">
<h2 class="search__title">检索测试台</h2>
</div>
<a-form
ref="formRef"
:model="formState"
:rules="rules"
layout="vertical"
>
<a-form-item label="查询语句" name="query">
<a-input
v-model:value="formState.query"
placeholder="请输入查询语句"
allow-clear
@pressEnter="handleSearch"
/>
</a-form-item>
<a-form-item label="top_k" name="top_k">
<a-input-number
v-model:value="formState.top_k"
:min="1"
:max="50"
style="width: 160px"
/>
</a-form-item>
<a-form-item name="summarize">
<a-checkbox v-model:checked="formState.summarize">
对结果生成 AI 总结
</a-checkbox>
</a-form-item>
<a-form-item>
<a-button type="primary" :loading="isLoading" @click="handleSearch">
检索
</a-button>
</a-form-item>
</a-form>
<div v-if="resultData" class="search__result">
<a-descriptions :column="1" size="small" bordered>
<a-descriptions-item label="routed_categories">
{{ routedCategoriesText(resultData.routed_categories) }}
</a-descriptions-item>
</a-descriptions>
<a-alert
v-if="resultData.fallback"
class="search__alert"
type="warning"
show-icon
message="fallback:本次检索触发了回退策略(路由类目无命中,已降级全局检索)"
/>
<div v-if="resultData.summary" class="search__summary">
<div class="search__summary-title">AI 总结</div>
<div class="text-break">{{ resultData.summary }}</div>
</div>
<div v-if="resultData.extracted_info" class="search__extracted">
<div class="search__extracted-title">AI 提取的关键信息</div>
<a-descriptions :column="1" size="small" bordered>
<a-descriptions-item label="改写">
{{ eiValue(resultData.extracted_info.rewrite) }}
</a-descriptions-item>
<a-descriptions-item label="关键词">
{{ eiValue(resultData.extracted_info.keywords) }}
</a-descriptions-item>
<a-descriptions-item label="实体">
{{ eiValue(resultData.extracted_info.entities) }}
</a-descriptions-item>
<a-descriptions-item label="意图">
{{ eiValue(resultData.extracted_info.intent) }}
</a-descriptions-item>
<a-descriptions-item label="时间范围">
{{ eiValue(resultData.extracted_info.time_range) }}
</a-descriptions-item>
<a-descriptions-item label="命中类目">
{{ eiValue(resultData.extracted_info.categories) }}
</a-descriptions-item>
</a-descriptions>
</div>
<div class="text-muted" style="margin: 12px 0 8px">
命中 {{ hits(resultData).length }}
</div>
<div
v-for="(hit, idx) in hits(resultData)"
:key="`hit-${idx}`"
class="search__hit"
>
<div class="search__hit-meta">
score={{ hit.score }} | doc_id={{ hit.doc_id }} | 标题={{ hit.title || '' }} | section={{ hit.section_path || '' }}
</div>
<div class="search__hit-snippet text-break">{{ hit.text || '' }}</div>
<div v-if="hit.doc_summary" class="search__hit-meta">
文档摘要{{ hit.doc_summary }}
</div>
</div>
</div>
</div>
</template>
<style scoped>
.search__header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.search__title {
font-size: 16px;
margin: 0;
}
.search__result {
margin-top: 16px;
}
.search__alert {
margin: 12px 0;
}
.search__summary {
background: #f0fdf4;
border: 1px solid #bbf7d0;
border-radius: 6px;
padding: 12px 14px;
margin: 12px 0;
font-size: 13px;
}
.search__summary-title {
font-weight: 600;
margin-bottom: 6px;
color: #15803d;
}
.search__extracted {
background: #f0f9ff;
border: 1px solid #bae6fd;
border-radius: 6px;
padding: 12px 14px;
margin: 12px 0;
font-size: 13px;
}
.search__extracted-title {
font-weight: 600;
margin-bottom: 8px;
color: #0369a1;
}
.search__hit {
border: 1px solid #e5e7eb;
border-radius: 6px;
padding: 10px 12px;
margin-bottom: 10px;
background: #fff;
}
.search__hit-meta {
font-size: 12px;
color: #6b7280;
margin-bottom: 6px;
word-break: break-all;
}
.search__hit-snippet {
font-size: 13px;
color: #1f2937;
}
</style>
+489
View File
@@ -0,0 +1,489 @@
<script setup>
import { computed, onMounted, reactive, ref } from 'vue'
import { message, Modal } from 'ant-design-vue'
import { storeToRefs } from 'pinia'
import {
RobotOutlined,
FileTextOutlined,
FilterOutlined,
ReloadOutlined,
SaveOutlined,
UndoOutlined,
LockOutlined
} from '@ant-design/icons-vue'
import {
get as getSettings,
update as updateSettings,
schema as getSettingsSchema,
reset as resetSettings
} from '@/api/settings'
import { useAuthStore } from '@/stores/useAuthStore'
const authStore = useAuthStore()
const { isAdmin } = storeToRefs(authStore)
const activeTab = ref('models')
const isLoading = ref(false)
const isSaving = ref(false)
const isResetting = ref(false)
const schemaData = ref(null)
const DEFAULT_MODEL_CONFIG = {
provider: 'ollama',
base_url: '',
api_key: '',
model: '',
timeout: 120,
temperature: 0.3
}
const form = reactive({
models: {
summarize: { ...DEFAULT_MODEL_CONFIG },
query: { ...DEFAULT_MODEL_CONFIG },
classify: { ...DEFAULT_MODEL_CONFIG }
},
parsers: {
ocr: { plugin: '', params: {} },
pdf: { plugin: '', params: {} },
docx: { plugin: '', params: {} }
},
dedup: {
strategy: 'sha256',
simhash_threshold: 3,
ttl_seconds: 86400
}
})
const modelKeys = ['summarize', 'query', 'classify']
const modelMeta = {
summarize: {
label: '总结模型',
key: 'summarize',
desc: '用于文档三级总结',
icon: RobotOutlined,
color: '#1677ff'
},
query: {
label: '查询模型',
key: 'query',
desc: '用于 query 解析与重写',
icon: FilterOutlined,
color: '#722ed1'
},
classify: {
label: '分类模型',
key: 'classify',
desc: '用于文档分类判定',
icon: FileTextOutlined,
color: '#13c2c2'
}
}
const llmProviderOptions = computed(() => {
return (schemaData.value?.llm_providers || ['ollama', 'openai_compatible']).map(
(p) => ({ label: p, value: p })
)
})
const ocrPluginOptions = computed(() =>
(schemaData.value?.ocr_plugins || []).map((p) => ({ label: p, value: p }))
)
const pdfPluginOptions = computed(() =>
(schemaData.value?.pdf_plugins || []).map((p) => ({ label: p, value: p }))
)
const docxPluginOptions = computed(() =>
(schemaData.value?.docx_plugins || []).map((p) => ({ label: p, value: p }))
)
const dedupStrategyOptions = computed(() =>
(schemaData.value?.dedup_strategies || ['none', 'sha256', 'simhash']).map((s) => ({
label: s,
value: s
}))
)
const isSimhash = computed(() => form.dedup.strategy === 'simhash')
function applySettingsToForm(cfg) {
if (!cfg || typeof cfg !== 'object') return
if (cfg.models && typeof cfg.models === 'object') {
for (const key of modelKeys) {
const src = cfg.models[key] || {}
form.models[key] = {
provider: src.provider ?? DEFAULT_MODEL_CONFIG.provider,
base_url: src.base_url ?? '',
api_key: src.api_key ?? '',
model: src.model ?? '',
timeout: src.timeout ?? 120,
temperature: src.temperature ?? 0.3
}
}
}
if (cfg.parsers && typeof cfg.parsers === 'object') {
const ocr = cfg.parsers.ocr || {}
const pdf = cfg.parsers.pdf || {}
const docx = cfg.parsers.docx || {}
form.parsers.ocr = { plugin: ocr.plugin ?? '', params: ocr.params || {} }
form.parsers.pdf = { plugin: pdf.plugin ?? '', params: pdf.params || {} }
form.parsers.docx = { plugin: docx.plugin ?? '', params: docx.params || {} }
}
if (cfg.dedup && typeof cfg.dedup === 'object') {
form.dedup.strategy = cfg.dedup.strategy ?? 'sha256'
form.dedup.simhash_threshold = cfg.dedup.simhash_threshold ?? 3
form.dedup.ttl_seconds = cfg.dedup.ttl_seconds ?? 86400
}
}
function buildPayload() {
return {
models: {
summarize: { ...form.models.summarize },
query: { ...form.models.query },
classify: { ...form.models.classify }
},
parsers: {
ocr: { plugin: form.parsers.ocr.plugin, params: { ...form.parsers.ocr.params } },
pdf: { plugin: form.parsers.pdf.plugin, params: { ...form.parsers.pdf.params } },
docx: { plugin: form.parsers.docx.plugin, params: { ...form.parsers.docx.params } }
},
dedup: {
strategy: form.dedup.strategy,
simhash_threshold: form.dedup.simhash_threshold,
ttl_seconds: form.dedup.ttl_seconds
}
}
}
async function loadAll() {
isLoading.value = true
try {
const [cfg, schema] = await Promise.all([getSettings(), getSettingsSchema()])
schemaData.value = schema
applySettingsToForm(cfg)
} catch (err) {
message.error(err?.message || '加载设置失败')
} finally {
isLoading.value = false
}
}
async function handleSave() {
if (!isAdmin.value) {
message.error('仅管理员可修改设置')
return
}
isSaving.value = true
try {
const payload = buildPayload()
const cfg = await updateSettings(payload)
applySettingsToForm(cfg)
message.success('设置已保存')
} catch (err) {
message.error(err?.message || '保存设置失败')
} finally {
isSaving.value = false
}
}
function handleReset() {
if (!isAdmin.value) {
message.error('仅管理员可重置设置')
return
}
Modal.confirm({
title: '确认重置',
content: '确定要将所有运行时设置重置为默认值吗?此操作不可撤销。',
okText: '重置',
okType: 'danger',
cancelText: '取消',
async onOk() {
isResetting.value = true
try {
const cfg = await resetSettings()
applySettingsToForm(cfg)
message.success('已重置为默认值')
} catch (err) {
message.error(err?.message || '重置失败')
} finally {
isResetting.value = false
}
}
})
}
onMounted(() => {
loadAll()
})
</script>
<template>
<div class="settings page-section">
<div class="settings__header">
<h2 class="page-title">设置</h2>
<a-space>
<a-button :loading="isLoading" @click="loadAll">
<template #icon><ReloadOutlined /></template>
刷新
</a-button>
<a-button
type="primary"
:loading="isSaving"
:disabled="!isAdmin"
@click="handleSave"
>
<template #icon><SaveOutlined /></template>
保存
</a-button>
<a-button
danger
:loading="isResetting"
:disabled="!isAdmin"
@click="handleReset"
>
<template #icon><UndoOutlined /></template>
重置默认
</a-button>
</a-space>
</div>
<a-alert
v-if="!isAdmin"
class="settings__alert"
type="info"
show-icon
message="当前用户非管理员,仅可查看设置;保存与重置需 admin 权限。"
/>
<a-spin :spinning="isLoading">
<a-tabs v-model:activeKey="activeTab">
<a-tab-pane key="models" tab="模型配置">
<div class="settings__cards">
<a-card
v-for="key in modelKeys"
:key="key"
class="settings__card"
:body-style="{ padding: '16px 18px' }"
>
<template #title>
<div class="settings__card-title">
<span
class="settings__card-icon"
:style="{ background: modelMeta[key].color }"
>
<component :is="modelMeta[key].icon" />
</span>
<div class="settings__card-head">
<div class="settings__card-name">{{ modelMeta[key].label }}</div>
<div class="settings__card-desc">{{ modelMeta[key].desc }}</div>
</div>
</div>
</template>
<a-form layout="vertical" :colon="false">
<a-form-item label="provider">
<a-radio-group v-model:value="form.models[key].provider" button-style="solid">
<a-radio
v-for="opt in llmProviderOptions"
:key="opt.value"
:value="opt.value"
>
{{ opt.label }}
</a-radio>
</a-radio-group>
</a-form-item>
<a-row :gutter="12">
<a-col :span="24">
<a-form-item label="base_url">
<a-input
v-model:value="form.models[key].base_url"
placeholder="例如 http://localhost:11434"
allow-clear
/>
</a-form-item>
</a-col>
</a-row>
<a-form-item
v-if="form.models[key].provider !== 'ollama'"
label="api_key"
>
<a-input-password
v-model:value="form.models[key].api_key"
placeholder="无则留空"
allow-clear
>
<template #prefix><LockOutlined /></template>
</a-input-password>
</a-form-item>
<a-row :gutter="12">
<a-col :span="14">
<a-form-item label="model">
<a-input
v-model:value="form.models[key].model"
placeholder="例如 qwen2.5:1.5b"
allow-clear
/>
</a-form-item>
</a-col>
<a-col :span="10">
<a-form-item label="timeout (秒)">
<a-input-number
v-model:value="form.models[key].timeout"
:min="1"
style="width: 100%"
/>
</a-form-item>
</a-col>
</a-row>
<a-form-item label="temperature">
<a-input-number
v-model:value="form.models[key].temperature"
:min="0"
:max="2"
:step="0.1"
style="width: 100%"
/>
</a-form-item>
</a-form>
</a-card>
</div>
</a-tab-pane>
<a-tab-pane key="parsers" tab="解析插件">
<a-form layout="vertical" class="settings__parsers">
<a-form-item label="OCR 插件">
<a-select
v-model:value="form.parsers.ocr.plugin"
:options="ocrPluginOptions"
placeholder="选择 OCR 插件"
allow-clear
/>
</a-form-item>
<a-form-item label="PDF 插件">
<a-select
v-model:value="form.parsers.pdf.plugin"
:options="pdfPluginOptions"
placeholder="选择 PDF 插件"
allow-clear
/>
</a-form-item>
<a-form-item label="DOCX 插件">
<a-select
v-model:value="form.parsers.docx.plugin"
:options="docxPluginOptions"
placeholder="选择 DOCX 插件"
allow-clear
/>
</a-form-item>
</a-form>
</a-tab-pane>
<a-tab-pane key="dedup" tab="去重策略">
<a-form layout="vertical" class="settings__dedup">
<a-form-item label="strategy">
<a-select
v-model:value="form.dedup.strategy"
:options="dedupStrategyOptions"
placeholder="选择去重策略"
/>
</a-form-item>
<a-form-item label="simhash_threshold (0~64)">
<a-input-number
v-model:value="form.dedup.simhash_threshold"
:min="0"
:max="64"
:disabled="!isSimhash"
style="width: 100%"
/>
<span v-if="!isSimhash" class="text-muted" style="margin-top: 4px; display: inline-block">
strategy=simhash 时启用
</span>
</a-form-item>
<a-form-item label="ttl_seconds">
<a-input-number
v-model:value="form.dedup.ttl_seconds"
:min="0"
style="width: 100%"
/>
</a-form-item>
</a-form>
</a-tab-pane>
</a-tabs>
</a-spin>
</div>
</template>
<style scoped>
.settings__header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.settings__alert {
margin-bottom: 12px;
}
.settings__cards {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 16px;
}
.settings__card {
border-radius: 8px;
border: 1px solid #e5e7eb;
transition: box-shadow 0.2s;
}
.settings__card:hover {
box-shadow: 0 4px 12px rgba(0, 21, 41, 0.06);
}
.settings__card :deep(.ant-card-head) {
padding: 12px 16px;
border-bottom: 1px solid #f0f0f0;
min-height: auto;
}
.settings__card-title {
display: flex;
align-items: center;
gap: 10px;
}
.settings__card-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
border-radius: 6px;
color: #fff;
font-size: 16px;
flex: 0 0 auto;
}
.settings__card-head {
display: flex;
flex-direction: column;
gap: 2px;
}
.settings__card-name {
font-size: 14px;
font-weight: 600;
color: #1f2937;
line-height: 1.2;
}
.settings__card-desc {
font-size: 12px;
color: #6b7280;
line-height: 1.2;
}
.settings__parsers,
.settings__dedup {
max-width: 480px;
}
</style>