修复知识库的标签管理和分类管理

This commit is contained in:
扫地僧 2025-11-02 18:34:04 +08:00
parent 494e0160b7
commit 7972f0a6d9
14 changed files with 1675 additions and 331 deletions

View File

@ -205,36 +205,19 @@ const transformMenuData = (menus) => {
const fetchMenus = async () => {
loading.value = true;
try {
// localStorage
const cachedMenus = localStorage.getItem('menuData');
let menuData = null;
if (cachedMenus) {
try {
menuData = JSON.parse(cachedMenus);
} catch (e) {
console.warn('缓存菜单数据解析失败:', e);
}
// 使
const res = await getAllMenus();
if (res && res.success && res.data) {
const menuData = res.data;
//
const transformedMenus = transformMenuData(menuData);
list.value = transformedMenus;
} else {
console.error('获取菜单失败:', res?.message || '未知错误');
list.value = [];
loading.value = false;
return;
}
//
if (!menuData) {
const res = await getAllMenus();
if (res && res.success && res.data) {
menuData = res.data;
//
localStorage.setItem('menuData', JSON.stringify(menuData));
} else {
console.error('获取菜单失败:', res?.message || '未知错误');
list.value = [];
loading.value = false;
return;
}
}
//
const transformedMenus = transformMenuData(menuData);
list.value = transformedMenus;
} catch (error) {
console.error('获取菜单异常:', error);
list.value = [];

View File

@ -130,11 +130,6 @@ const handleCommand = (command) => {
//
localStorage.removeItem('tenant');
sessionStorage.removeItem('tenant');
//
localStorage.removeItem('menus');
sessionStorage.removeItem('menus');
localStorage.removeItem('menuData');
sessionStorage.removeItem('menuData');
router.push('/login');
}
};

View File

@ -42,16 +42,30 @@ export function convertMenusToRoutes(menus) {
// 子菜单添加到父菜单的 children 中
const parentRoute = menuMap[menu.parentId];
if (parentRoute) {
// 修正子路由路径:相对于父路由的路径
const parentPath = parentRoute.path; // 例如 "system"
const childFullPath = currentRoute.path; // 例如 "system/users"
// 修正子路由路径:使用原始菜单路径来准确匹配
// 使用原始路径menuPath来准确计算相对路径
const parentOriginalPath = parentRoute.meta?.menuPath || parentRoute.path;
const childOriginalPath = currentRoute.meta?.menuPath || currentRoute.path;
// 确保路径格式一致(去掉前导斜杠)
const parentPath = parentOriginalPath.startsWith('/') ? parentOriginalPath.substring(1) : parentOriginalPath;
const childFullPath = childOriginalPath.startsWith('/') ? childOriginalPath.substring(1) : childOriginalPath;
// 如果子路径以父路径开头,则只保留剩余部分
if (childFullPath.startsWith(parentPath + '/')) {
currentRoute.path = childFullPath.substring(parentPath.length + 1); // "users"
} else if (childFullPath.startsWith('/')) {
// 如果仍然有前导斜杠,去掉它
currentRoute.path = childFullPath.substring(1);
currentRoute.path = childFullPath.substring(parentPath.length + 1); // "users" 或 "knowledge/category"
} else {
// 如果子路径不以父路径开头,尝试直接使用子路径的最后一部分
// 这处理了路径不连续的情况
const pathParts = childFullPath.split('/');
const parentPathParts = parentPath.split('/');
// 找到子路径相对于父路径的部分
if (pathParts.length > parentPathParts.length) {
currentRoute.path = pathParts.slice(parentPathParts.length).join('/');
} else {
// 如果无法确定相对路径,使用最后一个路径段
currentRoute.path = pathParts[pathParts.length - 1];
}
}
if (!parentRoute.children) {
@ -64,6 +78,29 @@ export function convertMenusToRoutes(menus) {
}
}
});
// 2.5. 为有子路由但没有 component 的父路由自动添加默认组件
const addDefaultComponent = (routeList) => {
routeList.forEach(route => {
// 如果路由有子路由但没有组件,尝试添加默认组件
if (route.children && route.children.length > 0 && !route.component) {
// 使用原始菜单路径menuPath来推断组件路径而不是修正后的路径
const originalPath = route.meta?.menuPath || route.path;
// 确保路径以 / 开头
const normalizedPath = originalPath.startsWith('/') ? originalPath : `/${originalPath}`;
const defaultComponentPath = `${normalizedPath}/index.vue`;
const defaultComponent = createComponentLoader(defaultComponentPath);
if (defaultComponent) {
route.component = defaultComponent;
}
}
// 递归处理子路由
if (route.children && route.children.length > 0) {
addDefaultComponent(route.children);
}
});
};
addDefaultComponent(routes);
// 3. 按 order 排序
const sortRoutes = (routesList) => {

View File

@ -142,24 +142,24 @@ function addDynamicRoutes(menus) {
// 添加知识库的子路由(详情页和编辑页)
// 直接在 Main 路由下添加完整路径的子路由
router.addRoute('Main', {
path: 'apps/knowledge/detail/:id',
name: 'apps-knowledge-detail',
component: () => import('@/views/apps/knowledge/components/detail.vue'),
meta: {
requiresAuth: true,
title: '知识详情'
}
});
router.addRoute('Main', {
path: 'apps/knowledge/edit/:id',
name: 'apps-knowledge-edit',
component: () => import('@/views/apps/knowledge/components/edit.vue'),
meta: {
requiresAuth: true,
title: '编辑知识'
}
});
// router.addRoute('Main', {
// path: 'apps/knowledge/detail/:id',
// name: 'apps-knowledge-detail',
// component: () => import('@/views/apps/knowledge/components/detail.vue'),
// meta: {
// requiresAuth: true,
// title: '知识详情'
// }
// });
// router.addRoute('Main', {
// path: 'apps/knowledge/edit/:id',
// name: 'apps-knowledge-edit',
// component: () => import('@/views/apps/knowledge/components/edit.vue'),
// meta: {
// requiresAuth: true,
// title: '编辑知识'
// }
// });
dynamicRoutesAdded = true;
}

View File

@ -1,11 +1,11 @@
<script setup>
// Apps
</script>
<template>
<router-view />
</template>
<style scoped>
/* Apps 父路由容器 */
</style>

View File

@ -1,29 +1,139 @@
<template>
<div class="category-manage">
<!-- 页面头部 -->
<div class="page-header">
<h2 class="page-title">分类管理</h2>
<el-button type="primary" @click="handleAdd">
<el-icon><Plus /></el-icon>
添加分类
</el-button>
<div class="header-left">
<h2 class="page-title">
<el-icon class="title-icon"><Folder /></el-icon>
分类管理
</h2>
<p class="page-desc">管理知识库的分类方便组织和检索内容</p>
</div>
<div class="header-actions">
<el-button type="primary" @click="handleAdd">
<el-icon><Plus /></el-icon>
添加分类
</el-button>
<el-button @click="fetchList" :loading="loading">
<el-icon><Refresh /></el-icon>
刷新
</el-button>
</div>
</div>
<!-- 统计信息 -->
<div class="stats-cards">
<el-descriptions :column="1" border>
<el-descriptions-item label="分类总数">
<el-tag type="primary">{{ categoryList.length }}</el-tag>
</el-descriptions-item>
</el-descriptions>
</div>
<!-- 搜索栏 -->
<div class="search-bar">
<el-input
v-model="searchKeyword"
placeholder="搜索分类名称"
clearable
@input="handleSearch"
style="max-width: 300px"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
</div>
<!-- 分类内容 -->
<div class="category-content">
<el-card shadow="never">
<el-table :data="categoryList" style="width: 100%" v-loading="loading">
<el-table-column prop="categoryId" label="ID" width="80" />
<el-table-column prop="categoryName" label="分类名称" />
<el-table-column label="操作" width="200">
<template #default="{ row }">
<el-button type="primary" link size="small" @click="handleEdit(row)">
编辑
</el-button>
<el-button type="danger" link size="small" @click="handleDelete(row)">
删除
</el-button>
</template>
</el-table-column>
</el-table>
<el-card shadow="never" v-loading="loading">
<!-- 树形表格视图 -->
<div v-if="treeCategoryList.length > 0" class="table-container">
<el-table
:data="treeCategoryList"
style="width: 100%"
stripe
row-key="categoryId"
:tree-props="{ children: 'children', hasChildren: 'hasChildren' }"
:default-expand-all="defaultExpandAll"
>
<el-table-column prop="categoryName" label="分类名称" min-width="250">
<template #default="{ row }">
<div class="category-name-cell">
<el-icon style="margin-right: 6px;">
<Folder v-if="!row.children || row.children.length === 0" />
<FolderOpened v-else />
</el-icon>
<span>{{ row.categoryName }}</span>
<el-tag v-if="row.parentId === 0" size="small" type="info" class="root-tag">顶级</el-tag>
</div>
</template>
</el-table-column>
<el-table-column prop="categoryDesc" label="描述" min-width="250">
<template #default="{ row }">
<span class="category-desc">
{{ row.categoryDesc || '暂无描述' }}
</span>
</template>
</el-table-column>
<el-table-column prop="createTime" label="创建时间" width="180">
<template #default="{ row }">
<span class="create-time">{{ formatDate(row.createTime) }}</span>
</template>
</el-table-column>
<el-table-column label="操作" width="220" fixed="right">
<template #default="{ row }">
<el-button
type="primary"
link
size="small"
@click="handleAddChild(row)"
title="添加子分类"
>
<el-icon><Plus /></el-icon>
添加子分类
</el-button>
<el-button
type="primary"
link
size="small"
@click="handleEdit(row)"
>
<el-icon><Edit /></el-icon>
编辑
</el-button>
<el-button
type="danger"
link
size="small"
@click="handleDelete(row)"
>
<el-icon><Delete /></el-icon>
删除
</el-button>
</template>
</el-table-column>
</el-table>
<!-- 操作栏 -->
<div class="table-actions">
<el-button link size="small" @click="toggleExpandAll">
{{ defaultExpandAll ? '收起全部' : '展开全部' }}
</el-button>
</div>
</div>
<!-- 空状态 -->
<el-empty
v-else
:description="searchKeyword ? '未找到匹配的分类' : '暂无分类数据'"
>
<el-button type="primary" @click="handleAdd" v-if="!searchKeyword">
<el-icon><Plus /></el-icon>
添加第一个分类
</el-button>
</el-empty>
</el-card>
</div>
@ -32,6 +142,8 @@
v-model="dialogVisible"
:title="dialogTitle"
width="500px"
:close-on-click-modal="false"
class="category-dialog"
>
<el-form
:model="formData"
@ -39,70 +151,255 @@
ref="formRef"
label-width="100px"
>
<el-form-item label="父分类" prop="parentId">
<el-cascader
v-model="cascaderValue"
:options="categoryTreeOptions"
:props="cascaderProps"
placeholder="请选择父分类(不选择则为顶级分类)"
clearable
style="width: 100%"
@change="handleCascaderChange"
/>
<div class="form-tip">选择父分类可创建层级分类留空则创建顶级分类</div>
</el-form-item>
<el-form-item label="分类名称" prop="categoryName">
<el-input
v-model="formData.categoryName"
placeholder="请输入分类名称"
clearable
maxlength="50"
show-word-limit
/>
</el-form-item>
<el-form-item label="分类描述" prop="categoryDesc">
<el-input
v-model="formData.categoryDesc"
type="textarea"
:rows="3"
placeholder="请输入分类描述(可选)"
maxlength="200"
show-word-limit
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSubmit">确定</el-button>
<div class="dialog-footer">
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSubmit" :loading="submitting">
确定
</el-button>
</div>
</template>
</el-dialog>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus } from '@element-plus/icons-vue'
import type { FormInstance, FormRules } from 'element-plus'
import { getCategoryList, addCategory } from '@/api/knowledge'
import { ref, reactive, computed, onMounted } from 'vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import { Plus, Folder, Refresh, Search, Edit, Delete, FolderOpened } from '@element-plus/icons-vue';
import type { FormInstance, FormRules } from 'element-plus';
import { getCategoryList, addCategory } from '@/api/knowledge.js';
const loading = ref(false)
const categoryList = ref<any[]>([])
const dialogVisible = ref(false)
const dialogTitle = ref('添加分类')
const formRef = ref<FormInstance>()
const loading = ref(false);
const submitting = ref(false);
const categoryList = ref<any[]>([]);
const searchKeyword = ref('');
const dialogVisible = ref(false);
const dialogTitle = ref('添加分类');
const defaultExpandAll = ref(false);
const formRef = ref<FormInstance>();
const cascaderValue = ref<any>(null);
const formData = reactive({
//
const cascaderProps = {
value: 'categoryId',
label: 'categoryName',
children: 'children',
checkStrictly: true,
emitPath: false,
};
interface CategoryFormData {
categoryId: number;
parentId: number;
categoryName: string;
categoryDesc?: string;
}
const formData = reactive<CategoryFormData>({
categoryId: 0,
parentId: 0,
categoryName: '',
})
categoryDesc: '',
});
const rules = reactive<FormRules>({
categoryName: [
{ required: true, message: '请输入分类名称', trigger: 'blur' },
{ min: 1, max: 50, message: '分类名称长度应在 1 到 50 个字符之间', trigger: 'blur' },
],
})
});
//
const treeCategoryList = computed(() => {
return buildCategoryTree(categoryList.value);
});
//
const categoryTreeOptions = computed(() => {
if (formData.categoryId > 0) {
//
const excludeIds = getDescendantIds(formData.categoryId, categoryList.value);
return buildCategoryTree(
categoryList.value.filter(cat => !excludeIds.includes(cat.categoryId))
);
}
return buildCategoryTree(categoryList.value);
});
// ID
const getDescendantIds = (categoryId: number, list: any[]): number[] => {
const ids = [categoryId];
const children = list.filter(cat => cat.parentId === categoryId);
children.forEach(child => {
ids.push(...getDescendantIds(child.categoryId, list));
});
return ids;
};
//
const handleCascaderChange = (value: any) => {
formData.parentId = value || 0;
};
//
const buildCategoryTree = (list: any[]): any[] => {
const map = new Map();
const roots: any[] = [];
//
list.forEach(item => {
map.set(item.categoryId, {
...item,
children: [],
hasChildren: false,
});
});
//
list.forEach(item => {
const node = map.get(item.categoryId);
const parentId = item.parentId || 0;
if (parentId === 0 || !map.has(parentId)) {
//
roots.push(node);
} else {
//
const parent = map.get(parentId);
parent.children.push(node);
parent.hasChildren = true;
}
});
//
if (searchKeyword.value.trim()) {
return filterTreeWithKeyword(roots, searchKeyword.value.toLowerCase());
}
return roots;
};
//
const filterTreeWithKeyword = (tree: any[], keyword: string): any[] => {
return tree
.map(node => {
const matched =
node.categoryName?.toLowerCase().includes(keyword) ||
node.categoryDesc?.toLowerCase().includes(keyword);
const filteredChildren = node.children ? filterTreeWithKeyword(node.children, keyword) : [];
if (matched || filteredChildren.length > 0) {
return {
...node,
children: filteredChildren,
hasChildren: filteredChildren.length > 0,
};
}
return null;
})
.filter(Boolean) as any[];
};
//
const formatDate = (dateStr: string | undefined | null): string => {
if (!dateStr) return '-';
const date = new Date(dateStr);
if (isNaN(date.getTime())) return String(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 handleSearch = () => {
// computed
};
const fetchList = async () => {
loading.value = true
loading.value = true;
try {
const res = await getCategoryList()
const data = res.code === 0 && res.data ? res.data : res.data || []
categoryList.value = Array.isArray(data) ? data : []
const res = await getCategoryList();
const data = res.code === 0 && res.data ? res.data : res.data || [];
categoryList.value = Array.isArray(data) ? data : [];
} catch (e: any) {
ElMessage.error(e.message || '获取分类列表失败')
ElMessage.error(e.message || '获取分类列表失败');
} finally {
loading.value = false
}
loading.value = false;
}
};
const handleAdd = () => {
dialogTitle.value = '添加分类'
formData.categoryId = 0
formData.categoryName = ''
dialogVisible.value = true
}
dialogTitle.value = '添加分类';
//
formRef.value?.resetFields();
//
formData.categoryId = 0;
formData.parentId = 0;
formData.categoryName = '';
formData.categoryDesc = '';
cascaderValue.value = null;
dialogVisible.value = true;
};
const handleAddChild = (parent: any) => {
dialogTitle.value = '添加子分类';
//
formRef.value?.resetFields();
//
formData.categoryId = 0; // 0
formData.parentId = parent.categoryId;
formData.categoryName = '';
formData.categoryDesc = '';
cascaderValue.value = parent.categoryId;
dialogVisible.value = true;
};
const handleEdit = (row: any) => {
dialogTitle.value = '编辑分类'
formData.categoryId = row.categoryId
formData.categoryName = row.categoryName
dialogVisible.value = true
}
dialogTitle.value = '编辑分类';
formData.categoryId = row.categoryId;
formData.parentId = row.parentId || 0;
formData.categoryName = row.categoryName || '';
formData.categoryDesc = row.categoryDesc || '';
cascaderValue.value = row.parentId || null;
dialogVisible.value = true;
};
const handleDelete = (row: any) => {
ElMessageBox.confirm('确认删除该分类?', '提示', {
@ -112,62 +409,141 @@ const handleDelete = (row: any) => {
}).then(async () => {
try {
// TODO: API
ElMessage.success('删除成功')
fetchList()
ElMessage.success('删除成功');
fetchList();
} catch (e: any) {
ElMessage.error(e.message || '删除失败')
ElMessage.error(e.message || '删除失败');
}
})
}
});
};
const handleSubmit = () => {
if (!formRef.value) return
if (!formRef.value) return;
formRef.value.validate(async (valid: boolean) => {
if (!valid) return
try {
await addCategory({
categoryName: formData.categoryName,
})
ElMessage.success('操作成功')
dialogVisible.value = false
fetchList()
} catch (e: any) {
ElMessage.error(e.message || '操作失败')
if (!valid) return;
//
if (formData.categoryId > 0 && formData.parentId === formData.categoryId) {
ElMessage.warning('不能将自己设为父分类');
return;
}
})
}
submitting.value = true;
try {
//
const isEdit = formData.categoryId > 0;
//
const submitData: any = {
categoryName: formData.categoryName,
};
if (formData.categoryDesc) {
submitData.categoryDesc = formData.categoryDesc;
}
if (formData.parentId > 0) {
submitData.parentId = formData.parentId;
}
// categoryId
// categoryId
if (isEdit) {
submitData.categoryId = formData.categoryId;
}
// submitDatacategoryId
await addCategory(submitData);
ElMessage.success(isEdit ? '分类更新成功' : '分类添加成功');
dialogVisible.value = false;
formData.categoryId = 0;
formData.parentId = 0;
formData.categoryName = '';
formData.categoryDesc = '';
cascaderValue.value = null;
formRef.value?.resetFields();
fetchList();
} catch (e: any) {
ElMessage.error(e.message || '操作失败');
} finally {
submitting.value = false;
}
});
};
// /
const toggleExpandAll = () => {
defaultExpandAll.value = !defaultExpandAll.value;
};
onMounted(() => {
fetchList()
})
fetchList();
});
</script>
<style scoped lang="less">
.category-manage {
padding: 24px;
min-height: 100%;
background-color: var(--el-bg-color-page);
padding: 20px;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
align-items: flex-start;
margin-bottom: 20px;
.page-title {
margin: 0;
font-size: 20px;
font-weight: 600;
color: var(--el-text-color-primary);
.header-left {
.page-title {
display: flex;
align-items: center;
gap: 8px;
margin: 0 0 8px 0;
}
}
.header-actions {
display: flex;
gap: 10px;
}
}
.category-content {
:deep(.el-card) {
background: var(--el-bg-color);
border: 1px solid var(--el-border-color-lighter);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
.stats-cards {
margin-bottom: 20px;
}
.search-bar {
margin-bottom: 20px;
display: flex;
justify-content: flex-end;
}
.table-container {
.category-name-cell {
display: inline-flex;
align-items: center;
gap: 8px;
.root-tag {
margin-left: 8px;
}
}
.table-actions {
margin-top: 16px;
display: flex;
justify-content: flex-end;
}
}
.form-tip {
font-size: 12px;
color: var(--el-text-color-secondary);
margin-top: 4px;
line-height: 1.4;
}
</style>

View File

@ -1,5 +1,10 @@
<template>
<div class="knowledge-home">
<!-- 如果当前是子路由category tag显示子路由内容 -->
<router-view v-if="isSubRoute" />
<!-- 否则显示知识库列表 -->
<template v-else>
<!-- 顶部搜索区域 -->
<div class="search-section">
<div class="search-content">
@ -268,12 +273,13 @@
/>
</div>
</div>
</template>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from "vue";
import { useRouter } from "vue-router";
import { useRouter, useRoute } from "vue-router";
import { ElMessage, ElMessageBox } from "element-plus";
import { Search, Plus, Folder, PriceTag, User, View, Star, Clock } from "@element-plus/icons-vue";
@ -310,6 +316,13 @@ interface StatItem {
//
const router = useRouter();
const route = useRoute();
// category tag
const isSubRoute = computed(() => {
const path = route.path;
return path.includes('/category') || path.includes('/tag');
});
//
const keyword = ref("");
@ -422,7 +435,7 @@ function handleCategory() {
}
function handleTags() {
router.push(`/apps/knowledge/tags`);
router.push(`/apps/knowledge/tag`);
}
function handleDelete(repo: Knowledge) {

View File

@ -1,29 +1,124 @@
<template>
<div class="tag-manage">
<!-- 页面头部 -->
<div class="page-header">
<h2 class="page-title">标签管理</h2>
<el-button type="primary" @click="handleAdd">
<el-icon><Plus /></el-icon>
添加标签
</el-button>
<div class="header-left">
<h2 class="page-title">
<el-icon><PriceTag /></el-icon>
标签管理
</h2>
<p class="page-desc">管理知识库的标签方便标记和检索内容</p>
</div>
<div class="header-actions">
<el-button type="primary" @click="handleAdd">
<el-icon><Plus /></el-icon>
添加标签
</el-button>
<el-button @click="fetchList" :loading="loading">
<el-icon><Refresh /></el-icon>
刷新
</el-button>
</div>
</div>
<!-- 统计信息 -->
<div class="stats-cards">
<el-descriptions :column="1" border>
<el-descriptions-item label="标签总数">
<el-tag type="primary">{{ tagList.length }}</el-tag>
</el-descriptions-item>
</el-descriptions>
</div>
<!-- 搜索栏 -->
<div class="search-bar">
<el-input
v-model="searchKeyword"
placeholder="搜索标签名称"
clearable
@input="handleSearch"
style="max-width: 300px"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
</div>
<!-- 标签内容 -->
<div class="tag-content">
<el-card shadow="never">
<el-table :data="tagList" style="width: 100%" v-loading="loading">
<el-table-column prop="tagId" label="ID" width="80" />
<el-table-column prop="tagName" label="标签名称" />
<el-table-column label="操作" width="200">
<template #default="{ row }">
<el-button type="primary" link size="small" @click="handleEdit(row)">
编辑
</el-button>
<el-button type="danger" link size="small" @click="handleDelete(row)">
删除
</el-button>
</template>
</el-table-column>
</el-table>
<el-card shadow="never" v-loading="loading">
<!-- 表格视图 -->
<div v-if="filteredTagList.length > 0" class="table-container">
<el-table
:data="filteredTagList"
style="width: 100%"
stripe
:default-sort="{ prop: 'tagId', order: 'ascending' }"
>
<el-table-column prop="tagName" label="标签名称" min-width="250">
<template #default="{ row }">
<div class="tag-name-cell">
<el-icon><PriceTag /></el-icon>
<el-tag
:style="{
backgroundColor: row.tagBackground || '#f0f0f0',
color: row.tagColor || '#333',
borderColor: row.tagBackground || '#e0e0e0'
}"
size="small"
class="custom-tag"
>
{{ row.tagName }}
</el-tag>
</div>
</template>
</el-table-column>
<el-table-column prop="usageCount" label="使用次数" width="120">
<template #default="{ row }">
<span>{{ row.usageCount || 0 }}</span>
</template>
</el-table-column>
<el-table-column prop="createTime" label="创建时间" width="180">
<template #default="{ row }">
<span>{{ formatDate(row.createTime) }}</span>
</template>
</el-table-column>
<el-table-column label="操作" width="200" fixed="right">
<template #default="{ row }">
<el-button
type="primary"
link
size="small"
@click="handleEdit(row)"
>
<el-icon><Edit /></el-icon>
编辑
</el-button>
<el-button
type="danger"
link
size="small"
@click="handleDelete(row)"
>
<el-icon><Delete /></el-icon>
删除
</el-button>
</template>
</el-table-column>
</el-table>
</div>
<!-- 空状态 -->
<el-empty
v-else
:description="searchKeyword ? '未找到匹配的标签' : '暂无标签数据'"
>
<el-button type="primary" @click="handleAdd" v-if="!searchKeyword">
<el-icon><Plus /></el-icon>
添加第一个标签
</el-button>
</el-empty>
</el-card>
</div>
@ -32,6 +127,7 @@
v-model="dialogVisible"
:title="dialogTitle"
width="500px"
:close-on-click-modal="false"
>
<el-form
:model="formData"
@ -43,67 +139,148 @@
<el-input
v-model="formData.tagName"
placeholder="请输入标签名称"
clearable
maxlength="50"
show-word-limit
/>
</el-form-item>
<el-form-item label="字体颜色" prop="tagColor">
<el-color-picker
v-model="formData.tagColor"
:predefine="predefineColors"
show-alpha
/>
<div class="form-tip">选择标签字体颜色可选</div>
</el-form-item>
<el-form-item label="背景颜色" prop="tagBackground">
<el-color-picker
v-model="formData.tagBackground"
:predefine="predefineColors"
show-alpha
/>
<div class="form-tip">选择标签背景颜色可选</div>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSubmit">确定</el-button>
<div class="dialog-footer">
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSubmit" :loading="submitting">
确定
</el-button>
</div>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus } from '@element-plus/icons-vue'
import type { FormInstance, FormRules } from 'element-plus'
import { getTagList, addTag } from '@/api/knowledge'
import { ref, reactive, computed, onMounted } from 'vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import { Plus, PriceTag, Refresh, Search, Edit, Delete } from '@element-plus/icons-vue';
import type { FormInstance, FormRules } from 'element-plus';
import { getTagList, addTag } from '@/api/knowledge.js';
const loading = ref(false)
const tagList = ref<any[]>([])
const dialogVisible = ref(false)
const dialogTitle = ref('添加标签')
const formRef = ref<FormInstance>()
const loading = ref(false);
const submitting = ref(false);
const tagList = ref<any[]>([]);
const searchKeyword = ref('');
const dialogVisible = ref(false);
const dialogTitle = ref('添加标签');
const formRef = ref<FormInstance>();
const formData = reactive({
interface TagFormData {
tagId: number;
tagName: string;
tagColor?: string;
tagBackground?: string;
}
const formData = reactive<TagFormData>({
tagId: 0,
tagName: '',
})
tagColor: '',
tagBackground: '',
});
const rules = reactive<FormRules>({
tagName: [
{ required: true, message: '请输入标签名称', trigger: 'blur' },
{ min: 1, max: 50, message: '标签名称长度应在 1 到 50 个字符之间', trigger: 'blur' },
],
})
});
//
const predefineColors = [
'#ff4500',
'#ff8c00',
'#ffd700',
'#90ee90',
'#00ced1',
'#1e90ff',
'#c71585',
'#ff1493',
];
//
const filteredTagList = computed(() => {
if (!searchKeyword.value.trim()) {
return tagList.value;
}
const keyword = searchKeyword.value.toLowerCase();
return tagList.value.filter(tag =>
tag.tagName?.toLowerCase().includes(keyword)
);
});
//
const formatDate = (dateStr: string | undefined | null): string => {
if (!dateStr) return '-';
const date = new Date(dateStr);
if (isNaN(date.getTime())) return String(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 handleSearch = () => {
// computed
};
const fetchList = async () => {
loading.value = true
loading.value = true;
try {
const res = await getTagList()
const data = res.code === 0 && res.data ? res.data : res.data || []
tagList.value = Array.isArray(data) ? data : []
const res = await getTagList();
const data = res.code === 0 && res.data ? res.data : res.data || [];
tagList.value = Array.isArray(data) ? data : [];
} catch (e: any) {
ElMessage.error(e.message || '获取标签列表失败')
ElMessage.error(e.message || '获取标签列表失败');
} finally {
loading.value = false
loading.value = false;
}
}
};
const handleAdd = () => {
dialogTitle.value = '添加标签'
formData.tagId = 0
formData.tagName = ''
dialogVisible.value = true
}
dialogTitle.value = '添加标签';
formRef.value?.resetFields();
formData.tagId = 0;
formData.tagName = '';
formData.tagColor = '';
formData.tagBackground = '';
dialogVisible.value = true;
};
const handleEdit = (row: any) => {
dialogTitle.value = '编辑标签'
formData.tagId = row.tagId
formData.tagName = row.tagName
dialogVisible.value = true
}
dialogTitle.value = '编辑标签';
formData.tagId = row.tagId;
formData.tagName = row.tagName || '';
formData.tagColor = row.tagColor || '';
formData.tagBackground = row.tagBackground || '';
dialogVisible.value = true;
};
const handleDelete = (row: any) => {
ElMessageBox.confirm('确认删除该标签?', '提示', {
@ -113,63 +290,112 @@ const handleDelete = (row: any) => {
}).then(async () => {
try {
// TODO: API
ElMessage.success('删除成功')
fetchList()
ElMessage.success('删除成功');
fetchList();
} catch (e: any) {
ElMessage.error(e.message || '删除失败')
ElMessage.error(e.message || '删除失败');
}
})
}
});
};
const handleSubmit = () => {
if (!formRef.value) return
if (!formRef.value) return;
formRef.value.validate(async (valid: boolean) => {
if (!valid) return
if (!valid) return;
submitting.value = true;
try {
await addTag({
const isEdit = formData.tagId > 0;
//
const submitData: any = {
tagName: formData.tagName,
})
ElMessage.success('操作成功')
dialogVisible.value = false
fetchList()
// 使
tagColor: formData.tagColor || '',
tagBackground: formData.tagBackground || '',
};
// tagId
if (isEdit) {
submitData.tagId = formData.tagId;
}
await addTag(submitData);
ElMessage.success(isEdit ? '标签更新成功' : '标签添加成功');
dialogVisible.value = false;
formData.tagId = 0;
formData.tagName = '';
formData.tagColor = '';
formData.tagBackground = '';
formRef.value?.resetFields();
fetchList();
} catch (e: any) {
ElMessage.error(e.message || '操作失败')
ElMessage.error(e.message || '操作失败');
} finally {
submitting.value = false;
}
})
}
});
};
onMounted(() => {
fetchList()
})
fetchList();
});
</script>
<style scoped lang="less">
.tag-manage {
padding: 24px;
min-height: 100%;
background-color: var(--el-bg-color-page);
padding: 20px;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
align-items: flex-start;
margin-bottom: 20px;
.page-title {
margin: 0;
font-size: 20px;
font-weight: 600;
color: var(--el-text-color-primary);
.header-left {
.page-title {
display: flex;
align-items: center;
gap: 8px;
margin: 0 0 8px 0;
}
}
.header-actions {
display: flex;
gap: 10px;
}
}
.tag-content {
:deep(.el-card) {
background: var(--el-bg-color);
border: 1px solid var(--el-border-color-lighter);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
.stats-cards {
margin-bottom: 20px;
}
.search-bar {
margin-bottom: 20px;
display: flex;
justify-content: flex-end;
}
.table-container {
.tag-name-cell {
display: flex;
align-items: center;
gap: 8px;
.custom-tag {
border: 1px solid;
padding: 4px 12px;
}
}
}
.form-tip {
font-size: 12px;
color: var(--el-text-color-secondary);
margin-top: 4px;
line-height: 1.4;
}
</style>

View File

@ -1,11 +1,11 @@
<script setup>
// Settings
</script>
<template>
<router-view />
</template>
<style scoped>
/* Settings 父路由容器 */
</style>

View File

@ -1,16 +1,420 @@
<template>
<div class="container-box">
<div class="header-bar">
<h2>菜单管理</h2>
<el-button type="primary" @click="handleAddMenu = true">
<el-icon><Plus /></el-icon>
添加菜单
</el-button>
</div>
<el-divider></el-divider>
<div class="system-info-container">
<el-card class="info-card">
<template #header>
<div class="card-header">
<el-icon class="header-icon"><InfoFilled /></el-icon>
<span class="header-title">系统信息</span>
</div>
</template>
<div v-loading="loading" class="info-content">
<!-- 系统基本信息 -->
<el-descriptions title="系统基本信息" :column="2" border class="info-section">
<el-descriptions-item label="系统名称">
<el-tag type="primary">{{ systemInfo.name || '云泽管理系统' }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="系统版本">
<el-tag>{{ systemInfo.version || '1.0.0' }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="运行环境">
<el-tag type="success">{{ systemInfo.environment || 'Production' }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="系统时间">
{{ currentTime }}
</el-descriptions-item>
<el-descriptions-item label="运行时间">
{{ systemInfo.uptime || '-' }}
</el-descriptions-item>
<el-descriptions-item label="时区">
{{ systemInfo.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone }}
</el-descriptions-item>
</el-descriptions>
<!-- 服务器信息 -->
<el-descriptions title="服务器信息" :column="2" border class="info-section">
<el-descriptions-item label="服务器地址">
{{ systemInfo.serverHost || '-' }}
</el-descriptions-item>
<el-descriptions-item label="API地址">
{{ apiBaseUrl }}
</el-descriptions-item>
<el-descriptions-item label="操作系统">
{{ systemInfo.os || clientInfo.os }}
</el-descriptions-item>
<el-descriptions-item label="CPU核心数">
{{ systemInfo.cpuCores || clientInfo.cpuCores }}
</el-descriptions-item>
<el-descriptions-item label="内存使用">
<el-progress
:percentage="systemInfo.memoryUsage || 0"
:color="getMemoryColor(systemInfo.memoryUsage)"
:format="(percentage) => `${percentage}%`"
/>
</el-descriptions-item>
<el-descriptions-item label="磁盘使用">
<el-progress
:percentage="systemInfo.diskUsage || 0"
:color="getDiskColor(systemInfo.diskUsage)"
:format="(percentage) => `${percentage}%`"
/>
</el-descriptions-item>
</el-descriptions>
<!-- 数据库信息 -->
<el-descriptions title="数据库信息" :column="2" border class="info-section">
<el-descriptions-item label="数据库类型">
<el-tag type="info">{{ systemInfo.dbType || 'MySQL' }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="数据库版本">
{{ systemInfo.dbVersion || '-' }}
</el-descriptions-item>
<el-descriptions-item label="数据库状态">
<el-tag :type="systemInfo.dbStatus === 'connected' ? 'success' : 'danger'">
{{ systemInfo.dbStatus === 'connected' ? '已连接' : '未连接' }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="连接数">
{{ systemInfo.dbConnections || '-' }}
</el-descriptions-item>
</el-descriptions>
<!-- 客户端信息 -->
<el-descriptions title="客户端信息" :column="2" border class="info-section">
<el-descriptions-item label="浏览器">
{{ clientInfo.browser }}
</el-descriptions-item>
<el-descriptions-item label="浏览器版本">
{{ clientInfo.browserVersion }}
</el-descriptions-item>
<el-descriptions-item label="操作系统">
{{ clientInfo.os }}
</el-descriptions-item>
<el-descriptions-item label="屏幕分辨率">
{{ clientInfo.screenResolution }}
</el-descriptions-item>
<el-descriptions-item label="设备类型">
<el-tag :type="clientInfo.isMobile ? 'warning' : 'success'">
{{ clientInfo.isMobile ? '移动设备' : '桌面设备' }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="用户代理">
<span class="user-agent">{{ clientInfo.userAgent }}</span>
</el-descriptions-item>
</el-descriptions>
<!-- 应用信息 -->
<el-descriptions title="应用信息" :column="2" border class="info-section">
<el-descriptions-item label="前端框架">
<el-tag type="success">Vue 3</el-tag>
</el-descriptions-item>
<el-descriptions-item label="UI框架">
<el-tag type="primary">Element Plus</el-tag>
</el-descriptions-item>
<el-descriptions-item label="构建工具">
<el-tag type="info">Vite</el-tag>
</el-descriptions-item>
<el-descriptions-item label="Node版本">
{{ clientInfo.nodeVersion || '-' }}
</el-descriptions-item>
</el-descriptions>
<!-- 操作按钮 -->
<div class="action-buttons">
<el-button type="primary" @click="refreshInfo" :loading="loading">
<el-icon><Refresh /></el-icon>
刷新信息
</el-button>
<el-button @click="copySystemInfo">
<el-icon><DocumentCopy /></el-icon>
复制信息
</el-button>
</div>
</div>
</el-card>
</div>
</template>
<script setup></script>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue';
import { ElMessage } from 'element-plus';
import { InfoFilled, Refresh, DocumentCopy } from '@element-plus/icons-vue';
//
const loading = ref(false);
const systemInfo = ref<any>({});
const currentTime = ref(new Date().toLocaleString('zh-CN'));
// API
const apiBaseUrl = computed(() => {
return import.meta.env.VITE_API_BASE_URL || window.location.origin;
});
//
const clientInfo = computed(() => {
const ua = navigator.userAgent;
//
let browser = 'Unknown';
let browserVersion = '';
if (ua.includes('Chrome') && !ua.includes('Edg')) {
browser = 'Chrome';
const match = ua.match(/Chrome\/([\d.]+)/);
browserVersion = match ? match[1] : '';
} else if (ua.includes('Firefox')) {
browser = 'Firefox';
const match = ua.match(/Firefox\/([\d.]+)/);
browserVersion = match ? match[1] : '';
} else if (ua.includes('Safari') && !ua.includes('Chrome')) {
browser = 'Safari';
const match = ua.match(/Version\/([\d.]+)/);
browserVersion = match ? match[1] : '';
} else if (ua.includes('Edg')) {
browser = 'Edge';
const match = ua.match(/Edg\/([\d.]+)/);
browserVersion = match ? match[1] : '';
}
//
let os = 'Unknown';
if (ua.includes('Win')) os = 'Windows';
else if (ua.includes('Mac')) os = 'macOS';
else if (ua.includes('Linux')) os = 'Linux';
else if (ua.includes('Android')) os = 'Android';
else if (ua.includes('iOS')) os = 'iOS';
//
const isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(ua);
return {
browser,
browserVersion,
os,
screenResolution: `${screen.width}x${screen.height}`,
isMobile,
userAgent: ua,
cpuCores: navigator.hardwareConcurrency || '-',
nodeVersion: '-', // Node.js
};
});
// 使
const getMemoryColor = (usage: number | undefined) => {
if (!usage) return '#409eff';
if (usage >= 90) return '#f56c6c';
if (usage >= 70) return '#e6a23c';
return '#67c23a';
};
// 使
const getDiskColor = (usage: number | undefined) => {
if (!usage) return '#409eff';
if (usage >= 90) return '#f56c6c';
if (usage >= 70) return '#e6a23c';
return '#67c23a';
};
//
async function fetchSystemInfo() {
loading.value = true;
try {
// API
// const res = await getSystemInfo();
// if (res.success && res.data) {
// systemInfo.value = res.data;
// }
// API
systemInfo.value = {
name: '云泽管理系统',
version: '1.0.0',
environment: 'Production',
uptime: '24天 5小时 30分钟',
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
serverHost: window.location.hostname,
os: 'Linux',
cpuCores: '4',
memoryUsage: 65,
diskUsage: 42,
dbType: 'MySQL',
dbVersion: '8.0',
dbStatus: 'connected',
dbConnections: 15,
};
} catch (err: any) {
ElMessage.error('获取系统信息失败:' + (err.message || '未知错误'));
} finally {
loading.value = false;
}
}
//
function refreshInfo() {
fetchSystemInfo();
currentTime.value = new Date().toLocaleString('zh-CN');
ElMessage.success('信息已刷新');
}
//
function copySystemInfo() {
const infoText = `系统信息报告
==================
系统名称${systemInfo.value.name || '-'}
系统版本${systemInfo.value.version || '-'}
运行环境${systemInfo.value.environment || '-'}
系统时间${currentTime.value}
运行时间${systemInfo.value.uptime || '-'}
服务器信息
- 服务器地址${systemInfo.value.serverHost || '-'}
- API地址${apiBaseUrl.value}
- 操作系统${systemInfo.value.os || '-'}
- CPU核心数${systemInfo.value.cpuCores || '-'}
- 内存使用${systemInfo.value.memoryUsage || 0}%
- 磁盘使用${systemInfo.value.diskUsage || 0}%
数据库信息
- 数据库类型${systemInfo.value.dbType || '-'}
- 数据库版本${systemInfo.value.dbVersion || '-'}
- 数据库状态${systemInfo.value.dbStatus === 'connected' ? '已连接' : '未连接'}
- 连接数${systemInfo.value.dbConnections || '-'}
客户端信息
- 浏览器${clientInfo.value.browser} ${clientInfo.value.browserVersion}
- 操作系统${clientInfo.value.os}
- 屏幕分辨率${clientInfo.value.screenResolution}
- 设备类型${clientInfo.value.isMobile ? '移动设备' : '桌面设备'}
生成时间${new Date().toLocaleString('zh-CN')}`;
if (navigator.clipboard) {
navigator.clipboard.writeText(infoText).then(() => {
ElMessage.success('系统信息已复制到剪贴板');
}).catch(() => {
ElMessage.error('复制失败,请手动复制');
});
} else {
//
const textarea = document.createElement('textarea');
textarea.value = infoText;
document.body.appendChild(textarea);
textarea.select();
try {
document.execCommand('copy');
ElMessage.success('系统信息已复制到剪贴板');
} catch {
ElMessage.error('复制失败,请手动复制');
}
document.body.removeChild(textarea);
}
}
//
let timeInterval: NodeJS.Timeout | null = null;
onMounted(() => {
fetchSystemInfo();
//
timeInterval = setInterval(() => {
currentTime.value = new Date().toLocaleString('zh-CN');
}, 1000);
});
onUnmounted(() => {
if (timeInterval) {
clearInterval(timeInterval);
}
});
</script>
<style scoped lang="less">
.system-info-container {
padding: 24px;
background-color: var(--el-bg-color-page);
min-height: 100%;
.info-card {
background: var(--el-bg-color);
border: 1px solid var(--el-border-color-lighter);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
.card-header {
display: flex;
align-items: center;
gap: 8px;
.header-icon {
font-size: 20px;
color: var(--el-color-primary);
}
.header-title {
font-size: 18px;
font-weight: 600;
color: var(--el-text-color-primary);
}
}
.info-content {
.info-section {
margin-bottom: 24px;
:deep(.el-descriptions__title) {
font-size: 16px;
font-weight: 600;
color: var(--el-text-color-primary);
margin-bottom: 16px;
padding-bottom: 8px;
border-bottom: 2px solid #4f84ff;
}
:deep(.el-descriptions__label) {
font-weight: 600;
color: var(--el-text-color-regular);
background-color: var(--el-fill-color-lighter);
}
:deep(.el-descriptions__content) {
color: var(--el-text-color-primary);
}
.user-agent {
font-size: 12px;
color: var(--el-text-color-secondary);
word-break: break-all;
}
}
.action-buttons {
display: flex;
justify-content: center;
gap: 12px;
margin-top: 32px;
padding-top: 24px;
border-top: 1px solid var(--el-border-color-lighter);
}
}
}
}
@media (max-width: 768px) {
.system-info-container {
padding: 12px;
.info-card {
.info-content {
.info-section {
:deep(.el-descriptions) {
:deep(.el-descriptions__table) {
.el-descriptions__cell {
display: block;
width: 100% !important;
}
}
}
}
}
}
}
}
</style>
<style lang="scss" scoped></style>

View File

@ -120,7 +120,7 @@
<!-- 文件数据列表 -->
<div class="file-list stylish-panel">
<el-table
:data="filteredFiles"
:data="paginatedFiles"
v-loading="loading"
highlight-current-row
border
@ -361,9 +361,9 @@
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from "vue";
import { ref, computed, onMounted, watch } from "vue";
import { useRouter } from "vue-router";
import { ElMessage } from "element-plus";
import { ElMessage, ElMessageBox } from "element-plus";
import {
Download,
Delete,
@ -373,58 +373,16 @@ import {
Link,
UploadFilled,
} from "@element-plus/icons-vue";
import { getAllFiles, getMyFiles, getFileById, deleteFile as deleteFileApi, searchFiles } from "@/api/file";
const router = useRouter();
const recentFiles = ref([
{
id: 1,
name: "项目报告.pdf",
size: 2456789,
type: "PDF",
uploadTime: "2024-01-15 10:30:25",
},
{
id: 2,
name: "数据统计.xlsx",
size: 123456,
type: "Excel",
uploadTime: "2024-01-14 14:20:10",
},
{
id: 3,
name: "产品图片.jpg",
size: 345678,
type: "Image",
uploadTime: "2024-01-13 09:15:45",
},
]);
const getFileIcon = (type: string) => {
const iconMap: Record<string, string> = {
PDF: "fa-solid fa-file-pdf",
Excel: "fa-solid fa-file-excel",
Word: "fa-solid fa-file-word",
Image: "fa-solid fa-file-image",
Video: "fa-solid fa-file-video",
Audio: "fa-solid fa-file-audio",
Archive: "fa-solid fa-file-archive",
};
return iconMap[type] || "fa-solid fa-file";
};
const formatFileSize = (bytes: number) => {
if (bytes === 0) return "0 B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
};
// mock
const categories = ref(["文档", "图片", "视频", "音频", "其他"]);
const filteredFiles = ref([]);
//
const allFiles = ref<any[]>([]);
const categories = ref<string[]>([]);
const filteredFiles = ref<any[]>([]);
const loading = ref(false);
const error = ref("");
const searchKeyword = ref("");
const filterCategory = ref("");
const showMyFiles = ref(false);
@ -433,6 +391,106 @@ const currentPage = ref(1);
const totalFiles = ref(0);
const showUploadDialog = ref(false);
//
async function fetchFiles() {
loading.value = true;
error.value = "";
try {
let res;
if (showMyFiles.value) {
res = await getMyFiles();
} else {
res = await getAllFiles();
}
if (res.success && res.data) {
const files = Array.isArray(res.data) ? res.data : [];
allFiles.value = files.map((file: any) => ({
...file,
upload_time: file.upload_time || file.create_time,
}));
//
const uniqueCategories = new Set<string>();
files.forEach((file: any) => {
if (file.category) {
uniqueCategories.add(file.category);
}
});
categories.value = Array.from(uniqueCategories).sort();
applyFilters();
totalFiles.value = filteredFiles.value.length;
} else {
error.value = res.message || "获取文件列表失败";
allFiles.value = [];
totalFiles.value = 0;
}
} catch (err: any) {
error.value = err.message || "获取文件列表失败";
allFiles.value = [];
totalFiles.value = 0;
} finally {
loading.value = false;
}
}
//
function applyFilters() {
let result = [...allFiles.value];
//
if (filterCategory.value) {
result = result.filter((file) => file.category === filterCategory.value);
}
//
if (searchKeyword.value.trim()) {
const keyword = searchKeyword.value.toLowerCase();
result = result.filter(
(file) =>
(file.file_name && file.file_name.toLowerCase().includes(keyword)) ||
(file.original_name && file.original_name.toLowerCase().includes(keyword)) ||
(file.category && file.category.toLowerCase().includes(keyword))
);
}
filteredFiles.value = result;
totalFiles.value = result.length;
//
if (currentPage.value > 1 && result.length === 0) {
currentPage.value = 1;
}
}
//
const formatFileSize = (bytes: number | string | undefined | null): string => {
if (!bytes && bytes !== 0) return "0 B";
const bytesValue = typeof bytes === 'string' ? parseFloat(bytes) : Number(bytes);
if (isNaN(bytesValue) || bytesValue < 0) return "0 B";
if (bytesValue === 0) return "0 B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytesValue) / Math.log(k));
return parseFloat((bytesValue / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
};
//
const formatDate = (dateStr: string | number | undefined | null): string => {
if (!dateStr) return "-";
const date = new Date(dateStr);
if (isNaN(date.getTime())) return String(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");
const seconds = String(date.getSeconds()).padStart(2, "0");
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
};
//
const uploadUrl = computed(() => {
const baseUrl = import.meta.env.VITE_API_BASE_URL ;
@ -453,28 +511,53 @@ const uploadForm = ref({
const showFileDetail = ref(false);
const currentFile = ref<any>(null);
const handleSearch = () => {};
const handleFilter = () => {};
const handleSizeChange = () => {};
const handleCurrentChange = () => {};
//
const handleSearch = () => {
applyFilters();
};
//
const handleFilter = () => {
fetchFiles();
};
//
const handleSizeChange = (val: number) => {
pageSize.value = val;
currentPage.value = 1;
};
//
const handleCurrentChange = (val: number) => {
currentPage.value = val;
};
//
const handleUploadClose = () => {
showUploadDialog.value = false;
uploadForm.value.category = "";
uploadForm.value.isPublic = false;
};
//
const handleUploadSuccess = (response: any, file: any) => {
ElMessage.success('文件上传成功!');
//
showUploadDialog.value = false;
//
// loadFiles();
if (response.success) {
ElMessage.success('文件上传成功!');
showUploadDialog.value = false;
uploadForm.value.category = "";
uploadForm.value.isPublic = false;
fetchFiles();
} else {
ElMessage.error(response.message || '文件上传失败!');
}
};
//
const handleUploadError = (error: Error, file: any) => {
ElMessage.error('文件上传失败:' + error.message);
ElMessage.error('文件上传失败:' + (error.message || error));
};
//
const beforeUpload = (file: File) => {
const maxSize = 10 * 1024 * 1024; // 10MB
if (file.size > maxSize) {
@ -484,23 +567,143 @@ const beforeUpload = (file: File) => {
return true;
};
//
const submitUpload = () => {
// Element Plus el-upload
ElMessage.info('开始上传文件...');
//
};
const viewFile = (row: any) => {};
const formatDate = (val: string | number) => val;
const copyFileUrl = (file: any) => {};
//
const viewFile = async (row: any) => {
try {
loading.value = true;
const res = await getFileById(row.id);
if (res.success && res.data) {
currentFile.value = res.data;
showFileDetail.value = true;
} else {
ElMessage.error(res.message || "获取文件详情失败");
}
} catch (err: any) {
ElMessage.error(err.message || "获取文件详情失败");
} finally {
loading.value = false;
}
};
//
const copyFileUrl = (file: any) => {
const baseUrl = import.meta.env.VITE_API_BASE_URL || window.location.origin;
const fileUrl = `${baseUrl}/api/files/${file.id}/download`;
if (navigator.clipboard) {
navigator.clipboard.writeText(fileUrl).then(() => {
ElMessage.success('文件链接已复制到剪贴板');
}).catch(() => {
ElMessage.error('复制失败,请手动复制');
});
} else {
//
const textarea = document.createElement('textarea');
textarea.value = fileUrl;
document.body.appendChild(textarea);
textarea.select();
try {
document.execCommand('copy');
ElMessage.success('文件链接已复制到剪贴板');
} catch {
ElMessage.error('复制失败,请手动复制');
}
document.body.removeChild(textarea);
}
};
//
const downloadFile = (file: any) => {
//
const baseUrl = import.meta.env.VITE_API_BASE_URL || window.location.origin;
const downloadUrl = `${baseUrl}/api/files/${file.id}/download`;
const token = localStorage.getItem('token');
const link = document.createElement('a');
link.href = downloadUrl;
if (token) {
// token使fetchblob
fetch(downloadUrl, {
headers: {
'Authorization': `Bearer ${token}`
}
})
.then(response => response.blob())
.then(blob => {
const url = window.URL.createObjectURL(blob);
link.href = url;
link.download = file.original_name || file.file_name;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
ElMessage.success('文件下载开始');
})
.catch(err => {
ElMessage.error('文件下载失败:' + err.message);
});
} else {
link.download = file.original_name || file.file_name;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
};
const deleteFile = (file: any) => {
//
//
const deleteFile = async (file: any) => {
try {
await ElMessageBox.confirm(
`确定要删除文件「${file.original_name || file.file_name}」吗?删除后不可恢复。`,
'警告',
{
type: 'warning',
confirmButtonText: '确定',
cancelButtonText: '取消',
}
);
loading.value = true;
const res = await deleteFileApi(file.id);
if (res.success) {
ElMessage.success('删除成功');
await fetchFiles();
} else {
ElMessage.error(res.message || '删除失败');
}
} catch (err: any) {
if (err !== 'cancel') {
ElMessage.error(err.message || '删除失败');
}
} finally {
loading.value = false;
}
};
//
watch([showMyFiles], () => {
fetchFiles();
});
//
const paginatedFiles = computed(() => {
const start = (currentPage.value - 1) * pageSize.value;
const end = start + pageSize.value;
return filteredFiles.value.slice(start, end);
});
//
watch([filteredFiles, currentPage, pageSize], () => {
// filteredFiles computed 使 paginatedFiles
}, { immediate: true });
onMounted(() => {
fetchFiles();
});
</script>

View File

@ -290,12 +290,13 @@ func (c *KnowledgeController) GetTags() {
var result []map[string]interface{}
for _, tag := range tags {
result = append(result, map[string]interface{}{
"tagId": tag.TagId,
"tagName": tag.TagName,
"tagColor": tag.TagColor,
"usageCount": tag.UsageCount,
"createTime": tag.CreateTime,
"updateTime": tag.UpdateTime,
"tagId": tag.TagId,
"tagName": tag.TagName,
"tagColor": tag.TagColor,
"tagBackground": tag.TagBackground,
"usageCount": tag.UsageCount,
"createTime": tag.CreateTime,
"updateTime": tag.UpdateTime,
})
}
@ -322,7 +323,8 @@ func (c *KnowledgeController) AddCategory() {
return
}
id, err := models.AddCategory(&category)
// 不处理id直接添加表自动递增
_, err = models.AddCategory(&category)
if err != nil {
c.Data["json"] = map[string]interface{}{
"code": 1,
@ -336,12 +338,12 @@ func (c *KnowledgeController) AddCategory() {
c.Data["json"] = map[string]interface{}{
"code": 0,
"message": "添加成功",
"data": map[string]interface{}{"categoryId": id},
"data": nil,
}
c.ServeJSON()
}
// AddTag 添加标签
// AddTag 添加或更新标签
// @router /api/knowledge/tag/add [post]
func (c *KnowledgeController) AddTag() {
var tag models.KnowledgeTag
@ -356,6 +358,31 @@ func (c *KnowledgeController) AddTag() {
return
}
// 判断是添加还是更新
if tag.TagId > 0 {
// 更新操作
err = models.UpdateTag(&tag)
if err != nil {
c.Data["json"] = map[string]interface{}{
"code": 1,
"message": "更新标签失败: " + err.Error(),
"data": nil,
}
c.ServeJSON()
return
}
c.Data["json"] = map[string]interface{}{
"code": 0,
"message": "更新成功",
"data": map[string]interface{}{"tagId": tag.TagId},
}
c.ServeJSON()
return
}
// 添加操作:确保 TagId 为 0让数据库自动生成
tag.TagId = 0
id, err := models.AddTag(&tag)
if err != nil {
c.Data["json"] = map[string]interface{}{

View File

@ -0,0 +1,65 @@
-- 添加知识库管理相关的菜单项
-- 注意需要先查询知识库菜单的IDparent_id假设为 11
-- 如果知识库菜单ID为11添加分类管理和标签管理菜单
-- 请根据实际数据库中的知识库菜单ID修改下面的 parent_id 值
-- 查询知识库菜单ID如果需要
-- SELECT id FROM yz_menus WHERE path = '/apps/knowledge';
-- 添加分类管理菜单(如果不存在)
INSERT INTO yz_menus (
name,
path,
parent_id,
icon,
`order`,
status,
component_path,
menu_type,
description
)
SELECT
'分类管理',
'/apps/knowledge/category',
id,
'fa-solid fa-folder',
2,
1,
'@/views/apps/knowledge/category/index.vue',
1,
'知识库分类管理'
FROM yz_menus
WHERE path = '/apps/knowledge'
AND NOT EXISTS (
SELECT 1 FROM yz_menus WHERE path = '/apps/knowledge/category'
);
-- 添加标签管理菜单(如果不存在)
INSERT INTO yz_menus (
name,
path,
parent_id,
icon,
`order`,
status,
component_path,
menu_type,
description
)
SELECT
'标签管理',
'/apps/knowledge/tag',
id,
'fa-solid fa-tags',
3,
1,
'@/views/apps/knowledge/tag/index.vue',
1,
'知识库标签管理'
FROM yz_menus
WHERE path = '/apps/knowledge'
AND NOT EXISTS (
SELECT 1 FROM yz_menus WHERE path = '/apps/knowledge/tag'
);

View File

@ -54,12 +54,13 @@ func (kc *KnowledgeCategory) TableName() string {
// KnowledgeTag 知识库标签模型
type KnowledgeTag struct {
TagId int `orm:"column(tag_id);pk;auto" json:"tagId"`
TagName string `orm:"column(tag_name);size(50);unique" json:"tagName"`
TagColor string `orm:"column(tag_color);size(20);null" json:"tagColor"`
UsageCount int `orm:"column(usage_count);default(0)" json:"usageCount"`
CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"createTime"`
UpdateTime time.Time `orm:"column(update_time);auto_now;type(datetime)" json:"updateTime"`
TagId int `orm:"column(tag_id);pk;auto" json:"tagId"`
TagName string `orm:"column(tag_name);size(50);unique" json:"tagName"`
TagColor string `orm:"column(tag_color);size(20);null" json:"tagColor"`
TagBackground string `orm:"column(tag_background);size(20);null" json:"tagBackground"`
UsageCount int `orm:"column(usage_count);default(0)" json:"usageCount"`
CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"createTime"`
UpdateTime time.Time `orm:"column(update_time);auto_now;type(datetime)" json:"updateTime"`
}
// TableName 设置表名
@ -286,9 +287,23 @@ func AddCategory(category *KnowledgeCategory) (int64, error) {
return id, err
}
// UpdateCategory 更新分类
func UpdateCategory(category *KnowledgeCategory) error {
o := orm.NewOrm()
_, err := o.Update(category, "CategoryName", "CategoryDesc", "ParentId", "SortOrder")
return err
}
// AddTag 添加标签
func AddTag(tag *KnowledgeTag) (int64, error) {
o := orm.NewOrm()
id, err := o.Insert(tag)
return id, err
}
// UpdateTag 更新标签
func UpdateTag(tag *KnowledgeTag) error {
o := orm.NewOrm()
_, err := o.Update(tag, "TagName", "TagColor", "TagBackground")
return err
}