fix: 修正 FAQ 表单与分类交互

This commit is contained in:
zxr
2026-07-22 02:46:50 +08:00
parent f2f4390ac2
commit e43ada5c7f
4 changed files with 130 additions and 85 deletions

View File

@@ -1,9 +1,13 @@
import { request } from '@/api/request'
/** FAQ 审核状态。 */
export type FaqStatus = 'draft' | 'published' | 'reviewed' | 'rejected'
/** FAQ 优先级。 */
export type FaqPriority = 'low' | 'medium' | 'high'
/** FAQ 列表可见范围。 */
export type FaqScope = 'my' | 'all'
/** 知识库服务统一响应。 */
export interface KbReply<T> {
code: number
message: string
@@ -11,6 +15,7 @@ export interface KbReply<T> {
timeseq: number
}
/** FAQ 资源。 */
export interface Faq {
id: number
created_at: string
@@ -48,6 +53,7 @@ export interface Faq {
is_favorited: boolean
}
/** FAQ 分页结果。 */
export interface FaqPage {
total: number
page: number
@@ -55,6 +61,7 @@ export interface FaqPage {
data: Faq[]
}
/** FAQ 列表查询参数。 */
export interface FaqListParams {
page?: number
page_size?: number
@@ -66,6 +73,7 @@ export interface FaqListParams {
problem_type?: string
}
/** FAQ 创建和编辑字段。 */
export interface FaqFormData {
question: string
answer: string
@@ -83,10 +91,12 @@ export interface FaqFormData {
remarks: string
}
/** FAQ 编辑请求。 */
export interface UpdateFaqData extends FaqFormData {
id: number
}
/** 公共分类树节点。 */
export interface CategoryTreeNode {
id: number
name: string
@@ -104,6 +114,7 @@ export interface CategoryTreeNode {
children: CategoryTreeNode[]
}
/** FAQ 状态筛选项。 */
export const faqStatusOptions: Array<{ label: string; value: FaqStatus }> = [
{ label: '草稿', value: 'draft' },
{ label: '待审核', value: 'published' },
@@ -111,12 +122,14 @@ export const faqStatusOptions: Array<{ label: string; value: FaqStatus }> = [
{ label: '已拒绝', value: 'rejected' },
]
/** FAQ 优先级筛选项。 */
export const faqPriorityOptions: Array<{ label: string; value: FaqPriority }> = [
{ label: '低', value: 'low' },
{ label: '中', value: 'medium' },
{ label: '高', value: 'high' },
]
/** 常用问题类型;接口仍允许自由字符串。 */
export const faqProblemTypeOptions = [
{ label: '故障', value: '故障' },
{ label: '咨询', value: '咨询' },
@@ -124,31 +137,65 @@ export const faqProblemTypeOptions = [
{ label: '其他', value: '其他' },
]
const faqStatusMeta: Record<FaqStatus, { text: string; color: string }> = {
draft: { text: '草稿', color: 'gray' },
published: { text: '待审核', color: 'orange' },
reviewed: { text: '已审核', color: 'green' },
rejected: { text: '已拒绝', color: 'red' },
}
const faqPriorityMeta: Record<FaqPriority, { text: string; color: string }> = {
low: { text: '低', color: 'gray' },
medium: { text: '中', color: 'blue' },
high: { text: '高', color: 'orange' },
}
/** 获取 FAQ 状态文案。 */
export const getFaqStatusText = (status: FaqStatus | string) => faqStatusMeta[status as FaqStatus]?.text || status || '-'
/** 获取 FAQ 状态标签颜色。 */
export const getFaqStatusColor = (status: FaqStatus | string) => faqStatusMeta[status as FaqStatus]?.color || 'gray'
/** 获取 FAQ 优先级文案。 */
export const getFaqPriorityText = (priority: FaqPriority | string) => faqPriorityMeta[priority as FaqPriority]?.text || priority || '-'
/** 获取 FAQ 优先级标签颜色。 */
export const getFaqPriorityColor = (priority: FaqPriority | string) => faqPriorityMeta[priority as FaqPriority]?.color || 'gray'
/** 获取 FAQ 列表。 */
export const fetchFaqList = (params: FaqListParams) =>
request.get<KbReply<FaqPage>>('/Kb/v1/faq/list', {
params,
})
/** 获取 FAQ 详情。 */
export const fetchFaqDetail = (id: number) => request.get<KbReply<Faq>>(`/Kb/v1/faq/${id}`)
/** 创建 FAQ 草稿。 */
export const createFaq = (data: FaqFormData) => request.post<KbReply<Faq>>('/Kb/v1/faq/create', data)
/** 更新 FAQ。 */
export const updateFaq = (data: UpdateFaqData) => request.post<KbReply<Faq>>('/Kb/v1/faq/update', data)
/** 提交 FAQ 审核。 */
export const publishFaq = (id: number) => request.post<KbReply<string>>('/Kb/v1/faq/publish', { id })
/** 删除 FAQ 并移入回收站。 */
export const deleteFaq = (id: number) => request.delete<KbReply<string>>(`/Kb/v1/faq/${id}`)
/** 收藏 FAQ。 */
export const favoriteFaq = (id: number) =>
request.post<KbReply<unknown>>('/Kb/v1/favorite/collect', {
resource_type: 'faq',
resource_id: id,
})
/** 取消收藏 FAQ。 */
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

@@ -15,8 +15,8 @@
<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-tag :color="getFaqStatusColor(faq.status)">{{ getFaqStatusText(faq.status) }}</a-tag>
<a-tag :color="getFaqPriorityColor(faq.priority)">{{ getFaqPriorityText(faq.priority) }}</a-tag>
</a-space>
</div>
<a-space wrap>
@@ -42,7 +42,8 @@
<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.sub_category || '-' }}</a-descriptions-item>
<a-descriptions-item label="问题类型">{{ 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>
@@ -105,7 +106,7 @@
<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'
import { getFaqPriorityColor, getFaqPriorityText, getFaqStatusColor, getFaqStatusText, type Faq } from '@/api/kb/faq'
interface Props {
visible: boolean
@@ -145,40 +146,12 @@ 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 || '未填写原因'

View File

@@ -3,10 +3,11 @@
:visible="visible"
:width="760"
:title="faq ? '编辑 FAQ' : '新建 FAQ'"
:ok-loading="submitting"
ok-text="保存"
:closable="!submitting"
:mask-closable="!submitting"
:esc-to-close="!submitting"
:on-before-cancel="canClose"
unmount-on-close
@ok="handleSubmit"
@cancel="close"
@update:visible="handleVisibleChange"
>
@@ -43,7 +44,7 @@
</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-select v-model="form.problem_type" placeholder="请选择或输入" allow-clear allow-search allow-create>
<a-option v-for="option in faqProblemTypeOptions" :key="option.value" :value="option.value">
{{ option.label }}
</a-option>
@@ -51,6 +52,9 @@
</a-form-item>
</a-col>
</a-row>
<a-form-item label="子分类" field="sub_category">
<a-input v-model="form.sub_category" placeholder="请输入子分类或补充分类说明" :max-length="100" allow-clear />
</a-form-item>
</section>
<section class="form-section">
@@ -106,6 +110,13 @@
</a-form-item>
</section>
</a-form>
<template #footer>
<a-space>
<a-button :disabled="submitting" @click="close">取消</a-button>
<a-button type="primary" :loading="submitting" :disabled="submitting" @click="handleSubmit">保存</a-button>
</a-space>
</template>
</a-drawer>
</template>
@@ -114,6 +125,7 @@ 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
@@ -121,6 +133,7 @@ export interface FaqCategoryOption {
parentId: number
level: number
path: string
status: 'active' | 'inactive'
}
interface Props {
@@ -144,6 +157,7 @@ type FaqEditorForm = Omit<FaqFormData, 'category_id'> & { category_id?: number }
const form = reactive<FaqEditorForm>(createEmptyForm())
/** 创建一份不共享引用的空表单。 */
function createEmptyForm(): FaqEditorForm {
return {
question: '',
@@ -163,6 +177,7 @@ function createEmptyForm(): FaqEditorForm {
}
}
/** 打开抽屉时载入当前 FAQ关闭后由 unmount-on-close 清理视图。 */
function resetForm() {
const faq = props.faq
Object.assign(
@@ -196,6 +211,7 @@ watch(
}
)
/** 校验通过后交由父页面保存,接口成功前不关闭抽屉。 */
async function handleSubmit() {
if (props.submitting) return
const errors = await formRef.value?.validate()
@@ -214,6 +230,7 @@ async function handleSubmit() {
question,
answer,
category_id: categoryId,
sub_category: form.sub_category.trim(),
problem_type: form.problem_type.trim(),
solution: form.solution.trim(),
process_steps: form.process_steps.trim(),
@@ -226,10 +243,15 @@ async function handleSubmit() {
})
}
/** 提交中拒绝关闭,避免中断请求或丢失输入。 */
function close() {
if (!props.submitting) emit('update:visible', false)
}
function canClose() {
return !props.submitting
}
function handleVisibleChange(visible: boolean) {
if (!visible) close()
}

View File

@@ -33,8 +33,8 @@
:loading="categoryLoading"
style="width: 220px"
>
<a-option v-for="category in categoryOptions" :key="category.id" :value="category.id">
{{ category.label }}
<a-option v-for="category in categoryFilterOptions" :key="category.id" :value="category.id">
{{ category.label }}{{ category.status === 'inactive' ? '停用' : '' }}
</a-option>
</a-select>
</a-form-item>
@@ -53,7 +53,7 @@
</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-select v-model="filters.problem_type" placeholder="请选择或输入" allow-clear allow-search allow-create style="width: 150px">
<a-option v-for="option in faqProblemTypeOptions" :key="option.value" :value="option.value">
{{ option.label }}
</a-option>
@@ -86,10 +86,10 @@
{{ categoryName(record.category_id) }}
</template>
<template #priority="{ record }">
<a-tag :color="priorityColor(record.priority)">{{ priorityText(record.priority) }}</a-tag>
<a-tag :color="getFaqPriorityColor(record.priority)">{{ getFaqPriorityText(record.priority) }}</a-tag>
</template>
<template #status="{ record }">
<a-tag :color="statusColor(record.status)">{{ statusText(record.status) }}</a-tag>
<a-tag :color="getFaqStatusColor(record.status)">{{ getFaqStatusText(record.status) }}</a-tag>
</template>
<template #author="{ record }">
{{ record.author_name || `用户 ${record.author_id}` }}
@@ -138,7 +138,7 @@
<FaqFormDrawer
v-model:visible="formVisible"
:faq="editingFaq"
:categories="categoryOptions"
:categories="categoryFormOptions"
:submitting="formSubmitting"
@submit="saveFaq"
/>
@@ -167,11 +167,8 @@ 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 type { TableColumnData } from '@arco-design/web-vue/es/table/interface'
import {
createFaq,
deleteFaq,
@@ -182,6 +179,10 @@ import {
fetchFaqDetail,
fetchFaqList,
fetchGeneralCategoryTree,
getFaqPriorityColor,
getFaqPriorityText,
getFaqStatusColor,
getFaqStatusText,
publishFaq,
unfavoriteFaq,
updateFaq,
@@ -193,6 +194,9 @@ import {
type FaqStatus,
type KbReply,
} from '@/api/kb/faq'
import usePermissionCodes from '@/hooks/usePermissionCodes'
import { useUserStore } from '@/store'
import SafeStorage, { AppStorageKey } from '@/utils/safeStorage'
import FaqFormDrawer, { type FaqCategoryOption } from './components/FaqFormDrawer.vue'
import FaqDetailDrawer from './components/FaqDetailDrawer.vue'
@@ -250,7 +254,8 @@ const pagination = reactive({
let listRequestSequence = 0
const categoryLoading = ref(false)
const categoryOptions = ref<FaqCategoryOption[]>([])
const categoryFilterOptions = ref<FaqCategoryOption[]>([])
const categoryFormOptions = computed(() => categoryFilterOptions.value.filter((category) => category.status === 'active'))
const categoryLabels = ref(new Map<number, string>())
const formVisible = ref(false)
@@ -264,12 +269,14 @@ let detailRequestSequence = 0
const actionState = reactive<{ id: number; type: ActionType | '' }>({ id: 0, type: '' })
/** 将登录信息里的 ID 收敛为可比较的数值。 */
function numericId(value: unknown) {
if (value === null || value === undefined || value === '') return 0
const id = Number(value)
return Number.isFinite(id) ? id : 0
}
/** 按服务端规则判断当前用户能否修改 FAQ。 */
function canMutate(faq: Faq) {
if (faq.status === 'published') return false
if (faq.status === 'reviewed') return canManage.value
@@ -289,6 +296,7 @@ function canDelete(faq: Faq) {
return canMutate(faq)
}
/** 加载当前范围的 FAQ过期响应不会覆盖新查询。 */
async function fetchData() {
const sequence = ++listRequestSequence
loading.value = true
@@ -322,6 +330,7 @@ async function fetchData() {
}
}
/** 加载全部公共分类,同时为表单保留 active 子集。 */
async function loadCategories() {
categoryLoading.value = true
try {
@@ -331,10 +340,10 @@ async function loadCategories() {
return
}
const labels = new Map<number, string>()
const active: FaqCategoryOption[] = []
flattenCategories(reply.details || [], [], labels, active)
const categories: FaqCategoryOption[] = []
flattenCategories(reply.details || [], [], labels, categories)
categoryLabels.value = labels
categoryOptions.value = active
categoryFilterOptions.value = categories
} catch (error) {
showRequestError(error, '获取公共分类失败')
} finally {
@@ -342,22 +351,22 @@ async function loadCategories() {
}
}
function flattenCategories(nodes: CategoryTreeNode[], parentNames: string[], labels: Map<number, string>, active: FaqCategoryOption[]) {
/** 展开分类树,并保留每个节点的父级、层级、路径和状态。 */
function flattenCategories(nodes: CategoryTreeNode[], parentNames: string[], labels: Map<number, string>, categories: 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)
labels.set(node.id, node.status === 'inactive' ? `${label}(停用)` : label)
categories.push({
id: node.id,
name: node.name,
label,
parentId: node.parent_id,
level: node.level,
path: node.path,
status: node.status,
})
flattenCategories(node.children || [], names, labels, categories)
})
}
@@ -365,11 +374,17 @@ function categoryName(categoryId: number) {
return categoryLabels.value.get(categoryId) || '-'
}
function handleScopeChange() {
/** 切换可见范围时清除范围不兼容的状态筛选。 */
function changeScope(scope: FaqScope, load = true) {
activeScope.value = scope
pagination.current = 1
filters.status = undefined
handleDetailVisible(false)
fetchData()
if (load) fetchData()
}
function handleScopeChange(scope: string | number) {
changeScope(scope as FaqScope)
}
function handleSearch() {
@@ -421,6 +436,7 @@ function openEditFromDetail(faq: Faq) {
openEdit(faq)
}
/** 保存表单;仅接口成功后关闭抽屉。 */
async function saveFaq(values: FaqFormData) {
const target = editingFaq.value
if ((!target && !canCreate.value) || (target && !canEdit(target))) {
@@ -435,8 +451,8 @@ async function saveFaq(values: FaqFormData) {
Message.success(target ? 'FAQ 已保存' : 'FAQ 已创建')
formVisible.value = false
editingFaq.value = null
if (!target) activeScope.value = 'my'
pagination.current = 1
if (!target) changeScope('my', false)
else pagination.current = 1
await fetchData()
} catch (error) {
showRequestError(error, target ? '保存 FAQ 失败' : '创建 FAQ 失败')
@@ -445,6 +461,7 @@ async function saveFaq(values: FaqFormData) {
}
}
/** 获取最新详情,关闭抽屉后自动废弃仍在途的响应。 */
async function openDetail(faq: Faq) {
const sequence = ++detailRequestSequence
detailVisible.value = true
@@ -579,12 +596,14 @@ 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
}
/** 按 HTTP 状态展示鉴权与冲突错误。 */
function showRequestError(error: unknown, fallback: string) {
if (!axios.isAxiosError(error)) {
Message.error(fallback)
@@ -612,22 +631,6 @@ 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()