Files
bianchengshequ/frontend/src/views/KnowledgeBase.vue

392 lines
18 KiB
Vue
Raw 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.
<template>
<div class="h-full bg-gray-950 text-gray-100 flex flex-col overflow-hidden">
<!-- 密码验证层 -->
<div v-if="!authenticated" class="flex-1 flex items-center justify-center">
<div class="w-80 bg-gray-900 border border-gray-800 rounded-2xl p-6 space-y-4">
<div class="text-center">
<div class="w-12 h-12 mx-auto rounded-xl bg-indigo-600/20 flex items-center justify-center mb-3">
<svg class="w-6 h-6 text-indigo-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"/></svg>
</div>
<h2 class="text-lg font-bold text-white">团队知识库</h2>
<p class="text-xs text-gray-500 mt-1">请输入访问密码</p>
</div>
<div>
<input
v-model="password"
type="password"
placeholder="输入密码"
class="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-sm text-white placeholder-gray-500 focus:outline-none focus:border-indigo-500"
@keyup.enter="handleAuth"
/>
<p v-if="authError" class="text-xs text-red-400 mt-1">{{ authError }}</p>
</div>
<button
@click="handleAuth"
:disabled="authLoading"
class="w-full py-2 bg-indigo-600 hover:bg-indigo-500 text-white text-sm font-medium rounded-lg transition-colors disabled:opacity-50"
>{{ authLoading ? '验证中...' : '进入知识库' }}</button>
</div>
</div>
<!-- 主界面 -->
<template v-else>
<!-- 顶部标题栏 -->
<div class="px-6 py-4 border-b border-gray-800 flex items-center justify-between shrink-0">
<div>
<h1 class="text-lg font-bold text-white">团队知识库</h1>
<p class="text-xs text-gray-500 mt-0.5"> {{ totalItems }} 篇知识文档</p>
</div>
<button
@click="showAiPanel = !showAiPanel"
class="px-3 py-1.5 bg-indigo-600/20 text-indigo-400 text-xs font-medium rounded-lg hover:bg-indigo-600/30 transition-colors flex items-center gap-1.5"
>
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"/></svg>
AI 问答
</button>
</div>
<div class="flex-1 flex overflow-hidden">
<!-- 左侧分类 -->
<aside class="w-44 border-r border-gray-800 py-3 px-2 shrink-0 overflow-y-auto">
<div
@click="selectedCategory = null; fetchItems()"
class="flex items-center justify-between px-3 py-1.5 rounded-lg text-xs cursor-pointer transition-colors mb-0.5"
:class="selectedCategory === null ? 'bg-indigo-600/20 text-indigo-400' : 'text-gray-400 hover:text-gray-200 hover:bg-gray-800/60'"
>
<span>全部</span>
<span class="text-[10px] text-gray-600">{{ totalItems }}</span>
</div>
<div
v-for="cat in categories"
:key="cat.id"
@click="selectedCategory = cat.id; fetchItems()"
class="flex items-center justify-between px-3 py-1.5 rounded-lg text-xs cursor-pointer transition-colors mb-0.5"
:class="selectedCategory === cat.id ? 'bg-indigo-600/20 text-indigo-400' : 'text-gray-400 hover:text-gray-200 hover:bg-gray-800/60'"
>
<span>{{ cat.name }}</span>
<span class="text-[10px] text-gray-600">{{ cat.count }}</span>
</div>
</aside>
<!-- 主内容区 -->
<div class="flex-1 flex flex-col overflow-hidden">
<!-- 搜索栏 -->
<div class="px-4 py-3 border-b border-gray-800/50 shrink-0">
<div class="relative">
<svg class="w-4 h-4 text-gray-500 absolute left-3 top-1/2 -translate-y-1/2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg>
<input
v-model="searchKeyword"
placeholder="搜索知识库..."
class="w-full pl-9 pr-3 py-2 bg-gray-900/50 border border-gray-800 rounded-lg text-sm text-white placeholder-gray-500 focus:outline-none focus:border-indigo-500/50"
@keyup.enter="fetchItems()"
/>
</div>
</div>
<!-- 条目列表 -->
<div class="flex-1 overflow-y-auto px-4 py-3 space-y-2">
<div v-if="loading" class="text-center text-gray-500 text-xs py-10">加载中...</div>
<div v-else-if="items.length === 0" class="text-center text-gray-600 text-xs py-10">暂无知识文档</div>
<div
v-for="item in items"
:key="item.id"
@click="openDetail(item)"
class="bg-gray-900/50 border border-gray-800/50 rounded-xl p-4 cursor-pointer hover:border-gray-700 transition-colors group"
>
<div class="flex items-start justify-between">
<h3 class="text-sm font-medium text-white group-hover:text-indigo-400 transition-colors">{{ item.title }}</h3>
<span v-if="item.category_name" class="text-[10px] px-1.5 py-0.5 bg-indigo-600/10 text-indigo-400/70 rounded shrink-0 ml-2">{{ item.category_name }}</span>
</div>
<p class="text-xs text-gray-500 mt-1.5 line-clamp-2">{{ item.summary }}</p>
<div class="flex items-center gap-3 mt-2 text-[10px] text-gray-600">
<span>{{ item.added_by_name }}</span>
<span>{{ formatDate(item.created_at) }}</span>
</div>
</div>
<!-- 分页 -->
<div v-if="totalPages > 1" class="flex items-center justify-center gap-2 py-4">
<button
@click="page > 1 && (page--, fetchItems())"
:disabled="page <= 1"
class="px-2.5 py-1 text-xs text-gray-400 bg-gray-900 border border-gray-800 rounded disabled:opacity-30"
>上一页</button>
<span class="text-xs text-gray-500">{{ page }} / {{ totalPages }}</span>
<button
@click="page < totalPages && (page++, fetchItems())"
:disabled="page >= totalPages"
class="px-2.5 py-1 text-xs text-gray-400 bg-gray-900 border border-gray-800 rounded disabled:opacity-30"
>下一页</button>
</div>
</div>
</div>
<!-- AI 问答面板 -->
<aside v-if="showAiPanel" class="w-96 border-l border-gray-800 flex flex-col shrink-0">
<div class="px-4 py-3 border-b border-gray-800 flex items-center justify-between">
<h3 class="text-sm font-medium text-white">AI 知识问答</h3>
<button @click="showAiPanel = false" class="text-gray-500 hover:text-gray-300">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
</button>
</div>
<!-- 对话区 -->
<div ref="chatArea" class="flex-1 overflow-y-auto px-4 py-3 space-y-3">
<div v-if="chatMessages.length === 0" class="text-center text-gray-600 text-xs py-10">
<p>基于知识库内容进行智能问答</p>
<p class="mt-1">输入问题开始对话</p>
</div>
<div v-for="(msg, i) in chatMessages" :key="i" :class="msg.role === 'user' ? 'flex justify-end' : ''">
<div
:class="msg.role === 'user'
? 'bg-indigo-600/20 text-indigo-100 rounded-2xl rounded-tr-sm px-3 py-2 max-w-[85%]'
: 'bg-gray-900 border border-gray-800 rounded-2xl rounded-tl-sm px-3 py-2 max-w-[95%]'"
>
<div v-if="msg.role === 'assistant'" class="text-xs text-gray-300 prose prose-invert prose-sm max-w-none" v-html="renderMd(msg.content)"></div>
<div v-else class="text-xs">{{ msg.content }}</div>
</div>
</div>
<div v-if="aiLoading" class="flex items-center gap-2 text-xs text-gray-500">
<div class="w-1.5 h-1.5 bg-indigo-400 rounded-full animate-pulse"></div>
AI 思考中...
</div>
</div>
<!-- 输入区 -->
<div class="px-4 py-3 border-t border-gray-800">
<div class="flex gap-2">
<input
v-model="aiQuestion"
placeholder="输入你的问题..."
class="flex-1 px-3 py-2 bg-gray-900 border border-gray-800 rounded-lg text-xs text-white placeholder-gray-500 focus:outline-none focus:border-indigo-500"
@keyup.enter="sendAiQuestion"
/>
<button
@click="sendAiQuestion"
:disabled="aiLoading || !aiQuestion.trim()"
class="px-3 py-2 bg-indigo-600 hover:bg-indigo-500 text-white text-xs rounded-lg transition-colors disabled:opacity-50 shrink-0"
>发送</button>
</div>
</div>
</aside>
</div>
<!-- 文档详情弹窗 -->
<div v-if="detailItem" class="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-6" @click.self="detailItem = null">
<div class="bg-gray-900 border border-gray-800 rounded-2xl w-full max-w-3xl max-h-[85vh] flex flex-col">
<div class="px-6 py-4 border-b border-gray-800 flex items-center justify-between shrink-0">
<div>
<h2 class="text-base font-bold text-white">{{ detailItem.title }}</h2>
<div class="flex items-center gap-2 mt-1 text-[10px] text-gray-500">
<span v-if="detailItem.category_name" class="px-1.5 py-0.5 bg-indigo-600/10 text-indigo-400/70 rounded">{{ detailItem.category_name }}</span>
<span v-if="detailItem.post_author">{{ detailItem.post_author.username }}</span>
<span v-if="detailItem.post_category">{{ detailItem.post_category }}</span>
</div>
</div>
<button @click="detailItem = null" class="text-gray-500 hover:text-gray-300">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
</button>
</div>
<div class="flex-1 overflow-y-auto px-6 py-4">
<div class="prose prose-invert prose-sm max-w-none" v-html="renderMd(detailItem.post_content || '')"></div>
<!-- 附件 -->
<div v-if="detailItem.attachments?.length" class="mt-6 pt-4 border-t border-gray-800">
<h4 class="text-xs font-medium text-gray-400 uppercase tracking-wider mb-3">附件 ({{ detailItem.attachments.length }})</h4>
<div class="space-y-2">
<a v-for="att in detailItem.attachments" :key="att.id" :href="att.url" target="_blank" rel="noopener"
class="flex items-center gap-3 px-3 py-2 bg-gray-800/50 border border-gray-800 rounded-lg hover:border-indigo-500/40 hover:bg-indigo-600/5 transition-colors group">
<svg class="w-4 h-4 text-gray-500 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/></svg>
<span class="flex-1 text-xs text-gray-300 truncate group-hover:text-indigo-400">{{ att.filename }}</span>
<span class="text-[10px] text-gray-600 shrink-0">{{ formatSize(att.file_size) }}</span>
<svg class="w-3.5 h-3.5 text-gray-600 group-hover:text-indigo-400 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/></svg>
</a>
</div>
</div>
</div>
<div class="px-6 py-3 border-t border-gray-800 flex justify-end shrink-0">
<router-link
v-if="detailItem.post_id"
:to="`/post/${detailItem.post_id}`"
class="text-xs text-indigo-400 hover:text-indigo-300"
>查看原文 &rarr;</router-link>
</div>
</div>
</div>
</template>
</div>
</template>
<script setup>
import { ref, onMounted, computed, nextTick } from 'vue'
import { kbApi } from '../api/modules'
import MarkdownIt from 'markdown-it'
const md = new MarkdownIt({ html: true, linkify: true, breaks: true })
function renderMd(text) { return md.render(text || '') }
function formatSize(bytes) {
if (!bytes) return '0 B'
if (bytes < 1024) return bytes + ' B'
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
}
// 认证
const authenticated = ref(false)
const password = ref('')
const authError = ref('')
const authLoading = ref(false)
// 数据
const categories = ref([])
const items = ref([])
const selectedCategory = ref(null)
const searchKeyword = ref('')
const page = ref(1)
const pageSize = 20
const totalItems = ref(0)
const totalPages = computed(() => Math.ceil(totalItems.value / pageSize))
const loading = ref(false)
// 详情
const detailItem = ref(null)
// AI 面板
const showAiPanel = ref(false)
const aiQuestion = ref('')
const aiLoading = ref(false)
const chatMessages = ref([])
const chatArea = ref(null)
onMounted(async () => {
try {
const res = await kbApi.checkPassword()
if (!res.data.has_password) {
// 无密码直接获取token
const authRes = await kbApi.auth('')
sessionStorage.setItem('kb_token', authRes.data.token)
authenticated.value = true
loadData()
} else {
// 检查是否已有token
const t = sessionStorage.getItem('kb_token')
if (t) {
try {
await kbApi.getCategories()
authenticated.value = true
loadData()
} catch {
// token 过期或密码已变更清除旧token
sessionStorage.removeItem('kb_token')
}
}
}
} catch (e) { /* ignore */ }
})
async function handleAuth() {
authError.value = ''
authLoading.value = true
try {
const res = await kbApi.auth(password.value)
sessionStorage.setItem('kb_token', res.data.token)
authenticated.value = true
loadData()
} catch (e) {
authError.value = e.response?.data?.detail || '验证失败'
} finally {
authLoading.value = false
}
}
async function loadData() {
await Promise.all([fetchCategories(), fetchItems()])
}
async function fetchCategories() {
try {
const res = await kbApi.getCategories()
categories.value = res.data
} catch (e) { /* ignore */ }
}
async function fetchItems() {
loading.value = true
try {
const params = { page: page.value, size: pageSize }
if (selectedCategory.value) params.category_id = selectedCategory.value
if (searchKeyword.value.trim()) params.keyword = searchKeyword.value.trim()
const res = await kbApi.getItems(params)
items.value = res.data.items
totalItems.value = res.data.total
} catch (e) { items.value = [] }
finally { loading.value = false }
}
async function openDetail(item) {
try {
const res = await kbApi.getItem(item.id)
detailItem.value = res.data
} catch (e) { /* ignore */ }
}
function formatDate(str) {
if (!str) return ''
return new Date(str).toLocaleDateString('zh-CN')
}
async function sendAiQuestion() {
const q = aiQuestion.value.trim()
if (!q || aiLoading.value) return
chatMessages.value.push({ role: 'user', content: q })
aiQuestion.value = ''
aiLoading.value = true
await nextTick()
scrollChat()
try {
const token = localStorage.getItem('token')
const kbToken = sessionStorage.getItem('kb_token')
const resp = await fetch('/api/kb/ai-chat', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
'X-Kb-Token': kbToken || '',
},
body: JSON.stringify({ question: q }),
})
const reader = resp.body.getReader()
const decoder = new TextDecoder()
let aiContent = ''
chatMessages.value.push({ role: 'assistant', content: '' })
const aiIdx = chatMessages.value.length - 1
while (true) {
const { done, value } = await reader.read()
if (done) break
const text = decoder.decode(value, { stream: true })
const lines = text.split('\n')
for (const line of lines) {
if (!line.startsWith('data: ')) continue
try {
const data = JSON.parse(line.slice(6))
if (data.done) break
aiContent += data.content
chatMessages.value[aiIdx].content = aiContent
await nextTick()
scrollChat()
} catch { /* ignore */ }
}
}
} catch (e) {
chatMessages.value.push({ role: 'assistant', content: '请求失败,请稍后重试。' })
} finally {
aiLoading.value = false
}
}
function scrollChat() {
if (chatArea.value) chatArea.value.scrollTop = chatArea.value.scrollHeight
}
</script>