完成知识库模块

This commit is contained in:
李志强 2025-10-28 16:08:40 +08:00
parent 6cd942fb29
commit 1062bcfb70
25 changed files with 3170 additions and 384 deletions

94
KNOWLEDGE_SETUP.md Normal file
View File

@ -0,0 +1,94 @@
# 知识库功能快速设置指南
## 问题原因
500 错误通常是因为数据库表没有创建导致的。
## 解决步骤
### 1. 创建数据库表
打开 MySQL 客户端,连接到您的数据库,执行:
```bash
# 方法1命令行执行
mysql -u root -p your_database < server/database/create_knowledge_tables.sql
# 方法2在 MySQL 客户端中
source server/database/create_knowledge_tables.sql;
```
### 2. 验证表创建成功
```sql
SHOW TABLES LIKE 'yz_knowledge%';
```
应该看到以下表:
- yz_knowledge
- yz_knowledge_category
- yz_knowledge_tags
### 3. 重启后端服务
```bash
cd server
go run main.go
```
### 4. 测试 API
浏览器访问http://localhost:8080/api/knowledge/list
应该返回:
```json
{
"code": 0,
"message": "success",
"data": {
"list": [],
"total": 0,
"page": 1,
"pageSize": 10
}
}
```
## 已完成的修改
### 后端Go
- ✅ 创建 `server/models/knowledge.go` - 知识库模型
- ✅ 创建 `server/controllers/knowledge.go` - 知识库控制器
- ✅ 创建 `server/database/yz_knowledge.sql` - 数据库表结构
- ✅ 更新 `server/routers/router.go` - 添加路由
- ✅ 更新 `server/models/user.go` - 注册模型
- ✅ 修复模型字段映射问题
### 前端TypeScript
- ✅ 更新 `front/src/api/knowledge.ts` - API 调用
- ✅ 更新 `front/src/views/apps/knowledge/index.vue` - 列表页面
- ✅ 更新 `front/src/views/apps/knowledge/components/edit.vue` - 编辑页面
- ✅ 更新 `front/src/views/apps/knowledge/components/detail.vue` - 详情页面
- ✅ 创建 `front/src/components/WangEditor.vue` - 富文本编辑器组件
- ✅ 更新 `front/src/main.ts` - 全局注册组件
- ✅ 更新 `front/src/router/index.ts` - 路由配置
## API 端点
- `GET /api/knowledge/list` - 获取列表
- `GET /api/knowledge/detail?id=xxx` - 获取详情
- `POST /api/knowledge/create` - 创建知识
- `POST /api/knowledge/update` - 更新知识
- `POST /api/knowledge/delete` - 删除知识
- `GET /api/knowledge/categories` - 获取分类
- `GET /api/knowledge/tags` - 获取标签
## 功能特性
- ✅ 富文本编辑器wangEditor
- ✅ 实时预览
- ✅ 分类和标签管理
- ✅ 搜索和分页
- ✅ 主题支持
- ✅ 响应式布局

