Compare commits

..

2 Commits

Author SHA1 Message Date
ygx
4547dc7777 Merge branch 'main' of https://git.apinb.com/ops/front 2026-03-21 22:37:55 +08:00
ygx
f5ef075fb1 feat 2026-03-21 22:37:48 +08:00
7 changed files with 1254 additions and 98 deletions

86
src/api/kb/favorite.ts Normal file
View File

@@ -0,0 +1,86 @@
import { request } from '@/api/request';
/** 资源类型 */
export type ResourceType = 'document' | 'faq';
/** 收藏记录接口 */
export interface Favorite {
id: number;
created_at: string;
resource_type: ResourceType;
resource_id: number;
resource_name: string;
remarks: string;
is_deleted: boolean;
resource_data?: any;
}
/** 收藏列表响应 */
export interface FavoriteListResponse {
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;
}
/** API响应包装类型 */
export interface ApiResponse<T = any> {
code: number;
message: string;
data: T;
}
/**
* 获取收藏列表
*/
export async function fetchFavoriteList(
params: FetchFavoriteListParams = {}
): Promise<ApiResponse<FavoriteListResponse>> {
return request.get<ApiResponse<FavoriteListResponse>>('/Kb/v1/favorite/list', {
params,
});
}
/**
* 收藏资源
*/
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' },
];

View File

@@ -12,6 +12,162 @@ export interface ReviewStatsPayload {
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;
}
/** 分页响应类型 */
export interface PaginatedResponse<T> {
total: number;
page: number;
page_size: number;
data: T[];
}
/** API响应包装类型 */
export interface ApiResponse<T = any> {
code: number;
message: string;
data: T;
}
/** 获取审核列表参数 */
export interface FetchReviewListParams {
page?: number;
page_size?: number;
resource_type?: ReviewStatsResourceType;
}
/** 审核通过参数 */
export interface ApproveParams {
resource_type: "document" | "faq";
id: number;
}
/** 审核拒绝参数 */
export interface RejectParams {
resource_type: "document" | "faq";
id: number;
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<ApiResponse<PaginatedResponse<ReviewListItem | DocumentResource | FaqResource>>>("/Kb/v1/review/list", { params });
/** 审核通过 */
export const approveReview = (data: ApproveParams) =>
request.post<ApiResponse<string>>("/Kb/v1/review/approve", data);
/** 审核拒绝 */
export const rejectReview = (data: RejectParams) =>
request.post<ApiResponse<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 = [
{ 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 getResourceTypeColor = (type: string): string => {
const colorMap: Record<string, string> = {
document: 'arcoblue',
faq: 'green',
};
return colorMap[type] || 'gray';
};

View File

@@ -133,6 +133,16 @@ const OPS: AppRouteRecordRaw = {
roles: ['*'],
},
},
{
path: 'favorite',
name: 'Favorite',
component: () => import('@/views/ops/pages/favorite/index.vue'),
meta: {
locale: '收藏管理',
requiresAuth: true,
roles: ['*'],
},
},
],
}

View File

