feat: 增加知识库 FAQ 管理页面

This commit is contained in:
zxr
2026-07-22 02:30:50 +08:00
parent 52a0346c4c
commit f2f4390ac2
4 changed files with 1309 additions and 0 deletions

154
src/api/kb/faq.ts Normal file
View File

@@ -0,0 +1,154 @@
import { request } from '@/api/request'
export type FaqStatus = 'draft' | 'published' | 'reviewed' | 'rejected'
export type FaqPriority = 'low' | 'medium' | 'high'
export type FaqScope = 'my' | 'all'
export interface KbReply<T> {
code: number
message: string
details: T
timeseq: number
}
export interface Faq {
id: number
created_at: string
updated_at: string
faq_no: string
question: string
answer: string
status: FaqStatus
priority: FaqPriority
category_id: number
sub_category: string
problem_type: string
solution: string
process_steps: string
prerequisites: string
author_id: number
author_name: string
reviewer_id: number
reviewer_name: string
reviewed_at: string | null
published_at: string | null
view_count: number
use_count: number
helpful_count: number
useless_count: number
tags: string
related_faqs: string
related_docs: string
related_links: string
detection_point_ids: string
attachments: string
keywords: string
applicable_scope: string
remarks: string
is_favorited: boolean
}
export interface FaqPage {
total: number
page: number
page_size: number
data: Faq[]
}
export interface FaqListParams {
page?: number
page_size?: number
scope?: FaqScope
keyword?: string
category_id?: number
status?: FaqStatus
priority?: FaqPriority
problem_type?: string
}
export interface FaqFormData {
question: string
answer: string
priority: FaqPriority
category_id: number
sub_category: string
problem_type: string
solution: string
process_steps: string
prerequisites: string
keywords: string
tags: string
detection_point_ids: string
applicable_scope: string
remarks: string
}
export interface UpdateFaqData extends FaqFormData {
id: number
}
export interface CategoryTreeNode {
id: number
name: string
description: string
type: 'general'
icon: string
color: string
parent_id: number
level: number
path: string
sort_order: number
status: 'active' | 'inactive'
doc_count: number
faq_count: number
children: CategoryTreeNode[]
}
export const faqStatusOptions: Array<{ label: string; value: FaqStatus }> = [
{ label: '草稿', value: 'draft' },
{ label: '待审核', value: 'published' },
{ label: '已审核', value: 'reviewed' },
{ label: '已拒绝', value: 'rejected' },
]
export const faqPriorityOptions: Array<{ label: string; value: FaqPriority }> = [
{ label: '低', value: 'low' },
{ label: '中', value: 'medium' },
{ label: '高', value: 'high' },
]
export const faqProblemTypeOptions = [
{ label: '故障', value: '故障' },
{ label: '咨询', value: '咨询' },
{ label: '请求', value: '请求' },
{ label: '其他', value: '其他' },
]
export const fetchFaqList = (params: FaqListParams) =>
request.get<KbReply<FaqPage>>('/Kb/v1/faq/list', {
params,
})
export const fetchFaqDetail = (id: number) => request.get<KbReply<Faq>>(`/Kb/v1/faq/${id}`)
export const createFaq = (data: FaqFormData) => request.post<KbReply<Faq>>('/Kb/v1/faq/create', data)
export const updateFaq = (data: UpdateFaqData) => request.post<KbReply<Faq>>('/Kb/v1/faq/update', data)
export const publishFaq = (id: number) => request.post<KbReply<string>>('/Kb/v1/faq/publish', { id })
export const deleteFaq = (id: number) => request.delete<KbReply<string>>(`/Kb/v1/faq/${id}`)
export const favoriteFaq = (id: number) =>
request.post<KbReply<unknown>>('/Kb/v1/favorite/collect', {
resource_type: 'faq',
resource_id: id,
})
export const unfavoriteFaq = (id: number) =>
request.post<KbReply<string>>('/Kb/v1/favorite/uncollect', {
resource_type: 'faq',
resource_id: id,
})
export const fetchGeneralCategoryTree = () => request.get<KbReply<CategoryTreeNode[]>>('/Kb/v1/category/tree')

View File

