feat: 增加知识库 FAQ 管理页面
This commit is contained in:
154
src/api/kb/faq.ts
Normal file
154
src/api/kb/faq.ts
Normal 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')
|
||||||
229
src/views/ops/pages/kb/faq/components/FaqDetailDrawer.vue
Normal file
229
src/views/ops/pages/kb/faq/components/FaqDetailDrawer.vue
Normal 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>
|
||||||
252
src/views/ops/pages/kb/faq/components/FaqFormDrawer.vue
Normal file
252
src/views/ops/pages/kb/faq/components/FaqFormDrawer.vue
Normal 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>
|
||||||
674
src/views/ops/pages/kb/faq/index.vue
Normal file
674
src/views/ops/pages/kb/faq/index.vue
Normal 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>
|
||||||
Reference in New Issue
Block a user