fix: 修正 FAQ 表单与分类交互
This commit is contained in:
@@ -1,9 +1,13 @@
|
|||||||
import { request } from '@/api/request'
|
import { request } from '@/api/request'
|
||||||
|
|
||||||
|
/** FAQ 审核状态。 */
|
||||||
export type FaqStatus = 'draft' | 'published' | 'reviewed' | 'rejected'
|
export type FaqStatus = 'draft' | 'published' | 'reviewed' | 'rejected'
|
||||||
|
/** FAQ 优先级。 */
|
||||||
export type FaqPriority = 'low' | 'medium' | 'high'
|
export type FaqPriority = 'low' | 'medium' | 'high'
|
||||||
|
/** FAQ 列表可见范围。 */
|
||||||
export type FaqScope = 'my' | 'all'
|
export type FaqScope = 'my' | 'all'
|
||||||
|
|
||||||
|
/** 知识库服务统一响应。 */
|
||||||
export interface KbReply<T> {
|
export interface KbReply<T> {
|
||||||
code: number
|
code: number
|
||||||
message: string
|
message: string
|
||||||
@@ -11,6 +15,7 @@ export interface KbReply<T> {
|
|||||||
timeseq: number
|
timeseq: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** FAQ 资源。 */
|
||||||
export interface Faq {
|
export interface Faq {
|
||||||
id: number
|
id: number
|
||||||
created_at: string
|
created_at: string
|
||||||
@@ -48,6 +53,7 @@ export interface Faq {
|
|||||||
is_favorited: boolean
|
is_favorited: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** FAQ 分页结果。 */
|
||||||
export interface FaqPage {
|
export interface FaqPage {
|
||||||
total: number
|
total: number
|
||||||
page: number
|
page: number
|
||||||
@@ -55,6 +61,7 @@ export interface FaqPage {
|
|||||||
data: Faq[]
|
data: Faq[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** FAQ 列表查询参数。 */
|
||||||
export interface FaqListParams {
|
export interface FaqListParams {
|
||||||
page?: number
|
page?: number
|
||||||
page_size?: number
|
page_size?: number
|
||||||
@@ -66,6 +73,7 @@ export interface FaqListParams {
|
|||||||
problem_type?: string
|
problem_type?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** FAQ 创建和编辑字段。 */
|
||||||
export interface FaqFormData {
|
export interface FaqFormData {
|
||||||
question: string
|
question: string
|
||||||
answer: string
|
answer: string
|
||||||
@@ -83,10 +91,12 @@ export interface FaqFormData {
|
|||||||
remarks: string
|
remarks: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** FAQ 编辑请求。 */
|
||||||
export interface UpdateFaqData extends FaqFormData {
|
export interface UpdateFaqData extends FaqFormData {
|
||||||
id: number
|
id: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 公共分类树节点。 */
|
||||||
export interface CategoryTreeNode {
|
export interface CategoryTreeNode {
|
||||||
id: number
|
id: number
|
||||||
name: string
|
name: string
|
||||||
@@ -104,6 +114,7 @@ export interface CategoryTreeNode {
|
|||||||
children: CategoryTreeNode[]
|
children: CategoryTreeNode[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** FAQ 状态筛选项。 */
|
||||||
export const faqStatusOptions: Array<{ label: string; value: FaqStatus }> = [
|
export const faqStatusOptions: Array<{ label: string; value: FaqStatus }> = [
|
||||||
{ label: '草稿', value: 'draft' },
|
{ label: '草稿', value: 'draft' },
|
||||||
{ label: '待审核', value: 'published' },
|
{ label: '待审核', value: 'published' },
|
||||||
@@ -111,12 +122,14 @@ export const faqStatusOptions: Array<{ label: string; value: FaqStatus }> = [
|
|||||||
{ label: '已拒绝', value: 'rejected' },
|
{ label: '已拒绝', value: 'rejected' },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
/** FAQ 优先级筛选项。 */
|
||||||
export const faqPriorityOptions: Array<{ label: string; value: FaqPriority }> = [
|
export const faqPriorityOptions: Array<{ label: string; value: FaqPriority }> = [
|
||||||
{ label: '低', value: 'low' },
|
{ label: '低', value: 'low' },
|
||||||
{ label: '中', value: 'medium' },
|
{ label: '中', value: 'medium' },
|
||||||
{ label: '高', value: 'high' },
|
{ label: '高', value: 'high' },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
/** 常用问题类型;接口仍允许自由字符串。 */
|
||||||
export const faqProblemTypeOptions = [
|
export const faqProblemTypeOptions = [
|
||||||
{ label: '故障', value: '故障' },
|
{ label: '故障', value: '故障' },
|
||||||
{ label: '咨询', value: '咨询' },
|
{ label: '咨询', value: '咨询' },
|
||||||
@@ -124,31 +137,65 @@ export const faqProblemTypeOptions = [
|
|||||||
{ label: '其他', value: '其他' },
|
{ 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) =>
|
export const fetchFaqList = (params: FaqListParams) =>
|
||||||
request.get<KbReply<FaqPage>>('/Kb/v1/faq/list', {
|
request.get<KbReply<FaqPage>>('/Kb/v1/faq/list', {
|
||||||
params,
|
params,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/** 获取 FAQ 详情。 */
|
||||||
export const fetchFaqDetail = (id: number) => request.get<KbReply<Faq>>(`/Kb/v1/faq/${id}`)
|
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)
|
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)
|
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 })
|
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}`)
|
export const deleteFaq = (id: number) => request.delete<KbReply<string>>(`/Kb/v1/faq/${id}`)
|
||||||
|
|
||||||
|
/** 收藏 FAQ。 */
|
||||||
export const favoriteFaq = (id: number) =>
|
export const favoriteFaq = (id: number) =>
|
||||||
request.post<KbReply<unknown>>('/Kb/v1/favorite/collect', {
|
request.post<KbReply<unknown>>('/Kb/v1/favorite/collect', {
|
||||||
resource_type: 'faq',
|
resource_type: 'faq',
|
||||||
resource_id: id,
|
resource_id: id,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/** 取消收藏 FAQ。 */
|
||||||
export const unfavoriteFaq = (id: number) =>
|
export const unfavoriteFaq = (id: number) =>
|
||||||
request.post<KbReply<string>>('/Kb/v1/favorite/uncollect', {
|
request.post<KbReply<string>>('/Kb/v1/favorite/uncollect', {
|
||||||
resource_type: 'faq',
|
resource_type: 'faq',
|
||||||
resource_id: id,
|
resource_id: id,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/** 获取公共分类树。 */
|
||||||
export const fetchGeneralCategoryTree = () => request.get<KbReply<CategoryTreeNode[]>>('/Kb/v1/category/tree')
|
export const fetchGeneralCategoryTree = () => request.get<KbReply<CategoryTreeNode[]>>('/Kb/v1/category/tree')
|
||||||
|
|||||||
@@ -15,8 +15,8 @@
|
|||||||
<div class="faq-no">{{ faq.faq_no || `FAQ-${faq.id}` }}</div>
|
<div class="faq-no">{{ faq.faq_no || `FAQ-${faq.id}` }}</div>
|
||||||
<h2>{{ faq.question }}</h2>
|
<h2>{{ faq.question }}</h2>
|
||||||
<a-space>
|
<a-space>
|
||||||
<a-tag :color="statusColor(faq.status)">{{ statusText(faq.status) }}</a-tag>
|
<a-tag :color="getFaqStatusColor(faq.status)">{{ getFaqStatusText(faq.status) }}</a-tag>
|
||||||
<a-tag :color="priorityColor(faq.priority)">{{ priorityText(faq.priority) }}</a-tag>
|
<a-tag :color="getFaqPriorityColor(faq.priority)">{{ getFaqPriorityText(faq.priority) }}</a-tag>
|
||||||
</a-space>
|
</a-space>
|
||||||
</div>
|
</div>
|
||||||
<a-space wrap>
|
<a-space wrap>
|
||||||
@@ -42,7 +42,8 @@
|
|||||||
<h3>基本信息</h3>
|
<h3>基本信息</h3>
|
||||||
<a-descriptions :column="2" bordered>
|
<a-descriptions :column="2" bordered>
|
||||||
<a-descriptions-item label="公共分类">{{ categoryName || '-' }}</a-descriptions-item>
|
<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="作者">{{ faq.author_name || `用户 ${faq.author_id}` }}</a-descriptions-item>
|
||||||
<a-descriptions-item label="更新时间">{{ formatTime(faq.updated_at) }}</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="审核人">{{ faq.reviewer_name || '-' }}</a-descriptions-item>
|
||||||
@@ -105,7 +106,7 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
import { IconStar, IconStarFill } from '@arco-design/web-vue/es/icon'
|
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 {
|
interface Props {
|
||||||
visible: boolean
|
visible: boolean
|
||||||
@@ -145,40 +146,12 @@ function handleVisibleChange(visible: boolean) {
|
|||||||
if (!visible) close()
|
if (!visible) close()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 将后端时间统一为列表使用的分钟精度。 */
|
||||||
function formatTime(value: string | null | undefined) {
|
function formatTime(value: string | null | undefined) {
|
||||||
return value ? dayjs(value).format('YYYY-MM-DD HH:mm') : '-'
|
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) {
|
function rejectionReason(remarks: string) {
|
||||||
const matches = [...remarks.matchAll(/\[审核拒绝 [^\]]+\]\s*([^\n]+)/g)]
|
const matches = [...remarks.matchAll(/\[审核拒绝 [^\]]+\]\s*([^\n]+)/g)]
|
||||||
return matches.length ? matches[matches.length - 1][1] : remarks || '未填写原因'
|
return matches.length ? matches[matches.length - 1][1] : remarks || '未填写原因'
|
||||||
|
|||||||
@@ -3,10 +3,11 @@
|
|||||||
:visible="visible"
|
:visible="visible"
|
||||||
:width="760"
|
:width="760"
|
||||||
:title="faq ? '编辑 FAQ' : '新建 FAQ'"
|
:title="faq ? '编辑 FAQ' : '新建 FAQ'"
|
||||||
:ok-loading="submitting"
|
:closable="!submitting"
|
||||||
ok-text="保存"
|
:mask-closable="!submitting"
|
||||||
|
:esc-to-close="!submitting"
|
||||||
|
:on-before-cancel="canClose"
|
||||||
unmount-on-close
|
unmount-on-close
|
||||||
@ok="handleSubmit"
|
|
||||||
@cancel="close"
|
@cancel="close"
|
||||||
@update:visible="handleVisibleChange"
|
@update:visible="handleVisibleChange"
|
||||||
>
|
>
|
||||||
@@ -43,7 +44,7 @@
|
|||||||
</a-col>
|
</a-col>
|
||||||
<a-col :span="6">
|
<a-col :span="6">
|
||||||
<a-form-item label="问题类型" field="problem_type">
|
<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">
|
<a-option v-for="option in faqProblemTypeOptions" :key="option.value" :value="option.value">
|
||||||
{{ option.label }}
|
{{ option.label }}
|
||||||
</a-option>
|
</a-option>
|
||||||
@@ -51,6 +52,9 @@
|
|||||||
</a-form-item>
|
</a-form-item>
|
||||||
</a-col>
|
</a-col>
|
||||||
</a-row>
|
</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>
|
||||||
|
|
||||||
<section class="form-section">
|
<section class="form-section">
|
||||||
@@ -106,6 +110,13 @@
|
|||||||
</a-form-item>
|
</a-form-item>
|
||||||
</section>
|
</section>
|
||||||
</a-form>
|
</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>
|
</a-drawer>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -114,6 +125,7 @@ import { reactive, ref, watch } from 'vue'
|
|||||||
import { Message, type FormInstance } from '@arco-design/web-vue'
|
import { Message, type FormInstance } from '@arco-design/web-vue'
|
||||||
import { faqPriorityOptions, faqProblemTypeOptions, type Faq, type FaqFormData } from '@/api/kb/faq'
|
import { faqPriorityOptions, faqProblemTypeOptions, type Faq, type FaqFormData } from '@/api/kb/faq'
|
||||||
|
|
||||||
|
/** 表单和筛选使用的扁平分类节点。 */
|
||||||
export interface FaqCategoryOption {
|
export interface FaqCategoryOption {
|
||||||
id: number
|
id: number
|
||||||
name: string
|
name: string
|
||||||
@@ -121,6 +133,7 @@ export interface FaqCategoryOption {
|
|||||||
parentId: number
|
parentId: number
|
||||||
level: number
|
level: number
|
||||||
path: string
|
path: string
|
||||||
|
status: 'active' | 'inactive'
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -144,6 +157,7 @@ type FaqEditorForm = Omit<FaqFormData, 'category_id'> & { category_id?: number }
|
|||||||
|
|
||||||
const form = reactive<FaqEditorForm>(createEmptyForm())
|
const form = reactive<FaqEditorForm>(createEmptyForm())
|
||||||
|
|
||||||
|
/** 创建一份不共享引用的空表单。 */
|
||||||
function createEmptyForm(): FaqEditorForm {
|
function createEmptyForm(): FaqEditorForm {
|
||||||
return {
|
return {
|
||||||
question: '',
|
question: '',
|
||||||
@@ -163,6 +177,7 @@ function createEmptyForm(): FaqEditorForm {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 打开抽屉时载入当前 FAQ,关闭后由 unmount-on-close 清理视图。 */
|
||||||
function resetForm() {
|
function resetForm() {
|
||||||
const faq = props.faq
|
const faq = props.faq
|
||||||
Object.assign(
|
Object.assign(
|
||||||
@@ -196,6 +211,7 @@ watch(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/** 校验通过后交由父页面保存,接口成功前不关闭抽屉。 */
|
||||||
async function handleSubmit() {
|
async function handleSubmit() {
|
||||||
if (props.submitting) return
|
if (props.submitting) return
|
||||||
const errors = await formRef.value?.validate()
|
const errors = await formRef.value?.validate()
|
||||||
@@ -214,6 +230,7 @@ async function handleSubmit() {
|
|||||||
question,
|
question,
|
||||||
answer,
|
answer,
|
||||||
category_id: categoryId,
|
category_id: categoryId,
|
||||||
|
sub_category: form.sub_category.trim(),
|
||||||
problem_type: form.problem_type.trim(),
|
problem_type: form.problem_type.trim(),
|
||||||
solution: form.solution.trim(),
|
solution: form.solution.trim(),
|
||||||
process_steps: form.process_steps.trim(),
|
process_steps: form.process_steps.trim(),
|
||||||
@@ -226,10 +243,15 @@ async function handleSubmit() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 提交中拒绝关闭,避免中断请求或丢失输入。 */
|
||||||
function close() {
|
function close() {
|
||||||
if (!props.submitting) emit('update:visible', false)
|
if (!props.submitting) emit('update:visible', false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function canClose() {
|
||||||
|
return !props.submitting
|
||||||
|
}
|
||||||
|
|
||||||
function handleVisibleChange(visible: boolean) {
|
function handleVisibleChange(visible: boolean) {
|
||||||
if (!visible) close()
|
if (!visible) close()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,8 +33,8 @@
|
|||||||
:loading="categoryLoading"
|
:loading="categoryLoading"
|
||||||
style="width: 220px"
|
style="width: 220px"
|
||||||
>
|
>
|
||||||
<a-option v-for="category in categoryOptions" :key="category.id" :value="category.id">
|
<a-option v-for="category in categoryFilterOptions" :key="category.id" :value="category.id">
|
||||||
{{ category.label }}
|
{{ category.label }}{{ category.status === 'inactive' ? '(停用)' : '' }}
|
||||||
</a-option>
|
</a-option>
|
||||||
</a-select>
|
</a-select>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
@@ -53,7 +53,7 @@
|
|||||||
</a-select>
|
</a-select>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
<a-form-item label="问题类型" field="problem_type">
|
<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">
|
<a-option v-for="option in faqProblemTypeOptions" :key="option.value" :value="option.value">
|
||||||
{{ option.label }}
|
{{ option.label }}
|
||||||
</a-option>
|
</a-option>
|
||||||
@@ -86,10 +86,10 @@
|
|||||||
{{ categoryName(record.category_id) }}
|
{{ categoryName(record.category_id) }}
|
||||||
</template>
|
</template>
|
||||||
<template #priority="{ record }">
|
<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>
|
||||||
<template #status="{ record }">
|
<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>
|
||||||
<template #author="{ record }">
|
<template #author="{ record }">
|
||||||
{{ record.author_name || `用户 ${record.author_id}` }}
|
{{ record.author_name || `用户 ${record.author_id}` }}
|
||||||
@@ -138,7 +138,7 @@
|
|||||||
<FaqFormDrawer
|
<FaqFormDrawer
|
||||||
v-model:visible="formVisible"
|
v-model:visible="formVisible"
|
||||||
:faq="editingFaq"
|
:faq="editingFaq"
|
||||||
:categories="categoryOptions"
|
:categories="categoryFormOptions"
|
||||||
:submitting="formSubmitting"
|
:submitting="formSubmitting"
|
||||||
@submit="saveFaq"
|
@submit="saveFaq"
|
||||||
/>
|
/>
|
||||||
@@ -167,11 +167,8 @@ import { computed, onMounted, reactive, ref } from 'vue'
|
|||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
import { Message, Modal } from '@arco-design/web-vue'
|
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 { IconPlus, IconRefresh, IconStar, IconStarFill } from '@arco-design/web-vue/es/icon'
|
||||||
import usePermissionCodes from '@/hooks/usePermissionCodes'
|
import type { TableColumnData } from '@arco-design/web-vue/es/table/interface'
|
||||||
import { useUserStore } from '@/store'
|
|
||||||
import SafeStorage, { AppStorageKey } from '@/utils/safeStorage'
|
|
||||||
import {
|
import {
|
||||||
createFaq,
|
createFaq,
|
||||||
deleteFaq,
|
deleteFaq,
|
||||||
@@ -182,6 +179,10 @@ import {
|
|||||||
fetchFaqDetail,
|
fetchFaqDetail,
|
||||||
fetchFaqList,
|
fetchFaqList,
|
||||||
fetchGeneralCategoryTree,
|
fetchGeneralCategoryTree,
|
||||||
|
getFaqPriorityColor,
|
||||||
|
getFaqPriorityText,
|
||||||
|
getFaqStatusColor,
|
||||||
|
getFaqStatusText,
|
||||||
publishFaq,
|
publishFaq,
|
||||||
unfavoriteFaq,
|
unfavoriteFaq,
|
||||||
updateFaq,
|
updateFaq,
|
||||||
@@ -193,6 +194,9 @@ import {
|
|||||||
type FaqStatus,
|
type FaqStatus,
|
||||||
type KbReply,
|
type KbReply,
|
||||||
} from '@/api/kb/faq'
|
} 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 FaqFormDrawer, { type FaqCategoryOption } from './components/FaqFormDrawer.vue'
|
||||||
import FaqDetailDrawer from './components/FaqDetailDrawer.vue'
|
import FaqDetailDrawer from './components/FaqDetailDrawer.vue'
|
||||||
|
|
||||||
@@ -250,7 +254,8 @@ const pagination = reactive({
|
|||||||
let listRequestSequence = 0
|
let listRequestSequence = 0
|
||||||
|
|
||||||
const categoryLoading = ref(false)
|
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 categoryLabels = ref(new Map<number, string>())
|
||||||
|
|
||||||
const formVisible = ref(false)
|
const formVisible = ref(false)
|
||||||
@@ -264,12 +269,14 @@ let detailRequestSequence = 0
|
|||||||
|
|
||||||
const actionState = reactive<{ id: number; type: ActionType | '' }>({ id: 0, type: '' })
|
const actionState = reactive<{ id: number; type: ActionType | '' }>({ id: 0, type: '' })
|
||||||
|
|
||||||
|
/** 将登录信息里的 ID 收敛为可比较的数值。 */
|
||||||
function numericId(value: unknown) {
|
function numericId(value: unknown) {
|
||||||
if (value === null || value === undefined || value === '') return 0
|
if (value === null || value === undefined || value === '') return 0
|
||||||
const id = Number(value)
|
const id = Number(value)
|
||||||
return Number.isFinite(id) ? id : 0
|
return Number.isFinite(id) ? id : 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 按服务端规则判断当前用户能否修改 FAQ。 */
|
||||||
function canMutate(faq: Faq) {
|
function canMutate(faq: Faq) {
|
||||||
if (faq.status === 'published') return false
|
if (faq.status === 'published') return false
|
||||||
if (faq.status === 'reviewed') return canManage.value
|
if (faq.status === 'reviewed') return canManage.value
|
||||||
@@ -289,6 +296,7 @@ function canDelete(faq: Faq) {
|
|||||||
return canMutate(faq)
|
return canMutate(faq)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 加载当前范围的 FAQ,过期响应不会覆盖新查询。 */
|
||||||
async function fetchData() {
|
async function fetchData() {
|
||||||
const sequence = ++listRequestSequence
|
const sequence = ++listRequestSequence
|
||||||
loading.value = true
|
loading.value = true
|
||||||
@@ -322,6 +330,7 @@ async function fetchData() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 加载全部公共分类,同时为表单保留 active 子集。 */
|
||||||
async function loadCategories() {
|
async function loadCategories() {
|
||||||
categoryLoading.value = true
|
categoryLoading.value = true
|
||||||
try {
|
try {
|
||||||
@@ -331,10 +340,10 @@ async function loadCategories() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
const labels = new Map<number, string>()
|
const labels = new Map<number, string>()
|
||||||
const active: FaqCategoryOption[] = []
|
const categories: FaqCategoryOption[] = []
|
||||||
flattenCategories(reply.details || [], [], labels, active)
|
flattenCategories(reply.details || [], [], labels, categories)
|
||||||
categoryLabels.value = labels
|
categoryLabels.value = labels
|
||||||
categoryOptions.value = active
|
categoryFilterOptions.value = categories
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showRequestError(error, '获取公共分类失败')
|
showRequestError(error, '获取公共分类失败')
|
||||||
} finally {
|
} 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) => {
|
nodes.forEach((node) => {
|
||||||
const names = [...parentNames, node.name]
|
const names = [...parentNames, node.name]
|
||||||
const label = names.join(' / ')
|
const label = names.join(' / ')
|
||||||
labels.set(node.id, label)
|
labels.set(node.id, node.status === 'inactive' ? `${label}(停用)` : label)
|
||||||
if (node.status === 'active') {
|
categories.push({
|
||||||
active.push({
|
id: node.id,
|
||||||
id: node.id,
|
name: node.name,
|
||||||
name: node.name,
|
label,
|
||||||
label,
|
parentId: node.parent_id,
|
||||||
parentId: node.parent_id,
|
level: node.level,
|
||||||
level: node.level,
|
path: node.path,
|
||||||
path: node.path,
|
status: node.status,
|
||||||
})
|
})
|
||||||
}
|
flattenCategories(node.children || [], names, labels, categories)
|
||||||
flattenCategories(node.children || [], names, labels, active)
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -365,11 +374,17 @@ function categoryName(categoryId: number) {
|
|||||||
return categoryLabels.value.get(categoryId) || '-'
|
return categoryLabels.value.get(categoryId) || '-'
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleScopeChange() {
|
/** 切换可见范围时清除范围不兼容的状态筛选。 */
|
||||||
|
function changeScope(scope: FaqScope, load = true) {
|
||||||
|
activeScope.value = scope
|
||||||
pagination.current = 1
|
pagination.current = 1
|
||||||
filters.status = undefined
|
filters.status = undefined
|
||||||
handleDetailVisible(false)
|
handleDetailVisible(false)
|
||||||
fetchData()
|
if (load) fetchData()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleScopeChange(scope: string | number) {
|
||||||
|
changeScope(scope as FaqScope)
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleSearch() {
|
function handleSearch() {
|
||||||
@@ -421,6 +436,7 @@ function openEditFromDetail(faq: Faq) {
|
|||||||
openEdit(faq)
|
openEdit(faq)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 保存表单;仅接口成功后关闭抽屉。 */
|
||||||
async function saveFaq(values: FaqFormData) {
|
async function saveFaq(values: FaqFormData) {
|
||||||
const target = editingFaq.value
|
const target = editingFaq.value
|
||||||
if ((!target && !canCreate.value) || (target && !canEdit(target))) {
|
if ((!target && !canCreate.value) || (target && !canEdit(target))) {
|
||||||
@@ -435,8 +451,8 @@ async function saveFaq(values: FaqFormData) {
|
|||||||
Message.success(target ? 'FAQ 已保存' : 'FAQ 已创建')
|
Message.success(target ? 'FAQ 已保存' : 'FAQ 已创建')
|
||||||
formVisible.value = false
|
formVisible.value = false
|
||||||
editingFaq.value = null
|
editingFaq.value = null
|
||||||
if (!target) activeScope.value = 'my'
|
if (!target) changeScope('my', false)
|
||||||
pagination.current = 1
|
else pagination.current = 1
|
||||||
await fetchData()
|
await fetchData()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showRequestError(error, target ? '保存 FAQ 失败' : '创建 FAQ 失败')
|
showRequestError(error, target ? '保存 FAQ 失败' : '创建 FAQ 失败')
|
||||||
@@ -445,6 +461,7 @@ async function saveFaq(values: FaqFormData) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 获取最新详情,关闭抽屉后自动废弃仍在途的响应。 */
|
||||||
async function openDetail(faq: Faq) {
|
async function openDetail(faq: Faq) {
|
||||||
const sequence = ++detailRequestSequence
|
const sequence = ++detailRequestSequence
|
||||||
detailVisible.value = true
|
detailVisible.value = true
|
||||||
@@ -579,12 +596,14 @@ function isActionLoading(id: number, type: ActionType) {
|
|||||||
return actionState.id === id && actionState.type === type
|
return actionState.id === id && actionState.type === type
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 处理服务端返回的业务错误。 */
|
||||||
function showReplyResult(reply: KbReply<unknown>, fallback: string) {
|
function showReplyResult(reply: KbReply<unknown>, fallback: string) {
|
||||||
if (reply.code === 0) return true
|
if (reply.code === 0) return true
|
||||||
Message.error(reply.message || fallback)
|
Message.error(reply.message || fallback)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 按 HTTP 状态展示鉴权与冲突错误。 */
|
||||||
function showRequestError(error: unknown, fallback: string) {
|
function showRequestError(error: unknown, fallback: string) {
|
||||||
if (!axios.isAxiosError(error)) {
|
if (!axios.isAxiosError(error)) {
|
||||||
Message.error(fallback)
|
Message.error(fallback)
|
||||||
@@ -612,22 +631,6 @@ function formatTime(value: string) {
|
|||||||
return value ? dayjs(value).format('YYYY-MM-DD HH:mm') : '-'
|
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(() => {
|
onMounted(() => {
|
||||||
loadCategories()
|
loadCategories()
|
||||||
fetchData()
|
fetchData()
|
||||||
|
|||||||
Reference in New Issue
Block a user