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.
219 lines
13 KiB
219 lines
13 KiB
<template>
|
|
<t-card title="❓ FAQ 精准匹配管理" :bordered="false">
|
|
<!-- 操作栏 -->
|
|
<div class="toolbar">
|
|
<t-button theme="primary" size="small" @click="openAddDialog">+ 添加 FAQ</t-button>
|
|
<t-button variant="outline" size="small" @click="showImportDialog = true">📥 批量导入</t-button>
|
|
<t-button variant="outline" size="small" @click="doExport">📤 导出</t-button>
|
|
<div style="flex:1;" />
|
|
<t-input v-model="searchKeyword" placeholder="搜索问题..." clearable size="small" style="width:200px;" @change="debouncedSearch" />
|
|
<t-select v-model="filterCategoryId" :options="categoryOptions" placeholder="全部分类" clearable size="small" style="width:180px;" @change="loadList" />
|
|
<t-select v-model="filterStatus" :options="statusOptions" placeholder="全部状态" clearable size="small" style="width:100px;" @change="loadList" />
|
|
</div>
|
|
|
|
<!-- 统计 -->
|
|
<div v-if="stats" class="stats-bar">
|
|
<t-tag variant="light">总计: {{ stats.totalCount || 0 }}</t-tag>
|
|
<t-tag variant="light" theme="success">启用: {{ stats.enabledCount || 0 }}</t-tag>
|
|
<t-tag variant="light" theme="primary">总命中: {{ stats.totalHitCount || stats.totalHits || 0 }}</t-tag>
|
|
</div>
|
|
|
|
<!-- FAQ 表格 -->
|
|
<BaseTable :data="faqs" :columns="columns" row-key="id" :loading="loading" :sort="sortInfo"
|
|
:pagination="{ current: page, total: total, pageSize: pageSize, showJumper: true }" @page-change="onPageChange" @sort-change="onSortChange">
|
|
<template #status="{ row }">
|
|
<span @click="toggleStatus(row)" style="cursor:pointer;" :style="{color: row.status === 'ENABLED' ? 'var(--td-success-color)' : 'var(--td-error-color)'}">
|
|
{{ row.status === 'ENABLED' ? '✅ 启用' : '❌ 禁用' }}
|
|
</span>
|
|
</template>
|
|
<template #op="{ row }">
|
|
<t-space :size="4">
|
|
<t-button size="small" variant="text" @click="openEditDialog(row)">编辑</t-button>
|
|
<t-button size="small" variant="text" theme="danger" @click="removeFaq(row.id)">删除</t-button>
|
|
</t-space>
|
|
</template>
|
|
</BaseTable>
|
|
|
|
<!-- 新增/编辑抽屉(富文本 Markdown 编辑器需宽幅,用抽屉替代窄弹窗) -->
|
|
<FormDrawer
|
|
v-model:visible="showFormDialog"
|
|
:title="editingFaq ? '编辑 FAQ' : '添加 FAQ'"
|
|
size="1200px"
|
|
:disable-confirm="!form.question || !form.answer"
|
|
:before-close="beforeCloseDrawer"
|
|
@confirm="saveFaq"
|
|
>
|
|
<t-form label-align="top">
|
|
<t-form-item label="问题 *"><t-input v-model="form.question" placeholder="输入标准问题" /></t-form-item>
|
|
<t-form-item label="标准答案 *"><MdEditor v-model="form.answer" :on-upload-img="handleUploadImg" style="height: 460px;" placeholder="输入标准答案(支持粘贴图片)" /></t-form-item>
|
|
<t-form-item label="相似问题(每行一个)"><t-textarea v-model="form.similarQuestionsText" :autosize="{ minRows: 3, maxRows: 5 }" placeholder="每行一个相似问法" /></t-form-item>
|
|
<t-row :gutter="16">
|
|
<t-col :span="6">
|
|
<t-form-item label="分类"><t-select v-model="form.categoryId" :options="categoryOptions2" placeholder="不分类" clearable /></t-form-item>
|
|
</t-col>
|
|
<t-col :span="6">
|
|
<t-form-item label="优先级"><t-input-number v-model="form.priority" :min="0" style="width:100%;" /></t-form-item>
|
|
</t-col>
|
|
</t-row>
|
|
</t-form>
|
|
</FormDrawer>
|
|
|
|
<!-- 批量导入弹窗 -->
|
|
<t-dialog v-model:visible="showImportDialog" header="批量导入 FAQ" width="660px" :footer="false">
|
|
<p class="desc-text">使用 JSON 格式批量导入,每条包含 question、answer、similarQuestions(可选数组)</p>
|
|
<t-textarea v-model="importJson" :autosize="{ minRows: 6, maxRows: 12 }" placeholder='[{"question":"退货流程","answer":"请先在订单页面..."}]' />
|
|
<div class="dialog-footer">
|
|
<t-button variant="outline" @click="showImportDialog = false">取消</t-button>
|
|
<t-button theme="primary" @click="doImport" :disabled="!importJson.trim()">导入</t-button>
|
|
</div>
|
|
</t-dialog>
|
|
</t-card>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, onMounted, computed } from 'vue'
|
|
import { listFaqs, createFaq, updateFaq, deleteFaq, toggleFaqStatus, batchImportFaqs, exportFaqs, getFaqStats } from '@/api/faq'
|
|
import { getCategoryTree } from '@/api/category'
|
|
import { toast } from '@/utils/toast'
|
|
import { useConfirm } from '@/composables/useConfirm'
|
|
import { useDebounce } from '@/composables/useDebounce'
|
|
import { uploadAttachment } from '@/api/upload'
|
|
import FormDrawer from '@/components/FormDrawer.vue'
|
|
import { MdEditor } from 'md-editor-v3'
|
|
import 'md-editor-v3/lib/style.css'
|
|
|
|
const { confirm } = useConfirm()
|
|
const { debounce } = useDebounce()
|
|
|
|
const faqs = ref<any[]>([])
|
|
const loading = ref(false)
|
|
const page = ref(1); const pageSize = ref(20); const total = ref(0)
|
|
const searchKeyword = ref(''); const filterCategoryId = ref(''); const filterStatus = ref('')
|
|
const stats = ref<any>(null)
|
|
const showFormDialog = ref(false); const showImportDialog = ref(false); const editingFaq = ref<any>(null)
|
|
const form = ref({ question: '', answer: '', similarQuestionsText: '', categoryId: null as any, priority: 0 })
|
|
const initialForm = ref({ question: '', answer: '', similarQuestionsText: '', categoryId: null as any, priority: 0 })
|
|
const importJson = ref('')
|
|
const flatCategories = ref<any[]>([]); const categoryMap = ref<Record<string, string>>({})
|
|
|
|
function sourceLabel(s: string): string {
|
|
const map: Record<string, string> = { manual: '手动', import: '导入', feedback_positive: '👍反馈', feedback_negative: '👎反馈' }
|
|
return map[s] || s || '-'
|
|
}
|
|
|
|
/** 去除 markdown 语法得到纯文本摘要(用于表格列展示) */
|
|
function stripMarkdown(md: string): string {
|
|
if (!md) return ''
|
|
return md
|
|
.replace(/!\[[^\]]*\]\([^)]*\)/g, '') // 图片
|
|
.replace(/\[([^\]]*)\]\([^)]*\)/g, '$1') // 链接
|
|
.replace(/[#>*`~_-]+/g, '') // 标记符号
|
|
.replace(/\n+/g, ' ')
|
|
.trim()
|
|
}
|
|
|
|
/** md-editor-v3 粘贴/拖拽图片上传回调:上传到 SFTP 后回填 URL,由编辑器自动插入  */
|
|
async function handleUploadImg(files: File[], callback: (urls: string[]) => void) {
|
|
const urls: string[] = []
|
|
for (const file of files) {
|
|
try {
|
|
const fd = new FormData()
|
|
fd.append('file', file)
|
|
const res: any = await uploadAttachment(fd)
|
|
if (res?.success && res?.data?.url) {
|
|
urls.push(res.data.url)
|
|
} else {
|
|
toast(`图片上传失败(${file.name}):` + (res?.message || '未知错误'), 'error')
|
|
}
|
|
} catch (e: any) {
|
|
toast(`图片上传失败(${file.name}):` + e.message, 'error')
|
|
}
|
|
}
|
|
callback(urls)
|
|
}
|
|
|
|
const columns = [
|
|
{ colKey: 'question', title: '问题', width: 200, ellipsis: true, sorter: true },
|
|
{ colKey: 'answer', title: '答案(摘要)', width: 200, ellipsis: true, cell: (_h: any, { row }: any) => stripMarkdown(row.answer || '') },
|
|
{ colKey: 'categoryName', title: '分类', width: 100, cell: (_h:any,{row}:any)=>categoryMap.value[row.categoryId]||'-' },
|
|
{ colKey: 'source', title: '来源', width: 70, cell: (_h:any,{row}:any)=>sourceLabel(row.source) },
|
|
{ colKey: 'priority', title: '优先级', width: 60, sorter: true },
|
|
{ colKey: 'hitCount', title: '命中', width: 55, sorter: true },
|
|
{ colKey: 'status', title: '状态', width: 75, sorter: true },
|
|
{ colKey: 'op', title: '操作', width: 120 },
|
|
]
|
|
const sortInfo = ref<{ sortBy: string; descending: boolean } | null>(null)
|
|
|
|
const statusOptions = [{label:'全部状态',value:''},{label:'启用',value:'ENABLED'},{label:'禁用',value:'DISABLED'}]
|
|
const categoryOptions = computed(()=>[{label:'全部分类',value:''},...flatCategories.value.map(c=>({label:c.path,value:String(c.id)}))])
|
|
const categoryOptions2 = computed(()=>[{label:'不分类',value:null as any},...flatCategories.value.map(c=>({label:c.path,value:String(c.id)}))])
|
|
|
|
onMounted(() => { loadCategories(); loadList(); loadStats() })
|
|
|
|
async function loadCategories() {
|
|
try { const r = await getCategoryTree(); if (r.success) { flatCategories.value = flattenTree(r.data); const m:Record<string,string>={}; flatCategories.value.forEach(c=>m[c.id]=c.path); categoryMap.value = m } } catch {/* */}
|
|
}
|
|
function flattenTree(tree: any[], prefix = ''): any[] {
|
|
const r: any[] = []
|
|
for (const n of (tree||[])) { const p = prefix?`${prefix}/${n.name}`:n.name; r.push({id:n.id,name:n.name,path:p}); if(n.children?.length) r.push(...flattenTree(n.children,p)) }
|
|
return r
|
|
}
|
|
|
|
async function loadList() { loading.value = true
|
|
try { const r = await listFaqs(page.value,pageSize.value,searchKeyword.value||undefined,filterCategoryId.value||undefined,filterStatus.value||undefined,sortInfo.value?.sortBy||undefined,sortInfo.value?(sortInfo.value.descending?'desc':'asc'):undefined)
|
|
if (r.success) { faqs.value = r.data?.records||r.data||[]; total.value = r.data?.total||r.total||0 } } catch(e:any){ toast('加载失败:'+e.message,'error') }
|
|
finally { loading.value = false } }
|
|
|
|
async function loadStats() { try { const r = await getFaqStats(); if(r.success) stats.value=r.data } catch {/* */} }
|
|
|
|
function onPageChange(info:{current:number;pageSize:number}) { page.value=info.current; pageSize.value=info.pageSize; loadList() }
|
|
function onSortChange(s:any){sortInfo.value=s&&s.sortBy?{sortBy:s.sortBy,descending:!!s.descending}:null;loadList()}
|
|
const debouncedSearch = debounce(() => { page.value = 1; loadList() })
|
|
|
|
/** 表单当前值快照,用于判断是否有未保存修改 */
|
|
function snapshotForm() {
|
|
return { question: form.value.question, answer: form.value.answer, similarQuestionsText: form.value.similarQuestionsText, categoryId: form.value.categoryId, priority: form.value.priority }
|
|
}
|
|
/** 是否有未保存的修改(与打开时快照对比) */
|
|
function isDirty() {
|
|
const a = form.value, b = initialForm.value
|
|
return a.question !== b.question || a.answer !== b.answer || a.similarQuestionsText !== b.similarQuestionsText || a.categoryId !== b.categoryId || a.priority !== b.priority
|
|
}
|
|
/** 抽屉关闭前拦截:有未保存修改时二次确认 */
|
|
async function beforeCloseDrawer(): Promise<boolean> {
|
|
if (!isDirty()) return true
|
|
return confirm('当前修改尚未保存,确定要退出吗?', '退出后未保存的内容将丢失')
|
|
}
|
|
|
|
function openAddDialog() { editingFaq.value=null; form.value={question:'',answer:'',similarQuestionsText:'',categoryId:null,priority:0}; initialForm.value=snapshotForm(); showFormDialog.value=true }
|
|
function openEditDialog(f:any) {
|
|
editingFaq.value = f
|
|
let st = ''; try { const a = typeof f.similarQuestions==='string'?JSON.parse(f.similarQuestions):f.similarQuestions; st=Array.isArray(a)?a.join('\n'):'' } catch { st=f.similarQuestions||'' }
|
|
form.value = { question:f.question, answer:f.answer, similarQuestionsText:st, categoryId:f.categoryId||null, priority:f.priority||0 }; initialForm.value=snapshotForm(); showFormDialog.value=true
|
|
}
|
|
|
|
async function saveFaq() {
|
|
const sq = form.value.similarQuestionsText.split('\n').map(s=>s.trim()).filter(Boolean)
|
|
const d:any = { question:form.value.question, answer:form.value.answer, similarQuestions:JSON.stringify(sq), categoryId:form.value.categoryId||null, priority:form.value.priority||0 }
|
|
try { let r; if(editingFaq.value) r=await updateFaq(editingFaq.value.id,d); else r=await createFaq(d)
|
|
if(r.success){ toast(editingFaq.value?'修改成功':'添加成功','success'); showFormDialog.value=false; loadList(); loadStats() } else toast(r.message||'操作失败','error')
|
|
} catch(e:any){ toast('操作失败:'+e.message,'error') }
|
|
}
|
|
|
|
async function toggleStatus(f:any) { const ns = f.status==='ENABLED'?'DISABLED':'ENABLED'
|
|
try { const r = await toggleFaqStatus(f.id,ns); if(r.success){ f.status=ns; toast(`已${ns==='ENABLED'?'启用':'禁用'}`,'success') } else toast(r.message||'操作失败','error') } catch(e:any){ toast('操作失败:'+e.message,'error') } }
|
|
|
|
async function removeFaq(id:string) { if(!await confirm('确认删除?'))return
|
|
try { const r = await deleteFaq(id); if(r.success){ toast('删除成功','success'); loadList(); loadStats() } else toast(r.message||'删除失败','error') } catch(e:any){ toast('删除失败:'+e.message,'error') } }
|
|
|
|
async function doImport() {
|
|
try { let faqs = JSON.parse(importJson.value); if(!Array.isArray(faqs)||!faqs.length) return toast('请输入有效数组','error')
|
|
for(const f of faqs){ if(Array.isArray(f.similarQuestions)) f.similarQuestions=JSON.stringify(f.similarQuestions); else if(!f.similarQuestions) f.similarQuestions='[]' }
|
|
const r = await batchImportFaqs({faqs}); if(r.success){ toast(`成功导入 ${r.data||faqs.length} 条`,'success'); showImportDialog.value=false; importJson.value=''; loadList(); loadStats() } else toast(r.message||'导入失败','error')
|
|
}catch(e:any){ if(e instanceof SyntaxError) toast('JSON 格式错误','error'); else toast('导入失败:'+e.message,'error') } }
|
|
|
|
async function doExport() {
|
|
try { const r = await exportFaqs(); if(r.success){ const b=new Blob([JSON.stringify(r.data,null,2)],{type:'application/json'}); const u=URL.createObjectURL(b); const a=document.createElement('a'); a.href=u; a.download='faq_export.json'; a.click(); URL.revokeObjectURL(u); toast('导出成功','success') } else toast(r.message||'导出失败','error')
|
|
}catch(e:any){ toast('导出失败:'+e.message,'error') } }
|
|
</script>
|
|
<style scoped>.stats-bar{display:flex;gap:8px;margin-bottom:12px;}</style>
|