@@ -0,0 +1,229 @@
<template>
<a-drawer
:visible="visible"
:width="820"
title="FAQ 详情"
:footer="false"
unmount-on-close
@cancel="close"
@update:visible="handleVisibleChange"
>
<a-spin :loading="loading" style="width: 100%">
<template v-if="faq">
<div class="detail-header">
<div>
<div class="faq-no">{{ faq.faq_no || `FAQ-${faq.id}` }}</div>
<h2>{{ faq.question }}</h2>
<a-space>
<a-tag :color="statusColor(faq.status)">{{ statusText(faq.status) }}</a-tag>
<a-tag :color="priorityColor(faq.priority)">{{ priorityText(faq.priority) }}</a-tag>
</a-space>
</div>
<a-space wrap>
<a-button :loading="favoriteLoading" @click="emit('favorite', faq)">
<template #icon>
<icon-star-fill v-if="faq.is_favorited" />
<icon-star v-else />
</template>
{{ faq.is_favorited ? '取消收藏' : '收藏' }}
</a-button>
<a-button v-if="canEdit" type="primary" @click="emit('edit', faq)">编辑</a-button>
<a-button v-if="canSubmit" :loading="actionLoading" @click="emit('submit', faq)">提交审核</a-button>
<a-button v-if="canDelete" status="danger" :loading="actionLoading" @click="emit('delete', faq)">删除</a-button>
</a-space>
</div>
<a-alert v-if="faq.status === 'rejected'" type="error" class="reject-alert">
<template #title>审核未通过</template>
{{ rejectionReason(faq.remarks) }}
</a-alert>
<section>
<h3>基本信息</h3>
<a-descriptions :column="2" bordered>
<a-descriptions-item label="公共分类">{{ categoryName || '-' }}</a-descriptions-item>
<a-descriptions-item label="问题类型">{{ problemTypeText(faq.problem_type) }}</a-descriptions-item>
<a-descriptions-item label="作者">{{ faq.author_name || `用户 ${faq.author_id}` }}</a-descriptions-item>
<a-descriptions-item label="更新时间">{{ formatTime(faq.updated_at) }}</a-descriptions-item>
<a-descriptions-item label="审核人">{{ faq.reviewer_name || '-' }}</a-descriptions-item>
<a-descriptions-item label="审核时间">{{ formatTime(faq.reviewed_at) }}</a-descriptions-item>
<a-descriptions-item label="提交时间">{{ formatTime(faq.published_at) }}</a-descriptions-item>
<a-descriptions-item label="创建时间">{{ formatTime(faq.created_at) }}</a-descriptions-item>
</a-descriptions>
</section>
<section>
<h3>处理方案</h3>
<a-descriptions :column="1" bordered>
<a-descriptions-item label="答案">
<div class="pre-wrap">{{ faq.answer || '-' }}</div>
</a-descriptions-item>
<a-descriptions-item label="详细解决方案">
<div class="pre-wrap">{{ faq.solution || '-' }}</div>
</a-descriptions-item>
<a-descriptions-item label="处理步骤">
<div class="pre-wrap">{{ faq.process_steps || '-' }}</div>
</a-descriptions-item>
<a-descriptions-item label="前置条件">
<div class="pre-wrap">{{ faq.prerequisites || '-' }}</div>
</a-descriptions-item>
</a-descriptions>
</section>
<section>
<h3>使用范围与关联</h3>
<a-descriptions :column="2" bordered>
<a-descriptions-item label="适用范围" :span="2">{{ faq.applicable_scope || '-' }}</a-descriptions-item>
<a-descriptions-item label="关键词" :span="2">{{ faq.keywords || '-' }}</a-descriptions-item>
<a-descriptions-item label="标签" :span="2">{{ faq.tags || '-' }}</a-descriptions-item>
<a-descriptions-item label="关联 FAQ">{{ faq.related_faqs || '-' }}</a-descriptions-item>
<a-descriptions-item label="关联文档">{{ faq.related_docs || '-' }}</a-descriptions-item>
<a-descriptions-item label="相关链接" :span="2">{{ faq.related_links || '-' }}</a-descriptions-item>
<a-descriptions-item label="检测点">{{ faq.detection_point_ids || '-' }}</a-descriptions-item>
<a-descriptions-item label="附件">{{ faq.attachments || '-' }}</a-descriptions-item>
<a-descriptions-item label="备注" :span="2">
<div class="pre-wrap">{{ faq.remarks || '-' }}</div>
</a-descriptions-item>
</a-descriptions>
</section>
<section>
<h3>使用情况</h3>
<a-descriptions :column="4" bordered>
<a-descriptions-item label="浏览">{{ faq.view_count || 0 }}</a-descriptions-item>
<a-descriptions-item label="使用">{{ faq.use_count || 0 }}</a-descriptions-item>
<a-descriptions-item label="有帮助">{{ faq.helpful_count || 0 }}</a-descriptions-item>
<a-descriptions-item label="无帮助">{{ faq.useless_count || 0 }}</a-descriptions-item>
</a-descriptions>
</section>
</template>
<a-empty v-else-if="!loading" description="未找到 FAQ" />
</a-spin>
</a-drawer>
</template>
<script lang="ts" setup>
import dayjs from 'dayjs'
import { IconStar, IconStarFill } from '@arco-design/web-vue/es/icon'
import type { Faq, FaqPriority, FaqStatus } from '@/api/kb/faq'
interface Props {
visible: boolean
faq: Faq | null
categoryName?: string
loading?: boolean
favoriteLoading?: boolean
actionLoading?: boolean
canEdit?: boolean
canSubmit?: boolean
canDelete?: boolean
}
withDefaults(defineProps<Props>(), {
categoryName: '',
loading: false,
favoriteLoading: false,
actionLoading: false,
canEdit: false,
canSubmit: false,
canDelete: false,
})
const emit = defineEmits<{
(event: 'update:visible', visible: boolean): void
(event: 'edit', faq: Faq): void
(event: 'submit', faq: Faq): void
(event: 'favorite', faq: Faq): void
(event: 'delete', faq: Faq): void
}>()
function close() {
emit('update:visible', false)
}
function handleVisibleChange(visible: boolean) {
if (!visible) close()
}
function formatTime(value: string | null | undefined) {
return value ? dayjs(value).format('YYYY-MM-DD HH:mm') : '-'
}
function statusText(status: FaqStatus) {
return {
draft: '草稿',
published: '待审核',
reviewed: '已审核',
rejected: '已拒绝',
}[status]
}
function statusColor(status: FaqStatus) {
return {
draft: 'gray',
published: 'orange',
reviewed: 'green',
rejected: 'red',
}[status]
}
function priorityText(priority: FaqPriority) {
return { low: '低', medium: '中', high: '高' }[priority] || priority || '-'
}
function priorityColor(priority: FaqPriority) {
return { low: 'gray', medium: 'blue', high: 'orange' }[priority] || 'gray'
}
function problemTypeText(type: string) {
return { fault: '故障', consultation: '咨询', request: '请求', other: '其他' }[type] || type || '-'
}
function rejectionReason(remarks: string) {
const matches = [...remarks.matchAll(/\[审核拒绝 [^\]]+\]\s*([^\n]+)/g)]
return matches.length ? matches[matches.length - 1][1] : remarks || '未填写原因'
}
</script>
<style scoped lang="less">
.detail-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 24px;
margin-bottom: 20px;
h2 {
margin: 4px 0 12px;
color: var(--color-text-1);
font-size: 20px;
line-height: 1.5;
}
}
.faq-no {
color: var(--color-text-3);
font-size: 12px;
}
.reject-alert {
margin-bottom: 20px;
}
section {
& + & {
margin-top: 24px;
}
h3 {
margin: 0 0 12px;
color: var(--color-text-1);
font-size: 15px;
}
}
.pre-wrap {
white-space: pre-wrap;
word-break: break-word;
}
</style>

