Files
huihuiSquare/digital-avatar-app/src/views/KnowledgeManage.vue
T

741 lines
19 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="knowledge-page">
<!-- 顶部导航 -->
<header class="page-header">
<div class="header-left">
<button class="back-btn" @click="goBack">‹</button>
<h1 class="page-title">知识库管理</h1>
</div>
</header>
<div v-if="!avatarId" class="empty-state">
<span class="empty-icon">🤖</span>
<p class="empty-text">请先创建数字分身后再管理知识库</p>
</div>
<template v-else>
<div class="tab-switcher" role="tablist" aria-label="知识库类型">
<button class="tab-btn" :class="{ active: activeTab === 'docs' }" role="tab" :aria-selected="activeTab === 'docs'" @click="activeTab = 'docs'">文档知识库 <b>{{ docs.length }}</b></button>
<button class="tab-btn" :class="{ active: activeTab === 'qa' }" role="tab" :aria-selected="activeTab === 'qa'" @click="activeTab = 'qa'">标准问答对 <b>{{ qaPairs.length }}</b></button>
</div>
<section v-if="activeTab === 'docs'" class="knowledge-panel">
<div class="upload-section">
<div class="upload-zone" :class="{ 'drag-over': dragOver }" @click="triggerFile" @dragover.prevent="dragOver = true" @dragleave.prevent="dragOver = false" @drop.prevent="onDrop">
<div class="upload-icon">📥</div>
<p class="upload-title">拖拽文件到此处,或<span class="upload-link">点击上传</span></p>
<p class="upload-hint">支持 MD / TXT / PDF / DOC / DOCX / XLSX,上传后自动向量化</p>
<input ref="fileInput" type="file" accept=".md,.txt,.pdf,.doc,.docx,.xlsx" class="hidden-input" @change="onFileChange" />
</div>
<p v-if="uploading" class="uploading-text">上传并向量化中…</p>
<p v-if="uploadError" class="error-text">{{ uploadError }}</p>
</div>
<div v-if="docs.length" class="mobile-card-list">
<article v-for="doc in docs" :key="doc.id" class="knowledge-card">
<div class="card-icon">{{ fileEmoji(doc.fileType) }}</div>
<div class="card-content">
<div class="card-title-row">
<strong>{{ doc.filename }}</strong>
<span class="status-pill" :class="{ pending: !doc.vectorized && doc.filePresent !== false, missing: doc.filePresent === false }">{{ doc.filePresent === false ? '文件缺失' : (doc.vectorized ? '已入库' : '处理中') }}</span>
</div>
<p class="card-meta">{{ doc.fileType.toUpperCase() }} · {{ formatSize(doc.fileSize) }} · {{ formatDate(doc.createdAt) }}</p>
<p class="card-detail">{{ doc.filePresent === false ? '原文件不可用,请删除后重新上传' : (doc.vectorized ? `已切分 ${doc.chunkCount || 0} 段,可用于对话` : '正在解析并建立知识索引') }}</p>
</div>
<button class="card-delete" @click="removeDoc(doc.id)">删除</button>
</article>
</div>
<div v-else class="card-empty">📂 暂无文档,先上传一个知识文件</div>
<section class="search-section">
<h3 class="section-title">向量检索测试</h3>
<div class="search-bar"><input v-model="query" class="search-input" placeholder="输入检索词,测试文档向量召回" @keyup.enter="doSearch" /><button class="search-btn" :disabled="searching" @click="doSearch">检索</button></div>
<div v-if="searchResults.length" class="search-results"><div class="search-item" v-for="(r, i) in searchResults" :key="i"><div class="search-head"><span class="search-name">{{ r.filename }}</span><span class="search-score">相似度 {{ r.score }}</span></div><p class="search-snippet">{{ r.snippet }}</p></div></div>
<p v-if="searched && !searchResults.length" class="empty-sub-text">未检索到相关内容,先上传文档试试</p>
</section>
</section>
<section v-else class="knowledge-panel">
<div class="panel-heading"><div><h3 class="section-title">标准问答对</h3><p>相似问法命中后优先使用标准答案。</p></div><button class="add-qa-btn" @click="goAddQa">+ 添加</button></div>
<div v-if="qaPairs.length" class="mobile-card-list qa-card-list">
<article v-for="qa in qaPairs" :key="qa.id" class="knowledge-card qa-card" :class="{ 'qa-disabled': qa.enabled === false }">
<div class="card-content">
<div class="qa-card-head">
<span class="qa-label">标准问题</span>
<label class="switch" :title="qa.enabled === false ? '已停用' : '已启用'"><input type="checkbox" :checked="qa.enabled !== false" @change="toggleQa(qa, $event)" /><span class="slider"></span></label>
</div>
<strong class="qa-question">{{ qa.question }}</strong>
<p class="qa-answer">{{ qa.answer }}</p>
<p class="card-meta">更新于 {{ formatDate(qa.updatedAt || qa.createdAt) }}</p>
<div class="qa-card-actions"><button class="qa-edit" @click="goEditQa(qa)">编辑</button><button class="qa-del" @click="removeQa(qa.id)">删除</button></div>
</div>
</article>
</div>
<div v-else class="card-empty">💡 暂无问答对,添加后分身会优先按此作答</div>
</section>
</template>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useAvatarStore } from '@/store/avatar'
import { pickScopedAvatarId, unwrapListData } from '@/utils/avatar-page-data.js'
import {
getKnowledgeDocs,
uploadKnowledgeDoc,
deleteKnowledgeDoc,
getQAPairs,
deleteQAPair,
searchKnowledge,
setQaEnabled
} from '@/api'
const router = useRouter()
const route = useRoute()
const store = useAvatarStore()
const avatarId = computed(() => pickScopedAvatarId(route.params.avatarId, store.currentAvatarId, store.avatars))
const activeTab = ref<'docs' | 'qa'>('docs')
const docs = ref<any[]>([])
const qaPairs = ref<any[]>([])
const uploading = ref(false)
const uploadError = ref('')
const dragOver = ref(false)
const fileInput = ref<HTMLInputElement | null>(null)
const query = ref('')
const searching = ref(false)
const searched = ref(false)
const searchResults = ref<any[]>([])
const loadDocs = async () => {
if (!avatarId.value) return
try {
const res: any = await getKnowledgeDocs(avatarId.value)
docs.value = unwrapListData(res)
} catch (e) {
console.error(e)
}
}
const loadQA = async () => {
if (!avatarId.value) return
try {
const res: any = await getQAPairs(avatarId.value)
qaPairs.value = unwrapListData(res)
} catch (e) {
console.error(e)
}
}
const triggerFile = () => fileInput.value?.click()
const onFileChange = (e: Event) => {
const f = (e.target as HTMLInputElement).files?.[0]
if (f) doUpload(f)
;(e.target as HTMLInputElement).value = ''
}
const onDrop = (e: DragEvent) => {
dragOver.value = false
const f = e.dataTransfer?.files?.[0]
if (f) doUpload(f)
}
const doUpload = async (file: File) => {
uploadError.value = ''
const ext = '.' + (file.name.split('.').pop() || '').toLowerCase()
if (!['.md', '.txt', '.pdf', '.doc', '.docx', '.xlsx'].includes(ext)) {
uploadError.value = `不支持的类型:${ext},仅支持 md/txt/pdf/doc/docx/xlsx`
return
}
if (!avatarId.value) {
uploadError.value = '请先创建数字分身'
return
}
uploading.value = true
try {
await uploadKnowledgeDoc(avatarId.value, file)
await loadDocs()
} catch (e: any) {
uploadError.value = e?.message || '上传失败'
} finally {
uploading.value = false
}
}
const removeDoc = async (id: string) => {
if (!avatarId.value) return
await deleteKnowledgeDoc(avatarId.value, id)
await loadDocs()
}
const doSearch = async () => {
if (!avatarId.value || !query.value.trim()) return
searching.value = true
searched.value = true
try {
const res: any = await searchKnowledge(avatarId.value, query.value.trim(), 5)
searchResults.value = unwrapListData(res)
} catch (e) {
console.error(e)
searchResults.value = []
} finally {
searching.value = false
}
}
const toggleQa = async (qa: any, e: Event) => {
const enabled = (e.target as HTMLInputElement).checked
qa.enabled = enabled
if (!avatarId.value) return
try {
await setQaEnabled(avatarId.value, qa.id, enabled)
} catch (err) {
qa.enabled = !enabled
;(e.target as HTMLInputElement).checked = !enabled
console.error('切换启用状态失败', err)
}
}
const goAddQa = () => {
if (route.params.avatarId && avatarId.value) {
router.push({ name: 'AvatarQaPairCreate', params: { avatarId: avatarId.value } })
return
}
router.push('/knowledge/qa/create')
}
const goEditQa = (qa: any) => {
if (route.params.avatarId && avatarId.value) {
router.push({ name: 'AvatarQaPairEdit', params: { avatarId: avatarId.value, qaId: qa.id } })
return
}
router.push(`/knowledge/qa/${qa.id}/edit`)
}
const removeQa = async (id: string) => {
if (!avatarId.value) return
await deleteQAPair(avatarId.value, id)
await loadQA()
}
const goBack = () => router.back()
const formatSize = (n: number) => {
if (n < 1024) return n + ' B'
if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB'
return (n / 1024 / 1024).toFixed(1) + ' MB'
}
const formatDate = (value?: string) => value ? new Date(value).toLocaleDateString('zh-CN') : '-'
const fileEmoji = (t: string) => (['xlsx'].includes(t) ? '📊' : '📄')
onMounted(async () => {
if (!store.avatars.length) {
await store.loadAvatars()
}
if (avatarId.value) store.currentAvatarId = avatarId.value
await Promise.all([loadDocs(), loadQA()])
})
</script>
<style scoped>
.knowledge-page {
min-height: 100vh;
background: #F8F9FA;
padding-bottom: calc(28px + env(safe-area-inset-bottom));
overflow-x: hidden;
}
.tab-switcher {
display: flex;
gap: 8px;
padding: 16px 20px 0;
}
.tab-btn {
flex: 1;
padding: 12px 14px;
border: 1px solid #F4D8BE;
border-radius: 12px 12px 0 0;
background: #FFF8F1;
color: #8B6B58;
font-size: 14px;
cursor: pointer;
}
.tab-btn b { margin-left: 4px; font-size: 12px; color: #B0896C; }
.tab-btn.active { background: white; border-color: #F97316; color: #F97316; font-weight: 700; }
.tab-btn.active b { color: #F97316; }
.knowledge-panel { min-width: 0; padding: 0 16px; }
.panel-heading { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 18px 0 12px; }
.panel-heading p { margin: -5px 0 0; color: #9398AE; font-size: 12px; }
.mobile-card-list { display: grid; grid-template-columns: minmax(0, 1fr); width: 100%; min-width: 0; gap: 10px; }
.knowledge-card { display: flex; align-items: center; width: 100%; min-width: 0; box-sizing: border-box; gap: 11px; padding: 14px; background: #fff; border: 1px solid #F1E1D3; border-radius: 16px; box-shadow: 0 5px 16px rgba(112, 62, 22, .04); }
.card-icon { flex: 0 0 auto; width: 42px; height: 42px; display: grid; place-items: center; border-radius: 13px; background: #FFF3E6; font-size: 22px; }
.card-content { min-width: 0; flex: 1; overflow: hidden; }
.card-title-row { display: flex; align-items: center; gap: 8px; min-width: 0; }
.card-title-row strong { min-width: 0; flex: 1; overflow: hidden; color: #27201C; font-size: 14px; text-overflow: ellipsis; white-space: nowrap; }
.status-pill { flex: 0 0 auto; display: inline-flex; padding: 4px 7px; border-radius: 999px; color: #15803D; background: #ECFDF3; font-size: 10px; white-space: nowrap; }.status-pill.pending { color: #B45309; background: #FFFBEB; }
.status-pill.missing { color: #B91C1C; background: #FEF2F2; }
.card-meta, .card-detail { margin: 5px 0 0; color: #9398AE; font-size: 11px; line-height: 1.4; }.card-detail { color: #8B6B58; }
.card-delete { flex: 0 0 auto; align-self: center; border: 0; color: #EF4444; background: #FEF2F2; border-radius: 8px; padding: 7px 9px; font-size: 12px; cursor: pointer; }
.card-empty { padding: 42px 16px; border: 1px dashed #F1D9C3; border-radius: 16px; color: #9398AE; background: #fff; font-size: 14px; text-align: center; }
.qa-card { align-items: stretch; text-align: left; }.qa-card.qa-disabled { opacity: .58; }
.qa-card .card-content,
.qa-card .qa-question,
.qa-card .qa-answer,
.qa-card .card-meta,
.qa-card .qa-card-actions { text-align: left; }
.qa-card-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; }.qa-label { color: #C15F18; font-size: 11px; font-weight: 700; }
.qa-question { display: block; color: #27201C; font-size: 15px; line-height: 1.5; }.qa-answer { display: -webkit-box; margin: 7px 0 0; overflow: hidden; color: #6B7280; font-size: 13px; line-height: 1.55; -webkit-box-orient: vertical; -webkit-line-clamp: 3; }
.qa-card-actions { display: flex; gap: 8px; margin-top: 11px; }
.page-header {
display: flex;
align-items: center;
padding: 16px 20px;
background: linear-gradient(135deg, #F97316 0%, #FB923C 100%);
color: white;
}
.header-left {
display: flex;
align-items: center;
gap: 12px;
}
.back-btn {
background: none;
border: none;
color: white;
font-size: 24px;
cursor: pointer;
padding: 4px;
}
.page-title {
font-size: 18px;
font-weight: 600;
margin: 0;
}
/* 上传区 */
.upload-section {
padding: 16px 0;
}
.upload-zone {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
padding: 28px 20px;
background: white;
border: 2px dashed #FCD9B6;
border-radius: 12px;
cursor: pointer;
transition: border-color 0.2s, background 0.2s;
}
.upload-zone.drag-over {
border-color: #F97316;
background: #FFF7EF;
}
.upload-icon {
font-size: 32px;
}
.upload-title {
font-size: 14px;
color: #18191C;
margin: 0;
}
.upload-link {
color: #F97316;
font-weight: 600;
}
.upload-hint {
font-size: 12px;
color: #9398AE;
margin: 0;
}
.hidden-input {
display: none;
}
.uploading-text {
font-size: 13px;
color: #F97316;
text-align: center;
margin: 10px 0 0;
}
.error-text {
font-size: 13px;
color: #EF4444;
text-align: center;
margin: 10px 0 0;
}
/* 文档列表 */
.docs-section,
.qa-section,
.search-section {
padding: 16px 0 0;
}
.section-title {
font-size: 16px;
font-weight: 600;
margin: 0 0 12px;
color: #18191C;
}
.doc-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.doc-item {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 16px;
background: white;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
}
.doc-icon {
font-size: 22px;
flex-shrink: 0;
}
.doc-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 3px;
min-width: 0;
}
.doc-name {
font-size: 14px;
font-weight: 600;
color: #18191C;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.doc-meta {
font-size: 12px;
color: #9398AE;
}
.badge-vec {
align-self: flex-start;
margin-top: 2px;
font-size: 11px;
color: #16A34A;
background: #ECFDF3;
border-radius: 6px;
padding: 2px 8px;
}
.badge-vec.pending {
color: #D97706;
background: #FFFAEB;
}
.doc-del {
padding: 6px 12px;
background: #FEF2F2;
color: #EF4444;
border: none;
border-radius: 8px;
font-size: 13px;
cursor: pointer;
flex-shrink: 0;
}
/* 检索测试 */
.search-bar {
display: flex;
gap: 10px;
}
.search-input {
min-width: 0;
flex: 1;
border: 1px solid #E5E7EB;
border-radius: 8px;
padding: 10px 12px;
font-size: 14px;
color: #18191C;
box-sizing: border-box;
}
.search-input:focus {
outline: none;
border-color: #F97316;
}
.search-btn {
width: 72px;
height: 38px;
background: #F97316;
color: white;
border: none;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
text-align: center;
flex-shrink: 0;
}
@media (max-width: 520px) {
.knowledge-panel { padding: 0 12px; }
.knowledge-card { display: grid; grid-template-columns: 42px minmax(0, 1fr); align-items: start; gap: 10px; padding: 13px; }
.card-content { grid-column: 2; }
.card-delete { grid-column: 2; justify-self: end; margin-top: -2px; }
.qa-card { display: block; }
.qa-card .card-content { width: 100%; grid-column: 1; }
.card-title-row { align-items: flex-start; flex-wrap: wrap; gap: 5px 7px; }
.status-pill { order: 2; }
.search-bar { gap: 8px; }
.search-btn { width: 68px; }
}
.search-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.search-results {
margin-top: 12px;
display: flex;
flex-direction: column;
gap: 10px;
}
.search-item {
padding: 12px 14px;
background: white;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
}
.search-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 6px;
}
.search-name {
font-size: 13px;
font-weight: 600;
color: #18191C;
}
.search-score {
font-size: 12px;
color: #F97316;
}
.search-snippet {
font-size: 12px;
color: #6B7280;
margin: 0;
line-height: 1.5;
}
/* 问答对 */
.qa-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.qa-head .section-title {
margin: 0;
}
.add-qa-btn {
width: 72px;
height: 38px;
background: #F97316;
color: white;
border: none;
border-radius: 8px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
text-align: center;
flex-shrink: 0;
}
.qa-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.qa-item {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 14px 16px;
background: white;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
}
.qa-item.qa-disabled {
opacity: 0.55;
}
.qa-content {
flex: 1;
min-width: 0;
}
.qa-question {
font-size: 14px;
font-weight: 600;
color: #18191C;
margin: 0 0 6px;
line-height: 1.4;
}
.qa-answer {
font-size: 13px;
color: #6B7280;
margin: 0;
line-height: 1.5;
}
.qa-actions {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 6px;
flex-shrink: 0;
}
.qa-edit {
padding: 5px 12px;
background: #FFF0E6;
color: #F97316;
border: none;
border-radius: 8px;
font-size: 12px;
cursor: pointer;
}
.qa-del {
padding: 5px 12px;
background: #FEF2F2;
color: #EF4444;
border: none;
border-radius: 8px;
font-size: 12px;
cursor: pointer;
}
/* 开关 */
.switch {
position: relative;
display: inline-block;
width: 40px;
height: 22px;
}
.switch input {
opacity: 0;
width: 0;
height: 0;
}
.slider {
position: absolute;
cursor: pointer;
inset: 0;
background: #E5E7EB;
border-radius: 22px;
transition: background 0.2s;
}
.slider::before {
content: "";
position: absolute;
height: 16px;
width: 16px;
left: 3px;
top: 3px;
background: white;
border-radius: 50%;
transition: transform 0.2s;
}
.switch input:checked + .slider {
background: #F97316;
}
.switch input:checked + .slider::before {
transform: translateX(18px);
}
/* 空状态 */
.empty-state,
.empty-sub {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40px 20px;
background: white;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
}
.empty-state {
margin: 16px 20px;
}
.docs-section .empty-sub,
.qa-section .empty-sub {
margin: 16px 0 0;
}
/* 上传区空态无需额外 margin,.upload-zone 已占满 section content 宽度 */
.empty-icon,
.empty-sub-icon {
font-size: 44px;
margin-bottom: 12px;
}
.empty-text,
.empty-sub-text {
font-size: 14px;
color: #9398AE;
margin: 0;
text-align: center;
}
</style>