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
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="每行一个相似问法 例如: 怎么退货? 退货流程是什么?" 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='[ {"question":"退货流程","answer":"请先在订单页面...","similarQuestions":["怎么退货","退货步骤"],"category":"退货政策"}, {"question":"运费谁出","answer":"7天内退货运费..."} ]' 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')
|
|
}
|
|
}
|
|
}
|
|
}
|