View File

@@ -0,0 +1,252 @@
<template>
<a-drawer
:visible="visible"
:width="760"
:title="faq ? '编辑 FAQ' : '新建 FAQ'"
:ok-loading="submitting"
ok-text="保存"
unmount-on-close
@ok="handleSubmit"
@cancel="close"
@update:visible="handleVisibleChange"
>
<a-form ref="formRef" :model="form" layout="vertical">
<section class="form-section">
<h3>基本信息</h3>
<a-form-item label="问题" field="question" :rules="[{ required: true, message: '请输入问题' }]">
<a-textarea
v-model="form.question"
placeholder="请输入常见问题"
:auto-size="{ minRows: 2, maxRows: 5 }"
:max-length="1000"
show-word-limit
/>
</a-form-item>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="公共分类" field="category_id" :rules="[{ required: true, message: '请选择公共分类' }]">
<a-select v-model="form.category_id" placeholder="请选择公共分类" allow-search>
<a-option v-for="category in categories" :key="category.id" :value="category.id">
{{ category.label }}
</a-option>
</a-select>
</a-form-item>
</a-col>
<a-col :span="6">
<a-form-item label="优先级" field="priority">
<a-select v-model="form.priority">
<a-option v-for="option in faqPriorityOptions" :key="option.value" :value="option.value">
{{ option.label }}
</a-option>
</a-select>
</a-form-item>
</a-col>
<a-col :span="6">
<a-form-item label="问题类型" field="problem_type">
<a-select v-model="form.problem_type" placeholder="请选择或输入" allow-clear allow-create>
<a-option v-for="option in faqProblemTypeOptions" :key="option.value" :value="option.value">
{{ option.label }}
</a-option>
</a-select>
</a-form-item>
</a-col>
</a-row>
</section>
<section class="form-section">
<h3>处理方案</h3>
<a-form-item label="答案" field="answer" :rules="[{ required: true, message: '请输入答案' }]">
<a-textarea v-model="form.answer" placeholder="请输入答案或处理方式" :auto-size="{ minRows: 4, maxRows: 10 }" />
</a-form-item>
<a-form-item label="详细解决方案" field="solution">
<a-textarea v-model="form.solution" placeholder="补充完整的解决方案" :auto-size="{ minRows: 3, maxRows: 8 }" />
</a-form-item>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="处理步骤" field="process_steps">
<a-textarea v-model="form.process_steps" placeholder="请输入处理步骤" :auto-size="{ minRows: 3, maxRows: 8 }" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="前置条件" field="prerequisites">
<a-textarea v-model="form.prerequisites" placeholder="请输入前置条件" :auto-size="{ minRows: 3, maxRows: 8 }" />
</a-form-item>
</a-col>
</a-row>
</section>
<section class="form-section">
<h3>使用范围</h3>
<a-form-item label="适用范围" field="applicable_scope">
<a-textarea
v-model="form.applicable_scope"
placeholder="说明适用的系统、角色或业务场景"
:auto-size="{ minRows: 2, maxRows: 5 }"
:max-length="200"
show-word-limit
/>
</a-form-item>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="关键词" field="keywords">
<a-input v-model="form.keywords" placeholder="多个关键词用逗号分隔" :max-length="500" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="标签" field="tags">
<a-input v-model="form.tags" placeholder="支持逗号分隔或 JSON 数组" />
</a-form-item>
</a-col>
</a-row>
<a-form-item label="检测点 ID" field="detection_point_ids">
<a-input v-model="form.detection_point_ids" placeholder="支持逗号分隔或 JSON 数组" />
</a-form-item>
<a-form-item label="备注" field="remarks">
<a-textarea v-model="form.remarks" placeholder="请输入备注" :auto-size="{ minRows: 2, maxRows: 5 }" />
</a-form-item>
</section>
</a-form>
</a-drawer>
</template>
<script lang="ts" setup>
import { reactive, ref, watch } from 'vue'
import { Message, type FormInstance } from '@arco-design/web-vue'
import { faqPriorityOptions, faqProblemTypeOptions, type Faq, type FaqFormData } from '@/api/kb/faq'
export interface FaqCategoryOption {
id: number
name: string
label: string
parentId: number
level: number
path: string
}
interface Props {
visible: boolean
faq: Faq | null
categories: FaqCategoryOption[]
submitting?: boolean
}
const props = withDefaults(defineProps<Props>(), {
submitting: false,
})
const emit = defineEmits<{
(event: 'update:visible', visible: boolean): void
(event: 'submit', values: FaqFormData): void
}>()
const formRef = ref<FormInstance>()
type FaqEditorForm = Omit<FaqFormData, 'category_id'> & { category_id?: number }
const form = reactive<FaqEditorForm>(createEmptyForm())
function createEmptyForm(): FaqEditorForm {
return {
question: '',
answer: '',
priority: 'medium',
category_id: undefined,
sub_category: '',
problem_type: '',
solution: '',
process_steps: '',
prerequisites: '',
keywords: '',
tags: '',
detection_point_ids: '',
applicable_scope: '',
remarks: '',
}
}
function resetForm() {
const faq = props.faq
Object.assign(
form,
faq
? {
question: faq.question,
answer: faq.answer,
priority: faq.priority || 'medium',
category_id: faq.category_id,
sub_category: faq.sub_category || '',
problem_type: faq.problem_type || '',
solution: faq.solution || '',
process_steps: faq.process_steps || '',
prerequisites: faq.prerequisites || '',
keywords: faq.keywords || '',
tags: faq.tags || '',
detection_point_ids: faq.detection_point_ids || '',
applicable_scope: faq.applicable_scope || '',
remarks: faq.remarks || '',
}
: createEmptyForm()
)
formRef.value?.clearValidate()
}
watch(
() => props.visible,
(visible) => {
if (visible) resetForm()
}
)
async function handleSubmit() {
if (props.submitting) return
const errors = await formRef.value?.validate()
if (errors) return
const question = form.question.trim()
const answer = form.answer.trim()
const categoryId = form.category_id
if (!question || !answer || !categoryId) {
Message.warning('问题、答案和公共分类为必填项')
return
}
emit('submit', {
...form,
question,
answer,
category_id: categoryId,
problem_type: form.problem_type.trim(),
solution: form.solution.trim(),
process_steps: form.process_steps.trim(),
prerequisites: form.prerequisites.trim(),
keywords: form.keywords.trim(),
tags: form.tags.trim(),
detection_point_ids: form.detection_point_ids.trim(),
applicable_scope: form.applicable_scope.trim(),
remarks: form.remarks.trim(),
})
}
function close() {
if (!props.submitting) emit('update:visible', false)
}
function handleVisibleChange(visible: boolean) {
if (!visible) close()
}
</script>
<style scoped lang="less">
.form-section {
& + & {
margin-top: 28px;
padding-top: 20px;
border-top: 1px solid var(--color-neutral-3);
}
h3 {
margin: 0 0 16px;
color: var(--color-text-1);
font-size: 16px;
}
}
</style>