631
front/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -11,6 +11,10 @@
"dependencies": {
"@element-plus/icons-vue": "^2.3.2",
"@tailwindcss/vite": "^4.1.14",
"@wangeditor/basic-modules": "^1.1.7",
"@wangeditor/editor": "^5.1.23",
"@wangeditor/list-module": "^1.0.5",
"@wangeditor/table-module": "^1.1.4",
"axios": "^1.11.0",
"chart.js": "^4.5.1",
"element-plus": "^2.10.7",

125
front/src/api/knowledge.ts Normal file
View File

@ -0,0 +1,125 @@
import axios from './index';
// 响应类型定义
interface ApiResponse<T = any> {
code: number;
message: string;
data: T;
}
/**
*
*/
export async function getKnowledgeList(params?: {
page?: number;
pageSize?: number;
status?: number;
categoryId?: number;
keyword?: string;
}): Promise<any> {
const response: ApiResponse = await axios.get('/api/knowledge/list', { params });
if (response.code === 0) {
return response.data;
}
throw new Error(response.message || '获取列表失败');
}
/**
*
*/
export async function getKnowledgeDetail(id: string | number): Promise<any> {
const response: ApiResponse = await axios.get('/api/knowledge/detail', {
params: { id }
});
if (response.code === 0) {
return { data: response.data };
}
throw new Error(response.message || '获取详情失败');
}
/**
*
*/
export async function createKnowledge(data: any): Promise<any> {
const response: ApiResponse = await axios.post('/api/knowledge/create', data);
if (response.code === 0) {
return response.data;
}
throw new Error(response.message || '创建失败');
}
/**
*
*/
export async function updateKnowledge(id: string | number, data: any): Promise<any> {
const response: ApiResponse = await axios.post('/api/knowledge/update', {
id,
...data
});
if (response.code === 0) {
return { success: true };
}
throw new Error(response.message || '更新失败');
}
/**
*
*/
export async function deleteKnowledge(id: string | number): Promise<any> {
const response: ApiResponse = await axios.post('/api/knowledge/delete', { id });
if (response.code === 0) {
return { success: true };
}
throw new Error(response.message || '删除失败');
}
/**
*
*/
export async function getCategoryList(): Promise<any> {
const response: ApiResponse = await axios.get('/api/knowledge/categories');
if (response.code === 0) {
const categories = response.data || [];
return { data: categories };
}
throw new Error(response.message || '获取分类失败');
}
/**
*
*/
export async function getTagList(): Promise<any> {
const response: ApiResponse = await axios.get('/api/knowledge/tags');
if (response.code === 0) {
// 转换为名称数组格式,兼容前端使用
const tags = response.data || [];
return {
data: tags.map((tag: any) => tag.tagName || tag.tag_name)
};
}
throw new Error(response.message || '获取标签失败');
}
/**
*
*/
export async function addCategory(data: {
categoryName: string;
categoryDesc?: string;
parentId?: number;
sortOrder?: number;
}): Promise<any> {
const response: ApiResponse = await axios.post('/api/knowledge/category/add', data);
return response.data;
}
/**
*
*/
export async function addTag(data: {
tagName: string;
tagColor?: string;
}): Promise<any> {
const response: ApiResponse = await axios.post('/api/knowledge/tag/add', data);
return response.data;
}

View File

@ -71,6 +71,7 @@ $transition-base: all 0.3s cubic-bezier(.25,.8,.25,1);
// 组件颜色
--card-bg: #ffffff;
--card-bg1: #1890ff;
--card-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
--sidebar-bg: #f8f9fa;
--sidebar-hover: #e9ecef;
@ -146,6 +147,7 @@ $transition-base: all 0.3s cubic-bezier(.25,.8,.25,1);
// 组件颜色
--card-bg: #2d2d2d;
--card-bg1: #2d2d2d;
--card-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
--sidebar-bg: #1f1f1f;
--sidebar-hover: #3d3d3d;

View File

@ -27,3 +27,177 @@
gap: 8px;
align-items: center;
}
/* 标准文章样式 */
.markdown-body {
color: var(--text-color);
font-size: 16px;
line-height: 1.8;
word-break: break-word;
background: var(--content-bg);
h1,
h2,
h3,
h4,
h5,
h6 {
color: var(--title-color, var(--text-color));
margin-top: 1.6em;
margin-bottom: 0.8em;
font-family: inherit;
font-weight: 700;
line-height: 1.3;
}
h1 {
font-size: 2.2em;
border-bottom: 1px solid #eaecef;
padding-bottom: 0.4em;
}
h2 {
font-size: 1.8em;
border-bottom: 1px solid #eaecef;
padding-bottom: 0.3em;
}
h3 {
font-size: 1.4em;
}
h4 {
font-size: 1.2em;
}
h5 {
font-size: 1.06em;
}
h6 {
font-size: 1em;
color: #969696;
}
p {
margin: 0.9em 0;
color: inherit;
}
ul,
ol {
padding-left: 2em;
margin: 0.9em 0;
}
ul {
list-style-type: disc;
}
ol {
list-style-type: decimal;
}
li {
margin: 0.7em 0 0.7em 0.7em;
color: inherit;
}
a {
color: var(--primary-color, #2d8cf0);
text-decoration: underline;
transition: color 0.2s;
&:hover {
color: var(--primary-hover, #1a73e8);
}
}
img {
max-width: 100%;
height: auto;
display: block;
margin: 1em auto;
border-radius: 4px;
background: #f7f7fa;
}
blockquote {
border-left: 4px solid #d0dde9;
background: #f7f9fa;
padding: 0.6em 1.2em;
margin: 1.1em 0;
color: #6580a0;
font-style: italic;
}
code {
font-family: var(
--code-font,
"Fira Mono",
"Menlo",
"Consolas",
"monospace"
);
background: #f4f4f4;
border-radius: 3px;
padding: 0.15em 0.4em;
color: #c7254e;
font-size: 0.97em;
margin: 0 0.1em;
}
pre {
background: #f6f8fa;
border-radius: 4px;
padding: 1em 1.2em;
font-family: var(
--code-font,
"Fira Mono",
"Menlo",
"Consolas",
"monospace"
);
font-size: 0.98em;
overflow-x: auto;
color: #212529;
margin: 1.2em 0;
code {
background: none;
color: inherit;
font-size: inherit;
padding: 0;
}
}
table {
width: 100%;
border-collapse: collapse;
margin: 1.2em 0;
color: inherit;
font-size: 1em;
background: #fff;
}
th,
td {
border: 1px solid #dee2e6;
padding: 0.5em 1em;
color: inherit;
}
th {
background: #f4f8fb;
font-weight: 600;
}
tr:nth-child(even) {
background: #f9fbfc;
}
hr {
border: none;
border-top: 1px solid #eaecef;
margin: 2em 0;
}
br {
display: block;
margin: 0.2em 0;
content: "";
}
// 兼容 Element 表单
.el-form-item__label,
.el-form-item__content,
.el-form-item__error,
.el-form-item__error-tip {
color: var(--text-color);
}
}

View File

@ -0,0 +1,126 @@
<template>
<div class="wang-editor-wrapper">
<div ref="toolbarRef" class="toolbar-container"></div>
<div ref="editorRef" class="editor-container"></div>
</div>
</template>
<script setup lang="ts">
import { ref, watch, onMounted, onBeforeUnmount, nextTick } from 'vue';
import '@wangeditor/editor/dist/css/style.css';
interface Props {
modelValue: string;
}
const props = withDefaults(defineProps<Props>(), {
modelValue: '',
});
const emit = defineEmits<{
'update:modelValue': [value: string];
}>();
const toolbarRef = ref<HTMLDivElement>();
const editorRef = ref<HTMLDivElement>();
let editorInstance: any = null;
let isDestroyed = false;
//
const initEditor = async () => {
if (!editorRef.value || !toolbarRef.value || isDestroyed) return;
try {
// wangEditor
const { createEditor, createToolbar } = await import('@wangeditor/editor');
const editorConfig = {
placeholder: '请输入内容...',
onChange: (editor: any) => {
if (!isDestroyed) {
const html = editor.getHtml();
emit('update:modelValue', html);
}
},
};
//
editorInstance = createEditor({
selector: editorRef.value,
html: props.modelValue || '',
config: editorConfig,
mode: 'default',
});
//
createToolbar({
editor: editorInstance,
selector: toolbarRef.value,
config: {},
});
} catch (error) {
console.error('Failed to initialize editor:', error);
}
};
//
watch(() => props.modelValue, (newVal) => {
if (editorInstance && newVal !== editorInstance.getHtml()) {
editorInstance.setHtml(newVal || '');
}
});
//
defineExpose({
clear: () => {
if (editorInstance) {
editorInstance.clear();
}
},
getContent: () => {
if (editorInstance) {
return editorInstance.getHtml();
}
return props.modelValue;
},
setContent: (content: string) => {
if (editorInstance) {
editorInstance.setHtml(content || '');
}
},
});
onMounted(() => {
nextTick(() => {
setTimeout(() => {
initEditor();
}, 100);
});
});
onBeforeUnmount(() => {
isDestroyed = true;
if (editorInstance) {
editorInstance.destroy();
editorInstance = null;
}
});
</script>
<style scoped>
.wang-editor-wrapper {
width: 100%;
border: 1px solid var(--border-color);
border-radius: var(--border-radius);
overflow: hidden;
}
.toolbar-container {
border-bottom: 1px solid var(--border-color);
}
.editor-container {
min-height: 400px;
}
</style>

View File

@ -14,6 +14,9 @@ import router from './router';
import { createPinia } from 'pinia';
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate';
// 导入全局组件
import WangEditor from '@/components/WangEditor.vue';
const app = createApp(App);
// 注册 Element Plus
@ -24,6 +27,9 @@ for (const [key, component] of Object.entries(Icons)) {
app.component(key, component);
}
// 全局注册 WangEditor 组件
app.component('WangEditor', WangEditor);
// 创建 Pinia 实例
const pinia = createPinia();
pinia.use(piniaPluginPersistedstate);

View File

@ -74,7 +74,7 @@ class DynamicRouteManager {
}
const menus = response.data;
// console.log('从数据库获取到菜单数据:', menus);
// // console.log('从数据库获取到菜单数据:', menus);
// 获取主布局路由
const mainRoute = router.getRoutes().find(route => route.name === 'main');
@ -90,7 +90,7 @@ class DynamicRouteManager {
}
this.routesLoaded = true;
// console.log("动态路由生成完成,共添加", menus.length, "个菜单路由");
// // console.log("动态路由生成完成,共添加", menus.length, "个菜单路由");
} catch (error) {
console.error("从数据库生成动态路由失败:", error);
// 即使失败也标记为已加载,避免重复尝试
@ -125,7 +125,48 @@ class DynamicRouteManager {
// 检查路由是否已存在
if (router.hasRoute(routeName)) {
// console.log(`路由已存在,跳过: ${menu.Path}`);
// // console.log(`路由已存在,跳过: ${menu.Path}`);
return;
}
// 特殊处理detail/edit 路由作为独立路由,全屏显示
if (menu.Path.includes('/detail') || menu.Path.includes('/edit')) {
// 尝试获取组件
const component = await this.getComponentForMenu(menu);
if (!component) {
console.warn(`未找到组件,跳过: ${menu.Path}`);
return;
}
// 保持原始路径格式(确保以 / 开头),并添加动态参数
let routePath = menu.Path;
if (!routePath.startsWith('/')) {
routePath = '/' + routePath;
}
// 检查路径中是否已包含动态参数,如果没有则添加 :id 参数
if (!routePath.includes(':id')) {
routePath = routePath + '/:id';
}
const childRoute = {
path: routePath,
name: routeName,
component: component,
props: true, // 启用 props 传递,使组件可以直接访问路由参数
meta: {
title: menu.Name,
fullScreen: true, // 标记为全屏路由
menuId: menu.Id,
path: menu.Path,
icon: menu.Icon,
isExternal: menu.IsExternal === 1,
externalUrl: menu.ExternalUrl,
parentId: menu.ParentId
},
};
router.addRoute('main', childRoute);
// console.log(`✅ 知识库路由已注册: ${routePath}`);
return;
}
@ -182,7 +223,7 @@ class DynamicRouteManager {
};
router.addRoute('main', childRoute);
// console.log(`✅ 动态路由已添加: ${menu.Path} -> ${menu.Name}`);
// // console.log(`✅ 动态路由已添加: ${menu.Path} -> ${menu.Name}`);
}
// 根据菜单信息获取组件

View File

@ -2,7 +2,9 @@
<div class="knowledge-detail">
<!-- 顶部标题栏 -->
<div class="detail-header">
<el-button type="text" icon="el-icon-arrow-left" @click="goBack">返回</el-button>
<el-button type="text" @click="goBack"
><i class="fas fa-arrow-left"></i> 返回</el-button
>
<h2>{{ knowledgeTitle }}</h2>
</div>
@ -11,23 +13,25 @@
<!-- 左侧信息面板 -->
<div class="info-panel">
<el-card shadow="never">
<div slot="header">
<div slot="header" class="header">
<span>基本信息</span>
</div>
<el-divider />
<el-form label-width="80px" size="small">
<el-form-item label="标题:">
<span>{{ formData.title }}</span>
</el-form-item>
<el-form-item label="分类:">
<el-tag size="mini">{{ formData.category }}</el-tag>
<el-tag size="small">{{ formData.category }}</el-tag>
</el-form-item>
<el-form-item label="标签:">
<el-tag
v-for="tag in formData.tags"
:key="tag"
size="mini"
size="small"
style="margin-right: 4px"
>{{ tag }}</el-tag>
>{{ tag }}</el-tag
>
</el-form-item>
<el-form-item label="作者:">
<span>{{ formData.author }}</span>
@ -39,13 +43,16 @@
<span>{{ formData.updateTime }}</span>
</el-form-item>
</el-form>
<el-form label-width="80px" size="small" align="center">
<el-divider />
<el-button type="primary" @click="handleEdit"
><i class="fas fa-edit"></i> 编辑</el-button
>
<el-button type="danger" @click="handleDelete"
><i class="fas fa-trash"></i> 删除</el-button
>
</el-form>
</el-card>
<!-- 操作按钮 -->
<div class="actions">
<el-button type="primary" icon="el-icon-edit" @click="handleEdit">编辑</el-button>
<el-button type="danger" icon="el-icon-delete" @click="handleDelete">删除</el-button>
</div>
</div>
<!-- 右侧内容面板 -->
@ -61,110 +68,154 @@
</div>
</template>
<script>
import { marked } from 'marked'
import { getKnowledgeDetail, deleteKnowledge } from '@/api/knowledge'
<script lang="ts" setup>
import { ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { marked } from "marked"
import { getKnowledgeDetail, deleteKnowledge } from "@/api/knowledge"
export default {
name: 'KnowledgeDetail',
props: {
id: {
type: String,
required: true
}
},
data() {
return {
knowledgeTitle: '知识详情',
formData: {
title: '',
category: '',
const route = useRoute()
const router = useRouter()
const knowledgeTitle = ref('知识详情')
interface FormData {
title: string,
category: string,
tags: string[],
author: string,
createTime: string,
updateTime: string,
content: string,
}
const formData = ref<FormData>({
title: "",
category: "",
tags: [],
author: '',
createTime: '',
updateTime: '',
content: ''
}
}
},
computed: {
compiledMarkdown() {
return marked(this.formData.content || '')
}
},
created() {
this.fetchDetail()
},
methods: {
//
async fetchDetail() {
author: "",
createTime: "",
updateTime: "",
content: "",
})
const id = computed(() => {
const queryId = route.query.id
const paramId = route.params.id
if (queryId) return Array.isArray(queryId) ? queryId[0] : queryId
if (paramId) return Array.isArray(paramId) ? paramId[0] : paramId
return ''
})
const compiledMarkdown = computed(() => marked(formData.value.content || ""))
function parseTags(tagsStr: string): string[] {
if (!tagsStr) return []
try {
const res = await getKnowledgeDetail(this.id)
this.formData = res.data
} catch (e) {
this.$message.error('获取详情失败')
const parsed = JSON.parse(tagsStr)
return Array.isArray(parsed) ? parsed : []
} catch {
return []
}
},
}
//
goBack() {
this.$router.push('/apps/knowledge')
},
//
handleEdit() {
this.$router.push(`/apps/knowledge/edit/${this.id}`)
},
//
handleDelete() {
this.$confirm('确认删除该知识?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(async () => {
async function fetchDetail() {
try {
await deleteKnowledge(this.id)
this.$message.success('删除成功')
this.goBack()
const idValue = id.value as string | number
const res = await getKnowledgeDetail(idValue)
const data = res.data
formData.value = {
title: data.title || '',
category: data.categoryName || '',
tags: parseTags(data.tags),
author: data.author || '',
createTime: data.createTime || '',
updateTime: data.updateTime || '',
content: data.content || '',
}
} catch (e) {
this.$message.error('删除失败')
ElMessage.error("获取详情失败")
}
}
onMounted(fetchDetail)
function goBack() {
router.push("/apps/knowledge")
}
function handleEdit() {
router.push({
name: "apps-knowledge-edit",
params: { id: id.value as string }
})
}
function handleDelete() {
ElMessageBox.confirm(
"确认删除该知识?",
"提示",
{
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "warning",
}
).then(async () => {
try {
const idValue = id.value as string | number
await deleteKnowledge(idValue)
ElMessage.success("删除成功")
goBack()
} catch (e) {
ElMessage.error("删除失败")
}
})
}
}
}
</script>
<style scoped>
<style scoped lang="scss">
/* 全屏显示,覆盖父容器 */
.knowledge-detail {
padding: 16px;
background: #f5f5f5;
min-height: 100vh;
position: fixed;
top: 81px; /* 减去 header 高度 */
left: 160px; /* 侧边栏宽度,如果收起的活是 64px */
right: 0;
bottom: 81px; /* 减去 footer 高度 */
z-index: 1000;
overflow-y: auto;
background: var(--background-color);
padding: 24px;
transition: left var(--transition-base);
}
.detail-header {
display: flex;
align-items: center;
margin-bottom: 16px;
background: #fff;
padding: 12px 16px;
border-radius: 4px;
margin-bottom: 20px;
background: var(--card-bg);
padding: 16px 24px;
border-radius: var(--border-radius-lg);
box-shadow: var(--shadow-sm);
border: 1px solid var(--border-color);
}
.detail-header h2 {
margin: 0 0 0 12px;
font-size: 18px;
font-weight: 500;
font-size: 20px;
font-weight: 600;
color: var(--text-color);
}
.detail-body {
display: flex;
gap: 16px;
gap: 20px;
/* padding: 0 24px 0 24px; */
}
.info-panel {
width: 320px;
flex-shrink: 0;
.header{
// margin-bottom: 14px;
}
}
.content-panel {
@ -179,5 +230,26 @@ export default {
.markdown-body {
font-size: 14px;
line-height: 1.8;
color: var(--text-color);
}
/* 响应式布局 */
@media (max-width: 768px) {
.detail-body {
flex-direction: column;
padding: 0 16px 16px;
}
.info-panel {
width: 100%;
}
.detail-header {
padding: 12px 16px;
}
.detail-header h2 {
font-size: 16px;
}
}
</style>

View File

@ -0,0 +1,462 @@
<template>
<div class="knowledge-edit">
<!-- 顶部标题栏 -->
<div class="edit-header">
<el-button type="text" @click="goBack"
><i class="fas fa-arrow-left"></i> 返回</el-button
>
<h2>{{ isEdit ? "编辑知识" : "新建知识" }}</h2>
</div>
<!-- 基本信息 -->
<div class="edit-meta">
<el-card shadow="never">
<el-form
:model="formData"
:rules="rules"
ref="formRef"
label-width="80px"
size="default"
>
<el-row :gutter="20">
<el-col :span="6">
<el-form-item label="标题:" prop="title">
<el-input v-model="formData.title" placeholder="请输入标题" />
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="分类:" prop="category">
<el-select
v-model="formData.category"
placeholder="请选择分类"
style="width: 100%"
@change="handleCategoryChange"
>
<el-option
v-for="item in categoryList"
:key="item.categoryId"
:label="item.categoryName"
:value="item.categoryName"
/>
</el-select>
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="标签:" prop="tags">
<el-select
v-model="formData.tags"
multiple
placeholder="请选择标签"
style="width: 100%"
allow-create
default-first-option
>
<el-option
v-for="tag in tagList"
:key="tag"
:label="tag"
:value="tag"
/>
</el-select>
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="作者:" prop="author">
<el-input v-model="formData.author" placeholder="请输入作者" />
</el-form-item>
</el-col>
</el-row>
</el-form>
<el-divider />
<div class="meta-actions">
<el-button type="primary" @click="handleSubmit"
><i class="fas fa-save"></i> 保存</el-button
>
<el-button @click="goBack">取消</el-button>
</div>
</el-card>
</div>
<!-- 正文内容区 - 左右结构 -->
<div class="edit-body">
<!-- 左侧编辑器 -->
<div class="editor-panel">
<el-card shadow="never">
<WangEditor v-model="formData.content" />
</el-card>
</div>
<!-- 右侧预览 -->
<div class="preview-panel">
<el-card shadow="never">
<div slot="header">
<span>预览效果</span>
</div>
<div class="markdown-body" v-html="compiledMarkdown"></div>
</el-card>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted, watch } from "vue";
import { useRouter, useRoute } from "vue-router";
import { marked } from "marked";
import {
getKnowledgeDetail,
updateKnowledge,
createKnowledge,
getCategoryList,
getTagList,
} from "@/api/knowledge";
import { ElMessage } from "element-plus";
import type { FormInstance, FormRules } from "element-plus";
// DOM
const formRef = ref<FormInstance>();
const router = useRouter();
const route = useRoute();
//
const formData = reactive<{
title: string;
category: string;
categoryId: number;
tags: string[];
author: string;
content: string;
}>({
title: "",
category: "",
categoryId: 0,
tags: [],
author: "",
content: "",
});
//
const rules = reactive<FormRules>({
title: [{ required: true, message: "请输入标题", trigger: "blur" }],
category: [{ required: true, message: "请选择分类", trigger: "change" }],
// tags: [
// { type: "array", required: true, message: "", trigger: "change" },
// ],
author: [{ required: true, message: "请输入作者", trigger: "blur" }],
content: [{ required: true, message: "请输入正文", trigger: "blur" }],
});
//
interface CategoryItem {
categoryId: number;
categoryName: string;
}
const categoryList = ref<CategoryItem[]>([]);
const tagList = ref<string[]>([]);
// id: 'new'
const id = computed(() => route.params.id || route.query.id);
const isEdit = computed(() => {
const currentId = id.value;
return !!currentId && currentId !== "new" && currentId !== "";
});
// markdown
const compiledMarkdown = computed(() => marked(formData.content || ""));
// TODO: wangEditor
// 使 textarea
watch(
() => formData.content,
() => {
//
}
);
//
const getLoginUser = () => {
const userStr = localStorage.getItem("user");
if (userStr) {
try {
const user = JSON.parse(userStr);
return user.username || user.name || user.userName || "";
} catch (e) {
return "";
}
}
return "";
};
//
const fetchDetail = async () => {
try {
const currentId = id.value as string;
if (currentId && currentId !== "new") {
const res = await getKnowledgeDetail(currentId);
const data = res.data;
//
// categoryName
formData.title = data.title || "";
formData.category = data.categoryName || "";
formData.categoryId = data.categoryId || 0;
formData.author = data.author || "";
formData.content = data.content || "";
// categoryId categoryName
if (!formData.categoryId && formData.category) {
const foundCategory = categoryList.value.find(
(item) => item.categoryName === formData.category
);
if (foundCategory) {
formData.categoryId = foundCategory.categoryId;
}
}
// Tags JSON
if (data.tags) {
try {
formData.tags = JSON.parse(data.tags);
} catch {
//
formData.tags = Array.isArray(data.tags) ? data.tags : [];
}
} else {
formData.tags = [];
}
}
} catch (e) {
ElMessage.error("获取详情失败");
}
};
//
const loadCategoryAndTag = async () => {
try {
const [catRes, tagResRes] = await Promise.all([
getCategoryList
? getCategoryList()
: Promise.resolve({ data: [] }),
getTagList ? getTagList() : Promise.resolve({ data: [] }),
]);
categoryList.value = catRes.data || [];
tagList.value = tagResRes.data || [];
} catch (e) {
categoryList.value = [];
tagList.value = [];
}
};
//
const handleCategoryChange = (categoryName: string) => {
const selectedCategory = categoryList.value.find(
(item) => item.categoryName === categoryName
);
if (selectedCategory) {
formData.categoryId = selectedCategory.categoryId;
}
};
//
const goBack = () => {
router.push("/apps/knowledge");
};
//
const handleSubmit = () => {
if (!formRef.value) return;
formRef.value.validate(async (valid: boolean) => {
if (!valid) return;
const currentId = id.value as string;
//
const submitData = {
id: isEdit.value ? parseInt(currentId as string) : 0,
title: formData.title,
categoryId: formData.categoryId,
author: formData.author,
content: formData.content,
tags: JSON.stringify(formData.tags), // JSON
status: 1, //
};
if (isEdit.value && currentId !== "new") {
//
try {
await updateKnowledge(currentId, submitData);
ElMessage.success("保存成功");
goBack();
} catch (e: any) {
ElMessage.error(e.message || "保存失败");
}
} else {
//
try {
await createKnowledge(submitData);
ElMessage.success("创建成功");
goBack();
} catch (e: any) {
ElMessage.error(e.message || "创建失败");
}
}
});
};
//
onMounted(async () => {
//
const author = getLoginUser();
if (author && !isEdit.value) {
formData.author = author;
}
//
await loadCategoryAndTag();
//
if (isEdit.value) {
await fetchDetail();
} else {
//
if (categoryList.value.length > 0 && !formData.category) {
formData.category = categoryList.value[0].categoryName;
formData.categoryId = categoryList.value[0].categoryId;
}
}
});
//
defineExpose({
formRef,
});
</script>
<style scoped>
.knowledge-edit {
position: fixed;
top: 81px;
left: 160px;
right: 0;
bottom: 81px;
z-index: 1000;
background: var(--background-color);
padding: 24px;
overflow-y: auto;
transition: left var(--transition-base);
}
.edit-header {
display: flex;
align-items: center;
margin-bottom: 20px;
background: var(--card-bg);
padding: 16px 24px;
border-radius: var(--border-radius-lg);
box-shadow: var(--shadow-sm);
border: 1px solid var(--border-color);
}
.edit-header h2 {
margin: 0 0 0 12px;
font-size: 20px;
font-weight: 600;
color: var(--text-color);
}
/* 基本信息区 */
.edit-meta {
margin-bottom: 20px;
}
.meta-actions {
margin-top: 16px;
text-align: right;
}
/* 正文编辑区 - 左右结构 */
.edit-body {
display: flex;
gap: 20px;
height: calc(100vh - 320px);
}
.editor-panel {
flex: 1;
min-width: 0;
}
.preview-panel {
flex: 1;
min-width: 0;
}
/* 编辑器容器样式 */
.editor-panel {
position: relative;
}
.markdown-body {
font-size: 14px;
line-height: 1.8;
color: var(--text-color);
min-height: 400px;
padding: 14px 16px;
}
.markdown-body :deep(h1),
.markdown-body :deep(h2),
.markdown-body :deep(h3),
.markdown-body :deep(h4),
.markdown-body :deep(h5),
.markdown-body :deep(h6) {
color: var(--text-color);
margin-top: 16px;
margin-bottom: 8px;
}
.markdown-body :deep(p) {
color: var(--text-secondary);
margin: 8px 0;
}
.markdown-body :deep(code) {
background-color: var(--background-hover);
color: var(--text-color);
padding: 2px 6px;
border-radius: 3px;
}
.markdown-body :deep(pre) {
background-color: var(--background-hover);
padding: 12px;
border-radius: var(--border-radius);
overflow-x: auto;
}
/* 响应式布局 */
@media (max-width: 768px) {
.edit-body {
flex-direction: column;
height: auto;
}
.editor-panel,
.preview-panel {
width: 100%;
}
.edit-header {
padding: 12px 16px;
}
.edit-header h2 {
font-size: 16px;
}
}
:deep() {
.el-form-item--default {
margin-bottom: 0px;
}
}
</style>

View File

@ -11,7 +11,7 @@
<el-select
v-model="searchType"
placeholder="知识库"
size="medium"
size="default"
class="search-select"
style="width: auto"
>
@ -20,13 +20,13 @@
<el-input
v-model="keyword"
placeholder="请输入关键字、产品编码进行查询"
size="medium"
size="default"
class="search-input"
@keyup.enter.native="handleSearch"
@keyup.enter="handleSearch"
/>
<el-button
type="primary"
size="medium"
size="default"
class="search-button"
@click="handleSearch"
>
@ -60,24 +60,13 @@
:key="index"
class="stat-col"
>
<div class="stat-card" :style="{ '--stat-color': stat.color }">
<div class="stat-card">
<div class="stat-icon">
<i :class="stat.icon"></i>
</div>
<div class="stat-info">
<div class="stat-label">{{ stat.label }}</div>
<div class="stat-value">{{ stat.value }}</div>
<div
class="stat-trend"
:class="stat.trend > 0 ? 'positive' : 'negative'"
>
<i class="el-icon-arrow-up" v-if="stat.trend > 0"></i>
<i class="el-icon-arrow-down" v-else-if="stat.trend < 0"></i>
<span
>{{ Math.abs(stat.trend) }}%
{{ stat.trend > 0 ? "增长" : "变化" }}</span
>
</div>
</div>
</div>
</el-col>
@ -106,21 +95,17 @@
<!-- 卡片头部 -->
<div class="repo-header">
<div class="repo-icon">
<i class="el-icon-folder"></i>
<i class="fa-solid fa-book"></i>
</div>
<div class="repo-title-container">
<h3 class="repo-name">{{ repo.name }}</h3>
<h3 class="repo-name">{{ repo.title }}</h3>
<div class="repo-meta">
<el-tag v-if="repo.isPrivate" size="mini" class="private-tag">
<i class="el-icon-lock"></i> 私有
<el-tag size="small" class="category-tag">
{{ repo.categoryName || '未分类' }}
</el-tag>
<span class="repo-owner">
<img
src="https://picsum.photos/seed/{{repo.creatorName}}/24/24"
alt="创建者"
class="owner-avatar"
/>
{{ repo.creatorName }}
<i class="fa-solid fa-user"></i>
{{ repo.author }}
</span>
</div>
</div>
@ -128,22 +113,29 @@
<!-- 卡片内容 -->
<div class="repo-content">
<p class="repo-description">
{{ repo.description || "暂无描述信息" }}
</p>
<div class="repo-tags" v-if="repo.tags">
<el-tag
v-for="tag in parseTags(repo.tags)"
:key="tag"
size="small"
class="tag-item"
>
{{ tag }}
</el-tag>
</div>
<div class="repo-stats">
<div class="stat-item">
<i class="el-icon-document"></i>
<span>{{ repo.docCount }} 文档</span>
<i class="fa-solid fa-eye"></i>
<span>{{ repo.viewCount || 0 }} 浏览</span>
</div>
<div class="stat-item">
<i class="el-icon-eye"></i>
<span>{{ Math.floor(Math.random() * 1000) + 100 }} 浏览</span>
<i class="fa-solid fa-heart"></i>
<span>{{ repo.likeCount || 0 }} 点赞</span>
</div>
<div class="stat-item">
<i class="el-icon-time"></i>
<span>3天前</span>
<i class="fa-solid fa-clock"></i>
<span>{{ formatDate(repo.createTime) }}</span>
</div>
</div>
</div>
@ -192,148 +184,224 @@
</div>
</template>
<script>
export default {
name: "KnowledgeHome",
data() {
return {
keyword: "",
filterType: "all",
searchType: "knowledge",
hotTags: ["化妆品", "汽车零部件", "口罩", "工业用品", "食品"],
stats: {
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import { ElMessage, ElMessageBox } from 'element-plus';
import { getKnowledgeList, deleteKnowledge } from '@/api/knowledge';
//
interface Knowledge {
id: number;
title: string;
content: string;
categoryName: string;
tags: string;
author: string;
viewCount: number;
likeCount: number;
createTime: string;
updateTime: string;
}
interface Stats {
total: number;
docCount: number;
memberCount: number;
viewCount: number;
}
interface StatItem {
label: string;
value: string | number;
icon: string;
}
//
const router = useRouter();
//
const keyword = ref('');
const searchType = ref('knowledge');
const hotTags = ref(['化妆品', '汽车零部件', '口罩', '工业用品', '食品']);
const stats = reactive<Stats>({
total: 0,
docCount: 0,
memberCount: 0,
viewCount: 0,
},
repoList: [],
total: 0,
pageSize: 12,
currentPage: 1,
};
},
computed: {
//
//
statsList() {
return [
});
const repoList = ref<Knowledge[]>([]);
const total = ref(0);
const pageSize = ref(12);
const currentPage = ref(1);
const loading = ref(false);
//
const statsList = computed<StatItem[]>(() => [
{
label: '知识库总数',
value: this.repoList.length,
icon: 'el-icon-document',
trend: '+12%'
value: repoList.value.length,
icon: 'fa fa-book',
},
{
label: '今日新增',
value: '12',
icon: 'el-icon-plus',
trend: '+8%'
icon: 'fa fa-plus',
},
{
label: '本周更新',
value: '28',
icon: 'el-icon-refresh',
trend: '+15%'
icon: 'fa fa-refresh',
},
{
label: '协作项目',
value: '6',
icon: 'el-icon-user',
trend: '+5%'
icon: 'fa fa-users',
},
]);
//
async function fetchStats() {
try {
//
const result = await getKnowledgeList({ page: 1, pageSize: 1 });
stats.total = result.total || 0;
// 使
stats.docCount = result.total || 0;
stats.memberCount = 0; //
stats.viewCount = 0; //
} catch (error: any) {
console.error('获取统计数据失败:', error);
}
];
},
},
created() {
this.fetchStats();
this.fetchRepoList();
},
methods: {
//
async fetchStats() {
//
this.stats = {
total: 42,
docCount: 1280,
memberCount: 256,
viewCount: 10240,
};
},
//
async fetchRepoList() {
//
this.repoList = [
{
id: 1,
name: "产品手册",
description: "公司产品相关文档、使用说明、FAQ",
isPrivate: false,
creatorName: "张三",
docCount: 56,
},
{
id: 2,
name: "技术规范",
description: "前后端开发规范、接口文档、部署手册",
isPrivate: true,
creatorName: "李四",
docCount: 128,
},
{
id: 3,
name: "运营资料",
description: "市场活动、用户运营、数据分析",
isPrivate: false,
creatorName: "王五",
docCount: 89,
},
];
this.total = 3;
},
//
handleSearch() {
this.currentPage = 1;
this.fetchRepoList();
},
//
handlePageChange(page) {
this.currentPage = page;
this.fetchRepoList();
},
//
handleCreate() {
this.$message.success("新建知识库");
},
//
handleView(repo) {
// 使ID
this.$router.push({
name: 'apps-knowledge-components-detail',
params: { id: repo.id }
}
function handleSearch() {
currentPage.value = 1;
fetchRepoList();
}
function handlePageChange(page: number) {
currentPage.value = page;
fetchRepoList();
}
// fetchRepoList
async function fetchRepoList() {
loading.value = true;
try {
const result = await getKnowledgeList({
page: currentPage.value,
pageSize: pageSize.value,
status: 1, //
keyword: keyword.value, //
});
},
//
handleEdit(repo) {
this.$message.success(`编辑 ${repo.name}`);
},
//
handleDelete(repo) {
this.$confirm(`确认删除知识库「${repo.name}」?`, "提示", {
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "warning",
}).then(() => {
this.$message.success("删除成功");
repoList.value = result.list || [];
total.value = result.total || 0;
} catch (error: any) {
ElMessage.error(error.message || '获取知识库列表失败');
repoList.value = [];
total.value = 0;
} finally {
loading.value = false;
}
}
function handleCreate() {
//
router.push({
name: 'apps-knowledge-edit',
params: { id: 'new' },
});
},
//
handleHotSearch(tag) {
this.keyword = tag;
this.handleSearch();
},
},
};
}
function handleView(repo: Knowledge) {
// 使 params id
router.push({
name: 'apps-knowledge-detail',
params: { id: repo.id.toString() },
});
}
function handleEdit(repo: Knowledge) {
// 使 params id
router.push({
name: 'apps-knowledge-edit',
params: { id: repo.id.toString() },
});
}
function handleDelete(repo: Knowledge) {
ElMessageBox.confirm(`确认删除知识「${repo.title}」?`, '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
})
.then(async () => {
try {
await deleteKnowledge(repo.id);
ElMessage.success('删除成功');
fetchRepoList(); //
fetchStats(); //
} catch (error: any) {
ElMessage.error(error.message || '删除失败');
}
})
.catch(() => {
//
});
}
function handleHotSearch(tag: string) {
keyword.value = tag;
handleSearch();
}
//
function parseTags(tagsStr: string): string[] {
if (!tagsStr) return [];
try {
const parsed = JSON.parse(tagsStr);
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}
//
function formatDate(dateStr: string): string {
if (!dateStr) return '';
const date = new Date(dateStr);
const now = new Date();
const diff = now.getTime() - date.getTime();
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
if (days < 1) {
const hours = Math.floor(diff / (1000 * 60 * 60));
if (hours < 1) {
const minutes = Math.floor(diff / (1000 * 60));
return `${minutes}分钟前`;
}
return `${hours}小时前`;
} else if (days < 7) {
return `${days}天前`;
} else if (days < 30) {
const weeks = Math.floor(days / 7);
return `${weeks}周前`;
} else {
return date.toLocaleDateString('zh-CN');
}
}
//
onMounted(() => {
fetchStats();
fetchRepoList();
});
</script>
<style lang="scss" scoped>
@ -341,12 +409,13 @@ export default {
//
.hero.new-style {
background: linear-gradient(135deg, var(--primary-color), var(--info-color));
background: var(--card-bg1);
padding: 60px 20px;
margin-bottom: 30px;
position: relative;
overflow: hidden;
transition: var(--transition-base);
border-radius: 8px;
//
&::after {
@ -356,7 +425,7 @@ export default {
right: 0;
width: 500px;
height: 300px;
background-image: url('https://picsum.photos/seed/container/800/600');
// background-image: url('https://picsum.photos/seed/container/800/600');
background-size: cover;
background-position: right bottom;
opacity: 0.1;
@ -510,25 +579,6 @@ export default {
margin-bottom: 4px;
line-height: 1;
}
.stat-trend {
font-size: 12px;
display: flex;
align-items: center;
i {
margin-right: 4px;
font-size: 14px;
}
&.positive {
color: var(--success-color);
}
&.negative {
color: var(--error-color);
}
}
}
}
}
@ -536,6 +586,7 @@ export default {
//
.knowledge-repos {
margin-bottom: 40px;
padding-bottom: 40px;
.repos-header {
display: flex;
@ -647,17 +698,17 @@ export default {
.repo-content {
padding: 16px 20px;
.repo-description {
font-size: 14px;
color: var(--text-secondary);
line-height: 1.5;
margin: 0 0 16px 0;
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
height: 42px;
.repo-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 16px;
.tag-item {
background: var(--background-hover);
color: var(--text-color);
border-color: var(--border-color);
}
}
.repo-stats {

View File

@ -18,19 +18,12 @@
circle
size="small"
/>
<button
class="menu-toggle"
@click="toggleSidebar"
aria-label="Toggle menu"
>
<svg-icon name="menu" />
</button>
<h1 class="page-title">Dashboard</h1>
</div>
<div class="header-right">
<div class="search-bar">
<svg-icon name="search" class="search-icon" />
<i class="fas fa-search search-icon"></i>
<input
type="text"
placeholder="Search..."
@ -172,18 +165,6 @@ const isDark = computed(() => theme.isDark());
align-items: center;
gap: 1.5rem;
.menu-toggle {
background: none;
border: none;
cursor: pointer;
color: var(--text-color);
transition: var(--transition-fast);
&:hover {
color: var(--primary-color);
}
}
.page-title {
margin: 0;
font-size: 1.25rem;
@ -219,6 +200,7 @@ const isDark = computed(() => theme.isDark());
.search-icon {
color: var(--text-secondary);
margin-right: 0.5rem;
font-size: 14px;
}
input {

View File

@ -207,9 +207,9 @@ const handleMenuClick = (path: string) => {
//
const loadMenuData = async () => {
try {
console.log("开始加载主侧边栏菜单数据...");
// console.log("...");
const response = await menuAPI.getTopLevelMenus();
console.log("主侧边栏菜单API响应:", response);
// console.log("API:", response);
if (response && response.success) {
// 使 data
@ -222,7 +222,7 @@ const loadMenuData = async () => {
icon: item.Icon,
children: item.Children || [],
})) || [];
console.log("主侧边栏菜单数据加载成功:", menuData.value);
// console.log(":", menuData.value);
} else {
console.error("获取菜单数据失败:", response?.message || "未知错误");
menuData.value = [];

View File

@ -48,7 +48,7 @@
</template>
<script setup lang="ts">
import { ref, onMounted, watch, computed, onActivated } from "vue";
import { ref, shallowRef, onMounted, watch, computed, onActivated, markRaw } from "vue";
import { useRoute, useRouter } from "vue-router";
import SubSidebar from "@/views/components/sub-sidebar.vue";
import { menuAPI } from "@/services/api";
@ -83,7 +83,7 @@ const pathToMenuIdMap = ref<Record<string, number>>({});
//
const currentSubModule = ref<MenuItem | null>(null);
const dynamicComponent = ref<any>(null);
const dynamicComponent = shallowRef<any>(null); // 使 shallowRef
const componentLoading = ref(false);
const loading = ref(false);
const error = ref("");
@ -107,7 +107,7 @@ const initMenuMap = async () => {
}
});
pathToMenuIdMap.value = map;
console.log("路径-ID映射表:", map);
// console.log("-ID:", map);
}
} catch (err: any) {
console.error("初始化菜单映射失败:", err.message);
@ -127,7 +127,7 @@ const computedParentId = computed(() => {
if (route.query.id) {
const queryId = Number(route.query.id);
if (!isNaN(queryId) && queryId > 0) {
console.log("从路由参数获取父ID:", queryId);
// console.log("ID:", queryId);
return queryId;
}
}
@ -142,24 +142,24 @@ const computedParentId = computed(() => {
if (firstLevelPath) {
const menuId = getMenuIdByPath(firstLevelPath);
if (menuId > 0) {
console.log(`从路径 ${firstLevelPath} 匹配父ID:`, menuId);
// // console.log(` ${firstLevelPath} ID:`, menuId);
return menuId;
}
}
}
// 3. 0
console.log("未匹配到父菜单ID返回0");
// console.log("ID0");
return 0;
});
// ID
const fetchSubMenuItems = async () => {
console.log('开始获取二级菜单数据:', {
parentId: computedParentId.value,
currentPath: route.path,
currentQuery: route.query
});
// console.log(':', {
// parentId: computedParentId.value,
// currentPath: route.path,
// currentQuery: route.query
// });
loading.value = true;
error.value = "";
@ -167,14 +167,14 @@ const fetchSubMenuItems = async () => {
try {
const parentId = computedParentId.value;
if (parentId === 0) {
console.log('父ID为0清空菜单数据');
// console.log('ID0');
subMenuItems.value = [];
loading.value = false;
return;
}
const response = await menuAPI.getMenusByParentId(parentId);
console.log('获取菜单数据响应:', response);
// console.log(':', response);
if (response && response.success) {
subMenuItems.value = response.data
@ -188,7 +188,7 @@ const fetchSubMenuItems = async () => {
}))
.filter((item: any) => item.path && item.title);
console.log('处理后的二级菜单数据:', subMenuItems.value);
// console.log(':', subMenuItems.value);
//
await selectDefaultMenuItem();
@ -201,20 +201,20 @@ const fetchSubMenuItems = async () => {
console.error('获取菜单数据异常:', err);
} finally {
loading.value = false;
console.log('获取二级菜单数据完成');
// console.log('');
}
};
const selectDefaultMenuItem = async () => {
console.log('开始选择默认菜单项:', {
currentPath: route.path,
subMenuItems: subMenuItems.value
});
// console.log(':', {
// currentPath: route.path,
// subMenuItems: subMenuItems.value
// });
//
const matchedItem = subMenuItems.value.find(item => item.path === route.path);
if (matchedItem) {
console.log('找到匹配的菜单项:', matchedItem);
// console.log(':', matchedItem);
currentSubModule.value = matchedItem;
await loadDynamicComponent(matchedItem);
return;
@ -223,28 +223,28 @@ const selectDefaultMenuItem = async () => {
//
if (subMenuItems.value.length > 0) {
const firstItem = subMenuItems.value[0];
console.log('未找到匹配项,选择第一个菜单项:', firstItem);
// console.log(':', firstItem);
currentSubModule.value = firstItem;
//
router.push(firstItem.path);
//
await loadDynamicComponent(firstItem);
} else {
console.log('没有可用的菜单项');
// console.log('');
}
};
const checkComponentExists = async (componentPath: string): Promise<boolean> => {
try {
console.log('检查组件是否存在:', componentPath);
// console.log(':', componentPath);
// 使Viteimport.meta.glob
const modules = import.meta.glob('@/views/**/*.vue');
console.log('可用模块:', Object.keys(modules));
// console.log(':', Object.keys(modules));
//
const normalizedPath = componentPath.replace('@', '/src');
const exists = normalizedPath in modules;
console.log('组件存在检查结果:', { componentPath, normalizedPath, exists });
// console.log(':', { componentPath, normalizedPath, exists });
return exists;
} catch (error) {
@ -254,12 +254,12 @@ const checkComponentExists = async (componentPath: string): Promise<boolean> =>
};
const loadDynamicComponent = async (menuItem: MenuItem) => {
console.log('开始加载动态组件:', menuItem);
// console.log(':', menuItem);
if (!menuItem.componentPath) {
error.value = "未指定组件路径";
dynamicComponent.value = null;
console.log('组件路径为空');
// console.log('');
return;
}
@ -269,8 +269,8 @@ const loadDynamicComponent = async (menuItem: MenuItem) => {
try {
//
if (loadedComponents[menuItem.componentPath]) {
console.log('组件已缓存,直接使用:', menuItem.componentPath);
dynamicComponent.value = loadedComponents[menuItem.componentPath];
// console.log('使:', menuItem.componentPath);
dynamicComponent.value = markRaw(loadedComponents[menuItem.componentPath]);
loading.value = false;
return;
}
@ -281,7 +281,7 @@ const loadDynamicComponent = async (menuItem: MenuItem) => {
throw new Error(`组件文件不存在: ${menuItem.componentPath}`);
}
console.log('开始动态导入组件:', menuItem.componentPath);
// console.log(':', menuItem.componentPath);
// 使Viteimport.meta.glob
const modules = import.meta.glob('@/views/**/*.vue');
@ -289,24 +289,24 @@ const loadDynamicComponent = async (menuItem: MenuItem) => {
if (modules[normalizedPath]) {
const module = await modules[normalizedPath]();
console.log('动态导入完成:', module);
// console.log(':', module);
//
let component = null;
if (module.default) {
component = module.default;
console.log('使用默认导出组件');
// console.log('使');
} else if (Object.keys(module).length === 1) {
// 使
const key = Object.keys(module)[0];
component = module[key];
console.log('使用单一导出组件:', key);
// console.log('使:', key);
} else {
//
for (const key of Object.keys(module)) {
if (typeof module[key] === 'object' && module[key]?.__name) {
component = module[key];
console.log('找到命名组件:', key);
// console.log(':', key);
break;
}
}
@ -316,10 +316,12 @@ const loadDynamicComponent = async (menuItem: MenuItem) => {
throw new Error(`无法加载组件: ${menuItem.componentPath}`);
}
console.log('组件加载成功:', component);
// console.log(':', component);
// 使 markRaw
const rawComponent = markRaw(component);
//
loadedComponents[menuItem.componentPath] = component;
dynamicComponent.value = component;
loadedComponents[menuItem.componentPath] = rawComponent;
dynamicComponent.value = rawComponent;
} else {
throw new Error(`无法找到组件模块: ${menuItem.componentPath}`);
}
@ -347,7 +349,7 @@ const retry = () => {
watch(
() => [route.path, route.query.id],
(newVal, oldVal) => {
console.log('路由变化监听:', { newVal, oldVal });
// console.log(':', { newVal, oldVal });
fetchSubMenuItems();
},
{ immediate: true }
@ -357,7 +359,7 @@ watch(
watch(
() => route.name,
(newName, oldName) => {
console.log('路由名称变化监听:', { newName, oldName });
// console.log(':', { newName, oldName });
if (newName !== oldName) {
fetchSubMenuItems();
}
@ -380,27 +382,27 @@ onMounted(async () => {
// keep-alive
onActivated(async () => {
console.log('onActivated被调用:', {
routePath: route.path,
currentSubModulePath: currentSubModule.value?.path,
subMenuItems: subMenuItems.value
});
// console.log('onActivated:', {
// routePath: route.path,
// currentSubModulePath: currentSubModule.value?.path,
// subMenuItems: subMenuItems.value
// });
//
if (route.path !== currentSubModule.value?.path) {
console.log('路由路径发生变化,重新获取菜单数据');
// console.log('');
await fetchSubMenuItems();
//
if (route.path && subMenuItems.value.length > 0) {
const menuItem = subMenuItems.value.find(item => item.path === route.path);
if (menuItem) {
console.log('加载动态组件:', menuItem);
// console.log(':', menuItem);
await loadDynamicComponent(menuItem);
}
}
} else {
console.log('路由路径未发生变化,无需重新加载');
// console.log('');
}
});
</script>

View File

@ -0,0 +1,366 @@
package controllers
import (
"encoding/json"
"server/models"
"strconv"
beego "github.com/beego/beego/v2/server/web"
)
// KnowledgeController 知识库控制器
type KnowledgeController struct {
beego.Controller
}
// List 获取知识列表
// @router /api/knowledge/list [get]
func (c *KnowledgeController) List() {
// 获取查询参数
page, _ := c.GetInt("page", 1)
pageSize, _ := c.GetInt("pageSize", 10)
status, _ := c.GetInt8("status", -1) // -1表示查询所有
categoryId, _ := c.GetInt("categoryId", 0)
keyword := c.GetString("keyword", "")
knowledges, total, err := models.GetAllKnowledge(page, pageSize, status, categoryId, keyword)
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": "success",
"data": map[string]interface{}{
"list": knowledges,
"total": total,
"page": page,
"pageSize": pageSize,
},
}
c.ServeJSON()
}
// Detail 获取知识详情
// @router /api/knowledge/detail [get]
func (c *KnowledgeController) Detail() {
id, _ := strconv.Atoi(c.GetString("id"))
if id == 0 {
c.Data["json"] = map[string]interface{}{
"code": 1,
"message": "知识ID不能为空",
"data": nil,
}
c.ServeJSON()
return
}
knowledge, err := models.GetKnowledgeById(id)
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": "success",
"data": knowledge,
}
c.ServeJSON()
}
// Create 创建知识
// @router /api/knowledge/create [post]
func (c *KnowledgeController) Create() {
// 解析请求体
var knowledge models.Knowledge
err := json.Unmarshal(c.Ctx.Input.RequestBody, &knowledge)
if err != nil {
c.Data["json"] = map[string]interface{}{
"code": 1,
"message": "请求参数格式错误",
"data": nil,
}
c.ServeJSON()
return
}
// 验证必填字段
if knowledge.Title == "" {
c.Data["json"] = map[string]interface{}{
"code": 1,
"message": "标题不能为空",
"data": nil,
}
c.ServeJSON()
return
}
id, err := models.AddKnowledge(&knowledge)
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{}{"id": id},
}
c.ServeJSON()
}
// Update 更新知识
// @router /api/knowledge/update [post]
func (c *KnowledgeController) Update() {
// 解析请求体
var knowledge models.Knowledge
err := json.Unmarshal(c.Ctx.Input.RequestBody, &knowledge)
if err != nil {
c.Data["json"] = map[string]interface{}{
"code": 1,
"message": "请求参数格式错误",
"data": nil,
}
c.ServeJSON()
return
}
// 验证必填字段
if knowledge.Id == 0 {
c.Data["json"] = map[string]interface{}{
"code": 1,
"message": "知识ID不能为空",
"data": nil,
}
c.ServeJSON()
return
}
err = models.UpdateKnowledge(knowledge.Id, &knowledge)
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": nil,
}
c.ServeJSON()
}
// Delete 软删除知识
// @router /api/knowledge/delete [post]
func (c *KnowledgeController) Delete() {
var request map[string]interface{}
err := json.Unmarshal(c.Ctx.Input.RequestBody, &request)
if err != nil {
c.Data["json"] = map[string]interface{}{
"code": 1,
"message": "请求参数格式错误",
"data": nil,
}
c.ServeJSON()
return
}
id, ok := request["id"].(float64)
if !ok {
c.Data["json"] = map[string]interface{}{
"code": 1,
"message": "知识ID不能为空",
"data": nil,
}
c.ServeJSON()
return
}
// 从请求头或 session 中获取当前用户信息
deleteBy := c.GetString("username")
if deleteBy == "" {
deleteBy = c.GetString("user")
}
if deleteBy == "" {
deleteBy = "system" // 默认值
}
err = models.DeleteKnowledge(int(id), deleteBy)
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": nil,
}
c.ServeJSON()
}
// GetCategories 获取分类列表
// @router /api/knowledge/categories [get]
func (c *KnowledgeController) GetCategories() {
categories, err := models.GetAllCategories()
if err != nil {
c.Data["json"] = map[string]interface{}{
"code": 1,
"message": "获取分类列表失败: " + err.Error(),
"data": nil,
}
c.ServeJSON()
return
}
// 手动转换数据格式,确保字段名正确
var result []map[string]interface{}
for _, cat := range categories {
result = append(result, map[string]interface{}{
"categoryId": cat.CategoryId,
"categoryName": cat.CategoryName,
"categoryDesc": cat.CategoryDesc,
"parentId": cat.ParentId,
"sortOrder": cat.SortOrder,
"createTime": cat.CreateTime,
"updateTime": cat.UpdateTime,
})
}
c.Data["json"] = map[string]interface{}{
"code": 0,
"message": "success",
"data": result,
}
c.ServeJSON()
}
// GetTags 获取标签列表
// @router /api/knowledge/tags [get]
func (c *KnowledgeController) GetTags() {
tags, err := models.GetAllTags()
if err != nil {
c.Data["json"] = map[string]interface{}{
"code": 1,
"message": "获取标签列表失败: " + err.Error(),
"data": nil,
}
c.ServeJSON()
return
}
// 手动转换数据格式,确保字段名正确
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,
})
}
c.Data["json"] = map[string]interface{}{
"code": 0,
"message": "success",
"data": result,
}
c.ServeJSON()
}
// AddCategory 添加分类
// @router /api/knowledge/category/add [post]
func (c *KnowledgeController) AddCategory() {
var category models.KnowledgeCategory
err := json.Unmarshal(c.Ctx.Input.RequestBody, &category)
if err != nil {
c.Data["json"] = map[string]interface{}{
"code": 1,
"message": "请求参数格式错误",
"data": nil,
}
c.ServeJSON()
return
}
id, err := models.AddCategory(&category)
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{}{"categoryId": id},
}
c.ServeJSON()
}
// AddTag 添加标签
// @router /api/knowledge/tag/add [post]
func (c *KnowledgeController) AddTag() {
var tag models.KnowledgeTag
err := json.Unmarshal(c.Ctx.Input.RequestBody, &tag)
if err != nil {
c.Data["json"] = map[string]interface{}{
"code": 1,
"message": "请求参数格式错误",
"data": nil,
}
c.ServeJSON()
return
}
id, err := models.AddTag(&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": id},
}
c.ServeJSON()
}

View File

@ -0,0 +1,59 @@
# 知识库数据库表创建说明
## 创建步骤
### 方法1执行独立的 SQL 文件(推荐)
```bash
# 在 MySQL 中执行
mysql -u root -p your_database < server/database/create_knowledge_tables.sql
```
### 方法2手动执行 SQL
```sql
-- 1. 进入 MySQL
mysql -u root -p your_database
-- 2. 执行 SQL 脚本
SOURCE server/database/create_knowledge_tables.sql;
```
### 方法3在 MySQL 客户端中复制粘贴
直接打开 `server/database/create_knowledge_tables.sql` 文件,复制所有内容,在 MySQL 客户端中执行。
## 创建的表
1. **yz_knowledge_category** - 知识库分类表
2. **yz_knowledge_tags** - 知识库标签表
3. **yz_knowledge** - 知识库内容表
## 默认数据
- 5 个分类技术文档、产品手册、用户指南、常见问题、API文档
- 8 个标签Vue、React、TypeScript、Element Plus、Vue Router、Pinia、Go、Python
## 验证创建
```sql
-- 查看所有知识库相关表
SHOW TABLES LIKE 'yz_knowledge%';
-- 查看知识库表结构
DESC yz_knowledge;
-- 查看数据
SELECT * FROM yz_knowledge;
SELECT * FROM yz_knowledge_category;
SELECT * FROM yz_knowledge_tags;
```
## 如果表已存在
如果想重新创建表(会清空现有数据):
```sql
DROP TABLE IF EXISTS yz_knowledge_favorites;
DROP TABLE IF EXISTS yz_knowledge;
DROP TABLE IF EXISTS yz_knowledge_tags;
DROP TABLE IF EXISTS yz_knowledge_category;
```
然后再执行创建脚本。

View File

@ -0,0 +1 @@

View File

@ -162,13 +162,108 @@ INSERT INTO yz_menus (name, path, parent_id, icon, `order`, status, component_pa
('系统设置', '/settings', 0, 'fa-solid fa-gear', 99, 1, '@/views/settings/index.vue', 1, '系统参数设置'),
('应用管理', '/apps', 0, 'fa-solid fa-gear', 4, 1, null, 2, '应用管理功能模块'),
('文件管理', '/system/files', 2, 'fa-solid fa-folder', 3, 1, '@/views/system/files/index.vue', 1, '文件管理系统'),
('租户管理', '/system/tenant', 2, 'fas fa-user-friends', 2, 1, '@/views/system/tenant/index.vue', 1, '租户管理'),
('用户管理', '/system/users', 2, 'fa-solid fa-user', 1, 1, '@/views/system/users/index.vue', 1, '用户信息管理'),
('角色管理', '/system/roles', 2, 'fa-solid fa-user-tag', 2, 1, '@/views/system/roles/index.vue', 1, '角色权限管理'),
('权限管理', '/system/permissions', 2, 'fa-solid fa-key', 2, 1, '@/views/system/permissions/index.vue', 1, '权限管理'),
('菜单管理', '/system/menus', 2, 'fa-solid fa-bars-progress', 2, 1, '@/views/system/menus/manager.vue', 1, '菜单权限管理'),
('程序管理', '/system/programs', 2, 'fa-solid fa-grip', 3, 1, '@/views/system/programs/index.vue', 1, '程序功能管理'),
('知识库', '/apps/knowledge', 4, 'fa-solid fa-book', 1, 1, '@/views/apps/knowledge/index.vue', 1, '知识库管理'),
('编辑', '/apps/knowledge/edit', 11, '', 1, 1, '@/views/apps/knowledge/components/edit.vue', 1, '知识库编辑'),
('详情', '/apps/knowledge/detail', 11, '', 1, 1, '@/views/apps/knowledge/components/detail.vue', 1, '知识库详情');
-- =============================================
-- 4. 知识库相关表
-- =============================================
-- 创建知识库分类表
CREATE TABLE IF NOT EXISTS yz_knowledge_category (
category_id INT PRIMARY KEY AUTO_INCREMENT COMMENT '分类ID',
category_name VARCHAR(100) NOT NULL COMMENT '分类名称',
category_desc VARCHAR(500) DEFAULT NULL COMMENT '分类描述',
parent_id INT DEFAULT 0 COMMENT '父分类ID0表示顶级分类',
sort_order INT DEFAULT 0 COMMENT '排序序号',
create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
INDEX idx_parent_id (parent_id),
INDEX idx_sort_order (sort_order)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='知识库分类表';
-- 创建知识库标签表
CREATE TABLE IF NOT EXISTS yz_knowledge_tags (
tag_id INT PRIMARY KEY AUTO_INCREMENT COMMENT '标签ID',
tag_name VARCHAR(50) NOT NULL COMMENT '标签名称',
tag_color VARCHAR(20) DEFAULT NULL COMMENT '标签颜色',
usage_count INT DEFAULT 0 COMMENT '使用次数',
create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
UNIQUE KEY uk_tag_name (tag_name),
INDEX idx_usage_count (usage_count)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='知识库标签表';
-- 创建知识库内容表
CREATE TABLE IF NOT EXISTS yz_knowledge (
knowledge_id INT PRIMARY KEY AUTO_INCREMENT COMMENT '知识ID',
title VARCHAR(200) NOT NULL COMMENT '标题',
category_id INT DEFAULT NULL COMMENT '分类ID',
tags TEXT COMMENT '标签JSON数组存储标签名称',
author VARCHAR(50) NOT NULL COMMENT '作者',
content LONGTEXT COMMENT '正文内容(富文本)',
summary VARCHAR(500) DEFAULT NULL COMMENT '摘要',
cover_url VARCHAR(500) DEFAULT NULL COMMENT '封面图片URL',
status TINYINT DEFAULT 0 COMMENT '状态0-草稿1-已发布2-已归档',
view_count INT DEFAULT 0 COMMENT '查看次数',
like_count INT DEFAULT 0 COMMENT '点赞数',
is_recommend TINYINT DEFAULT 0 COMMENT '是否推荐0-否1-是',
is_top TINYINT DEFAULT 0 COMMENT '是否置顶0-否1-是',
create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
create_by VARCHAR(50) DEFAULT NULL COMMENT '创建人',
update_by VARCHAR(50) DEFAULT NULL COMMENT '更新人',
INDEX idx_category_id (category_id),
INDEX idx_author (author),
INDEX idx_title (title),
INDEX idx_status (status),
INDEX idx_create_time (create_time),
INDEX idx_view_count (view_count),
INDEX idx_is_recommend (is_recommend),
INDEX idx_is_top (is_top)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='知识库内容表';
-- 创建知识库收藏表
CREATE TABLE IF NOT EXISTS yz_knowledge_favorites (
favorite_id INT PRIMARY KEY AUTO_INCREMENT COMMENT '收藏ID',
knowledge_id INT NOT NULL COMMENT '知识ID',
user_id INT NOT NULL COMMENT '用户ID',
create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
UNIQUE KEY uk_knowledge_user (knowledge_id, user_id),
INDEX idx_user_id (user_id),
CONSTRAINT yz_fk_fav_knowledge FOREIGN KEY (knowledge_id) REFERENCES yz_knowledge (knowledge_id) ON DELETE CASCADE,
CONSTRAINT yz_fk_fav_user FOREIGN KEY (user_id) REFERENCES yz_users (id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='知识库收藏表';
-- 插入默认知识库分类
INSERT INTO yz_knowledge_category (category_name, category_desc, parent_id, sort_order) VALUES
('技术文档', '技术相关的文档资料', 0, 1),
('产品手册', '产品使用手册和说明', 0, 2),
('用户指南', '用户操作指南和教程', 0, 3),
('常见问题', '常见问题解答', 0, 4),
('API文档', 'API接口文档', 0, 5),
('Vue', 'Vue框架相关', 1, 101),
('React', 'React框架相关', 1, 102),
('Go', 'Go语言相关', 1, 103);
-- 插入默认知识库标签
INSERT INTO yz_knowledge_tags (tag_name, tag_color, usage_count) VALUES
('Vue', '#4CAF50', 0),
('React', '#42A5F5', 0),
('TypeScript', '#3178C6', 0),
('Element Plus', '#409EFF', 0),
('Vue Router', '#4FC08D', 0),
('Pinia', '#FFD54F', 0),
('Go', '#00ADD8', 0),
('Python', '#3776AB', 0);
-- 插入默认程序分类
INSERT INTO yz_program_category (category_name, category_desc, parent_id, sort_order) VALUES
('办公软件', '办公相关程序', 0, 1),

View File

@ -0,0 +1,97 @@
# 知识库分类联查更新说明
## 更新内容
本次更新将 `yz_knowledge` 表中的 `category_name` 字段移除,改为通过联查获取分类名称。
## 数据库变更
### 移除的字段
`yz_knowledge` 表中移除 `category_name` 字段:
```sql
ALTER TABLE `yz_knowledge` DROP COLUMN `category_name`;
```
### 更新后的表结构
`yz_knowledge` 表现在只保留 `category_id` 字段,通过 `LEFT JOIN` 联查 `yz_knowledge_category` 表获取分类名称:
```sql
SELECT k.*, c.category_name
FROM yz_knowledge k
LEFT JOIN yz_knowledge_category c ON k.category_id = c.category_id
WHERE k.knowledge_id = ?
```
## Go 代码变更
### server/models/knowledge.go
1. **Knowledge 结构体**
- `CategoryName` 字段的 ORM 标签改为 `orm:"-"`,表示不在数据库中存储
- 从联查结果中获取分类名称
2. **GetKnowledgeById 方法**
- 使用 `LEFT JOIN` 联查获取分类名称
- 使用原生 SQL 查询替代 ORM 查询
3. **GetAllKnowledge 方法**
- 使用原生 SQL 查询进行联查
- 支持分页、状态筛选、分类筛选、关键词搜索
4. **UpdateKnowledge 方法**
- 移除了对 `category_name` 字段的更新操作
- 只更新 `category_id` 字段
## 前端代码变更
### front/src/views/apps/knowledge/components/detail.vue
- 将 `categoryName` 映射到显示用的 `category` 字段
- 添加 `parseTags` 方法解析标签数组
### front/src/views/apps/knowledge/components/edit.vue
- 接收数据时使用 `data.categoryName` 获取分类名称
- 提交数据时不需要传递 `categoryName`,只传递 `category_id`
## 使用说明
### 创建知识记录
创建知识记录时,只传递 `category_id`
```javascript
{
title: "标题",
category_id: 1, // 只需传递分类ID
// category_name 不需要传递,后台会从联查获取
}
```
### 获取知识详情
获取知识详情时,会自动联查获取 `category_name`
```javascript
{
id: 1,
title: "标题",
category_id: 1,
category_name: "技术文档", // 从联查获取
}
```
## 优势
1. **数据冗余消除**:分类名称不再冗余存储
2. **数据一致性**:分类名称始终与分类表保持一致
3. **易于维护**:修改分类名称时,所有知识的分类名称都会自动更新
## 注意事项
1. 确保 `yz_knowledge_category` 表存在并包含必要的分类数据
2. 所有知识记录的 `category_id` 必须存在于 `yz_knowledge_category` 表中
3. 如果某个知识没有分类(`category_id` 为 NULL联查返回的 `category_name` 将为 NULL

View File

@ -0,0 +1,94 @@
-- 创建知识库分类表
CREATE TABLE `yz_knowledge_category` (
`category_id` INT NOT NULL AUTO_INCREMENT COMMENT '分类ID',
`category_name` VARCHAR(100) NOT NULL COMMENT '分类名称',
`category_desc` VARCHAR(500) DEFAULT NULL COMMENT '分类描述',
`parent_id` INT DEFAULT 0 COMMENT '父分类ID0表示顶级分类',
`sort_order` INT DEFAULT 0 COMMENT '排序序号',
`create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`category_id`),
KEY `idx_parent_id` (`parent_id`),
KEY `idx_sort_order` (`sort_order`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='知识库分类表';
-- 创建知识库标签表
CREATE TABLE `yz_knowledge_tags` (
`tag_id` INT NOT NULL AUTO_INCREMENT COMMENT '标签ID',
`tag_name` VARCHAR(50) NOT NULL COMMENT '标签名称',
`tag_color` VARCHAR(20) DEFAULT NULL COMMENT '标签颜色',
`usage_count` INT DEFAULT 0 COMMENT '使用次数',
`create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`tag_id`),
UNIQUE KEY `uk_tag_name` (`tag_name`),
KEY `idx_usage_count` (`usage_count`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='知识库标签表';
-- 创建知识库内容表
CREATE TABLE `yz_knowledge` (
`knowledge_id` INT NOT NULL AUTO_INCREMENT COMMENT '知识ID',
`title` VARCHAR(200) NOT NULL COMMENT '标题',
`category_id` INT DEFAULT NULL COMMENT '分类ID',
`tags` TEXT COMMENT '标签JSON数组存储标签名称',
`author` VARCHAR(50) NOT NULL COMMENT '作者',
`content` LONGTEXT COMMENT '正文内容(富文本)',
`summary` VARCHAR(500) DEFAULT NULL COMMENT '摘要',
`cover_url` VARCHAR(500) DEFAULT NULL COMMENT '封面图片URL',
`status` TINYINT DEFAULT 0 COMMENT '状态0-草稿1-已发布2-已归档',
`view_count` INT DEFAULT 0 COMMENT '查看次数',
`like_count` INT DEFAULT 0 COMMENT '点赞数',
`is_recommend` TINYINT DEFAULT 0 COMMENT '是否推荐0-否1-是',
`is_top` TINYINT DEFAULT 0 COMMENT '是否置顶0-否1-是',
`create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
`create_by` VARCHAR(50) DEFAULT NULL COMMENT '创建人',
`update_by` VARCHAR(50) DEFAULT NULL COMMENT '更新人',
PRIMARY KEY (`knowledge_id`),
KEY `idx_category_id` (`category_id`),
KEY `idx_author` (`author`),
KEY `idx_status` (`status`),
KEY `idx_create_time` (`create_time`),
KEY `idx_view_count` (`view_count`),
KEY `idx_is_recommend` (`is_recommend`),
KEY `idx_is_top` (`is_top`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='知识库内容表';
-- 创建知识库收藏表
CREATE TABLE `yz_knowledge_favorites` (
`favorite_id` INT NOT NULL AUTO_INCREMENT COMMENT '收藏ID',
`knowledge_id` INT NOT NULL COMMENT '知识ID',
`user_id` INT NOT NULL COMMENT '用户ID',
`create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
PRIMARY KEY (`favorite_id`),
UNIQUE KEY `uk_knowledge_user` (`knowledge_id`, `user_id`),
KEY `idx_user_id` (`user_id`),
CONSTRAINT `yz_fk_fav_knowledge` FOREIGN KEY (`knowledge_id`) REFERENCES `yz_knowledge` (`knowledge_id`) ON DELETE CASCADE,
CONSTRAINT `yz_fk_fav_user` FOREIGN KEY (`user_id`) REFERENCES `yz_users` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='知识库收藏表';
-- 插入默认分类
INSERT INTO `yz_knowledge_category` (`category_name`, `category_desc`, `parent_id`, `sort_order`) VALUES
('技术文档', '技术相关的文档资料', 0, 1),
('产品手册', '产品使用手册和说明', 0, 2),
('用户指南', '用户操作指南和教程', 0, 3),
('常见问题', '常见问题解答', 0, 4),
('API文档', 'API接口文档', 0, 5),
('Vue', 'Vue框架相关', 1, 101),
('React', 'React框架相关', 1, 102),
('Go', 'Go语言相关', 1, 103);
-- 插入默认标签
INSERT INTO `yz_knowledge_tags` (`tag_name`, `tag_color`, `usage_count`) VALUES
('Vue', '#4CAF50', 0),
('React', '#42A5F5', 0),
('TypeScript', '#3178C6', 0),
('Element Plus', '#409EFF', 0),
('Vue Router', '#4FC08D', 0),
('Pinia', '#FFD54F', 0),
('Go', '#00ADD8', 0),
('Python', '#3776AB', 0);
--
INSERT INTO `yz_knowledge` (`title`, `category_id`, `tags`, `author`, `content`, `summary`, `cover_url`, `status`, `view_count`, `like_count`, `is_recommend`, `is_top`, `create_time`, `update_time`, `create_by`, `update_by`) VALUES
('Vue', 1, 'Vue', 'admin', 'Vue is a progressive framework for building user interfaces.', 'Vue is a progressive framework for building user interfaces.', 'https://vuejs.org/images/logo.png', 1, 0, 0, 0, 0, '2021-01-01 00:00:00', '2021-01-01 00:00:00', 'admin', 'admin');

318
server/models/knowledge.go Normal file
View File

@ -0,0 +1,318 @@
package models
import (
"time"
"github.com/beego/beego/v2/client/orm"
)
// Knowledge 知识库模型
type Knowledge struct {
Id int `orm:"column(knowledge_id);pk;auto" json:"id"`
Title string `orm:"column(title);size(200)" json:"title"`
CategoryId int `orm:"column(category_id);default(0);null" json:"categoryId"`
CategoryName string `orm:"-" json:"categoryName"` // 不映射到数据库,从联查获取
Tags string `orm:"column(tags);type(text);null" json:"tags"`
Author string `orm:"column(author);size(50)" json:"author"`
Content string `orm:"column(content);type(longtext)" json:"content"`
Summary string `orm:"column(summary);size(500);null" json:"summary"`
CoverUrl string `orm:"column(cover_url);size(500);null" json:"coverUrl"`
Status int8 `orm:"column(status);default(0)" json:"status"`
ViewCount int `orm:"column(view_count);default(0)" json:"viewCount"`
LikeCount int `orm:"column(like_count);default(0)" json:"likeCount"`
IsRecommend int8 `orm:"column(is_recommend);default(0)" json:"isRecommend"`
IsTop int8 `orm:"column(is_top);default(0)" json:"isTop"`
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"`
CreateBy string `orm:"column(create_by);size(50);null" json:"createBy"`
UpdateBy string `orm:"column(update_by);size(50);null" json:"updateBy"`
DeleteTime time.Time `orm:"column(delete_time);type(datetime);null" json:"deleteTime"`
DeleteBy string `orm:"column(delete_by);size(50);null" json:"deleteBy"`
}
// TableName 设置表名
func (k *Knowledge) TableName() string {
return "yz_knowledge"
}
// KnowledgeCategory 知识库分类模型
type KnowledgeCategory struct {
CategoryId int `orm:"column(category_id);pk;auto" json:"categoryId"`
CategoryName string `orm:"column(category_name);size(100)" json:"categoryName"`
CategoryDesc string `orm:"column(category_desc);size(500);null" json:"categoryDesc"`
ParentId int `orm:"column(parent_id);default(0)" json:"parentId"`
SortOrder int `orm:"column(sort_order);default(0)" json:"sortOrder"`
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 设置表名
func (kc *KnowledgeCategory) TableName() string {
return "yz_knowledge_category"
}
// 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"`
}
// TableName 设置表名
func (kt *KnowledgeTag) TableName() string {
return "yz_knowledge_tags"
}
// AddKnowledge 创建知识
func AddKnowledge(k *Knowledge) (int64, error) {
o := orm.NewOrm()
id, err := o.Insert(k)
return id, err
}
// GetKnowledgeById 根据ID获取知识详情使用联查获取分类名称
func GetKnowledgeById(id int) (*Knowledge, error) {
o := orm.NewOrm()
// 使用联查获取分类名称(只查询未删除的记录)
querySQL := `
SELECT k.*, c.category_name
FROM yz_knowledge k
LEFT JOIN yz_knowledge_category c ON k.category_id = c.category_id
WHERE k.knowledge_id = ? AND k.delete_time IS NULL
`
var result struct {
Id int `orm:"column(knowledge_id)"`
Title string `orm:"column(title)"`
CategoryId int `orm:"column(category_id)"`
CategoryName string `orm:"column(category_name)"`
Tags string `orm:"column(tags)"`
Author string `orm:"column(author)"`
Content string `orm:"column(content)"`
Summary string `orm:"column(summary)"`
CoverUrl string `orm:"column(cover_url)"`
Status int8 `orm:"column(status)"`
ViewCount int `orm:"column(view_count)"`
LikeCount int `orm:"column(like_count)"`
IsRecommend int8 `orm:"column(is_recommend)"`
IsTop int8 `orm:"column(is_top)"`
CreateTime time.Time `orm:"column(create_time)"`
UpdateTime time.Time `orm:"column(update_time)"`
CreateBy string `orm:"column(create_by)"`
UpdateBy string `orm:"column(update_by)"`
}
err := o.Raw(querySQL, id).QueryRow(&result)
if err != nil {
return nil, err
}
knowledge := &Knowledge{
Id: result.Id,
Title: result.Title,
CategoryId: result.CategoryId,
CategoryName: result.CategoryName,
Tags: result.Tags,
Author: result.Author,
Content: result.Content,
Summary: result.Summary,
CoverUrl: result.CoverUrl,
Status: result.Status,
ViewCount: result.ViewCount,
LikeCount: result.LikeCount,
IsRecommend: result.IsRecommend,
IsTop: result.IsTop,
CreateTime: result.CreateTime,
UpdateTime: result.UpdateTime,
CreateBy: result.CreateBy,
UpdateBy: result.UpdateBy,
}
// 增加查看次数
knowledge.ViewCount++
_, err = o.Raw("UPDATE yz_knowledge SET view_count = ? WHERE knowledge_id = ?", knowledge.ViewCount, id).Exec()
return knowledge, err
}
// GetAllKnowledge 获取所有知识(支持分页和筛选)
func GetAllKnowledge(page, pageSize int, status int8, categoryId int, keyword string) ([]*Knowledge, int64, error) {
o := orm.NewOrm()
// 构建 WHERE 条件(只查询未删除的记录)
whereSQL := "delete_time IS NULL"
params := []interface{}{}
// 状态筛选
if status >= 0 {
whereSQL += " AND status = ?"
params = append(params, status)
}
// 分类筛选
if categoryId > 0 {
whereSQL += " AND category_id = ?"
params = append(params, categoryId)
}
// 关键词搜索
if keyword != "" {
whereSQL += " AND title LIKE ?"
params = append(params, "%"+keyword+"%")
}
// 获取总数
var total int64
countSQL := "SELECT COUNT(*) FROM yz_knowledge WHERE " + whereSQL
err := o.Raw(countSQL, params...).QueryRow(&total)
if err != nil {
return nil, 0, err
}
// 联查获取分类名称
querySQL := `
SELECT k.*, c.category_name
FROM yz_knowledge k
LEFT JOIN yz_knowledge_category c ON k.category_id = c.category_id
WHERE ` + whereSQL + `
ORDER BY k.is_top DESC, k.create_time DESC
LIMIT ? OFFSET ?
`
params = append(params, pageSize, (page-1)*pageSize)
var knowledges []*Knowledge
var results []struct {
Id int `orm:"column(knowledge_id)"`
Title string `orm:"column(title)"`
CategoryId int `orm:"column(category_id)"`
CategoryName string `orm:"column(category_name)"`
Tags string `orm:"column(tags)"`
Author string `orm:"column(author)"`
Content string `orm:"column(content)"`
Summary string `orm:"column(summary)"`
CoverUrl string `orm:"column(cover_url)"`
Status int8 `orm:"column(status)"`
ViewCount int `orm:"column(view_count)"`
LikeCount int `orm:"column(like_count)"`
IsRecommend int8 `orm:"column(is_recommend)"`
IsTop int8 `orm:"column(is_top)"`
CreateTime time.Time `orm:"column(create_time)"`
UpdateTime time.Time `orm:"column(update_time)"`
CreateBy string `orm:"column(create_by)"`
UpdateBy string `orm:"column(update_by)"`
}
_, err = o.Raw(querySQL, params...).QueryRows(&results)
if err != nil {
return nil, 0, err
}
// 转换结果
for _, r := range results {
k := &Knowledge{
Id: r.Id,
Title: r.Title,
CategoryId: r.CategoryId,
CategoryName: r.CategoryName,
Tags: r.Tags,
Author: r.Author,
Content: r.Content,
Summary: r.Summary,
CoverUrl: r.CoverUrl,
Status: r.Status,
ViewCount: r.ViewCount,
LikeCount: r.LikeCount,
IsRecommend: r.IsRecommend,
IsTop: r.IsTop,
CreateTime: r.CreateTime,
UpdateTime: r.UpdateTime,
CreateBy: r.CreateBy,
UpdateBy: r.UpdateBy,
}
knowledges = append(knowledges, k)
}
return knowledges, total, nil
}
// UpdateKnowledge 更新知识
func UpdateKnowledge(id int, k *Knowledge) error {
o := orm.NewOrm()
knowledge := &Knowledge{Id: id}
err := o.Read(knowledge)
if err != nil {
return err
}
// 更新字段不包含category_name因为它是从联查获取的
knowledge.Title = k.Title
knowledge.CategoryId = k.CategoryId
knowledge.Tags = k.Tags
knowledge.Author = k.Author
knowledge.Content = k.Content
knowledge.Summary = k.Summary
knowledge.CoverUrl = k.CoverUrl
knowledge.Status = k.Status
knowledge.IsRecommend = k.IsRecommend
knowledge.IsTop = k.IsTop
knowledge.UpdateBy = k.UpdateBy
_, err = o.Update(knowledge, "title", "category_id", "tags", "author", "content", "summary", "cover_url", "status", "is_recommend", "is_top", "update_by", "update_time")
return err
}
// DeleteKnowledge 软删除知识
func DeleteKnowledge(id int, deleteBy string) error {
o := orm.NewOrm()
knowledge := &Knowledge{Id: id}
err := o.Read(knowledge)
if err != nil {
return err
}
// 执行软删除:设置 delete_time 和 delete_by
_, err = o.Raw("UPDATE yz_knowledge SET delete_time = ?, delete_by = ? WHERE knowledge_id = ?", time.Now(), deleteBy, id).Exec()
return err
}
// GetAllCategories 获取所有分类
func GetAllCategories() ([]*KnowledgeCategory, error) {
o := orm.NewOrm()
var categories []*KnowledgeCategory
_, err := o.QueryTable("yz_knowledge_category").OrderBy("sort_order").All(&categories)
return categories, err
}
// GetCategoryById 根据ID获取分类
func GetCategoryById(id int) (*KnowledgeCategory, error) {
o := orm.NewOrm()
category := &KnowledgeCategory{CategoryId: id}
err := o.Read(category)
return category, err
}
// GetAllTags 获取所有标签
func GetAllTags() ([]*KnowledgeTag, error) {
o := orm.NewOrm()
var tags []*KnowledgeTag
_, err := o.QueryTable("yz_knowledge_tags").All(&tags)
return tags, err
}
// AddCategory 添加分类
func AddCategory(category *KnowledgeCategory) (int64, error) {
o := orm.NewOrm()
id, err := o.Insert(category)
return id, err
}
// AddTag 添加标签
func AddTag(tag *KnowledgeTag) (int64, error) {
o := orm.NewOrm()
id, err := o.Insert(tag)
return id, err
}

View File

@ -268,6 +268,9 @@ func Init() {
orm.RegisterModel(new(ProgramCategory))
orm.RegisterModel(new(ProgramInfo))
orm.RegisterModel(new(FileInfo))
orm.RegisterModel(new(Knowledge))
orm.RegisterModel(new(KnowledgeCategory))
orm.RegisterModel(new(KnowledgeTag))
ormConfig, err := beego.AppConfig.String("orm")
if err != nil {

View File

@ -82,6 +82,17 @@ func init() {
// 文件管理路由 - 自动映射到 /api/file/*
beego.AutoRouter(&controllers.FileController{})
// 知识库路由
beego.Router("/api/knowledge/list", &controllers.KnowledgeController{}, "get:List")
beego.Router("/api/knowledge/detail", &controllers.KnowledgeController{}, "get:Detail")
beego.Router("/api/knowledge/create", &controllers.KnowledgeController{}, "post:Create")
beego.Router("/api/knowledge/update", &controllers.KnowledgeController{}, "post:Update")
beego.Router("/api/knowledge/delete", &controllers.KnowledgeController{}, "post:Delete")
beego.Router("/api/knowledge/categories", &controllers.KnowledgeController{}, "get:GetCategories")
beego.Router("/api/knowledge/tags", &controllers.KnowledgeController{}, "get:GetTags")
beego.Router("/api/knowledge/category/add", &controllers.KnowledgeController{}, "post:AddCategory")
beego.Router("/api/knowledge/tag/add", &controllers.KnowledgeController{}, "post:AddTag")
// 手动配置特殊路由(无法通过自动路由处理的)
beego.Router("/api/menus/active", &controllers.MenuController{}, "get:GetActiveMenus")
beego.Router("/api/allmenu", &controllers.MenuController{}, "get:GetAllMenus")

Binary file not shown.