本地 RAG 知识库
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

299 lines
15 KiB

/**
* FAQ 管理组件
* 支持 CRUD + 批量导入 + 导出 + 启用/禁用
*/
import { listFaqs, createFaq, updateFaq, deleteFaq, toggleFaqStatus, batchImportFaqs, exportFaqs, getFaqStats } from '../js/api.js'
import { toast } from '../js/utils.js'
export default {
template: `
<div class="card" style="margin-top:16px;">
<h3 style="margin-bottom:12px;">❓ FAQ 精准匹配管理</h3>
<!-- 操作栏 -->
<div style="display:flex;gap:8px;margin-bottom:12px;flex-wrap:wrap;align-items:center;">
<button @click="openAddDialog" style="padding:6px 14px;background:var(--primary);color:#fff;border:none;border-radius:6px;cursor:pointer;">+ 添加 FAQ</button>
<button @click="showImportDialog = true" style="padding:6px 14px;background:#6c757d;color:#fff;border:none;border-radius:6px;cursor:pointer;">📥 批量导入</button>
<button @click="doExport" style="padding:6px 14px;background:#28a745;color:#fff;border:none;border-radius:6px;cursor:pointer;">📤 导出</button>
<input v-model="searchKeyword" @input="debouncedSearch" placeholder="搜索问题..." style="margin-left:auto;padding:6px 10px;border:1px solid var(--border);border-radius:6px;width:200px;" />
<select v-model="filterCategory" @change="loadList" style="padding:6px 10px;border:1px solid var(--border);border-radius:6px;">
<option value="">全部分类</option>
<option v-for="c in categories" :key="c" :value="c">{{ c }}</option>
</select>
<select v-model="filterStatus" @change="loadList" style="padding:6px 10px;border:1px solid var(--border);border-radius:6px;">
<option value="">全部状态</option>
<option value="ENABLED">启用</option>
<option value="DISABLED">禁用</option>
</select>
</div>
<!-- 统计概要 -->
<div v-if="stats" style="display:flex;gap:16px;margin-bottom:12px;font-size:13px;color:#666;">
<span>总计: {{ stats.totalCount || 0 }}</span>
<span>启用: {{ stats.enabledCount || 0 }}</span>
<span>总命中: {{ stats.totalHitCount || 0 }}</span>
</div>
<!-- FAQ 表格 -->
<table style="width:100%;border-collapse:collapse;">
<thead>
<tr style="border-bottom:2px solid var(--border);">
<th style="text-align:left;padding:8px;max-width:200px;">问题</th>
<th style="text-align:left;padding:8px;max-width:200px;">答案(摘要)</th>
<th style="text-align:center;padding:8px;">分类</th>
<th style="text-align:center;padding:8px;">优先级</th>
<th style="text-align:center;padding:8px;">命中</th>
<th style="text-align:center;padding:8px;">状态</th>
<th style="text-align:right;padding:8px;">操作</th>
</tr>
</thead>
<tbody>
<tr v-if="loading"><td colspan="7" style="text-align:center;padding:20px;">加载中...</td></tr>
<tr v-else-if="faqs.length === 0"><td colspan="7" style="text-align:center;padding:20px;color:#999;">暂无 FAQ</td></tr>
<tr v-for="f in faqs" :key="f.id" style="border-bottom:1px solid var(--border);">
<td style="padding:8px;max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" :title="f.question">{{ f.question }}</td>
<td style="padding:8px;max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:#666;" :title="f.answer">{{ f.answer }}</td>
<td style="text-align:center;padding:8px;font-size:12px;">{{ f.category || '-' }}</td>
<td style="text-align:center;padding:8px;">{{ f.priority }}</td>
<td style="text-align:center;padding:8px;">{{ f.hitCount }}</td>
<td style="text-align:center;padding:8px;">
<span @click="toggleStatus(f)" style="cursor:pointer;" :style="{color: f.status === 'ENABLED' ? '#28a745' : '#dc3545'}">
{{ f.status === 'ENABLED' ? '✅ 启用' : '❌ 禁用' }}
</span>
</td>
<td style="text-align:right;padding:8px;">
<button @click="openEditDialog(f)" style="padding:3px 8px;background:none;border:1px solid var(--border);border-radius:4px;cursor:pointer;margin-right:4px;">编辑</button>
<button @click="removeFaq(f.id)" style="padding:3px 8px;background:none;border:1px solid #dc3545;color:#dc3545;border-radius:4px;cursor:pointer;">删除</button>
</td>
</tr>
</tbody>
</table>
<!-- 分页 -->
<div v-if="total > pageSize" style="display:flex;justify-content:center;gap:8px;margin-top:12px;">
<button @click="page > 1 && (page--, loadList())" :disabled="page <= 1" style="padding:4px 10px;">上一页</button>
<span style="line-height:32px;">第 {{ page }} / {{ Math.ceil(total / pageSize) }} 页(共 {{ total }} 条)</span>
<button @click="page < Math.ceil(total / pageSize) && (page++, loadList())" :disabled="page >= Math.ceil(total / pageSize)" style="padding:4px 10px;">下一页</button>
</div>
<!-- 新增/编辑弹窗 -->
<div v-if="showFormDialog" style="position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,.5);display:flex;align-items:center;justify-content:center;z-index:1000;">
<div style="background:var(--card);border-radius:12px;padding:24px;width:720px;max-width:90vw;max-height:90vh;overflow-y:auto;">
<h4 style="margin-bottom:16px;">{{ editingFaq ? '编辑 FAQ' : '添加 FAQ' }}</h4>
<div style="margin-bottom:12px;">
<label style="display:block;margin-bottom:4px;font-size:13px;">问题 *</label>
<input v-model="form.question" placeholder="输入标准问题" style="width:100%;padding:8px;border:1px solid var(--border);border-radius:6px;box-sizing:border-box;" />
</div>
<div style="margin-bottom:12px;">
<label style="display:block;margin-bottom:4px;font-size:13px;">标准答案 *</label>
<textarea v-model="form.answer" rows="6" placeholder="输入标准答案" style="width:100%;padding:8px;border:1px solid var(--border);border-radius:6px;box-sizing:border-box;"></textarea>
</div>
<div style="margin-bottom:12px;">
<label style="display:block;margin-bottom:4px;font-size:13px;">相似问题(每行一个)</label>
<textarea v-model="form.similarQuestionsText" rows="3" placeholder="每行一个相似问法&#10;例如:&#10;怎么退货?&#10;退货流程是什么?" style="width:100%;padding:8px;border:1px solid var(--border);border-radius:6px;box-sizing:border-box;font-family:monospace;"></textarea>
</div>
<div style="display:flex;gap:8px;margin-bottom:12px;">
<div style="flex:1;">
<label style="display:block;margin-bottom:4px;font-size:13px;">分类</label>
<input v-model="form.category" placeholder="如: 退货政策" style="width:100%;padding:8px;border:1px solid var(--border);border-radius:6px;box-sizing:border-box;" />
</div>
<div style="width:100px;">
<label style="display:block;margin-bottom:4px;font-size:13px;">优先级</label>
<input v-model.number="form.priority" type="number" min="0" style="width:100%;padding:8px;border:1px solid var(--border);border-radius:6px;box-sizing:border-box;" />
</div>
</div>
<div style="display:flex;justify-content:flex-end;gap:8px;">
<button @click="showFormDialog = false" style="padding:8px 16px;background:none;border:1px solid var(--border);border-radius:6px;cursor:pointer;">取消</button>
<button @click="saveFaq" :disabled="!form.question || !form.answer" style="padding:8px 16px;background:var(--primary);color:#fff;border:none;border-radius:6px;cursor:pointer;">保存</button>
</div>
</div>
</div>
<!-- 批量导入弹窗 -->
<div v-if="showImportDialog" style="position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,.5);display:flex;align-items:center;justify-content:center;z-index:1000;">
<div style="background:var(--card);border-radius:12px;padding:24px;width:660px;max-width:90vw;">
<h4 style="margin-bottom:16px;">批量导入 FAQ</h4>
<p style="font-size:13px;color:#666;margin-bottom:12px;">使用 JSON 格式批量导入,每条包含 question、answer、similarQuestions(可选数组)、category(可选)</p>
<textarea v-model="importJson" rows="10" placeholder='[&#10; {"question":"退货流程","answer":"请先在订单页面...","similarQuestions":["怎么退货","退货步骤"],"category":"退货政策"},&#10; {"question":"运费谁出","answer":"7天内退货运费..."}&#10;]' style="width:100%;padding:8px;border:1px solid var(--border);border-radius:6px;box-sizing:border-box;font-family:monospace;font-size:12px;"></textarea>
<div style="display:flex;justify-content:flex-end;gap:8px;margin-top:12px;">
<button @click="showImportDialog = false" style="padding:8px 16px;background:none;border:1px solid var(--border);border-radius:6px;cursor:pointer;">取消</button>
<button @click="doImport" :disabled="!importJson.trim()" style="padding:8px 16px;background:var(--primary);color:#fff;border:none;border-radius:6px;cursor:pointer;">导入</button>
</div>
</div>
</div>
</div>
`,
data() {
return {
faqs: [],
loading: false,
page: 1,
pageSize: 20,
total: 0,
searchKeyword: '',
filterCategory: '',
filterStatus: '',
categories: [],
stats: null,
showFormDialog: false,
showImportDialog: false,
editingFaq: null,
form: { question: '', answer: '', similarQuestionsText: '', category: '', priority: 0 },
importJson: '',
searchTimer: null
}
},
mounted() {
this.loadList()
this.loadStats()
},
methods: {
async loadList() {
this.loading = true
try {
const res = await listFaqs(this.page, this.pageSize, this.searchKeyword || undefined, this.filterCategory || undefined, this.filterStatus || undefined)
if (res.success) {
this.faqs = res.data?.records || res.data || []
this.total = res.data?.total || res.total || 0
// 提取分类列表
const cats = new Set(this.faqs.map(f => f.category).filter(Boolean))
this.categories = [...cats]
}
} catch (e) {
toast('加载失败: ' + e.message, 'error')
}
this.loading = false
},
async loadStats() {
try {
const res = await getFaqStats()
if (res.success) this.stats = res.data
} catch { /* silent */ }
},
debouncedSearch() {
clearTimeout(this.searchTimer)
this.searchTimer = setTimeout(() => { this.page = 1; this.loadList() }, 300)
},
openAddDialog() {
this.editingFaq = null
this.form = { question: '', answer: '', similarQuestionsText: '', category: '', priority: 0 }
this.showFormDialog = true
},
openEditDialog(f) {
this.editingFaq = f
let similarText = ''
try {
const arr = typeof f.similarQuestions === 'string' ? JSON.parse(f.similarQuestions) : f.similarQuestions
similarText = Array.isArray(arr) ? arr.join('\n') : ''
} catch { similarText = f.similarQuestions || '' }
this.form = { question: f.question, answer: f.answer, similarQuestionsText: similarText, category: f.category || '', priority: f.priority || 0 }
this.showFormDialog = true
},
async saveFaq() {
const similarQuestions = this.form.similarQuestionsText.split('\n').map(s => s.trim()).filter(Boolean)
const data = {
question: this.form.question,
answer: this.form.answer,
similarQuestions: JSON.stringify(similarQuestions),
category: this.form.category || null,
priority: this.form.priority || 0
}
try {
let res
if (this.editingFaq) {
res = await updateFaq(this.editingFaq.id, data)
} else {
res = await createFaq(data)
}
if (res.success) {
toast(this.editingFaq ? '修改成功' : '添加成功', 'success')
this.showFormDialog = false
this.loadList()
this.loadStats()
} else {
toast(res.message || '操作失败', 'error')
}
} catch (e) {
toast('操作失败: ' + e.message, 'error')
}
},
async toggleStatus(f) {
const newStatus = f.status === 'ENABLED' ? 'DISABLED' : 'ENABLED'
try {
const res = await toggleFaqStatus(f.id, newStatus)
if (res.success) {
f.status = newStatus
toast(`${newStatus === 'ENABLED' ? '启用' : '禁用'}`, 'success')
} else {
toast(res.message || '操作失败', 'error')
}
} catch (e) {
toast('操作失败: ' + e.message, 'error')
}
},
async removeFaq(id) {
if (!confirm('确认删除该 FAQ?')) return
try {
const res = await deleteFaq(id)
if (res.success) { toast('删除成功', 'success'); this.loadList(); this.loadStats() }
else toast(res.message || '删除失败', 'error')
} catch (e) {
toast('删除失败: ' + e.message, 'error')
}
},
async doImport() {
try {
const faqs = JSON.parse(this.importJson)
if (!Array.isArray(faqs) || faqs.length === 0) return toast('请输入有效的 FAQ 数组', 'error')
// 处理 similarQuestions 字段
for (const f of faqs) {
if (Array.isArray(f.similarQuestions)) f.similarQuestions = JSON.stringify(f.similarQuestions)
else if (!f.similarQuestions) f.similarQuestions = '[]'
}
const res = await batchImportFaqs({ faqs })
if (res.success) {
toast(`成功导入 ${res.data || faqs.length}`, 'success')
this.showImportDialog = false
this.importJson = ''
this.loadList()
this.loadStats()
} else {
toast(res.message || '导入失败', 'error')
}
} catch (e) {
if (e instanceof SyntaxError) toast('JSON 格式错误', 'error')
else toast('导入失败: ' + e.message, 'error')
}
},
async doExport() {
try {
const res = await exportFaqs()
if (res.success) {
const data = JSON.stringify(res.data, null, 2)
const blob = new Blob([data], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url; a.download = 'faq_export.json'; a.click()
URL.revokeObjectURL(url)
toast('导出成功', 'success')
} else {
toast(res.message || '导出失败', 'error')
}
} catch (e) {
toast('导出失败: ' + e.message, 'error')
}
}
}
}