feat: 整合知识库管理页面
This commit is contained in:
@@ -1,93 +1,73 @@
|
|||||||
import { request } from '@/api/request'
|
import { request } from '@/api/request'
|
||||||
|
import type { KbReply } from './faq'
|
||||||
|
|
||||||
/** 分类类型 */
|
/** 公共分类状态。 */
|
||||||
|
export type CategoryStatus = 'active' | 'inactive'
|
||||||
|
|
||||||
|
/** 公共分类。 */
|
||||||
export interface Category {
|
export interface Category {
|
||||||
id: number
|
id: number
|
||||||
created_at: string
|
created_at: string
|
||||||
updated_at: string
|
updated_at: string
|
||||||
name: string
|
name: string
|
||||||
description: string
|
description: string
|
||||||
type: string
|
type: 'general'
|
||||||
icon: string
|
icon: string
|
||||||
color: string
|
color: string
|
||||||
parent_id: number
|
parent_id: number
|
||||||
level: number
|
level: 1 | 2 | 3
|
||||||
path: string
|
path: string
|
||||||
sort_order: number
|
sort_order: number
|
||||||
status: string
|
status: CategoryStatus
|
||||||
creator_id: number
|
creator_id: number
|
||||||
creator_name: string
|
creator_name: string
|
||||||
doc_count: number
|
doc_count: number
|
||||||
faq_count: number
|
faq_count: number
|
||||||
metadata: string | null
|
metadata: string
|
||||||
remarks: string
|
remarks: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/** API响应包装类型 */
|
/** 公共分类树节点。 */
|
||||||
export interface ApiResponse<T = any> {
|
export interface CategoryTreeNode extends Category {
|
||||||
code: number
|
children: CategoryTreeNode[]
|
||||||
message: string
|
|
||||||
data: T
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 创建分类请求参数 */
|
/** 创建分类参数。 */
|
||||||
export interface CreateCategoryParams {
|
export interface CreateCategoryParams {
|
||||||
name: string
|
name: string
|
||||||
description?: string
|
description: string
|
||||||
type?: string
|
icon: string
|
||||||
icon?: string
|
color: string
|
||||||
color?: string
|
parent_id: number
|
||||||
parent_id?: number
|
sort_order: number
|
||||||
sort_order?: number
|
status: CategoryStatus
|
||||||
remarks?: string
|
remarks: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 更新分类请求参数 */
|
/** 更新分类参数。 */
|
||||||
export interface UpdateCategoryParams {
|
export interface UpdateCategoryParams extends CreateCategoryParams {
|
||||||
id: number
|
id: number
|
||||||
name?: string
|
|
||||||
description?: string
|
|
||||||
icon?: string
|
|
||||||
color?: string
|
|
||||||
sort_order?: number
|
|
||||||
status?: string
|
|
||||||
remarks?: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 获取分类列表参数 */
|
/** 分类列表参数。 */
|
||||||
export interface FetchCategoryListParams {
|
export interface FetchCategoryListParams {
|
||||||
type?: string
|
|
||||||
parent_id?: number
|
parent_id?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 创建分类 */
|
/** 创建公共分类。 */
|
||||||
export const createCategory = (data: CreateCategoryParams) => {
|
export const createCategory = (data: CreateCategoryParams) => request.post<KbReply<Category>>('/Kb/v1/category/create', data)
|
||||||
return request.post<ApiResponse<Category>>('/Kb/v1/category/create', data)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 更新分类 */
|
/** 更新公共分类。 */
|
||||||
export const updateCategory = (data: UpdateCategoryParams) => {
|
export const updateCategory = (data: UpdateCategoryParams) => request.post<KbReply<Category>>('/Kb/v1/category/update', data)
|
||||||
return request.post<ApiResponse<Category>>('/Kb/v1/category/update', data)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 删除分类 */
|
/** 删除公共分类。 */
|
||||||
export const deleteCategory = (id: number) => {
|
export const deleteCategory = (id: number) => request.delete<KbReply<string>>(`/Kb/v1/category/${id}`)
|
||||||
return request.delete<ApiResponse<string>>(`/Kb/v1/category/${id}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 获取分类详情 */
|
/** 获取公共分类详情。 */
|
||||||
export const fetchCategoryDetail = (id: number) => {
|
export const fetchCategoryDetail = (id: number) => request.get<KbReply<Category>>(`/Kb/v1/category/${id}`)
|
||||||
return request.get<ApiResponse<Category>>(`/Kb/v1/category/${id}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 获取分类列表 */
|
/** 获取公共分类列表。 */
|
||||||
export const fetchCategoryList = (params?: FetchCategoryListParams) => {
|
export const fetchCategoryList = (params?: FetchCategoryListParams) => request.get<KbReply<Category[]>>('/Kb/v1/category/list', { params })
|
||||||
return request.get<ApiResponse<Category[]>>('/Kb/v1/category/list', { params })
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 获取分类树 */
|
/** 获取完整公共分类树,包含停用节点。 */
|
||||||
export const fetchCategoryTree = (type?: string) => {
|
export const fetchCategoryTree = () => request.get<KbReply<CategoryTreeNode[]>>('/Kb/v1/category/tree')
|
||||||
return request.get<ApiResponse<Category[]>>('/Kb/v1/category/tree', {
|
|
||||||
params: type ? { type } : undefined,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
import { request } from '@/api/request'
|
import { request } from '@/api/request'
|
||||||
|
import type { KbReply } from './faq'
|
||||||
|
|
||||||
/** 文档状态 */
|
/** 文档状态。 */
|
||||||
export type DocumentStatus = 'draft' | 'published' | 'reviewed' | 'rejected'
|
export type DocumentStatus = 'draft' | 'published' | 'reviewed' | 'rejected'
|
||||||
|
/** 文档类型。 */
|
||||||
/** 文档类型 */
|
|
||||||
export type DocumentType = 'common' | 'guide' | 'solution' | 'troubleshoot' | 'process' | 'technical'
|
export type DocumentType = 'common' | 'guide' | 'solution' | 'troubleshoot' | 'process' | 'technical'
|
||||||
|
/** 文档列表范围。 */
|
||||||
|
export type DocumentScope = 'my' | 'all'
|
||||||
|
|
||||||
/** 文档接口类型 */
|
/** 文档资源。 */
|
||||||
export interface Document {
|
export interface Document {
|
||||||
id: number
|
id: number
|
||||||
created_at: string
|
created_at: string
|
||||||
@@ -33,71 +35,54 @@ export interface Document {
|
|||||||
version: string
|
version: string
|
||||||
version_notes: string
|
version_notes: string
|
||||||
tags: string
|
tags: string
|
||||||
attachments: string | null
|
attachments: string
|
||||||
related_docs: string | null
|
related_docs: string
|
||||||
detection_point_ids: string | null
|
detection_point_ids: string
|
||||||
metadata: string | null
|
metadata: string
|
||||||
keywords: string
|
keywords: string
|
||||||
remarks: string
|
remarks: string
|
||||||
is_favorited?: boolean
|
is_favorited: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
/** API响应包装类型 */
|
/** 文档分页结果。 */
|
||||||
export interface ApiResponse<T = any> {
|
export interface DocumentPage {
|
||||||
code: number
|
|
||||||
message: string
|
|
||||||
data: T
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 分页响应类型 */
|
|
||||||
export interface PaginatedResponse<T> {
|
|
||||||
total: number
|
total: number
|
||||||
page: number
|
page: number
|
||||||
page_size: number
|
page_size: number
|
||||||
data: T[]
|
data: Document[]
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 创建文档请求参数 */
|
/** 文档创建字段。 */
|
||||||
export interface CreateDocumentParams {
|
export interface CreateDocumentParams {
|
||||||
title: string
|
title: string
|
||||||
description?: string
|
description: string
|
||||||
content: string
|
content: string
|
||||||
type?: DocumentType
|
type: DocumentType
|
||||||
category_id?: number
|
category_id: number
|
||||||
sub_category?: string
|
sub_category: string
|
||||||
keywords?: string
|
keywords: string
|
||||||
tags?: string
|
tags: string
|
||||||
detection_point_ids?: string
|
detection_point_ids: string
|
||||||
remarks?: string
|
remarks: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 更新文档请求参数 */
|
/** 文档编辑字段。 */
|
||||||
export interface UpdateDocumentParams {
|
export interface UpdateDocumentParams extends CreateDocumentParams {
|
||||||
id: number
|
id: number
|
||||||
title?: string
|
|
||||||
description?: string
|
|
||||||
content?: string
|
|
||||||
type?: DocumentType
|
|
||||||
category_id?: number
|
|
||||||
sub_category?: string
|
|
||||||
keywords?: string
|
|
||||||
tags?: string
|
|
||||||
detection_point_ids?: string
|
|
||||||
remarks?: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 获取文档列表参数 */
|
/** 文档列表参数。 */
|
||||||
export interface FetchDocumentListParams {
|
export interface FetchDocumentListParams {
|
||||||
page?: number
|
page?: number
|
||||||
page_size?: number
|
page_size?: number
|
||||||
|
scope?: DocumentScope
|
||||||
keyword?: string
|
keyword?: string
|
||||||
type?: DocumentType
|
type?: DocumentType
|
||||||
status?: DocumentStatus
|
status?: DocumentStatus
|
||||||
category_id?: number
|
category_id?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 文档类型选项 */
|
export const documentTypeOptions: Array<{ label: string; value: DocumentType }> = [
|
||||||
export const documentTypeOptions = [
|
|
||||||
{ label: '通用文档', value: 'common' },
|
{ label: '通用文档', value: 'common' },
|
||||||
{ label: '操作指南', value: 'guide' },
|
{ label: '操作指南', value: 'guide' },
|
||||||
{ label: '解决方案', value: 'solution' },
|
{ label: '解决方案', value: 'solution' },
|
||||||
@@ -106,115 +91,28 @@ export const documentTypeOptions = [
|
|||||||
{ label: '技术文档', value: 'technical' },
|
{ label: '技术文档', value: 'technical' },
|
||||||
]
|
]
|
||||||
|
|
||||||
/** 文档状态选项 */
|
/** 创建文档草稿。 */
|
||||||
export const documentStatusOptions = [
|
export const createDocument = (data: CreateDocumentParams) => request.post<KbReply<Document>>('/Kb/v1/document/create', data)
|
||||||
{ label: '草稿', value: 'draft' },
|
|
||||||
{ label: '已发布', value: 'published' },
|
|
||||||
{ label: '已审核', value: 'reviewed' },
|
|
||||||
{ label: '未通过审核', value: 'rejected' },
|
|
||||||
]
|
|
||||||
|
|
||||||
/** 获取文档状态文本 */
|
/** 更新文档。 */
|
||||||
export const getDocumentStatusText = (status: DocumentStatus): string => {
|
export const updateDocument = (data: UpdateDocumentParams) => request.post<KbReply<Document>>('/Kb/v1/document/update', data)
|
||||||
const statusMap: Record<DocumentStatus, string> = {
|
|
||||||
draft: '草稿',
|
|
||||||
published: '已发布',
|
|
||||||
reviewed: '已审核',
|
|
||||||
rejected: '未通过审核',
|
|
||||||
}
|
|
||||||
return statusMap[status] || status
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 获取文档状态颜色 */
|
/** 删除文档并移入回收站。 */
|
||||||
export const getDocumentStatusColor = (status: DocumentStatus): string => {
|
export const deleteDocument = (id: number) => request.delete<KbReply<string>>(`/Kb/v1/document/${id}`)
|
||||||
const colorMap: Record<DocumentStatus, string> = {
|
|
||||||
draft: 'gray',
|
|
||||||
published: 'blue',
|
|
||||||
reviewed: 'green',
|
|
||||||
rejected: 'red',
|
|
||||||
}
|
|
||||||
return colorMap[status] || 'gray'
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 获取文档类型文本 */
|
/** 获取文档详情。 */
|
||||||
export const getDocumentTypeText = (type: DocumentType): string => {
|
export const fetchDocumentDetail = (id: number) => request.get<KbReply<Document>>(`/Kb/v1/document/${id}`)
|
||||||
const typeMap: Record<DocumentType, string> = {
|
|
||||||
common: '通用文档',
|
|
||||||
guide: '操作指南',
|
|
||||||
solution: '解决方案',
|
|
||||||
troubleshoot: '故障排查',
|
|
||||||
process: '流程规范',
|
|
||||||
technical: '技术文档',
|
|
||||||
}
|
|
||||||
return typeMap[type] || type
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 创建文档 */
|
/** 获取指定可见范围的文档列表。 */
|
||||||
export const createDocument = (data: CreateDocumentParams) => {
|
export const fetchDocumentList = (params: FetchDocumentListParams) => request.get<KbReply<DocumentPage>>('/Kb/v1/document/list', { params })
|
||||||
return request.post<ApiResponse<Document>>('/Kb/v1/document/create', data)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 更新文档 */
|
/** 提交文档审核。 */
|
||||||
export const updateDocument = (data: UpdateDocumentParams) => {
|
export const publishDocument = (id: number) => request.post<KbReply<string>>('/Kb/v1/document/publish', { id })
|
||||||
return request.post<ApiResponse<Document>>('/Kb/v1/document/update', data)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 删除文档(移入回收站) */
|
/** 收藏文档。 */
|
||||||
export const deleteDocument = (id: number) => {
|
export const favoriteDocument = (id: number) =>
|
||||||
return request.delete<ApiResponse<string>>(`/Kb/v1/document/${id}`)
|
request.post<KbReply<unknown>>('/Kb/v1/favorite/collect', { resource_type: 'document', resource_id: id })
|
||||||
}
|
|
||||||
|
|
||||||
/** 获取文档详情 */
|
/** 取消收藏文档。 */
|
||||||
export const fetchDocumentDetail = (id: number) => {
|
export const unfavoriteDocument = (id: number) =>
|
||||||
return request.get<ApiResponse<Document>>(`/Kb/v1/document/${id}`)
|
request.post<KbReply<string>>('/Kb/v1/favorite/uncollect', { resource_type: 'document', resource_id: id })
|
||||||
}
|
|
||||||
|
|
||||||
/** 获取文档列表 */
|
|
||||||
export const fetchDocumentList = (params?: FetchDocumentListParams) => {
|
|
||||||
return request.get<ApiResponse<PaginatedResponse<Document>>>('/Kb/v1/document/list', { params })
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 发布文档 */
|
|
||||||
export const publishDocument = (id: number) => {
|
|
||||||
return request.post<ApiResponse<string>>('/Kb/v1/document/publish', { id })
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 移入回收站 */
|
|
||||||
export const moveToTrash = (resourceId: number, resourceType: string) => {
|
|
||||||
return request.post<ApiResponse<string>>('/Kb/v1/trash/move', {
|
|
||||||
resource_id: resourceId,
|
|
||||||
resource_type: resourceType,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 获取我的文档列表(由我创建的所有文档) */
|
|
||||||
export const fetchMyDocumentList = (params?: FetchDocumentListParams) => {
|
|
||||||
return request.get<ApiResponse<PaginatedResponse<Document>>>('/Kb/v1/review/publish/list', { params })
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 获取已审核通过的文档列表 */
|
|
||||||
export const fetchApprovedDocumentList = (params?: FetchDocumentListParams) => {
|
|
||||||
return request.get<ApiResponse<PaginatedResponse<Document>>>('/Kb/v1/review/approved/list', { params })
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 收藏文档 */
|
|
||||||
export const favoriteDocument = (id: number, remarks?: string) => {
|
|
||||||
return request.post<ApiResponse<string>>('/Kb/v1/favorite/collect', {
|
|
||||||
resource_type: 'document',
|
|
||||||
resource_id: id,
|
|
||||||
remarks,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 取消收藏文档 */
|
|
||||||
export const unfavoriteDocument = (id: number) => {
|
|
||||||
return request.post<ApiResponse<string>>('/Kb/v1/favorite/uncollect', {
|
|
||||||
resource_type: 'document',
|
|
||||||
resource_id: id,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 下载文档 */
|
|
||||||
export const downloadDocument = (id: number) => {
|
|
||||||
return request.get<Blob>(`/Kb/v1/document/${id}/download`, { responseType: 'blob' })
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { request } from '@/api/request'
|
import { request } from '@/api/request'
|
||||||
|
import type { Document } from './document'
|
||||||
|
import type { Faq, KbReply } from './faq'
|
||||||
|
|
||||||
/** 资源类型 */
|
|
||||||
export type ResourceType = 'document' | 'faq'
|
export type ResourceType = 'document' | 'faq'
|
||||||
|
|
||||||
/** 收藏记录接口 */
|
/** 收藏记录。 */
|
||||||
export interface Favorite {
|
export interface Favorite {
|
||||||
id: number
|
id: number
|
||||||
created_at: string
|
created_at: string
|
||||||
@@ -12,69 +13,36 @@ export interface Favorite {
|
|||||||
resource_name: string
|
resource_name: string
|
||||||
remarks: string
|
remarks: string
|
||||||
is_deleted: boolean
|
is_deleted: boolean
|
||||||
resource_data?: any
|
resource_data?: Document | Faq
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 收藏列表响应 */
|
export interface FavoritePage {
|
||||||
export interface FavoriteListResponse {
|
|
||||||
total: number
|
total: number
|
||||||
page: number
|
page: number
|
||||||
page_size: number
|
page_size: number
|
||||||
data: Favorite[]
|
data: Favorite[]
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 获取收藏列表参数 */
|
|
||||||
export interface FetchFavoriteListParams {
|
export interface FetchFavoriteListParams {
|
||||||
page?: number
|
page?: number
|
||||||
page_size?: number
|
page_size?: number
|
||||||
resource_type?: ResourceType
|
resource_type?: ResourceType
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 收藏请求参数 */
|
|
||||||
export interface CollectParams {
|
export interface CollectParams {
|
||||||
resource_type: ResourceType
|
resource_type: ResourceType
|
||||||
resource_id: number
|
resource_id: number
|
||||||
remarks?: string
|
remarks?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 取消收藏参数 */
|
export type UncollectParams = Omit<CollectParams, 'remarks'>
|
||||||
export interface UncollectParams {
|
|
||||||
resource_type: ResourceType
|
|
||||||
resource_id: number
|
|
||||||
}
|
|
||||||
|
|
||||||
/** API响应包装类型 */
|
/** 获取收藏列表。 */
|
||||||
export interface ApiResponse<T = any> {
|
export const fetchFavoriteList = (params: FetchFavoriteListParams = {}) =>
|
||||||
code: number
|
request.get<KbReply<FavoritePage>>('/Kb/v1/favorite/list', { params })
|
||||||
message: string
|
|
||||||
data: T
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/** 收藏资源。 */
|
||||||
* 获取收藏列表
|
export const collectResource = (data: CollectParams) => request.post<KbReply<Favorite>>('/Kb/v1/favorite/collect', data)
|
||||||
*/
|
|
||||||
export async function fetchFavoriteList(params: FetchFavoriteListParams = {}): Promise<ApiResponse<FavoriteListResponse>> {
|
|
||||||
return request.get<ApiResponse<FavoriteListResponse>>('/Kb/v1/favorite/list', {
|
|
||||||
params,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/** 取消收藏。 */
|
||||||
* 收藏资源
|
export const uncollectResource = (data: UncollectParams) => request.post<KbReply<string>>('/Kb/v1/favorite/uncollect', data)
|
||||||
*/
|
|
||||||
export async function collectResource(data: CollectParams): Promise<ApiResponse<Favorite>> {
|
|
||||||
return request.post<ApiResponse<Favorite>>('/Kb/v1/favorite/collect', data)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 取消收藏
|
|
||||||
*/
|
|
||||||
export async function uncollectResource(data: UncollectParams): Promise<ApiResponse<string>> {
|
|
||||||
return request.post<ApiResponse<string>>('/Kb/v1/favorite/uncollect', data)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 资源类型选项 */
|
|
||||||
export const resourceTypeOptions = [
|
|
||||||
{ label: '文档', value: 'document' },
|
|
||||||
{ label: 'FAQ', value: 'faq' },
|
|
||||||
]
|
|
||||||
|
|||||||
@@ -1,169 +1,70 @@
|
|||||||
import { request } from '@/api/request'
|
import { request } from '@/api/request'
|
||||||
|
import type { Document } from './document'
|
||||||
|
import type { Faq, KbReply } from './faq'
|
||||||
|
|
||||||
export type ReviewStatsResourceType = 'all' | 'document' | 'faq'
|
export type ReviewResourceType = 'all' | 'document' | 'faq'
|
||||||
|
|
||||||
/** 审核统计接口返回的 data 字段 */
|
/** 统一审核列表项。 */
|
||||||
export interface ReviewStatsPayload {
|
|
||||||
need_my_review_document?: number
|
|
||||||
need_my_review_faq?: number
|
|
||||||
need_my_review_total?: number
|
|
||||||
need_my_review_unreviewed_document?: number
|
|
||||||
need_my_review_unreviewed_faq?: number
|
|
||||||
need_my_review_unreviewed_total?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 文档资源类型 */
|
|
||||||
export interface DocumentResource {
|
|
||||||
id: number
|
|
||||||
created_at: string
|
|
||||||
updated_at: string
|
|
||||||
doc_no: string
|
|
||||||
title: string
|
|
||||||
description: string
|
|
||||||
content: string
|
|
||||||
type: string
|
|
||||||
status: string
|
|
||||||
category_id: number
|
|
||||||
sub_category: string
|
|
||||||
author_id: number
|
|
||||||
author_name: string
|
|
||||||
reviewer_id: number
|
|
||||||
reviewer_name: string
|
|
||||||
reviewed_at: string | null
|
|
||||||
published_at: string | null
|
|
||||||
publisher_id: number
|
|
||||||
view_count: number
|
|
||||||
like_count: number
|
|
||||||
comment_count: number
|
|
||||||
download_count: number
|
|
||||||
version: string
|
|
||||||
version_notes: string
|
|
||||||
tags: string
|
|
||||||
attachments: string | null
|
|
||||||
related_docs: string | null
|
|
||||||
metadata: string | null
|
|
||||||
keywords: string
|
|
||||||
remarks: string
|
|
||||||
}
|
|
||||||
|
|
||||||
/** FAQ资源类型 */
|
|
||||||
export interface FaqResource {
|
|
||||||
id: number
|
|
||||||
created_at: string
|
|
||||||
updated_at: string
|
|
||||||
faq_no: string
|
|
||||||
question: string
|
|
||||||
answer: string
|
|
||||||
status: string
|
|
||||||
priority: string
|
|
||||||
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 | null
|
|
||||||
related_docs: string | null
|
|
||||||
related_links: string | null
|
|
||||||
attachments: string | null
|
|
||||||
keywords: string
|
|
||||||
applicable_scope: string
|
|
||||||
remarks: string
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 审核列表项(resource_type 为 all 时) */
|
|
||||||
export interface ReviewListItem {
|
export interface ReviewListItem {
|
||||||
type: 'document' | 'faq'
|
type: 'document' | 'faq'
|
||||||
resource: DocumentResource | FaqResource
|
resource: Document | Faq
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 分页响应类型 */
|
/** 审核分页结果。 */
|
||||||
export interface PaginatedResponse<T> {
|
export interface ReviewPage {
|
||||||
total: number
|
total: number
|
||||||
page: number
|
page: number
|
||||||
page_size: number
|
page_size: number
|
||||||
data: T[]
|
data: ReviewListItem[]
|
||||||
}
|
}
|
||||||
|
|
||||||
/** API响应包装类型 */
|
/** 当前审核人的待审数量。 */
|
||||||
export interface ApiResponse<T = any> {
|
export interface ReviewStats {
|
||||||
code: number
|
need_my_review_document: number
|
||||||
message: string
|
need_my_review_faq: number
|
||||||
data: T
|
need_my_review_total: number
|
||||||
|
need_my_review_unreviewed_document: number
|
||||||
|
need_my_review_unreviewed_faq: number
|
||||||
|
need_my_review_unreviewed_total: number
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 获取审核列表参数 */
|
/** 审核列表参数。 */
|
||||||
export interface FetchReviewListParams {
|
export interface FetchReviewListParams {
|
||||||
page?: number
|
page?: number
|
||||||
page_size?: number
|
page_size?: number
|
||||||
resource_type?: ReviewStatsResourceType
|
resource_type?: ReviewResourceType
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 审核通过参数 */
|
|
||||||
export interface ApproveParams {
|
export interface ApproveParams {
|
||||||
resource_type: 'document' | 'faq'
|
resource_type: 'document' | 'faq'
|
||||||
id: number
|
id: number
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 审核拒绝参数 */
|
export interface RejectParams extends ApproveParams {
|
||||||
export interface RejectParams {
|
reason: string
|
||||||
resource_type: 'document' | 'faq'
|
|
||||||
id: number
|
|
||||||
reason?: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 按当前登录用户统计需要本人审核的数量(不含本人为作者的稿件) */
|
/** 获取当前用户可审核的待审资源。 */
|
||||||
export const fetchReviewStats = (params?: { resource_type?: ReviewStatsResourceType }) =>
|
export const fetchReviewList = (params: FetchReviewListParams) => request.get<KbReply<ReviewPage>>('/Kb/v1/review/list', { params })
|
||||||
request.get('/Kb/v1/review/stats', params ? { params } : undefined)
|
|
||||||
|
|
||||||
/** 获取待审核列表 */
|
/** 获取当前审核人的待审统计。 */
|
||||||
export const fetchReviewList = (params?: FetchReviewListParams) =>
|
export const fetchReviewStats = (params: { resource_type?: ReviewResourceType } = {}) =>
|
||||||
request.get<ApiResponse<PaginatedResponse<ReviewListItem | DocumentResource | FaqResource>>>('/Kb/v1/review/list', { params })
|
request.get<KbReply<ReviewStats>>('/Kb/v1/review/stats', { params })
|
||||||
|
|
||||||
/** 审核通过 */
|
/** 审核通过。 */
|
||||||
export const approveReview = (data: ApproveParams) => request.post<ApiResponse<string>>('/Kb/v1/review/approve', data)
|
export const approveReview = (data: ApproveParams) => request.post<KbReply<string>>('/Kb/v1/review/approve', data)
|
||||||
|
|
||||||
/** 审核拒绝 */
|
/** 审核拒绝。 */
|
||||||
export const rejectReview = (data: RejectParams) => request.post<ApiResponse<string>>('/Kb/v1/review/reject', data)
|
export const rejectReview = (data: RejectParams) => request.post<KbReply<string>>('/Kb/v1/review/reject', data)
|
||||||
|
|
||||||
/** 获取文档详情 */
|
export const resourceTypeOptions: Array<{ label: string; value: ReviewResourceType }> = [
|
||||||
export const fetchDocumentDetail = (id: number) => request.get<ApiResponse<DocumentResource>>(`/Kb/v1/document/${id}`)
|
|
||||||
|
|
||||||
/** 获取FAQ详情 */
|
|
||||||
export const fetchFaqDetail = (id: number) => request.get<ApiResponse<FaqResource>>(`/Kb/v1/faq/${id}`)
|
|
||||||
|
|
||||||
/** 资源类型选项 */
|
|
||||||
export const resourceTypeOptions = [
|
|
||||||
{ label: '全部', value: 'all' },
|
{ label: '全部', value: 'all' },
|
||||||
{ label: '文档', value: 'document' },
|
{ label: '文档', value: 'document' },
|
||||||
{ label: 'FAQ', value: 'faq' },
|
{ label: 'FAQ', value: 'faq' },
|
||||||
]
|
]
|
||||||
|
|
||||||
/** 获取资源类型文本 */
|
/** 获取审核资源类型文案。 */
|
||||||
export const getResourceTypeText = (type: string): string => {
|
export const getResourceTypeText = (type: string) => ({ document: '文档', faq: 'FAQ' })[type] || type
|
||||||
const typeMap: Record<string, string> = {
|
|
||||||
document: '文档',
|
|
||||||
faq: 'FAQ',
|
|
||||||
}
|
|
||||||
return typeMap[type] || type
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 获取资源类型颜色 */
|
/** 获取审核资源类型颜色。 */
|
||||||
export const getResourceTypeColor = (type: string): string => {
|
export const getResourceTypeColor = (type: string) => ({ document: 'arcoblue', faq: 'green' })[type] || 'gray'
|
||||||
const colorMap: Record<string, string> = {
|
|
||||||
document: 'arcoblue',
|
|
||||||
faq: 'green',
|
|
||||||
}
|
|
||||||
return colorMap[type] || 'gray'
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,26 +1,14 @@
|
|||||||
import { request } from '@/api/request'
|
import { request } from '@/api/request'
|
||||||
|
import type { KbReply } from './faq'
|
||||||
|
|
||||||
/** API响应包装类型 */
|
export type TrashResourceType = 'document' | 'faq'
|
||||||
export interface ApiResponse<T = any> {
|
|
||||||
code: number
|
|
||||||
message: string
|
|
||||||
data: T
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 分页响应类型 */
|
/** 回收站记录。 */
|
||||||
export interface PaginatedResponse<T> {
|
|
||||||
total: number
|
|
||||||
page: number
|
|
||||||
page_size: number
|
|
||||||
data: T[]
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 回收站记录 */
|
|
||||||
export interface TrashRecord {
|
export interface TrashRecord {
|
||||||
id: number
|
id: number
|
||||||
created_at: string
|
created_at: string
|
||||||
updated_at: string
|
updated_at: string
|
||||||
resource_type: 'document' | 'faq'
|
resource_type: TrashResourceType
|
||||||
resource_id: number
|
resource_id: number
|
||||||
resource_name: string
|
resource_name: string
|
||||||
deleted_by: number
|
deleted_by: number
|
||||||
@@ -31,58 +19,35 @@ export interface TrashRecord {
|
|||||||
remarks: string
|
remarks: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 获取回收站列表参数 */
|
export interface TrashPage {
|
||||||
|
total: number
|
||||||
|
page: number
|
||||||
|
page_size: number
|
||||||
|
data: TrashRecord[]
|
||||||
|
}
|
||||||
|
|
||||||
export interface FetchTrashListParams {
|
export interface FetchTrashListParams {
|
||||||
page?: number
|
page?: number
|
||||||
page_size?: number
|
page_size?: number
|
||||||
resource_type?: 'document' | 'faq'
|
resource_type?: TrashResourceType
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 恢复资源请求参数 */
|
export const resourceTypeOptions: Array<{ label: string; value: TrashResourceType }> = [
|
||||||
export interface RestoreTrashParams {
|
|
||||||
id: number
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 彻底删除请求参数 */
|
|
||||||
export interface DeleteTrashParams {
|
|
||||||
id: number
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 资源类型选项 */
|
|
||||||
export const resourceTypeOptions = [
|
|
||||||
{ label: '文档', value: 'document' },
|
{ label: '文档', value: 'document' },
|
||||||
{ label: '常见问题', value: 'faq' },
|
{ label: 'FAQ', value: 'faq' },
|
||||||
]
|
]
|
||||||
|
|
||||||
/** 获取资源类型文本 */
|
/** 获取回收站资源类型文案。 */
|
||||||
export const getResourceTypeText = (type: string): string => {
|
export const getResourceTypeText = (type: string) => ({ document: '文档', faq: 'FAQ' })[type] || type
|
||||||
const typeMap: Record<string, string> = {
|
|
||||||
document: '文档',
|
|
||||||
faq: '常见问题',
|
|
||||||
}
|
|
||||||
return typeMap[type] || type
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 获取资源类型颜色 */
|
/** 获取回收站资源类型颜色。 */
|
||||||
export const getResourceTypeColor = (type: string): string => {
|
export const getResourceTypeColor = (type: string) => ({ document: 'blue', faq: 'green' })[type] || 'gray'
|
||||||
const colorMap: Record<string, string> = {
|
|
||||||
document: 'blue',
|
|
||||||
faq: 'green',
|
|
||||||
}
|
|
||||||
return colorMap[type] || 'gray'
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 获取回收站列表 */
|
/** 获取回收站列表。 */
|
||||||
export const fetchTrashList = (params?: FetchTrashListParams) => {
|
export const fetchTrashList = (params: FetchTrashListParams) => request.get<KbReply<TrashPage>>('/Kb/v1/trash/list', { params })
|
||||||
return request.get<ApiResponse<PaginatedResponse<TrashRecord>>>('/Kb/v1/trash/list', { params })
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 恢复资源 */
|
/** 恢复资源。 */
|
||||||
export const restoreTrash = (data: RestoreTrashParams) => {
|
export const restoreTrash = (id: number) => request.post<KbReply<string>>('/Kb/v1/trash/restore', { id })
|
||||||
return request.post<ApiResponse<string>>('/Kb/v1/trash/restore', data)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 彻底删除 */
|
/** 彻底删除资源。 */
|
||||||
export const deleteTrash = (data: DeleteTrashParams) => {
|
export const deleteTrash = (id: number) => request.post<KbReply<string>>('/Kb/v1/trash/delete', { id })
|
||||||
return request.post<ApiResponse<string>>('/Kb/v1/trash/delete', data)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -953,13 +953,13 @@ export const localMenuFlatItems: MenuItem[] = [
|
|||||||
{
|
{
|
||||||
id: 64,
|
id: 64,
|
||||||
identity: '019b591d-03e8-7d4b-b8cf-7a5142320c61',
|
identity: '019b591d-03e8-7d4b-b8cf-7a5142320c61',
|
||||||
title: '标签管理',
|
title: '公共分类',
|
||||||
title_en: 'Tag Management',
|
title_en: 'Category Management',
|
||||||
code: 'ops:知识库管理:标签管理',
|
code: 'ops:知识库管理:公共分类',
|
||||||
description: '知识库管理 - 标签管理',
|
description: '知识库管理 - 公共分类',
|
||||||
app_id: 2,
|
app_id: 2,
|
||||||
parent_id: 63,
|
parent_id: 63,
|
||||||
menu_path: '/kb/tags',
|
menu_path: '/kb/categories',
|
||||||
menu_icon: 'appstore',
|
menu_icon: 'appstore',
|
||||||
type: 1,
|
type: 1,
|
||||||
sort_key: 46,
|
sort_key: 46,
|
||||||
|
|||||||
@@ -1125,13 +1125,13 @@ export const localMenuItems: MenuItem[] = [
|
|||||||
{
|
{
|
||||||
id: 64,
|
id: 64,
|
||||||
identity: '019b591d-03e8-7d4b-b8cf-7a5142320c61',
|
identity: '019b591d-03e8-7d4b-b8cf-7a5142320c61',
|
||||||
title: '标签管理',
|
title: '公共分类',
|
||||||
title_en: 'Tag Management',
|
title_en: 'Category Management',
|
||||||
code: 'ops:知识库管理:标签管理',
|
code: 'ops:知识库管理:公共分类',
|
||||||
description: '知识库管理 - 标签管理',
|
description: '知识库管理 - 公共分类',
|
||||||
app_id: 2,
|
app_id: 2,
|
||||||
parent_id: 63,
|
parent_id: 63,
|
||||||
menu_path: '/kb/tags',
|
menu_path: '/kb/categories',
|
||||||
menu_icon: 'appstore',
|
menu_icon: 'appstore',
|
||||||
type: 1,
|
type: 1,
|
||||||
sort_key: 11,
|
sort_key: 11,
|
||||||
|
|||||||
363
src/views/ops/pages/kb/categories/index.vue
Normal file
363
src/views/ops/pages/kb/categories/index.vue
Normal file
@@ -0,0 +1,363 @@
|
|||||||
|
<template>
|
||||||
|
<div class="category-page">
|
||||||
|
<Breadcrumb :items="['知识管理', '公共分类']" />
|
||||||
|
<div class="category-layout">
|
||||||
|
<a-card class="tree-card" title="分类树" :bordered="false">
|
||||||
|
<template #extra>
|
||||||
|
<a-space>
|
||||||
|
<a-button v-if="canManage" type="primary" size="small" @click="openCreate(0)">新增一级分类</a-button>
|
||||||
|
<a-button size="small" :loading="loading" @click="loadTree">刷新</a-button>
|
||||||
|
</a-space>
|
||||||
|
</template>
|
||||||
|
<a-spin :loading="loading" class="tree-loading">
|
||||||
|
<a-tree
|
||||||
|
v-if="treeData.length"
|
||||||
|
v-model:selected-keys="selectedKeys"
|
||||||
|
:data="treeData"
|
||||||
|
block-node
|
||||||
|
default-expand-all
|
||||||
|
@select="handleSelect"
|
||||||
|
>
|
||||||
|
<template #title="nodeData">
|
||||||
|
<span>{{ nodeData.title }}</span>
|
||||||
|
<a-tag v-if="nodeData.category.status === 'inactive'" color="gray" size="small">停用</a-tag>
|
||||||
|
</template>
|
||||||
|
</a-tree>
|
||||||
|
<a-empty v-else description="暂无公共分类" />
|
||||||
|
</a-spin>
|
||||||
|
</a-card>
|
||||||
|
|
||||||
|
<a-card class="detail-card" :bordered="false">
|
||||||
|
<template #title>{{ selectedCategory?.name || '分类详情' }}</template>
|
||||||
|
<template #extra>
|
||||||
|
<a-space v-if="selectedCategory && canManage">
|
||||||
|
<a-button v-if="selectedCategory.level < 3" type="primary" size="small" @click="openCreate(selectedCategory.id)">
|
||||||
|
新增子分类
|
||||||
|
</a-button>
|
||||||
|
<a-button size="small" @click="openEdit">编辑</a-button>
|
||||||
|
<a-button size="small" status="danger" @click="confirmDelete">删除</a-button>
|
||||||
|
</a-space>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<a-descriptions v-if="selectedCategory" :column="2" bordered>
|
||||||
|
<a-descriptions-item label="分类名称">{{ selectedCategory.name }}</a-descriptions-item>
|
||||||
|
<a-descriptions-item label="状态">
|
||||||
|
<a-tag :color="selectedCategory.status === 'active' ? 'green' : 'gray'">
|
||||||
|
{{ selectedCategory.status === 'active' ? '启用' : '停用' }}
|
||||||
|
</a-tag>
|
||||||
|
</a-descriptions-item>
|
||||||
|
<a-descriptions-item label="层级">第 {{ selectedCategory.level }} 级</a-descriptions-item>
|
||||||
|
<a-descriptions-item label="排序">{{ selectedCategory.sort_order }}</a-descriptions-item>
|
||||||
|
<a-descriptions-item label="文档数">{{ selectedCategory.doc_count }}</a-descriptions-item>
|
||||||
|
<a-descriptions-item label="FAQ 数">{{ selectedCategory.faq_count }}</a-descriptions-item>
|
||||||
|
<a-descriptions-item label="创建人">{{ selectedCategory.creator_name || '-' }}</a-descriptions-item>
|
||||||
|
<a-descriptions-item label="更新时间">{{ formatTime(selectedCategory.updated_at) }}</a-descriptions-item>
|
||||||
|
<a-descriptions-item label="描述" :span="2">{{ selectedCategory.description || '-' }}</a-descriptions-item>
|
||||||
|
<a-descriptions-item label="备注" :span="2">{{ selectedCategory.remarks || '-' }}</a-descriptions-item>
|
||||||
|
</a-descriptions>
|
||||||
|
<a-empty v-else description="请从左侧选择分类" />
|
||||||
|
</a-card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a-modal
|
||||||
|
v-model:visible="formVisible"
|
||||||
|
:title="editingCategory ? '编辑分类' : '新增分类'"
|
||||||
|
:ok-loading="submitting"
|
||||||
|
width="640px"
|
||||||
|
@ok="submitForm"
|
||||||
|
@cancel="closeForm"
|
||||||
|
>
|
||||||
|
<a-form ref="formRef" :model="form" layout="vertical">
|
||||||
|
<a-form-item label="分类名称" field="name" :rules="[{ required: true, message: '请输入分类名称' }]">
|
||||||
|
<a-input v-model="form.name" :max-length="100" placeholder="请输入分类名称" />
|
||||||
|
</a-form-item>
|
||||||
|
<a-row :gutter="16">
|
||||||
|
<a-col :span="12">
|
||||||
|
<a-form-item label="父分类" field="parent_id">
|
||||||
|
<a-select v-model="form.parent_id" placeholder="请选择父分类">
|
||||||
|
<a-option :value="0">无(一级分类)</a-option>
|
||||||
|
<a-option v-for="option in parentOptions" :key="option.id" :value="option.id">
|
||||||
|
{{ option.label }}{{ option.status === 'inactive' ? '(停用)' : '' }}
|
||||||
|
</a-option>
|
||||||
|
</a-select>
|
||||||
|
</a-form-item>
|
||||||
|
</a-col>
|
||||||
|
<a-col :span="6">
|
||||||
|
<a-form-item label="排序" field="sort_order">
|
||||||
|
<a-input-number v-model="form.sort_order" :min="0" style="width: 100%" />
|
||||||
|
</a-form-item>
|
||||||
|
</a-col>
|
||||||
|
<a-col :span="6">
|
||||||
|
<a-form-item label="状态" field="status">
|
||||||
|
<a-select v-model="form.status">
|
||||||
|
<a-option value="active">启用</a-option>
|
||||||
|
<a-option value="inactive">停用</a-option>
|
||||||
|
</a-select>
|
||||||
|
</a-form-item>
|
||||||
|
</a-col>
|
||||||
|
</a-row>
|
||||||
|
<a-form-item label="描述" field="description">
|
||||||
|
<a-textarea v-model="form.description" :max-length="500" :auto-size="{ minRows: 3, maxRows: 6 }" />
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="备注" field="remarks">
|
||||||
|
<a-textarea v-model="form.remarks" :max-length="500" :auto-size="{ minRows: 2, maxRows: 4 }" />
|
||||||
|
</a-form-item>
|
||||||
|
</a-form>
|
||||||
|
</a-modal>
|
||||||
|
</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 { FormInstance } from '@arco-design/web-vue'
|
||||||
|
import {
|
||||||
|
createCategory,
|
||||||
|
deleteCategory,
|
||||||
|
fetchCategoryTree,
|
||||||
|
updateCategory,
|
||||||
|
type Category,
|
||||||
|
type CategoryStatus,
|
||||||
|
type CategoryTreeNode,
|
||||||
|
} from '@/api/kb/category'
|
||||||
|
import usePermissionCodes from '@/hooks/usePermissionCodes'
|
||||||
|
|
||||||
|
interface TreeViewNode {
|
||||||
|
key: string
|
||||||
|
title: string
|
||||||
|
category: Category
|
||||||
|
children: TreeViewNode[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ParentOption {
|
||||||
|
id: number
|
||||||
|
label: string
|
||||||
|
level: number
|
||||||
|
status: CategoryStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
const { hasPermission } = usePermissionCodes()
|
||||||
|
const canManage = computed(() => hasPermission('kb:content:manage'))
|
||||||
|
const loading = ref(false)
|
||||||
|
const tree = ref<CategoryTreeNode[]>([])
|
||||||
|
const selectedKeys = ref<string[]>([])
|
||||||
|
const selectedCategory = ref<Category | null>(null)
|
||||||
|
let requestSequence = 0
|
||||||
|
|
||||||
|
const treeData = computed<TreeViewNode[]>(() => tree.value.map((node) => toViewNode(node)))
|
||||||
|
|
||||||
|
const formVisible = ref(false)
|
||||||
|
const submitting = ref(false)
|
||||||
|
const formRef = ref<FormInstance>()
|
||||||
|
const editingCategory = ref<Category | null>(null)
|
||||||
|
const form = reactive({
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
parent_id: 0,
|
||||||
|
sort_order: 0,
|
||||||
|
status: 'active' as CategoryStatus,
|
||||||
|
remarks: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const parentOptions = computed<ParentOption[]>(() => {
|
||||||
|
const excluded = editingCategory.value ? descendantIds(editingCategory.value.id) : new Set<number>()
|
||||||
|
if (editingCategory.value) excluded.add(editingCategory.value.id)
|
||||||
|
const subtreeHeight = editingCategory.value ? categorySubtreeHeight(editingCategory.value.id) : 1
|
||||||
|
return flattenTree(tree.value).filter((item) => !excluded.has(item.id) && item.level + subtreeHeight <= 3)
|
||||||
|
})
|
||||||
|
|
||||||
|
function toViewNode(node: CategoryTreeNode): TreeViewNode {
|
||||||
|
return {
|
||||||
|
key: String(node.id),
|
||||||
|
title: node.name,
|
||||||
|
category: node,
|
||||||
|
children: (node.children || []).map(toViewNode),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function flattenTree(nodes: CategoryTreeNode[], prefix = ''): ParentOption[] {
|
||||||
|
return nodes.flatMap((node) => {
|
||||||
|
const label = prefix ? `${prefix} / ${node.name}` : node.name
|
||||||
|
return [{ id: node.id, label, level: node.level, status: node.status }, ...flattenTree(node.children || [], label)]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function findCategory(nodes: CategoryTreeNode[], id: number): CategoryTreeNode | undefined {
|
||||||
|
for (const node of nodes) {
|
||||||
|
if (node.id === id) return node
|
||||||
|
const child = findCategory(node.children || [], id)
|
||||||
|
if (child) return child
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function descendantIds(id: number): Set<number> {
|
||||||
|
const result = new Set<number>()
|
||||||
|
const root = findCategory(tree.value, id)
|
||||||
|
const visit = (nodes: CategoryTreeNode[]) => {
|
||||||
|
nodes.forEach((node) => {
|
||||||
|
result.add(node.id)
|
||||||
|
visit(node.children || [])
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (root) visit(root.children || [])
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
function categorySubtreeHeight(id: number): number {
|
||||||
|
const root = findCategory(tree.value, id)
|
||||||
|
if (!root?.children?.length) return 1
|
||||||
|
return 1 + Math.max(...root.children.map((child) => categorySubtreeHeight(child.id)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 加载最新分类树,并保留仍存在的选中项。 */
|
||||||
|
async function loadTree() {
|
||||||
|
const sequence = ++requestSequence
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const reply = await fetchCategoryTree()
|
||||||
|
if (sequence !== requestSequence) return
|
||||||
|
if (reply.code !== 0) throw new Error(reply.message || '获取分类树失败')
|
||||||
|
tree.value = reply.details || []
|
||||||
|
const selectedID = selectedCategory.value?.id
|
||||||
|
const selected = selectedID ? findCategory(tree.value, selectedID) : tree.value[0]
|
||||||
|
selectedCategory.value = selected || null
|
||||||
|
selectedKeys.value = selected ? [String(selected.id)] : []
|
||||||
|
} catch (error) {
|
||||||
|
if (sequence === requestSequence) {
|
||||||
|
tree.value = []
|
||||||
|
selectedCategory.value = null
|
||||||
|
selectedKeys.value = []
|
||||||
|
showRequestError(error, '获取分类树失败')
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (sequence === requestSequence) loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSelect(keys: Array<string | number>) {
|
||||||
|
const id = Number(keys[0])
|
||||||
|
selectedCategory.value = Number.isFinite(id) ? findCategory(tree.value, id) || null : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreate(parentID: number) {
|
||||||
|
editingCategory.value = null
|
||||||
|
Object.assign(form, { name: '', description: '', parent_id: parentID, sort_order: 0, status: 'active', remarks: '' })
|
||||||
|
formVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit() {
|
||||||
|
if (!selectedCategory.value) return
|
||||||
|
editingCategory.value = selectedCategory.value
|
||||||
|
Object.assign(form, {
|
||||||
|
name: selectedCategory.value.name,
|
||||||
|
description: selectedCategory.value.description,
|
||||||
|
parent_id: selectedCategory.value.parent_id,
|
||||||
|
sort_order: selectedCategory.value.sort_order,
|
||||||
|
status: selectedCategory.value.status,
|
||||||
|
remarks: selectedCategory.value.remarks,
|
||||||
|
})
|
||||||
|
formVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeForm() {
|
||||||
|
formVisible.value = false
|
||||||
|
editingCategory.value = null
|
||||||
|
formRef.value?.clearValidate()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitForm() {
|
||||||
|
if (await formRef.value?.validate()) return false
|
||||||
|
submitting.value = true
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
name: form.name.trim(),
|
||||||
|
description: form.description.trim(),
|
||||||
|
icon: editingCategory.value?.icon || '',
|
||||||
|
color: editingCategory.value?.color || '',
|
||||||
|
parent_id: form.parent_id,
|
||||||
|
sort_order: form.sort_order,
|
||||||
|
status: form.status,
|
||||||
|
remarks: form.remarks.trim(),
|
||||||
|
}
|
||||||
|
const reply = editingCategory.value ? await updateCategory({ ...payload, id: editingCategory.value.id }) : await createCategory(payload)
|
||||||
|
if (reply.code !== 0) throw new Error(reply.message || '保存分类失败')
|
||||||
|
Message.success(editingCategory.value ? '分类已更新' : '分类已创建')
|
||||||
|
closeForm()
|
||||||
|
await loadTree()
|
||||||
|
return true
|
||||||
|
} catch (error) {
|
||||||
|
showRequestError(error, '保存分类失败')
|
||||||
|
return false
|
||||||
|
} finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmDelete() {
|
||||||
|
if (!selectedCategory.value) return
|
||||||
|
const category = selectedCategory.value
|
||||||
|
Modal.confirm({
|
||||||
|
title: '确认删除分类',
|
||||||
|
content: `确认删除「${category.name}」吗?存在子分类或内容时服务端将拒绝删除。`,
|
||||||
|
onOk: async () => {
|
||||||
|
try {
|
||||||
|
const reply = await deleteCategory(category.id)
|
||||||
|
if (reply.code !== 0) throw new Error(reply.message || '删除分类失败')
|
||||||
|
Message.success('分类已删除')
|
||||||
|
selectedCategory.value = null
|
||||||
|
await loadTree()
|
||||||
|
} catch (error) {
|
||||||
|
showRequestError(error, '删除分类失败')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function showRequestError(error: unknown, fallback: string) {
|
||||||
|
if (axios.isAxiosError(error)) {
|
||||||
|
const status = error.response?.status
|
||||||
|
const message = (error.response?.data as { message?: string } | undefined)?.message
|
||||||
|
if (status === 403) return Message.error('无操作权限')
|
||||||
|
if (status === 400 || status === 409) return Message.error(message || fallback)
|
||||||
|
}
|
||||||
|
Message.error(error instanceof Error && error.message ? error.message : fallback)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(value: string) {
|
||||||
|
return value ? dayjs(value).format('YYYY-MM-DD HH:mm') : '-'
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(loadTree)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="less">
|
||||||
|
.category-page {
|
||||||
|
padding: 0 20px 20px;
|
||||||
|
}
|
||||||
|
.category-layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 360px minmax(0, 1fr);
|
||||||
|
gap: 16px;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
.tree-card,
|
||||||
|
.detail-card {
|
||||||
|
min-height: 560px;
|
||||||
|
}
|
||||||
|
.tree-loading {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
:deep(.arco-tree-node-title) {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
@media (max-width: 960px) {
|
||||||
|
.category-layout {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,392 +1,259 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="container">
|
<div class="favorite-page">
|
||||||
<a-card class="general-card" title="收藏管理">
|
<Breadcrumb :items="['知识管理', '我的收藏']" />
|
||||||
<!-- 数据表格 -->
|
<a-card class="general-card" :bordered="false">
|
||||||
|
<template #title>我的收藏</template>
|
||||||
|
<template #extra><a-button :loading="loading" @click="loadFavorites">刷新</a-button></template>
|
||||||
|
|
||||||
|
<a-form :model="filters" layout="inline" class="filters" @submit-success="search">
|
||||||
|
<a-form-item label="资源类型">
|
||||||
|
<a-select v-model="filters.resource_type" placeholder="全部类型" allow-clear style="width: 160px">
|
||||||
|
<a-option value="document">文档</a-option>
|
||||||
|
<a-option value="faq">FAQ</a-option>
|
||||||
|
</a-select>
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item><a-button type="primary" html-type="submit">查询</a-button></a-form-item>
|
||||||
|
</a-form>
|
||||||
|
|
||||||
<a-table
|
<a-table
|
||||||
:data="tableData"
|
row-key="id"
|
||||||
|
:data="favorites"
|
||||||
:columns="columns"
|
:columns="columns"
|
||||||
:loading="loading"
|
:loading="loading"
|
||||||
:pagination="pagination"
|
:pagination="pagination"
|
||||||
row-key="id"
|
@page-change="changePage"
|
||||||
@page-change="handlePageChange"
|
@page-size-change="changePageSize"
|
||||||
>
|
>
|
||||||
<!-- 序号 -->
|
|
||||||
<template #index="{ rowIndex }">
|
|
||||||
{{ rowIndex + 1 + (pagination.current - 1) * pagination.pageSize }}
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<!-- 资源类型 -->
|
|
||||||
<template #resource_type="{ record }">
|
<template #resource_type="{ record }">
|
||||||
<a-tag :color="record.resource_type === 'document' ? 'blue' : 'green'">
|
|
||||||
{{ record.resource_type === 'document' ? '文档' : 'FAQ' }}
|
|
||||||
</a-tag>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<!-- 收藏时间 -->
|
|
||||||
<template #created_at="{ record }">
|
|
||||||
{{ formatDateTime(record.created_at) }}
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<!-- 操作 -->
|
|
||||||
<template #actions="{ record }">
|
|
||||||
<a-space>
|
<a-space>
|
||||||
<a-button type="text" size="small" :disabled="record.is_deleted" @click="handleView(record)">查看</a-button>
|
<a-tag :color="record.resource_type === 'document' ? 'blue' : 'green'">
|
||||||
<a-button type="text" size="small" :disabled="record.is_deleted" @click="handleDownload(record)">下载</a-button>
|
{{ record.resource_type === 'document' ? '文档' : 'FAQ' }}
|
||||||
<a-button type="text" size="small" status="danger" @click="handleUncollect(record)">取消收藏</a-button>
|
</a-tag>
|
||||||
|
<a-tag v-if="record.is_deleted" color="red">已删除</a-tag>
|
||||||
</a-space>
|
</a-space>
|
||||||
</template>
|
</template>
|
||||||
|
<template #created_at="{ record }">{{ formatTime(record.created_at) }}</template>
|
||||||
|
<template #actions="{ record }">
|
||||||
|
<a-space>
|
||||||
|
<a-button type="text" size="small" :disabled="record.is_deleted" @click="openDetail(record)">查看</a-button>
|
||||||
|
<a-button type="text" size="small" status="danger" :loading="actionID === record.id" @click="confirmUncollect(record)">
|
||||||
|
取消收藏
|
||||||
|
</a-button>
|
||||||
|
</a-space>
|
||||||
|
</template>
|
||||||
|
<template #empty><a-empty description="暂无收藏" /></template>
|
||||||
</a-table>
|
</a-table>
|
||||||
</a-card>
|
</a-card>
|
||||||
|
|
||||||
<!-- 文档详情对话框 -->
|
<a-drawer v-model:visible="detailVisible" :title="detailType === 'document' ? '文档详情' : 'FAQ 详情'" :width="720" :footer="false">
|
||||||
<a-modal v-model:visible="detailVisible" title="文档详情" :width="800" :footer="false" unmount-on-close>
|
<a-spin :loading="detailLoading" class="detail-spin">
|
||||||
<div v-if="currentResource" class="detail-content">
|
<a-descriptions v-if="detailType === 'document' && detailDocument" :column="2" bordered>
|
||||||
<a-descriptions :column="2" bordered>
|
<a-descriptions-item label="标题" :span="2">{{ detailDocument.title }}</a-descriptions-item>
|
||||||
<a-descriptions-item label="资源名称">
|
<a-descriptions-item label="作者">{{ detailDocument.author_name || `用户 ${detailDocument.author_id}` }}</a-descriptions-item>
|
||||||
{{ currentResource.title || '-' }}
|
<a-descriptions-item label="状态">{{ statusText(detailDocument.status) }}</a-descriptions-item>
|
||||||
</a-descriptions-item>
|
<a-descriptions-item label="描述" :span="2">{{ detailDocument.description || '-' }}</a-descriptions-item>
|
||||||
<a-descriptions-item label="资源类型">
|
<a-descriptions-item label="正文" :span="2">
|
||||||
<a-tag color="blue">文档</a-tag>
|
<pre class="content-preview">{{ detailDocument.content }}</pre>
|
||||||
</a-descriptions-item>
|
|
||||||
<a-descriptions-item label="作者">
|
|
||||||
{{ currentResource.author_name || '-' }}
|
|
||||||
</a-descriptions-item>
|
|
||||||
<a-descriptions-item label="状态">
|
|
||||||
<a-tag :color="getDocStatusColor(currentResource.status)">
|
|
||||||
{{ getDocStatusText(currentResource.status) }}
|
|
||||||
</a-tag>
|
|
||||||
</a-descriptions-item>
|
|
||||||
<a-descriptions-item label="发布时间">
|
|
||||||
{{ formatDateTime(currentResource.published_at) }}
|
|
||||||
</a-descriptions-item>
|
|
||||||
<a-descriptions-item label="浏览次数">
|
|
||||||
{{ currentResource.view_count || 0 }}
|
|
||||||
</a-descriptions-item>
|
|
||||||
<a-descriptions-item label="描述" :span="2">
|
|
||||||
{{ currentResource.description || '-' }}
|
|
||||||
</a-descriptions-item>
|
|
||||||
<a-descriptions-item label="内容" :span="2">
|
|
||||||
<div class="content-preview" v-html="currentResource.content || '-'"></div>
|
|
||||||
</a-descriptions-item>
|
</a-descriptions-item>
|
||||||
</a-descriptions>
|
</a-descriptions>
|
||||||
</div>
|
<a-descriptions v-else-if="detailType === 'faq' && detailFaq" :column="2" bordered>
|
||||||
</a-modal>
|
<a-descriptions-item label="问题" :span="2">{{ detailFaq.question }}</a-descriptions-item>
|
||||||
|
<a-descriptions-item label="作者">{{ detailFaq.author_name || `用户 ${detailFaq.author_id}` }}</a-descriptions-item>
|
||||||
<!-- FAQ详情对话框 -->
|
<a-descriptions-item label="状态">{{ statusText(detailFaq.status) }}</a-descriptions-item>
|
||||||
<a-modal v-model:visible="faqDetailVisible" title="FAQ详情" :width="800" :footer="false" unmount-on-close>
|
|
||||||
<div v-if="currentFaq" class="detail-content">
|
|
||||||
<a-descriptions :column="2" bordered>
|
|
||||||
<a-descriptions-item label="资源名称">
|
|
||||||
{{ currentFaq.question || '-' }}
|
|
||||||
</a-descriptions-item>
|
|
||||||
<a-descriptions-item label="资源类型">
|
|
||||||
<a-tag color="green">FAQ</a-tag>
|
|
||||||
</a-descriptions-item>
|
|
||||||
<a-descriptions-item label="状态">
|
|
||||||
<a-tag color="green">{{ currentFaq.status || '已发布' }}</a-tag>
|
|
||||||
</a-descriptions-item>
|
|
||||||
<a-descriptions-item label="浏览次数">
|
|
||||||
{{ currentFaq.view_count || 0 }}
|
|
||||||
</a-descriptions-item>
|
|
||||||
<a-descriptions-item label="答案" :span="2">
|
<a-descriptions-item label="答案" :span="2">
|
||||||
<div class="content-preview" v-html="currentFaq.answer || '-'"></div>
|
<pre class="content-preview">{{ detailFaq.answer }}</pre>
|
||||||
</a-descriptions-item>
|
</a-descriptions-item>
|
||||||
|
<a-descriptions-item label="解决方案" :span="2">
|
||||||
|
<pre class="content-preview">{{ detailFaq.solution || '-' }}</pre>
|
||||||
|
</a-descriptions-item>
|
||||||
|
<a-descriptions-item label="处理步骤" :span="2">
|
||||||
|
<pre class="content-preview">{{ detailFaq.process_steps || '-' }}</pre>
|
||||||
|
</a-descriptions-item>
|
||||||
|
<a-descriptions-item label="适用范围" :span="2">{{ detailFaq.applicable_scope || '-' }}</a-descriptions-item>
|
||||||
</a-descriptions>
|
</a-descriptions>
|
||||||
</div>
|
</a-spin>
|
||||||
</a-modal>
|
</a-drawer>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { ref, reactive, computed, onMounted } from 'vue'
|
import { onMounted, reactive, ref } from 'vue'
|
||||||
|
import axios from 'axios'
|
||||||
|
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 type { TableColumnData } from '@arco-design/web-vue/es/table/interface'
|
||||||
import { fetchFavoriteList, uncollectResource, type Favorite, type ResourceType } from '@/api/kb/favorite'
|
import { fetchFavoriteList, uncollectResource, type Favorite, type ResourceType } from '@/api/kb/favorite'
|
||||||
import { request } from '@/api/request'
|
import { fetchDocumentDetail, type Document, type DocumentStatus } from '@/api/kb/document'
|
||||||
|
import { fetchFaqDetail, type Faq } from '@/api/kb/faq'
|
||||||
|
|
||||||
// 状态管理
|
const columns: TableColumnData[] = [
|
||||||
|
{ title: '资源名称', dataIndex: 'resource_name', ellipsis: true, tooltip: true },
|
||||||
|
{ title: '资源类型', dataIndex: 'resource_type', slotName: 'resource_type', width: 170, align: 'center' },
|
||||||
|
{ title: '收藏备注', dataIndex: 'remarks', ellipsis: true, tooltip: true },
|
||||||
|
{ title: '收藏时间', dataIndex: 'created_at', slotName: 'created_at', width: 180, align: 'center' },
|
||||||
|
{ title: '操作', slotName: 'actions', width: 180, fixed: 'right', align: 'center' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const filters = reactive<{ resource_type?: ResourceType }>({ resource_type: undefined })
|
||||||
|
const favorites = ref<Favorite[]>([])
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const tableData = ref<Favorite[]>([])
|
const pagination = reactive({ current: 1, pageSize: 20, total: 0, showTotal: true, showPageSize: true })
|
||||||
|
let listSequence = 0
|
||||||
|
const actionID = ref(0)
|
||||||
|
|
||||||
const pagination = reactive({
|
|
||||||
current: 1,
|
|
||||||
pageSize: 20,
|
|
||||||
total: 0,
|
|
||||||
})
|
|
||||||
|
|
||||||
// 表格列配置
|
|
||||||
const columns = computed<TableColumnData[]>(() => [
|
|
||||||
{
|
|
||||||
title: '序号',
|
|
||||||
dataIndex: 'index',
|
|
||||||
slotName: 'index',
|
|
||||||
width: 70,
|
|
||||||
align: 'center',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '资源名称',
|
|
||||||
dataIndex: 'resource_name',
|
|
||||||
ellipsis: true,
|
|
||||||
tooltip: true,
|
|
||||||
width: 250,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '资源类型',
|
|
||||||
dataIndex: 'resource_type',
|
|
||||||
slotName: 'resource_type',
|
|
||||||
width: 100,
|
|
||||||
align: 'center',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '收藏时间',
|
|
||||||
dataIndex: 'created_at',
|
|
||||||
slotName: 'created_at',
|
|
||||||
width: 180,
|
|
||||||
align: 'center',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '操作',
|
|
||||||
slotName: 'actions',
|
|
||||||
width: 200,
|
|
||||||
fixed: 'right',
|
|
||||||
},
|
|
||||||
])
|
|
||||||
|
|
||||||
// 当前选中的资源
|
|
||||||
const currentResource = ref<any>(null)
|
|
||||||
const currentFaq = ref<any>(null)
|
|
||||||
|
|
||||||
// 对话框可见性
|
|
||||||
const detailVisible = ref(false)
|
const detailVisible = ref(false)
|
||||||
const faqDetailVisible = ref(false)
|
const detailLoading = ref(false)
|
||||||
|
const detailType = ref<ResourceType>('document')
|
||||||
|
const detailDocument = ref<Document | null>(null)
|
||||||
|
const detailFaq = ref<Faq | null>(null)
|
||||||
|
let detailSequence = 0
|
||||||
|
|
||||||
// 获取收藏列表
|
/** 加载当前用户收藏,忽略过期请求。 */
|
||||||
const fetchFavorites = async () => {
|
async function loadFavorites() {
|
||||||
|
const sequence = ++listSequence
|
||||||
loading.value = true
|
loading.value = true
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const params = {
|
const reply = await fetchFavoriteList({
|
||||||
page: pagination.current,
|
page: pagination.current,
|
||||||
page_size: pagination.pageSize,
|
page_size: pagination.pageSize,
|
||||||
}
|
resource_type: filters.resource_type,
|
||||||
|
})
|
||||||
const res: any = await fetchFavoriteList(params)
|
if (sequence !== listSequence) return
|
||||||
|
if (reply.code !== 0) throw new Error(reply.message || '获取收藏列表失败')
|
||||||
if (res.code === 0) {
|
favorites.value = reply.details?.data || []
|
||||||
tableData.value = res.details?.data || []
|
pagination.total = reply.details?.total || 0
|
||||||
pagination.total = res.details?.total || 0
|
} catch (error) {
|
||||||
} else {
|
if (sequence === listSequence) {
|
||||||
Message.error(res.message || '获取收藏列表失败')
|
favorites.value = []
|
||||||
tableData.value = []
|
|
||||||
pagination.total = 0
|
pagination.total = 0
|
||||||
|
showRequestError(error, '获取收藏列表失败')
|
||||||
}
|
}
|
||||||
} catch (error) {
|
|
||||||
console.error('获取收藏列表失败:', error)
|
|
||||||
Message.error('获取收藏列表失败')
|
|
||||||
tableData.value = []
|
|
||||||
pagination.total = 0
|
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
if (sequence === listSequence) loading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 分页变化
|
function search() {
|
||||||
const handlePageChange = (current: number) => {
|
pagination.current = 1
|
||||||
pagination.current = current
|
loadFavorites()
|
||||||
fetchFavorites()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 查看详情
|
function changePage(page: number) {
|
||||||
const handleView = async (record: Favorite) => {
|
pagination.current = page
|
||||||
|
loadFavorites()
|
||||||
|
}
|
||||||
|
|
||||||
|
function changePageSize(pageSize: number) {
|
||||||
|
pagination.current = 1
|
||||||
|
pagination.pageSize = pageSize
|
||||||
|
loadFavorites()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openDetail(record: Favorite) {
|
||||||
if (record.is_deleted) {
|
if (record.is_deleted) {
|
||||||
Message.warning('该资源已被删除,无法查看')
|
Message.warning('资源已删除,无法查看详情')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
const sequence = ++detailSequence
|
||||||
|
detailType.value = record.resource_type
|
||||||
|
detailDocument.value = null
|
||||||
|
detailFaq.value = null
|
||||||
|
detailVisible.value = true
|
||||||
|
detailLoading.value = true
|
||||||
try {
|
try {
|
||||||
if (record.resource_type === 'document') {
|
if (record.resource_type === 'document') {
|
||||||
// 如果有resource_data直接使用,否则请求详情
|
const cached = asDocument(record)
|
||||||
if (record.resource_data) {
|
if (cached) detailDocument.value = cached
|
||||||
currentResource.value = record.resource_data
|
else {
|
||||||
detailVisible.value = true
|
const reply = await fetchDocumentDetail(record.resource_id)
|
||||||
} else {
|
if (sequence !== detailSequence) return
|
||||||
const res = await request.get<any>(`/Kb/v1/document/${record.resource_id}`)
|
if (reply.code !== 0) throw new Error(reply.message || '获取文档详情失败')
|
||||||
if (res.code === 0) {
|
detailDocument.value = reply.details
|
||||||
currentResource.value = res.details
|
|
||||||
detailVisible.value = true
|
|
||||||
} else {
|
|
||||||
Message.error(res.message || '获取文档详情失败')
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} else if (record.resource_type === 'faq') {
|
} else {
|
||||||
// FAQ详情
|
const cached = asFaq(record)
|
||||||
if (record.resource_data) {
|
if (cached) detailFaq.value = cached
|
||||||
currentFaq.value = record.resource_data
|
else {
|
||||||
faqDetailVisible.value = true
|
const reply = await fetchFaqDetail(record.resource_id)
|
||||||
} else {
|
if (sequence !== detailSequence) return
|
||||||
const res = await request.get<any>(`/Kb/v1/faq/${record.resource_id}`)
|
if (reply.code !== 0) throw new Error(reply.message || '获取 FAQ 详情失败')
|
||||||
if (res.code === 0) {
|
detailFaq.value = reply.details
|
||||||
currentFaq.value = res.details
|
|
||||||
faqDetailVisible.value = true
|
|
||||||
} else {
|
|
||||||
Message.error(res.message || '获取FAQ详情失败')
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('获取详情失败:', error)
|
if (sequence === detailSequence) showRequestError(error, '获取收藏详情失败')
|
||||||
Message.error('获取详情失败')
|
} finally {
|
||||||
|
if (sequence === detailSequence) detailLoading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 下载文档
|
function asDocument(record: Favorite): Document | null {
|
||||||
const handleDownload = async (record: Favorite) => {
|
if (record.resource_type !== 'document' || !record.resource_data) return null
|
||||||
if (record.is_deleted) {
|
return record.resource_data as Document
|
||||||
Message.warning('该资源已被删除,无法下载')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (record.resource_type === 'document') {
|
|
||||||
// 调用文档下载接口
|
|
||||||
const res: any = await request.get<any>(`/Kb/v1/document/${record.resource_id}`)
|
|
||||||
if (res.code === 0) {
|
|
||||||
const doc = res.details
|
|
||||||
// 创建下载内容
|
|
||||||
const content = `# ${doc.title || '无标题'}\n\n## 描述\n${doc.description || '无描述'}\n\n## 内容\n${doc.content || '无内容'}`
|
|
||||||
const blob = new Blob([content], { type: 'text/markdown;charset=utf-8' })
|
|
||||||
const url = URL.createObjectURL(blob)
|
|
||||||
const link = document.createElement('a')
|
|
||||||
link.href = url
|
|
||||||
link.download = `${doc.title || 'document'}.md`
|
|
||||||
link.click()
|
|
||||||
URL.revokeObjectURL(url)
|
|
||||||
Message.success('下载成功')
|
|
||||||
} else {
|
|
||||||
Message.error(res.message || '获取文档失败')
|
|
||||||
}
|
|
||||||
} else if (record.resource_type === 'faq') {
|
|
||||||
// FAQ下载
|
|
||||||
const res = await request.get<any>(`/Kb/v1/faq/${record.resource_id}`)
|
|
||||||
if (res.code === 0) {
|
|
||||||
const faq = res.details
|
|
||||||
const content = `# ${faq.question || 'FAQ'}\n\n## 答案\n${faq.answer || '无答案'}`
|
|
||||||
const blob = new Blob([content], { type: 'text/markdown;charset=utf-8' })
|
|
||||||
const url = URL.createObjectURL(blob)
|
|
||||||
const link = document.createElement('a')
|
|
||||||
link.href = url
|
|
||||||
link.download = `FAQ-${faq.faq_no || 'unknown'}.md`
|
|
||||||
link.click()
|
|
||||||
URL.revokeObjectURL(url)
|
|
||||||
Message.success('下载成功')
|
|
||||||
} else {
|
|
||||||
Message.error(res.message || '获取FAQ失败')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('下载失败:', error)
|
|
||||||
Message.error('下载失败')
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 取消收藏
|
function asFaq(record: Favorite): Faq | null {
|
||||||
const handleUncollect = (record: Favorite) => {
|
if (record.resource_type !== 'faq' || !record.resource_data) return null
|
||||||
|
return record.resource_data as Faq
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmUncollect(record: Favorite) {
|
||||||
Modal.confirm({
|
Modal.confirm({
|
||||||
title: '确认取消收藏',
|
title: '确认取消收藏',
|
||||||
content: `确认取消收藏「${record.resource_name}」吗?`,
|
content: `确认取消收藏「${record.resource_name}」吗?`,
|
||||||
onOk: async () => {
|
onOk: async () => {
|
||||||
|
actionID.value = record.id
|
||||||
try {
|
try {
|
||||||
const res = await uncollectResource({
|
const reply = await uncollectResource({ resource_type: record.resource_type, resource_id: record.resource_id })
|
||||||
resource_type: record.resource_type,
|
if (reply.code !== 0) throw new Error(reply.message || '取消收藏失败')
|
||||||
resource_id: record.resource_id,
|
Message.success('已取消收藏')
|
||||||
})
|
await loadFavorites()
|
||||||
|
|
||||||
if (res.code === 0) {
|
|
||||||
Message.success('取消收藏成功')
|
|
||||||
fetchFavorites()
|
|
||||||
} else {
|
|
||||||
Message.error(res.message || '取消收藏失败')
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('取消收藏失败:', error)
|
showRequestError(error, '取消收藏失败')
|
||||||
Message.error('取消收藏失败')
|
} finally {
|
||||||
|
actionID.value = 0
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 格式化日期时间
|
function showRequestError(error: unknown, fallback: string) {
|
||||||
const formatDateTime = (dateStr?: string) => {
|
if (axios.isAxiosError(error)) {
|
||||||
if (!dateStr) return '-'
|
const status = error.response?.status
|
||||||
// 处理多种日期格式
|
const message = (error.response?.data as { message?: string } | undefined)?.message
|
||||||
let date: Date
|
if (status === 403) return Message.error('无操作权限')
|
||||||
if (dateStr.includes('T')) {
|
if (status === 400 || status === 409) return Message.error(message || fallback)
|
||||||
date = new Date(dateStr)
|
|
||||||
} else {
|
|
||||||
// 格式: YYYY-MM-DD HH:mm:ss
|
|
||||||
date = new Date(dateStr.replace(' ', 'T'))
|
|
||||||
}
|
}
|
||||||
|
Message.error(error instanceof Error && error.message ? error.message : fallback)
|
||||||
if (isNaN(date.getTime())) return dateStr
|
|
||||||
|
|
||||||
const year = date.getFullYear()
|
|
||||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
|
||||||
const day = String(date.getDate()).padStart(2, '0')
|
|
||||||
const hours = String(date.getHours()).padStart(2, '0')
|
|
||||||
const minutes = String(date.getMinutes()).padStart(2, '0')
|
|
||||||
|
|
||||||
return `${year}-${month}-${day} ${hours}:${minutes}`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取文档状态颜色
|
function statusText(status: DocumentStatus) {
|
||||||
const getDocStatusColor = (status?: string) => {
|
return { draft: '草稿', published: '待审核', reviewed: '已审核', rejected: '已拒绝' }[status]
|
||||||
const colorMap: Record<string, string> = {
|
|
||||||
draft: 'gray',
|
|
||||||
published: 'green',
|
|
||||||
reviewed: 'blue',
|
|
||||||
rejected: 'red',
|
|
||||||
}
|
|
||||||
return colorMap[status || ''] || 'gray'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取文档状态文本
|
function formatTime(value: string) {
|
||||||
const getDocStatusText = (status?: string) => {
|
return value ? dayjs(value).format('YYYY-MM-DD HH:mm') : '-'
|
||||||
const textMap: Record<string, string> = {
|
|
||||||
draft: '草稿',
|
|
||||||
published: '已发布',
|
|
||||||
reviewed: '已审核',
|
|
||||||
rejected: '已拒绝',
|
|
||||||
}
|
|
||||||
return textMap[status || ''] || '未知'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 初始化加载数据
|
onMounted(loadFavorites)
|
||||||
onMounted(() => {
|
|
||||||
fetchFavorites()
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<script lang="ts">
|
|
||||||
export default {
|
|
||||||
name: 'FavoriteManage',
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="less">
|
<style scoped lang="less">
|
||||||
.container {
|
.favorite-page {
|
||||||
padding: 20px;
|
padding: 0 20px 20px;
|
||||||
}
|
}
|
||||||
|
.filters {
|
||||||
.detail-content {
|
margin-bottom: 16px;
|
||||||
max-height: 60vh;
|
}
|
||||||
overflow-y: auto;
|
.detail-spin {
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.content-preview {
|
.content-preview {
|
||||||
max-height: 300px;
|
max-height: 300px;
|
||||||
overflow-y: auto;
|
margin: 0;
|
||||||
padding: 8px;
|
overflow: auto;
|
||||||
background-color: var(--color-fill-1);
|
white-space: pre-wrap;
|
||||||
border-radius: 4px;
|
word-break: break-word;
|
||||||
|
font-family: inherit;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,298 +1,259 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="container">
|
<div class="trash-page">
|
||||||
<SearchTable
|
<Breadcrumb :items="['知识管理', '回收站']" />
|
||||||
:form-model="searchForm"
|
<a-card class="general-card" :bordered="false">
|
||||||
:form-items="filters"
|
<template #title>回收站</template>
|
||||||
:data="tableData"
|
<template #extra><a-button :loading="loading" :disabled="!canManage" @click="loadTrash">刷新</a-button></template>
|
||||||
:columns="columns"
|
|
||||||
:loading="loading"
|
<a-alert v-if="!canManage" type="warning">当前账号没有知识内容管理权限。</a-alert>
|
||||||
title="回收站"
|
<template v-else>
|
||||||
:pagination="{
|
<a-form :model="filters" layout="inline" class="filters" @submit-success="search">
|
||||||
current: page,
|
<a-form-item label="资源类型">
|
||||||
pageSize,
|
<a-select v-model="filters.resource_type" placeholder="全部类型" allow-clear style="width: 160px">
|
||||||
total,
|
<a-option v-for="option in resourceTypeOptions" :key="option.value" :value="option.value">
|
||||||
}"
|
{{ option.label }}
|
||||||
:show-download="false"
|
</a-option>
|
||||||
@update:form-model="handleFormModelUpdate"
|
</a-select>
|
||||||
@search="handleSearch"
|
</a-form-item>
|
||||||
@reset="handleReset"
|
<a-form-item><a-button type="primary" html-type="submit">查询</a-button></a-form-item>
|
||||||
@page-change="handlePageChange"
|
</a-form>
|
||||||
@refresh="fetchData"
|
|
||||||
>
|
<a-table
|
||||||
<!-- 序号列 -->
|
row-key="id"
|
||||||
<template #index="{ rowIndex }">
|
:data="records"
|
||||||
{{ rowIndex + 1 + (page - 1) * pageSize }}
|
:columns="columns"
|
||||||
|
:loading="loading"
|
||||||
|
:pagination="pagination"
|
||||||
|
@page-change="changePage"
|
||||||
|
@page-size-change="changePageSize"
|
||||||
|
>
|
||||||
|
<template #resource_type="{ record }">
|
||||||
|
<a-tag :color="getResourceTypeColor(record.resource_type)">{{ getResourceTypeText(record.resource_type) }}</a-tag>
|
||||||
|
</template>
|
||||||
|
<template #deleted_name="{ record }">{{ record.deleted_name || `用户 ${record.deleted_by}` }}</template>
|
||||||
|
<template #deleted_time="{ record }">{{ formatTime(record.deleted_time) }}</template>
|
||||||
|
<template #actions="{ record }">
|
||||||
|
<a-space>
|
||||||
|
<a-button type="text" size="small" @click="openDetail(record)">查看</a-button>
|
||||||
|
<a-button type="text" size="small" :loading="actionID === record.id" @click="confirmRestore(record)">恢复</a-button>
|
||||||
|
<a-button type="text" size="small" status="danger" :loading="actionID === record.id" @click="confirmDelete(record)">
|
||||||
|
彻底删除
|
||||||
|
</a-button>
|
||||||
|
</a-space>
|
||||||
|
</template>
|
||||||
|
<template #empty><a-empty description="回收站为空" /></template>
|
||||||
|
</a-table>
|
||||||
</template>
|
</template>
|
||||||
|
</a-card>
|
||||||
|
|
||||||
<!-- 资源类型列 -->
|
<a-drawer v-model:visible="detailVisible" title="已删除内容" :width="720" :footer="false">
|
||||||
<template #resource_type="{ record }">
|
<a-alert v-if="detailParseError" type="error" class="parse-alert">原始数据无法解析,恢复操作仍以服务端结果为准。</a-alert>
|
||||||
<a-tag :color="getResourceTypeColor(record.resource_type)">
|
<a-descriptions v-if="detailRecord" :column="2" bordered>
|
||||||
{{ getResourceTypeText(record.resource_type) }}
|
<a-descriptions-item label="资源类型">
|
||||||
</a-tag>
|
<a-tag :color="getResourceTypeColor(detailRecord.resource_type)">{{ getResourceTypeText(detailRecord.resource_type) }}</a-tag>
|
||||||
</template>
|
</a-descriptions-item>
|
||||||
|
<a-descriptions-item label="资源 ID">{{ detailRecord.resource_id }}</a-descriptions-item>
|
||||||
<!-- 删除时间列 -->
|
<a-descriptions-item :label="detailRecord.resource_type === 'document' ? '标题' : '问题'" :span="2">
|
||||||
<template #deleted_time="{ record }">
|
{{ originalTitle || detailRecord.resource_name }}
|
||||||
{{ formatTime(record.deleted_time) }}
|
</a-descriptions-item>
|
||||||
</template>
|
<a-descriptions-item label="删除人">{{ detailRecord.deleted_name || `用户 ${detailRecord.deleted_by}` }}</a-descriptions-item>
|
||||||
|
<a-descriptions-item label="删除时间">{{ formatTime(detailRecord.deleted_time) }}</a-descriptions-item>
|
||||||
<!-- 删除人列 -->
|
<a-descriptions-item label="删除原因" :span="2">{{ detailRecord.delete_reason || '-' }}</a-descriptions-item>
|
||||||
<template #deleted_name="{ record }">
|
<a-descriptions-item v-if="detailRecord.resource_type === 'document'" label="描述" :span="2">
|
||||||
{{ record.deleted_name || '-' }}
|
{{ originalDocument?.description || '-' }}
|
||||||
</template>
|
</a-descriptions-item>
|
||||||
|
<a-descriptions-item v-if="detailRecord.resource_type === 'document'" label="正文" :span="2">
|
||||||
<!-- 操作列 -->
|
<pre class="content-preview">{{ originalDocument?.content || '-' }}</pre>
|
||||||
<template #operation="{ record }">
|
</a-descriptions-item>
|
||||||
<a-space>
|
<a-descriptions-item v-if="detailRecord.resource_type === 'faq'" label="答案" :span="2">
|
||||||
<a-button type="text" size="small" @click="handleRestore(record)">恢复</a-button>
|
<pre class="content-preview">{{ originalFaq?.answer || '-' }}</pre>
|
||||||
<a-button type="text" size="small" status="danger" @click="handleDelete(record)">彻底删除</a-button>
|
</a-descriptions-item>
|
||||||
</a-space>
|
<a-descriptions-item v-if="detailRecord.resource_type === 'faq'" label="解决方案" :span="2">
|
||||||
</template>
|
<pre class="content-preview">{{ originalFaq?.solution || '-' }}</pre>
|
||||||
</SearchTable>
|
</a-descriptions-item>
|
||||||
|
<a-descriptions-item v-if="detailRecord.resource_type === 'faq'" label="处理步骤" :span="2">
|
||||||
<!-- 恢复确认对话框 -->
|
<pre class="content-preview">{{ originalFaq?.process_steps || '-' }}</pre>
|
||||||
<a-modal v-model:visible="restoreConfirmVisible" title="恢复确认" @ok="handleConfirmRestore" @cancel="restoreConfirmVisible = false">
|
</a-descriptions-item>
|
||||||
<p>确定要恢复「{{ recordToRestore?.resource_name }}」吗?</p>
|
</a-descriptions>
|
||||||
<p style="color: rgb(var(--primary-6))">恢复后资源将回到正常状态。</p>
|
</a-drawer>
|
||||||
</a-modal>
|
|
||||||
|
|
||||||
<!-- 彻底删除确认对话框 -->
|
|
||||||
<a-modal v-model:visible="deleteConfirmVisible" title="彻底删除确认" @ok="handleConfirmDelete" @cancel="deleteConfirmVisible = false">
|
|
||||||
<p>确定要彻底删除「{{ recordToDelete?.resource_name }}」吗?</p>
|
|
||||||
<p style="color: rgb(var(--danger-6))">警告:此操作不可恢复,删除后将无法找回!</p>
|
|
||||||
</a-modal>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { ref, reactive, computed, onMounted } from 'vue'
|
import { computed, onMounted, reactive, ref } from 'vue'
|
||||||
import { Message } from '@arco-design/web-vue'
|
import axios from 'axios'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
import SearchTable from '@/components/search-table/index.vue'
|
import { Message, Modal } from '@arco-design/web-vue'
|
||||||
import type { FormItem } from '@/components/search-form/types'
|
|
||||||
import type { TableColumnData } from '@arco-design/web-vue/es/table/interface'
|
import type { TableColumnData } from '@arco-design/web-vue/es/table/interface'
|
||||||
import type { TrashRecord, FetchTrashListParams } from '@/api/kb/trash'
|
import {
|
||||||
import { fetchTrashList, restoreTrash, deleteTrash, getResourceTypeText, getResourceTypeColor, resourceTypeOptions } from '@/api/kb/trash'
|
deleteTrash,
|
||||||
|
fetchTrashList,
|
||||||
|
getResourceTypeColor,
|
||||||
|
getResourceTypeText,
|
||||||
|
resourceTypeOptions,
|
||||||
|
restoreTrash,
|
||||||
|
type TrashRecord,
|
||||||
|
type TrashResourceType,
|
||||||
|
} from '@/api/kb/trash'
|
||||||
|
import type { Document } from '@/api/kb/document'
|
||||||
|
import type { Faq } from '@/api/kb/faq'
|
||||||
|
import usePermissionCodes from '@/hooks/usePermissionCodes'
|
||||||
|
|
||||||
// 表格列配置
|
const columns: TableColumnData[] = [
|
||||||
const columns = computed((): TableColumnData[] => [
|
{ title: '资源名称', dataIndex: 'resource_name', ellipsis: true, tooltip: true },
|
||||||
{
|
{ title: '资源类型', dataIndex: 'resource_type', slotName: 'resource_type', width: 120, align: 'center' },
|
||||||
title: '序号',
|
{ title: '删除人', dataIndex: 'deleted_name', slotName: 'deleted_name', width: 150 },
|
||||||
dataIndex: 'index',
|
{ title: '删除时间', dataIndex: 'deleted_time', slotName: 'deleted_time', width: 180, align: 'center' },
|
||||||
slotName: 'index',
|
{ title: '删除原因', dataIndex: 'delete_reason', ellipsis: true, tooltip: true },
|
||||||
width: 80,
|
{ title: '操作', slotName: 'actions', width: 250, fixed: 'right', align: 'center' },
|
||||||
align: 'center',
|
]
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '标题',
|
|
||||||
dataIndex: 'resource_name',
|
|
||||||
ellipsis: true,
|
|
||||||
tooltip: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '分类',
|
|
||||||
dataIndex: 'resource_type',
|
|
||||||
slotName: 'resource_type',
|
|
||||||
width: 120,
|
|
||||||
align: 'center',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '删除人',
|
|
||||||
dataIndex: 'deleted_name',
|
|
||||||
slotName: 'deleted_name',
|
|
||||||
width: 120,
|
|
||||||
align: 'center',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '删除时间',
|
|
||||||
dataIndex: 'deleted_time',
|
|
||||||
slotName: 'deleted_time',
|
|
||||||
width: 180,
|
|
||||||
align: 'center',
|
|
||||||
sortable: {
|
|
||||||
sortDirections: ['ascend', 'descend'],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '操作',
|
|
||||||
dataIndex: 'operation',
|
|
||||||
slotName: 'operation',
|
|
||||||
width: 180,
|
|
||||||
align: 'center',
|
|
||||||
fixed: 'right',
|
|
||||||
},
|
|
||||||
])
|
|
||||||
|
|
||||||
// 搜索表单配置
|
const { hasPermission } = usePermissionCodes()
|
||||||
const filters = computed((): FormItem[] => [
|
const canManage = computed(() => hasPermission('kb:content:manage'))
|
||||||
{
|
const filters = reactive<{ resource_type?: TrashResourceType }>({ resource_type: undefined })
|
||||||
label: '关键词',
|
const records = ref<TrashRecord[]>([])
|
||||||
field: 'keyword',
|
|
||||||
type: 'input',
|
|
||||||
placeholder: '搜索标题',
|
|
||||||
span: 6,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: '资源类型',
|
|
||||||
field: 'resource_type',
|
|
||||||
type: 'select',
|
|
||||||
placeholder: '请选择资源类型',
|
|
||||||
options: resourceTypeOptions,
|
|
||||||
span: 6,
|
|
||||||
},
|
|
||||||
])
|
|
||||||
|
|
||||||
// 搜索表单数据
|
|
||||||
const searchForm = reactive({
|
|
||||||
keyword: '',
|
|
||||||
resource_type: '',
|
|
||||||
})
|
|
||||||
|
|
||||||
// 处理表单模型更新
|
|
||||||
const handleFormModelUpdate = (newFormModel: Record<string, any>) => {
|
|
||||||
Object.assign(searchForm, newFormModel)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 表格数据
|
|
||||||
const tableData = ref<TrashRecord[]>([])
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
|
const pagination = reactive({ current: 1, pageSize: 20, total: 0, showTotal: true, showPageSize: true })
|
||||||
|
let listSequence = 0
|
||||||
|
const actionID = ref(0)
|
||||||
|
|
||||||
// 分页
|
const detailVisible = ref(false)
|
||||||
const page = ref(1)
|
const detailRecord = ref<TrashRecord | null>(null)
|
||||||
const pageSize = ref(10)
|
const originalDocument = ref<Document | null>(null)
|
||||||
const total = ref(0)
|
const originalFaq = ref<Faq | null>(null)
|
||||||
|
const detailParseError = ref(false)
|
||||||
|
const originalTitle = computed(() => originalDocument.value?.title || originalFaq.value?.question || '')
|
||||||
|
|
||||||
// 恢复确认
|
/** 加载回收站列表。 */
|
||||||
const restoreConfirmVisible = ref(false)
|
async function loadTrash() {
|
||||||
const recordToRestore = ref<TrashRecord | null>(null)
|
if (!canManage.value) {
|
||||||
|
records.value = []
|
||||||
// 删除确认
|
pagination.total = 0
|
||||||
const deleteConfirmVisible = ref(false)
|
return
|
||||||
const recordToDelete = ref<TrashRecord | null>(null)
|
}
|
||||||
|
const sequence = ++listSequence
|
||||||
// 获取数据
|
loading.value = true
|
||||||
const fetchData = async () => {
|
|
||||||
try {
|
try {
|
||||||
loading.value = true
|
const reply = await fetchTrashList({
|
||||||
const params: FetchTrashListParams = {
|
page: pagination.current,
|
||||||
page: page.value,
|
page_size: pagination.pageSize,
|
||||||
page_size: pageSize.value,
|
resource_type: filters.resource_type,
|
||||||
resource_type: (searchForm.resource_type || undefined) as 'document' | 'faq' | undefined,
|
})
|
||||||
}
|
if (sequence !== listSequence) return
|
||||||
|
if (reply.code !== 0) throw new Error(reply.message || '获取回收站失败')
|
||||||
const res: any = await fetchTrashList(params)
|
records.value = reply.details?.data || []
|
||||||
console.log('获取回收站列表成功:', res)
|
pagination.total = reply.details?.total || 0
|
||||||
if (res?.code === 0) {
|
|
||||||
// 如果有关键词,在前端过滤
|
|
||||||
let data = res.details?.data || []
|
|
||||||
if (searchForm.keyword) {
|
|
||||||
const keyword = searchForm.keyword.toLowerCase()
|
|
||||||
data = data.filter(
|
|
||||||
(item) => item.resource_name?.toLowerCase().includes(keyword) || item.deleted_name?.toLowerCase().includes(keyword)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
tableData.value = data
|
|
||||||
total.value = res.details.total || 0
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('获取回收站列表失败:', error)
|
if (sequence === listSequence) {
|
||||||
Message.error('获取回收站列表失败')
|
records.value = []
|
||||||
|
pagination.total = 0
|
||||||
|
showRequestError(error, '获取回收站失败')
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
if (sequence === listSequence) loading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 搜索
|
function search() {
|
||||||
const handleSearch = () => {
|
pagination.current = 1
|
||||||
page.value = 1
|
loadTrash()
|
||||||
fetchData()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 重置
|
function changePage(page: number) {
|
||||||
const handleReset = () => {
|
pagination.current = page
|
||||||
searchForm.keyword = ''
|
loadTrash()
|
||||||
searchForm.resource_type = ''
|
|
||||||
page.value = 1
|
|
||||||
fetchData()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 页码变化
|
function changePageSize(pageSize: number) {
|
||||||
const handlePageChange = (current: number) => {
|
pagination.current = 1
|
||||||
page.value = current
|
pagination.pageSize = pageSize
|
||||||
fetchData()
|
loadTrash()
|
||||||
}
|
}
|
||||||
|
|
||||||
// 格式化时间
|
function openDetail(record: TrashRecord) {
|
||||||
const formatTime = (time: string | null): string => {
|
detailRecord.value = record
|
||||||
return time ? dayjs(time).format('YYYY-MM-DD HH:mm') : '-'
|
originalDocument.value = null
|
||||||
}
|
originalFaq.value = null
|
||||||
|
detailParseError.value = false
|
||||||
// 恢复资源
|
|
||||||
const handleRestore = (record: TrashRecord) => {
|
|
||||||
recordToRestore.value = record
|
|
||||||
restoreConfirmVisible.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
// 确认恢复
|
|
||||||
const handleConfirmRestore = async () => {
|
|
||||||
if (!recordToRestore.value?.id) return
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
loading.value = true
|
const parsed: unknown = JSON.parse(record.original_data)
|
||||||
const res = await restoreTrash({ id: recordToRestore.value.id })
|
if (!parsed || typeof parsed !== 'object') throw new Error('invalid original_data')
|
||||||
if (res?.code === 0) {
|
if (record.resource_type === 'document') originalDocument.value = parsed as Document
|
||||||
Message.success('恢复成功')
|
else originalFaq.value = parsed as Faq
|
||||||
restoreConfirmVisible.value = false
|
} catch {
|
||||||
recordToRestore.value = null
|
detailParseError.value = true
|
||||||
await fetchData()
|
}
|
||||||
} else {
|
detailVisible.value = true
|
||||||
Message.error(res?.message || '恢复失败')
|
}
|
||||||
}
|
|
||||||
|
function confirmRestore(record: TrashRecord) {
|
||||||
|
if (!canManage.value) return Message.error('无操作权限')
|
||||||
|
Modal.confirm({
|
||||||
|
title: '确认恢复',
|
||||||
|
content: `确认恢复${getResourceTypeText(record.resource_type)}「${record.resource_name}」吗?`,
|
||||||
|
onOk: () => runAction(record, 'restore'),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmDelete(record: TrashRecord) {
|
||||||
|
if (!canManage.value) return Message.error('无操作权限')
|
||||||
|
Modal.confirm({
|
||||||
|
title: '确认彻底删除',
|
||||||
|
content: `彻底删除「${record.resource_name}」后不可恢复,是否继续?`,
|
||||||
|
okText: '彻底删除',
|
||||||
|
okButtonProps: { status: 'danger' },
|
||||||
|
onOk: () => runAction(record, 'delete'),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runAction(record: TrashRecord, action: 'restore' | 'delete') {
|
||||||
|
actionID.value = record.id
|
||||||
|
try {
|
||||||
|
const reply = action === 'restore' ? await restoreTrash(record.id) : await deleteTrash(record.id)
|
||||||
|
if (reply.code !== 0) throw new Error(reply.message || (action === 'restore' ? '恢复失败' : '彻底删除失败'))
|
||||||
|
Message.success(action === 'restore' ? '资源已恢复' : '资源已彻底删除')
|
||||||
|
if (detailRecord.value?.id === record.id) detailVisible.value = false
|
||||||
|
await loadTrash()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('恢复失败:', error)
|
showRequestError(error, action === 'restore' ? '恢复失败' : '彻底删除失败')
|
||||||
Message.error('恢复失败')
|
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
actionID.value = 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 彻底删除
|
function showRequestError(error: unknown, fallback: string) {
|
||||||
const handleDelete = (record: TrashRecord) => {
|
if (axios.isAxiosError(error)) {
|
||||||
recordToDelete.value = record
|
const status = error.response?.status
|
||||||
deleteConfirmVisible.value = true
|
const message = (error.response?.data as { message?: string } | undefined)?.message
|
||||||
}
|
if (status === 403) return Message.error('无操作权限')
|
||||||
|
if (status === 400 || status === 409) return Message.error(message || fallback)
|
||||||
// 确认彻底删除
|
|
||||||
const handleConfirmDelete = async () => {
|
|
||||||
if (!recordToDelete.value?.id) return
|
|
||||||
|
|
||||||
try {
|
|
||||||
loading.value = true
|
|
||||||
const res = await deleteTrash({ id: recordToDelete.value.id })
|
|
||||||
if (res?.code === 0) {
|
|
||||||
Message.success('彻底删除成功')
|
|
||||||
deleteConfirmVisible.value = false
|
|
||||||
recordToDelete.value = null
|
|
||||||
await fetchData()
|
|
||||||
} else {
|
|
||||||
Message.error(res?.message || '删除失败')
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('删除失败:', error)
|
|
||||||
Message.error('删除失败')
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
}
|
||||||
|
Message.error(error instanceof Error && error.message ? error.message : fallback)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 初始化
|
function formatTime(value: string | null) {
|
||||||
onMounted(() => {
|
return value ? dayjs(value).format('YYYY-MM-DD HH:mm') : '-'
|
||||||
fetchData()
|
}
|
||||||
})
|
|
||||||
|
onMounted(loadTrash)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="less">
|
<style scoped lang="less">
|
||||||
.container {
|
.trash-page {
|
||||||
margin-top: 20px;
|
padding: 0 20px 20px;
|
||||||
|
}
|
||||||
|
.filters {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.parse-alert {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.content-preview {
|
||||||
|
max-height: 300px;
|
||||||
|
margin: 0;
|
||||||
|
overflow: auto;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
font-family: inherit;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,131 +1,105 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="container">
|
<div class="review-page">
|
||||||
<SearchTable
|
<Breadcrumb :items="['知识管理', '内容审核']" />
|
||||||
:form-model="searchForm"
|
<a-card class="general-card" :bordered="false">
|
||||||
:form-items="filters"
|
<template #title>待审核内容</template>
|
||||||
:data="tableData"
|
<template #extra>
|
||||||
:columns="columns"
|
<a-button :loading="loading" :disabled="!canReview" @click="loadReviews">刷新</a-button>
|
||||||
:loading="loading"
|
|
||||||
title="待审核列表"
|
|
||||||
:pagination="{
|
|
||||||
current: page,
|
|
||||||
pageSize,
|
|
||||||
total,
|
|
||||||
}"
|
|
||||||
:show-download="false"
|
|
||||||
@update:form-model="handleFormModelUpdate"
|
|
||||||
@search="handleSearch"
|
|
||||||
@reset="handleReset"
|
|
||||||
@page-change="handlePageChange"
|
|
||||||
@refresh="fetchData"
|
|
||||||
>
|
|
||||||
<!-- 序号列 -->
|
|
||||||
<template #index="{ rowIndex }">
|
|
||||||
{{ rowIndex + 1 + (page - 1) * pageSize }}
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- 分类列 -->
|
<a-alert v-if="!canReview" type="warning">当前账号没有内容审核权限。</a-alert>
|
||||||
<template #category="{ record }">
|
<template v-else>
|
||||||
<a-tag :color="getResourceTypeColor(record.type)">
|
<a-form :model="filters" layout="inline" class="filters" @submit-success="search">
|
||||||
{{ getResourceTypeText(record.type) }}
|
<a-form-item label="资源类型">
|
||||||
</a-tag>
|
<a-select v-model="filters.resource_type" style="width: 160px">
|
||||||
|
<a-option v-for="option in resourceTypeOptions" :key="option.value" :value="option.value">
|
||||||
|
{{ option.label }}
|
||||||
|
</a-option>
|
||||||
|
</a-select>
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item>
|
||||||
|
<a-button type="primary" html-type="submit">查询</a-button>
|
||||||
|
</a-form-item>
|
||||||
|
</a-form>
|
||||||
|
|
||||||
|
<a-table
|
||||||
|
row-key="review_key"
|
||||||
|
:data="tableData"
|
||||||
|
:columns="columns"
|
||||||
|
:loading="loading"
|
||||||
|
:pagination="pagination"
|
||||||
|
@page-change="changePage"
|
||||||
|
@page-size-change="changePageSize"
|
||||||
|
>
|
||||||
|
<template #type="{ record }">
|
||||||
|
<a-tag :color="getResourceTypeColor(record.type)">{{ getResourceTypeText(record.type) }}</a-tag>
|
||||||
|
</template>
|
||||||
|
<template #title="{ record }">{{ resourceTitle(record) }}</template>
|
||||||
|
<template #author="{ record }">
|
||||||
|
{{ record.resource.author_name || `用户 ${record.resource.author_id}` }}
|
||||||
|
<a-tag v-if="isSelfReview(record)" color="orange" size="small">自审</a-tag>
|
||||||
|
</template>
|
||||||
|
<template #created_at="{ record }">{{ formatTime(record.resource.created_at) }}</template>
|
||||||
|
<template #actions="{ record }">
|
||||||
|
<a-space>
|
||||||
|
<a-button type="text" size="small" @click="openDetail(record)">查看</a-button>
|
||||||
|
<a-button type="text" size="small" status="success" :loading="isActionLoading(record)" @click="confirmApprove(record)">
|
||||||
|
通过
|
||||||
|
</a-button>
|
||||||
|
<a-button type="text" size="small" status="danger" :loading="isActionLoading(record)" @click="openReject(record)">
|
||||||
|
拒绝
|
||||||
|
</a-button>
|
||||||
|
</a-space>
|
||||||
|
</template>
|
||||||
|
<template #empty><a-empty description="暂无待审核内容" /></template>
|
||||||
|
</a-table>
|
||||||
</template>
|
</template>
|
||||||
|
</a-card>
|
||||||
|
|
||||||
<!-- 作者列 -->
|
<a-drawer v-model:visible="detailVisible" title="审核详情" :width="720" :footer="false">
|
||||||
<template #author="{ record }">
|
<a-spin :loading="detailLoading" class="detail-spin">
|
||||||
{{ record.resource?.author_name || '-' }}
|
<a-descriptions v-if="detailRecord" :column="2" bordered>
|
||||||
</template>
|
<a-descriptions-item label="资源类型">
|
||||||
|
<a-tag :color="getResourceTypeColor(detailRecord.type)">{{ getResourceTypeText(detailRecord.type) }}</a-tag>
|
||||||
|
</a-descriptions-item>
|
||||||
|
<a-descriptions-item label="编号">{{ resourceNumber(detailRecord) }}</a-descriptions-item>
|
||||||
|
<a-descriptions-item :label="detailRecord.type === 'faq' ? '问题' : '标题'" :span="2">
|
||||||
|
{{ resourceTitle(detailRecord) }}
|
||||||
|
</a-descriptions-item>
|
||||||
|
<a-descriptions-item label="作者">
|
||||||
|
{{ detailRecord.resource.author_name || `用户 ${detailRecord.resource.author_id}` }}
|
||||||
|
</a-descriptions-item>
|
||||||
|
<a-descriptions-item label="提交时间">{{ formatTime(detailRecord.resource.published_at) }}</a-descriptions-item>
|
||||||
|
<a-descriptions-item v-if="detailRecord.type === 'document'" label="描述" :span="2">
|
||||||
|
{{ documentOf(detailRecord)?.description || '-' }}
|
||||||
|
</a-descriptions-item>
|
||||||
|
<a-descriptions-item v-if="detailRecord.type === 'document'" label="正文" :span="2">
|
||||||
|
<pre class="content-preview">{{ documentOf(detailRecord)?.content || '-' }}</pre>
|
||||||
|
</a-descriptions-item>
|
||||||
|
<a-descriptions-item v-if="detailRecord.type === 'faq'" label="答案" :span="2">
|
||||||
|
<pre class="content-preview">{{ faqOf(detailRecord)?.answer || '-' }}</pre>
|
||||||
|
</a-descriptions-item>
|
||||||
|
<a-descriptions-item v-if="detailRecord.type === 'faq'" label="解决方案" :span="2">
|
||||||
|
<pre class="content-preview">{{ faqOf(detailRecord)?.solution || '-' }}</pre>
|
||||||
|
</a-descriptions-item>
|
||||||
|
<a-descriptions-item label="关键词" :span="2">{{ detailRecord.resource.keywords || '-' }}</a-descriptions-item>
|
||||||
|
<a-descriptions-item label="备注" :span="2">{{ detailRecord.resource.remarks || '-' }}</a-descriptions-item>
|
||||||
|
</a-descriptions>
|
||||||
|
</a-spin>
|
||||||
|
</a-drawer>
|
||||||
|
|
||||||
<!-- 申请时间列 -->
|
<a-modal v-model:visible="rejectVisible" title="拒绝审核" :ok-loading="actionLoading" @ok="confirmReject" @cancel="closeReject">
|
||||||
<template #created_at="{ record }">
|
<a-alert v-if="rejectRecord && isSelfReview(rejectRecord)" type="warning" class="self-review-alert">
|
||||||
{{ formatTime(record.resource?.created_at) }}
|
这是你创建的内容。提交后还会再次确认,并记录自审日志。
|
||||||
</template>
|
</a-alert>
|
||||||
|
|
||||||
<!-- 描述列 -->
|
|
||||||
<template #description="{ record }">
|
|
||||||
<a-tooltip :content="record.resource?.description || record.resource?.question || '-'">
|
|
||||||
<span class="description-text">
|
|
||||||
{{ record.resource?.description || record.resource?.question || '-' }}
|
|
||||||
</span>
|
|
||||||
</a-tooltip>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<!-- 操作列 -->
|
|
||||||
<template #operation="{ record }">
|
|
||||||
<a-space>
|
|
||||||
<a-button type="text" size="small" @click="handleView(record)">查看</a-button>
|
|
||||||
<a-button type="text" size="small" status="success" @click="handleApprove(record)">审核通过</a-button>
|
|
||||||
<a-button type="text" size="small" status="danger" @click="handleReject(record)">拒绝</a-button>
|
|
||||||
</a-space>
|
|
||||||
</template>
|
|
||||||
</SearchTable>
|
|
||||||
|
|
||||||
<!-- 详情弹窗 -->
|
|
||||||
<a-modal v-model:visible="detailVisible" title="详情" :width="720" :footer="false">
|
|
||||||
<a-descriptions :column="2" bordered>
|
|
||||||
<a-descriptions-item label="类型">
|
|
||||||
<a-tag :color="getResourceTypeColor(currentRecord?.type || '')">
|
|
||||||
{{ getResourceTypeText(currentRecord?.type || '') }}
|
|
||||||
</a-tag>
|
|
||||||
</a-descriptions-item>
|
|
||||||
<a-descriptions-item label="编号">
|
|
||||||
{{ getResourceNo(currentRecord) }}
|
|
||||||
</a-descriptions-item>
|
|
||||||
<a-descriptions-item label="标题" :span="2">
|
|
||||||
{{ getResourceTitle(currentRecord) }}
|
|
||||||
</a-descriptions-item>
|
|
||||||
<a-descriptions-item label="作者">
|
|
||||||
{{ currentRecord?.resource?.author_name || '-' }}
|
|
||||||
</a-descriptions-item>
|
|
||||||
<a-descriptions-item label="创建时间">
|
|
||||||
{{ formatTime(currentRecord?.resource?.created_at) }}
|
|
||||||
</a-descriptions-item>
|
|
||||||
<a-descriptions-item label="分类">
|
|
||||||
{{ currentRecord?.resource?.sub_category || '-' }}
|
|
||||||
</a-descriptions-item>
|
|
||||||
<a-descriptions-item label="状态">
|
|
||||||
<a-tag :color="getStatusColor(currentRecord?.resource?.status)">
|
|
||||||
{{ getStatusText(currentRecord?.resource?.status) }}
|
|
||||||
</a-tag>
|
|
||||||
</a-descriptions-item>
|
|
||||||
<a-descriptions-item label="描述" :span="2">
|
|
||||||
{{ getResourceDescription(currentRecord) }}
|
|
||||||
</a-descriptions-item>
|
|
||||||
<a-descriptions-item label="关键词" :span="2">
|
|
||||||
{{ currentRecord?.resource?.keywords || '-' }}
|
|
||||||
</a-descriptions-item>
|
|
||||||
<a-descriptions-item label="标签" :span="2">
|
|
||||||
<a-space wrap>
|
|
||||||
<a-tag v-for="tag in parseTags(currentRecord?.resource?.tags)" :key="tag">
|
|
||||||
{{ tag }}
|
|
||||||
</a-tag>
|
|
||||||
</a-space>
|
|
||||||
</a-descriptions-item>
|
|
||||||
<a-descriptions-item v-if="currentRecord?.type === 'faq'" label="答案" :span="2">
|
|
||||||
<div class="content-preview">{{ getFaqAnswer(currentRecord) }}</div>
|
|
||||||
</a-descriptions-item>
|
|
||||||
<a-descriptions-item v-if="currentRecord?.type === 'document'" label="内容" :span="2">
|
|
||||||
<div class="content-preview">{{ getDocumentContent(currentRecord) }}</div>
|
|
||||||
</a-descriptions-item>
|
|
||||||
</a-descriptions>
|
|
||||||
</a-modal>
|
|
||||||
|
|
||||||
<!-- 拒绝原因对话框 -->
|
|
||||||
<a-modal
|
|
||||||
v-model:visible="rejectVisible"
|
|
||||||
title="拒绝原因"
|
|
||||||
:ok-loading="rejectLoading"
|
|
||||||
@ok="handleConfirmReject"
|
|
||||||
@cancel="handleCancelReject"
|
|
||||||
>
|
|
||||||
<a-form :model="rejectForm" layout="vertical">
|
<a-form :model="rejectForm" layout="vertical">
|
||||||
<a-form-item label="拒绝原因" required>
|
<a-form-item label="拒绝原因" required>
|
||||||
<a-textarea
|
<a-textarea
|
||||||
v-model="rejectForm.reason"
|
v-model="rejectForm.reason"
|
||||||
placeholder="请输入拒绝原因"
|
|
||||||
:max-length="500"
|
:max-length="500"
|
||||||
:auto-size="{ minRows: 3, maxRows: 6 }"
|
:auto-size="{ minRows: 4, maxRows: 8 }"
|
||||||
show-word-limit
|
show-word-limit
|
||||||
|
placeholder="请输入拒绝原因"
|
||||||
/>
|
/>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
</a-form>
|
</a-form>
|
||||||
@@ -134,341 +108,293 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { ref, reactive, computed, onMounted } from 'vue'
|
import { computed, onMounted, reactive, ref } from 'vue'
|
||||||
import { Message, Modal } from '@arco-design/web-vue'
|
import axios from 'axios'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
import SearchTable from '@/components/search-table/index.vue'
|
import { Message, Modal } from '@arco-design/web-vue'
|
||||||
import type { FormItem } from '@/components/search-form/types'
|
|
||||||
import type { TableColumnData } from '@arco-design/web-vue/es/table/interface'
|
import type { TableColumnData } from '@arco-design/web-vue/es/table/interface'
|
||||||
import type { ReviewListItem, FetchReviewListParams } from '@/api/kb/review'
|
|
||||||
import {
|
import {
|
||||||
fetchReviewList,
|
|
||||||
approveReview,
|
approveReview,
|
||||||
rejectReview,
|
fetchReviewList,
|
||||||
getResourceTypeText,
|
|
||||||
getResourceTypeColor,
|
getResourceTypeColor,
|
||||||
|
getResourceTypeText,
|
||||||
|
rejectReview,
|
||||||
resourceTypeOptions,
|
resourceTypeOptions,
|
||||||
|
type ReviewListItem,
|
||||||
|
type ReviewResourceType,
|
||||||
} from '@/api/kb/review'
|
} from '@/api/kb/review'
|
||||||
|
import { fetchDocumentDetail, type Document } from '@/api/kb/document'
|
||||||
|
import { fetchFaqDetail, type Faq } from '@/api/kb/faq'
|
||||||
|
import usePermissionCodes from '@/hooks/usePermissionCodes'
|
||||||
|
import { useUserStore } from '@/store'
|
||||||
|
import SafeStorage, { AppStorageKey } from '@/utils/safeStorage'
|
||||||
|
|
||||||
// 表格列配置
|
type ReviewRow = ReviewListItem & { review_key: string }
|
||||||
const columns = computed((): TableColumnData[] => [
|
|
||||||
{
|
|
||||||
title: '序号',
|
|
||||||
dataIndex: 'index',
|
|
||||||
slotName: 'index',
|
|
||||||
width: 80,
|
|
||||||
align: 'center',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '分类',
|
|
||||||
dataIndex: 'category',
|
|
||||||
slotName: 'category',
|
|
||||||
width: 100,
|
|
||||||
align: 'center',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '作者',
|
|
||||||
dataIndex: 'author',
|
|
||||||
slotName: 'author',
|
|
||||||
width: 120,
|
|
||||||
align: 'center',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '申请时间',
|
|
||||||
dataIndex: 'created_at',
|
|
||||||
slotName: 'created_at',
|
|
||||||
width: 180,
|
|
||||||
align: 'center',
|
|
||||||
sortable: {
|
|
||||||
sortDirections: ['ascend', 'descend'],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '描述',
|
|
||||||
dataIndex: 'description',
|
|
||||||
slotName: 'description',
|
|
||||||
ellipsis: true,
|
|
||||||
tooltip: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '操作',
|
|
||||||
dataIndex: 'operation',
|
|
||||||
slotName: 'operation',
|
|
||||||
width: 280,
|
|
||||||
align: 'center',
|
|
||||||
fixed: 'right',
|
|
||||||
},
|
|
||||||
])
|
|
||||||
|
|
||||||
// 搜索表单配置
|
const columns: TableColumnData[] = [
|
||||||
const filters = computed((): FormItem[] => [
|
{ title: '类型', dataIndex: 'type', slotName: 'type', width: 100, align: 'center' },
|
||||||
{
|
{ title: '标题 / 问题', slotName: 'title', ellipsis: true, tooltip: true },
|
||||||
label: '资源类型',
|
{ title: '作者', slotName: 'author', width: 180 },
|
||||||
field: 'resource_type',
|
{ title: '提交时间', slotName: 'created_at', width: 180, align: 'center' },
|
||||||
type: 'select',
|
{ title: '操作', slotName: 'actions', width: 230, fixed: 'right', align: 'center' },
|
||||||
placeholder: '请选择资源类型',
|
]
|
||||||
options: resourceTypeOptions,
|
|
||||||
span: 6,
|
|
||||||
},
|
|
||||||
])
|
|
||||||
|
|
||||||
// 搜索表单数据
|
const { hasPermission } = usePermissionCodes()
|
||||||
const searchForm = reactive({
|
const canReview = computed(() => hasPermission('kb:content:review'))
|
||||||
resource_type: 'all',
|
const canSelfReview = computed(() => hasPermission('kb:content:self-review'))
|
||||||
|
const userStore = useUserStore()
|
||||||
|
const currentUserID = computed(() => {
|
||||||
|
let source = userStore.$state.userInfo as Record<string, unknown> | null | undefined
|
||||||
|
if (!source || typeof source !== 'object') source = SafeStorage.get<Record<string, unknown>>(AppStorageKey.USER_INFO)
|
||||||
|
const value = Number(source?.user_id ?? source?.id)
|
||||||
|
return Number.isFinite(value) ? value : 0
|
||||||
})
|
})
|
||||||
|
|
||||||
// 处理表单模型更新
|
const filters = reactive<{ resource_type: ReviewResourceType }>({ resource_type: 'all' })
|
||||||
const handleFormModelUpdate = (newFormModel: Record<string, any>) => {
|
const tableData = ref<ReviewRow[]>([])
|
||||||
Object.assign(searchForm, newFormModel)
|
const loading = ref(false)
|
||||||
|
const pagination = reactive({ current: 1, pageSize: 10, total: 0, showTotal: true, showPageSize: true })
|
||||||
|
let listSequence = 0
|
||||||
|
|
||||||
|
const detailVisible = ref(false)
|
||||||
|
const detailLoading = ref(false)
|
||||||
|
const detailRecord = ref<ReviewListItem | null>(null)
|
||||||
|
let detailSequence = 0
|
||||||
|
|
||||||
|
const actionLoading = ref(false)
|
||||||
|
const actionKey = ref('')
|
||||||
|
const rejectVisible = ref(false)
|
||||||
|
const rejectRecord = ref<ReviewListItem | null>(null)
|
||||||
|
const rejectForm = reactive({ reason: '' })
|
||||||
|
|
||||||
|
function rowKey(record: ReviewListItem) {
|
||||||
|
return `${record.type}:${record.resource.id}`
|
||||||
}
|
}
|
||||||
|
|
||||||
// 表格数据
|
function isSelfReview(record: ReviewListItem) {
|
||||||
const tableData = ref<ReviewListItem[]>([])
|
return currentUserID.value > 0 && record.resource.author_id === currentUserID.value
|
||||||
const loading = ref(false)
|
}
|
||||||
|
|
||||||
// 分页
|
function canOperate(record: ReviewListItem) {
|
||||||
const page = ref(1)
|
return canReview.value && record.resource.status === 'published' && (!isSelfReview(record) || canSelfReview.value)
|
||||||
const pageSize = ref(10)
|
}
|
||||||
const total = ref(0)
|
|
||||||
|
|
||||||
// 详情弹窗
|
/** 加载审核列表,并在前端再次排除无自审权限的本人内容。 */
|
||||||
const detailVisible = ref(false)
|
async function loadReviews() {
|
||||||
const currentRecord = ref<ReviewListItem | null>(null)
|
if (!canReview.value) {
|
||||||
|
tableData.value = []
|
||||||
// 拒绝对话框
|
pagination.total = 0
|
||||||
const rejectVisible = ref(false)
|
return
|
||||||
const rejectLoading = ref(false)
|
}
|
||||||
const recordToReject = ref<ReviewListItem | null>(null)
|
const sequence = ++listSequence
|
||||||
const rejectForm = reactive({
|
loading.value = true
|
||||||
reason: '',
|
|
||||||
})
|
|
||||||
|
|
||||||
// 获取数据
|
|
||||||
const fetchData = async () => {
|
|
||||||
try {
|
try {
|
||||||
loading.value = true
|
const reply = await fetchReviewList({
|
||||||
const params: FetchReviewListParams = {
|
page: pagination.current,
|
||||||
page: page.value,
|
page_size: pagination.pageSize,
|
||||||
page_size: pageSize.value,
|
resource_type: filters.resource_type,
|
||||||
resource_type: searchForm.resource_type as 'all' | 'document' | 'faq',
|
})
|
||||||
|
if (sequence !== listSequence) return
|
||||||
|
if (reply.code !== 0) throw new Error(reply.message || '获取审核列表失败')
|
||||||
|
const rows = (reply.details?.data || []).filter(canOperate)
|
||||||
|
tableData.value = rows.map((item) => ({ ...item, review_key: rowKey(item) }))
|
||||||
|
pagination.total = reply.details?.total || 0
|
||||||
|
} catch (error) {
|
||||||
|
if (sequence === listSequence) {
|
||||||
|
tableData.value = []
|
||||||
|
pagination.total = 0
|
||||||
|
showRequestError(error, '获取审核列表失败')
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
|
if (sequence === listSequence) loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const res: any = await fetchReviewList(params)
|
function search() {
|
||||||
|
pagination.current = 1
|
||||||
|
loadReviews()
|
||||||
|
}
|
||||||
|
|
||||||
if (res?.code === 0) {
|
function changePage(page: number) {
|
||||||
tableData.value = res.details?.data || []
|
pagination.current = page
|
||||||
total.value = res.details?.total || 0
|
loadReviews()
|
||||||
|
}
|
||||||
|
|
||||||
|
function changePageSize(pageSize: number) {
|
||||||
|
pagination.current = 1
|
||||||
|
pagination.pageSize = pageSize
|
||||||
|
loadReviews()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openDetail(record: ReviewListItem) {
|
||||||
|
const sequence = ++detailSequence
|
||||||
|
detailVisible.value = true
|
||||||
|
detailLoading.value = true
|
||||||
|
detailRecord.value = record
|
||||||
|
try {
|
||||||
|
if (record.type === 'document') {
|
||||||
|
const reply = await fetchDocumentDetail(record.resource.id)
|
||||||
|
if (sequence !== detailSequence) return
|
||||||
|
if (reply.code !== 0) throw new Error(reply.message || '获取文档详情失败')
|
||||||
|
detailRecord.value = { type: 'document', resource: reply.details }
|
||||||
} else {
|
} else {
|
||||||
Message.error(res?.message || '获取审核列表失败')
|
const reply = await fetchFaqDetail(record.resource.id)
|
||||||
|
if (sequence !== detailSequence) return
|
||||||
|
if (reply.code !== 0) throw new Error(reply.message || '获取 FAQ 详情失败')
|
||||||
|
detailRecord.value = { type: 'faq', resource: reply.details }
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('获取审核列表失败:', error)
|
if (sequence === detailSequence) showRequestError(error, '获取审核详情失败')
|
||||||
Message.error('获取审核列表失败')
|
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
if (sequence === detailSequence) detailLoading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 搜索
|
function confirmApprove(record: ReviewListItem) {
|
||||||
const handleSearch = () => {
|
if (!canOperate(record)) return Message.error('无操作权限')
|
||||||
page.value = 1
|
const openFinalConfirm = () =>
|
||||||
fetchData()
|
Modal.confirm({
|
||||||
}
|
title: '确认审核通过',
|
||||||
|
content: `确认通过${getResourceTypeText(record.type)}「${resourceTitle(record)}」吗?`,
|
||||||
|
okText: '确认通过',
|
||||||
|
onOk: () => runApprove(record),
|
||||||
|
})
|
||||||
|
|
||||||
// 重置
|
if (isSelfReview(record)) {
|
||||||
const handleReset = () => {
|
Modal.confirm({
|
||||||
searchForm.resource_type = 'all'
|
title: '确认自审',
|
||||||
page.value = 1
|
content: '这是你创建的内容。继续操作将记录自审日志,是否进入最终确认?',
|
||||||
fetchData()
|
okText: '继续',
|
||||||
}
|
onOk: openFinalConfirm,
|
||||||
|
})
|
||||||
// 页码变化
|
return
|
||||||
const handlePageChange = (current: number) => {
|
|
||||||
page.value = current
|
|
||||||
fetchData()
|
|
||||||
}
|
|
||||||
|
|
||||||
// 格式化时间
|
|
||||||
const formatTime = (time: string | null | undefined): string => {
|
|
||||||
return time ? dayjs(time).format('YYYY-MM-DD HH:mm') : '-'
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取状态文本
|
|
||||||
const getStatusText = (status: string | undefined): string => {
|
|
||||||
const statusMap: Record<string, string> = {
|
|
||||||
draft: '草稿',
|
|
||||||
published: '待审核',
|
|
||||||
reviewed: '已审核',
|
|
||||||
rejected: '已拒绝',
|
|
||||||
}
|
}
|
||||||
return statusMap[status || ''] || status || '-'
|
openFinalConfirm()
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取状态颜色
|
async function runApprove(record: ReviewListItem) {
|
||||||
const getStatusColor = (status: string | undefined): string => {
|
actionLoading.value = true
|
||||||
const colorMap: Record<string, string> = {
|
actionKey.value = rowKey(record)
|
||||||
draft: 'gray',
|
|
||||||
published: 'orange',
|
|
||||||
reviewed: 'green',
|
|
||||||
rejected: 'red',
|
|
||||||
}
|
|
||||||
return colorMap[status || ''] || 'gray'
|
|
||||||
}
|
|
||||||
|
|
||||||
// 解析标签
|
|
||||||
const parseTags = (tags: string | null | undefined): string[] => {
|
|
||||||
if (!tags) return []
|
|
||||||
try {
|
try {
|
||||||
return JSON.parse(tags)
|
const reply = await approveReview({ resource_type: record.type, id: record.resource.id })
|
||||||
} catch {
|
if (reply.code !== 0) throw new Error(reply.message || '审核失败')
|
||||||
return tags.split(',').filter(Boolean)
|
Message.success('审核已通过')
|
||||||
|
await loadReviews()
|
||||||
|
} catch (error) {
|
||||||
|
showRequestError(error, '审核失败')
|
||||||
|
} finally {
|
||||||
|
actionLoading.value = false
|
||||||
|
actionKey.value = ''
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取资源编号
|
function openReject(record: ReviewListItem) {
|
||||||
const getResourceNo = (record: ReviewListItem | null): string => {
|
if (!canOperate(record)) return Message.error('无操作权限')
|
||||||
if (!record?.resource) return '-'
|
rejectRecord.value = record
|
||||||
const resource = record.resource as any
|
|
||||||
return resource.doc_no || resource.faq_no || '-'
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取资源标题
|
|
||||||
const getResourceTitle = (record: ReviewListItem | null): string => {
|
|
||||||
if (!record?.resource) return '-'
|
|
||||||
const resource = record.resource as any
|
|
||||||
return resource.title || resource.question || '-'
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取资源描述
|
|
||||||
const getResourceDescription = (record: ReviewListItem | null): string => {
|
|
||||||
if (!record?.resource) return '-'
|
|
||||||
const resource = record.resource as any
|
|
||||||
return resource.description || '-'
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取FAQ答案
|
|
||||||
const getFaqAnswer = (record: ReviewListItem | null): string => {
|
|
||||||
if (!record?.resource) return '-'
|
|
||||||
const resource = record.resource as any
|
|
||||||
return resource.answer || '-'
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取文档内容
|
|
||||||
const getDocumentContent = (record: ReviewListItem | null): string => {
|
|
||||||
if (!record?.resource) return '-'
|
|
||||||
const resource = record.resource as any
|
|
||||||
return resource.content || '-'
|
|
||||||
}
|
|
||||||
|
|
||||||
// 查看详情
|
|
||||||
const handleView = (record: ReviewListItem) => {
|
|
||||||
currentRecord.value = record
|
|
||||||
detailVisible.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
// 审核通过
|
|
||||||
const handleApprove = (record: ReviewListItem) => {
|
|
||||||
const resourceTitle = getResourceTitle(record)
|
|
||||||
const resourceTypeText = getResourceTypeText(record.type)
|
|
||||||
|
|
||||||
Modal.confirm({
|
|
||||||
title: '确认审核通过',
|
|
||||||
content: `确定要通过该${resourceTypeText}的审核吗?\n标题:${resourceTitle}`,
|
|
||||||
okText: '确认通过',
|
|
||||||
cancelText: '取消',
|
|
||||||
onOk: async () => {
|
|
||||||
try {
|
|
||||||
loading.value = true
|
|
||||||
const res: any = await approveReview({
|
|
||||||
resource_type: record.type,
|
|
||||||
id: record.resource.id,
|
|
||||||
})
|
|
||||||
if (res?.code === 0) {
|
|
||||||
Message.success('审核通过')
|
|
||||||
await fetchData()
|
|
||||||
} else {
|
|
||||||
Message.error(res?.message || '审核失败')
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('审核失败:', error)
|
|
||||||
Message.error('审核失败')
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// 拒绝
|
|
||||||
const handleReject = (record: ReviewListItem) => {
|
|
||||||
recordToReject.value = record
|
|
||||||
rejectForm.reason = ''
|
rejectForm.reason = ''
|
||||||
rejectVisible.value = true
|
rejectVisible.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
// 确认拒绝
|
function confirmReject() {
|
||||||
const handleConfirmReject = async () => {
|
if (!rejectRecord.value) return false
|
||||||
if (!recordToReject.value) return
|
|
||||||
|
|
||||||
if (!rejectForm.reason.trim()) {
|
if (!rejectForm.reason.trim()) {
|
||||||
Message.warning('请输入拒绝原因')
|
Message.warning('请输入拒绝原因')
|
||||||
return
|
return false
|
||||||
}
|
}
|
||||||
|
const record = rejectRecord.value
|
||||||
try {
|
if (isSelfReview(record)) {
|
||||||
rejectLoading.value = true
|
Modal.confirm({
|
||||||
const res: any = await rejectReview({
|
title: '再次确认自审拒绝',
|
||||||
resource_type: recordToReject.value.type,
|
content: '确认拒绝自己创建的内容吗?本次操作将记录自审日志。',
|
||||||
id: recordToReject.value.resource.id,
|
okText: '确认拒绝',
|
||||||
reason: rejectForm.reason,
|
onOk: () => runReject(record, rejectForm.reason.trim()),
|
||||||
})
|
})
|
||||||
if (res?.code === 0) {
|
return false
|
||||||
Message.success('已拒绝')
|
}
|
||||||
rejectVisible.value = false
|
return runReject(record, rejectForm.reason.trim())
|
||||||
recordToReject.value = null
|
}
|
||||||
rejectForm.reason = ''
|
|
||||||
await fetchData()
|
async function runReject(record: ReviewListItem, reason: string) {
|
||||||
} else {
|
actionLoading.value = true
|
||||||
Message.error(res?.message || '拒绝失败')
|
actionKey.value = rowKey(record)
|
||||||
}
|
try {
|
||||||
|
const reply = await rejectReview({ resource_type: record.type, id: record.resource.id, reason })
|
||||||
|
if (reply.code !== 0) throw new Error(reply.message || '拒绝审核失败')
|
||||||
|
Message.success('审核已拒绝')
|
||||||
|
closeReject()
|
||||||
|
await loadReviews()
|
||||||
|
return true
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('拒绝失败:', error)
|
showRequestError(error, '拒绝审核失败')
|
||||||
Message.error('拒绝失败')
|
return false
|
||||||
} finally {
|
} finally {
|
||||||
rejectLoading.value = false
|
actionLoading.value = false
|
||||||
|
actionKey.value = ''
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 取消拒绝
|
function closeReject() {
|
||||||
const handleCancelReject = () => {
|
|
||||||
rejectVisible.value = false
|
rejectVisible.value = false
|
||||||
recordToReject.value = null
|
rejectRecord.value = null
|
||||||
rejectForm.reason = ''
|
rejectForm.reason = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
// 初始化
|
function isActionLoading(record: ReviewListItem) {
|
||||||
onMounted(() => {
|
return actionLoading.value && actionKey.value === rowKey(record)
|
||||||
fetchData()
|
}
|
||||||
})
|
|
||||||
|
function documentOf(record: ReviewListItem | null): Document | null {
|
||||||
|
return record?.type === 'document' ? (record.resource as Document) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function faqOf(record: ReviewListItem | null): Faq | null {
|
||||||
|
return record?.type === 'faq' ? (record.resource as Faq) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function resourceTitle(record: ReviewListItem | null) {
|
||||||
|
return documentOf(record)?.title || faqOf(record)?.question || '-'
|
||||||
|
}
|
||||||
|
|
||||||
|
function resourceNumber(record: ReviewListItem | null) {
|
||||||
|
return documentOf(record)?.doc_no || faqOf(record)?.faq_no || '-'
|
||||||
|
}
|
||||||
|
|
||||||
|
function showRequestError(error: unknown, fallback: string) {
|
||||||
|
if (axios.isAxiosError(error)) {
|
||||||
|
const status = error.response?.status
|
||||||
|
const message = (error.response?.data as { message?: string } | undefined)?.message
|
||||||
|
if (status === 403) return Message.error('无操作权限')
|
||||||
|
if (status === 400 || status === 409) return Message.error(message || fallback)
|
||||||
|
}
|
||||||
|
Message.error(error instanceof Error && error.message ? error.message : fallback)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(value: string | null | undefined) {
|
||||||
|
return value ? dayjs(value).format('YYYY-MM-DD HH:mm') : '-'
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(loadReviews)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="less">
|
<style scoped lang="less">
|
||||||
.container {
|
.review-page {
|
||||||
margin-top: 20px;
|
padding: 0 20px 20px;
|
||||||
}
|
}
|
||||||
|
.filters {
|
||||||
.description-text {
|
margin-bottom: 16px;
|
||||||
display: -webkit-box;
|
}
|
||||||
-webkit-line-clamp: 2;
|
.detail-spin {
|
||||||
-webkit-box-orient: vertical;
|
width: 100%;
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.content-preview {
|
.content-preview {
|
||||||
max-height: 200px;
|
max-height: 320px;
|
||||||
overflow-y: auto;
|
margin: 0;
|
||||||
|
overflow: auto;
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
word-break: break-all;
|
word-break: break-word;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
.self-review-alert {
|
||||||
|
margin-bottom: 16px;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,297 +0,0 @@
|
|||||||
<template>
|
|
||||||
<a-modal
|
|
||||||
:visible="visible"
|
|
||||||
:title="isEdit ? '编辑标签' : '新增标签'"
|
|
||||||
width="600px"
|
|
||||||
@ok="handleOk"
|
|
||||||
@cancel="handleCancel"
|
|
||||||
@update:visible="handleVisibleChange"
|
|
||||||
:confirm-loading="submitting"
|
|
||||||
>
|
|
||||||
<a-form :model="form" layout="vertical" ref="formRef">
|
|
||||||
<a-row :gutter="16">
|
|
||||||
<a-col :span="12">
|
|
||||||
<a-form-item label="标签名称" field="name" :rules="[{ required: true, message: '请输入标签名称' }]">
|
|
||||||
<a-input v-model="form.name" placeholder="请输入标签名称" :max-length="200" />
|
|
||||||
</a-form-item>
|
|
||||||
</a-col>
|
|
||||||
<a-col :span="12">
|
|
||||||
<a-form-item label="标签类型" field="type">
|
|
||||||
<a-select v-model="form.type" placeholder="请选择标签类型" allow-clear>
|
|
||||||
<a-option value="document">文档标签</a-option>
|
|
||||||
<a-option value="faq">FAQ标签</a-option>
|
|
||||||
<a-option value="general">通用标签</a-option>
|
|
||||||
</a-select>
|
|
||||||
</a-form-item>
|
|
||||||
</a-col>
|
|
||||||
</a-row>
|
|
||||||
|
|
||||||
<a-form-item label="标签描述" field="description">
|
|
||||||
<a-textarea v-model="form.description" placeholder="请输入标签描述" :auto-size="{ minRows: 2, maxRows: 4 }" :max-length="500" />
|
|
||||||
</a-form-item>
|
|
||||||
|
|
||||||
<a-form-item label="标签颜色" field="color">
|
|
||||||
<div class="color-picker-wrapper">
|
|
||||||
<a-input v-model="form.color" placeholder="请选择颜色" readonly @click="showColorPicker = !showColorPicker">
|
|
||||||
<template #prefix>
|
|
||||||
<div class="color-preview" :style="{ backgroundColor: form.color || '#ccc' }"></div>
|
|
||||||
</template>
|
|
||||||
</a-input>
|
|
||||||
<div v-if="showColorPicker" class="color-picker-dropdown">
|
|
||||||
<div class="color-picker-header">选择颜色</div>
|
|
||||||
<div class="color-picker-grid">
|
|
||||||
<div
|
|
||||||
v-for="color in presetColors"
|
|
||||||
:key="color"
|
|
||||||
class="color-item"
|
|
||||||
:style="{ backgroundColor: color }"
|
|
||||||
@click="selectColor(color)"
|
|
||||||
></div>
|
|
||||||
</div>
|
|
||||||
<div class="color-picker-custom">
|
|
||||||
<span>自定义颜色:</span>
|
|
||||||
<input type="color" v-model="form.color" @change="showColorPicker = false" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</a-form-item>
|
|
||||||
|
|
||||||
<a-form-item label="排序号" field="sort_order">
|
|
||||||
<a-input-number v-model="form.sort_order" placeholder="请输入排序号" :min="0" style="width: 100%" />
|
|
||||||
</a-form-item>
|
|
||||||
|
|
||||||
<a-form-item label="备注信息" field="remarks">
|
|
||||||
<a-textarea v-model="form.remarks" placeholder="请输入备注信息" :auto-size="{ minRows: 2, maxRows: 4 }" :max-length="500" />
|
|
||||||
</a-form-item>
|
|
||||||
</a-form>
|
|
||||||
</a-modal>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script lang="ts" setup>
|
|
||||||
import { ref, watch, computed } from 'vue'
|
|
||||||
import { Message } from '@arco-design/web-vue'
|
|
||||||
import { createCategory, updateCategory, type Category } from '@/api/kb/category'
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
visible: boolean
|
|
||||||
category: Category | null
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Emits {
|
|
||||||
(e: 'update:visible', value: boolean): void
|
|
||||||
(e: 'success'): void
|
|
||||||
}
|
|
||||||
|
|
||||||
const props = defineProps<Props>()
|
|
||||||
const emit = defineEmits<Emits>()
|
|
||||||
|
|
||||||
const formRef = ref()
|
|
||||||
const submitting = ref(false)
|
|
||||||
const showColorPicker = ref(false)
|
|
||||||
|
|
||||||
// 预设颜色
|
|
||||||
const presetColors = [
|
|
||||||
'#FF0000',
|
|
||||||
'#FF4500',
|
|
||||||
'#FF8C00',
|
|
||||||
'#FFD700',
|
|
||||||
'#FFFF00',
|
|
||||||
'#9ACD32',
|
|
||||||
'#32CD32',
|
|
||||||
'#00FF00',
|
|
||||||
'#00FA9A',
|
|
||||||
'#00CED1',
|
|
||||||
'#1E90FF',
|
|
||||||
'#0000FF',
|
|
||||||
'#8A2BE2',
|
|
||||||
'#9400D3',
|
|
||||||
'#FF00FF',
|
|
||||||
'#FF1493',
|
|
||||||
'#DC143C',
|
|
||||||
'#B22222',
|
|
||||||
'#8B0000',
|
|
||||||
'#800000',
|
|
||||||
]
|
|
||||||
|
|
||||||
// 表单数据
|
|
||||||
const form = ref({
|
|
||||||
name: '',
|
|
||||||
description: '',
|
|
||||||
type: 'document',
|
|
||||||
color: '',
|
|
||||||
sort_order: 0,
|
|
||||||
remarks: '',
|
|
||||||
})
|
|
||||||
|
|
||||||
// 是否为编辑模式
|
|
||||||
const isEdit = computed(() => !!props.category?.id)
|
|
||||||
|
|
||||||
// 选择颜色
|
|
||||||
const selectColor = (color: string) => {
|
|
||||||
form.value.color = color
|
|
||||||
showColorPicker.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
// 监听对话框显示状态
|
|
||||||
watch(
|
|
||||||
() => props.visible,
|
|
||||||
(newVal) => {
|
|
||||||
if (newVal) {
|
|
||||||
if (props.category && isEdit.value) {
|
|
||||||
// 编辑模式:填充表单
|
|
||||||
form.value = {
|
|
||||||
name: props.category.name || '',
|
|
||||||
description: props.category.description || '',
|
|
||||||
type: props.category.type || 'document',
|
|
||||||
color: props.category.color || '',
|
|
||||||
sort_order: props.category.sort_order || 0,
|
|
||||||
remarks: props.category.remarks || '',
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// 新建模式:重置表单
|
|
||||||
form.value = {
|
|
||||||
name: '',
|
|
||||||
description: '',
|
|
||||||
type: 'document',
|
|
||||||
color: '',
|
|
||||||
sort_order: 0,
|
|
||||||
remarks: '',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
showColorPicker.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
// 确认提交
|
|
||||||
const handleOk = async () => {
|
|
||||||
const valid = await formRef.value?.validate()
|
|
||||||
if (valid) return
|
|
||||||
|
|
||||||
submitting.value = true
|
|
||||||
try {
|
|
||||||
const data: any = {
|
|
||||||
name: form.value.name,
|
|
||||||
description: form.value.description,
|
|
||||||
type: form.value.type,
|
|
||||||
color: form.value.color,
|
|
||||||
sort_order: form.value.sort_order,
|
|
||||||
remarks: form.value.remarks,
|
|
||||||
}
|
|
||||||
|
|
||||||
let res: any
|
|
||||||
if (isEdit.value && props.category?.id) {
|
|
||||||
// 编辑标签
|
|
||||||
data.id = props.category.id
|
|
||||||
res = await updateCategory(data)
|
|
||||||
} else {
|
|
||||||
// 新建标签
|
|
||||||
res = await createCategory(data)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (res.code === 0) {
|
|
||||||
Message.success(isEdit.value ? '编辑成功' : '创建成功')
|
|
||||||
emit('success')
|
|
||||||
emit('update:visible', false)
|
|
||||||
} else {
|
|
||||||
Message.error(res.message || (isEdit.value ? '编辑失败' : '创建失败'))
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
Message.error(isEdit.value ? '编辑失败' : '创建失败')
|
|
||||||
console.error(error)
|
|
||||||
} finally {
|
|
||||||
submitting.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 取消
|
|
||||||
const handleCancel = () => {
|
|
||||||
emit('update:visible', false)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 处理对话框可见性变化
|
|
||||||
const handleVisibleChange = (visible: boolean) => {
|
|
||||||
emit('update:visible', visible)
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<script lang="ts">
|
|
||||||
export default {
|
|
||||||
name: 'KbCategoryFormDialog',
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped lang="less">
|
|
||||||
.color-picker-wrapper {
|
|
||||||
position: relative;
|
|
||||||
|
|
||||||
.color-preview {
|
|
||||||
width: 16px;
|
|
||||||
height: 16px;
|
|
||||||
border-radius: 2px;
|
|
||||||
border: 1px solid #d9d9d9;
|
|
||||||
}
|
|
||||||
|
|
||||||
.color-picker-dropdown {
|
|
||||||
position: absolute;
|
|
||||||
top: 100%;
|
|
||||||
left: 0;
|
|
||||||
z-index: 1000;
|
|
||||||
background: #fff;
|
|
||||||
border: 1px solid #d9d9d9;
|
|
||||||
border-radius: 4px;
|
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
|
||||||
padding: 12px;
|
|
||||||
margin-top: 4px;
|
|
||||||
width: 280px;
|
|
||||||
|
|
||||||
.color-picker-header {
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 500;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
color: #262626;
|
|
||||||
}
|
|
||||||
|
|
||||||
.color-picker-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(10, 1fr);
|
|
||||||
gap: 4px;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
|
|
||||||
.color-item {
|
|
||||||
width: 20px;
|
|
||||||
height: 20px;
|
|
||||||
border-radius: 2px;
|
|
||||||
cursor: pointer;
|
|
||||||
border: 1px solid #d9d9d9;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
transform: scale(1.1);
|
|
||||||
border-color: #1890ff;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.color-picker-custom {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
padding-top: 8px;
|
|
||||||
border-top: 1px solid #f0f0f0;
|
|
||||||
|
|
||||||
span {
|
|
||||||
font-size: 12px;
|
|
||||||
color: #595959;
|
|
||||||
}
|
|
||||||
|
|
||||||
input[type='color'] {
|
|
||||||
width: 60px;
|
|
||||||
height: 28px;
|
|
||||||
border: 1px solid #d9d9d9;
|
|
||||||
border-radius: 4px;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,314 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="container">
|
|
||||||
<search-table
|
|
||||||
:form-model="formModel"
|
|
||||||
:form-items="formItems"
|
|
||||||
:data="tableData"
|
|
||||||
:columns="columns"
|
|
||||||
:loading="loading"
|
|
||||||
:pagination="pagination"
|
|
||||||
title="标签管理"
|
|
||||||
search-button-text="查询"
|
|
||||||
reset-button-text="重置"
|
|
||||||
@update:form-model="handleFormModelUpdate"
|
|
||||||
@search="handleSearch"
|
|
||||||
@reset="handleReset"
|
|
||||||
@refresh="handleRefresh"
|
|
||||||
@page-change="handlePageChange"
|
|
||||||
@page-size-change="handlePageSizeChange"
|
|
||||||
>
|
|
||||||
<template #toolbar-left>
|
|
||||||
<a-button type="primary" @click="handleCreate">新增标签</a-button>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<!-- 序号 -->
|
|
||||||
<template #index="{ rowIndex }">
|
|
||||||
{{ rowIndex + 1 }}
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<!-- 标签类型 -->
|
|
||||||
<template #type="{ record }">
|
|
||||||
<a-tag :color="getTypeColor(record.type)">
|
|
||||||
{{ getTypeLabel(record.type) }}
|
|
||||||
</a-tag>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<!-- 操作 -->
|
|
||||||
<template #actions="{ record }">
|
|
||||||
<a-button type="text" size="small" @click="handleEdit(record)">编辑</a-button>
|
|
||||||
<a-button type="text" size="small" status="danger" @click="handleDelete(record)">删除</a-button>
|
|
||||||
</template>
|
|
||||||
</search-table>
|
|
||||||
|
|
||||||
<!-- 标签表单对话框(新增/编辑) -->
|
|
||||||
<category-form-dialog v-model:visible="formVisible" :category="editingCategory" @success="handleFormSuccess" />
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script lang="ts" setup>
|
|
||||||
import { ref, computed, onMounted } from 'vue'
|
|
||||||
import { Message, Modal } from '@arco-design/web-vue'
|
|
||||||
import type { FormItem } from '@/components/search-form/types'
|
|
||||||
import SearchTable from '@/components/search-table/index.vue'
|
|
||||||
import { fetchCategoryList, deleteCategory, type Category } from '@/api/kb/category'
|
|
||||||
import CategoryFormDialog from './components/CategoryFormDialog.vue'
|
|
||||||
|
|
||||||
// 状态管理
|
|
||||||
const loading = ref(false)
|
|
||||||
const allData = ref<Category[]>([]) // 存储全量数据
|
|
||||||
const tableData = ref<Category[]>([]) // 当前页数据
|
|
||||||
const formModel = ref({
|
|
||||||
keyword: '',
|
|
||||||
type: '',
|
|
||||||
})
|
|
||||||
|
|
||||||
// 分页状态
|
|
||||||
const pagination = ref({
|
|
||||||
current: 1,
|
|
||||||
pageSize: 20,
|
|
||||||
total: 0,
|
|
||||||
})
|
|
||||||
|
|
||||||
// 表单项配置
|
|
||||||
const formItems = computed<FormItem[]>(() => [
|
|
||||||
{
|
|
||||||
field: 'keyword',
|
|
||||||
label: '关键词',
|
|
||||||
type: 'input',
|
|
||||||
placeholder: '请输入标签名称',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
field: 'type',
|
|
||||||
label: '标签类型',
|
|
||||||
type: 'select',
|
|
||||||
placeholder: '请选择标签类型',
|
|
||||||
options: [
|
|
||||||
{ label: '文档标签', value: 'document' },
|
|
||||||
{ label: 'FAQ标签', value: 'faq' },
|
|
||||||
{ label: '通用标签', value: 'general' },
|
|
||||||
],
|
|
||||||
allowClear: true,
|
|
||||||
},
|
|
||||||
])
|
|
||||||
|
|
||||||
// 表格列配置
|
|
||||||
const columns = computed(() => [
|
|
||||||
{
|
|
||||||
title: '序号',
|
|
||||||
dataIndex: 'index',
|
|
||||||
slotName: 'index',
|
|
||||||
width: 80,
|
|
||||||
align: 'center' as const,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '标签名称',
|
|
||||||
dataIndex: 'name',
|
|
||||||
ellipsis: true,
|
|
||||||
tooltip: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '标签描述',
|
|
||||||
dataIndex: 'description',
|
|
||||||
ellipsis: true,
|
|
||||||
tooltip: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '标签类型',
|
|
||||||
dataIndex: 'type',
|
|
||||||
slotName: 'type',
|
|
||||||
width: 120,
|
|
||||||
align: 'center' as const,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '排序',
|
|
||||||
dataIndex: 'sort_order',
|
|
||||||
width: 80,
|
|
||||||
align: 'center' as const,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '备注信息',
|
|
||||||
dataIndex: 'remarks',
|
|
||||||
ellipsis: true,
|
|
||||||
tooltip: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '操作',
|
|
||||||
slotName: 'actions',
|
|
||||||
width: 250,
|
|
||||||
fixed: 'right' as const,
|
|
||||||
},
|
|
||||||
])
|
|
||||||
|
|
||||||
// 当前选中的标签
|
|
||||||
const editingCategory = ref<Category | null>(null)
|
|
||||||
|
|
||||||
// 对话框可见性
|
|
||||||
const formVisible = ref(false)
|
|
||||||
|
|
||||||
// 获取标签类型标签
|
|
||||||
const getTypeLabel = (type: string) => {
|
|
||||||
const typeMap: Record<string, string> = {
|
|
||||||
document: '文档标签',
|
|
||||||
faq: 'FAQ标签',
|
|
||||||
general: '通用标签',
|
|
||||||
}
|
|
||||||
return typeMap[type] || type
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取标签类型颜色
|
|
||||||
const getTypeColor = (type: string) => {
|
|
||||||
const colorMap: Record<string, string> = {
|
|
||||||
document: 'blue',
|
|
||||||
faq: 'green',
|
|
||||||
general: 'orange',
|
|
||||||
}
|
|
||||||
return colorMap[type] || 'gray'
|
|
||||||
}
|
|
||||||
|
|
||||||
// 更新表格数据(前端分页)
|
|
||||||
const updateTableData = () => {
|
|
||||||
const { current, pageSize } = pagination.value
|
|
||||||
const start = (current - 1) * pageSize
|
|
||||||
const end = start + pageSize
|
|
||||||
tableData.value = allData.value.slice(start, end)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取标签列表
|
|
||||||
const fetchCategories = async () => {
|
|
||||||
loading.value = true
|
|
||||||
|
|
||||||
try {
|
|
||||||
const params: any = {}
|
|
||||||
|
|
||||||
if (formModel.value.type) {
|
|
||||||
params.type = formModel.value.type
|
|
||||||
}
|
|
||||||
|
|
||||||
const res: any = await fetchCategoryList(params)
|
|
||||||
|
|
||||||
if (res.code === 0) {
|
|
||||||
let data = res.details || []
|
|
||||||
|
|
||||||
// 如果有关键词搜索,进行过滤
|
|
||||||
if (formModel.value.keyword) {
|
|
||||||
data = data.filter((item: Category) => item.name.toLowerCase().includes(formModel.value.keyword.toLowerCase()))
|
|
||||||
}
|
|
||||||
|
|
||||||
// 保存全量数据并更新分页
|
|
||||||
allData.value = data
|
|
||||||
pagination.value.total = data.length
|
|
||||||
// 重置到第一页
|
|
||||||
pagination.value.current = 1
|
|
||||||
updateTableData()
|
|
||||||
} else {
|
|
||||||
Message.error(res.message || '获取标签列表失败')
|
|
||||||
allData.value = []
|
|
||||||
tableData.value = []
|
|
||||||
pagination.value.total = 0
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('获取标签列表失败:', error)
|
|
||||||
Message.error('获取标签列表失败')
|
|
||||||
allData.value = []
|
|
||||||
tableData.value = []
|
|
||||||
pagination.value.total = 0
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 搜索
|
|
||||||
const handleSearch = () => {
|
|
||||||
fetchCategories()
|
|
||||||
}
|
|
||||||
|
|
||||||
// 处理表单模型更新
|
|
||||||
const handleFormModelUpdate = (value: any) => {
|
|
||||||
formModel.value = value
|
|
||||||
}
|
|
||||||
|
|
||||||
// 重置
|
|
||||||
const handleReset = () => {
|
|
||||||
formModel.value = {
|
|
||||||
keyword: '',
|
|
||||||
type: '',
|
|
||||||
}
|
|
||||||
fetchCategories()
|
|
||||||
}
|
|
||||||
|
|
||||||
// 刷新
|
|
||||||
const handleRefresh = () => {
|
|
||||||
fetchCategories()
|
|
||||||
Message.success('数据已刷新')
|
|
||||||
}
|
|
||||||
|
|
||||||
// 分页切换
|
|
||||||
const handlePageChange = (current: number) => {
|
|
||||||
pagination.value.current = current
|
|
||||||
updateTableData()
|
|
||||||
}
|
|
||||||
|
|
||||||
// 每页条数切换
|
|
||||||
const handlePageSizeChange = (pageSize: number) => {
|
|
||||||
pagination.value.current = 1
|
|
||||||
pagination.value.pageSize = pageSize
|
|
||||||
updateTableData()
|
|
||||||
}
|
|
||||||
|
|
||||||
// 新增标签
|
|
||||||
const handleCreate = () => {
|
|
||||||
editingCategory.value = null
|
|
||||||
formVisible.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
// 编辑标签
|
|
||||||
const handleEdit = (record: Category) => {
|
|
||||||
editingCategory.value = { ...record }
|
|
||||||
formVisible.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
// 删除标签
|
|
||||||
const handleDelete = async (record: Category) => {
|
|
||||||
Modal.confirm({
|
|
||||||
title: '确认删除',
|
|
||||||
content: `确认删除标签「${record.name}」吗?`,
|
|
||||||
onOk: async () => {
|
|
||||||
try {
|
|
||||||
const res: any = await deleteCategory(record.id)
|
|
||||||
if (res.code === 0) {
|
|
||||||
Message.success('删除成功')
|
|
||||||
fetchCategories()
|
|
||||||
} else {
|
|
||||||
Message.error(res.message || '删除失败')
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('删除标签失败:', error)
|
|
||||||
Message.error('删除失败')
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// 表单成功回调
|
|
||||||
const handleFormSuccess = () => {
|
|
||||||
formVisible.value = false
|
|
||||||
fetchCategories()
|
|
||||||
}
|
|
||||||
|
|
||||||
// 初始化加载数据
|
|
||||||
onMounted(() => {
|
|
||||||
fetchCategories()
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<script lang="ts">
|
|
||||||
export default {
|
|
||||||
name: 'KbCategoryManage',
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped lang="less">
|
|
||||||
.container {
|
|
||||||
margin-top: 20px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -218,6 +218,9 @@ const getPageTotal = (payload: any) => {
|
|||||||
return Number(body?.total ?? payload?.total ?? 0) || 0
|
return Number(body?.total ?? payload?.total ?? 0) || 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isFailedRequest = (value: unknown): value is { error: unknown; success: false } =>
|
||||||
|
Boolean(value && typeof value === 'object' && 'success' in value && value.success === false)
|
||||||
|
|
||||||
// 统计卡片数据
|
// 统计卡片数据
|
||||||
const statCards = computed(() => [
|
const statCards = computed(() => [
|
||||||
{
|
{
|
||||||
@@ -551,26 +554,19 @@ const loadStatistics = async () => {
|
|||||||
loading.value = true
|
loading.value = true
|
||||||
|
|
||||||
// 并行请求所有统计接口和待处理告警
|
// 并行请求所有统计接口和待处理告警
|
||||||
const [
|
const [alertData, collectorData, ticketData, alertListData, reviewStatsData, networkTotalData, networkEnabledData] =
|
||||||
alertData,
|
await Promise.allSettled([
|
||||||
collectorData,
|
fetchAlertCount().catch((e) => ({ error: e, success: false })),
|
||||||
ticketData,
|
fetchCollectorStatistics().catch((e) => ({ error: e, success: false })),
|
||||||
alertListData,
|
fetchFeedbackTicketStatistics().catch((e) => ({ error: e, success: false })),
|
||||||
reviewStatsData,
|
fetchHistories({ page: 1, page_size: 5, status: 'pending' }).catch((e) => ({ error: e, success: false })),
|
||||||
networkTotalData,
|
fetchReviewStats({ resource_type: 'all' }).catch((e: unknown) => ({ error: e, success: false as const })),
|
||||||
networkEnabledData,
|
fetchNetworkDeviceList({ page: 1, size: 1 }).catch((e) => ({ error: e, success: false })),
|
||||||
] = await Promise.allSettled([
|
fetchNetworkDeviceList({ page: 1, size: 1, enabled: true }).catch((e) => ({ error: e, success: false })),
|
||||||
fetchAlertCount().catch((e) => ({ error: e, success: false })),
|
])
|
||||||
fetchCollectorStatistics().catch((e) => ({ error: e, success: false })),
|
|
||||||
fetchFeedbackTicketStatistics().catch((e) => ({ error: e, success: false })),
|
|
||||||
fetchHistories({ page: 1, page_size: 5, status: 'pending' }).catch((e) => ({ error: e, success: false })),
|
|
||||||
fetchReviewStats({ resource_type: 'all' }).catch((e) => ({ error: e, success: false })),
|
|
||||||
fetchNetworkDeviceList({ page: 1, size: 1 }).catch((e) => ({ error: e, success: false })),
|
|
||||||
fetchNetworkDeviceList({ page: 1, size: 1, enabled: true }).catch((e) => ({ error: e, success: false })),
|
|
||||||
])
|
|
||||||
|
|
||||||
// 处理服务器及PC统计数据
|
// 处理服务器及PC统计数据
|
||||||
if (collectorData.status === 'fulfilled' && collectorData.value?.success !== false) {
|
if (collectorData.status === 'fulfilled' && !isFailedRequest(collectorData.value)) {
|
||||||
const resourceStats = unwrapDetails(collectorData.value)
|
const resourceStats = unwrapDetails(collectorData.value)
|
||||||
statistics.serverPc = normalizeCountPair(resourceStats.servers)
|
statistics.serverPc = normalizeCountPair(resourceStats.servers)
|
||||||
statistics.database = normalizeCountPair(resourceStats.database_services)
|
statistics.database = normalizeCountPair(resourceStats.database_services)
|
||||||
@@ -580,9 +576,9 @@ const loadStatistics = async () => {
|
|||||||
|
|
||||||
if (
|
if (
|
||||||
networkTotalData.status === 'fulfilled' &&
|
networkTotalData.status === 'fulfilled' &&
|
||||||
networkTotalData.value?.success !== false &&
|
!isFailedRequest(networkTotalData.value) &&
|
||||||
networkEnabledData.status === 'fulfilled' &&
|
networkEnabledData.status === 'fulfilled' &&
|
||||||
networkEnabledData.value?.success !== false
|
!isFailedRequest(networkEnabledData.value)
|
||||||
) {
|
) {
|
||||||
statistics.network = {
|
statistics.network = {
|
||||||
pending: getPageTotal(networkEnabledData.value),
|
pending: getPageTotal(networkEnabledData.value),
|
||||||
@@ -593,7 +589,7 @@ const loadStatistics = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 处理告警统计数据
|
// 处理告警统计数据
|
||||||
if (alertData.status === 'fulfilled' && alertData.value?.success !== false) {
|
if (alertData.status === 'fulfilled' && !isFailedRequest(alertData.value)) {
|
||||||
statistics.alert = {
|
statistics.alert = {
|
||||||
pending: alertData.value?.details?.status_counts?.pending || 0,
|
pending: alertData.value?.details?.status_counts?.pending || 0,
|
||||||
total: alertData.value?.details?.total || 0,
|
total: alertData.value?.details?.total || 0,
|
||||||
@@ -603,7 +599,7 @@ const loadStatistics = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 处理工单统计数据
|
// 处理工单统计数据
|
||||||
if (ticketData.status === 'fulfilled' && ticketData.value?.success !== false) {
|
if (ticketData.status === 'fulfilled' && !isFailedRequest(ticketData.value)) {
|
||||||
statistics.ticket = {
|
statistics.ticket = {
|
||||||
pending: ticketData.value?.details?.pending || ticketData.value?.pending || 0,
|
pending: ticketData.value?.details?.pending || ticketData.value?.pending || 0,
|
||||||
total: ticketData.value?.details?.total || ticketData.value?.total || 0,
|
total: ticketData.value?.details?.total || ticketData.value?.total || 0,
|
||||||
@@ -613,20 +609,24 @@ const loadStatistics = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 审核统计数据(待处理=尚未审核,总数=需本人审核)
|
// 审核统计数据(待处理=尚未审核,总数=需本人审核)
|
||||||
if (reviewStatsData.status === 'fulfilled' && reviewStatsData.value?.success !== false) {
|
if (reviewStatsData.status === 'fulfilled' && !isFailedRequest(reviewStatsData.value)) {
|
||||||
const reviewPayload = reviewStatsData.value?.data ?? reviewStatsData.value?.details
|
const reviewPayload = reviewStatsData.value.details
|
||||||
if (reviewPayload != null && 'need_my_review_total' in reviewPayload) {
|
statistics.review = {
|
||||||
statistics.review = {
|
pending: Number(reviewPayload.need_my_review_unreviewed_total) || 0,
|
||||||
pending: Number(reviewPayload.need_my_review_unreviewed_total) || 0,
|
total: Number(reviewPayload.need_my_review_total) || 0,
|
||||||
total: Number(reviewPayload.need_my_review_total) || 0,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
console.warn('审核统计数据加载失败:', reviewStatsData.status === 'rejected' ? reviewStatsData.reason : reviewStatsData.value?.error)
|
const reason =
|
||||||
|
reviewStatsData.status === 'rejected'
|
||||||
|
? reviewStatsData.reason
|
||||||
|
: isFailedRequest(reviewStatsData.value)
|
||||||
|
? reviewStatsData.value.error
|
||||||
|
: undefined
|
||||||
|
console.warn('审核统计数据加载失败:', reason)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 设置待处理告警列表
|
// 设置待处理告警列表
|
||||||
if (alertListData.status === 'fulfilled' && alertListData.value?.success !== false) {
|
if (alertListData.status === 'fulfilled' && !isFailedRequest(alertListData.value)) {
|
||||||
pendingAlerts.value = alertListData.value?.details?.data || []
|
pendingAlerts.value = alertListData.value?.details?.data || []
|
||||||
} else {
|
} else {
|
||||||
console.warn('待处理告警列表加载失败:', alertListData.status === 'rejected' ? alertListData.reason : alertListData.value?.error)
|
console.warn('待处理告警列表加载失败:', alertListData.status === 'rejected' ? alertListData.reason : alertListData.value?.error)
|
||||||
|
|||||||
Reference in New Issue
Block a user