Compare commits
12 Commits
8c1772c784
...
7353c4eede
| Author | SHA1 | Date | |
|---|---|---|---|
| 7353c4eede | |||
| dbda9f76fb | |||
| c2c5afa82d | |||
| f5c7ebc59c | |||
| 98e1baf1bb | |||
| 51f938fbfc | |||
| e43ada5c7f | |||
| f2f4390ac2 | |||
| 52a0346c4c | |||
| 4b9a87638c | |||
| b4dba77229 | |||
| 7e61f330dc |
@@ -1,93 +1,86 @@
|
||||
import { request } from '@/api/request'
|
||||
import type { KbReply } from './types'
|
||||
|
||||
/** 分类类型 */
|
||||
/** 公共分类状态。 */
|
||||
export type CategoryStatus = 'active' | 'inactive'
|
||||
|
||||
/** 公共分类。 */
|
||||
export interface Category {
|
||||
id: number
|
||||
created_at: string
|
||||
updated_at: string
|
||||
name: string
|
||||
description: string
|
||||
type: string
|
||||
type: 'general'
|
||||
icon: string
|
||||
color: string
|
||||
parent_id: number
|
||||
level: number
|
||||
level: 1 | 2 | 3
|
||||
path: string
|
||||
sort_order: number
|
||||
status: string
|
||||
status: CategoryStatus
|
||||
creator_id: number
|
||||
creator_name: string
|
||||
doc_count: number
|
||||
faq_count: number
|
||||
metadata: string | null
|
||||
metadata: string
|
||||
remarks: string
|
||||
}
|
||||
|
||||
/** API响应包装类型 */
|
||||
export interface ApiResponse<T = any> {
|
||||
code: number
|
||||
message: string
|
||||
data: T
|
||||
/** 公共分类树节点;详情字段需通过详情接口获取。 */
|
||||
export interface CategoryTreeNode {
|
||||
id: number
|
||||
name: string
|
||||
description: string
|
||||
type: 'general'
|
||||
icon: string
|
||||
color: string
|
||||
parent_id: number
|
||||
level: 1 | 2 | 3
|
||||
path: string
|
||||
sort_order: number
|
||||
status: CategoryStatus
|
||||
doc_count: number
|
||||
faq_count: number
|
||||
children: CategoryTreeNode[]
|
||||
}
|
||||
|
||||
/** 创建分类请求参数 */
|
||||
/** 创建分类参数。 */
|
||||
export interface CreateCategoryParams {
|
||||
name: string
|
||||
description?: string
|
||||
type?: string
|
||||
icon?: string
|
||||
color?: string
|
||||
parent_id?: number
|
||||
sort_order?: number
|
||||
remarks?: string
|
||||
description: string
|
||||
icon: string
|
||||
color: string
|
||||
parent_id: number
|
||||
sort_order: number
|
||||
status: CategoryStatus
|
||||
remarks: string
|
||||
}
|
||||
|
||||
/** 更新分类请求参数 */
|
||||
export interface UpdateCategoryParams {
|
||||
/** 更新分类参数。 */
|
||||
export interface UpdateCategoryParams extends CreateCategoryParams {
|
||||
id: number
|
||||
name?: string
|
||||
description?: string
|
||||
icon?: string
|
||||
color?: string
|
||||
sort_order?: number
|
||||
status?: string
|
||||
remarks?: string
|
||||
}
|
||||
|
||||
/** 获取分类列表参数 */
|
||||
/** 分类列表参数。 */
|
||||
export interface FetchCategoryListParams {
|
||||
type?: string
|
||||
parent_id?: number
|
||||
}
|
||||
|
||||
/** 创建分类 */
|
||||
export const createCategory = (data: CreateCategoryParams) => {
|
||||
return request.post<ApiResponse<Category>>('/Kb/v1/category/create', data)
|
||||
}
|
||||
/** 创建公共分类。 */
|
||||
export const createCategory = (data: CreateCategoryParams) => request.post<KbReply<Category>>('/Kb/v1/category/create', data)
|
||||
|
||||
/** 更新分类 */
|
||||
export const updateCategory = (data: UpdateCategoryParams) => {
|
||||
return request.post<ApiResponse<Category>>('/Kb/v1/category/update', data)
|
||||
}
|
||||
/** 更新公共分类。 */
|
||||
export const updateCategory = (data: UpdateCategoryParams) => request.post<KbReply<Category>>('/Kb/v1/category/update', data)
|
||||
|
||||
/** 删除分类 */
|
||||
export const deleteCategory = (id: number) => {
|
||||
return request.delete<ApiResponse<string>>(`/Kb/v1/category/${id}`)
|
||||
}
|
||||
/** 删除公共分类。 */
|
||||
export const deleteCategory = (id: number) => request.delete<KbReply<string>>(`/Kb/v1/category/${id}`)
|
||||
|
||||
/** 获取分类详情 */
|
||||
export const fetchCategoryDetail = (id: number) => {
|
||||
return request.get<ApiResponse<Category>>(`/Kb/v1/category/${id}`)
|
||||
}
|
||||
/** 获取公共分类详情。 */
|
||||
export const fetchCategoryDetail = (id: number) => request.get<KbReply<Category>>(`/Kb/v1/category/${id}`)
|
||||
|
||||
/** 获取分类列表 */
|
||||
export const fetchCategoryList = (params?: FetchCategoryListParams) => {
|
||||
return request.get<ApiResponse<Category[]>>('/Kb/v1/category/list', { params })
|
||||
}
|
||||
/** 获取公共分类列表。 */
|
||||
export const fetchCategoryList = (params?: FetchCategoryListParams) => request.get<KbReply<Category[]>>('/Kb/v1/category/list', { params })
|
||||
|
||||
/** 获取分类树 */
|
||||
export const fetchCategoryTree = (type?: string) => {
|
||||
return request.get<ApiResponse<Category[]>>('/Kb/v1/category/tree', {
|
||||
params: type ? { type } : undefined,
|
||||
})
|
||||
}
|
||||
/** 获取完整公共分类树,包含停用节点。 */
|
||||
export const fetchCategoryTree = () => request.get<KbReply<CategoryTreeNode[]>>('/Kb/v1/category/tree')
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { request } from '@/api/request'
|
||||
import type { KbReply } from './types'
|
||||
|
||||
/** 文档状态 */
|
||||
/** 文档状态。 */
|
||||
export type DocumentStatus = 'draft' | 'published' | 'reviewed' | 'rejected'
|
||||
|
||||
/** 文档类型 */
|
||||
/** 文档类型。 */
|
||||
export type DocumentType = 'common' | 'guide' | 'solution' | 'troubleshoot' | 'process' | 'technical'
|
||||
/** 文档列表范围。 */
|
||||
export type DocumentScope = 'my' | 'all'
|
||||
|
||||
/** 文档接口类型 */
|
||||
/** 文档资源。 */
|
||||
export interface Document {
|
||||
id: number
|
||||
created_at: string
|
||||
@@ -33,71 +35,54 @@ export interface Document {
|
||||
version: string
|
||||
version_notes: string
|
||||
tags: string
|
||||
attachments: string | null
|
||||
related_docs: string | null
|
||||
detection_point_ids: string | null
|
||||
metadata: string | null
|
||||
attachments: string
|
||||
related_docs: string
|
||||
detection_point_ids: string
|
||||
metadata: string
|
||||
keywords: string
|
||||
remarks: string
|
||||
is_favorited?: boolean
|
||||
is_favorited: boolean
|
||||
}
|
||||
|
||||
/** API响应包装类型 */
|
||||
export interface ApiResponse<T = any> {
|
||||
code: number
|
||||
message: string
|
||||
data: T
|
||||
}
|
||||
|
||||
/** 分页响应类型 */
|
||||
export interface PaginatedResponse<T> {
|
||||
/** 文档分页结果。 */
|
||||
export interface DocumentPage {
|
||||
total: number
|
||||
page: number
|
||||
page_size: number
|
||||
data: T[]
|
||||
data: Document[]
|
||||
}
|
||||
|
||||
/** 创建文档请求参数 */
|
||||
/** 文档创建字段。 */
|
||||
export interface CreateDocumentParams {
|
||||
title: string
|
||||
description?: string
|
||||
description: string
|
||||
content: string
|
||||
type?: DocumentType
|
||||
category_id?: number
|
||||
sub_category?: string
|
||||
keywords?: string
|
||||
tags?: string
|
||||
detection_point_ids?: string
|
||||
remarks?: string
|
||||
type: DocumentType
|
||||
category_id: number
|
||||
sub_category: string
|
||||
keywords: string
|
||||
tags: string
|
||||
detection_point_ids: string
|
||||
remarks: string
|
||||
}
|
||||
|
||||
/** 更新文档请求参数 */
|
||||
export interface UpdateDocumentParams {
|
||||
/** 文档编辑字段。 */
|
||||
export interface UpdateDocumentParams extends CreateDocumentParams {
|
||||
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 {
|
||||
page?: number
|
||||
page_size?: number
|
||||
scope?: DocumentScope
|
||||
keyword?: string
|
||||
type?: DocumentType
|
||||
status?: DocumentStatus
|
||||
category_id?: number
|
||||
}
|
||||
|
||||
/** 文档类型选项 */
|
||||
export const documentTypeOptions = [
|
||||
export const documentTypeOptions: Array<{ label: string; value: DocumentType }> = [
|
||||
{ label: '通用文档', value: 'common' },
|
||||
{ label: '操作指南', value: 'guide' },
|
||||
{ label: '解决方案', value: 'solution' },
|
||||
@@ -106,115 +91,28 @@ export const documentTypeOptions = [
|
||||
{ label: '技术文档', value: 'technical' },
|
||||
]
|
||||
|
||||
/** 文档状态选项 */
|
||||
export const documentStatusOptions = [
|
||||
{ label: '草稿', value: 'draft' },
|
||||
{ label: '已发布', value: 'published' },
|
||||
{ label: '已审核', value: 'reviewed' },
|
||||
{ label: '未通过审核', value: 'rejected' },
|
||||
]
|
||||
/** 创建文档草稿。 */
|
||||
export const createDocument = (data: CreateDocumentParams) => request.post<KbReply<Document>>('/Kb/v1/document/create', data)
|
||||
|
||||
/** 获取文档状态文本 */
|
||||
export const getDocumentStatusText = (status: DocumentStatus): string => {
|
||||
const statusMap: Record<DocumentStatus, string> = {
|
||||
draft: '草稿',
|
||||
published: '已发布',
|
||||
reviewed: '已审核',
|
||||
rejected: '未通过审核',
|
||||
}
|
||||
return statusMap[status] || status
|
||||
}
|
||||
/** 更新文档。 */
|
||||
export const updateDocument = (data: UpdateDocumentParams) => request.post<KbReply<Document>>('/Kb/v1/document/update', data)
|
||||
|
||||
/** 获取文档状态颜色 */
|
||||
export const getDocumentStatusColor = (status: DocumentStatus): string => {
|
||||
const colorMap: Record<DocumentStatus, string> = {
|
||||
draft: 'gray',
|
||||
published: 'blue',
|
||||
reviewed: 'green',
|
||||
rejected: 'red',
|
||||
}
|
||||
return colorMap[status] || 'gray'
|
||||
}
|
||||
/** 删除文档并移入回收站。 */
|
||||
export const deleteDocument = (id: number) => request.delete<KbReply<string>>(`/Kb/v1/document/${id}`)
|
||||
|
||||
/** 获取文档类型文本 */
|
||||
export const getDocumentTypeText = (type: DocumentType): string => {
|
||||
const typeMap: Record<DocumentType, string> = {
|
||||
common: '通用文档',
|
||||
guide: '操作指南',
|
||||
solution: '解决方案',
|
||||
troubleshoot: '故障排查',
|
||||
process: '流程规范',
|
||||
technical: '技术文档',
|
||||
}
|
||||
return typeMap[type] || type
|
||||
}
|
||||
/** 获取文档详情。 */
|
||||
export const fetchDocumentDetail = (id: number) => request.get<KbReply<Document>>(`/Kb/v1/document/${id}`)
|
||||
|
||||
/** 创建文档 */
|
||||
export const createDocument = (data: CreateDocumentParams) => {
|
||||
return request.post<ApiResponse<Document>>('/Kb/v1/document/create', data)
|
||||
}
|
||||
/** 获取指定可见范围的文档列表。 */
|
||||
export const fetchDocumentList = (params: FetchDocumentListParams) => request.get<KbReply<DocumentPage>>('/Kb/v1/document/list', { params })
|
||||
|
||||
/** 更新文档 */
|
||||
export const updateDocument = (data: UpdateDocumentParams) => {
|
||||
return request.post<ApiResponse<Document>>('/Kb/v1/document/update', data)
|
||||
}
|
||||
/** 提交文档审核。 */
|
||||
export const publishDocument = (id: number) => request.post<KbReply<string>>('/Kb/v1/document/publish', { id })
|
||||
|
||||
/** 删除文档(移入回收站) */
|
||||
export const deleteDocument = (id: number) => {
|
||||
return request.delete<ApiResponse<string>>(`/Kb/v1/document/${id}`)
|
||||
}
|
||||
/** 收藏文档。 */
|
||||
export const favoriteDocument = (id: number) =>
|
||||
request.post<KbReply<unknown>>('/Kb/v1/favorite/collect', { resource_type: 'document', resource_id: id })
|
||||
|
||||
/** 获取文档详情 */
|
||||
export const fetchDocumentDetail = (id: number) => {
|
||||
return request.get<ApiResponse<Document>>(`/Kb/v1/document/${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' })
|
||||
}
|
||||
/** 取消收藏文档。 */
|
||||
export const unfavoriteDocument = (id: number) =>
|
||||
request.post<KbReply<string>>('/Kb/v1/favorite/uncollect', { resource_type: 'document', resource_id: id })
|
||||
|
||||
177
src/api/kb/faq.ts
Normal file
177
src/api/kb/faq.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
import { request } from '@/api/request'
|
||||
import type { KbReply } from './types'
|
||||
|
||||
export { fetchCategoryTree as fetchGeneralCategoryTree } from './category'
|
||||
export type { CategoryTreeNode } from './category'
|
||||
export type { KbReply } from './types'
|
||||
|
||||
/** FAQ 审核状态。 */
|
||||
export type FaqStatus = 'draft' | 'published' | 'reviewed' | 'rejected'
|
||||
/** FAQ 优先级。 */
|
||||
export type FaqPriority = 'low' | 'medium' | 'high'
|
||||
/** FAQ 列表可见范围。 */
|
||||
export type FaqScope = 'my' | 'all'
|
||||
|
||||
/** FAQ 资源。 */
|
||||
export interface Faq {
|
||||
id: number
|
||||
created_at: string
|
||||
updated_at: string
|
||||
faq_no: string
|
||||
question: string
|
||||
answer: string
|
||||
status: FaqStatus
|
||||
priority: FaqPriority
|
||||
category_id: number
|
||||
sub_category: string
|
||||
problem_type: string
|
||||
solution: string
|
||||
process_steps: string
|
||||
prerequisites: string
|
||||
author_id: number
|
||||
author_name: string
|
||||
reviewer_id: number
|
||||
reviewer_name: string
|
||||
reviewed_at: string | null
|
||||
published_at: string | null
|
||||
view_count: number
|
||||
use_count: number
|
||||
helpful_count: number
|
||||
useless_count: number
|
||||
tags: string
|
||||
related_faqs: string
|
||||
related_docs: string
|
||||
related_links: string
|
||||
detection_point_ids: string
|
||||
attachments: string
|
||||
keywords: string
|
||||
applicable_scope: string
|
||||
remarks: string
|
||||
is_favorited: boolean
|
||||
}
|
||||
|
||||
/** FAQ 分页结果。 */
|
||||
export interface FaqPage {
|
||||
total: number
|
||||
page: number
|
||||
page_size: number
|
||||
data: Faq[]
|
||||
}
|
||||
|
||||
/** FAQ 列表查询参数。 */
|
||||
export interface FaqListParams {
|
||||
page?: number
|
||||
page_size?: number
|
||||
scope?: FaqScope
|
||||
keyword?: string
|
||||
category_id?: number
|
||||
status?: FaqStatus
|
||||
priority?: FaqPriority
|
||||
problem_type?: string
|
||||
}
|
||||
|
||||
/** FAQ 创建和编辑字段。 */
|
||||
export interface FaqFormData {
|
||||
question: string
|
||||
answer: string
|
||||
priority: FaqPriority
|
||||
category_id: number
|
||||
sub_category: string
|
||||
problem_type: string
|
||||
solution: string
|
||||
process_steps: string
|
||||
prerequisites: string
|
||||
keywords: string
|
||||
tags: string
|
||||
detection_point_ids: string
|
||||
applicable_scope: string
|
||||
remarks: string
|
||||
}
|
||||
|
||||
/** FAQ 编辑请求。 */
|
||||
export interface UpdateFaqData extends FaqFormData {
|
||||
id: number
|
||||
}
|
||||
|
||||
/** FAQ 状态筛选项。 */
|
||||
export const faqStatusOptions: Array<{ label: string; value: FaqStatus }> = [
|
||||
{ label: '草稿', value: 'draft' },
|
||||
{ label: '待审核', value: 'published' },
|
||||
{ label: '已审核', value: 'reviewed' },
|
||||
{ label: '已拒绝', value: 'rejected' },
|
||||
]
|
||||
|
||||
/** FAQ 优先级筛选项。 */
|
||||
export const faqPriorityOptions: Array<{ label: string; value: FaqPriority }> = [
|
||||
{ label: '低', value: 'low' },
|
||||
{ label: '中', value: 'medium' },
|
||||
{ label: '高', value: 'high' },
|
||||
]
|
||||
|
||||
/** 常用问题类型;接口仍允许自由字符串。 */
|
||||
export const faqProblemTypeOptions = [
|
||||
{ label: '故障', value: '故障' },
|
||||
{ label: '咨询', value: '咨询' },
|
||||
{ label: '请求', value: '请求' },
|
||||
{ label: '其他', value: '其他' },
|
||||
]
|
||||
|
||||
const faqStatusMeta: Record<FaqStatus, { text: string; color: string }> = {
|
||||
draft: { text: '草稿', color: 'gray' },
|
||||
published: { text: '待审核', color: 'orange' },
|
||||
reviewed: { text: '已审核', color: 'green' },
|
||||
rejected: { text: '已拒绝', color: 'red' },
|
||||
}
|
||||
|
||||
const faqPriorityMeta: Record<FaqPriority, { text: string; color: string }> = {
|
||||
low: { text: '低', color: 'gray' },
|
||||
medium: { text: '中', color: 'blue' },
|
||||
high: { text: '高', color: 'orange' },
|
||||
}
|
||||
|
||||
/** 获取 FAQ 状态文案。 */
|
||||
export const getFaqStatusText = (status: FaqStatus | string) => faqStatusMeta[status as FaqStatus]?.text || status || '-'
|
||||
|
||||
/** 获取 FAQ 状态标签颜色。 */
|
||||
export const getFaqStatusColor = (status: FaqStatus | string) => faqStatusMeta[status as FaqStatus]?.color || 'gray'
|
||||
|
||||
/** 获取 FAQ 优先级文案。 */
|
||||
export const getFaqPriorityText = (priority: FaqPriority | string) => faqPriorityMeta[priority as FaqPriority]?.text || priority || '-'
|
||||
|
||||
/** 获取 FAQ 优先级标签颜色。 */
|
||||
export const getFaqPriorityColor = (priority: FaqPriority | string) => faqPriorityMeta[priority as FaqPriority]?.color || 'gray'
|
||||
|
||||
/** 获取 FAQ 列表。 */
|
||||
export const fetchFaqList = (params: FaqListParams) =>
|
||||
request.get<KbReply<FaqPage>>('/Kb/v1/faq/list', {
|
||||
params,
|
||||
})
|
||||
|
||||
/** 获取 FAQ 详情。 */
|
||||
export const fetchFaqDetail = (id: number) => request.get<KbReply<Faq>>(`/Kb/v1/faq/${id}`)
|
||||
|
||||
/** 创建 FAQ 草稿。 */
|
||||
export const createFaq = (data: FaqFormData) => request.post<KbReply<Faq>>('/Kb/v1/faq/create', data)
|
||||
|
||||
/** 更新 FAQ。 */
|
||||
export const updateFaq = (data: UpdateFaqData) => request.post<KbReply<Faq>>('/Kb/v1/faq/update', data)
|
||||
|
||||
/** 提交 FAQ 审核。 */
|
||||
export const publishFaq = (id: number) => request.post<KbReply<string>>('/Kb/v1/faq/publish', { id })
|
||||
|
||||
/** 删除 FAQ 并移入回收站。 */
|
||||
export const deleteFaq = (id: number) => request.delete<KbReply<string>>(`/Kb/v1/faq/${id}`)
|
||||
|
||||
/** 收藏 FAQ。 */
|
||||
export const favoriteFaq = (id: number) =>
|
||||
request.post<KbReply<unknown>>('/Kb/v1/favorite/collect', {
|
||||
resource_type: 'faq',
|
||||
resource_id: id,
|
||||
})
|
||||
|
||||
/** 取消收藏 FAQ。 */
|
||||
export const unfavoriteFaq = (id: number) =>
|
||||
request.post<KbReply<string>>('/Kb/v1/favorite/uncollect', {
|
||||
resource_type: 'faq',
|
||||
resource_id: id,
|
||||
})
|
||||
@@ -1,9 +1,11 @@
|
||||
import { request } from '@/api/request'
|
||||
import type { Document } from './document'
|
||||
import type { Faq } from './faq'
|
||||
import type { KbReply } from './types'
|
||||
|
||||
/** 资源类型 */
|
||||
export type ResourceType = 'document' | 'faq'
|
||||
|
||||
/** 收藏记录接口 */
|
||||
/** 收藏记录。 */
|
||||
export interface Favorite {
|
||||
id: number
|
||||
created_at: string
|
||||
@@ -12,69 +14,36 @@ export interface Favorite {
|
||||
resource_name: string
|
||||
remarks: string
|
||||
is_deleted: boolean
|
||||
resource_data?: any
|
||||
resource_data?: Document | Faq
|
||||
}
|
||||
|
||||
/** 收藏列表响应 */
|
||||
export interface FavoriteListResponse {
|
||||
export interface FavoritePage {
|
||||
total: number
|
||||
page: number
|
||||
page_size: number
|
||||
data: Favorite[]
|
||||
}
|
||||
|
||||
/** 获取收藏列表参数 */
|
||||
export interface FetchFavoriteListParams {
|
||||
page?: number
|
||||
page_size?: number
|
||||
resource_type?: ResourceType
|
||||
}
|
||||
|
||||
/** 收藏请求参数 */
|
||||
export interface CollectParams {
|
||||
resource_type: ResourceType
|
||||
resource_id: number
|
||||
remarks?: string
|
||||
}
|
||||
|
||||
/** 取消收藏参数 */
|
||||
export interface UncollectParams {
|
||||
resource_type: ResourceType
|
||||
resource_id: number
|
||||
}
|
||||
export type UncollectParams = Omit<CollectParams, 'remarks'>
|
||||
|
||||
/** API响应包装类型 */
|
||||
export interface ApiResponse<T = any> {
|
||||
code: number
|
||||
message: string
|
||||
data: T
|
||||
}
|
||||
/** 获取收藏列表。 */
|
||||
export const fetchFavoriteList = (params: FetchFavoriteListParams = {}) =>
|
||||
request.get<KbReply<FavoritePage>>('/Kb/v1/favorite/list', { params })
|
||||
|
||||
/**
|
||||
* 获取收藏列表
|
||||
*/
|
||||
export async function fetchFavoriteList(params: FetchFavoriteListParams = {}): Promise<ApiResponse<FavoriteListResponse>> {
|
||||
return request.get<ApiResponse<FavoriteListResponse>>('/Kb/v1/favorite/list', {
|
||||
params,
|
||||
})
|
||||
}
|
||||
/** 收藏资源。 */
|
||||
export const collectResource = (data: CollectParams) => request.post<KbReply<Favorite>>('/Kb/v1/favorite/collect', 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' },
|
||||
]
|
||||
/** 取消收藏。 */
|
||||
export const uncollectResource = (data: UncollectParams) => request.post<KbReply<string>>('/Kb/v1/favorite/uncollect', data)
|
||||
|
||||
@@ -1,169 +1,71 @@
|
||||
import { request } from '@/api/request'
|
||||
import type { Document } from './document'
|
||||
import type { Faq } from './faq'
|
||||
import type { KbReply } from './types'
|
||||
|
||||
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 {
|
||||
type: 'document' | 'faq'
|
||||
resource: DocumentResource | FaqResource
|
||||
resource: Document | Faq
|
||||
}
|
||||
|
||||
/** 分页响应类型 */
|
||||
export interface PaginatedResponse<T> {
|
||||
/** 审核分页结果。 */
|
||||
export interface ReviewPage {
|
||||
total: number
|
||||
page: number
|
||||
page_size: number
|
||||
data: T[]
|
||||
data: ReviewListItem[]
|
||||
}
|
||||
|
||||
/** API响应包装类型 */
|
||||
export interface ApiResponse<T = any> {
|
||||
code: number
|
||||
message: string
|
||||
data: T
|
||||
/** 当前审核人的待审数量。 */
|
||||
export interface ReviewStats {
|
||||
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 FetchReviewListParams {
|
||||
page?: number
|
||||
page_size?: number
|
||||
resource_type?: ReviewStatsResourceType
|
||||
resource_type?: ReviewResourceType
|
||||
}
|
||||
|
||||
/** 审核通过参数 */
|
||||
export interface ApproveParams {
|
||||
resource_type: 'document' | 'faq'
|
||||
id: number
|
||||
}
|
||||
|
||||
/** 审核拒绝参数 */
|
||||
export interface RejectParams {
|
||||
resource_type: 'document' | 'faq'
|
||||
id: number
|
||||
reason?: string
|
||||
export interface RejectParams extends ApproveParams {
|
||||
reason: string
|
||||
}
|
||||
|
||||
/** 按当前登录用户统计需要本人审核的数量(不含本人为作者的稿件) */
|
||||
export const fetchReviewStats = (params?: { resource_type?: ReviewStatsResourceType }) =>
|
||||
request.get('/Kb/v1/review/stats', params ? { params } : undefined)
|
||||
/** 获取当前用户可审核的待审资源。 */
|
||||
export const fetchReviewList = (params: FetchReviewListParams) => request.get<KbReply<ReviewPage>>('/Kb/v1/review/list', { params })
|
||||
|
||||
/** 获取待审核列表 */
|
||||
export const fetchReviewList = (params?: FetchReviewListParams) =>
|
||||
request.get<ApiResponse<PaginatedResponse<ReviewListItem | DocumentResource | FaqResource>>>('/Kb/v1/review/list', { params })
|
||||
/** 获取当前审核人的待审统计。 */
|
||||
export const fetchReviewStats = (params: { resource_type?: ReviewResourceType } = {}) =>
|
||||
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 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 = [
|
||||
export const resourceTypeOptions: Array<{ label: string; value: ReviewResourceType }> = [
|
||||
{ label: '全部', value: 'all' },
|
||||
{ label: '文档', value: 'document' },
|
||||
{ label: 'FAQ', value: 'faq' },
|
||||
]
|
||||
|
||||
/** 获取资源类型文本 */
|
||||
export const getResourceTypeText = (type: string): string => {
|
||||
const typeMap: Record<string, string> = {
|
||||
document: '文档',
|
||||
faq: 'FAQ',
|
||||
}
|
||||
return typeMap[type] || type
|
||||
}
|
||||
/** 获取审核资源类型文案。 */
|
||||
export const getResourceTypeText = (type: string) => ({ document: '文档', faq: 'FAQ' })[type] || type
|
||||
|
||||
/** 获取资源类型颜色 */
|
||||
export const getResourceTypeColor = (type: string): string => {
|
||||
const colorMap: Record<string, string> = {
|
||||
document: 'arcoblue',
|
||||
faq: 'green',
|
||||
}
|
||||
return colorMap[type] || 'gray'
|
||||
}
|
||||
/** 获取审核资源类型颜色。 */
|
||||
export const getResourceTypeColor = (type: string) => ({ document: 'arcoblue', faq: 'green' })[type] || 'gray'
|
||||
|
||||
@@ -1,26 +1,14 @@
|
||||
import { request } from '@/api/request'
|
||||
import type { KbReply } from './types'
|
||||
|
||||
/** API响应包装类型 */
|
||||
export interface ApiResponse<T = any> {
|
||||
code: number
|
||||
message: string
|
||||
data: T
|
||||
}
|
||||
export type TrashResourceType = 'document' | 'faq'
|
||||
|
||||
/** 分页响应类型 */
|
||||
export interface PaginatedResponse<T> {
|
||||
total: number
|
||||
page: number
|
||||
page_size: number
|
||||
data: T[]
|
||||
}
|
||||
|
||||
/** 回收站记录 */
|
||||
/** 回收站记录。 */
|
||||
export interface TrashRecord {
|
||||
id: number
|
||||
created_at: string
|
||||
updated_at: string
|
||||
resource_type: 'document' | 'faq'
|
||||
resource_type: TrashResourceType
|
||||
resource_id: number
|
||||
resource_name: string
|
||||
deleted_by: number
|
||||
@@ -31,58 +19,35 @@ export interface TrashRecord {
|
||||
remarks: string
|
||||
}
|
||||
|
||||
/** 获取回收站列表参数 */
|
||||
export interface TrashPage {
|
||||
total: number
|
||||
page: number
|
||||
page_size: number
|
||||
data: TrashRecord[]
|
||||
}
|
||||
|
||||
export interface FetchTrashListParams {
|
||||
page?: number
|
||||
page_size?: number
|
||||
resource_type?: 'document' | 'faq'
|
||||
resource_type?: TrashResourceType
|
||||
}
|
||||
|
||||
/** 恢复资源请求参数 */
|
||||
export interface RestoreTrashParams {
|
||||
id: number
|
||||
}
|
||||
|
||||
/** 彻底删除请求参数 */
|
||||
export interface DeleteTrashParams {
|
||||
id: number
|
||||
}
|
||||
|
||||
/** 资源类型选项 */
|
||||
export const resourceTypeOptions = [
|
||||
export const resourceTypeOptions: Array<{ label: string; value: TrashResourceType }> = [
|
||||
{ label: '文档', value: 'document' },
|
||||
{ label: '常见问题', value: 'faq' },
|
||||
{ label: 'FAQ', value: 'faq' },
|
||||
]
|
||||
|
||||
/** 获取资源类型文本 */
|
||||
export const getResourceTypeText = (type: string): string => {
|
||||
const typeMap: Record<string, string> = {
|
||||
document: '文档',
|
||||
faq: '常见问题',
|
||||
}
|
||||
return typeMap[type] || type
|
||||
}
|
||||
/** 获取回收站资源类型文案。 */
|
||||
export const getResourceTypeText = (type: string) => ({ document: '文档', faq: 'FAQ' })[type] || type
|
||||
|
||||
/** 获取资源类型颜色 */
|
||||
export const getResourceTypeColor = (type: string): string => {
|
||||
const colorMap: Record<string, string> = {
|
||||
document: 'blue',
|
||||
faq: 'green',
|
||||
}
|
||||
return colorMap[type] || 'gray'
|
||||
}
|
||||
/** 获取回收站资源类型颜色。 */
|
||||
export const getResourceTypeColor = (type: string) => ({ document: 'blue', faq: 'green' })[type] || 'gray'
|
||||
|
||||
/** 获取回收站列表 */
|
||||
export const fetchTrashList = (params?: FetchTrashListParams) => {
|
||||
return request.get<ApiResponse<PaginatedResponse<TrashRecord>>>('/Kb/v1/trash/list', { params })
|
||||
}
|
||||
/** 获取回收站列表。 */
|
||||
export const fetchTrashList = (params: FetchTrashListParams) => request.get<KbReply<TrashPage>>('/Kb/v1/trash/list', { params })
|
||||
|
||||
/** 恢复资源 */
|
||||
export const restoreTrash = (data: RestoreTrashParams) => {
|
||||
return request.post<ApiResponse<string>>('/Kb/v1/trash/restore', data)
|
||||
}
|
||||
/** 恢复资源。 */
|
||||
export const restoreTrash = (id: number) => request.post<KbReply<string>>('/Kb/v1/trash/restore', { id })
|
||||
|
||||
/** 彻底删除 */
|
||||
export const deleteTrash = (data: DeleteTrashParams) => {
|
||||
return request.post<ApiResponse<string>>('/Kb/v1/trash/delete', data)
|
||||
}
|
||||
/** 彻底删除资源。 */
|
||||
export const deleteTrash = (id: number) => request.post<KbReply<string>>('/Kb/v1/trash/delete', { id })
|
||||
|
||||
7
src/api/kb/types.ts
Normal file
7
src/api/kb/types.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
/** 知识库服务统一响应。 */
|
||||
export interface KbReply<T> {
|
||||
code: number
|
||||
message: string
|
||||
details: T
|
||||
timeseq: number
|
||||
}
|
||||
@@ -1,22 +1,27 @@
|
||||
import { request } from '@/api/request'
|
||||
|
||||
/** 许可证配置(与 DC-Control `LicenceConfig` / 接口 `data` 一致;字段按实际响应可能部分缺失) */
|
||||
export interface LicenceConfig {
|
||||
title?: string
|
||||
version?: string
|
||||
company_name?: string
|
||||
create_time?: string
|
||||
expire_time?: string
|
||||
machine_code?: string
|
||||
max_database?: number
|
||||
max_middleware?: number
|
||||
max_pc?: number
|
||||
max_server?: number
|
||||
max_client?: number
|
||||
max_user?: number
|
||||
max_role?: number
|
||||
max_permission?: number
|
||||
max_menu?: number
|
||||
export interface LicenceQuotas {
|
||||
max_database: number
|
||||
max_middleware: number
|
||||
max_network_device: number
|
||||
max_security: number
|
||||
max_storage: number
|
||||
max_pc: number
|
||||
max_server: number
|
||||
max_user: number
|
||||
max_role: number
|
||||
max_permission: number
|
||||
max_menu: number
|
||||
}
|
||||
|
||||
export interface LicenceInfo {
|
||||
id: string
|
||||
platform_name: string
|
||||
workspace: string
|
||||
issued_on: string
|
||||
valid_from: string
|
||||
expires_on: string
|
||||
quotas: LicenceQuotas
|
||||
}
|
||||
|
||||
/** 获取 采集器 */
|
||||
@@ -39,7 +44,8 @@ export const updateCollector = (data: any) => request.put(`/DC-Control/v1/collec
|
||||
export const fetchCollectorStatistics = () => request.get('/DC-Control/v1/statistics')
|
||||
|
||||
/** 获取 许可证信息 */
|
||||
export const fetchLicenseInfo = () => request.get<{ code?: number; data?: LicenceConfig; message?: string }>('/DC-Control/v1/license')
|
||||
export const fetchLicenseInfo = () =>
|
||||
request.get<{ code: number; details: LicenceInfo; message: string; timeseq: number }>('/DC-Control/v1/license')
|
||||
|
||||
export interface PageResult<T> {
|
||||
total: number
|
||||
@@ -182,7 +188,9 @@ export const fetchControlResources = (params?: ResourceListParams) =>
|
||||
request.get<{ code?: number; details?: PageResult<ControlResource>; message?: string }>('/DC-Control/v1/resources', { params })
|
||||
|
||||
export const fetchControlResourceOptions = (params?: { resource_category?: string }) =>
|
||||
request.get<{ code?: number; details?: { list: OptionItem[]; count: number }; message?: string }>('/DC-Control/v1/resources/options', { params })
|
||||
request.get<{ code?: number; details?: { list: OptionItem[]; count: number }; message?: string }>('/DC-Control/v1/resources/options', {
|
||||
params,
|
||||
})
|
||||
|
||||
export const createControlResource = (data: ControlResourcePayload) =>
|
||||
request.post<{ code?: number; details?: ControlResource; message?: string }>('/DC-Control/v1/resources', data)
|
||||
@@ -203,10 +211,9 @@ export const fetchControlResourceTypeOptions = () =>
|
||||
request.get<{ code?: number; details?: { list: OptionItem[]; count: number }; message?: string }>('/DC-Control/v1/resource-types/options')
|
||||
|
||||
export const fetchControlMetricDefinitions = (params?: { page?: number; size?: number; keyword?: string; resource_category?: string }) =>
|
||||
request.get<{ code?: number; details?: PageResult<ControlMetricDefinition>; message?: string }>(
|
||||
'/DC-Control/v1/metric-definitions',
|
||||
{ params }
|
||||
)
|
||||
request.get<{ code?: number; details?: PageResult<ControlMetricDefinition>; message?: string }>('/DC-Control/v1/metric-definitions', {
|
||||
params,
|
||||
})
|
||||
|
||||
export const fetchControlMetricDefinitionOptions = (params?: { resource_category?: string }) =>
|
||||
request.get<{ code?: number; details?: { list: OptionItem[]; count: number }; message?: string }>(
|
||||
@@ -218,10 +225,14 @@ export const fetchControlMetricSeries = (params?: MetricSeriesListParams) =>
|
||||
request.get<{ code?: number; details?: PageResult<ControlMetricSeries>; message?: string }>('/DC-Control/v1/metric-series', { params })
|
||||
|
||||
export const fetchControlBusinessSystems = (params?: { page?: number; size?: number; keyword?: string; status?: string }) =>
|
||||
request.get<{ code?: number; details?: PageResult<ControlBusinessSystem>; message?: string }>('/DC-Control/v1/business-systems', { params })
|
||||
request.get<{ code?: number; details?: PageResult<ControlBusinessSystem>; message?: string }>('/DC-Control/v1/business-systems', {
|
||||
params,
|
||||
})
|
||||
|
||||
export const fetchControlBusinessSystemOptions = () =>
|
||||
request.get<{ code?: number; details?: { list: OptionItem[]; count: number }; message?: string }>('/DC-Control/v1/business-systems/options')
|
||||
request.get<{ code?: number; details?: { list: OptionItem[]; count: number }; message?: string }>(
|
||||
'/DC-Control/v1/business-systems/options'
|
||||
)
|
||||
|
||||
export const fetchCollectionStatus = (params?: {
|
||||
page?: number
|
||||
@@ -231,7 +242,6 @@ export const fetchCollectionStatus = (params?: {
|
||||
resource_uid?: string
|
||||
status?: string
|
||||
}) =>
|
||||
request.get<{ code?: number; details?: PageResult<ControlCollectionStatus>; message?: string }>(
|
||||
'/DC-Control/v1/collection/status',
|
||||
{ params }
|
||||
)
|
||||
request.get<{ code?: number; details?: PageResult<ControlCollectionStatus>; message?: string }>('/DC-Control/v1/collection/status', {
|
||||
params,
|
||||
})
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import { request } from '@/api/request'
|
||||
|
||||
/** IPAM服务统一响应 */
|
||||
export interface IPAMResponse<T> {
|
||||
code: number
|
||||
details: T
|
||||
message?: string
|
||||
timeseq?: number
|
||||
}
|
||||
|
||||
/** IP地址运行状态 */
|
||||
export type IPStatus = 'online' | 'offline' | 'unknown'
|
||||
|
||||
@@ -118,7 +126,8 @@ export interface IPGroupListParams {
|
||||
|
||||
/** IP分组列表响应 */
|
||||
export interface IPGroupListResponse {
|
||||
data: IPGroupItem[]
|
||||
list: IPGroupItem[]
|
||||
count: number
|
||||
}
|
||||
|
||||
/** IP分组表单数据 */
|
||||
@@ -359,92 +368,96 @@ export interface IPAnomalyFormData {
|
||||
/** ========== 概览 API ========== */
|
||||
|
||||
/** 获取IPAM概览统计 */
|
||||
export const fetchIPAMOverview = () => request.get<IPAMOverview>('/DC-Control/v1/ipam/overview')
|
||||
export const fetchIPAMOverview = () => request.get<IPAMResponse<IPAMOverview>>('/DC-Control/v1/ipam/overview')
|
||||
|
||||
/** ========== IP地址 API ========== */
|
||||
|
||||
/** 获取IP地址列表 */
|
||||
export const fetchIPAddressList = (params?: IPAddressListParams) =>
|
||||
request.get<IPAddressListResponse>('/DC-Control/v1/ipaddresses', { params })
|
||||
request.get<IPAMResponse<IPAddressListResponse>>('/DC-Control/v1/ipaddresses', { params })
|
||||
|
||||
/** 获取IP地址详情 */
|
||||
export const fetchIPAddressDetail = (id: number) => request.get<IPAddressItem>(`/DC-Control/v1/ipaddresses/${id}`)
|
||||
export const fetchIPAddressDetail = (id: number) => request.get<IPAMResponse<IPAddressItem>>(`/DC-Control/v1/ipaddresses/${id}`)
|
||||
|
||||
/** 创建IP地址 */
|
||||
export const createIPAddress = (data: IPAddressFormData) => request.post<IPAddressItem>('/DC-Control/v1/ipaddresses', data)
|
||||
export const createIPAddress = (data: IPAddressFormData) => request.post<IPAMResponse<IPAddressItem>>('/DC-Control/v1/ipaddresses', data)
|
||||
|
||||
/** 更新IP地址 */
|
||||
export const updateIPAddress = (id: number, data: Partial<IPAddressFormData>) =>
|
||||
request.put<{ message: string }>(`/DC-Control/v1/ipaddresses/${id}`, data)
|
||||
request.put<IPAMResponse<{ message: string }>>(`/DC-Control/v1/ipaddresses/${id}`, data)
|
||||
|
||||
/** 删除IP地址 */
|
||||
export const deleteIPAddress = (id: number) => request.delete<{ message: string }>(`/DC-Control/v1/ipaddresses/${id}`)
|
||||
export const deleteIPAddress = (id: number) => request.delete<IPAMResponse<{ message: string }>>(`/DC-Control/v1/ipaddresses/${id}`)
|
||||
|
||||
/** ========== IP分组 API ========== */
|
||||
|
||||
/** 获取IP分组列表(树形) */
|
||||
export const fetchIPGroupList = (params?: IPGroupListParams) => request.get<IPGroupListResponse>('/DC-Control/v1/ip-groups', { params })
|
||||
export const fetchIPGroupList = (params?: IPGroupListParams) =>
|
||||
request.get<IPAMResponse<IPGroupListResponse>>('/DC-Control/v1/ip-groups', { params })
|
||||
|
||||
/** 创建IP分组 */
|
||||
export const createIPGroup = (data: IPGroupFormData) => request.post<IPGroupItem>('/DC-Control/v1/ip-groups', data)
|
||||
export const createIPGroup = (data: IPGroupFormData) => request.post<IPAMResponse<IPGroupItem>>('/DC-Control/v1/ip-groups', data)
|
||||
|
||||
/** 更新IP分组 */
|
||||
export const updateIPGroup = (id: number, data: Partial<IPGroupFormData>) =>
|
||||
request.put<{ message: string }>(`/DC-Control/v1/ip-groups/${id}`, data)
|
||||
request.put<IPAMResponse<{ message: string }>>(`/DC-Control/v1/ip-groups/${id}`, data)
|
||||
|
||||
/** 删除IP分组 */
|
||||
export const deleteIPGroup = (id: number) => request.delete<{ message: string }>(`/DC-Control/v1/ip-groups/${id}`)
|
||||
export const deleteIPGroup = (id: number) => request.delete<IPAMResponse<{ message: string }>>(`/DC-Control/v1/ip-groups/${id}`)
|
||||
|
||||
/** ========== IP子网 API ========== */
|
||||
|
||||
/** 获取IP子网列表 */
|
||||
export const fetchIPSubnetList = (params?: IPSubnetListParams) => request.get<IPSubnetListResponse>('/DC-Control/v1/ip-subnets', { params })
|
||||
export const fetchIPSubnetList = (params?: IPSubnetListParams) =>
|
||||
request.get<IPAMResponse<IPSubnetListResponse>>('/DC-Control/v1/ip-subnets', { params })
|
||||
|
||||
/** 获取IP子网详情 */
|
||||
export const fetchIPSubnetDetail = (id: number) => request.get<IPSubnetItem>(`/DC-Control/v1/ip-subnets/${id}`)
|
||||
export const fetchIPSubnetDetail = (id: number) => request.get<IPAMResponse<IPSubnetItem>>(`/DC-Control/v1/ip-subnets/${id}`)
|
||||
|
||||
/** 创建IP子网 */
|
||||
export const createIPSubnet = (data: IPSubnetFormData) => request.post<IPSubnetItem>('/DC-Control/v1/ip-subnets', data)
|
||||
export const createIPSubnet = (data: IPSubnetFormData) => request.post<IPAMResponse<IPSubnetItem>>('/DC-Control/v1/ip-subnets', data)
|
||||
|
||||
/** 更新IP子网 */
|
||||
export const updateIPSubnet = (id: number, data: Partial<IPSubnetFormData>) =>
|
||||
request.put<{ message: string }>(`/DC-Control/v1/ip-subnets/${id}`, data)
|
||||
request.put<IPAMResponse<{ message: string }>>(`/DC-Control/v1/ip-subnets/${id}`, data)
|
||||
|
||||
/** 删除IP子网 */
|
||||
export const deleteIPSubnet = (id: number) => request.delete<{ message: string }>(`/DC-Control/v1/ip-subnets/${id}`)
|
||||
export const deleteIPSubnet = (id: number) => request.delete<IPAMResponse<{ message: string }>>(`/DC-Control/v1/ip-subnets/${id}`)
|
||||
|
||||
/** ========== DHCP租约 API ========== */
|
||||
|
||||
/** 获取DHCP租约列表 */
|
||||
export const fetchDHCPLeaseList = (params?: DHCPLeaseListParams) =>
|
||||
request.get<DHCPLeaseListResponse>('/DC-Control/v1/ipam/dhcp-leases', { params })
|
||||
request.get<IPAMResponse<DHCPLeaseListResponse>>('/DC-Control/v1/ipam/dhcp-leases', { params })
|
||||
|
||||
/** 创建DHCP租约 */
|
||||
export const createDHCPLease = (data: DHCPLeaseFormData) => request.post<DHCPLeaseItem>('/DC-Control/v1/ipam/dhcp-leases', data)
|
||||
export const createDHCPLease = (data: DHCPLeaseFormData) =>
|
||||
request.post<IPAMResponse<DHCPLeaseItem>>('/DC-Control/v1/ipam/dhcp-leases', data)
|
||||
|
||||
/** ========== IP冲突 API ========== */
|
||||
|
||||
/** 获取IP冲突列表 */
|
||||
export const fetchIPConflictList = (params?: IPConflictListParams) =>
|
||||
request.get<IPConflictListResponse>('/DC-Control/v1/ipam/conflicts', { params })
|
||||
request.get<IPAMResponse<IPConflictListResponse>>('/DC-Control/v1/ipam/conflicts', { params })
|
||||
|
||||
/** 创建IP冲突记录 */
|
||||
export const createIPConflict = (data: IPConflictFormData) => request.post<IPConflictItem>('/DC-Control/v1/ipam/conflicts', data)
|
||||
export const createIPConflict = (data: IPConflictFormData) =>
|
||||
request.post<IPAMResponse<IPConflictItem>>('/DC-Control/v1/ipam/conflicts', data)
|
||||
|
||||
/** ========== IP变更 API ========== */
|
||||
|
||||
/** 获取IP变更列表 */
|
||||
export const fetchIPChangeList = (params?: IPChangeListParams) =>
|
||||
request.get<IPChangeListResponse>('/DC-Control/v1/ipam/changes', { params })
|
||||
request.get<IPAMResponse<IPChangeListResponse>>('/DC-Control/v1/ipam/changes', { params })
|
||||
|
||||
/** 创建IP变更记录 */
|
||||
export const createIPChange = (data: IPChangeFormData) => request.post<IPChangeItem>('/DC-Control/v1/ipam/changes', data)
|
||||
export const createIPChange = (data: IPChangeFormData) => request.post<IPAMResponse<IPChangeItem>>('/DC-Control/v1/ipam/changes', data)
|
||||
|
||||
/** ========== IP异常 API ========== */
|
||||
|
||||
/** 获取IP异常列表 */
|
||||
export const fetchIPAnomalyList = (params?: IPAnomalyListParams) =>
|
||||
request.get<IPAnomalyListResponse>('/DC-Control/v1/ipam/anomalies', { params })
|
||||
request.get<IPAMResponse<IPAnomalyListResponse>>('/DC-Control/v1/ipam/anomalies', { params })
|
||||
|
||||
/** 创建IP异常记录 */
|
||||
export const createIPAnomaly = (data: IPAnomalyFormData) => request.post<IPAnomalyItem>('/DC-Control/v1/ipam/anomalies', data)
|
||||
export const createIPAnomaly = (data: IPAnomalyFormData) => request.post<IPAMResponse<IPAnomalyItem>>('/DC-Control/v1/ipam/anomalies', data)
|
||||
|
||||
@@ -76,6 +76,8 @@ export interface SyslogRule {
|
||||
keyword_regex: string
|
||||
source_match: string
|
||||
message_regex: string
|
||||
recovery_match_regex: string
|
||||
lifecycle_key: string
|
||||
alert_name: string
|
||||
severity_code: string
|
||||
severity_mapping_json: string
|
||||
@@ -92,6 +94,8 @@ export interface TrapRule {
|
||||
priority: number
|
||||
oid_prefix: string
|
||||
varbind_match_regex: string
|
||||
recovery_match_regex: string
|
||||
lifecycle_key: string
|
||||
alert_name: string
|
||||
severity_code: string
|
||||
policy_id: number
|
||||
|
||||
@@ -9,7 +9,7 @@ export interface RoomDeviceItem {
|
||||
service_identity: string
|
||||
name: string
|
||||
description: string
|
||||
room_id: string
|
||||
room_id: number
|
||||
device_code?: string
|
||||
device_category: string
|
||||
type?: string
|
||||
@@ -69,7 +69,7 @@ export interface RoomDeviceCreateData {
|
||||
service_identity?: string
|
||||
name: string
|
||||
description?: string
|
||||
room_id: string
|
||||
room_id: number
|
||||
device_category: string
|
||||
agent_config?: string
|
||||
collect_method?: 'api' | 'snmp'
|
||||
@@ -97,7 +97,7 @@ export interface RoomDeviceCreateData {
|
||||
export interface RoomDeviceUpdateData {
|
||||
name?: string
|
||||
description?: string
|
||||
room_id?: string
|
||||
room_id?: number
|
||||
device_category?: string
|
||||
agent_config?: string
|
||||
collect_method?: 'api' | 'snmp'
|
||||
|
||||
@@ -67,7 +67,7 @@ export interface StorageListParams {
|
||||
|
||||
/** 创建存储设备请求参数 */
|
||||
export interface StorageCreateData {
|
||||
service_identity: string
|
||||
service_identity?: string
|
||||
name: string
|
||||
category?: string
|
||||
type: string
|
||||
|
||||
@@ -15,6 +15,7 @@ export interface LoginData {
|
||||
/** 用户信息 */
|
||||
export interface UserItem {
|
||||
id?: number
|
||||
user_id?: number
|
||||
account?: string
|
||||
password?: string
|
||||
confirmPassword?: string
|
||||
|
||||
@@ -93,7 +93,8 @@
|
||||
<li>
|
||||
<a-dropdown trigger="click">
|
||||
<a-avatar :size="32" :style="{ marginRight: '8px' }">
|
||||
<img alt="avatar" :src="avatar" />
|
||||
<img v-if="avatar" alt="avatar" :src="avatar" />
|
||||
<icon-user v-else />
|
||||
</a-avatar>
|
||||
<template #content>
|
||||
<a-doption>
|
||||
@@ -149,7 +150,7 @@ const { changeLocale, currentLocale }: any = useLocale()
|
||||
const { isFullscreen, toggle: toggleFullScreen } = useFullscreen()
|
||||
const locales = [...LOCALE_OPTIONS]
|
||||
const avatar = computed(() => {
|
||||
return userStore.avatar || '//p3-armor.byteimg.com/tos-cn-i-49unhts6dw/dfdba5317c0c20ce20e64fac803d52bc.svg~tplv-49unhts6dw-image.image'
|
||||
return userStore.avatar
|
||||
})
|
||||
const theme = computed(() => {
|
||||
return appStore.theme
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
import { ref, watch } from 'vue'
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
import { resetUserPassword } from '@/api/module/user'
|
||||
import type { UserItem } from '@/api/types'
|
||||
import SafeStorage, { AppStorageKey } from '@/utils/safeStorage'
|
||||
|
||||
interface Props {
|
||||
@@ -79,12 +80,12 @@ const handleSavePassword = async () => {
|
||||
|
||||
try {
|
||||
// 从 SafeStorage 获取登录用户信息
|
||||
const userInfo = SafeStorage.get(AppStorageKey.USER_INFO) || {}
|
||||
const userInfo = SafeStorage.get<UserItem>(AppStorageKey.USER_INFO)
|
||||
const res = await resetUserPassword({
|
||||
account: userInfo.account || '',
|
||||
account: userInfo?.account || '',
|
||||
code: '123456', // 暂时没校验,随便传
|
||||
password: form.value.newPassword,
|
||||
phone: userInfo.phone || '13800138000', // 暂时没校验,随便传
|
||||
phone: userInfo?.phone || '13800138000', // 暂时没校验,随便传
|
||||
})
|
||||
|
||||
if (res.code === 0) {
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
import { ref, watch } from 'vue'
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
import { modifyUser } from '@/api/module/user'
|
||||
import type { UserItem } from '@/api/types'
|
||||
import SafeStorage, { AppStorageKey } from '@/utils/safeStorage'
|
||||
|
||||
interface Props {
|
||||
@@ -56,11 +57,11 @@ watch(
|
||||
visible.value = val
|
||||
if (val) {
|
||||
// 打开时从存储中获取用户信息
|
||||
const userInfo = SafeStorage.get(AppStorageKey.USER_INFO) || {}
|
||||
const userInfo = SafeStorage.get<UserItem>(AppStorageKey.USER_INFO)
|
||||
form.value = {
|
||||
account: userInfo.account || '',
|
||||
name: userInfo.name || '',
|
||||
email: userInfo.email || '',
|
||||
account: userInfo?.account || '',
|
||||
name: userInfo?.name || '',
|
||||
email: userInfo?.email || '',
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -76,11 +77,11 @@ const handleSaveProfile = async () => {
|
||||
loading.value = true
|
||||
|
||||
try {
|
||||
const userInfo = SafeStorage.get(AppStorageKey.USER_INFO) || {}
|
||||
const userInfo = SafeStorage.get<UserItem>(AppStorageKey.USER_INFO)
|
||||
const updatedUserInfo = {
|
||||
...userInfo,
|
||||
...form.value,
|
||||
id: userInfo.user_id,
|
||||
id: userInfo?.user_id ?? userInfo?.id,
|
||||
}
|
||||
|
||||
const res = await modifyUser(updatedUserInfo)
|
||||
|
||||
14
src/hooks/usePermissionCodes.ts
Normal file
14
src/hooks/usePermissionCodes.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { computed } from 'vue'
|
||||
import { useAppStore } from '@/store'
|
||||
|
||||
export default function usePermissionCodes() {
|
||||
const appStore = useAppStore()
|
||||
const permissionCodes = computed(() => appStore.permissionCodes as string[])
|
||||
|
||||
const hasPermission = (code: string) => permissionCodes.value.includes(code)
|
||||
|
||||
return {
|
||||
permissionCodes,
|
||||
hasPermission,
|
||||
}
|
||||
}
|
||||
@@ -953,13 +953,13 @@ export const localMenuFlatItems: MenuItem[] = [
|
||||
{
|
||||
id: 64,
|
||||
identity: '019b591d-03e8-7d4b-b8cf-7a5142320c61',
|
||||
title: '标签管理',
|
||||
title_en: 'Tag Management',
|
||||
code: 'ops:知识库管理:标签管理',
|
||||
description: '知识库管理 - 标签管理',
|
||||
title: '公共分类',
|
||||
title_en: 'Category Management',
|
||||
code: 'ops:知识库管理:公共分类',
|
||||
description: '知识库管理 - 公共分类',
|
||||
app_id: 2,
|
||||
parent_id: 63,
|
||||
menu_path: '/kb/tags',
|
||||
menu_path: '/kb/categories',
|
||||
menu_icon: 'appstore',
|
||||
type: 1,
|
||||
sort_key: 46,
|
||||
|
||||
@@ -1125,13 +1125,13 @@ export const localMenuItems: MenuItem[] = [
|
||||
{
|
||||
id: 64,
|
||||
identity: '019b591d-03e8-7d4b-b8cf-7a5142320c61',
|
||||
title: '标签管理',
|
||||
title_en: 'Tag Management',
|
||||
code: 'ops:知识库管理:标签管理',
|
||||
description: '知识库管理 - 标签管理',
|
||||
title: '公共分类',
|
||||
title_en: 'Category Management',
|
||||
code: 'ops:知识库管理:公共分类',
|
||||
description: '知识库管理 - 公共分类',
|
||||
app_id: 2,
|
||||
parent_id: 63,
|
||||
menu_path: '/kb/tags',
|
||||
menu_path: '/kb/categories',
|
||||
menu_icon: 'appstore',
|
||||
type: 1,
|
||||
sort_key: 11,
|
||||
|
||||
@@ -12,6 +12,7 @@ export interface ServerMenuItem extends TreeNodeBase {
|
||||
title?: string // 菜单标题
|
||||
title_en?: string // 英文标题
|
||||
code?: string // 菜单编码
|
||||
type: number | string // 1:菜单,2:按钮
|
||||
menu_path?: string // 菜单路径,如 '/overview'
|
||||
component?: string // 组件路径,如 'ops/pages/overview'
|
||||
icon?: string
|
||||
@@ -28,10 +29,13 @@ export interface ServerMenuItem extends TreeNodeBase {
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
export function isMenuPermission(item: Pick<ServerMenuItem, 'type'>): boolean {
|
||||
return item.type === 1 || item.type === '1'
|
||||
}
|
||||
|
||||
// 预定义的视图模块映射(用于 Vite 动态导入)
|
||||
const viewModules = import.meta.glob('@/views/**/*.vue')
|
||||
|
||||
console.log('viewModules', viewModules)
|
||||
/**
|
||||
* 动态加载视图组件
|
||||
* @param componentPath 组件路径,如 'ops/pages/overview' 或 'ops/pages/overview/index'
|
||||
@@ -76,6 +80,8 @@ export function transformMenuToRoutes(menuItems: ServerMenuItem[]): AppRouteReco
|
||||
const routes: AppRouteRecordRaw[] = []
|
||||
|
||||
for (const item of menuItems) {
|
||||
if (!isMenuPermission(item)) continue
|
||||
|
||||
// 根据 is_full 决定如何设置 component
|
||||
let routeComponent: AppRouteRecordRaw['component']
|
||||
|
||||
@@ -198,7 +204,7 @@ function transformChildRoutes(
|
||||
parentPath?: string,
|
||||
parentIsFull?: boolean
|
||||
): AppRouteRecordRaw[] {
|
||||
return children.map((child) => {
|
||||
return children.filter(isMenuPermission).map((child) => {
|
||||
const childFullPath = String(child.menu_path ?? child.path ?? '').trim()
|
||||
|
||||
// 已配置 component 的菜单绝不覆盖;仅对许可页做路径/code 兜底,避免 includes 误匹配
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { Notification } from '@arco-design/web-vue'
|
||||
import type { NotificationReturn } from '@arco-design/web-vue/es/notification/interface'
|
||||
import type { RouteRecordNormalized } from 'vue-router'
|
||||
import defaultSettings from '@/config/settings.json'
|
||||
import { userPmn } from '@/api/module/user'
|
||||
import { localMenuData, transformMenuToRoutes, type ServerMenuItem } from '@/router/menu-data'
|
||||
import { isMenuPermission, transformMenuToRoutes, type ServerMenuItem } from '@/router/menu-data'
|
||||
import { buildTree } from '@/utils/tree'
|
||||
import SafeStorage, { AppStorageKey } from '@/utils/safeStorage'
|
||||
import router from '@/router'
|
||||
import { AppState } from './types'
|
||||
|
||||
const useAppStore = defineStore('app', {
|
||||
state: (): AppState => ({ ...defaultSettings }),
|
||||
state: (): AppState => ({ ...defaultSettings, permissionCodes: [] }),
|
||||
|
||||
getters: {
|
||||
appCurrentSetting(state: AppState): AppState {
|
||||
@@ -50,22 +48,28 @@ const useAppStore = defineStore('app', {
|
||||
},
|
||||
async fetchServerMenuConfig() {
|
||||
const userInfo = SafeStorage.get(AppStorageKey.USER_INFO) as any
|
||||
let notifyInstance: NotificationReturn | null = null
|
||||
try {
|
||||
// 使用本地菜单数据(接口未准备好)
|
||||
// TODO: 接口准备好后,取消下面的注释,使用真实接口数据
|
||||
const res = await userPmn({ id: userInfo.user_id, workspace: import.meta.env.VITE_APP_WORKSPACE })
|
||||
console.log('res', res)
|
||||
if (res.code === 0 && res?.details?.length) {
|
||||
const permissions = (res.details[0].permissions ?? []) as ServerMenuItem[]
|
||||
|
||||
this.permissionCodes = [
|
||||
...new Set(
|
||||
permissions.map((permission) => permission.code).filter((code): code is string => typeof code === 'string' && code.length > 0)
|
||||
),
|
||||
]
|
||||
|
||||
const menuPermissions = permissions.filter(isMenuPermission)
|
||||
|
||||
// 使用 buildTree 将扁平数据构建为树结构
|
||||
const treeResult = buildTree(res.details[0].permissions as ServerMenuItem[], {
|
||||
const treeResult = buildTree(menuPermissions, {
|
||||
orderKey: 'order',
|
||||
})
|
||||
console.log('buildTree', treeResult)
|
||||
|
||||
// 使用 transformMenuToRoutes 将树结构转换为路由配置
|
||||
const routes = transformMenuToRoutes(treeResult.rootItems as ServerMenuItem[])
|
||||
console.log('transformMenuToRoutes', routes)
|
||||
|
||||
// 动态注册路由
|
||||
routes.forEach((route) => {
|
||||
@@ -105,6 +109,7 @@ const useAppStore = defineStore('app', {
|
||||
})
|
||||
}
|
||||
this.serverMenu = []
|
||||
this.permissionCodes = []
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
@@ -16,6 +16,7 @@ export interface AppState {
|
||||
tabBar: boolean
|
||||
menuFromServer: boolean
|
||||
serverMenu: RouteRecordNormalized[]
|
||||
permissionCodes: string[]
|
||||
workspace?: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
@@ -19,8 +19,14 @@ import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import bannerImage from '@/assets/images/login-banner.png'
|
||||
|
||||
interface CarouselItem {
|
||||
slogan?: string
|
||||
subSlogan?: string
|
||||
image: string
|
||||
}
|
||||
|
||||
const { t } = useI18n()
|
||||
const carouselItem = computed(() => [
|
||||
const carouselItem = computed<CarouselItem[]>(() => [
|
||||
{
|
||||
// slogan: t('login.banner.slogan1'),
|
||||
// subSlogan: t('login.banner.subSlogan1'),
|
||||
|
||||
@@ -64,8 +64,8 @@
|
||||
<a-option value="<"><</a-option>
|
||||
<a-option value=">=">≥</a-option>
|
||||
<a-option value="<=">≤</a-option>
|
||||
<a-option value="==">=</a-option>
|
||||
<a-option value="!=">≠</a-option>
|
||||
<a-option v-if="formData.rule_type !== 'dynamic'" value="==">=</a-option>
|
||||
<a-option v-if="formData.rule_type !== 'dynamic'" value="!=">≠</a-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
@@ -307,7 +307,10 @@ const loadRuleDetail = async () => {
|
||||
formData.metric_name = rule.metric_name || ''
|
||||
formData.data_source = rule.data_source || 'dc-control'
|
||||
formData.threshold = rule.threshold
|
||||
formData.compare_op = rule.compare_op || '>'
|
||||
formData.compare_op = rule.compare_op || '>'
|
||||
if (formData.rule_type === 'dynamic' && ['==', '!='].includes(formData.compare_op)) {
|
||||
formData.compare_op = '>'
|
||||
}
|
||||
formData.duration = rule.duration ?? 60
|
||||
formData.eval_interval = rule.eval_interval ?? 60
|
||||
if (rule.rule_type === 'dynamic') {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { computed } from 'vue'
|
||||
import { computed, type Ref } from 'vue'
|
||||
import type { FormItem } from '@/components/search-form/types'
|
||||
import { SUPPRESSION_TYPE_OPTIONS } from '@/api/ops/suppression'
|
||||
import type { PolicyOption } from '../types'
|
||||
@@ -6,7 +6,7 @@ import type { PolicyOption } from '../types'
|
||||
/**
|
||||
* 获取筛选表单项配置
|
||||
*/
|
||||
export const useFormItems = (policyOptions: PolicyOption[]) => {
|
||||
export const useFormItems = (policyOptions: Ref<PolicyOption[]>) => {
|
||||
return computed<FormItem[]>(() => [
|
||||
{
|
||||
field: 'keyword',
|
||||
@@ -32,7 +32,7 @@ export const useFormItems = (policyOptions: PolicyOption[]) => {
|
||||
options: [
|
||||
{ value: '', label: '全部' },
|
||||
{ value: '0', label: '仅全局规则' },
|
||||
...policyOptions.map((p) => ({ value: String(p.id), label: p.name })),
|
||||
...policyOptions.value.map((p) => ({ value: String(p.id), label: p.name })),
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -136,7 +136,7 @@ const {
|
||||
} = useTable()
|
||||
|
||||
// 筛选表单项配置
|
||||
const formItems = useFormItems(policyOptions.value)
|
||||
const formItems = useFormItems(policyOptions)
|
||||
|
||||
// 表格列配置
|
||||
const tableColumns = computed(() => getTableColumns())
|
||||
|
||||
@@ -81,30 +81,29 @@ export const validateJsonFields = (formData: SuppressionRule, fields: string[]):
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建更新数据(只传非空字段)
|
||||
* 构建完整更新数据,显式传递空值以支持清除旧配置
|
||||
*/
|
||||
export const buildUpdateData = (formData: SuppressionRule): SuppressionUpdateParams => {
|
||||
const updateData: SuppressionUpdateParams = { id: formData.id! }
|
||||
|
||||
if (formData.name) updateData.name = formData.name
|
||||
if (formData.type) updateData.type = formData.type
|
||||
if (formData.description) updateData.description = formData.description
|
||||
updateData.enabled = formData.enabled
|
||||
if (formData.priority && formData.priority > 0) updateData.priority = formData.priority
|
||||
if (formData.policy_id && formData.policy_id > 0) updateData.policy_id = formData.policy_id
|
||||
if (formData.dedup_window && formData.dedup_window > 0) updateData.dedup_window = formData.dedup_window
|
||||
if (formData.dedup_keys) updateData.dedup_keys = formData.dedup_keys
|
||||
if (formData.aggregate_window && formData.aggregate_window > 0) updateData.aggregate_window = formData.aggregate_window
|
||||
if (formData.group_by) updateData.group_by = formData.group_by
|
||||
if (formData.aggregate_count && formData.aggregate_count > 0) updateData.aggregate_count = formData.aggregate_count
|
||||
if (formData.source_matchers) updateData.source_matchers = formData.source_matchers
|
||||
if (formData.target_matchers) updateData.target_matchers = formData.target_matchers
|
||||
if (formData.throttle_count && formData.throttle_count > 0) updateData.throttle_count = formData.throttle_count
|
||||
if (formData.throttle_window && formData.throttle_window > 0) updateData.throttle_window = formData.throttle_window
|
||||
if (formData.schedule) updateData.schedule = formData.schedule
|
||||
if (formData.matchers) updateData.matchers = formData.matchers
|
||||
|
||||
return updateData
|
||||
return {
|
||||
id: formData.id!,
|
||||
name: formData.name,
|
||||
type: formData.type,
|
||||
description: formData.description ?? '',
|
||||
enabled: formData.enabled ?? true,
|
||||
priority: formData.priority ?? 0,
|
||||
policy_id: formData.policy_id ?? 0,
|
||||
dedup_window: formData.dedup_window ?? 0,
|
||||
dedup_keys: formData.dedup_keys ?? '',
|
||||
aggregate_window: formData.aggregate_window ?? 0,
|
||||
group_by: formData.group_by ?? '',
|
||||
aggregate_count: formData.aggregate_count ?? 0,
|
||||
source_matchers: formData.source_matchers ?? '',
|
||||
target_matchers: formData.target_matchers ?? '',
|
||||
throttle_count: formData.throttle_count ?? 0,
|
||||
throttle_window: formData.throttle_window ?? 0,
|
||||
schedule: formData.schedule ?? '',
|
||||
matchers: formData.matchers ?? '',
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -89,7 +89,7 @@
|
||||
<a-button type="text" size="small" :disabled="isActionDisabled(record, 'comment')" @click.stop="handleComment(record)">
|
||||
评论
|
||||
</a-button>
|
||||
<a-dropdown @select="(v) => handleMoreSelect(v, record)">
|
||||
<a-dropdown @select="handleMoreSelect($event, record)">
|
||||
<a-button type="text" size="small" @click.stop>更多</a-button>
|
||||
<template #content>
|
||||
<a-doption value="detail">详情</a-doption>
|
||||
|
||||
@@ -85,7 +85,7 @@
|
||||
<a-col :xs="24" :sm="12" :md="6">
|
||||
<a-form-item label="比较运算符" :field="`rules[${index}].compare_op`" :rules="getRuleFieldRules('compare_op')">
|
||||
<a-select v-model="rule.compare_op" placeholder="请选择" @change="syncUpdate(index)">
|
||||
<a-option v-for="item in COMPARE_OPERATORS" :key="item.value" :value="item.value">
|
||||
<a-option v-for="item in availableCompareOperators(rule.rule_type)" :key="item.value" :value="item.value">
|
||||
{{ item.label }}
|
||||
</a-option>
|
||||
</a-select>
|
||||
@@ -254,6 +254,7 @@ watch(
|
||||
const copied: RuleItem[] = JSON.parse(JSON.stringify(val))
|
||||
localRules.value = copied.map((rule) => ({
|
||||
...rule,
|
||||
compare_op: rule.rule_type === 'dynamic' && ['==', '!='].includes(rule.compare_op) ? '>' : rule.compare_op,
|
||||
_baseline: parseBaselineConfig(rule.baseline_config),
|
||||
}))
|
||||
},
|
||||
@@ -336,6 +337,9 @@ const getRuleFieldRules = (field: string) => {
|
||||
return [{ required: true, message: messages[field] || '该字段为必填项' }]
|
||||
}
|
||||
|
||||
const availableCompareOperators = (ruleType: string) =>
|
||||
ruleType === 'dynamic' ? COMPARE_OPERATORS.filter((item) => item.value !== '==' && item.value !== '!=') : COMPARE_OPERATORS
|
||||
|
||||
// 暴露验证方法给父组件
|
||||
const validate = async () => {
|
||||
return ruleFormRef.value?.validate()
|
||||
@@ -349,6 +353,9 @@ defineExpose({
|
||||
const syncUpdate = (index: number) => {
|
||||
const newRules = [...props.rules]
|
||||
const { _metrics, _metricsLoading, _baseline, ...rest } = localRules.value[index]
|
||||
if (rest.rule_type === 'dynamic' && ['==', '!='].includes(rest.compare_op)) {
|
||||
rest.compare_op = '>'
|
||||
}
|
||||
const baseline_config = rest.rule_type === 'dynamic' ? JSON.stringify(_baseline || defaultBaselineConfig()) : '{}'
|
||||
newRules[index] = { ...rest, baseline_config, _metrics, _metricsLoading } as RuleItem
|
||||
emit('update:rules', newRules)
|
||||
|
||||
@@ -527,6 +527,9 @@ const handleSubmit = async () => {
|
||||
// 提取规则数据
|
||||
const submitRules = formData.value.rules.map((r) => {
|
||||
const { _metrics, _metricsLoading, ...rest } = r as any
|
||||
if (rest.rule_type === 'dynamic' && ['==', '!='].includes(rest.compare_op)) {
|
||||
rest.compare_op = '>'
|
||||
}
|
||||
return rest
|
||||
})
|
||||
|
||||
|
||||
@@ -644,6 +644,9 @@ const handleSubmit = async () => {
|
||||
// 提取规则数据
|
||||
const submitRules = formData.value.rules.map((r) => {
|
||||
const { _metrics, _metricsLoading, ...rest } = r as any
|
||||
if (rest.rule_type === 'dynamic' && ['==', '!='].includes(rest.compare_op)) {
|
||||
rest.compare_op = '>'
|
||||
}
|
||||
return rest
|
||||
})
|
||||
|
||||
|
||||
@@ -163,7 +163,7 @@
|
||||
<!-- 分配U位对话框 -->
|
||||
<allocate-unit-dialog
|
||||
v-model:visible="allocateVisible"
|
||||
:rack-id="selectedRackId"
|
||||
:rack-id="selectedRackId!"
|
||||
:rack-height="rackInfo.height"
|
||||
@success="handleRefresh"
|
||||
/>
|
||||
@@ -171,7 +171,7 @@
|
||||
<!-- 预留U位对话框 -->
|
||||
<reserve-unit-dialog
|
||||
v-model:visible="reserveVisible"
|
||||
:rack-id="selectedRackId"
|
||||
:rack-id="selectedRackId!"
|
||||
:rack-height="rackInfo.height"
|
||||
@success="handleRefresh"
|
||||
/>
|
||||
|
||||
@@ -265,7 +265,7 @@ const loadPolicyOptions = async () => {
|
||||
|
||||
const loadRoomOptions = async () => {
|
||||
try {
|
||||
const response: any = await fetchRoomOptions({ enabled: true })
|
||||
const response: any = await fetchRoomOptions()
|
||||
if (Array.isArray(response)) {
|
||||
roomOptions.value = response
|
||||
} else if (response && response.details) {
|
||||
@@ -347,6 +347,11 @@ watch(
|
||||
const handleOk = async () => {
|
||||
try {
|
||||
await formRef.value?.validate()
|
||||
const roomId = formData.room_id
|
||||
if (!roomId) {
|
||||
Message.warning('请选择机房')
|
||||
return
|
||||
}
|
||||
if (formData.collect_method === 'api' && !formData.agent_config?.trim()) {
|
||||
Message.warning('API 模式下请填写采集地址')
|
||||
return
|
||||
@@ -384,7 +389,7 @@ const handleOk = async () => {
|
||||
const updateData: RoomDeviceUpdateData = {
|
||||
name: formData.name,
|
||||
description: formData.description,
|
||||
room_id: formData.room_id,
|
||||
room_id: roomId,
|
||||
device_category: formData.device_category,
|
||||
agent_config: formData.agent_config,
|
||||
collect_method: formData.collect_method,
|
||||
@@ -417,7 +422,7 @@ const handleOk = async () => {
|
||||
const createData: RoomDeviceCreateData = {
|
||||
name: formData.name,
|
||||
description: formData.description,
|
||||
room_id: formData.room_id,
|
||||
room_id: roomId,
|
||||
device_category: formData.device_category,
|
||||
agent_config: formData.agent_config,
|
||||
collect_method: formData.collect_method,
|
||||
|
||||
@@ -157,7 +157,7 @@ const handleViewMetrics = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const formatTime = (time?: string) => {
|
||||
const formatTime = (time?: string | null) => {
|
||||
if (!time) return '-'
|
||||
if (time.startsWith('0001-01-01')) return '-'
|
||||
const date = new Date(time)
|
||||
|
||||
@@ -160,7 +160,7 @@ const handleViewMetrics = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const formatTime = (time?: string) => {
|
||||
const formatTime = (time?: string | null) => {
|
||||
if (!time) return '-'
|
||||
if (time.startsWith('0001-01-01')) return '-'
|
||||
const date = new Date(time)
|
||||
|
||||
@@ -443,7 +443,7 @@ async function loadHardware() {
|
||||
if (isHostHardwareApiSuccess(colRes)) {
|
||||
const col = unwrapHostHardwareDetails(colRes)
|
||||
const did = col?.device_id
|
||||
deviceId.value = col?.device_id
|
||||
deviceId.value = did ?? null
|
||||
if (did) {
|
||||
const detailOk = await loadDeviceDetailIntoForm(did, gen)
|
||||
if (gen !== hardwareLoadGeneration) return
|
||||
|
||||
@@ -181,7 +181,9 @@ let assetSearchTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const isEdit = computed(() => !!props.record?.id)
|
||||
|
||||
const formData = reactive<ServerFormData>({
|
||||
type ServerDialogFormData = ServerFormData & Required<Pick<ServerFormData, 'host'>>
|
||||
|
||||
const formData = reactive<ServerDialogFormData>({
|
||||
server_identity: '',
|
||||
name: '',
|
||||
host: '',
|
||||
|
||||
447
src/views/ops/pages/kb/categories/index.vue
Normal file
447
src/views/ops/pages/kb/categories/index.vue
Normal file
@@ -0,0 +1,447 @@
|
||||
<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" :disabled="writeBusy" @click="openCreate(0)">新增一级分类</a-button>
|
||||
<a-button size="small" :loading="treeLoading" :disabled="writeBusy" @click="loadTree">刷新</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
<a-spin :loading="treeLoading" 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"
|
||||
:disabled="writeBusy"
|
||||
@click="openCreate(selectedCategory.id)"
|
||||
>
|
||||
新增子分类
|
||||
</a-button>
|
||||
<a-button size="small" :disabled="writeBusy" @click="openEdit">编辑</a-button>
|
||||
<a-button
|
||||
size="small"
|
||||
status="danger"
|
||||
:loading="deletingID === selectedCategory.id"
|
||||
:disabled="writeBusy"
|
||||
@click="confirmDelete"
|
||||
>
|
||||
删除
|
||||
</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
|
||||
<a-spin :loading="detailLoading" class="detail-loading">
|
||||
<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-spin>
|
||||
</a-card>
|
||||
</div>
|
||||
|
||||
<a-modal
|
||||
:visible="formVisible"
|
||||
:title="editingCategory ? '编辑分类' : '新增分类'"
|
||||
:closable="!submitting"
|
||||
:mask-closable="!submitting"
|
||||
:esc-to-close="!submitting"
|
||||
:on-before-cancel="canCloseForm"
|
||||
width="640px"
|
||||
@cancel="closeForm"
|
||||
@update:visible="handleFormVisibleChange"
|
||||
>
|
||||
<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>
|
||||
<template #footer>
|
||||
<a-space>
|
||||
<a-button :disabled="submitting" @click="closeForm">取消</a-button>
|
||||
<a-button type="primary" :loading="submitting" :disabled="submitting" @click="submitForm">保存</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
</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,
|
||||
fetchCategoryDetail,
|
||||
fetchCategoryTree,
|
||||
updateCategory,
|
||||
type Category,
|
||||
type CategoryStatus,
|
||||
type CategoryTreeNode,
|
||||
} from '@/api/kb/category'
|
||||
import usePermissionCodes from '@/hooks/usePermissionCodes'
|
||||
|
||||
interface TreeViewNode {
|
||||
key: string
|
||||
title: string
|
||||
category: CategoryTreeNode
|
||||
children: TreeViewNode[]
|
||||
}
|
||||
|
||||
interface ParentOption {
|
||||
id: number
|
||||
label: string
|
||||
level: number
|
||||
status: CategoryStatus
|
||||
}
|
||||
|
||||
const { hasPermission } = usePermissionCodes()
|
||||
const canManage = computed(() => hasPermission('kb:content:manage'))
|
||||
const treeLoading = ref(false)
|
||||
const detailLoading = ref(false)
|
||||
const tree = ref<CategoryTreeNode[]>([])
|
||||
const selectedKeys = ref<string[]>([])
|
||||
const selectedCategory = ref<Category | null>(null)
|
||||
const selectedTreeID = ref(0)
|
||||
let treeSequence = 0
|
||||
let detailSequence = 0
|
||||
|
||||
const treeData = computed<TreeViewNode[]>(() => tree.value.map((node) => toViewNode(node)))
|
||||
|
||||
const formVisible = ref(false)
|
||||
const submitting = ref(false)
|
||||
const deletingID = ref(0)
|
||||
const deletePending = ref(false)
|
||||
const writeBusy = computed(() => submitting.value || deletePending.value)
|
||||
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 = ++treeSequence
|
||||
++detailSequence
|
||||
detailLoading.value = false
|
||||
selectedCategory.value = null
|
||||
treeLoading.value = true
|
||||
try {
|
||||
const reply = await fetchCategoryTree()
|
||||
if (sequence !== treeSequence) return
|
||||
if (reply.code !== 0) throw new Error(reply.message || '获取分类树失败')
|
||||
tree.value = reply.details || []
|
||||
const selectedID = selectedTreeID.value
|
||||
const selected = selectedID ? findCategory(tree.value, selectedID) : tree.value[0]
|
||||
selectedTreeID.value = selected?.id || 0
|
||||
selectedKeys.value = selected ? [String(selected.id)] : []
|
||||
if (selected) await loadCategoryDetail(selected.id)
|
||||
} catch (error) {
|
||||
if (sequence === treeSequence) {
|
||||
tree.value = []
|
||||
selectedCategory.value = null
|
||||
selectedTreeID.value = 0
|
||||
selectedKeys.value = []
|
||||
showRequestError(error, '获取分类树失败')
|
||||
}
|
||||
} finally {
|
||||
if (sequence === treeSequence) treeLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleSelect(keys: Array<string | number>) {
|
||||
const id = Number(keys[0])
|
||||
if (!Number.isFinite(id) || !findCategory(tree.value, id)) {
|
||||
selectedTreeID.value = 0
|
||||
selectedCategory.value = null
|
||||
return
|
||||
}
|
||||
selectedTreeID.value = id
|
||||
loadCategoryDetail(id)
|
||||
}
|
||||
|
||||
/** 分类树只负责导航,完整详情始终由详情接口读取。 */
|
||||
async function loadCategoryDetail(id: number) {
|
||||
const sequence = ++detailSequence
|
||||
selectedCategory.value = null
|
||||
detailLoading.value = true
|
||||
try {
|
||||
const reply = await fetchCategoryDetail(id)
|
||||
if (sequence !== detailSequence || selectedTreeID.value !== id) return
|
||||
if (reply.code !== 0) throw new Error(reply.message || '获取分类详情失败')
|
||||
selectedCategory.value = reply.details
|
||||
} catch (error) {
|
||||
if (sequence === detailSequence && selectedTreeID.value === id) showRequestError(error, '获取分类详情失败')
|
||||
} finally {
|
||||
if (sequence === detailSequence) detailLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openCreate(parentID: number) {
|
||||
if (writeBusy.value) return
|
||||
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 || writeBusy.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() {
|
||||
if (submitting.value) return
|
||||
formVisible.value = false
|
||||
editingCategory.value = null
|
||||
formRef.value?.clearValidate()
|
||||
}
|
||||
|
||||
async function submitForm() {
|
||||
if (submitting.value) return
|
||||
submitting.value = true
|
||||
try {
|
||||
if (await formRef.value?.validate()) return
|
||||
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 ? '分类已更新' : '分类已创建')
|
||||
formVisible.value = false
|
||||
editingCategory.value = null
|
||||
formRef.value?.clearValidate()
|
||||
await loadTree()
|
||||
} catch (error) {
|
||||
showRequestError(error, '保存分类失败')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDelete() {
|
||||
if (!selectedCategory.value || writeBusy.value) return
|
||||
const category = selectedCategory.value
|
||||
deletePending.value = true
|
||||
deletingID.value = category.id
|
||||
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('分类已删除')
|
||||
selectedTreeID.value = 0
|
||||
selectedCategory.value = null
|
||||
await loadTree()
|
||||
} catch (error) {
|
||||
showRequestError(error, '删除分类失败')
|
||||
} finally {
|
||||
deletePending.value = false
|
||||
deletingID.value = 0
|
||||
}
|
||||
},
|
||||
onCancel: finishDelete,
|
||||
})
|
||||
}
|
||||
|
||||
function finishDelete() {
|
||||
deletePending.value = false
|
||||
deletingID.value = 0
|
||||
}
|
||||
|
||||
function canCloseForm() {
|
||||
return !submitting.value
|
||||
}
|
||||
|
||||
function handleFormVisibleChange(visible: boolean) {
|
||||
if (!visible) closeForm()
|
||||
}
|
||||
|
||||
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,
|
||||
.detail-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>
|
||||
202
src/views/ops/pages/kb/faq/components/FaqDetailDrawer.vue
Normal file
202
src/views/ops/pages/kb/faq/components/FaqDetailDrawer.vue
Normal file
@@ -0,0 +1,202 @@
|
||||
<template>
|
||||
<a-drawer
|
||||
:visible="visible"
|
||||
:width="820"
|
||||
title="FAQ 详情"
|
||||
:footer="false"
|
||||
unmount-on-close
|
||||
@cancel="close"
|
||||
@update:visible="handleVisibleChange"
|
||||
>
|
||||
<a-spin :loading="loading" style="width: 100%">
|
||||
<template v-if="faq">
|
||||
<div class="detail-header">
|
||||
<div>
|
||||
<div class="faq-no">{{ faq.faq_no || `FAQ-${faq.id}` }}</div>
|
||||
<h2>{{ faq.question }}</h2>
|
||||
<a-space>
|
||||
<a-tag :color="getFaqStatusColor(faq.status)">{{ getFaqStatusText(faq.status) }}</a-tag>
|
||||
<a-tag :color="getFaqPriorityColor(faq.priority)">{{ getFaqPriorityText(faq.priority) }}</a-tag>
|
||||
</a-space>
|
||||
</div>
|
||||
<a-space wrap>
|
||||
<a-button :loading="favoriteLoading" @click="emit('favorite', faq)">
|
||||
<template #icon>
|
||||
<icon-star-fill v-if="faq.is_favorited" />
|
||||
<icon-star v-else />
|
||||
</template>
|
||||
{{ faq.is_favorited ? '取消收藏' : '收藏' }}
|
||||
</a-button>
|
||||
<a-button v-if="canEdit" type="primary" @click="emit('edit', faq)">编辑</a-button>
|
||||
<a-button v-if="canSubmit" :loading="actionLoading" @click="emit('submit', faq)">提交审核</a-button>
|
||||
<a-button v-if="canDelete" status="danger" :loading="actionLoading" @click="emit('delete', faq)">删除</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
|
||||
<a-alert v-if="faq.status === 'rejected'" type="error" class="reject-alert">
|
||||
<template #title>审核未通过</template>
|
||||
{{ rejectionReason(faq.remarks) }}
|
||||
</a-alert>
|
||||
|
||||
<section>
|
||||
<h3>基本信息</h3>
|
||||
<a-descriptions :column="2" bordered>
|
||||
<a-descriptions-item label="公共分类">{{ categoryName || '-' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="子分类">{{ faq.sub_category || '-' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="问题类型">{{ faq.problem_type || '-' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="作者">{{ faq.author_name || `用户 ${faq.author_id}` }}</a-descriptions-item>
|
||||
<a-descriptions-item label="更新时间">{{ formatTime(faq.updated_at) }}</a-descriptions-item>
|
||||
<a-descriptions-item label="审核人">{{ faq.reviewer_name || '-' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="审核时间">{{ formatTime(faq.reviewed_at) }}</a-descriptions-item>
|
||||
<a-descriptions-item label="提交时间">{{ formatTime(faq.published_at) }}</a-descriptions-item>
|
||||
<a-descriptions-item label="创建时间">{{ formatTime(faq.created_at) }}</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3>处理方案</h3>
|
||||
<a-descriptions :column="1" bordered>
|
||||
<a-descriptions-item label="答案">
|
||||
<div class="pre-wrap">{{ faq.answer || '-' }}</div>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="详细解决方案">
|
||||
<div class="pre-wrap">{{ faq.solution || '-' }}</div>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="处理步骤">
|
||||
<div class="pre-wrap">{{ faq.process_steps || '-' }}</div>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="前置条件">
|
||||
<div class="pre-wrap">{{ faq.prerequisites || '-' }}</div>
|
||||
</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3>使用范围与关联</h3>
|
||||
<a-descriptions :column="2" bordered>
|
||||
<a-descriptions-item label="适用范围" :span="2">{{ faq.applicable_scope || '-' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="关键词" :span="2">{{ faq.keywords || '-' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="标签" :span="2">{{ faq.tags || '-' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="关联 FAQ">{{ faq.related_faqs || '-' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="关联文档">{{ faq.related_docs || '-' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="相关链接" :span="2">{{ faq.related_links || '-' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="检测点">{{ faq.detection_point_ids || '-' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="附件">{{ faq.attachments || '-' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="备注" :span="2">
|
||||
<div class="pre-wrap">{{ faq.remarks || '-' }}</div>
|
||||
</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3>使用情况</h3>
|
||||
<a-descriptions :column="4" bordered>
|
||||
<a-descriptions-item label="浏览">{{ faq.view_count || 0 }}</a-descriptions-item>
|
||||
<a-descriptions-item label="使用">{{ faq.use_count || 0 }}</a-descriptions-item>
|
||||
<a-descriptions-item label="有帮助">{{ faq.helpful_count || 0 }}</a-descriptions-item>
|
||||
<a-descriptions-item label="无帮助">{{ faq.useless_count || 0 }}</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
</section>
|
||||
</template>
|
||||
<a-empty v-else-if="!loading" description="未找到 FAQ" />
|
||||
</a-spin>
|
||||
</a-drawer>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import dayjs from 'dayjs'
|
||||
import { IconStar, IconStarFill } from '@arco-design/web-vue/es/icon'
|
||||
import { getFaqPriorityColor, getFaqPriorityText, getFaqStatusColor, getFaqStatusText, type Faq } from '@/api/kb/faq'
|
||||
|
||||
interface Props {
|
||||
visible: boolean
|
||||
faq: Faq | null
|
||||
categoryName?: string
|
||||
loading?: boolean
|
||||
favoriteLoading?: boolean
|
||||
actionLoading?: boolean
|
||||
canEdit?: boolean
|
||||
canSubmit?: boolean
|
||||
canDelete?: boolean
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
categoryName: '',
|
||||
loading: false,
|
||||
favoriteLoading: false,
|
||||
actionLoading: false,
|
||||
canEdit: false,
|
||||
canSubmit: false,
|
||||
canDelete: false,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'update:visible', visible: boolean): void
|
||||
(event: 'edit', faq: Faq): void
|
||||
(event: 'submit', faq: Faq): void
|
||||
(event: 'favorite', faq: Faq): void
|
||||
(event: 'delete', faq: Faq): void
|
||||
}>()
|
||||
|
||||
function close() {
|
||||
emit('update:visible', false)
|
||||
}
|
||||
|
||||
function handleVisibleChange(visible: boolean) {
|
||||
if (!visible) close()
|
||||
}
|
||||
|
||||
/** 将后端时间统一为列表使用的分钟精度。 */
|
||||
function formatTime(value: string | null | undefined) {
|
||||
return value ? dayjs(value).format('YYYY-MM-DD HH:mm') : '-'
|
||||
}
|
||||
|
||||
/** 提取最近一次审核拒绝原因。 */
|
||||
function rejectionReason(remarks: string) {
|
||||
const matches = [...remarks.matchAll(/\[审核拒绝 [^\]]+\]\s*([^\n]+)/g)]
|
||||
return matches.length ? matches[matches.length - 1][1] : remarks || '未填写原因'
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.detail-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
margin-bottom: 20px;
|
||||
|
||||
h2 {
|
||||
margin: 4px 0 12px;
|
||||
color: var(--color-text-1);
|
||||
font-size: 20px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
|
||||
.faq-no {
|
||||
color: var(--color-text-3);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.reject-alert {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
section {
|
||||
& + & {
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin: 0 0 12px;
|
||||
color: var(--color-text-1);
|
||||
font-size: 15px;
|
||||
}
|
||||
}
|
||||
|
||||
.pre-wrap {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
</style>
|
||||
317
src/views/ops/pages/kb/faq/components/FaqFormDrawer.vue
Normal file
317
src/views/ops/pages/kb/faq/components/FaqFormDrawer.vue
Normal file
@@ -0,0 +1,317 @@
|
||||
<template>
|
||||
<a-drawer
|
||||
:visible="visible"
|
||||
:width="760"
|
||||
:title="faq ? '编辑 FAQ' : '新建 FAQ'"
|
||||
:closable="!busy"
|
||||
:mask-closable="!busy"
|
||||
:esc-to-close="!busy"
|
||||
:on-before-cancel="canClose"
|
||||
unmount-on-close
|
||||
@cancel="close"
|
||||
@update:visible="handleVisibleChange"
|
||||
>
|
||||
<a-form ref="formRef" :model="form" layout="vertical">
|
||||
<section class="form-section">
|
||||
<h3>基本信息</h3>
|
||||
<a-form-item label="问题" field="question" :rules="[{ required: true, message: '请输入问题' }]">
|
||||
<a-textarea
|
||||
v-model="form.question"
|
||||
placeholder="请输入常见问题"
|
||||
:auto-size="{ minRows: 2, maxRows: 5 }"
|
||||
:max-length="1000"
|
||||
show-word-limit
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="12">
|
||||
<a-form-item label="公共分类" field="category_id" :rules="[{ required: true, message: '请选择公共分类' }]">
|
||||
<a-select v-model="form.category_id" placeholder="请选择公共分类" allow-search>
|
||||
<a-option v-for="category in categories" :key="category.id" :value="category.id">
|
||||
{{ category.label }}
|
||||
</a-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="6">
|
||||
<a-form-item label="优先级" field="priority">
|
||||
<a-select v-model="form.priority">
|
||||
<a-option v-for="option in faqPriorityOptions" :key="option.value" :value="option.value">
|
||||
{{ option.label }}
|
||||
</a-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="6">
|
||||
<a-form-item label="问题类型" field="problem_type">
|
||||
<a-select v-model="form.problem_type" placeholder="请选择或输入" allow-clear allow-search allow-create>
|
||||
<a-option v-for="option in faqProblemTypeOptions" :key="option.value" :value="option.value">
|
||||
{{ option.label }}
|
||||
</a-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
<a-form-item label="子分类" field="sub_category">
|
||||
<a-input v-model="form.sub_category" placeholder="请输入子分类或补充分类说明" :max-length="100" allow-clear />
|
||||
</a-form-item>
|
||||
<a-alert v-if="originalCategoryUnavailable" type="warning">原分类已停用或不可用,请重新选择</a-alert>
|
||||
</section>
|
||||
|
||||
<section class="form-section">
|
||||
<h3>处理方案</h3>
|
||||
<a-form-item label="答案" field="answer" :rules="[{ required: true, message: '请输入答案' }]">
|
||||
<a-textarea v-model="form.answer" placeholder="请输入答案或处理方式" :auto-size="{ minRows: 4, maxRows: 10 }" />
|
||||
</a-form-item>
|
||||
<a-form-item label="详细解决方案" field="solution">
|
||||
<a-textarea v-model="form.solution" placeholder="补充完整的解决方案" :auto-size="{ minRows: 3, maxRows: 8 }" />
|
||||
</a-form-item>
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="12">
|
||||
<a-form-item label="处理步骤" field="process_steps">
|
||||
<a-textarea v-model="form.process_steps" placeholder="请输入处理步骤" :auto-size="{ minRows: 3, maxRows: 8 }" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item label="前置条件" field="prerequisites">
|
||||
<a-textarea v-model="form.prerequisites" placeholder="请输入前置条件" :auto-size="{ minRows: 3, maxRows: 8 }" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</section>
|
||||
|
||||
<section class="form-section">
|
||||
<h3>使用范围</h3>
|
||||
<a-form-item label="适用范围" field="applicable_scope">
|
||||
<a-textarea
|
||||
v-model="form.applicable_scope"
|
||||
placeholder="说明适用的系统、角色或业务场景"
|
||||
:auto-size="{ minRows: 2, maxRows: 5 }"
|
||||
:max-length="200"
|
||||
show-word-limit
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="12">
|
||||
<a-form-item label="关键词" field="keywords">
|
||||
<a-input v-model="form.keywords" placeholder="多个关键词用逗号分隔" :max-length="500" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item label="标签" field="tags">
|
||||
<a-input v-model="form.tags" placeholder="支持逗号分隔或 JSON 数组" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
<a-form-item label="检测点 ID" field="detection_point_ids">
|
||||
<a-input v-model="form.detection_point_ids" placeholder="支持逗号分隔或 JSON 数组" />
|
||||
</a-form-item>
|
||||
<a-form-item label="备注" field="remarks">
|
||||
<a-textarea v-model="form.remarks" placeholder="请输入备注" :auto-size="{ minRows: 2, maxRows: 5 }" />
|
||||
</a-form-item>
|
||||
</section>
|
||||
</a-form>
|
||||
|
||||
<template #footer>
|
||||
<a-space>
|
||||
<a-button :disabled="busy" @click="close">取消</a-button>
|
||||
<a-button type="primary" :loading="busy" :disabled="busy" @click="handleSubmit">保存</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
</a-drawer>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import { Message, type FormInstance } from '@arco-design/web-vue'
|
||||
import { faqPriorityOptions, faqProblemTypeOptions, type Faq, type FaqFormData } from '@/api/kb/faq'
|
||||
|
||||
/** 表单和筛选使用的扁平分类节点。 */
|
||||
export interface FaqCategoryOption {
|
||||
id: number
|
||||
name: string
|
||||
label: string
|
||||
parentId: number
|
||||
level: number
|
||||
path: string
|
||||
status: 'active' | 'inactive'
|
||||
}
|
||||
|
||||
interface Props {
|
||||
visible: boolean
|
||||
faq: Faq | null
|
||||
categories: FaqCategoryOption[]
|
||||
submitting?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
submitting: false,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'update:visible', visible: boolean): void
|
||||
(event: 'submit', values: FaqFormData, done: () => void): void
|
||||
}>()
|
||||
|
||||
const formRef = ref<FormInstance>()
|
||||
const validating = ref(false)
|
||||
const originalCategoryUnavailable = ref(false)
|
||||
const busy = computed(() => validating.value || props.submitting)
|
||||
type FaqEditorForm = Omit<FaqFormData, 'category_id'> & { category_id?: number }
|
||||
|
||||
const form = reactive<FaqEditorForm>(createEmptyForm())
|
||||
|
||||
/** 创建一份不共享引用的空表单。 */
|
||||
function createEmptyForm(): FaqEditorForm {
|
||||
return {
|
||||
question: '',
|
||||
answer: '',
|
||||
priority: 'medium',
|
||||
category_id: undefined,
|
||||
sub_category: '',
|
||||
problem_type: '',
|
||||
solution: '',
|
||||
process_steps: '',
|
||||
prerequisites: '',
|
||||
keywords: '',
|
||||
tags: '',
|
||||
detection_point_ids: '',
|
||||
applicable_scope: '',
|
||||
remarks: '',
|
||||
}
|
||||
}
|
||||
|
||||
/** 打开抽屉时载入当前 FAQ,关闭后由 unmount-on-close 清理视图。 */
|
||||
function resetForm() {
|
||||
const faq = props.faq
|
||||
const categoryAvailable = Boolean(faq && props.categories.some((category) => category.id === faq.category_id))
|
||||
originalCategoryUnavailable.value = Boolean(faq && !categoryAvailable)
|
||||
Object.assign(
|
||||
form,
|
||||
faq
|
||||
? {
|
||||
question: faq.question,
|
||||
answer: faq.answer,
|
||||
priority: faq.priority || 'medium',
|
||||
category_id: categoryAvailable ? faq.category_id : undefined,
|
||||
sub_category: faq.sub_category || '',
|
||||
problem_type: faq.problem_type || '',
|
||||
solution: faq.solution || '',
|
||||
process_steps: faq.process_steps || '',
|
||||
prerequisites: faq.prerequisites || '',
|
||||
keywords: faq.keywords || '',
|
||||
tags: faq.tags || '',
|
||||
detection_point_ids: faq.detection_point_ids || '',
|
||||
applicable_scope: faq.applicable_scope || '',
|
||||
remarks: faq.remarks || '',
|
||||
}
|
||||
: createEmptyForm()
|
||||
)
|
||||
validating.value = false
|
||||
formRef.value?.clearValidate()
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.visible,
|
||||
(visible) => {
|
||||
if (visible) resetForm()
|
||||
else validating.value = false
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.categories,
|
||||
() => {
|
||||
const faq = props.faq
|
||||
if (!props.visible || !faq || !originalCategoryUnavailable.value) return
|
||||
if (props.categories.some((category) => category.id === faq.category_id)) {
|
||||
form.category_id = faq.category_id
|
||||
originalCategoryUnavailable.value = false
|
||||
}
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
watch(
|
||||
() => form.category_id,
|
||||
(categoryId) => {
|
||||
if (categoryId && props.categories.some((category) => category.id === categoryId)) {
|
||||
originalCategoryUnavailable.value = false
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
/** 校验通过后交由父页面保存,接口成功前不关闭抽屉。 */
|
||||
async function handleSubmit() {
|
||||
if (busy.value) return
|
||||
validating.value = true
|
||||
let handedOff = false
|
||||
try {
|
||||
const errors = await formRef.value?.validate()
|
||||
if (errors) return
|
||||
|
||||
const question = form.question.trim()
|
||||
const answer = form.answer.trim()
|
||||
const categoryId = form.category_id
|
||||
if (!question || !answer || !categoryId) {
|
||||
Message.warning('问题、答案和公共分类为必填项')
|
||||
return
|
||||
}
|
||||
|
||||
handedOff = true
|
||||
emit(
|
||||
'submit',
|
||||
{
|
||||
...form,
|
||||
question,
|
||||
answer,
|
||||
category_id: categoryId,
|
||||
sub_category: form.sub_category.trim(),
|
||||
problem_type: form.problem_type.trim(),
|
||||
solution: form.solution.trim(),
|
||||
process_steps: form.process_steps.trim(),
|
||||
prerequisites: form.prerequisites.trim(),
|
||||
keywords: form.keywords.trim(),
|
||||
tags: form.tags.trim(),
|
||||
detection_point_ids: form.detection_point_ids.trim(),
|
||||
applicable_scope: form.applicable_scope.trim(),
|
||||
remarks: form.remarks.trim(),
|
||||
},
|
||||
() => {
|
||||
validating.value = false
|
||||
}
|
||||
)
|
||||
} finally {
|
||||
if (!handedOff) validating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 提交中拒绝关闭,避免中断请求或丢失输入。 */
|
||||
function close() {
|
||||
if (!busy.value) emit('update:visible', false)
|
||||
}
|
||||
|
||||
function canClose() {
|
||||
return !busy.value
|
||||
}
|
||||
|
||||
function handleVisibleChange(visible: boolean) {
|
||||
if (!visible) close()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.form-section {
|
||||
& + & {
|
||||
margin-top: 28px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid var(--color-neutral-3);
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin: 0 0 16px;
|
||||
color: var(--color-text-1);
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
681
src/views/ops/pages/kb/faq/index.vue
Normal file
681
src/views/ops/pages/kb/faq/index.vue
Normal file
@@ -0,0 +1,681 @@
|
||||
<template>
|
||||
<div class="faq-page">
|
||||
<Breadcrumb :items="['知识管理', 'FAQ 管理']" />
|
||||
|
||||
<a-card class="general-card" :bordered="false">
|
||||
<div class="page-header">
|
||||
<a-tabs v-model:active-key="activeScope" @change="handleScopeChange">
|
||||
<a-tab-pane key="my" title="我的 FAQ" />
|
||||
<a-tab-pane key="all" title="全部 FAQ" />
|
||||
</a-tabs>
|
||||
<a-space>
|
||||
<a-button v-if="canCreate" type="primary" @click="openCreate">
|
||||
<template #icon><icon-plus /></template>
|
||||
新建 FAQ
|
||||
</a-button>
|
||||
<a-button :loading="loading" @click="fetchData">
|
||||
<template #icon><icon-refresh /></template>
|
||||
刷新
|
||||
</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
|
||||
<a-form :model="filters" layout="inline" class="filter-form" @submit-success="handleSearch">
|
||||
<a-form-item label="关键词" field="keyword">
|
||||
<a-input v-model="filters.keyword" placeholder="搜索问题、答案或方案" allow-clear />
|
||||
</a-form-item>
|
||||
<a-form-item label="公共分类" field="category_id">
|
||||
<a-select
|
||||
v-model="filters.category_id"
|
||||
placeholder="全部分类"
|
||||
allow-clear
|
||||
allow-search
|
||||
:loading="categoryLoading"
|
||||
style="width: 220px"
|
||||
>
|
||||
<a-option v-for="category in categoryFilterOptions" :key="category.id" :value="category.id">
|
||||
{{ category.label }}{{ category.status === 'inactive' ? '(停用)' : '' }}
|
||||
</a-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="状态" field="status">
|
||||
<a-select v-model="filters.status" placeholder="全部状态" allow-clear style="width: 120px">
|
||||
<a-option v-for="option in visibleStatusOptions" :key="option.value" :value="option.value">
|
||||
{{ option.label }}
|
||||
</a-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="优先级" field="priority">
|
||||
<a-select v-model="filters.priority" placeholder="全部优先级" allow-clear style="width: 120px">
|
||||
<a-option v-for="option in faqPriorityOptions" :key="option.value" :value="option.value">
|
||||
{{ option.label }}
|
||||
</a-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="问题类型" field="problem_type">
|
||||
<a-select v-model="filters.problem_type" placeholder="请选择或输入" allow-clear allow-search allow-create style="width: 150px">
|
||||
<a-option v-for="option in faqProblemTypeOptions" :key="option.value" :value="option.value">
|
||||
{{ option.label }}
|
||||
</a-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item>
|
||||
<a-space>
|
||||
<a-button type="primary" html-type="submit">查询</a-button>
|
||||
<a-button @click="handleReset">重置</a-button>
|
||||
</a-space>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
|
||||
<a-table
|
||||
row-key="id"
|
||||
:data="tableData"
|
||||
:columns="columns"
|
||||
:loading="loading"
|
||||
:pagination="pagination"
|
||||
:scroll="{ x: 1380 }"
|
||||
@page-change="handlePageChange"
|
||||
@page-size-change="handlePageSizeChange"
|
||||
>
|
||||
<template #question="{ record }">
|
||||
<a-button type="text" class="question-button" @click="openDetail(record)">
|
||||
{{ record.question }}
|
||||
</a-button>
|
||||
</template>
|
||||
<template #category="{ record }">
|
||||
{{ categoryName(record.category_id) }}
|
||||
</template>
|
||||
<template #priority="{ record }">
|
||||
<a-tag :color="getFaqPriorityColor(record.priority)">{{ getFaqPriorityText(record.priority) }}</a-tag>
|
||||
</template>
|
||||
<template #status="{ record }">
|
||||
<a-tag :color="getFaqStatusColor(record.status)">{{ getFaqStatusText(record.status) }}</a-tag>
|
||||
</template>
|
||||
<template #author="{ record }">
|
||||
{{ record.author_name || `用户 ${record.author_id}` }}
|
||||
</template>
|
||||
<template #updated_at="{ record }">
|
||||
{{ formatTime(record.updated_at) }}
|
||||
</template>
|
||||
<template #actions="{ record }">
|
||||
<a-space :size="4">
|
||||
<a-button type="text" size="small" @click="openDetail(record)">查看</a-button>
|
||||
<a-button type="text" size="small" :loading="isActionLoading(record.id, 'favorite')" @click="toggleFavorite(record)">
|
||||
<template #icon>
|
||||
<icon-star-fill v-if="record.is_favorited" />
|
||||
<icon-star v-else />
|
||||
</template>
|
||||
{{ record.is_favorited ? '取消收藏' : '收藏' }}
|
||||
</a-button>
|
||||
<a-button v-if="canEdit(record)" type="text" size="small" @click="openEdit(record)">编辑</a-button>
|
||||
<a-button
|
||||
v-if="canSubmit(record)"
|
||||
type="text"
|
||||
size="small"
|
||||
:loading="isActionLoading(record.id, 'publish')"
|
||||
@click="confirmPublish(record)"
|
||||
>
|
||||
提交审核
|
||||
</a-button>
|
||||
<a-button
|
||||
v-if="canDelete(record)"
|
||||
type="text"
|
||||
size="small"
|
||||
status="danger"
|
||||
:loading="isActionLoading(record.id, 'delete')"
|
||||
@click="confirmDelete(record)"
|
||||
>
|
||||
删除
|
||||
</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
<template #empty>
|
||||
<a-empty :description="activeScope === 'my' ? '暂无我的 FAQ' : '暂无已审核 FAQ'" />
|
||||
</template>
|
||||
</a-table>
|
||||
</a-card>
|
||||
|
||||
<FaqFormDrawer
|
||||
v-model:visible="formVisible"
|
||||
:faq="editingFaq"
|
||||
:categories="categoryFormOptions"
|
||||
:submitting="formSubmitting"
|
||||
@submit="saveFaq"
|
||||
/>
|
||||
|
||||
<FaqDetailDrawer
|
||||
:visible="detailVisible"
|
||||
:faq="detailFaq"
|
||||
:category-name="detailFaq ? categoryName(detailFaq.category_id) : ''"
|
||||
:loading="detailLoading"
|
||||
:favorite-loading="Boolean(detailFaq && isActionLoading(detailFaq.id, 'favorite'))"
|
||||
:action-loading="Boolean(detailFaq && (isActionLoading(detailFaq.id, 'publish') || isActionLoading(detailFaq.id, 'delete')))"
|
||||
:can-edit="Boolean(detailFaq && canEdit(detailFaq))"
|
||||
:can-submit="Boolean(detailFaq && canSubmit(detailFaq))"
|
||||
:can-delete="Boolean(detailFaq && canDelete(detailFaq))"
|
||||
@update:visible="handleDetailVisible"
|
||||
@edit="openEditFromDetail"
|
||||
@submit="confirmPublish"
|
||||
@favorite="toggleFavorite"
|
||||
@delete="confirmDelete"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import axios from 'axios'
|
||||
import dayjs from 'dayjs'
|
||||
import { Message, Modal } from '@arco-design/web-vue'
|
||||
import { IconPlus, IconRefresh, IconStar, IconStarFill } from '@arco-design/web-vue/es/icon'
|
||||
import type { TableColumnData } from '@arco-design/web-vue/es/table/interface'
|
||||
import {
|
||||
createFaq,
|
||||
deleteFaq,
|
||||
faqPriorityOptions,
|
||||
faqProblemTypeOptions,
|
||||
faqStatusOptions,
|
||||
favoriteFaq,
|
||||
fetchFaqDetail,
|
||||
fetchFaqList,
|
||||
fetchGeneralCategoryTree,
|
||||
getFaqPriorityColor,
|
||||
getFaqPriorityText,
|
||||
getFaqStatusColor,
|
||||
getFaqStatusText,
|
||||
publishFaq,
|
||||
unfavoriteFaq,
|
||||
updateFaq,
|
||||
type CategoryTreeNode,
|
||||
type Faq,
|
||||
type FaqFormData,
|
||||
type FaqPriority,
|
||||
type FaqScope,
|
||||
type FaqStatus,
|
||||
type KbReply,
|
||||
} from '@/api/kb/faq'
|
||||
import usePermissionCodes from '@/hooks/usePermissionCodes'
|
||||
import { useUserStore } from '@/store'
|
||||
import SafeStorage, { AppStorageKey } from '@/utils/safeStorage'
|
||||
import FaqFormDrawer, { type FaqCategoryOption } from './components/FaqFormDrawer.vue'
|
||||
import FaqDetailDrawer from './components/FaqDetailDrawer.vue'
|
||||
|
||||
type ActionType = 'favorite' | 'publish' | 'delete'
|
||||
|
||||
const columns: TableColumnData[] = [
|
||||
{ title: '问题', dataIndex: 'question', slotName: 'question', width: 320, ellipsis: true },
|
||||
{ title: '公共分类', dataIndex: 'category_id', slotName: 'category', width: 200, ellipsis: true },
|
||||
{ title: '优先级', dataIndex: 'priority', slotName: 'priority', width: 90, align: 'center' },
|
||||
{ title: '状态', dataIndex: 'status', slotName: 'status', width: 100, align: 'center' },
|
||||
{ title: '作者', dataIndex: 'author_name', slotName: 'author', width: 130, ellipsis: true },
|
||||
{ title: '更新时间', dataIndex: 'updated_at', slotName: 'updated_at', width: 170, align: 'center' },
|
||||
{ title: '操作', slotName: 'actions', width: 370, fixed: 'right' },
|
||||
]
|
||||
|
||||
const userStore = useUserStore()
|
||||
const { hasPermission } = usePermissionCodes()
|
||||
const canCreate = computed(() => hasPermission('kb:content:create'))
|
||||
const canManage = computed(() => hasPermission('kb:content:manage'))
|
||||
const currentUserId = computed(() => {
|
||||
let payload = userStore.$state.userInfo as Record<string, unknown> | null | undefined
|
||||
if (!payload || typeof payload !== 'object') {
|
||||
payload = SafeStorage.get<Record<string, unknown>>(AppStorageKey.USER_INFO)
|
||||
}
|
||||
return numericId(payload?.user_id ?? payload?.id)
|
||||
})
|
||||
|
||||
const activeScope = ref<FaqScope>('my')
|
||||
const filters = reactive<{
|
||||
keyword: string
|
||||
category_id?: number
|
||||
status?: FaqStatus
|
||||
priority?: FaqPriority
|
||||
problem_type: string
|
||||
}>({
|
||||
keyword: '',
|
||||
category_id: undefined,
|
||||
status: undefined,
|
||||
priority: undefined,
|
||||
problem_type: '',
|
||||
})
|
||||
const visibleStatusOptions = computed(() =>
|
||||
activeScope.value === 'all' ? faqStatusOptions.filter((option) => option.value === 'reviewed') : faqStatusOptions
|
||||
)
|
||||
|
||||
const tableData = ref<Faq[]>([])
|
||||
const loading = ref(false)
|
||||
const pagination = reactive({
|
||||
current: 1,
|
||||
pageSize: 20,
|
||||
total: 0,
|
||||
showTotal: true,
|
||||
showPageSize: true,
|
||||
})
|
||||
let listRequestSequence = 0
|
||||
|
||||
const categoryLoading = ref(false)
|
||||
const categoryFilterOptions = ref<FaqCategoryOption[]>([])
|
||||
const categoryFormOptions = computed(() => categoryFilterOptions.value.filter((category) => category.status === 'active'))
|
||||
const categoryLabels = ref(new Map<number, string>())
|
||||
|
||||
const formVisible = ref(false)
|
||||
const formSubmitting = ref(false)
|
||||
const editingFaq = ref<Faq | null>(null)
|
||||
|
||||
const detailVisible = ref(false)
|
||||
const detailLoading = ref(false)
|
||||
const detailFaq = ref<Faq | null>(null)
|
||||
let detailRequestSequence = 0
|
||||
|
||||
const actionState = reactive<{ id: number; type: ActionType | '' }>({ id: 0, type: '' })
|
||||
|
||||
/** 将登录信息里的 ID 收敛为可比较的数值。 */
|
||||
function numericId(value: unknown) {
|
||||
if (value === null || value === undefined || value === '') return 0
|
||||
const id = Number(value)
|
||||
return Number.isFinite(id) ? id : 0
|
||||
}
|
||||
|
||||
/** 按服务端规则判断当前用户能否修改 FAQ。 */
|
||||
function canMutate(faq: Faq) {
|
||||
if (faq.status === 'published') return false
|
||||
if (faq.status === 'reviewed') return canManage.value
|
||||
if (faq.status !== 'draft' && faq.status !== 'rejected') return false
|
||||
return canManage.value || (canCreate.value && currentUserId.value > 0 && faq.author_id === currentUserId.value)
|
||||
}
|
||||
|
||||
function canEdit(faq: Faq) {
|
||||
return canMutate(faq)
|
||||
}
|
||||
|
||||
function canSubmit(faq: Faq) {
|
||||
return (faq.status === 'draft' || faq.status === 'rejected') && canMutate(faq)
|
||||
}
|
||||
|
||||
function canDelete(faq: Faq) {
|
||||
return canMutate(faq)
|
||||
}
|
||||
|
||||
/** 加载当前范围的 FAQ,过期响应不会覆盖新查询。 */
|
||||
async function fetchData() {
|
||||
const sequence = ++listRequestSequence
|
||||
loading.value = true
|
||||
try {
|
||||
const reply = await fetchFaqList({
|
||||
page: pagination.current,
|
||||
page_size: pagination.pageSize,
|
||||
scope: activeScope.value,
|
||||
keyword: filters.keyword.trim() || undefined,
|
||||
category_id: filters.category_id,
|
||||
status: filters.status,
|
||||
priority: filters.priority,
|
||||
problem_type: filters.problem_type || undefined,
|
||||
})
|
||||
if (sequence !== listRequestSequence) return
|
||||
if (reply.code !== 0) {
|
||||
tableData.value = []
|
||||
pagination.total = 0
|
||||
Message.error(reply.message || '获取 FAQ 列表失败')
|
||||
return
|
||||
}
|
||||
tableData.value = reply.details?.data || []
|
||||
pagination.total = reply.details?.total || 0
|
||||
} catch (error) {
|
||||
if (sequence !== listRequestSequence) return
|
||||
tableData.value = []
|
||||
pagination.total = 0
|
||||
showRequestError(error, '获取 FAQ 列表失败')
|
||||
} finally {
|
||||
if (sequence === listRequestSequence) loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 加载全部公共分类,同时为表单保留 active 子集。 */
|
||||
async function loadCategories() {
|
||||
categoryLoading.value = true
|
||||
try {
|
||||
const reply = await fetchGeneralCategoryTree()
|
||||
if (reply.code !== 0) {
|
||||
Message.error(reply.message || '获取公共分类失败')
|
||||
return
|
||||
}
|
||||
const labels = new Map<number, string>()
|
||||
const categories: FaqCategoryOption[] = []
|
||||
flattenCategories(reply.details || [], [], labels, categories)
|
||||
categoryLabels.value = labels
|
||||
categoryFilterOptions.value = categories
|
||||
} catch (error) {
|
||||
showRequestError(error, '获取公共分类失败')
|
||||
} finally {
|
||||
categoryLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 展开分类树,并保留每个节点的父级、层级、路径和状态。 */
|
||||
function flattenCategories(nodes: CategoryTreeNode[], parentNames: string[], labels: Map<number, string>, categories: FaqCategoryOption[]) {
|
||||
nodes.forEach((node) => {
|
||||
const names = [...parentNames, node.name]
|
||||
const label = names.join(' / ')
|
||||
labels.set(node.id, node.status === 'inactive' ? `${label}(停用)` : label)
|
||||
categories.push({
|
||||
id: node.id,
|
||||
name: node.name,
|
||||
label,
|
||||
parentId: node.parent_id,
|
||||
level: node.level,
|
||||
path: node.path,
|
||||
status: node.status,
|
||||
})
|
||||
flattenCategories(node.children || [], names, labels, categories)
|
||||
})
|
||||
}
|
||||
|
||||
function categoryName(categoryId: number) {
|
||||
return categoryLabels.value.get(categoryId) || '-'
|
||||
}
|
||||
|
||||
/** 切换可见范围时清除范围不兼容的状态筛选。 */
|
||||
function changeScope(scope: FaqScope, load = true) {
|
||||
activeScope.value = scope
|
||||
pagination.current = 1
|
||||
filters.status = undefined
|
||||
handleDetailVisible(false)
|
||||
if (load) fetchData()
|
||||
}
|
||||
|
||||
function handleScopeChange(scope: string | number) {
|
||||
changeScope(scope as FaqScope)
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
pagination.current = 1
|
||||
fetchData()
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
filters.keyword = ''
|
||||
filters.category_id = undefined
|
||||
filters.status = undefined
|
||||
filters.priority = undefined
|
||||
filters.problem_type = ''
|
||||
pagination.current = 1
|
||||
fetchData()
|
||||
}
|
||||
|
||||
function handlePageChange(page: number) {
|
||||
pagination.current = page
|
||||
fetchData()
|
||||
}
|
||||
|
||||
function handlePageSizeChange(pageSize: number) {
|
||||
pagination.current = 1
|
||||
pagination.pageSize = pageSize
|
||||
fetchData()
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
if (!canCreate.value) {
|
||||
Message.error('无操作权限')
|
||||
return
|
||||
}
|
||||
editingFaq.value = null
|
||||
formVisible.value = true
|
||||
}
|
||||
|
||||
function openEdit(faq: Faq) {
|
||||
if (!canEdit(faq)) {
|
||||
Message.error('无操作权限')
|
||||
return
|
||||
}
|
||||
editingFaq.value = { ...faq }
|
||||
formVisible.value = true
|
||||
}
|
||||
|
||||
function openEditFromDetail(faq: Faq) {
|
||||
handleDetailVisible(false)
|
||||
openEdit(faq)
|
||||
}
|
||||
|
||||
/** 保存表单;仅接口成功后关闭抽屉。 */
|
||||
async function saveFaq(values: FaqFormData, done: () => void) {
|
||||
if (formSubmitting.value) {
|
||||
done()
|
||||
return
|
||||
}
|
||||
formSubmitting.value = true
|
||||
const target = editingFaq.value
|
||||
try {
|
||||
if ((!target && !canCreate.value) || (target && !canEdit(target))) {
|
||||
Message.error('无操作权限')
|
||||
return
|
||||
}
|
||||
const reply = target ? await updateFaq({ id: target.id, ...values }) : await createFaq(values)
|
||||
if (!showReplyResult(reply, target ? '保存 FAQ 失败' : '创建 FAQ 失败')) return
|
||||
Message.success(target ? 'FAQ 已保存' : 'FAQ 已创建')
|
||||
formVisible.value = false
|
||||
editingFaq.value = null
|
||||
if (!target) changeScope('my', false)
|
||||
else pagination.current = 1
|
||||
await fetchData()
|
||||
} catch (error) {
|
||||
showRequestError(error, target ? '保存 FAQ 失败' : '创建 FAQ 失败')
|
||||
} finally {
|
||||
formSubmitting.value = false
|
||||
done()
|
||||
}
|
||||
}
|
||||
|
||||
/** 获取最新详情,关闭抽屉后自动废弃仍在途的响应。 */
|
||||
async function openDetail(faq: Faq) {
|
||||
const sequence = ++detailRequestSequence
|
||||
detailVisible.value = true
|
||||
detailLoading.value = true
|
||||
detailFaq.value = null
|
||||
try {
|
||||
const reply = await fetchFaqDetail(faq.id)
|
||||
if (sequence !== detailRequestSequence) return
|
||||
if (!showReplyResult(reply, '获取 FAQ 详情失败')) return
|
||||
detailFaq.value = reply.details
|
||||
updateVisibleFaq(reply.details)
|
||||
} catch (error) {
|
||||
if (sequence !== detailRequestSequence) return
|
||||
showRequestError(error, '获取 FAQ 详情失败')
|
||||
} finally {
|
||||
if (sequence === detailRequestSequence) detailLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleDetailVisible(visible: boolean) {
|
||||
detailVisible.value = visible
|
||||
if (!visible) {
|
||||
detailRequestSequence += 1
|
||||
detailLoading.value = false
|
||||
detailFaq.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function confirmPublish(faq: Faq) {
|
||||
if (!canSubmit(faq)) {
|
||||
Message.error('无操作权限')
|
||||
return
|
||||
}
|
||||
Modal.confirm({
|
||||
title: '提交审核',
|
||||
content: `确认提交 FAQ「${faq.question}」进行审核吗?提交后将暂时不能修改。`,
|
||||
okText: '提交审核',
|
||||
onOk: () => submitForReview(faq),
|
||||
})
|
||||
}
|
||||
|
||||
async function submitForReview(faq: Faq) {
|
||||
if (!beginAction(faq.id, 'publish')) return false
|
||||
try {
|
||||
const reply = await publishFaq(faq.id)
|
||||
if (!showReplyResult(reply, '提交审核失败')) return false
|
||||
Message.success(reply.details || '已提交审核')
|
||||
handleDetailVisible(false)
|
||||
await fetchData()
|
||||
return true
|
||||
} catch (error) {
|
||||
showRequestError(error, '提交审核失败')
|
||||
return false
|
||||
} finally {
|
||||
endAction(faq.id, 'publish')
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDelete(faq: Faq) {
|
||||
if (!canDelete(faq)) {
|
||||
Message.error('无操作权限')
|
||||
return
|
||||
}
|
||||
Modal.confirm({
|
||||
title: '删除 FAQ',
|
||||
content: `确认删除 FAQ「${faq.question}」吗?删除后将移入回收站。`,
|
||||
okText: '删除',
|
||||
okButtonProps: { status: 'danger' },
|
||||
onOk: () => removeFaq(faq),
|
||||
})
|
||||
}
|
||||
|
||||
async function removeFaq(faq: Faq) {
|
||||
if (!beginAction(faq.id, 'delete')) return false
|
||||
try {
|
||||
const reply = await deleteFaq(faq.id)
|
||||
if (!showReplyResult(reply, '删除 FAQ 失败')) return false
|
||||
Message.success('FAQ 已移入回收站')
|
||||
if (detailFaq.value?.id === faq.id) handleDetailVisible(false)
|
||||
if (tableData.value.length === 1 && pagination.current > 1) pagination.current -= 1
|
||||
await fetchData()
|
||||
return true
|
||||
} catch (error) {
|
||||
showRequestError(error, '删除 FAQ 失败')
|
||||
return false
|
||||
} finally {
|
||||
endAction(faq.id, 'delete')
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleFavorite(faq: Faq) {
|
||||
if (!beginAction(faq.id, 'favorite')) return
|
||||
const nextFavorited = !faq.is_favorited
|
||||
try {
|
||||
const reply = nextFavorited ? await favoriteFaq(faq.id) : await unfavoriteFaq(faq.id)
|
||||
if (!showReplyResult(reply, nextFavorited ? '收藏失败' : '取消收藏失败')) return
|
||||
updateFavoriteState(faq.id, nextFavorited)
|
||||
Message.success(nextFavorited ? '已收藏' : '已取消收藏')
|
||||
} catch (error) {
|
||||
showRequestError(error, nextFavorited ? '收藏失败' : '取消收藏失败')
|
||||
} finally {
|
||||
endAction(faq.id, 'favorite')
|
||||
}
|
||||
}
|
||||
|
||||
function updateFavoriteState(id: number, isFavorited: boolean) {
|
||||
const row = tableData.value.find((item) => item.id === id)
|
||||
if (row) row.is_favorited = isFavorited
|
||||
if (detailFaq.value?.id === id) detailFaq.value.is_favorited = isFavorited
|
||||
}
|
||||
|
||||
function updateVisibleFaq(faq: Faq) {
|
||||
const index = tableData.value.findIndex((item) => item.id === faq.id)
|
||||
if (index >= 0) tableData.value[index] = { ...tableData.value[index], ...faq }
|
||||
}
|
||||
|
||||
function beginAction(id: number, type: ActionType) {
|
||||
if (actionState.type) return false
|
||||
actionState.id = id
|
||||
actionState.type = type
|
||||
return true
|
||||
}
|
||||
|
||||
function endAction(id: number, type: ActionType) {
|
||||
if (actionState.id === id && actionState.type === type) {
|
||||
actionState.id = 0
|
||||
actionState.type = ''
|
||||
}
|
||||
}
|
||||
|
||||
function isActionLoading(id: number, type: ActionType) {
|
||||
return actionState.id === id && actionState.type === type
|
||||
}
|
||||
|
||||
/** 处理服务端返回的业务错误。 */
|
||||
function showReplyResult(reply: KbReply<unknown>, fallback: string) {
|
||||
if (reply.code === 0) return true
|
||||
Message.error(reply.message || fallback)
|
||||
return false
|
||||
}
|
||||
|
||||
/** 按 HTTP 状态展示鉴权与冲突错误。 */
|
||||
function showRequestError(error: unknown, fallback: string) {
|
||||
if (!axios.isAxiosError(error)) {
|
||||
Message.error(fallback)
|
||||
return
|
||||
}
|
||||
const status = error.response?.status
|
||||
const data = error.response?.data
|
||||
const message = isReplyLike(data) ? data.message : ''
|
||||
if (status === 403) {
|
||||
Message.error('无操作权限')
|
||||
return
|
||||
}
|
||||
if (status === 409) {
|
||||
Message.error(message || fallback)
|
||||
return
|
||||
}
|
||||
Message.error(message || fallback)
|
||||
}
|
||||
|
||||
function isReplyLike(value: unknown): value is Partial<KbReply<unknown>> {
|
||||
return Boolean(value && typeof value === 'object' && 'message' in value)
|
||||
}
|
||||
|
||||
function formatTime(value: string) {
|
||||
return value ? dayjs(value).format('YYYY-MM-DD HH:mm') : '-'
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadCategories()
|
||||
fetchData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
export default {
|
||||
name: 'KbFaqManage',
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.faq-page {
|
||||
padding: 0 20px 20px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
|
||||
:deep(.arco-tabs) {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.filter-form {
|
||||
margin: 8px 0 20px;
|
||||
padding: 16px 16px 0;
|
||||
background: var(--color-fill-1);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.question-button {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-align: left;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
@@ -1,392 +1,275 @@
|
||||
<template>
|
||||
<div class="container">
|
||||
<a-card class="general-card" title="收藏管理">
|
||||
<!-- 数据表格 -->
|
||||
<div class="favorite-page">
|
||||
<Breadcrumb :items="['知识管理', '我的收藏']" />
|
||||
<a-card class="general-card" :bordered="false">
|
||||
<template #title>我的收藏</template>
|
||||
<template #extra><a-button :loading="loading" :disabled="actionBusy" @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" :disabled="actionBusy">查询</a-button></a-form-item>
|
||||
</a-form>
|
||||
|
||||
<a-table
|
||||
:data="tableData"
|
||||
row-key="id"
|
||||
:data="favorites"
|
||||
:columns="columns"
|
||||
:loading="loading"
|
||||
:pagination="pagination"
|
||||
row-key="id"
|
||||
@page-change="handlePageChange"
|
||||
@page-change="changePage"
|
||||
@page-size-change="changePageSize"
|
||||
>
|
||||
<!-- 序号 -->
|
||||
<template #index="{ rowIndex }">
|
||||
{{ rowIndex + 1 + (pagination.current - 1) * pagination.pageSize }}
|
||||
</template>
|
||||
|
||||
<!-- 资源类型 -->
|
||||
<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-button type="text" size="small" :disabled="record.is_deleted" @click="handleView(record)">查看</a-button>
|
||||
<a-button type="text" size="small" :disabled="record.is_deleted" @click="handleDownload(record)">下载</a-button>
|
||||
<a-button type="text" size="small" status="danger" @click="handleUncollect(record)">取消收藏</a-button>
|
||||
<a-tag :color="record.resource_type === 'document' ? 'blue' : 'green'">
|
||||
{{ record.resource_type === 'document' ? '文档' : 'FAQ' }}
|
||||
</a-tag>
|
||||
<a-tag v-if="record.is_deleted" color="red">已删除</a-tag>
|
||||
</a-space>
|
||||
</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 || actionBusy" @click="openDetail(record)">查看</a-button>
|
||||
<a-button
|
||||
type="text"
|
||||
size="small"
|
||||
status="danger"
|
||||
:loading="actionBusy && actionID === record.id"
|
||||
:disabled="actionBusy"
|
||||
@click="confirmUncollect(record)"
|
||||
>
|
||||
取消收藏
|
||||
</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
<template #empty><a-empty description="暂无收藏" /></template>
|
||||
</a-table>
|
||||
</a-card>
|
||||
|
||||
<!-- 文档详情对话框 -->
|
||||
<a-modal v-model:visible="detailVisible" title="文档详情" :width="800" :footer="false" unmount-on-close>
|
||||
<div v-if="currentResource" class="detail-content">
|
||||
<a-descriptions :column="2" bordered>
|
||||
<a-descriptions-item label="资源名称">
|
||||
{{ currentResource.title || '-' }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="资源类型">
|
||||
<a-tag color="blue">文档</a-tag>
|
||||
</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-drawer v-model:visible="detailVisible" :title="detailType === 'document' ? '文档详情' : 'FAQ 详情'" :width="720" :footer="false">
|
||||
<a-spin :loading="detailLoading" class="detail-spin">
|
||||
<a-descriptions v-if="detailType === 'document' && detailDocument" :column="2" bordered>
|
||||
<a-descriptions-item label="标题" :span="2">{{ detailDocument.title }}</a-descriptions-item>
|
||||
<a-descriptions-item label="作者">{{ detailDocument.author_name || `用户 ${detailDocument.author_id}` }}</a-descriptions-item>
|
||||
<a-descriptions-item label="状态">{{ statusText(detailDocument.status) }}</a-descriptions-item>
|
||||
<a-descriptions-item label="描述" :span="2">{{ detailDocument.description || '-' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="正文" :span="2">
|
||||
<pre class="content-preview">{{ detailDocument.content }}</pre>
|
||||
</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
</div>
|
||||
</a-modal>
|
||||
|
||||
<!-- FAQ详情对话框 -->
|
||||
<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 v-else-if="detailType === 'faq' && detailFaq" :column="2" bordered>
|
||||
<a-descriptions-item label="问题" :span="2">{{ detailFaq.question }}</a-descriptions-item>
|
||||
<a-descriptions-item label="作者">{{ detailFaq.author_name || `用户 ${detailFaq.author_id}` }}</a-descriptions-item>
|
||||
<a-descriptions-item label="状态">{{ statusText(detailFaq.status) }}</a-descriptions-item>
|
||||
<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 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>
|
||||
</div>
|
||||
</a-modal>
|
||||
</a-spin>
|
||||
</a-drawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<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 type { TableColumnData } from '@arco-design/web-vue/es/table/interface'
|
||||
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 tableData = ref<Favorite[]>([])
|
||||
const pagination = reactive({ current: 1, pageSize: 20, total: 0, showTotal: true, showPageSize: true })
|
||||
let listSequence = 0
|
||||
const actionID = ref(0)
|
||||
const actionBusy = ref(false)
|
||||
|
||||
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 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
|
||||
|
||||
try {
|
||||
const params = {
|
||||
const reply = await fetchFavoriteList({
|
||||
page: pagination.current,
|
||||
page_size: pagination.pageSize,
|
||||
}
|
||||
|
||||
const res: any = await fetchFavoriteList(params)
|
||||
|
||||
if (res.code === 0) {
|
||||
tableData.value = res.details?.data || []
|
||||
pagination.total = res.details?.total || 0
|
||||
} else {
|
||||
Message.error(res.message || '获取收藏列表失败')
|
||||
tableData.value = []
|
||||
resource_type: filters.resource_type,
|
||||
})
|
||||
if (sequence !== listSequence) return
|
||||
if (reply.code !== 0) throw new Error(reply.message || '获取收藏列表失败')
|
||||
favorites.value = reply.details?.data || []
|
||||
pagination.total = reply.details?.total || 0
|
||||
} catch (error) {
|
||||
if (sequence === listSequence) {
|
||||
favorites.value = []
|
||||
pagination.total = 0
|
||||
showRequestError(error, '获取收藏列表失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取收藏列表失败:', error)
|
||||
Message.error('获取收藏列表失败')
|
||||
tableData.value = []
|
||||
pagination.total = 0
|
||||
} finally {
|
||||
loading.value = false
|
||||
if (sequence === listSequence) loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 分页变化
|
||||
const handlePageChange = (current: number) => {
|
||||
pagination.current = current
|
||||
fetchFavorites()
|
||||
function search() {
|
||||
pagination.current = 1
|
||||
loadFavorites()
|
||||
}
|
||||
|
||||
// 查看详情
|
||||
const handleView = async (record: Favorite) => {
|
||||
function changePage(page: number) {
|
||||
pagination.current = page
|
||||
loadFavorites()
|
||||
}
|
||||
|
||||
function changePageSize(pageSize: number) {
|
||||
pagination.current = 1
|
||||
pagination.pageSize = pageSize
|
||||
loadFavorites()
|
||||
}
|
||||
|
||||
async function openDetail(record: Favorite) {
|
||||
if (record.is_deleted) {
|
||||
Message.warning('该资源已被删除,无法查看')
|
||||
Message.warning('资源已删除,无法查看详情')
|
||||
return
|
||||
}
|
||||
|
||||
const sequence = ++detailSequence
|
||||
detailType.value = record.resource_type
|
||||
detailDocument.value = null
|
||||
detailFaq.value = null
|
||||
detailVisible.value = true
|
||||
detailLoading.value = true
|
||||
try {
|
||||
if (record.resource_type === 'document') {
|
||||
// 如果有resource_data直接使用,否则请求详情
|
||||
if (record.resource_data) {
|
||||
currentResource.value = record.resource_data
|
||||
detailVisible.value = true
|
||||
} else {
|
||||
const res = await request.get<any>(`/Kb/v1/document/${record.resource_id}`)
|
||||
if (res.code === 0) {
|
||||
currentResource.value = res.details
|
||||
detailVisible.value = true
|
||||
} else {
|
||||
Message.error(res.message || '获取文档详情失败')
|
||||
}
|
||||
const cached = asDocument(record)
|
||||
if (cached) detailDocument.value = cached
|
||||
else {
|
||||
const reply = await fetchDocumentDetail(record.resource_id)
|
||||
if (sequence !== detailSequence) return
|
||||
if (reply.code !== 0) throw new Error(reply.message || '获取文档详情失败')
|
||||
detailDocument.value = reply.details
|
||||
}
|
||||
} else if (record.resource_type === 'faq') {
|
||||
// FAQ详情
|
||||
if (record.resource_data) {
|
||||
currentFaq.value = record.resource_data
|
||||
faqDetailVisible.value = true
|
||||
} else {
|
||||
const res = await request.get<any>(`/Kb/v1/faq/${record.resource_id}`)
|
||||
if (res.code === 0) {
|
||||
currentFaq.value = res.details
|
||||
faqDetailVisible.value = true
|
||||
} else {
|
||||
Message.error(res.message || '获取FAQ详情失败')
|
||||
}
|
||||
} else {
|
||||
const cached = asFaq(record)
|
||||
if (cached) detailFaq.value = cached
|
||||
else {
|
||||
const reply = await fetchFaqDetail(record.resource_id)
|
||||
if (sequence !== detailSequence) return
|
||||
if (reply.code !== 0) throw new Error(reply.message || '获取 FAQ 详情失败')
|
||||
detailFaq.value = reply.details
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取详情失败:', error)
|
||||
Message.error('获取详情失败')
|
||||
if (sequence === detailSequence) showRequestError(error, '获取收藏详情失败')
|
||||
} finally {
|
||||
if (sequence === detailSequence) detailLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 下载文档
|
||||
const handleDownload = async (record: Favorite) => {
|
||||
if (record.is_deleted) {
|
||||
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 asDocument(record: Favorite): Document | null {
|
||||
if (record.resource_type !== 'document' || !record.resource_data) return null
|
||||
return record.resource_data as Document
|
||||
}
|
||||
|
||||
// 取消收藏
|
||||
const handleUncollect = (record: Favorite) => {
|
||||
function asFaq(record: Favorite): Faq | null {
|
||||
if (record.resource_type !== 'faq' || !record.resource_data) return null
|
||||
return record.resource_data as Faq
|
||||
}
|
||||
|
||||
function confirmUncollect(record: Favorite) {
|
||||
if (actionBusy.value) return
|
||||
actionBusy.value = true
|
||||
actionID.value = record.id
|
||||
Modal.confirm({
|
||||
title: '确认取消收藏',
|
||||
content: `确认取消收藏「${record.resource_name}」吗?`,
|
||||
onOk: async () => {
|
||||
try {
|
||||
const res = await uncollectResource({
|
||||
resource_type: record.resource_type,
|
||||
resource_id: record.resource_id,
|
||||
})
|
||||
|
||||
if (res.code === 0) {
|
||||
Message.success('取消收藏成功')
|
||||
fetchFavorites()
|
||||
} else {
|
||||
Message.error(res.message || '取消收藏失败')
|
||||
}
|
||||
const reply = await uncollectResource({ resource_type: record.resource_type, resource_id: record.resource_id })
|
||||
if (reply.code !== 0) throw new Error(reply.message || '取消收藏失败')
|
||||
Message.success('已取消收藏')
|
||||
await loadFavorites()
|
||||
} catch (error) {
|
||||
console.error('取消收藏失败:', error)
|
||||
Message.error('取消收藏失败')
|
||||
showRequestError(error, '取消收藏失败')
|
||||
} finally {
|
||||
finishAction()
|
||||
}
|
||||
},
|
||||
onCancel: finishAction,
|
||||
})
|
||||
}
|
||||
|
||||
// 格式化日期时间
|
||||
const formatDateTime = (dateStr?: string) => {
|
||||
if (!dateStr) return '-'
|
||||
// 处理多种日期格式
|
||||
let date: Date
|
||||
if (dateStr.includes('T')) {
|
||||
date = new Date(dateStr)
|
||||
} else {
|
||||
// 格式: YYYY-MM-DD HH:mm:ss
|
||||
date = new Date(dateStr.replace(' ', 'T'))
|
||||
function finishAction() {
|
||||
actionBusy.value = false
|
||||
actionID.value = 0
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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}`
|
||||
Message.error(error instanceof Error && error.message ? error.message : fallback)
|
||||
}
|
||||
|
||||
// 获取文档状态颜色
|
||||
const getDocStatusColor = (status?: string) => {
|
||||
const colorMap: Record<string, string> = {
|
||||
draft: 'gray',
|
||||
published: 'green',
|
||||
reviewed: 'blue',
|
||||
rejected: 'red',
|
||||
}
|
||||
return colorMap[status || ''] || 'gray'
|
||||
function statusText(status: DocumentStatus) {
|
||||
return { draft: '草稿', published: '待审核', reviewed: '已审核', rejected: '已拒绝' }[status]
|
||||
}
|
||||
|
||||
// 获取文档状态文本
|
||||
const getDocStatusText = (status?: string) => {
|
||||
const textMap: Record<string, string> = {
|
||||
draft: '草稿',
|
||||
published: '已发布',
|
||||
reviewed: '已审核',
|
||||
rejected: '已拒绝',
|
||||
}
|
||||
return textMap[status || ''] || '未知'
|
||||
function formatTime(value: string) {
|
||||
return value ? dayjs(value).format('YYYY-MM-DD HH:mm') : '-'
|
||||
}
|
||||
|
||||
// 初始化加载数据
|
||||
onMounted(() => {
|
||||
fetchFavorites()
|
||||
})
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
export default {
|
||||
name: 'FavoriteManage',
|
||||
}
|
||||
onMounted(loadFavorites)
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.container {
|
||||
padding: 20px;
|
||||
.favorite-page {
|
||||
padding: 0 20px 20px;
|
||||
}
|
||||
|
||||
.detail-content {
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
.filters {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.detail-spin {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.content-preview {
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
padding: 8px;
|
||||
background-color: var(--color-fill-1);
|
||||
border-radius: 4px;
|
||||
margin: 0;
|
||||
overflow: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
font-family: inherit;
|
||||
}
|
||||
</style>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,298 +1,289 @@
|
||||
<template>
|
||||
<div class="container">
|
||||
<SearchTable
|
||||
:form-model="searchForm"
|
||||
:form-items="filters"
|
||||
:data="tableData"
|
||||
:columns="columns"
|
||||
: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 }}
|
||||
<div class="trash-page">
|
||||
<Breadcrumb :items="['知识管理', '回收站']" />
|
||||
<a-card class="general-card" :bordered="false">
|
||||
<template #title>回收站</template>
|
||||
<template #extra>
|
||||
<a-button :loading="loading" :disabled="!canManage || actionBusy" @click="loadTrash">刷新</a-button>
|
||||
</template>
|
||||
|
||||
<!-- 资源类型列 -->
|
||||
<template #resource_type="{ record }">
|
||||
<a-tag :color="getResourceTypeColor(record.resource_type)">
|
||||
{{ getResourceTypeText(record.resource_type) }}
|
||||
</a-tag>
|
||||
<a-alert v-if="!canManage" type="warning">当前账号没有知识内容管理权限。</a-alert>
|
||||
<template v-else>
|
||||
<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 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" :disabled="actionBusy">查询</a-button></a-form-item>
|
||||
</a-form>
|
||||
|
||||
<a-table
|
||||
row-key="id"
|
||||
:data="records"
|
||||
: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" :disabled="actionBusy" @click="openDetail(record)">查看</a-button>
|
||||
<a-button
|
||||
type="text"
|
||||
size="small"
|
||||
:loading="actionBusy && actionID === record.id"
|
||||
:disabled="actionBusy"
|
||||
@click="confirmRestore(record)"
|
||||
>
|
||||
恢复
|
||||
</a-button>
|
||||
<a-button
|
||||
type="text"
|
||||
size="small"
|
||||
status="danger"
|
||||
:loading="actionBusy && actionID === record.id"
|
||||
:disabled="actionBusy"
|
||||
@click="confirmDelete(record)"
|
||||
>
|
||||
彻底删除
|
||||
</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
<template #empty><a-empty description="回收站为空" /></template>
|
||||
</a-table>
|
||||
</template>
|
||||
</a-card>
|
||||
|
||||
<!-- 删除时间列 -->
|
||||
<template #deleted_time="{ record }">
|
||||
{{ formatTime(record.deleted_time) }}
|
||||
</template>
|
||||
|
||||
<!-- 删除人列 -->
|
||||
<template #deleted_name="{ record }">
|
||||
{{ record.deleted_name || '-' }}
|
||||
</template>
|
||||
|
||||
<!-- 操作列 -->
|
||||
<template #operation="{ record }">
|
||||
<a-space>
|
||||
<a-button type="text" size="small" @click="handleRestore(record)">恢复</a-button>
|
||||
<a-button type="text" size="small" status="danger" @click="handleDelete(record)">彻底删除</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
</SearchTable>
|
||||
|
||||
<!-- 恢复确认对话框 -->
|
||||
<a-modal v-model:visible="restoreConfirmVisible" title="恢复确认" @ok="handleConfirmRestore" @cancel="restoreConfirmVisible = false">
|
||||
<p>确定要恢复「{{ recordToRestore?.resource_name }}」吗?</p>
|
||||
<p style="color: rgb(var(--primary-6))">恢复后资源将回到正常状态。</p>
|
||||
</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>
|
||||
<a-drawer v-model:visible="detailVisible" title="已删除内容" :width="720" :footer="false">
|
||||
<a-alert v-if="detailParseError" type="error" class="parse-alert">原始数据无法解析,恢复操作仍以服务端结果为准。</a-alert>
|
||||
<a-descriptions v-if="detailRecord" :column="2" bordered>
|
||||
<a-descriptions-item label="资源类型">
|
||||
<a-tag :color="getResourceTypeColor(detailRecord.resource_type)">{{ getResourceTypeText(detailRecord.resource_type) }}</a-tag>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="资源 ID">{{ detailRecord.resource_id }}</a-descriptions-item>
|
||||
<a-descriptions-item :label="detailRecord.resource_type === 'document' ? '标题' : '问题'" :span="2">
|
||||
{{ originalTitle || detailRecord.resource_name }}
|
||||
</a-descriptions-item>
|
||||
<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>
|
||||
<a-descriptions-item v-if="detailRecord.resource_type === 'document'" label="描述" :span="2">
|
||||
{{ originalDocument?.description || '-' }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item v-if="detailRecord.resource_type === 'document'" label="正文" :span="2">
|
||||
<pre class="content-preview">{{ originalDocument?.content || '-' }}</pre>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item v-if="detailRecord.resource_type === 'faq'" label="答案" :span="2">
|
||||
<pre class="content-preview">{{ originalFaq?.answer || '-' }}</pre>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item v-if="detailRecord.resource_type === 'faq'" label="解决方案" :span="2">
|
||||
<pre class="content-preview">{{ originalFaq?.solution || '-' }}</pre>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item v-if="detailRecord.resource_type === 'faq'" label="处理步骤" :span="2">
|
||||
<pre class="content-preview">{{ originalFaq?.process_steps || '-' }}</pre>
|
||||
</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
</a-drawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import axios from 'axios'
|
||||
import dayjs from 'dayjs'
|
||||
import SearchTable from '@/components/search-table/index.vue'
|
||||
import type { FormItem } from '@/components/search-form/types'
|
||||
import { Message, Modal } from '@arco-design/web-vue'
|
||||
import type { TableColumnData } from '@arco-design/web-vue/es/table/interface'
|
||||
import type { TrashRecord, FetchTrashListParams } from '@/api/kb/trash'
|
||||
import { fetchTrashList, restoreTrash, deleteTrash, getResourceTypeText, getResourceTypeColor, resourceTypeOptions } from '@/api/kb/trash'
|
||||
import {
|
||||
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 = computed((): TableColumnData[] => [
|
||||
{
|
||||
title: '序号',
|
||||
dataIndex: 'index',
|
||||
slotName: 'index',
|
||||
width: 80,
|
||||
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 columns: TableColumnData[] = [
|
||||
{ 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: 150 },
|
||||
{ title: '删除时间', dataIndex: 'deleted_time', slotName: 'deleted_time', width: 180, align: 'center' },
|
||||
{ title: '删除原因', dataIndex: 'delete_reason', ellipsis: true, tooltip: true },
|
||||
{ title: '操作', slotName: 'actions', width: 250, fixed: 'right', align: 'center' },
|
||||
]
|
||||
|
||||
// 搜索表单配置
|
||||
const filters = computed((): FormItem[] => [
|
||||
{
|
||||
label: '关键词',
|
||||
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 { hasPermission } = usePermissionCodes()
|
||||
const canManage = computed(() => hasPermission('kb:content:manage'))
|
||||
const filters = reactive<{ resource_type?: TrashResourceType }>({ resource_type: undefined })
|
||||
const records = ref<TrashRecord[]>([])
|
||||
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 actionBusy = ref(false)
|
||||
|
||||
// 分页
|
||||
const page = ref(1)
|
||||
const pageSize = ref(10)
|
||||
const total = ref(0)
|
||||
const detailVisible = ref(false)
|
||||
const detailRecord = ref<TrashRecord | null>(null)
|
||||
const originalDocument = ref<Document | null>(null)
|
||||
const originalFaq = ref<Faq | null>(null)
|
||||
const detailParseError = ref(false)
|
||||
const originalTitle = computed(() => originalDocument.value?.title || originalFaq.value?.question || '')
|
||||
|
||||
// 恢复确认
|
||||
const restoreConfirmVisible = ref(false)
|
||||
const recordToRestore = ref<TrashRecord | null>(null)
|
||||
|
||||
// 删除确认
|
||||
const deleteConfirmVisible = ref(false)
|
||||
const recordToDelete = ref<TrashRecord | null>(null)
|
||||
|
||||
// 获取数据
|
||||
const fetchData = async () => {
|
||||
/** 加载回收站列表。 */
|
||||
async function loadTrash() {
|
||||
if (!canManage.value) {
|
||||
records.value = []
|
||||
pagination.total = 0
|
||||
return
|
||||
}
|
||||
const sequence = ++listSequence
|
||||
loading.value = true
|
||||
try {
|
||||
loading.value = true
|
||||
const params: FetchTrashListParams = {
|
||||
page: page.value,
|
||||
page_size: pageSize.value,
|
||||
resource_type: (searchForm.resource_type || undefined) as 'document' | 'faq' | undefined,
|
||||
}
|
||||
|
||||
const res: any = await fetchTrashList(params)
|
||||
console.log('获取回收站列表成功:', res)
|
||||
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
|
||||
}
|
||||
const reply = await fetchTrashList({
|
||||
page: pagination.current,
|
||||
page_size: pagination.pageSize,
|
||||
resource_type: filters.resource_type,
|
||||
})
|
||||
if (sequence !== listSequence) return
|
||||
if (reply.code !== 0) throw new Error(reply.message || '获取回收站失败')
|
||||
records.value = reply.details?.data || []
|
||||
pagination.total = reply.details?.total || 0
|
||||
} catch (error) {
|
||||
console.error('获取回收站列表失败:', error)
|
||||
Message.error('获取回收站列表失败')
|
||||
if (sequence === listSequence) {
|
||||
records.value = []
|
||||
pagination.total = 0
|
||||
showRequestError(error, '获取回收站失败')
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
if (sequence === listSequence) loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 搜索
|
||||
const handleSearch = () => {
|
||||
page.value = 1
|
||||
fetchData()
|
||||
function search() {
|
||||
pagination.current = 1
|
||||
loadTrash()
|
||||
}
|
||||
|
||||
// 重置
|
||||
const handleReset = () => {
|
||||
searchForm.keyword = ''
|
||||
searchForm.resource_type = ''
|
||||
page.value = 1
|
||||
fetchData()
|
||||
function changePage(page: number) {
|
||||
pagination.current = page
|
||||
loadTrash()
|
||||
}
|
||||
|
||||
// 页码变化
|
||||
const handlePageChange = (current: number) => {
|
||||
page.value = current
|
||||
fetchData()
|
||||
function changePageSize(pageSize: number) {
|
||||
pagination.current = 1
|
||||
pagination.pageSize = pageSize
|
||||
loadTrash()
|
||||
}
|
||||
|
||||
// 格式化时间
|
||||
const formatTime = (time: string | null): string => {
|
||||
return time ? dayjs(time).format('YYYY-MM-DD HH:mm') : '-'
|
||||
}
|
||||
|
||||
// 恢复资源
|
||||
const handleRestore = (record: TrashRecord) => {
|
||||
recordToRestore.value = record
|
||||
restoreConfirmVisible.value = true
|
||||
}
|
||||
|
||||
// 确认恢复
|
||||
const handleConfirmRestore = async () => {
|
||||
if (!recordToRestore.value?.id) return
|
||||
|
||||
function openDetail(record: TrashRecord) {
|
||||
detailRecord.value = record
|
||||
originalDocument.value = null
|
||||
originalFaq.value = null
|
||||
detailParseError.value = false
|
||||
try {
|
||||
loading.value = true
|
||||
const res = await restoreTrash({ id: recordToRestore.value.id })
|
||||
if (res?.code === 0) {
|
||||
Message.success('恢复成功')
|
||||
restoreConfirmVisible.value = false
|
||||
recordToRestore.value = null
|
||||
await fetchData()
|
||||
} else {
|
||||
Message.error(res?.message || '恢复失败')
|
||||
}
|
||||
const parsed: unknown = JSON.parse(record.original_data)
|
||||
if (!parsed || typeof parsed !== 'object') throw new Error('invalid original_data')
|
||||
if (record.resource_type === 'document') originalDocument.value = parsed as Document
|
||||
else originalFaq.value = parsed as Faq
|
||||
} catch {
|
||||
detailParseError.value = true
|
||||
}
|
||||
detailVisible.value = true
|
||||
}
|
||||
|
||||
function confirmRestore(record: TrashRecord) {
|
||||
if (!canManage.value) return Message.error('无操作权限')
|
||||
if (actionBusy.value) return
|
||||
actionBusy.value = true
|
||||
actionID.value = record.id
|
||||
Modal.confirm({
|
||||
title: '确认恢复',
|
||||
content: `确认恢复${getResourceTypeText(record.resource_type)}「${record.resource_name}」吗?`,
|
||||
onOk: () => runAction(record, 'restore'),
|
||||
onCancel: finishAction,
|
||||
})
|
||||
}
|
||||
|
||||
function confirmDelete(record: TrashRecord) {
|
||||
if (!canManage.value) return Message.error('无操作权限')
|
||||
if (actionBusy.value) return
|
||||
actionBusy.value = true
|
||||
actionID.value = record.id
|
||||
Modal.confirm({
|
||||
title: '确认彻底删除',
|
||||
content: `彻底删除「${record.resource_name}」后不可恢复,是否继续?`,
|
||||
okText: '彻底删除',
|
||||
okButtonProps: { status: 'danger' },
|
||||
onOk: () => runAction(record, 'delete'),
|
||||
onCancel: finishAction,
|
||||
})
|
||||
}
|
||||
|
||||
async function runAction(record: TrashRecord, action: 'restore' | 'delete') {
|
||||
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) {
|
||||
console.error('恢复失败:', error)
|
||||
Message.error('恢复失败')
|
||||
showRequestError(error, action === 'restore' ? '恢复失败' : '彻底删除失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
finishAction()
|
||||
}
|
||||
}
|
||||
|
||||
// 彻底删除
|
||||
const handleDelete = (record: TrashRecord) => {
|
||||
recordToDelete.value = record
|
||||
deleteConfirmVisible.value = true
|
||||
function finishAction() {
|
||||
actionBusy.value = false
|
||||
actionID.value = 0
|
||||
}
|
||||
|
||||
// 确认彻底删除
|
||||
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
|
||||
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)
|
||||
}
|
||||
|
||||
// 初始化
|
||||
onMounted(() => {
|
||||
fetchData()
|
||||
})
|
||||
function formatTime(value: string | null) {
|
||||
return value ? dayjs(value).format('YYYY-MM-DD HH:mm') : '-'
|
||||
}
|
||||
|
||||
onMounted(loadTrash)
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.container {
|
||||
margin-top: 20px;
|
||||
.trash-page {
|
||||
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>
|
||||
|
||||
@@ -1,474 +1,471 @@
|
||||
<template>
|
||||
<div class="container">
|
||||
<SearchTable
|
||||
:form-model="searchForm"
|
||||
:form-items="filters"
|
||||
:data="tableData"
|
||||
:columns="columns"
|
||||
: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 }}
|
||||
<div class="review-page">
|
||||
<Breadcrumb :items="['知识管理', '内容审核']" />
|
||||
<a-card class="general-card" :bordered="false">
|
||||
<template #title>待审核内容</template>
|
||||
<template #extra>
|
||||
<a-button :loading="loading" :disabled="!canReview || reviewLocked" @click="loadReviews">刷新</a-button>
|
||||
</template>
|
||||
|
||||
<!-- 分类列 -->
|
||||
<template #category="{ record }">
|
||||
<a-tag :color="getResourceTypeColor(record.type)">
|
||||
{{ getResourceTypeText(record.type) }}
|
||||
</a-tag>
|
||||
<a-alert v-if="!canReview" type="warning">当前账号没有内容审核权限。</a-alert>
|
||||
<template v-else>
|
||||
<a-form :model="filters" layout="inline" class="filters" @submit-success="search">
|
||||
<a-form-item label="资源类型">
|
||||
<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" :disabled="reviewLocked">查询</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" :disabled="reviewLocked" @click="openDetail(record)">查看</a-button>
|
||||
<a-button
|
||||
type="text"
|
||||
size="small"
|
||||
status="success"
|
||||
:loading="isActionLoading(record)"
|
||||
:disabled="reviewLocked"
|
||||
@click="confirmApprove(record)"
|
||||
>
|
||||
通过
|
||||
</a-button>
|
||||
<a-button
|
||||
type="text"
|
||||
size="small"
|
||||
status="danger"
|
||||
:loading="isActionLoading(record)"
|
||||
:disabled="reviewLocked"
|
||||
@click="openReject(record)"
|
||||
>
|
||||
拒绝
|
||||
</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
<template #empty><a-empty description="暂无待审核内容" /></template>
|
||||
</a-table>
|
||||
</template>
|
||||
</a-card>
|
||||
|
||||
<!-- 作者列 -->
|
||||
<template #author="{ record }">
|
||||
{{ record.resource?.author_name || '-' }}
|
||||
</template>
|
||||
<a-drawer v-model:visible="detailVisible" title="审核详情" :width="720" :footer="false">
|
||||
<a-spin :loading="detailLoading" class="detail-spin">
|
||||
<a-descriptions v-if="detailRecord" :column="2" bordered>
|
||||
<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>
|
||||
|
||||
<!-- 申请时间列 -->
|
||||
<template #created_at="{ record }">
|
||||
{{ formatTime(record.resource?.created_at) }}
|
||||
</template>
|
||||
|
||||
<!-- 描述列 -->
|
||||
<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"
|
||||
:visible="rejectVisible"
|
||||
title="拒绝审核"
|
||||
:closable="!actionLoading"
|
||||
:mask-closable="!actionLoading"
|
||||
:esc-to-close="!actionLoading"
|
||||
:on-before-cancel="canCloseReject"
|
||||
@cancel="closeReject"
|
||||
@update:visible="handleRejectVisibleChange"
|
||||
>
|
||||
<a-alert v-if="rejectRecord && isSelfReview(rejectRecord)" type="warning" class="self-review-alert">
|
||||
这是你创建的内容。提交后还会再次确认,并记录自审日志。
|
||||
</a-alert>
|
||||
<a-form :model="rejectForm" layout="vertical">
|
||||
<a-form-item label="拒绝原因" required>
|
||||
<a-textarea
|
||||
v-model="rejectForm.reason"
|
||||
placeholder="请输入拒绝原因"
|
||||
:max-length="500"
|
||||
:auto-size="{ minRows: 3, maxRows: 6 }"
|
||||
:auto-size="{ minRows: 4, maxRows: 8 }"
|
||||
show-word-limit
|
||||
placeholder="请输入拒绝原因"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
<template #footer>
|
||||
<a-space>
|
||||
<a-button :disabled="actionLoading" @click="closeReject">取消</a-button>
|
||||
<a-button
|
||||
type="primary"
|
||||
status="danger"
|
||||
:loading="actionLoading"
|
||||
:disabled="actionLoading || confirmationOpen"
|
||||
@click="confirmReject"
|
||||
>
|
||||
确认拒绝
|
||||
</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { Message, Modal } from '@arco-design/web-vue'
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import axios from 'axios'
|
||||
import dayjs from 'dayjs'
|
||||
import SearchTable from '@/components/search-table/index.vue'
|
||||
import type { FormItem } from '@/components/search-form/types'
|
||||
import { Message, Modal } from '@arco-design/web-vue'
|
||||
import type { TableColumnData } from '@arco-design/web-vue/es/table/interface'
|
||||
import type { ReviewListItem, FetchReviewListParams } from '@/api/kb/review'
|
||||
import {
|
||||
fetchReviewList,
|
||||
approveReview,
|
||||
rejectReview,
|
||||
getResourceTypeText,
|
||||
fetchReviewList,
|
||||
getResourceTypeColor,
|
||||
getResourceTypeText,
|
||||
rejectReview,
|
||||
resourceTypeOptions,
|
||||
type ReviewListItem,
|
||||
type ReviewResourceType,
|
||||
} 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'
|
||||
|
||||
// 表格列配置
|
||||
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',
|
||||
},
|
||||
])
|
||||
type ReviewRow = ReviewListItem & { review_key: string }
|
||||
|
||||
// 搜索表单配置
|
||||
const filters = computed((): FormItem[] => [
|
||||
{
|
||||
label: '资源类型',
|
||||
field: 'resource_type',
|
||||
type: 'select',
|
||||
placeholder: '请选择资源类型',
|
||||
options: resourceTypeOptions,
|
||||
span: 6,
|
||||
},
|
||||
])
|
||||
const columns: TableColumnData[] = [
|
||||
{ title: '类型', dataIndex: 'type', slotName: 'type', width: 100, align: 'center' },
|
||||
{ title: '标题 / 问题', slotName: 'title', ellipsis: true, tooltip: true },
|
||||
{ title: '作者', slotName: 'author', width: 180 },
|
||||
{ title: '提交时间', slotName: 'created_at', width: 180, align: 'center' },
|
||||
{ title: '操作', slotName: 'actions', width: 230, fixed: 'right', align: 'center' },
|
||||
]
|
||||
|
||||
// 搜索表单数据
|
||||
const searchForm = reactive({
|
||||
resource_type: 'all',
|
||||
const { hasPermission } = usePermissionCodes()
|
||||
const canReview = computed(() => hasPermission('kb:content:review'))
|
||||
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 handleFormModelUpdate = (newFormModel: Record<string, any>) => {
|
||||
Object.assign(searchForm, newFormModel)
|
||||
const filters = reactive<{ resource_type: ReviewResourceType }>({ resource_type: 'all' })
|
||||
const tableData = ref<ReviewRow[]>([])
|
||||
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 reviewLocked = ref(false)
|
||||
const confirmationOpen = ref(false)
|
||||
const rejectVisible = ref(false)
|
||||
const rejectRecord = ref<ReviewListItem | null>(null)
|
||||
const rejectForm = reactive({ reason: '' })
|
||||
|
||||
function rowKey(record: ReviewListItem) {
|
||||
return `${record.type}:${record.resource.id}`
|
||||
}
|
||||
|
||||
// 表格数据
|
||||
const tableData = ref<ReviewListItem[]>([])
|
||||
const loading = ref(false)
|
||||
function isSelfReview(record: ReviewListItem) {
|
||||
return currentUserID.value > 0 && record.resource.author_id === currentUserID.value
|
||||
}
|
||||
|
||||
// 分页
|
||||
const page = ref(1)
|
||||
const pageSize = ref(10)
|
||||
const total = ref(0)
|
||||
function canOperate(record: ReviewListItem) {
|
||||
return canReview.value && record.resource.status === 'published' && (!isSelfReview(record) || canSelfReview.value)
|
||||
}
|
||||
|
||||
// 详情弹窗
|
||||
const detailVisible = ref(false)
|
||||
const currentRecord = ref<ReviewListItem | null>(null)
|
||||
|
||||
// 拒绝对话框
|
||||
const rejectVisible = ref(false)
|
||||
const rejectLoading = ref(false)
|
||||
const recordToReject = ref<ReviewListItem | null>(null)
|
||||
const rejectForm = reactive({
|
||||
reason: '',
|
||||
})
|
||||
|
||||
// 获取数据
|
||||
const fetchData = async () => {
|
||||
/** 加载审核列表,并在前端再次排除无自审权限的本人内容。 */
|
||||
async function loadReviews() {
|
||||
if (!canReview.value) {
|
||||
tableData.value = []
|
||||
pagination.total = 0
|
||||
return
|
||||
}
|
||||
const sequence = ++listSequence
|
||||
loading.value = true
|
||||
try {
|
||||
loading.value = true
|
||||
const params: FetchReviewListParams = {
|
||||
page: page.value,
|
||||
page_size: pageSize.value,
|
||||
resource_type: searchForm.resource_type as 'all' | 'document' | 'faq',
|
||||
const reply = await fetchReviewList({
|
||||
page: pagination.current,
|
||||
page_size: pagination.pageSize,
|
||||
resource_type: filters.resource_type,
|
||||
})
|
||||
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) {
|
||||
tableData.value = res.details?.data || []
|
||||
total.value = res.details?.total || 0
|
||||
function changePage(page: number) {
|
||||
pagination.current = page
|
||||
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 {
|
||||
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) {
|
||||
console.error('获取审核列表失败:', error)
|
||||
Message.error('获取审核列表失败')
|
||||
if (sequence === detailSequence) showRequestError(error, '获取审核详情失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
if (sequence === detailSequence) detailLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 搜索
|
||||
const handleSearch = () => {
|
||||
page.value = 1
|
||||
fetchData()
|
||||
}
|
||||
function confirmApprove(record: ReviewListItem) {
|
||||
if (reviewLocked.value) return
|
||||
if (!canOperate(record)) return Message.error('无操作权限')
|
||||
reviewLocked.value = true
|
||||
const openFinalConfirm = () =>
|
||||
Modal.confirm({
|
||||
title: '确认审核通过',
|
||||
content: `确认通过${getResourceTypeText(record.type)}「${resourceTitle(record)}」吗?`,
|
||||
okText: '确认通过',
|
||||
onOk: () => runApprove(record),
|
||||
onCancel: releaseReviewLock,
|
||||
})
|
||||
|
||||
// 重置
|
||||
const handleReset = () => {
|
||||
searchForm.resource_type = 'all'
|
||||
page.value = 1
|
||||
fetchData()
|
||||
}
|
||||
|
||||
// 页码变化
|
||||
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: '已拒绝',
|
||||
if (isSelfReview(record)) {
|
||||
Modal.confirm({
|
||||
title: '确认自审',
|
||||
content: '这是你创建的内容。继续操作将记录自审日志,是否进入最终确认?',
|
||||
okText: '继续',
|
||||
onOk: openFinalConfirm,
|
||||
onCancel: releaseReviewLock,
|
||||
})
|
||||
return
|
||||
}
|
||||
return statusMap[status || ''] || status || '-'
|
||||
openFinalConfirm()
|
||||
}
|
||||
|
||||
// 获取状态颜色
|
||||
const getStatusColor = (status: string | undefined): string => {
|
||||
const colorMap: Record<string, string> = {
|
||||
draft: 'gray',
|
||||
published: 'orange',
|
||||
reviewed: 'green',
|
||||
rejected: 'red',
|
||||
}
|
||||
return colorMap[status || ''] || 'gray'
|
||||
}
|
||||
|
||||
// 解析标签
|
||||
const parseTags = (tags: string | null | undefined): string[] => {
|
||||
if (!tags) return []
|
||||
async function runApprove(record: ReviewListItem) {
|
||||
actionLoading.value = true
|
||||
actionKey.value = rowKey(record)
|
||||
try {
|
||||
return JSON.parse(tags)
|
||||
} catch {
|
||||
return tags.split(',').filter(Boolean)
|
||||
const reply = await approveReview({ resource_type: record.type, id: record.resource.id })
|
||||
if (reply.code !== 0) throw new Error(reply.message || '审核失败')
|
||||
Message.success('审核已通过')
|
||||
await loadReviews()
|
||||
} catch (error) {
|
||||
showRequestError(error, '审核失败')
|
||||
} finally {
|
||||
actionLoading.value = false
|
||||
actionKey.value = ''
|
||||
releaseReviewLock()
|
||||
}
|
||||
}
|
||||
|
||||
// 获取资源编号
|
||||
const getResourceNo = (record: ReviewListItem | null): string => {
|
||||
if (!record?.resource) return '-'
|
||||
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
|
||||
function openReject(record: ReviewListItem) {
|
||||
if (reviewLocked.value) return
|
||||
if (!canOperate(record)) return Message.error('无操作权限')
|
||||
reviewLocked.value = true
|
||||
rejectRecord.value = record
|
||||
rejectForm.reason = ''
|
||||
rejectVisible.value = true
|
||||
}
|
||||
|
||||
// 确认拒绝
|
||||
const handleConfirmReject = async () => {
|
||||
if (!recordToReject.value) return
|
||||
|
||||
function confirmReject() {
|
||||
if (!rejectRecord.value || actionLoading.value || confirmationOpen.value) return
|
||||
if (!rejectForm.reason.trim()) {
|
||||
Message.warning('请输入拒绝原因')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
rejectLoading.value = true
|
||||
const res: any = await rejectReview({
|
||||
resource_type: recordToReject.value.type,
|
||||
id: recordToReject.value.resource.id,
|
||||
reason: rejectForm.reason,
|
||||
const record = rejectRecord.value
|
||||
if (isSelfReview(record)) {
|
||||
confirmationOpen.value = true
|
||||
Modal.confirm({
|
||||
title: '再次确认自审拒绝',
|
||||
content: '确认拒绝自己创建的内容吗?本次操作将记录自审日志。',
|
||||
okText: '确认拒绝',
|
||||
onOk: () => {
|
||||
confirmationOpen.value = false
|
||||
return runReject(record, rejectForm.reason.trim())
|
||||
},
|
||||
onCancel: () => {
|
||||
confirmationOpen.value = false
|
||||
},
|
||||
})
|
||||
if (res?.code === 0) {
|
||||
Message.success('已拒绝')
|
||||
rejectVisible.value = false
|
||||
recordToReject.value = null
|
||||
rejectForm.reason = ''
|
||||
await fetchData()
|
||||
} else {
|
||||
Message.error(res?.message || '拒绝失败')
|
||||
}
|
||||
return
|
||||
}
|
||||
runReject(record, rejectForm.reason.trim())
|
||||
}
|
||||
|
||||
async function runReject(record: ReviewListItem, reason: string) {
|
||||
let succeeded = false
|
||||
actionLoading.value = true
|
||||
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('审核已拒绝')
|
||||
rejectVisible.value = false
|
||||
rejectRecord.value = null
|
||||
rejectForm.reason = ''
|
||||
await loadReviews()
|
||||
succeeded = true
|
||||
} catch (error) {
|
||||
console.error('拒绝失败:', error)
|
||||
Message.error('拒绝失败')
|
||||
showRequestError(error, '拒绝审核失败')
|
||||
} finally {
|
||||
rejectLoading.value = false
|
||||
actionLoading.value = false
|
||||
actionKey.value = ''
|
||||
if (succeeded) releaseReviewLock()
|
||||
}
|
||||
}
|
||||
|
||||
// 取消拒绝
|
||||
const handleCancelReject = () => {
|
||||
function closeReject() {
|
||||
if (actionLoading.value) return
|
||||
rejectVisible.value = false
|
||||
recordToReject.value = null
|
||||
rejectRecord.value = null
|
||||
rejectForm.reason = ''
|
||||
confirmationOpen.value = false
|
||||
releaseReviewLock()
|
||||
}
|
||||
|
||||
// 初始化
|
||||
onMounted(() => {
|
||||
fetchData()
|
||||
})
|
||||
function canCloseReject() {
|
||||
return !actionLoading.value
|
||||
}
|
||||
|
||||
function handleRejectVisibleChange(visible: boolean) {
|
||||
if (!visible) closeReject()
|
||||
}
|
||||
|
||||
function releaseReviewLock() {
|
||||
reviewLocked.value = false
|
||||
}
|
||||
|
||||
function isActionLoading(record: ReviewListItem) {
|
||||
return actionLoading.value && actionKey.value === rowKey(record)
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
<style scoped lang="less">
|
||||
.container {
|
||||
margin-top: 20px;
|
||||
.review-page {
|
||||
padding: 0 20px 20px;
|
||||
}
|
||||
|
||||
.description-text {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
.filters {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.detail-spin {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.content-preview {
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
max-height: 320px;
|
||||
margin: 0;
|
||||
overflow: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
word-break: break-word;
|
||||
font-family: inherit;
|
||||
}
|
||||
.self-review-alert {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
</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>
|
||||
@@ -74,6 +74,12 @@
|
||||
<a-form-item field="message_regex" label="消息正则">
|
||||
<a-input v-model="formData.message_regex" placeholder="匹配 Syslog 正文" />
|
||||
</a-form-item>
|
||||
<a-form-item field="recovery_match_regex" label="恢复消息正则">
|
||||
<a-input v-model="formData.recovery_match_regex" placeholder="命中后发送 resolved;留空表示无恢复消息" />
|
||||
</a-form-item>
|
||||
<a-form-item field="lifecycle_key" label="生命周期标识">
|
||||
<a-input v-model="formData.lifecycle_key" placeholder="故障与恢复使用同一稳定标识" />
|
||||
</a-form-item>
|
||||
<a-form-item field="resource_uid_extract_regex" label="资源 UID 提取正则">
|
||||
<a-input v-model="formData.resource_uid_extract_regex" placeholder="命名分组 resource_uid 或首个捕获组" />
|
||||
</a-form-item>
|
||||
@@ -156,6 +162,8 @@ const emptyForm = (): SyslogRule => ({
|
||||
source_match: '',
|
||||
keyword_regex: '',
|
||||
message_regex: '',
|
||||
recovery_match_regex: '',
|
||||
lifecycle_key: '',
|
||||
alert_name: '',
|
||||
severity_code: '',
|
||||
severity_mapping_json: '',
|
||||
|
||||
@@ -68,6 +68,12 @@
|
||||
<a-form-item field="varbind_match_regex" label="Varbind 匹配正则">
|
||||
<a-input v-model="formData.varbind_match_regex" placeholder="可选" />
|
||||
</a-form-item>
|
||||
<a-form-item field="recovery_match_regex" label="恢复 Trap 正则">
|
||||
<a-input v-model="formData.recovery_match_regex" placeholder="匹配恢复 OID 或 Varbind" />
|
||||
</a-form-item>
|
||||
<a-form-item field="lifecycle_key" label="生命周期标识">
|
||||
<a-input v-model="formData.lifecycle_key" placeholder="故障与恢复使用同一稳定标识" />
|
||||
</a-form-item>
|
||||
<a-form-item field="alert_name" label="告警名称">
|
||||
<a-input v-model="formData.alert_name" />
|
||||
</a-form-item>
|
||||
@@ -142,6 +148,8 @@ const emptyForm = (): TrapRule => ({
|
||||
priority: 0,
|
||||
oid_prefix: '',
|
||||
varbind_match_regex: '',
|
||||
recovery_match_regex: '',
|
||||
lifecycle_key: '',
|
||||
alert_name: '',
|
||||
severity_code: '',
|
||||
policy_id: 0,
|
||||
|
||||
@@ -331,16 +331,16 @@ function formatDateTime(v?: string) {
|
||||
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`
|
||||
}
|
||||
|
||||
function formatStatusText(status: string) {
|
||||
function formatStatusText(status?: string) {
|
||||
const m: Record<string, string> = {
|
||||
online: '在线',
|
||||
offline: '离线',
|
||||
unknown: '未知',
|
||||
}
|
||||
return m[status] || status || '-'
|
||||
return m[status || ''] || status || '-'
|
||||
}
|
||||
|
||||
function getStatusColor(status: string) {
|
||||
function getStatusColor(status?: string) {
|
||||
const colorMap: Record<string, string> = {
|
||||
online: 'green',
|
||||
offline: 'red',
|
||||
@@ -348,7 +348,7 @@ function getStatusColor(status: string) {
|
||||
warning: 'orange',
|
||||
success: 'green',
|
||||
}
|
||||
return colorMap[status] || 'gray'
|
||||
return colorMap[status || ''] || 'gray'
|
||||
}
|
||||
|
||||
function isMetricsTarget(d: RoomDeviceItem) {
|
||||
|
||||
@@ -409,7 +409,15 @@ function mergeMonitorOptionAsRow(opt: StorageMonitorOptionItem) {
|
||||
collect_method: 'api',
|
||||
snmp_target: '',
|
||||
snmp_port: 161,
|
||||
snmp_version: 'v2c',
|
||||
snmp_community: '',
|
||||
snmp_v3_security_level: '',
|
||||
snmp_v3_security_name: '',
|
||||
snmp_v3_auth_protocol: '',
|
||||
snmp_v3_auth_password: '',
|
||||
snmp_v3_priv_protocol: '',
|
||||
snmp_v3_priv_password: '',
|
||||
snmp_v3_context_name: '',
|
||||
snmp_timeout_ms: 3000,
|
||||
snmp_retries: 1,
|
||||
snmp_oids: '[]',
|
||||
|
||||
@@ -476,7 +476,7 @@ const handleScan = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const formatRange = (a?: string, b?: string) => {
|
||||
const formatRange = (a?: string, b?: string | null) => {
|
||||
if (!a) return ''
|
||||
const sa = dayjs(a).format('YYYY-MM-DD HH:mm:ss')
|
||||
const sb = b ? dayjs(b).format('YYYY-MM-DD HH:mm:ss') : '进行中'
|
||||
|
||||
@@ -81,7 +81,14 @@ const pagination = reactive({
|
||||
total: 0,
|
||||
})
|
||||
|
||||
const formModel = reactive({
|
||||
interface AnomalySearchForm {
|
||||
keyword: string
|
||||
subnet_id: number | ''
|
||||
anomaly_type: string
|
||||
status: string
|
||||
}
|
||||
|
||||
const formModel = reactive<AnomalySearchForm>({
|
||||
keyword: '',
|
||||
subnet_id: '',
|
||||
anomaly_type: '',
|
||||
@@ -144,7 +151,7 @@ const loadSubnets = async () => {
|
||||
try {
|
||||
const response = await fetchIPSubnetList({ size: 1000 })
|
||||
if (response && response.code === 0) {
|
||||
subnets.value = response.details?.data || response.data || []
|
||||
subnets.value = response.details.data || []
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load subnets:', error)
|
||||
@@ -157,7 +164,10 @@ const loadData = async () => {
|
||||
const params: IPAnomalyListParams = {
|
||||
page: pagination.current,
|
||||
size: pagination.pageSize,
|
||||
...formModel,
|
||||
keyword: formModel.keyword,
|
||||
subnet_id: formModel.subnet_id || undefined,
|
||||
anomaly_type: formModel.anomaly_type,
|
||||
status: formModel.status,
|
||||
}
|
||||
|
||||
Object.keys(params).forEach((key) => {
|
||||
@@ -168,8 +178,8 @@ const loadData = async () => {
|
||||
|
||||
const response = await fetchIPAnomalyList(params)
|
||||
if (response && response.code === 0) {
|
||||
tableData.value = response.details?.data || response.data || []
|
||||
pagination.total = response.details?.total || response.total || 0
|
||||
tableData.value = response.details.data || []
|
||||
pagination.total = response.details.total || 0
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load anomalies:', error)
|
||||
@@ -178,7 +188,7 @@ const loadData = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const handleFormModelUpdate = (model: typeof formModel) => {
|
||||
const handleFormModelUpdate = (model: Record<string, unknown>) => {
|
||||
Object.assign(formModel, model)
|
||||
}
|
||||
|
||||
|
||||
@@ -79,7 +79,13 @@ const pagination = reactive({
|
||||
total: 0,
|
||||
})
|
||||
|
||||
const formModel = reactive({
|
||||
interface ChangeSearchForm {
|
||||
keyword: string
|
||||
subnet_id: number | ''
|
||||
change_type: string
|
||||
}
|
||||
|
||||
const formModel = reactive<ChangeSearchForm>({
|
||||
keyword: '',
|
||||
subnet_id: '',
|
||||
change_type: '',
|
||||
@@ -130,7 +136,7 @@ const loadSubnets = async () => {
|
||||
try {
|
||||
const response = await fetchIPSubnetList({ size: 1000 })
|
||||
if (response && response.code === 0) {
|
||||
subnets.value = response.details?.data || response.data || []
|
||||
subnets.value = response.details.data || []
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load subnets:', error)
|
||||
@@ -143,7 +149,9 @@ const loadData = async () => {
|
||||
const params: IPChangeListParams = {
|
||||
page: pagination.current,
|
||||
size: pagination.pageSize,
|
||||
...formModel,
|
||||
keyword: formModel.keyword,
|
||||
subnet_id: formModel.subnet_id || undefined,
|
||||
change_type: formModel.change_type,
|
||||
}
|
||||
|
||||
Object.keys(params).forEach((key) => {
|
||||
@@ -154,8 +162,8 @@ const loadData = async () => {
|
||||
|
||||
const response = await fetchIPChangeList(params)
|
||||
if (response && response.code === 0) {
|
||||
tableData.value = response.details?.data || response.data || []
|
||||
pagination.total = response.details?.total || response.total || 0
|
||||
tableData.value = response.details.data || []
|
||||
pagination.total = response.details.total || 0
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load changes:', error)
|
||||
@@ -164,7 +172,7 @@ const loadData = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const handleFormModelUpdate = (model: typeof formModel) => {
|
||||
const handleFormModelUpdate = (model: Record<string, unknown>) => {
|
||||
Object.assign(formModel, model)
|
||||
}
|
||||
|
||||
|
||||
@@ -80,7 +80,13 @@ const pagination = reactive({
|
||||
total: 0,
|
||||
})
|
||||
|
||||
const formModel = reactive({
|
||||
interface ConflictSearchForm {
|
||||
keyword: string
|
||||
subnet_id: number | ''
|
||||
status: string
|
||||
}
|
||||
|
||||
const formModel = reactive<ConflictSearchForm>({
|
||||
keyword: '',
|
||||
subnet_id: '',
|
||||
status: '',
|
||||
@@ -132,7 +138,7 @@ const loadSubnets = async () => {
|
||||
try {
|
||||
const response = await fetchIPSubnetList({ size: 1000 })
|
||||
if (response && response.code === 0) {
|
||||
subnets.value = response.details?.data || response.data || []
|
||||
subnets.value = response.details.data || []
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load subnets:', error)
|
||||
@@ -145,7 +151,9 @@ const loadData = async () => {
|
||||
const params: IPConflictListParams = {
|
||||
page: pagination.current,
|
||||
size: pagination.pageSize,
|
||||
...formModel,
|
||||
keyword: formModel.keyword,
|
||||
subnet_id: formModel.subnet_id || undefined,
|
||||
status: formModel.status,
|
||||
}
|
||||
|
||||
Object.keys(params).forEach((key) => {
|
||||
@@ -156,8 +164,8 @@ const loadData = async () => {
|
||||
|
||||
const response = await fetchIPConflictList(params)
|
||||
if (response && response.code === 0) {
|
||||
tableData.value = response.details?.data || response.data || []
|
||||
pagination.total = response.details?.total || response.total || 0
|
||||
tableData.value = response.details.data || []
|
||||
pagination.total = response.details.total || 0
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load conflicts:', error)
|
||||
@@ -166,7 +174,7 @@ const loadData = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const handleFormModelUpdate = (model: typeof formModel) => {
|
||||
const handleFormModelUpdate = (model: Record<string, unknown>) => {
|
||||
Object.assign(formModel, model)
|
||||
}
|
||||
|
||||
|
||||
@@ -61,7 +61,12 @@ const pagination = reactive({
|
||||
total: 0,
|
||||
})
|
||||
|
||||
const formModel = reactive({
|
||||
interface DHCPLeaseSearchForm {
|
||||
keyword: string
|
||||
subnet_id: number | ''
|
||||
}
|
||||
|
||||
const formModel = reactive<DHCPLeaseSearchForm>({
|
||||
keyword: '',
|
||||
subnet_id: '',
|
||||
})
|
||||
@@ -114,7 +119,7 @@ const loadSubnets = async () => {
|
||||
try {
|
||||
const response = await fetchIPSubnetList({ size: 1000 })
|
||||
if (response && response.code === 0) {
|
||||
subnets.value = response.details?.data || response.data || []
|
||||
subnets.value = response.details.data || []
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load subnets:', error)
|
||||
@@ -127,7 +132,8 @@ const loadData = async () => {
|
||||
const params: DHCPLeaseListParams = {
|
||||
page: pagination.current,
|
||||
size: pagination.pageSize,
|
||||
...formModel,
|
||||
keyword: formModel.keyword,
|
||||
subnet_id: formModel.subnet_id || undefined,
|
||||
}
|
||||
|
||||
Object.keys(params).forEach((key) => {
|
||||
@@ -138,8 +144,8 @@ const loadData = async () => {
|
||||
|
||||
const response = await fetchDHCPLeaseList(params)
|
||||
if (response && response.code === 0) {
|
||||
tableData.value = response.details?.data || response.data || []
|
||||
pagination.total = response.details?.total || response.total || 0
|
||||
tableData.value = response.details.data || []
|
||||
pagination.total = response.details.total || 0
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load DHCP leases:', error)
|
||||
@@ -148,7 +154,7 @@ const loadData = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const handleFormModelUpdate = (model: typeof formModel) => {
|
||||
const handleFormModelUpdate = (model: Record<string, unknown>) => {
|
||||
Object.assign(formModel, model)
|
||||
}
|
||||
|
||||
|
||||
@@ -100,7 +100,15 @@ const pagination = reactive({
|
||||
total: 0,
|
||||
})
|
||||
|
||||
const formModel = reactive({
|
||||
interface IPAddressSearchForm {
|
||||
keyword: string
|
||||
subnet_id: number | ''
|
||||
allocation_status: AllocationStatus | ''
|
||||
status: IPStatus | ''
|
||||
usage_status: UsageStatus | ''
|
||||
}
|
||||
|
||||
const formModel = reactive<IPAddressSearchForm>({
|
||||
keyword: '',
|
||||
subnet_id: '',
|
||||
allocation_status: '',
|
||||
@@ -169,7 +177,7 @@ const loadSubnets = async () => {
|
||||
try {
|
||||
const response = await fetchIPSubnetList({ size: 1000 })
|
||||
if (response && response.code === 0) {
|
||||
subnets.value = response.details?.data || response.data || []
|
||||
subnets.value = response.details.data || []
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load subnets:', error)
|
||||
@@ -182,7 +190,11 @@ const loadData = async () => {
|
||||
const params: IPAddressListParams = {
|
||||
page: pagination.current,
|
||||
size: pagination.pageSize,
|
||||
...formModel,
|
||||
keyword: formModel.keyword,
|
||||
subnet_id: formModel.subnet_id || undefined,
|
||||
allocation_status: formModel.allocation_status || undefined,
|
||||
status: formModel.status || undefined,
|
||||
usage_status: formModel.usage_status || undefined,
|
||||
}
|
||||
|
||||
Object.keys(params).forEach((key) => {
|
||||
@@ -193,8 +205,8 @@ const loadData = async () => {
|
||||
|
||||
const response = await fetchIPAddressList(params)
|
||||
if (response && response.code === 0) {
|
||||
tableData.value = response.details?.data || response.data || []
|
||||
pagination.total = response.details?.total || response.total || 0
|
||||
tableData.value = response.details.data || []
|
||||
pagination.total = response.details.total || 0
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load IP addresses:', error)
|
||||
@@ -203,7 +215,7 @@ const loadData = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const handleFormModelUpdate = (model: typeof formModel) => {
|
||||
const handleFormModelUpdate = (model: Record<string, unknown>) => {
|
||||
Object.assign(formModel, model)
|
||||
}
|
||||
|
||||
|
||||
@@ -148,7 +148,7 @@ const loadData = async () => {
|
||||
try {
|
||||
const response = await fetchIPAMOverview()
|
||||
if (response && response.code === 0) {
|
||||
overview.value = response.details || response.data || response
|
||||
overview.value = response.details
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load overview:', error)
|
||||
|
||||
@@ -84,7 +84,12 @@ const pagination = reactive({
|
||||
total: 0,
|
||||
})
|
||||
|
||||
const formModel = reactive({
|
||||
interface SubnetSearchForm {
|
||||
keyword: string
|
||||
group_id: number | ''
|
||||
}
|
||||
|
||||
const formModel = reactive<SubnetSearchForm>({
|
||||
keyword: '',
|
||||
group_id: '',
|
||||
})
|
||||
@@ -113,7 +118,7 @@ const loadGroups = async () => {
|
||||
try {
|
||||
const response = await fetchIPGroupList()
|
||||
if (response && response.code === 0) {
|
||||
groups.value = response.details?.data || response.data || []
|
||||
groups.value = response.details.list || []
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load groups:', error)
|
||||
@@ -126,7 +131,8 @@ const loadData = async () => {
|
||||
const params: IPSubnetListParams = {
|
||||
page: pagination.current,
|
||||
size: pagination.pageSize,
|
||||
...formModel,
|
||||
keyword: formModel.keyword,
|
||||
group_id: formModel.group_id || undefined,
|
||||
}
|
||||
|
||||
Object.keys(params).forEach((key) => {
|
||||
@@ -137,8 +143,8 @@ const loadData = async () => {
|
||||
|
||||
const response = await fetchIPSubnetList(params)
|
||||
if (response && response.code === 0) {
|
||||
tableData.value = response.details?.data || response.data || []
|
||||
pagination.total = response.details?.total || response.total || 0
|
||||
tableData.value = response.details.data || []
|
||||
pagination.total = response.details.total || 0
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load subnets:', error)
|
||||
@@ -147,7 +153,7 @@ const loadData = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const handleFormModelUpdate = (model: typeof formModel) => {
|
||||
const handleFormModelUpdate = (model: Record<string, unknown>) => {
|
||||
Object.assign(formModel, model)
|
||||
}
|
||||
|
||||
|
||||
@@ -187,7 +187,7 @@ const formData = reactive<Partial<TopologyGroup> & { parent_id: number }>({
|
||||
name: '',
|
||||
description: '',
|
||||
sort: 0,
|
||||
enable: 2,
|
||||
enable: true,
|
||||
parent_id: 0,
|
||||
})
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { computed, ComputedRef, unref, Ref } from 'vue'
|
||||
import { Edge } from '@vue-flow/core'
|
||||
|
||||
type EdgeType = 'default' | 'straight' | 'step' | 'simplebezier'
|
||||
import type { EdgeType } from '../types'
|
||||
|
||||
/**
|
||||
* 边样式计算Hook
|
||||
|
||||
@@ -140,7 +140,7 @@ import '@vue-flow/core/dist/theme-default.css'
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
import * as TopoAPI from '@/api/ops/netarchTopo'
|
||||
|
||||
import { NodeData, DeviceType } from './types'
|
||||
import { NodeData, DeviceType, EdgeType } from './types'
|
||||
import { DEVICE_TYPE_CONFIG } from './config'
|
||||
import { CustomNode } from './components'
|
||||
import { useTopoLayout, useEdgeStyles } from './hooks'
|
||||
@@ -184,7 +184,7 @@ const edges = ref<any[]>([])
|
||||
// UI控制状态
|
||||
const selectedGroup = ref<string | null>(null)
|
||||
const expandedGroups = ref<Set<string>>(new Set())
|
||||
const edgeType = ref<'default' | 'straight' | 'step' | 'simplebezier'>('default')
|
||||
const edgeType = ref<EdgeType>('default')
|
||||
|
||||
// 节点操作状态
|
||||
const selectedNode = ref<any>(null)
|
||||
@@ -422,7 +422,7 @@ const handleLayout = (value: string | number | Record<string, any> | undefined)
|
||||
|
||||
// 设置边类型
|
||||
const setEdgeType = (value: string | number | Record<string, any> | undefined) => {
|
||||
const type = value as 'default' | 'straight' | 'step' | 'smoothstep' | 'simplebezier'
|
||||
const type = value as EdgeType
|
||||
edgeType.value = type
|
||||
}
|
||||
|
||||
|
||||
@@ -42,4 +42,4 @@ export interface LinkData {
|
||||
}
|
||||
|
||||
// 链路类型(用于边样式)
|
||||
export type EdgeType = 'default' | 'straight' | 'step' | 'simplebezier'
|
||||
export type EdgeType = 'default' | 'straight' | 'step' | 'smoothstep' | 'simplebezier'
|
||||
|
||||
@@ -555,7 +555,7 @@ const loadTrafficData = async () => {
|
||||
if (!dashboard?.protocols?.length && latest?.protocol_stats) {
|
||||
const protocol = parseJsonObject(latest.protocol_stats)
|
||||
if (protocol && typeof protocol === 'object') {
|
||||
const total = Object.values(protocol).reduce((sum, v) => sum + Number(v || 0), 0)
|
||||
const total = Object.values(protocol).reduce<number>((sum, v) => sum + Number(v || 0), 0)
|
||||
if (total > 0) {
|
||||
protocolData.value = Object.entries(protocol).map(([name, value], index) => ({
|
||||
name,
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
<div class="stat-title">
|
||||
<span class="stat-name">{{ card.title }}</span>
|
||||
<a-tag v-if="card.title === '警告' || card.title === '工单' || card.title === '审核'" size="small" class="stat-tag">
|
||||
待处理/总数
|
||||
{{ card.title === '警告' ? '告警中/总数' : '待处理/总数' }}
|
||||
</a-tag>
|
||||
<a-tag v-else size="small" class="stat-tag">启用/总数</a-tag>
|
||||
</div>
|
||||
@@ -218,6 +218,9 @@ const getPageTotal = (payload: any) => {
|
||||
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(() => [
|
||||
{
|
||||
@@ -503,11 +506,11 @@ const handleChange = (value: string) => {
|
||||
// 处理统计卡片点击
|
||||
const handleCardClick = (cardTitle: string) => {
|
||||
const routeMap: any = {
|
||||
服务器及PC: '/ops/monitor/server',
|
||||
数据库服务: '/ops/dc/database',
|
||||
网络设备: '/ops/monitor/network',
|
||||
服务器及PC: '/dc/server',
|
||||
数据库服务: '/dc/database',
|
||||
网络设备: '/monitor/network',
|
||||
警告: '/alert/tackle',
|
||||
工单: '/ops/ticket',
|
||||
工单: '/feedback/all',
|
||||
审核: '/kb/review',
|
||||
}
|
||||
const route = routeMap[cardTitle]
|
||||
@@ -551,26 +554,19 @@ const loadStatistics = async () => {
|
||||
loading.value = true
|
||||
|
||||
// 并行请求所有统计接口和待处理告警
|
||||
const [
|
||||
alertData,
|
||||
collectorData,
|
||||
ticketData,
|
||||
alertListData,
|
||||
reviewStatsData,
|
||||
networkTotalData,
|
||||
networkEnabledData,
|
||||
] = await Promise.allSettled([
|
||||
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 })),
|
||||
])
|
||||
const [alertData, collectorData, ticketData, alertListData, reviewStatsData, networkTotalData, networkEnabledData] =
|
||||
await Promise.allSettled([
|
||||
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: 'firing' }).catch((e) => ({ error: e, success: false })),
|
||||
fetchReviewStats({ resource_type: 'all' }).catch((e: unknown) => ({ error: e, success: false as const })),
|
||||
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统计数据
|
||||
if (collectorData.status === 'fulfilled' && collectorData.value?.success !== false) {
|
||||
if (collectorData.status === 'fulfilled' && !isFailedRequest(collectorData.value)) {
|
||||
const resourceStats = unwrapDetails(collectorData.value)
|
||||
statistics.serverPc = normalizeCountPair(resourceStats.servers)
|
||||
statistics.database = normalizeCountPair(resourceStats.database_services)
|
||||
@@ -580,9 +576,9 @@ const loadStatistics = async () => {
|
||||
|
||||
if (
|
||||
networkTotalData.status === 'fulfilled' &&
|
||||
networkTotalData.value?.success !== false &&
|
||||
!isFailedRequest(networkTotalData.value) &&
|
||||
networkEnabledData.status === 'fulfilled' &&
|
||||
networkEnabledData.value?.success !== false
|
||||
!isFailedRequest(networkEnabledData.value)
|
||||
) {
|
||||
statistics.network = {
|
||||
pending: getPageTotal(networkEnabledData.value),
|
||||
@@ -593,9 +589,9 @@ const loadStatistics = async () => {
|
||||
}
|
||||
|
||||
// 处理告警统计数据
|
||||
if (alertData.status === 'fulfilled' && alertData.value?.success !== false) {
|
||||
if (alertData.status === 'fulfilled' && !isFailedRequest(alertData.value)) {
|
||||
statistics.alert = {
|
||||
pending: alertData.value?.details?.status_counts?.pending || 0,
|
||||
pending: alertData.value?.details?.status_counts?.firing || 0,
|
||||
total: alertData.value?.details?.total || 0,
|
||||
}
|
||||
} else {
|
||||
@@ -603,7 +599,7 @@ const loadStatistics = async () => {
|
||||
}
|
||||
|
||||
// 处理工单统计数据
|
||||
if (ticketData.status === 'fulfilled' && ticketData.value?.success !== false) {
|
||||
if (ticketData.status === 'fulfilled' && !isFailedRequest(ticketData.value)) {
|
||||
statistics.ticket = {
|
||||
pending: ticketData.value?.details?.pending || ticketData.value?.pending || 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) {
|
||||
const reviewPayload = reviewStatsData.value?.data ?? reviewStatsData.value?.details
|
||||
if (reviewPayload != null && 'need_my_review_total' in reviewPayload) {
|
||||
statistics.review = {
|
||||
pending: Number(reviewPayload.need_my_review_unreviewed_total) || 0,
|
||||
total: Number(reviewPayload.need_my_review_total) || 0,
|
||||
}
|
||||
if (reviewStatsData.status === 'fulfilled' && !isFailedRequest(reviewStatsData.value)) {
|
||||
const reviewPayload = reviewStatsData.value.details
|
||||
statistics.review = {
|
||||
pending: Number(reviewPayload.need_my_review_unreviewed_total) || 0,
|
||||
total: Number(reviewPayload.need_my_review_total) || 0,
|
||||
}
|
||||
} 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 || []
|
||||
} else {
|
||||
console.warn('待处理告警列表加载失败:', alertListData.status === 'rejected' ? alertListData.reason : alertListData.value?.error)
|
||||
@@ -667,6 +667,7 @@ const getStatusColor = (status: any) => {
|
||||
const statusStr = typeof status === 'object' ? status?.code || status?.name : status
|
||||
const colorMap: any = {
|
||||
pending: '#FF4D4F',
|
||||
firing: '#FF4D4F',
|
||||
processing: '#1890FF',
|
||||
resolved: '#52C41A',
|
||||
closed: '#8C8C8C',
|
||||
@@ -679,6 +680,7 @@ const getStatusTagColor = (status: any) => {
|
||||
const statusStr = typeof status === 'object' ? status?.code || status?.name : status
|
||||
const colorMap: any = {
|
||||
pending: 'red',
|
||||
firing: 'red',
|
||||
processing: 'blue',
|
||||
resolved: 'green',
|
||||
closed: 'gray',
|
||||
@@ -693,6 +695,7 @@ const getStatusText = (status: any) => {
|
||||
}
|
||||
const statusMap: any = {
|
||||
pending: '待处理',
|
||||
firing: '告警中',
|
||||
processing: '处理中',
|
||||
resolved: '已解决',
|
||||
closed: '已关闭',
|
||||
|
||||
@@ -35,7 +35,7 @@ import { computed, ref } from 'vue'
|
||||
|
||||
export interface PermissionItem {
|
||||
id: number
|
||||
name: string
|
||||
title: string
|
||||
children?: PermissionItem[]
|
||||
}
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
<div class="kv-list">
|
||||
<div v-for="row in basicRows" :key="row.key" class="basic-row">
|
||||
<span class="label">{{ row.label }}</span>
|
||||
<span class="value" :class="{ 'value-code': row.key === 'machine_code' }">{{ row.display }}</span>
|
||||
<span class="value" :class="{ 'value-code': row.key === 'id' }">{{ row.display }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</a-card>
|
||||
@@ -35,7 +35,7 @@
|
||||
<template #title>
|
||||
<span class="section-title">资源限制</span>
|
||||
</template>
|
||||
<p class="quota-hint">配额项为 0 时表示该维度不按许可证限制数量(由业务逻辑决定)。</p>
|
||||
<p class="quota-hint">配额为 0 表示该资源未授权,不能新增。</p>
|
||||
<div class="kv-list">
|
||||
<div v-for="row in quotaRows" :key="row.key" class="quota-row">
|
||||
<span class="label">{{ row.label }}</span>
|
||||
@@ -53,135 +53,42 @@
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
import { IconRefresh } from '@arco-design/web-vue/es/icon'
|
||||
import { fetchLicenseInfo, type LicenceConfig } from '@/api/ops/dcControl'
|
||||
import { fetchLicenseInfo, type LicenceInfo } from '@/api/ops/dcControl'
|
||||
|
||||
const loading = ref(false)
|
||||
const loadError = ref(false)
|
||||
const license = ref<LicenceConfig | null>(null)
|
||||
const license = ref<LicenceInfo | null>(null)
|
||||
|
||||
const dash = (v: string | undefined | null) => (v != null && String(v).length > 0 ? String(v) : '—')
|
||||
|
||||
const formatQuota = (n: number | undefined) => {
|
||||
if (n == null || Number.isNaN(Number(n))) return '—'
|
||||
const v = Number(n)
|
||||
return v > 0 ? String(v) : '无限制'
|
||||
}
|
||||
|
||||
function pickStr(o: Record<string, unknown>, snake: string, camel: string): string | undefined {
|
||||
const v = o[snake] ?? o[camel]
|
||||
return typeof v === 'string' ? v : v != null ? String(v) : undefined
|
||||
}
|
||||
|
||||
function pickNum(o: Record<string, unknown>, snake: string, camel: string): number | undefined {
|
||||
const v = o[snake] ?? o[camel]
|
||||
if (v == null || v === '') return undefined
|
||||
const n = Number(v)
|
||||
return Number.isNaN(n) ? undefined : n
|
||||
}
|
||||
|
||||
/** 将接口对象统一为 LicenceConfig(兼容 snake_case / camelCase) */
|
||||
function normalizeLicensePayload(raw: Record<string, unknown>): LicenceConfig {
|
||||
return {
|
||||
title: pickStr(raw, 'title', 'title'),
|
||||
version: pickStr(raw, 'version', 'version'),
|
||||
company_name: pickStr(raw, 'company_name', 'companyName'),
|
||||
create_time: pickStr(raw, 'create_time', 'createTime'),
|
||||
expire_time: pickStr(raw, 'expire_time', 'expireTime'),
|
||||
machine_code: pickStr(raw, 'machine_code', 'machineCode'),
|
||||
max_database: pickNum(raw, 'max_database', 'maxDatabase'),
|
||||
max_middleware: pickNum(raw, 'max_middleware', 'maxMiddleware'),
|
||||
max_pc: pickNum(raw, 'max_pc', 'maxPc'),
|
||||
max_server: pickNum(raw, 'max_server', 'maxServer'),
|
||||
max_client: pickNum(raw, 'max_client', 'maxClient'),
|
||||
max_user: pickNum(raw, 'max_user', 'maxUser'),
|
||||
max_role: pickNum(raw, 'max_role', 'maxRole'),
|
||||
max_permission: pickNum(raw, 'max_permission', 'maxPermission'),
|
||||
max_menu: pickNum(raw, 'max_menu', 'maxMenu'),
|
||||
}
|
||||
}
|
||||
|
||||
function isLicensePayloadRaw(v: unknown): v is Record<string, unknown> {
|
||||
if (v == null || typeof v !== 'object' || Array.isArray(v)) return false
|
||||
const o = v as Record<string, unknown>
|
||||
const strHit = (s: string, c: string) => {
|
||||
const x = o[s] ?? o[c]
|
||||
return typeof x === 'string' && x.length > 0
|
||||
}
|
||||
const numHit = (s: string, c: string) => {
|
||||
const x = o[s] ?? o[c]
|
||||
return typeof x === 'number' && !Number.isNaN(x)
|
||||
}
|
||||
return (
|
||||
strHit('company_name', 'companyName') ||
|
||||
strHit('machine_code', 'machineCode') ||
|
||||
strHit('title', 'title') ||
|
||||
strHit('version', 'version') ||
|
||||
numHit('max_database', 'maxDatabase') ||
|
||||
numHit('max_middleware', 'maxMiddleware') ||
|
||||
numHit('max_pc', 'maxPc') ||
|
||||
numHit('max_server', 'maxServer') ||
|
||||
numHit('max_client', 'maxClient') ||
|
||||
numHit('max_user', 'maxUser') ||
|
||||
numHit('max_role', 'maxRole') ||
|
||||
numHit('max_permission', 'maxPermission') ||
|
||||
numHit('max_menu', 'maxMenu')
|
||||
)
|
||||
}
|
||||
|
||||
function parseLicenseResponse(res: unknown): {
|
||||
code?: number
|
||||
success?: boolean
|
||||
data: unknown
|
||||
message?: string
|
||||
} {
|
||||
if (res == null || typeof res !== 'object') {
|
||||
return { data: undefined, message: undefined }
|
||||
}
|
||||
const r = res as Record<string, unknown>
|
||||
const code = typeof r.code === 'number' ? r.code : undefined
|
||||
const success = typeof r.success === 'boolean' ? r.success : undefined
|
||||
const message = typeof r.message === 'string' ? r.message : typeof r.msg === 'string' ? r.msg : undefined
|
||||
|
||||
let data: unknown = r.data ?? r.details ?? r.result
|
||||
if (data == null && isLicensePayloadRaw(r)) {
|
||||
data = r
|
||||
}
|
||||
return { code, success, data, message }
|
||||
}
|
||||
|
||||
function responseIndicatesSuccess(code: number | undefined, success: boolean | undefined): boolean {
|
||||
if (success === false) return false
|
||||
if (success === true) return true
|
||||
if (code === undefined) return true
|
||||
return code === 200 || code === 0
|
||||
}
|
||||
const formatQuota = (value: number) => (value === 0 ? '未授权' : String(value))
|
||||
|
||||
const basicRows = computed(() => {
|
||||
const L = license.value
|
||||
if (!L) return []
|
||||
const current = license.value
|
||||
if (!current) return []
|
||||
return [
|
||||
{ key: 'company_name', label: '公司名称', display: dash(L.company_name) },
|
||||
{ key: 'title', label: '版本标题', display: dash(L.title) },
|
||||
{ key: 'version', label: '版本号', display: dash(L.version) },
|
||||
{ key: 'machine_code', label: '机器码', display: dash(L.machine_code) },
|
||||
{ key: 'create_time', label: '创建时间', display: dash(L.create_time) },
|
||||
{ key: 'expire_time', label: '过期时间', display: dash(L.expire_time) },
|
||||
{ key: 'id', label: '许可证 ID', display: current.id },
|
||||
{ key: 'platform_name', label: '平台名称', display: current.platform_name },
|
||||
{ key: 'workspace', label: 'Workspace', display: current.workspace },
|
||||
{ key: 'issued_on', label: '签发日期', display: current.issued_on },
|
||||
{ key: 'valid_from', label: '生效日期', display: current.valid_from },
|
||||
{ key: 'expires_on', label: '到期日期', display: current.expires_on },
|
||||
]
|
||||
})
|
||||
|
||||
const quotaRows = computed(() => {
|
||||
const L = license.value
|
||||
if (!L) return []
|
||||
const quotas = license.value?.quotas
|
||||
if (!quotas) return []
|
||||
return [
|
||||
{ key: 'max_database', label: '数据库', display: formatQuota(L.max_database) },
|
||||
{ key: 'max_middleware', label: '中间件', display: formatQuota(L.max_middleware) },
|
||||
{ key: 'max_pc', label: 'PC', display: formatQuota(L.max_pc) },
|
||||
{ key: 'max_server', label: '服务器', display: formatQuota(L.max_server) },
|
||||
{ key: 'max_client', label: '客户端', display: formatQuota(L.max_client) },
|
||||
{ key: 'max_user', label: '用户', display: formatQuota(L.max_user) },
|
||||
{ key: 'max_role', label: '角色', display: formatQuota(L.max_role) },
|
||||
{ key: 'max_permission', label: '权限', display: formatQuota(L.max_permission) },
|
||||
{ key: 'max_menu', label: '菜单', display: formatQuota(L.max_menu) },
|
||||
{ key: 'max_database', label: '数据库', display: formatQuota(quotas.max_database) },
|
||||
{ key: 'max_middleware', label: '中间件', display: formatQuota(quotas.max_middleware) },
|
||||
{ key: 'max_network_device', label: '网络设备', display: formatQuota(quotas.max_network_device) },
|
||||
{ key: 'max_security', label: '安全设备', display: formatQuota(quotas.max_security) },
|
||||
{ key: 'max_storage', label: '存储设备', display: formatQuota(quotas.max_storage) },
|
||||
{ key: 'max_pc', label: 'PC', display: formatQuota(quotas.max_pc) },
|
||||
{ key: 'max_server', label: '服务器', display: formatQuota(quotas.max_server) },
|
||||
{ key: 'max_user', label: '用户', display: formatQuota(quotas.max_user) },
|
||||
{ key: 'max_role', label: '角色', display: formatQuota(quotas.max_role) },
|
||||
{ key: 'max_permission', label: '权限', display: formatQuota(quotas.max_permission) },
|
||||
{ key: 'max_menu', label: '菜单', display: formatQuota(quotas.max_menu) },
|
||||
]
|
||||
})
|
||||
|
||||
@@ -189,28 +96,22 @@ async function loadLicense() {
|
||||
loading.value = true
|
||||
loadError.value = false
|
||||
try {
|
||||
const res = await fetchLicenseInfo()
|
||||
const { code, success, data, message } = parseLicenseResponse(res)
|
||||
const ok = responseIndicatesSuccess(code, success)
|
||||
if (data != null && typeof data === 'object' && !Array.isArray(data) && isLicensePayloadRaw(data) && ok) {
|
||||
license.value = normalizeLicensePayload(data as Record<string, unknown>)
|
||||
loadError.value = false
|
||||
} else {
|
||||
loadError.value = true
|
||||
Message.error(message || '获取许可证失败')
|
||||
const response = await fetchLicenseInfo()
|
||||
if (response.code !== 0) {
|
||||
throw new Error(response.message || '获取许可证失败')
|
||||
}
|
||||
} catch (e) {
|
||||
license.value = response.details
|
||||
} catch (error) {
|
||||
loadError.value = true
|
||||
console.error('[license-center] fetchLicenseInfo', e)
|
||||
Message.error('获取许可证失败')
|
||||
license.value = null
|
||||
console.error('[license-center] fetchLicenseInfo', error)
|
||||
Message.error(error instanceof Error ? error.message : '获取许可证失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadLicense()
|
||||
})
|
||||
onMounted(loadLicense)
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"module": "ES2020",
|
||||
"moduleResolution": "node",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"jsx": "preserve",
|
||||
"allowJs": true,
|
||||
"sourceMap": true,
|
||||
|
||||
Reference in New Issue
Block a user