View File

@@ -0,0 +1,674 @@
<template>
<div class="faq-page">
<Breadcrumb :items="['知识管理', 'FAQ 管理']" />
<a-card class="general-card" :bordered="false">
<div class="page-header">
<a-tabs v-model:active-key="activeScope" @change="handleScopeChange">
<a-tab-pane key="my" title="我的 FAQ" />
<a-tab-pane key="all" title="全部 FAQ" />
</a-tabs>
<a-space>
<a-button v-if="canCreate" type="primary" @click="openCreate">
<template #icon><icon-plus /></template>
新建 FAQ
</a-button>
<a-button :loading="loading" @click="fetchData">
<template #icon><icon-refresh /></template>
刷新
</a-button>
</a-space>
</div>
<a-form :model="filters" layout="inline" class="filter-form" @submit-success="handleSearch">
<a-form-item label="关键词" field="keyword">
<a-input v-model="filters.keyword" placeholder="搜索问题、答案或方案" allow-clear />
</a-form-item>
<a-form-item label="公共分类" field="category_id">
<a-select
v-model="filters.category_id"
placeholder="全部分类"
allow-clear
allow-search
:loading="categoryLoading"
style="width: 220px"
>
<a-option v-for="category in categoryOptions" :key="category.id" :value="category.id">
{{ category.label }}
</a-option>
</a-select>
</a-form-item>
<a-form-item label="状态" field="status">
<a-select v-model="filters.status" placeholder="全部状态" allow-clear style="width: 120px">
<a-option v-for="option in visibleStatusOptions" :key="option.value" :value="option.value">
{{ option.label }}
</a-option>
</a-select>
</a-form-item>
<a-form-item label="优先级" field="priority">
<a-select v-model="filters.priority" placeholder="全部优先级" allow-clear style="width: 120px">
<a-option v-for="option in faqPriorityOptions" :key="option.value" :value="option.value">
{{ option.label }}
</a-option>
</a-select>
</a-form-item>
<a-form-item label="问题类型" field="problem_type">
<a-select v-model="filters.problem_type" placeholder="全部类型" allow-clear style="width: 130px">
<a-option v-for="option in faqProblemTypeOptions" :key="option.value" :value="option.value">
{{ option.label }}
</a-option>
</a-select>
</a-form-item>
<a-form-item>
<a-space>
<a-button type="primary" html-type="submit">查询</a-button>
<a-button @click="handleReset">重置</a-button>
</a-space>
</a-form-item>
</a-form>
<a-table
row-key="id"
:data="tableData"
:columns="columns"
:loading="loading"
:pagination="pagination"
:scroll="{ x: 1380 }"
@page-change="handlePageChange"
@page-size-change="handlePageSizeChange"
>
<template #question="{ record }">
<a-button type="text" class="question-button" @click="openDetail(record)">
{{ record.question }}
</a-button>
</template>
<template #category="{ record }">
{{ categoryName(record.category_id) }}
</template>
<template #priority="{ record }">
<a-tag :color="priorityColor(record.priority)">{{ priorityText(record.priority) }}</a-tag>
</template>
<template #status="{ record }">
<a-tag :color="statusColor(record.status)">{{ statusText(record.status) }}</a-tag>
</template>
<template #author="{ record }">
{{ record.author_name || `用户 ${record.author_id}` }}
</template>
<template #updated_at="{ record }">
{{ formatTime(record.updated_at) }}
</template>
<template #actions="{ record }">
<a-space :size="4">
<a-button type="text" size="small" @click="openDetail(record)">查看</a-button>
<a-button type="text" size="small" :loading="isActionLoading(record.id, 'favorite')" @click="toggleFavorite(record)">
<template #icon>
<icon-star-fill v-if="record.is_favorited" />
<icon-star v-else />
</template>
{{ record.is_favorited ? '取消收藏' : '收藏' }}
</a-button>
<a-button v-if="canEdit(record)" type="text" size="small" @click="openEdit(record)">编辑</a-button>
<a-button
v-if="canSubmit(record)"
type="text"
size="small"
:loading="isActionLoading(record.id, 'publish')"
@click="confirmPublish(record)"
>
提交审核
</a-button>
<a-button
v-if="canDelete(record)"
type="text"
size="small"
status="danger"
:loading="isActionLoading(record.id, 'delete')"
@click="confirmDelete(record)"
>
删除
</a-button>
</a-space>
</template>
<template #empty>
<a-empty :description="activeScope === 'my' ? '暂无我的 FAQ' : '暂无已审核 FAQ'" />
</template>
</a-table>
</a-card>
<FaqFormDrawer
v-model:visible="formVisible"
:faq="editingFaq"
:categories="categoryOptions"
:submitting="formSubmitting"
@submit="saveFaq"
/>
<FaqDetailDrawer
:visible="detailVisible"
:faq="detailFaq"
:category-name="detailFaq ? categoryName(detailFaq.category_id) : ''"
:loading="detailLoading"
:favorite-loading="Boolean(detailFaq && isActionLoading(detailFaq.id, 'favorite'))"
:action-loading="Boolean(detailFaq && (isActionLoading(detailFaq.id, 'publish') || isActionLoading(detailFaq.id, 'delete')))"
:can-edit="Boolean(detailFaq && canEdit(detailFaq))"
:can-submit="Boolean(detailFaq && canSubmit(detailFaq))"
:can-delete="Boolean(detailFaq && canDelete(detailFaq))"
@update:visible="handleDetailVisible"
@edit="openEditFromDetail"
@submit="confirmPublish"
@favorite="toggleFavorite"
@delete="confirmDelete"
/>
</div>
</template>
<script lang="ts" setup>
import { computed, onMounted, reactive, ref } from 'vue'
import axios from 'axios'
import dayjs from 'dayjs'
import { Message, Modal } from '@arco-design/web-vue'
import type { TableColumnData } from '@arco-design/web-vue/es/table/interface'
import { IconPlus, IconRefresh, IconStar, IconStarFill } from '@arco-design/web-vue/es/icon'
import usePermissionCodes from '@/hooks/usePermissionCodes'
import { useUserStore } from '@/store'
import SafeStorage, { AppStorageKey } from '@/utils/safeStorage'
import {
createFaq,
deleteFaq,
faqPriorityOptions,
faqProblemTypeOptions,
faqStatusOptions,
favoriteFaq,
fetchFaqDetail,
fetchFaqList,
fetchGeneralCategoryTree,
publishFaq,
unfavoriteFaq,
updateFaq,
type CategoryTreeNode,
type Faq,
type FaqFormData,
type FaqPriority,
type FaqScope,
type FaqStatus,
type KbReply,
} from '@/api/kb/faq'
import FaqFormDrawer, { type FaqCategoryOption } from './components/FaqFormDrawer.vue'
import FaqDetailDrawer from './components/FaqDetailDrawer.vue'
type ActionType = 'favorite' | 'publish' | 'delete'
const columns: TableColumnData[] = [
{ title: '问题', dataIndex: 'question', slotName: 'question', width: 320, ellipsis: true },
{ title: '公共分类', dataIndex: 'category_id', slotName: 'category', width: 200, ellipsis: true },
{ title: '优先级', dataIndex: 'priority', slotName: 'priority', width: 90, align: 'center' },
{ title: '状态', dataIndex: 'status', slotName: 'status', width: 100, align: 'center' },
{ title: '作者', dataIndex: 'author_name', slotName: 'author', width: 130, ellipsis: true },
{ title: '更新时间', dataIndex: 'updated_at', slotName: 'updated_at', width: 170, align: 'center' },
{ title: '操作', slotName: 'actions', width: 370, fixed: 'right' },
]
const userStore = useUserStore()
const { hasPermission } = usePermissionCodes()
const canCreate = computed(() => hasPermission('kb:content:create'))
const canManage = computed(() => hasPermission('kb:content:manage'))
const currentUserId = computed(() => {
let payload = userStore.$state.userInfo as Record<string, unknown> | null | undefined
if (!payload || typeof payload !== 'object') {
payload = SafeStorage.get<Record<string, unknown>>(AppStorageKey.USER_INFO)
}
return numericId(payload?.user_id ?? payload?.id)
})
const activeScope = ref<FaqScope>('my')
const filters = reactive<{
keyword: string
category_id?: number
status?: FaqStatus
priority?: FaqPriority
problem_type: string
}>({
keyword: '',
category_id: undefined,
status: undefined,
priority: undefined,
problem_type: '',
})
const visibleStatusOptions = computed(() =>
activeScope.value === 'all' ? faqStatusOptions.filter((option) => option.value === 'reviewed') : faqStatusOptions
)
const tableData = ref<Faq[]>([])
const loading = ref(false)
const pagination = reactive({
current: 1,
pageSize: 20,
total: 0,
showTotal: true,
showPageSize: true,
})
let listRequestSequence = 0
const categoryLoading = ref(false)
const categoryOptions = ref<FaqCategoryOption[]>([])
const categoryLabels = ref(new Map<number, string>())
const formVisible = ref(false)
const formSubmitting = ref(false)
const editingFaq = ref<Faq | null>(null)
const detailVisible = ref(false)
const detailLoading = ref(false)
const detailFaq = ref<Faq | null>(null)
let detailRequestSequence = 0
const actionState = reactive<{ id: number; type: ActionType | '' }>({ id: 0, type: '' })
function numericId(value: unknown) {
if (value === null || value === undefined || value === '') return 0
const id = Number(value)
return Number.isFinite(id) ? id : 0
}
function canMutate(faq: Faq) {
if (faq.status === 'published') return false
if (faq.status === 'reviewed') return canManage.value
if (faq.status !== 'draft' && faq.status !== 'rejected') return false
return canManage.value || (canCreate.value && currentUserId.value > 0 && faq.author_id === currentUserId.value)
}
function canEdit(faq: Faq) {
return canMutate(faq)
}
function canSubmit(faq: Faq) {
return (faq.status === 'draft' || faq.status === 'rejected') && canMutate(faq)
}
function canDelete(faq: Faq) {
return canMutate(faq)
}
async function fetchData() {
const sequence = ++listRequestSequence
loading.value = true
try {
const reply = await fetchFaqList({
page: pagination.current,
page_size: pagination.pageSize,
scope: activeScope.value,
keyword: filters.keyword.trim() || undefined,
category_id: filters.category_id,
status: filters.status,
priority: filters.priority,
problem_type: filters.problem_type || undefined,
})
if (sequence !== listRequestSequence) return
if (reply.code !== 0) {
tableData.value = []
pagination.total = 0
Message.error(reply.message || '获取 FAQ 列表失败')
return
}
tableData.value = reply.details?.data || []
pagination.total = reply.details?.total || 0
} catch (error) {
if (sequence !== listRequestSequence) return
tableData.value = []
pagination.total = 0
showRequestError(error, '获取 FAQ 列表失败')
} finally {
if (sequence === listRequestSequence) loading.value = false
}
}
async function loadCategories() {
categoryLoading.value = true
try {
const reply = await fetchGeneralCategoryTree()
if (reply.code !== 0) {
Message.error(reply.message || '获取公共分类失败')
return
}
const labels = new Map<number, string>()
const active: FaqCategoryOption[] = []
flattenCategories(reply.details || [], [], labels, active)
categoryLabels.value = labels
categoryOptions.value = active
} catch (error) {
showRequestError(error, '获取公共分类失败')
} finally {
categoryLoading.value = false
}
}
function flattenCategories(nodes: CategoryTreeNode[], parentNames: string[], labels: Map<number, string>, active: FaqCategoryOption[]) {
nodes.forEach((node) => {
const names = [...parentNames, node.name]
const label = names.join(' / ')
labels.set(node.id, label)
if (node.status === 'active') {
active.push({
id: node.id,
name: node.name,
label,
parentId: node.parent_id,
level: node.level,
path: node.path,
})
}
flattenCategories(node.children || [], names, labels, active)
})
}
function categoryName(categoryId: number) {
return categoryLabels.value.get(categoryId) || '-'
}
function handleScopeChange() {
pagination.current = 1
filters.status = undefined
handleDetailVisible(false)
fetchData()
}
function handleSearch() {
pagination.current = 1
fetchData()
}
function handleReset() {
filters.keyword = ''
filters.category_id = undefined
filters.status = undefined
filters.priority = undefined
filters.problem_type = ''
pagination.current = 1
fetchData()
}
function handlePageChange(page: number) {
pagination.current = page
fetchData()
}
function handlePageSizeChange(pageSize: number) {
pagination.current = 1
pagination.pageSize = pageSize
fetchData()
}
function openCreate() {
if (!canCreate.value) {
Message.error('无操作权限')
return
}
editingFaq.value = null
formVisible.value = true
}
function openEdit(faq: Faq) {
if (!canEdit(faq)) {
Message.error('无操作权限')
return
}
editingFaq.value = { ...faq }
formVisible.value = true
}
function openEditFromDetail(faq: Faq) {
handleDetailVisible(false)
openEdit(faq)
}
async function saveFaq(values: FaqFormData) {
const target = editingFaq.value
if ((!target && !canCreate.value) || (target && !canEdit(target))) {
Message.error('无操作权限')
return
}
formSubmitting.value = true
try {
const reply = target ? await updateFaq({ id: target.id, ...values }) : await createFaq(values)
if (!showReplyResult(reply, target ? '保存 FAQ 失败' : '创建 FAQ 失败')) return
Message.success(target ? 'FAQ 已保存' : 'FAQ 已创建')
formVisible.value = false
editingFaq.value = null
if (!target) activeScope.value = 'my'
pagination.current = 1
await fetchData()
} catch (error) {
showRequestError(error, target ? '保存 FAQ 失败' : '创建 FAQ 失败')
} finally {
formSubmitting.value = false
}
}
async function openDetail(faq: Faq) {
const sequence = ++detailRequestSequence
detailVisible.value = true
detailLoading.value = true
detailFaq.value = null
try {
const reply = await fetchFaqDetail(faq.id)
if (sequence !== detailRequestSequence) return
if (!showReplyResult(reply, '获取 FAQ 详情失败')) return
detailFaq.value = reply.details
updateVisibleFaq(reply.details)
} catch (error) {
if (sequence !== detailRequestSequence) return
showRequestError(error, '获取 FAQ 详情失败')
} finally {
if (sequence === detailRequestSequence) detailLoading.value = false
}
}
function handleDetailVisible(visible: boolean) {
detailVisible.value = visible
if (!visible) {
detailRequestSequence += 1
detailLoading.value = false
detailFaq.value = null
}
}
function confirmPublish(faq: Faq) {
if (!canSubmit(faq)) {
Message.error('无操作权限')
return
}
Modal.confirm({
title: '提交审核',
content: `确认提交 FAQ「${faq.question}」进行审核吗?提交后将暂时不能修改。`,
okText: '提交审核',
onOk: () => submitForReview(faq),
})
}
async function submitForReview(faq: Faq) {
if (!beginAction(faq.id, 'publish')) return false
try {
const reply = await publishFaq(faq.id)
if (!showReplyResult(reply, '提交审核失败')) return false
Message.success(reply.details || '已提交审核')
handleDetailVisible(false)
await fetchData()
return true
} catch (error) {
showRequestError(error, '提交审核失败')
return false
} finally {
endAction(faq.id, 'publish')
}
}
function confirmDelete(faq: Faq) {
if (!canDelete(faq)) {
Message.error('无操作权限')
return
}
Modal.confirm({
title: '删除 FAQ',
content: `确认删除 FAQ「${faq.question}」吗?删除后将移入回收站。`,
okText: '删除',
okButtonProps: { status: 'danger' },
onOk: () => removeFaq(faq),
})
}
async function removeFaq(faq: Faq) {
if (!beginAction(faq.id, 'delete')) return false
try {
const reply = await deleteFaq(faq.id)
if (!showReplyResult(reply, '删除 FAQ 失败')) return false
Message.success('FAQ 已移入回收站')
if (detailFaq.value?.id === faq.id) handleDetailVisible(false)
if (tableData.value.length === 1 && pagination.current > 1) pagination.current -= 1
await fetchData()
return true
} catch (error) {
showRequestError(error, '删除 FAQ 失败')
return false
} finally {
endAction(faq.id, 'delete')
}
}
async function toggleFavorite(faq: Faq) {
if (!beginAction(faq.id, 'favorite')) return
const nextFavorited = !faq.is_favorited
try {
const reply = nextFavorited ? await favoriteFaq(faq.id) : await unfavoriteFaq(faq.id)
if (!showReplyResult(reply, nextFavorited ? '收藏失败' : '取消收藏失败')) return
updateFavoriteState(faq.id, nextFavorited)
Message.success(nextFavorited ? '已收藏' : '已取消收藏')
} catch (error) {
showRequestError(error, nextFavorited ? '收藏失败' : '取消收藏失败')
} finally {
endAction(faq.id, 'favorite')
}
}
function updateFavoriteState(id: number, isFavorited: boolean) {
const row = tableData.value.find((item) => item.id === id)
if (row) row.is_favorited = isFavorited
if (detailFaq.value?.id === id) detailFaq.value.is_favorited = isFavorited
}
function updateVisibleFaq(faq: Faq) {
const index = tableData.value.findIndex((item) => item.id === faq.id)
if (index >= 0) tableData.value[index] = { ...tableData.value[index], ...faq }
}
function beginAction(id: number, type: ActionType) {
if (actionState.type) return false
actionState.id = id
actionState.type = type
return true
}
function endAction(id: number, type: ActionType) {
if (actionState.id === id && actionState.type === type) {
actionState.id = 0
actionState.type = ''
}
}
function isActionLoading(id: number, type: ActionType) {
return actionState.id === id && actionState.type === type
}
function showReplyResult(reply: KbReply<unknown>, fallback: string) {
if (reply.code === 0) return true
Message.error(reply.message || fallback)
return false
}
function showRequestError(error: unknown, fallback: string) {
if (!axios.isAxiosError(error)) {
Message.error(fallback)
return
}
const status = error.response?.status
const data = error.response?.data
const message = isReplyLike(data) ? data.message : ''
if (status === 403) {
Message.error('无操作权限')
return
}
if (status === 409) {
Message.error(message || fallback)
return
}
Message.error(message || fallback)
}
function isReplyLike(value: unknown): value is Partial<KbReply<unknown>> {
return Boolean(value && typeof value === 'object' && 'message' in value)
}
function formatTime(value: string) {
return value ? dayjs(value).format('YYYY-MM-DD HH:mm') : '-'
}
function statusText(status: FaqStatus) {
return { draft: '草稿', published: '待审核', reviewed: '已审核', rejected: '已拒绝' }[status]
}
function statusColor(status: FaqStatus) {
return { draft: 'gray', published: 'orange', reviewed: 'green', rejected: 'red' }[status]
}
function priorityText(priority: FaqPriority) {
return { low: '低', medium: '中', high: '高' }[priority] || priority || '-'
}
function priorityColor(priority: FaqPriority) {
return { low: 'gray', medium: 'blue', high: 'orange' }[priority] || 'gray'
}
onMounted(() => {
loadCategories()
fetchData()
})
</script>
<script lang="ts">
export default {
name: 'KbFaqManage',
}
</script>
<style scoped lang="less">
.faq-page {
padding: 0 20px 20px;
}
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
:deep(.arco-tabs) {
flex: 1;
}
}
.filter-form {
margin: 8px 0 20px;
padding: 16px 16px 0;
background: var(--color-fill-1);
border-radius: 4px;
}
.question-button {
display: block;
max-width: 100%;
overflow: hidden;
text-align: left;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>