@@ -0,0 +1,440 @@
<template>
<div class="container">
<a-card class="general-card" title="收藏管理">
<!-- 数据表格 -->
<a-table
:data="tableData"
:columns="columns"
:loading="loading"
:pagination="pagination"
row-key="id"
@page-change="handlePageChange"
>
<!-- 序号 -->
<template #index="{ rowIndex }">
{{ rowIndex + 1 + (pagination.current - 1) * pagination.pageSize }}
</template>
<!-- 资源类型 -->
<template #resource_type="{ record }">
<a-tag :color="record.resource_type === 'document' ? 'arc-blue' : 'arc-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-space>
</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.doc_no || '-' }}
</a-descriptions-item>
<a-descriptions-item label="标题">
{{ currentResource.title || '-' }}
</a-descriptions-item>
<a-descriptions-item label="作者">
{{ currentResource.author_name || '-' }}
</a-descriptions-item>
<a-descriptions-item label="状态">
<a-tag :color="getDocStatusColor(currentResource.status)">
{{ getDocStatusText(currentResource.status) }}
</a-tag>
</a-descriptions-item>
<a-descriptions-item label="发布时间">
{{ formatDateTime(currentResource.published_at) }}
</a-descriptions-item>
<a-descriptions-item label="浏览次数">
{{ currentResource.view_count || 0 }}
</a-descriptions-item>
<a-descriptions-item label="描述" :span="2">
{{ currentResource.description || '-' }}
</a-descriptions-item>
<a-descriptions-item label="内容" :span="2">
<div class="content-preview" v-html="currentResource.content || '-'"></div>
</a-descriptions-item>
</a-descriptions>
</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="FAQ编号">
{{ currentFaq.faq_no || '-' }}
</a-descriptions-item>
<a-descriptions-item label="状态">
<a-tag color="green">{{ currentFaq.status || '已发布' }}</a-tag>
</a-descriptions-item>
<a-descriptions-item label="问题" :span="2">
{{ currentFaq.question || '-' }}
</a-descriptions-item>
<a-descriptions-item label="答案" :span="2">
<div class="content-preview" v-html="currentFaq.answer || '-'"></div>
</a-descriptions-item>
<a-descriptions-item label="浏览次数">
{{ currentFaq.view_count || 0 }}
</a-descriptions-item>
<a-descriptions-item label="有用次数">
{{ currentFaq.helpful_count || 0 }}
</a-descriptions-item>
</a-descriptions>
</div>
</a-modal>
</div>
</template>
<script lang="ts" setup>
import { ref, reactive, computed, onMounted } from 'vue';
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';
// 状态管理
const loading = ref(false);
const tableData = ref<Favorite[]>([]);
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: 'remarks',
ellipsis: true,
tooltip: true,
width: 200,
},
{
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 fetchFavorites = async () => {
loading.value = true;
try {
const params = {
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 = [];
pagination.total = 0;
}
} catch (error) {
console.error('获取收藏列表失败:', error);
Message.error('获取收藏列表失败');
tableData.value = [];
pagination.total = 0;
} finally {
loading.value = false;
}
};
// 分页变化
const handlePageChange = (current: number) => {
pagination.current = current;
fetchFavorites();
};
// 查看详情
const handleView = async (record: Favorite) => {
if (record.is_deleted) {
Message.warning('该资源已被删除,无法查看');
return;
}
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 || '获取文档详情失败');
}
}
} 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详情失败');
}
}
}
} catch (error) {
console.error('获取详情失败:', error);
Message.error('获取详情失败');
}
};
// 下载文档
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('下载失败');
}
};
// 取消收藏
const handleUncollect = (record: Favorite) => {
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 || '取消收藏失败');
}
} catch (error) {
console.error('取消收藏失败:', error);
Message.error('取消收藏失败');
}
},
});
};
// 格式化日期时间
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'));
}
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}`;
};
// 获取文档状态颜色
const getDocStatusColor = (status?: string) => {
const colorMap: Record<string, string> = {
draft: 'gray',
published: 'green',
reviewed: 'blue',
rejected: 'red',
};
return colorMap[status || ''] || 'gray';
};
// 获取文档状态文本
const getDocStatusText = (status?: string) => {
const textMap: Record<string, string> = {
draft: '草稿',
published: '已发布',
reviewed: '已审核',
rejected: '已拒绝',
};
return textMap[status || ''] || '未知';
};
// 初始化加载数据
onMounted(() => {
fetchFavorites();
});
</script>
<script lang="ts">
export default {
name: 'FavoriteManage',
};
</script>
<style scoped lang="less">
.container {
padding: 20px;
}
.detail-content {
max-height: 60vh;
overflow-y: auto;
}
.content-preview {
max-height: 300px;
overflow-y: auto;
padding: 8px;
background-color: var(--color-fill-1);
border-radius: 4px;
}
</style>

View File

@@ -90,14 +90,14 @@
</template>
<template #extra>
<a-space v-if="currentDocument || isEditing">
<!-- 编辑模式切换 -->
<a-button-group>
<!-- 编辑模式切换仅在编辑状态时显示 -->
<a-button-group v-if="isEditing">
<a-button
:type="editorMode === 'edit' ? 'primary' : 'secondary'"
size="small"
@click="editorMode = 'edit'"
>
编辑
源码
</a-button>
<a-button
:type="editorMode === 'preview' ? 'primary' : 'secondary'"
@@ -209,9 +209,9 @@
</div>
<!-- Markdown编辑/预览区域 -->
<div class="markdown-area" :class="editorMode">
<!-- 编辑区 -->
<div v-show="editorMode === 'edit' || editorMode === 'split'" class="editor-pane">
<div class="markdown-area" :class="isEditing ? editorMode : 'preview'">
<!-- 编辑区仅在编辑状态时显示 -->
<div v-show="isEditing && (editorMode === 'edit' || editorMode === 'split')" class="editor-pane">
<!-- 工具栏 -->
<div class="md-toolbar">
<a-space>
@@ -264,8 +264,8 @@
class="md-editor"
/>
</div>
<!-- 预览区 -->
<div v-show="editorMode === 'preview' || editorMode === 'split'" class="preview-pane">
<!-- 预览区非编辑状态始终显示编辑状态根据模式显示 -->
<div v-show="!isEditing || editorMode === 'preview' || editorMode === 'split'" class="preview-pane">
<div class="md-preview" v-html="renderedContent"></div>
</div>
</div>
@@ -410,30 +410,31 @@ const fetchData = async () => {
// 兼容多种API响应格式
if (res?.code === 0) {
let rawData: any[] = []
// 格式1: res.details.data
if (res.details?.data) {
documentList.value = res.details.data || []
rawData = res.details.data || []
total.value = res.details.total || 0
}
// 格式2: res.data.data
else if (res.data?.data) {
documentList.value = res.data.data || []
rawData = res.data.data || []
total.value = res.data.total || 0
}
// 格式3: res.data 是数组
else if (Array.isArray(res.data)) {
documentList.value = res.data
rawData = res.data
total.value = res.data.length
}
// 格式4: res.details 是数组
else if (Array.isArray(res.details)) {
documentList.value = res.details
rawData = res.details
total.value = res.details.length
}
else {
documentList.value = []
total.value = 0
}
// 处理数据:提取 resource 字段document 和 faq 都作为文档处理)
documentList.value = rawData.map((item: any) => item.resource)
} else {
documentList.value = []
total.value = 0
@@ -496,10 +497,12 @@ const handleSelectDocument = async (doc: Document) => {
loading.value = true
const res: any = await fetchDocumentDetail(doc.id)
if (res?.code === 0 && res.details) {
currentDocument.value = res.details
// 兼容两种响应格式:直接返回文档对象 或 { type, resource } 格式
const docData = res.details.resource || res.details
currentDocument.value = docData
// 检查收藏状态
if (res.details.status === 'reviewed') {
if (docData.status === 'reviewed') {
try {
const favRes: any = await checkFavorite(doc.id)
isFavorited.value = favRes?.details?.is_favorited || false
@@ -932,6 +935,7 @@ export default {
}
.editor-header {
max-width: 80%;
.title-input {
font-size: 16px;
font-weight: 600;

View File

@@ -1,73 +1,63 @@
<template>
<div class="container">
<Breadcrumb :items="['知识管理', '回收站']" />
<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 }}
</template>
<a-card class="general-card">
<!-- 搜索表单 -->
<SearchForm
:model-value="searchForm"
:form-items="filters"
@update:model-value="handleFormModelUpdate"
@search="handleSearch"
@reset="handleReset"
/>
<!-- 资源类型列 -->
<template #resource_type="{ record }">
<a-tag :color="getResourceTypeColor(record.resource_type)">
{{ getResourceTypeText(record.resource_type) }}
</a-tag>
</template>
<a-divider style="margin-top: 0" />
<!-- 删除时间列 -->
<template #deleted_time="{ record }">
{{ formatTime(record.deleted_time) }}
</template>
<!-- 数据表格 -->
<DataTable
:data="tableData"
:columns="columns"
:loading="loading"
:pagination="{
current: page,
pageSize,
total,
}"
:show-download="false"
@page-change="handlePageChange"
@refresh="fetchData"
>
<!-- 序号列 -->
<template #index="{ rowIndex }">
{{ rowIndex + 1 + (page - 1) * pageSize }}
</template>
<!-- 删除人列 -->
<template #deleted_name="{ record }">
{{ record.deleted_name || '-' }}
</template>
<!-- 资源类型 -->
<template #resource_type="{ record }">
<a-tag :color="getResourceTypeColor(record.resource_type)">
{{ getResourceTypeText(record.resource_type) }}
</a-tag>
</template>
<!-- 删除时间列 -->
<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>
</DataTable>
</a-card>
<!-- 操作 -->
<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
@@ -103,8 +93,7 @@
import { ref, reactive, computed, onMounted } from 'vue'
import { Message } from '@arco-design/web-vue'
import dayjs from 'dayjs'
import SearchForm from '@/components/search-form/index.vue'
import DataTable from '@/components/data-table/index.vue'
import SearchTable from '@/components/search-table/index.vue'
import type { FormItem } from '@/components/search-form/types'
import type { TableColumnData } from '@arco-design/web-vue/es/table/interface'
import type { TrashRecord, FetchTrashListParams } from '@/api/kb/trash'
@@ -181,7 +170,7 @@ const filters = computed((): FormItem[] => [
type: 'select',
placeholder: '请选择资源类型',
options: resourceTypeOptions,
span: 4,
span: 6,
},
])
@@ -226,11 +215,11 @@ const fetchData = async () => {
| undefined,
}
const res = await fetchTrashList(params)
if (res?.code === 200 && res.data) {
const res: any = await fetchTrashList(params)
console.log('获取回收站列表成功:', res)
if (res?.code === 0) {
// 如果有关键词,在前端过滤
let data = res.data.data || []
let data = res.details?.data || []
if (searchForm.keyword) {
const keyword = searchForm.keyword.toLowerCase()
data = data.filter(
@@ -240,7 +229,7 @@ const fetchData = async () => {
)
}
tableData.value = data
total.value = res.data.total || 0
total.value = res.details.total || 0
}
} catch (error) {
console.error('获取回收站列表失败:', error)
@@ -288,7 +277,7 @@ const handleConfirmRestore = async () => {
try {
loading.value = true
const res = await restoreTrash({ id: recordToRestore.value.id })
if (res?.code === 200) {
if (res?.code === 0) {
Message.success('恢复成功')
restoreConfirmVisible.value = false
recordToRestore.value = null
@@ -317,7 +306,7 @@ const handleConfirmDelete = async () => {
try {
loading.value = true
const res = await deleteTrash({ id: recordToDelete.value.id })
if (res?.code === 200) {
if (res?.code === 0) {
Message.success('彻底删除成功')
deleteConfirmVisible.value = false
recordToDelete.value = null
@@ -339,12 +328,9 @@ onMounted(() => {
})
</script>
<style scoped lang="less">
.container {
padding: 0 20px 20px 20px;
}
.general-card {
margin-top: 16px;
margin-top: 20px;
}
</style>

View File

@@ -0,0 +1,474 @@
<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 }}
</template>
<!-- 分类列 -->
<template #category="{ record }">
<a-tag :color="getResourceTypeColor(record.type)">
{{ getResourceTypeText(record.type) }}
</a-tag>
</template>
<!-- 作者列 -->
<template #author="{ record }">
{{ record.resource?.author_name || '-' }}
</template>
<!-- 申请时间列 -->
<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"
>
<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 }"
show-word-limit
/>
</a-form-item>
</a-form>
</a-modal>
</div>
</template>
<script lang="ts" setup>
import { ref, reactive, computed, onMounted } from 'vue'
import { Message } from '@arco-design/web-vue'
import dayjs from 'dayjs'
import SearchTable from '@/components/search-table/index.vue'
import type { FormItem } from '@/components/search-form/types'
import type { TableColumnData } from '@arco-design/web-vue/es/table/interface'
import type { ReviewListItem, FetchReviewListParams } from '@/api/kb/review'
import {
fetchReviewList,
approveReview,
rejectReview,
getResourceTypeText,
getResourceTypeColor,
resourceTypeOptions,
} from '@/api/kb/review'
// 表格列配置
const columns = computed((): TableColumnData[] => [
{
title: '序号',
dataIndex: 'index',
slotName: 'index',
width: 80,
align: 'center',
},
{
title: '分类',
dataIndex: 'category',
slotName: 'category',
width: 100,
align: 'center',
},
{
title: '作者',
dataIndex: 'author',
slotName: 'author',
width: 120,
align: 'center',
},
{
title: '申请时间',
dataIndex: 'created_at',
slotName: 'created_at',
width: 180,
align: 'center',
sortable: {
sortDirections: ['ascend', 'descend'],
},
},
{
title: '描述',
dataIndex: 'description',
slotName: 'description',
ellipsis: true,
tooltip: true,
},
{
title: '操作',
dataIndex: 'operation',
slotName: 'operation',
width: 280,
align: 'center',
fixed: 'right',
},
])
// 搜索表单配置
const filters = computed((): FormItem[] => [
{
label: '资源类型',
field: 'resource_type',
type: 'select',
placeholder: '请选择资源类型',
options: resourceTypeOptions,
span: 6,
},
])
// 搜索表单数据
const searchForm = reactive({
resource_type: 'all',
})
// 处理表单模型更新
const handleFormModelUpdate = (newFormModel: Record<string, any>) => {
Object.assign(searchForm, newFormModel)
}
// 表格数据
const tableData = ref<ReviewListItem[]>([])
const loading = ref(false)
// 分页
const page = ref(1)
const pageSize = ref(10)
const total = ref(0)
// 详情弹窗
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 () => {
try {
loading.value = true
const params: FetchReviewListParams = {
page: page.value,
page_size: pageSize.value,
resource_type: searchForm.resource_type as 'all' | 'document' | 'faq',
}
const res: any = await fetchReviewList(params)
if (res?.code === 0) {
tableData.value = res.details?.data || []
total.value = res.details?.total || 0
} else {
Message.error(res?.message || '获取审核列表失败')
}
} catch (error) {
console.error('获取审核列表失败:', error)
Message.error('获取审核列表失败')
} finally {
loading.value = false
}
}
// 搜索
const handleSearch = () => {
page.value = 1
fetchData()
}
// 重置
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: '已拒绝',
}
return statusMap[status || ''] || status || '-'
}
// 获取状态颜色
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 []
try {
return JSON.parse(tags)
} catch {
return tags.split(',').filter(Boolean)
}
}
// 获取资源编号
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 = async (record: ReviewListItem) => {
try {
loading.value = true
const res: any = await approveReview({
resource_type: record.type,
id: record.resource.id,
})
if (res?.code === 0) {
Message.success('审核通过')
await fetchData()
} else {
Message.error(res?.message || '审核失败')
}
} catch (error) {
console.error('审核失败:', error)
Message.error('审核失败')
} finally {
loading.value = false
}
}
// 拒绝
const handleReject = (record: ReviewListItem) => {
recordToReject.value = record
rejectForm.reason = ''
rejectVisible.value = true
}
// 确认拒绝
const handleConfirmReject = async () => {
if (!recordToReject.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,
})
if (res?.code === 0) {
Message.success('已拒绝')
rejectVisible.value = false
recordToReject.value = null
rejectForm.reason = ''
await fetchData()
} else {
Message.error(res?.message || '拒绝失败')
}
} catch (error) {
console.error('拒绝失败:', error)
Message.error('拒绝失败')
} finally {
rejectLoading.value = false
}
}
// 取消拒绝
const handleCancelReject = () => {
rejectVisible.value = false
recordToReject.value = null
rejectForm.reason = ''
}
// 初始化
onMounted(() => {
fetchData()
})
</script>
<style scoped lang="less">
.container {
margin-top: 20px;
}
.description-text {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
}
.content-preview {
max-height: 200px;
overflow-y: auto;
white-space: pre-wrap;
word-break: break-all;
}
</style>