增加cms模块
This commit is contained in:
parent
3c0f01eb36
commit
09cbd721a0
54
pc/src/api/article.js
Normal file
54
pc/src/api/article.js
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
// 文章管理相关API
|
||||||
|
import request from "@/utils/request";
|
||||||
|
|
||||||
|
// 获取文章列表
|
||||||
|
export function listArticles(params) {
|
||||||
|
return request({
|
||||||
|
url: `/api/articles`,
|
||||||
|
method: "get",
|
||||||
|
params,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取文章详情
|
||||||
|
export function getArticle(id) {
|
||||||
|
return request({
|
||||||
|
url: `/api/articles/${id}`,
|
||||||
|
method: "get",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建文章
|
||||||
|
export function createArticle(data) {
|
||||||
|
return request({
|
||||||
|
url: `/api/articles`,
|
||||||
|
method: "post",
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新文章
|
||||||
|
export function updateArticle(id, data) {
|
||||||
|
return request({
|
||||||
|
url: `/api/articles/${id}`,
|
||||||
|
method: "put",
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除文章
|
||||||
|
export function deleteArticle(id) {
|
||||||
|
return request({
|
||||||
|
url: `/api/articles/${id}`,
|
||||||
|
method: "delete",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新文章状态
|
||||||
|
export function updateArticleStatus(id, status) {
|
||||||
|
return request({
|
||||||
|
url: `/api/articles/${id}/status`,
|
||||||
|
method: "patch",
|
||||||
|
data: { status },
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -13,6 +13,7 @@
|
|||||||
:active-text-color="activeColor"
|
:active-text-color="activeColor"
|
||||||
:active-background-color="activeBgColor"
|
:active-background-color="activeBgColor"
|
||||||
class="el-menu-vertical-demo"
|
class="el-menu-vertical-demo"
|
||||||
|
:unique-opened="true"
|
||||||
@select="handleMenuSelect"
|
@select="handleMenuSelect"
|
||||||
:default-active="route.path"
|
:default-active="route.path"
|
||||||
>
|
>
|
||||||
@ -47,7 +48,7 @@ const loading = computed(() => menuStore.loading);
|
|||||||
|
|
||||||
const store = useAllDataStore();
|
const store = useAllDataStore();
|
||||||
const isCollapse = computed(() => store.state.isCollapse);
|
const isCollapse = computed(() => store.state.isCollapse);
|
||||||
const width = computed(() => (store.state.isCollapse ? "64px" : "180px"));
|
const width = computed(() => (store.state.isCollapse ? "64px" : "200px"));
|
||||||
// 使用 Element Plus 的颜色变量,主题切换时会自动适配
|
// 使用 Element Plus 的颜色变量,主题切换时会自动适配
|
||||||
// 这些值会被 el-menu 组件使用,el-menu 会自动适配主题
|
// 这些值会被 el-menu 组件使用,el-menu 会自动适配主题
|
||||||
// 计算是否为暗色主题(响应式)
|
// 计算是否为暗色主题(响应式)
|
||||||
@ -155,12 +156,13 @@ const transformMenuData = (menus) => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// 首先映射字段格式,只保留页面菜单(menu_type=1),并过滤掉功能页面和需要隐藏的子菜单
|
// 首先映射字段格式,只保留目录(menu_type=1)和页面(menu_type=2),并过滤掉功能页面和需要隐藏的子菜单
|
||||||
|
// 按钮类型(menu_type=3)用于权限控制,不在菜单中显示
|
||||||
const allMenus = menus
|
const allMenus = menus
|
||||||
.filter((menu) => {
|
.filter((menu) => {
|
||||||
// 只显示页面菜单,不显示API权限菜单
|
// 显示目录和页面,不显示按钮
|
||||||
if (menu.menuType !== 1 && menu.menuType !== undefined) {
|
if (menu.menuType !== 1 && menu.menuType !== 2 && menu.menuType !== undefined) {
|
||||||
// console.log('过滤掉非页面菜单:', menu);
|
// console.log('过滤掉非目录和非页面:', menu);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
// 过滤掉功能页面(详情、新增、编辑、删除等)
|
// 过滤掉功能页面(详情、新增、编辑、删除等)
|
||||||
@ -188,7 +190,7 @@ const transformMenuData = (menus) => {
|
|||||||
|
|
||||||
// console.log('过滤后的菜单数据:', allMenus);
|
// console.log('过滤后的菜单数据:', allMenus);
|
||||||
|
|
||||||
// 构建菜单映射表(只包含有效的页面菜单)
|
// 构建菜单映射表(只包含有效的目录和页面)
|
||||||
const menuMap = new Map();
|
const menuMap = new Map();
|
||||||
allMenus.forEach((menu) => {
|
allMenus.forEach((menu) => {
|
||||||
menuMap.set(menu.id, menu);
|
menuMap.set(menu.id, menu);
|
||||||
@ -202,7 +204,7 @@ const transformMenuData = (menus) => {
|
|||||||
// 顶级菜单直接加入
|
// 顶级菜单直接加入
|
||||||
rootMenus.push(menu);
|
rootMenus.push(menu);
|
||||||
} else {
|
} else {
|
||||||
// 检查父菜单是否存在(必须是有效的页面菜单)
|
// 检查父菜单是否存在(必须是有效的目录)
|
||||||
const parent = menuMap.get(menu.parentId);
|
const parent = menuMap.get(menu.parentId);
|
||||||
if (parent) {
|
if (parent) {
|
||||||
// 父菜单存在,添加到父菜单的children中
|
// 父菜单存在,添加到父菜单的children中
|
||||||
@ -285,8 +287,8 @@ const handleMenuRefresh = () => {
|
|||||||
fetchMenus();
|
fetchMenus();
|
||||||
};
|
};
|
||||||
|
|
||||||
// 组件挂载时初始化主题监听和获取菜单
|
// 组件挂载时初始化主题监听和获取菜单
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
// 初始化主题监听
|
// 初始化主题监听
|
||||||
themeObserver = new MutationObserver(() => {
|
themeObserver = new MutationObserver(() => {
|
||||||
updateTheme();
|
updateTheme();
|
||||||
@ -300,14 +302,18 @@ onMounted(() => {
|
|||||||
// 初始化时检查一次主题
|
// 初始化时检查一次主题
|
||||||
updateTheme();
|
updateTheme();
|
||||||
|
|
||||||
|
// 检查菜单是否已加载,如果未加载则获取菜单
|
||||||
|
// 这样可以避免在路由初始化后重复请求
|
||||||
|
if (!menuStore.menus || menuStore.menus.length === 0) {
|
||||||
// 延迟一点获取菜单,确保主题已初始化
|
// 延迟一点获取菜单,确保主题已初始化
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
fetchMenus();
|
fetchMenus();
|
||||||
}, 100);
|
}, 100);
|
||||||
|
}
|
||||||
|
|
||||||
// 监听菜单缓存刷新事件
|
// 监听菜单缓存刷新事件
|
||||||
window.addEventListener("menu-cache-refreshed", handleMenuRefresh);
|
window.addEventListener("menu-cache-refreshed", handleMenuRefresh);
|
||||||
});
|
});
|
||||||
|
|
||||||
// 组件卸载时清理事件监听
|
// 组件卸载时清理事件监听
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
|
|||||||
@ -11,6 +11,7 @@
|
|||||||
<el-sub-menu
|
<el-sub-menu
|
||||||
v-else
|
v-else
|
||||||
:index="menu.path"
|
:index="menu.path"
|
||||||
|
:unique-opened="true"
|
||||||
>
|
>
|
||||||
<template #title>
|
<template #title>
|
||||||
<i :class="['icons', 'fa', menu.icon || 'fa-circle']"></i>
|
<i :class="['icons', 'fa', menu.icon || 'fa-circle']"></i>
|
||||||
|
|||||||
@ -21,7 +21,7 @@ export function convertMenusToRoutes(menus) {
|
|||||||
parentId: menu.parentId,
|
parentId: menu.parentId,
|
||||||
menuPath: menu.path // 保存原始路径(完整路径,如 /dashboard)
|
menuPath: menu.path // 保存原始路径(完整路径,如 /dashboard)
|
||||||
},
|
},
|
||||||
// 有组件路径才添加 component(目录菜单可能没有)
|
// 有组件路径才添加 component(只有页面类型才有组件路径)
|
||||||
// componentPath 格式: /dashboard/index.vue (来自数据库)
|
// componentPath 格式: /dashboard/index.vue (来自数据库)
|
||||||
// 使用通用工具自动转换为别名路径并加载
|
// 使用通用工具自动转换为别名路径并加载
|
||||||
...(menu.componentPath
|
...(menu.componentPath
|
||||||
|
|||||||
@ -91,10 +91,10 @@ export const useMenuStore = defineStore('menu', () => {
|
|||||||
const cachedMenus = loadFromCache();
|
const cachedMenus = loadFromCache();
|
||||||
if (cachedMenus && cachedMenus.length > 0) {
|
if (cachedMenus && cachedMenus.length > 0) {
|
||||||
menus.value = cachedMenus;
|
menus.value = cachedMenus;
|
||||||
// 后台更新缓存(不阻塞UI)
|
// 注释掉后台更新缓存的逻辑,避免重复请求
|
||||||
fetchMenus(true).catch(err => {
|
// fetchMenus(true).catch(err => {
|
||||||
console.warn('后台更新菜单失败:', err);
|
// console.warn('后台更新菜单失败:', err);
|
||||||
});
|
// });
|
||||||
return Promise.resolve(cachedMenus);
|
return Promise.resolve(cachedMenus);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
220
pc/src/views/apps/cms/articles/components/edit.vue
Normal file
220
pc/src/views/apps/cms/articles/components/edit.vue
Normal file
@ -0,0 +1,220 @@
|
|||||||
|
<template>
|
||||||
|
<el-dialog
|
||||||
|
v-model="visible"
|
||||||
|
:title="isEdit ? '编辑文章' : '新增文章'"
|
||||||
|
width="800px"
|
||||||
|
:close-on-click-modal="false"
|
||||||
|
>
|
||||||
|
<el-form :model="form" :rules="rules" ref="formRef" label-width="80px">
|
||||||
|
<el-form-item label="标题" prop="title">
|
||||||
|
<el-input v-model="form.title" placeholder="请输入文章标题" />
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-row :gutter="20">
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-form-item label="作者" prop="author">
|
||||||
|
<el-input v-model="form.author" placeholder="请输入作者姓名" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-form-item label="分类" prop="category">
|
||||||
|
<el-select v-model="form.category" placeholder="请选择分类" style="width: 100%;">
|
||||||
|
<el-option label="新闻" value="news" />
|
||||||
|
<el-option label="公告" value="notice" />
|
||||||
|
<el-option label="帮助" value="help" />
|
||||||
|
<el-option label="其他" value="other" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
|
||||||
|
<el-form-item label="状态" prop="status">
|
||||||
|
<el-radio-group v-model="form.status">
|
||||||
|
<el-radio :value="0">草稿</el-radio>
|
||||||
|
<el-radio :value="1">发布</el-radio>
|
||||||
|
</el-radio-group>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="发布时间" prop="publish_time" v-if="form.status === 1">
|
||||||
|
<el-date-picker
|
||||||
|
v-model="form.publish_time"
|
||||||
|
type="datetime"
|
||||||
|
placeholder="选择发布时间"
|
||||||
|
format="YYYY-MM-DD HH:mm:ss"
|
||||||
|
value-format="YYYY-MM-DD HH:mm:ss"
|
||||||
|
style="width: 100%;"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="内容" prop="content">
|
||||||
|
<div class="editor-container">
|
||||||
|
<WangEditor v-model="form.content" />
|
||||||
|
</div>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<span class="dialog-footer">
|
||||||
|
<el-button @click="handleCancel">取消</el-button>
|
||||||
|
<el-button type="primary" @click="handleConfirm" :loading="submitLoading">确定</el-button>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, reactive, watch, nextTick } from 'vue';
|
||||||
|
import { ElMessage } from 'element-plus';
|
||||||
|
import WangEditor from '@/views/components/WangEditor.vue';
|
||||||
|
import { createArticle, updateArticle } from '@/api/article.js';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
modelValue: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
isEdit: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
model: {
|
||||||
|
type: Object,
|
||||||
|
default: () => null
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:modelValue', 'saved']);
|
||||||
|
|
||||||
|
const visible = ref(false);
|
||||||
|
const submitLoading = ref(false);
|
||||||
|
const formRef = ref(null);
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
title: '',
|
||||||
|
author: '',
|
||||||
|
category: '',
|
||||||
|
content: '',
|
||||||
|
status: 0,
|
||||||
|
publish_time: null
|
||||||
|
});
|
||||||
|
|
||||||
|
const rules = {
|
||||||
|
title: [
|
||||||
|
{ required: true, message: '请输入文章标题', trigger: 'blur' },
|
||||||
|
{ min: 2, max: 200, message: '标题长度在 2 到 200 个字符', trigger: 'blur' }
|
||||||
|
],
|
||||||
|
author: [
|
||||||
|
{ required: true, message: '请输入作者', trigger: 'blur' },
|
||||||
|
{ max: 50, message: '作者姓名不能超过50个字符', trigger: 'blur' }
|
||||||
|
],
|
||||||
|
category: [
|
||||||
|
{ required: true, message: '请选择分类', trigger: 'change' }
|
||||||
|
],
|
||||||
|
content: [
|
||||||
|
{ required: true, message: '请输入文章内容', trigger: 'blur' }
|
||||||
|
],
|
||||||
|
publish_time: [
|
||||||
|
{ required: true, message: '请选择发布时间', trigger: 'change' }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
// 监听对话框显示状态
|
||||||
|
watch(() => props.modelValue, (newVal) => {
|
||||||
|
visible.value = newVal;
|
||||||
|
if (newVal) {
|
||||||
|
if (props.isEdit && props.model) {
|
||||||
|
// 编辑模式,填充表单数据
|
||||||
|
Object.assign(form, {
|
||||||
|
title: props.model.title || '',
|
||||||
|
author: props.model.author || '',
|
||||||
|
category: props.model.category || '',
|
||||||
|
content: props.model.content || '',
|
||||||
|
status: props.model.status || 0,
|
||||||
|
publish_time: props.model.publish_time || null
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// 新增模式,重置表单
|
||||||
|
Object.assign(form, {
|
||||||
|
title: '',
|
||||||
|
author: '',
|
||||||
|
category: '',
|
||||||
|
content: '',
|
||||||
|
status: 0,
|
||||||
|
publish_time: null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
nextTick(() => {
|
||||||
|
formRef.value?.clearValidate();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 监听表单状态变化,如果状态改为发布,需要设置发布时间
|
||||||
|
watch(() => form.status, (newStatus) => {
|
||||||
|
if (newStatus === 1 && !form.publish_time) {
|
||||||
|
form.publish_time = new Date().toISOString().slice(0, 19).replace('T', ' ');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleCancel() {
|
||||||
|
emit('update:modelValue', false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleConfirm() {
|
||||||
|
formRef.value?.validate((valid) => {
|
||||||
|
if (valid) {
|
||||||
|
submitLoading.value = true;
|
||||||
|
|
||||||
|
const submitData = {
|
||||||
|
title: form.title,
|
||||||
|
author: form.author,
|
||||||
|
category: form.category,
|
||||||
|
content: form.content,
|
||||||
|
status: form.status,
|
||||||
|
publish_time: form.status === 1 ? form.publish_time : null
|
||||||
|
};
|
||||||
|
|
||||||
|
const apiCall = props.isEdit
|
||||||
|
? updateArticle(props.model.id, submitData)
|
||||||
|
: createArticle(submitData);
|
||||||
|
|
||||||
|
apiCall
|
||||||
|
.then((res) => {
|
||||||
|
const resp = (res && typeof res.code !== 'undefined') ? res : (res && res.data ? res.data : res);
|
||||||
|
if (resp && resp.code === 0) {
|
||||||
|
ElMessage.success(props.isEdit ? '更新成功' : '创建成功');
|
||||||
|
emit('update:modelValue', false);
|
||||||
|
emit('saved');
|
||||||
|
} else {
|
||||||
|
ElMessage.error((resp && resp.message) || (props.isEdit ? '更新失败' : '创建失败'));
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
ElMessage.error(props.isEdit ? '更新失败' : '创建失败');
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
submitLoading.value = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 暴露重置方法(如果需要的话)
|
||||||
|
defineExpose({
|
||||||
|
resetForm: () => {
|
||||||
|
formRef.value?.resetFields();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.editor-container {
|
||||||
|
border: 1px solid #dcdfe6;
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-footer {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
234
pc/src/views/apps/cms/articles/components/preview.vue
Normal file
234
pc/src/views/apps/cms/articles/components/preview.vue
Normal file
@ -0,0 +1,234 @@
|
|||||||
|
<template>
|
||||||
|
<el-drawer
|
||||||
|
v-model="visible"
|
||||||
|
title="文章预览"
|
||||||
|
size="60%"
|
||||||
|
:close-on-click-modal="false"
|
||||||
|
>
|
||||||
|
<div class="article-preview">
|
||||||
|
<div class="article-header">
|
||||||
|
<h1 class="article-title">{{ model?.title || '无标题' }}</h1>
|
||||||
|
<div class="article-meta">
|
||||||
|
<span class="meta-item">
|
||||||
|
<i class="el-icon-user"></i>
|
||||||
|
{{ model?.author || '未知' }}
|
||||||
|
</span>
|
||||||
|
<span class="meta-item">
|
||||||
|
<i class="el-icon-price-tag"></i>
|
||||||
|
<el-tag :type="getCategoryTagType(model?.category)">
|
||||||
|
{{ getCategoryLabel(model?.category) }}
|
||||||
|
</el-tag>
|
||||||
|
</span>
|
||||||
|
<span class="meta-item">
|
||||||
|
<i class="el-icon-view"></i>
|
||||||
|
{{ model?.view_count || 0 }} 阅读
|
||||||
|
</span>
|
||||||
|
<span class="meta-item" v-if="model?.publish_time">
|
||||||
|
<i class="el-icon-time"></i>
|
||||||
|
{{ formatDate(model.publish_time) }}
|
||||||
|
</span>
|
||||||
|
<span class="meta-item">
|
||||||
|
<el-tag :type="model?.status === 1 ? 'success' : model?.status === 2 ? 'danger' : 'info'">
|
||||||
|
{{ model?.status === 1 ? '已发布' : model?.status === 2 ? '已下架' : '草稿' }}
|
||||||
|
</el-tag>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="article-content">
|
||||||
|
<div v-if="model?.content" v-html="model.content" class="content-html"></div>
|
||||||
|
<div v-else class="no-content">
|
||||||
|
<el-empty description="暂无内容"></el-empty>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-drawer>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, watch } from 'vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
modelValue: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
model: {
|
||||||
|
type: Object,
|
||||||
|
default: () => null
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:modelValue']);
|
||||||
|
|
||||||
|
const visible = ref(false);
|
||||||
|
|
||||||
|
// 监听对话框显示状态
|
||||||
|
watch(() => props.modelValue, (newVal) => {
|
||||||
|
visible.value = newVal;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 监听visible变化,同步给父组件
|
||||||
|
watch(visible, (newVal) => {
|
||||||
|
emit('update:modelValue', newVal);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 格式化日期
|
||||||
|
function formatDate(dateStr) {
|
||||||
|
if (!dateStr) return '-';
|
||||||
|
const date = new Date(dateStr);
|
||||||
|
return date.toLocaleString('zh-CN', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取分类标签类型
|
||||||
|
function getCategoryTagType(category) {
|
||||||
|
const types = {
|
||||||
|
'news': 'primary',
|
||||||
|
'notice': 'warning',
|
||||||
|
'help': 'success',
|
||||||
|
'other': 'info'
|
||||||
|
};
|
||||||
|
return types[category] || 'info';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取分类标签文本
|
||||||
|
function getCategoryLabel(category) {
|
||||||
|
const labels = {
|
||||||
|
'news': '新闻',
|
||||||
|
'notice': '公告',
|
||||||
|
'help': '帮助',
|
||||||
|
'other': '其他'
|
||||||
|
};
|
||||||
|
return labels[category] || '其他';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.article-preview {
|
||||||
|
padding: 20px;
|
||||||
|
|
||||||
|
.article-header {
|
||||||
|
border-bottom: 1px solid #ebeef5;
|
||||||
|
padding-bottom: 20px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
|
||||||
|
.article-title {
|
||||||
|
margin: 0 0 15px 0;
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #303133;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-meta {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 15px;
|
||||||
|
color: #909399;
|
||||||
|
font-size: 14px;
|
||||||
|
|
||||||
|
.meta-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
|
||||||
|
i {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-content {
|
||||||
|
.content-html {
|
||||||
|
line-height: 1.8;
|
||||||
|
color: #606266;
|
||||||
|
|
||||||
|
:deep(h1), :deep(h2), :deep(h3), :deep(h4), :deep(h5), :deep(h6) {
|
||||||
|
margin: 20px 0 10px 0;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(h1) { font-size: 28px; }
|
||||||
|
:deep(h2) { font-size: 24px; }
|
||||||
|
:deep(h3) { font-size: 20px; }
|
||||||
|
:deep(h4) { font-size: 18px; }
|
||||||
|
:deep(h5) { font-size: 16px; }
|
||||||
|
:deep(h6) { font-size: 14px; }
|
||||||
|
|
||||||
|
:deep(p) {
|
||||||
|
margin: 10px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(ul), :deep(ol) {
|
||||||
|
margin: 10px 0;
|
||||||
|
padding-left: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(blockquote) {
|
||||||
|
border-left: 4px solid #ebeef5;
|
||||||
|
padding-left: 15px;
|
||||||
|
margin: 15px 0;
|
||||||
|
color: #909399;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(code) {
|
||||||
|
background-color: #f6f8fa;
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 2px 4px;
|
||||||
|
font-family: 'Monaco', 'Consolas', monospace;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(pre) {
|
||||||
|
background-color: #f6f8fa;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 16px;
|
||||||
|
overflow-x: auto;
|
||||||
|
margin: 15px 0;
|
||||||
|
|
||||||
|
code {
|
||||||
|
background-color: transparent;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(img) {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin: 10px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(table) {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin: 15px 0;
|
||||||
|
|
||||||
|
th, td {
|
||||||
|
border: 1px solid #ebeef5;
|
||||||
|
padding: 8px 12px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
background-color: #fafafa;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-content {
|
||||||
|
text-align: center;
|
||||||
|
padding: 40px 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
466
pc/src/views/apps/cms/articles/index.vue
Normal file
466
pc/src/views/apps/cms/articles/index.vue
Normal file
@ -0,0 +1,466 @@
|
|||||||
|
<template>
|
||||||
|
<div class="cms-articles">
|
||||||
|
<div class="articles-container">
|
||||||
|
<!-- 顶部操作栏 -->
|
||||||
|
<div class="toolbar">
|
||||||
|
<el-button type="primary" @click="handleAdd">新增文章</el-button>
|
||||||
|
<el-button @click="handleRefresh">刷新</el-button>
|
||||||
|
<div class="search-bar">
|
||||||
|
<el-input v-model="searchQuery" placeholder="搜索文章标题/作者" clearable @clear="handleSearch">
|
||||||
|
<template #append>
|
||||||
|
<el-button :icon="Search" @click="handleSearch" />
|
||||||
|
</template>
|
||||||
|
</el-input>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 筛选条件 -->
|
||||||
|
<div class="filters">
|
||||||
|
<el-select v-model="categoryFilter" placeholder="选择分类" clearable @change="handleFilterChange" style="width: 150px; margin-right: 10px;">
|
||||||
|
<el-option label="新闻" value="news" />
|
||||||
|
<el-option label="公告" value="notice" />
|
||||||
|
<el-option label="帮助" value="help" />
|
||||||
|
<el-option label="其他" value="other" />
|
||||||
|
</el-select>
|
||||||
|
<el-select v-model="statusFilter" placeholder="选择状态" clearable @change="handleFilterChange" style="width: 120px;">
|
||||||
|
<el-option label="草稿" value="0" />
|
||||||
|
<el-option label="已发布" value="1" />
|
||||||
|
<el-option label="已下架" value="2" />
|
||||||
|
</el-select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 文章列表 -->
|
||||||
|
<el-table :data="articleList" v-loading="loading" stripe border style="width: 100%">
|
||||||
|
<el-table-column prop="title" label="标题" min-width="200" show-overflow-tooltip />
|
||||||
|
<el-table-column prop="author" label="作者" width="120" />
|
||||||
|
<el-table-column prop="category" label="分类" width="100">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="getCategoryTagType(row.category)">
|
||||||
|
{{ getCategoryLabel(row.category) }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="status" label="状态" width="100">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="getStatusType(row.status)">
|
||||||
|
{{ getStatusText(row.status) }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="view_count" label="浏览量" width="100" align="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ row.view_count || 0 }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="publish_time" label="发布时间" width="160" sortable>
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ formatDate(row.publish_time) }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="update_time" label="更新时间" width="160" sortable>
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ formatDate(row.update_time) }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="250" fixed="right" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button link type="info" @click="handleView(row)">预览</el-button>
|
||||||
|
<el-button link type="primary" @click="handleEdit(row)">编辑</el-button>
|
||||||
|
<el-button link type="success" @click="handlePublish(row)" v-if="isDraftOrPending(row.status)">发布</el-button>
|
||||||
|
<el-button link type="info" @click="handleReject(row)" v-if="isPending(row.status)">退回</el-button>
|
||||||
|
<el-button link type="danger" @click="handleDelete(row)">删除</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<!-- 分页 -->
|
||||||
|
<div class="pagination">
|
||||||
|
<el-pagination v-model:current-page="currentPage" v-model:page-size="pageSize" :total="total"
|
||||||
|
:page-sizes="[10, 20, 50, 100]" layout="total, sizes, prev, pager, next, jumper"
|
||||||
|
@size-change="handleSizeChange" @current-change="handleCurrentChange" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 编辑弹窗 -->
|
||||||
|
<Edit v-model="dialogVisible" :is-edit="isEdit" :model="currentRow" @saved="onSaved" />
|
||||||
|
|
||||||
|
<!-- 预览抽屉 -->
|
||||||
|
<Preview v-model="previewVisible" :model="currentRow" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, reactive, onMounted } from 'vue';
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||||
|
import { Search } from '@element-plus/icons-vue';
|
||||||
|
import { useDictStore } from '@/stores/dict';
|
||||||
|
import Edit from './components/edit.vue'
|
||||||
|
import Preview from './components/preview.vue'
|
||||||
|
import { listArticles, getArticle, deleteArticle, updateArticleStatus } from '@/api/article.js'
|
||||||
|
|
||||||
|
const loading = ref(false);
|
||||||
|
const articleList = ref([]);
|
||||||
|
const searchQuery = ref('');
|
||||||
|
const categoryFilter = ref('');
|
||||||
|
const statusFilter = ref('');
|
||||||
|
const currentPage = ref(1);
|
||||||
|
const pageSize = ref(10);
|
||||||
|
const total = ref(0);
|
||||||
|
const dialogVisible = ref(false);
|
||||||
|
const previewVisible = ref(false);
|
||||||
|
const isEdit = ref(false);
|
||||||
|
const currentRow = ref(null);
|
||||||
|
|
||||||
|
// 字典store
|
||||||
|
const dictStore = useDictStore();
|
||||||
|
const statusOptions = ref([]);
|
||||||
|
|
||||||
|
// 格式化日期
|
||||||
|
function formatDate(dateStr) {
|
||||||
|
if (!dateStr) return '-';
|
||||||
|
const date = new Date(dateStr);
|
||||||
|
return date.toLocaleString('zh-CN', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取分类标签类型
|
||||||
|
function getCategoryTagType(category) {
|
||||||
|
const types = {
|
||||||
|
'news': 'primary',
|
||||||
|
'notice': 'warning',
|
||||||
|
'help': 'success',
|
||||||
|
'other': 'info'
|
||||||
|
};
|
||||||
|
return types[category] || 'info';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取分类标签文本
|
||||||
|
function getCategoryLabel(category) {
|
||||||
|
const labels = {
|
||||||
|
'news': '新闻',
|
||||||
|
'notice': '公告',
|
||||||
|
'help': '帮助',
|
||||||
|
'other': '其他'
|
||||||
|
};
|
||||||
|
return labels[category] || '其他';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取状态标签类型
|
||||||
|
function getStatusType(status) {
|
||||||
|
// 从状态选项中查找对应的颜色类型
|
||||||
|
const statusItem = statusOptions.value.find(item => String(item.dict_value) === String(status));
|
||||||
|
if (statusItem && statusItem.color_type) {
|
||||||
|
return statusItem.color_type;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 默认颜色映射
|
||||||
|
const defaultTypes = {
|
||||||
|
0: 'info', // 草稿
|
||||||
|
1: 'warning', // 待审核
|
||||||
|
2: 'success', // 已发布
|
||||||
|
3: 'danger' // 隐藏
|
||||||
|
};
|
||||||
|
return defaultTypes[status] || 'info';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取状态标签文本
|
||||||
|
function getStatusText(status) {
|
||||||
|
return dictStore.getDictLabel('article_status', status);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 状态判断辅助函数
|
||||||
|
function isDraftOrPending(status) {
|
||||||
|
const draftValue = dictStore.getDictValue('article_status', '草稿') || 0;
|
||||||
|
const pendingValue = dictStore.getDictValue('article_status', '待审核') || 1;
|
||||||
|
return status == draftValue || status == pendingValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPublished(status) {
|
||||||
|
const publishedValue = dictStore.getDictValue('article_status', '已发布') || 2;
|
||||||
|
return status == publishedValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPending(status) {
|
||||||
|
const pendingValue = dictStore.getDictValue('article_status', '待审核') || 1;
|
||||||
|
return status == pendingValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取状态值
|
||||||
|
function getStatusValueByLabel(label) {
|
||||||
|
return dictStore.getDictValue('article_status', label);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取文章列表
|
||||||
|
function fetchArticleList() {
|
||||||
|
loading.value = true;
|
||||||
|
|
||||||
|
// 从缓存读取用户信息
|
||||||
|
let uid = null;
|
||||||
|
let tenantId = null;
|
||||||
|
try {
|
||||||
|
const userInfoStr = localStorage.getItem('userInfo');
|
||||||
|
if (userInfoStr) {
|
||||||
|
const userInfo = JSON.parse(userInfoStr);
|
||||||
|
uid = userInfo.id || userInfo.uid || null;
|
||||||
|
tenantId = userInfo.tenant_id || userInfo.tenantId || null;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to parse user info from cache:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
const params = {
|
||||||
|
keyword: searchQuery.value,
|
||||||
|
category: categoryFilter.value,
|
||||||
|
status: statusFilter.value,
|
||||||
|
page: currentPage.value,
|
||||||
|
pageSize: pageSize.value,
|
||||||
|
uid: uid,
|
||||||
|
tenant_id: tenantId,
|
||||||
|
};
|
||||||
|
|
||||||
|
listArticles(params)
|
||||||
|
.then((res) => {
|
||||||
|
const resp = (res && typeof res.code !== 'undefined') ? res : (res && res.data ? res.data : res);
|
||||||
|
if (resp && resp.code === 0 && resp.data) {
|
||||||
|
const rows = resp.data.list || [];
|
||||||
|
total.value = resp.data.total || 0;
|
||||||
|
articleList.value = rows.map((m) => ({
|
||||||
|
id: m.id,
|
||||||
|
title: m.title || '',
|
||||||
|
author: m.author || '',
|
||||||
|
category: m.category || '',
|
||||||
|
content: m.content || '',
|
||||||
|
status: typeof m.status === 'number' ? m.status : parseInt(m.status) || 0,
|
||||||
|
view_count: m.view_count || 0,
|
||||||
|
publish_time: m.publish_time || null,
|
||||||
|
update_time: m.update_time || null,
|
||||||
|
_raw: m,
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
articleList.value = [];
|
||||||
|
total.value = 0;
|
||||||
|
if (resp && resp.message) ElMessage.error(resp.message);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
loading.value = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleAdd() {
|
||||||
|
isEdit.value = false;
|
||||||
|
currentRow.value = null;
|
||||||
|
dialogVisible.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleEdit(row) {
|
||||||
|
isEdit.value = true;
|
||||||
|
// 获取详情
|
||||||
|
getArticle(row.id).then((res) => {
|
||||||
|
const resp = (res && typeof res.code !== 'undefined') ? res : (res && res.data ? res.data : res);
|
||||||
|
if (resp && resp.code === 0 && resp.data) {
|
||||||
|
const m = resp.data;
|
||||||
|
currentRow.value = {
|
||||||
|
id: m.id,
|
||||||
|
title: m.title || '',
|
||||||
|
author: m.author || '',
|
||||||
|
category: m.category || '',
|
||||||
|
content: m.content || '',
|
||||||
|
status: typeof m.status === 'number' ? m.status : parseInt(m.status) || 0,
|
||||||
|
publish_time: m.publish_time || null,
|
||||||
|
_raw: m,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
currentRow.value = { ...row };
|
||||||
|
}
|
||||||
|
dialogVisible.value = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleView(row) {
|
||||||
|
// 获取最新详情再预览
|
||||||
|
getArticle(row.id).then((res) => {
|
||||||
|
const resp = (res && typeof res.code !== 'undefined') ? res : (res && res.data ? res.data : res);
|
||||||
|
if (resp && resp.code === 0 && resp.data) {
|
||||||
|
const m = resp.data;
|
||||||
|
currentRow.value = {
|
||||||
|
id: m.id,
|
||||||
|
title: m.title || '',
|
||||||
|
author: m.author || '',
|
||||||
|
category: m.category || '',
|
||||||
|
content: m.content || '',
|
||||||
|
status: typeof m.status === 'number' ? m.status : parseInt(m.status) || 0,
|
||||||
|
view_count: m.view_count || 0,
|
||||||
|
publish_time: m.publish_time || null,
|
||||||
|
update_time: m.update_time || null,
|
||||||
|
_raw: m,
|
||||||
|
};
|
||||||
|
previewVisible.value = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePublish(row) {
|
||||||
|
const draftValue = getStatusValueByLabel('草稿') || 0;
|
||||||
|
const actionText = row.status == draftValue ? '发布' : '审核通过';
|
||||||
|
ElMessageBox.confirm(`确定要${actionText}这篇文章吗?`, '提示', {
|
||||||
|
confirmButtonText: '确定',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
type: 'warning'
|
||||||
|
}).then(() => {
|
||||||
|
const publishedValue = getStatusValueByLabel('已发布') || 2;
|
||||||
|
updateArticleStatus(row.id, publishedValue)
|
||||||
|
.then((res) => {
|
||||||
|
const resp = (res && typeof res.code !== 'undefined') ? res : (res && res.data ? res.data : res);
|
||||||
|
if (resp && resp.code === 0) {
|
||||||
|
ElMessage.success(`${actionText}成功`);
|
||||||
|
fetchArticleList();
|
||||||
|
} else {
|
||||||
|
ElMessage.error((resp && resp.message) || `${actionText}失败`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 隐藏文章
|
||||||
|
function handleHide(row) {
|
||||||
|
ElMessageBox.confirm('确定要隐藏这篇文章吗?', '提示', {
|
||||||
|
confirmButtonText: '确定',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
type: 'warning'
|
||||||
|
}).then(() => {
|
||||||
|
const hiddenValue = getStatusValueByLabel('隐藏') || 3;
|
||||||
|
updateArticleStatus(row.id, hiddenValue)
|
||||||
|
.then((res) => {
|
||||||
|
const resp = (res && typeof res.code !== 'undefined') ? res : (res && res.data ? res.data : res);
|
||||||
|
if (resp && resp.code === 0) {
|
||||||
|
ElMessage.success('隐藏成功');
|
||||||
|
fetchArticleList();
|
||||||
|
} else {
|
||||||
|
ElMessage.error((resp && resp.message) || '隐藏失败');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 退回文章(审核不通过)
|
||||||
|
function handleReject(row) {
|
||||||
|
ElMessageBox.confirm('确定要将这篇文章退回草稿状态吗?', '提示', {
|
||||||
|
confirmButtonText: '确定',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
type: 'warning'
|
||||||
|
}).then(() => {
|
||||||
|
const draftValue = getStatusValueByLabel('草稿') || 0;
|
||||||
|
updateArticleStatus(row.id, draftValue)
|
||||||
|
.then((res) => {
|
||||||
|
const resp = (res && typeof res.code !== 'undefined') ? res : (res && res.data ? res.data : res);
|
||||||
|
if (resp && resp.code === 0) {
|
||||||
|
ElMessage.success('已退回草稿');
|
||||||
|
fetchArticleList();
|
||||||
|
} else {
|
||||||
|
ElMessage.error((resp && resp.message) || '退回失败');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDelete(row) {
|
||||||
|
ElMessageBox.confirm('确定要删除这篇文章吗?此操作不可恢复。', '提示', {
|
||||||
|
confirmButtonText: '确定',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
type: 'warning'
|
||||||
|
}).then(() => {
|
||||||
|
deleteArticle(row.id)
|
||||||
|
.then((res) => {
|
||||||
|
const resp = (res && typeof res.code !== 'undefined') ? res : (res && res.data ? res.data : res);
|
||||||
|
if (resp && resp.code === 0) {
|
||||||
|
ElMessage.success('删除成功');
|
||||||
|
fetchArticleList();
|
||||||
|
} else {
|
||||||
|
ElMessage.error((resp && resp.message) || '删除失败');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSaved() {
|
||||||
|
fetchArticleList();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSearch() {
|
||||||
|
currentPage.value = 1;
|
||||||
|
fetchArticleList();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFilterChange() {
|
||||||
|
currentPage.value = 1;
|
||||||
|
fetchArticleList();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRefresh() {
|
||||||
|
searchQuery.value = '';
|
||||||
|
categoryFilter.value = '';
|
||||||
|
statusFilter.value = '';
|
||||||
|
currentPage.value = 1;
|
||||||
|
fetchArticleList();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSizeChange() {
|
||||||
|
fetchArticleList();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCurrentChange() {
|
||||||
|
fetchArticleList();
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
// 加载文章状态字典
|
||||||
|
statusOptions.value = await dictStore.getDictItems('article_status');
|
||||||
|
|
||||||
|
fetchArticleList();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.cms-articles {
|
||||||
|
padding: 20px;
|
||||||
|
height: 100%;
|
||||||
|
background: #f5f7fa;
|
||||||
|
|
||||||
|
.articles-container {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 20px;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
.toolbar {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
|
||||||
|
.search-bar {
|
||||||
|
margin-left: auto;
|
||||||
|
width: 300px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.filters {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination {
|
||||||
|
margin-top: 20px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
8
pc/src/views/apps/cms/index.vue
Normal file
8
pc/src/views/apps/cms/index.vue
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
<template>
|
||||||
|
<router-view />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup></script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped></style>
|
||||||
|
|
||||||
278
pc/src/views/apps/cms/workbench/index.vue
Normal file
278
pc/src/views/apps/cms/workbench/index.vue
Normal file
@ -0,0 +1,278 @@
|
|||||||
|
<template>
|
||||||
|
<div class="exam-workbench">
|
||||||
|
<!-- 数据统计 -->
|
||||||
|
<div class="statistics-section">
|
||||||
|
<el-row :gutter="20">
|
||||||
|
<el-col :span="6">
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-icon">
|
||||||
|
<i class="fa-solid fa-file-lines"></i>
|
||||||
|
</div>
|
||||||
|
<div class="stat-content">
|
||||||
|
<div class="stat-value">{{ statistics.examCount }}</div>
|
||||||
|
<div class="stat-label">考试总数</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="6">
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-icon">
|
||||||
|
<i class="fa-solid fa-pen"></i>
|
||||||
|
</div>
|
||||||
|
<div class="stat-content">
|
||||||
|
<div class="stat-value">{{ statistics.practiceCount }}</div>
|
||||||
|
<div class="stat-label">练习总数</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="6">
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-icon">
|
||||||
|
<i class="fa-solid fa-book"></i>
|
||||||
|
</div>
|
||||||
|
<div class="stat-content">
|
||||||
|
<div class="stat-value">{{ statistics.courseCount }}</div>
|
||||||
|
<div class="stat-label">课程总数</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="6">
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-icon">
|
||||||
|
<i class="fa-solid fa-users"></i>
|
||||||
|
</div>
|
||||||
|
<div class="stat-content">
|
||||||
|
<div class="stat-value">{{ statistics.studentCount }}</div>
|
||||||
|
<div class="stat-label">考生总数</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 快捷功能 -->
|
||||||
|
<div class="quick-actions">
|
||||||
|
<h3 class="section-title">快捷功能</h3>
|
||||||
|
<el-row :gutter="20">
|
||||||
|
<el-col :span="4">
|
||||||
|
<div class="action-card" @click="handleCreateExam">
|
||||||
|
<div class="action-icon">
|
||||||
|
<i class="fa-solid fa-file-circle-plus"></i>
|
||||||
|
</div>
|
||||||
|
<div class="action-label">创建考试</div>
|
||||||
|
</div>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="4">
|
||||||
|
<div class="action-card" @click="handleCreatePractice">
|
||||||
|
<div class="action-icon">
|
||||||
|
<i class="fa-solid fa-pen-to-square"></i>
|
||||||
|
</div>
|
||||||
|
<div class="action-label">创建练习</div>
|
||||||
|
</div>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="4">
|
||||||
|
<div class="action-card" @click="handleCreateCourse">
|
||||||
|
<div class="action-icon">
|
||||||
|
<i class="fa-solid fa-book-open"></i>
|
||||||
|
</div>
|
||||||
|
<div class="action-label">创建课程</div>
|
||||||
|
</div>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="4">
|
||||||
|
<div class="action-card" @click="handleBatchImport">
|
||||||
|
<div class="action-icon">
|
||||||
|
<i class="fa-solid fa-file-import"></i>
|
||||||
|
</div>
|
||||||
|
<div class="action-label">批量导题</div>
|
||||||
|
</div>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="4">
|
||||||
|
<div class="action-card" @click="handleStudentManage">
|
||||||
|
<div class="action-icon">
|
||||||
|
<i class="fa-solid fa-user-group"></i>
|
||||||
|
</div>
|
||||||
|
<div class="action-label">考生管理</div>
|
||||||
|
</div>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="4">
|
||||||
|
<div class="action-card" @click="handleQuestionBank">
|
||||||
|
<div class="action-icon">
|
||||||
|
<i class="fa-solid fa-database"></i>
|
||||||
|
</div>
|
||||||
|
<div class="action-label">试题库</div>
|
||||||
|
</div>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted } from 'vue';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
import { ElMessage } from 'element-plus';
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const statistics = ref({
|
||||||
|
examCount: 0,
|
||||||
|
practiceCount: 0,
|
||||||
|
courseCount: 0,
|
||||||
|
studentCount: 0
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleCreateExam = () => {
|
||||||
|
router.push('/apps/exams/exam');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreatePractice = () => {
|
||||||
|
ElMessage.info('创建练习功能开发中');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateCourse = () => {
|
||||||
|
ElMessage.info('创建课程功能开发中');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBatchImport = () => {
|
||||||
|
ElMessage.info('批量导题功能开发中');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStudentManage = () => {
|
||||||
|
ElMessage.info('考生管理功能开发中');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleQuestionBank = () => {
|
||||||
|
ElMessage.info('试题库功能开发中');
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchStatistics = async () => {
|
||||||
|
try {
|
||||||
|
// TODO: 调用API获取统计数据
|
||||||
|
statistics.value = {
|
||||||
|
examCount: 0,
|
||||||
|
practiceCount: 0,
|
||||||
|
courseCount: 0,
|
||||||
|
studentCount: 0
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取统计数据失败', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchStatistics();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.exam-workbench {
|
||||||
|
padding: 20px;
|
||||||
|
|
||||||
|
.statistics-section {
|
||||||
|
margin-bottom: 30px;
|
||||||
|
|
||||||
|
.stat-card {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 24px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||||
|
transition: all 0.3s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: translateY(-4px);
|
||||||
|
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-icon {
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
i {
|
||||||
|
font-size: 28px;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-content {
|
||||||
|
flex: 1;
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #303133;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #909399;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-actions {
|
||||||
|
.section-title {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #303133;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-card {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 32px 20px;
|
||||||
|
text-align: center;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||||
|
transition: all 0.3s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: translateY(-4px);
|
||||||
|
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
|
||||||
|
|
||||||
|
.action-icon {
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
|
||||||
|
i {
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-icon {
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #f5f7fa;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin: 0 auto 16px;
|
||||||
|
transition: all 0.3s;
|
||||||
|
|
||||||
|
i {
|
||||||
|
font-size: 32px;
|
||||||
|
color: #667eea;
|
||||||
|
transition: all 0.3s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-label {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #606266;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -1,861 +1,11 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="knowledge-home">
|
<router-view />
|
||||||
<!-- 如果当前是子路由(category 或 tag),显示子路由内容 -->
|
|
||||||
<router-view v-if="isSubRoute" />
|
|
||||||
|
|
||||||
<!-- 否则显示知识库列表 -->
|
|
||||||
<template v-else>
|
|
||||||
<!-- 顶部搜索区域 -->
|
|
||||||
<div class="search-section">
|
|
||||||
<div class="search-content">
|
|
||||||
<h1 class="page-title">知识库</h1>
|
|
||||||
<p class="page-subtitle">全国单一窗口同步更新,实时最新</p>
|
|
||||||
|
|
||||||
<!-- 搜索框 -->
|
|
||||||
<div class="search-wrapper">
|
|
||||||
<el-input
|
|
||||||
v-model="keyword"
|
|
||||||
placeholder="请输入关键字、产品编码进行查询"
|
|
||||||
size="large"
|
|
||||||
clearable
|
|
||||||
@keyup.enter="handleSearch"
|
|
||||||
>
|
|
||||||
<template #prefix>
|
|
||||||
<el-icon><Search /></el-icon>
|
|
||||||
</template>
|
|
||||||
</el-input>
|
|
||||||
<el-button type="primary" size="large" @click="handleSearch">
|
|
||||||
查询
|
|
||||||
</el-button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 热门搜索 -->
|
|
||||||
<div class="hot-tags">
|
|
||||||
<span class="hot-label">热门搜索:</span>
|
|
||||||
<el-tag
|
|
||||||
v-for="tag in hotTags"
|
|
||||||
:key="tag"
|
|
||||||
size="small"
|
|
||||||
effect="plain"
|
|
||||||
class="hot-tag"
|
|
||||||
@click="handleHotSearch(tag)"
|
|
||||||
>
|
|
||||||
{{ tag }}
|
|
||||||
</el-tag>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 统计卡片 -->
|
|
||||||
<div class="stats-grid">
|
|
||||||
<div
|
|
||||||
v-for="(stat, index) in statsList"
|
|
||||||
:key="index"
|
|
||||||
class="stat-card"
|
|
||||||
>
|
|
||||||
<div class="stat-icon-wrapper">
|
|
||||||
<i :class="stat.icon"></i>
|
|
||||||
</div>
|
|
||||||
<div class="stat-content">
|
|
||||||
<div class="stat-value">{{ stat.value }}</div>
|
|
||||||
<div class="stat-label">{{ stat.label }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 知识库列表 -->
|
|
||||||
<div class="repos-section">
|
|
||||||
<div class="section-header">
|
|
||||||
<h2 class="section-title">知识库列表</h2>
|
|
||||||
<div class="action-buttons">
|
|
||||||
<el-button type="primary" @click="handleCreate">
|
|
||||||
<el-icon><Plus /></el-icon>
|
|
||||||
新建知识库
|
|
||||||
</el-button>
|
|
||||||
<el-button @click="handleCategory">
|
|
||||||
<el-icon><Folder /></el-icon>
|
|
||||||
分类管理
|
|
||||||
</el-button>
|
|
||||||
<el-button @click="handleTags">
|
|
||||||
<el-icon><PriceTag /></el-icon>
|
|
||||||
标签管理
|
|
||||||
</el-button>
|
|
||||||
<el-button @click="refresh" :loading="loading">
|
|
||||||
<el-icon><Refresh /></el-icon>
|
|
||||||
刷新
|
|
||||||
</el-button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 标签页 -->
|
|
||||||
<el-tabs v-model="activeTab" class="repos-tabs">
|
|
||||||
<el-tab-pane label="个人知识库" name="personal">
|
|
||||||
<div class="repos-grid" v-if="personalList.length > 0">
|
|
||||||
<div
|
|
||||||
v-for="repo in personalList"
|
|
||||||
:key="repo.id"
|
|
||||||
class="repo-card"
|
|
||||||
>
|
|
||||||
<div class="repo-header">
|
|
||||||
<div class="repo-icon">
|
|
||||||
<i class="fa-solid fa-book"></i>
|
|
||||||
</div>
|
|
||||||
<div class="repo-info">
|
|
||||||
<h3 class="repo-title">{{ repo.title }}</h3>
|
|
||||||
<div class="repo-meta">
|
|
||||||
<el-tag size="small" type="info">
|
|
||||||
{{ repo.categoryName || "未分类" }}
|
|
||||||
</el-tag>
|
|
||||||
<span class="repo-author">
|
|
||||||
<el-icon><User /></el-icon>
|
|
||||||
{{ repo.author }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="repo-body">
|
|
||||||
<div class="repo-tags" v-if="repo.tags && parseTags(repo.tags).length > 0">
|
|
||||||
<el-tag
|
|
||||||
v-for="tag in parseTags(repo.tags)"
|
|
||||||
:key="tag"
|
|
||||||
size="small"
|
|
||||||
effect="plain"
|
|
||||||
>
|
|
||||||
{{ tag }}
|
|
||||||
</el-tag>
|
|
||||||
</div>
|
|
||||||
<div class="repo-stats">
|
|
||||||
<div class="stat-item">
|
|
||||||
<el-icon><View /></el-icon>
|
|
||||||
<span>{{ repo.viewCount || 0 }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="stat-item">
|
|
||||||
<el-icon><Star /></el-icon>
|
|
||||||
<span>{{ repo.likeCount || 0 }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="stat-item">
|
|
||||||
<el-icon><Clock /></el-icon>
|
|
||||||
<span>{{ formatDate(repo.updateTime || repo.createTime) }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="repo-footer">
|
|
||||||
<el-button
|
|
||||||
type="primary"
|
|
||||||
link
|
|
||||||
size="small"
|
|
||||||
@click.stop="handleView(repo)"
|
|
||||||
>
|
|
||||||
查看
|
|
||||||
</el-button>
|
|
||||||
<el-button
|
|
||||||
type="success"
|
|
||||||
link
|
|
||||||
size="small"
|
|
||||||
@click.stop="handleEdit(repo)"
|
|
||||||
>
|
|
||||||
编辑
|
|
||||||
</el-button>
|
|
||||||
<el-button
|
|
||||||
type="danger"
|
|
||||||
link
|
|
||||||
size="small"
|
|
||||||
@click.stop="handleDelete(repo)"
|
|
||||||
>
|
|
||||||
删除
|
|
||||||
</el-button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 空状态 -->
|
|
||||||
<el-empty v-else description="暂无个人知识库">
|
|
||||||
<el-button type="primary" @click="handleCreate">
|
|
||||||
<el-icon><Plus /></el-icon>
|
|
||||||
新建知识库
|
|
||||||
</el-button>
|
|
||||||
</el-empty>
|
|
||||||
</el-tab-pane>
|
|
||||||
|
|
||||||
<el-tab-pane label="共享知识库" name="shared">
|
|
||||||
<div class="repos-grid" v-if="sharedList.length > 0">
|
|
||||||
<div
|
|
||||||
v-for="repo in sharedList"
|
|
||||||
:key="repo.id"
|
|
||||||
class="repo-card"
|
|
||||||
>
|
|
||||||
<div class="repo-header">
|
|
||||||
<div class="repo-icon">
|
|
||||||
<i class="fa-solid fa-book"></i>
|
|
||||||
</div>
|
|
||||||
<div class="repo-info">
|
|
||||||
<h3 class="repo-title">{{ repo.title }}</h3>
|
|
||||||
<div class="repo-meta">
|
|
||||||
<el-tag size="small" type="info">
|
|
||||||
{{ repo.categoryName || "未分类" }}
|
|
||||||
</el-tag>
|
|
||||||
<span class="repo-author">
|
|
||||||
<el-icon><User /></el-icon>
|
|
||||||
{{ repo.author }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="repo-body">
|
|
||||||
<div class="repo-tags" v-if="repo.tags && parseTags(repo.tags).length > 0">
|
|
||||||
<el-tag
|
|
||||||
v-for="tag in parseTags(repo.tags)"
|
|
||||||
:key="tag"
|
|
||||||
size="small"
|
|
||||||
effect="plain"
|
|
||||||
>
|
|
||||||
{{ tag }}
|
|
||||||
</el-tag>
|
|
||||||
</div>
|
|
||||||
<div class="repo-stats">
|
|
||||||
<div class="stat-item">
|
|
||||||
<el-icon><View /></el-icon>
|
|
||||||
<span>{{ repo.viewCount || 0 }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="stat-item">
|
|
||||||
<el-icon><Star /></el-icon>
|
|
||||||
<span>{{ repo.likeCount || 0 }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="stat-item">
|
|
||||||
<el-icon><Clock /></el-icon>
|
|
||||||
<span>{{ formatDate(repo.updateTime || repo.createTime) }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="repo-footer">
|
|
||||||
<el-button
|
|
||||||
type="primary"
|
|
||||||
link
|
|
||||||
size="small"
|
|
||||||
@click.stop="handleView(repo)"
|
|
||||||
>
|
|
||||||
查看
|
|
||||||
</el-button>
|
|
||||||
<el-button
|
|
||||||
type="success"
|
|
||||||
link
|
|
||||||
size="small"
|
|
||||||
@click.stop="handleEdit(repo)"
|
|
||||||
>
|
|
||||||
编辑
|
|
||||||
</el-button>
|
|
||||||
<el-button
|
|
||||||
type="danger"
|
|
||||||
link
|
|
||||||
size="small"
|
|
||||||
@click.stop="handleDelete(repo)"
|
|
||||||
>
|
|
||||||
删除
|
|
||||||
</el-button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 空状态 -->
|
|
||||||
<el-empty v-else description="暂无共享知识库" />
|
|
||||||
</el-tab-pane>
|
|
||||||
</el-tabs>
|
|
||||||
|
|
||||||
<!-- 分页 -->
|
|
||||||
<div class="pagination-wrapper" v-if="(activeTab === 'personal' ? personalList.length : sharedList.length) > 0">
|
|
||||||
<el-pagination
|
|
||||||
background
|
|
||||||
layout="prev, pager, next, total"
|
|
||||||
:total="activeTab === 'personal' ? personalList.length : sharedList.length"
|
|
||||||
:page-size="pageSize"
|
|
||||||
:current-page="currentPage"
|
|
||||||
@current-change="handlePageChange"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup></script>
|
||||||
import { ref, reactive, computed, onMounted } from "vue";
|
|
||||||
import { useRouter, useRoute } from "vue-router";
|
|
||||||
import { ElMessage, ElMessageBox } from "element-plus";
|
|
||||||
import { Search, Plus, Folder, PriceTag, User, View, Star, Clock, Refresh } from "@element-plus/icons-vue";
|
|
||||||
|
|
||||||
// @ts-ignore
|
<style lang="scss" scoped>
|
||||||
import { getKnowledgeList, deleteKnowledge } from "@/api/knowledge";
|
:deep(.el-form-item) {
|
||||||
|
margin-bottom: 4px !important;
|
||||||
// 类型定义
|
|
||||||
interface Knowledge {
|
|
||||||
id: number;
|
|
||||||
title: string;
|
|
||||||
content: string;
|
|
||||||
categoryName: string;
|
|
||||||
tags: string;
|
|
||||||
author: string;
|
|
||||||
viewCount: number;
|
|
||||||
likeCount: number;
|
|
||||||
createTime: string;
|
|
||||||
updateTime: string;
|
|
||||||
share: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Stats {
|
|
||||||
total: number;
|
|
||||||
docCount: number;
|
|
||||||
memberCount: number;
|
|
||||||
viewCount: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface StatItem {
|
|
||||||
label: string;
|
|
||||||
value: string | number;
|
|
||||||
icon: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 路由
|
|
||||||
const router = useRouter();
|
|
||||||
const route = useRoute();
|
|
||||||
|
|
||||||
// 检查当前路由是否是子路由(category、tag、edit 或 detail)
|
|
||||||
const isSubRoute = computed(() => {
|
|
||||||
const path = route.path;
|
|
||||||
return path.includes('/category') || path.includes('/tag') || path.includes('/edit') || path.includes('/detail');
|
|
||||||
});
|
|
||||||
|
|
||||||
// 响应式数据
|
|
||||||
const keyword = ref("");
|
|
||||||
const hotTags = ref(["化妆品", "汽车零部件", "口罩", "工业用品", "食品"]);
|
|
||||||
|
|
||||||
const activeTab = ref('personal');
|
|
||||||
|
|
||||||
const stats = reactive<Stats>({
|
|
||||||
total: 0,
|
|
||||||
docCount: 0,
|
|
||||||
memberCount: 0,
|
|
||||||
viewCount: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
const repoList = ref<Knowledge[]>([]);
|
|
||||||
const total = ref(0);
|
|
||||||
const pageSize = ref(12);
|
|
||||||
const currentPage = ref(1);
|
|
||||||
const loading = ref(false);
|
|
||||||
|
|
||||||
// Separate lists based on share field
|
|
||||||
const personalList = computed(() => repoList.value.filter(repo => Number(repo.share) === 0));
|
|
||||||
const sharedList = computed(() => repoList.value.filter(repo => Number(repo.share) === 1));
|
|
||||||
|
|
||||||
// 计算属性
|
|
||||||
const statsList = computed<StatItem[]>(() => [
|
|
||||||
{
|
|
||||||
label: "知识库总数",
|
|
||||||
value: repoList.value.length,
|
|
||||||
icon: "fa fa-book",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "今日新增",
|
|
||||||
value: "12",
|
|
||||||
icon: "fa fa-plus",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "本周更新",
|
|
||||||
value: "28",
|
|
||||||
icon: "fa fa-refresh",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "协作项目",
|
|
||||||
value: "6",
|
|
||||||
icon: "fa fa-users",
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
// 方法
|
|
||||||
function updateStats() {
|
|
||||||
stats.total = total.value;
|
|
||||||
stats.docCount = total.value;
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleSearch() {
|
|
||||||
currentPage.value = 1;
|
|
||||||
fetchRepoList();
|
|
||||||
}
|
|
||||||
|
|
||||||
function handlePageChange(page: number) {
|
|
||||||
currentPage.value = page;
|
|
||||||
fetchRepoList();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 重构 fetchRepoList 以支持关键词搜索 (no share filter)
|
|
||||||
async function fetchRepoList() {
|
|
||||||
loading.value = true;
|
|
||||||
try {
|
|
||||||
const result = await getKnowledgeList({
|
|
||||||
page: currentPage.value,
|
|
||||||
pageSize: pageSize.value,
|
|
||||||
status: 1,
|
|
||||||
keyword: keyword.value,
|
|
||||||
share: -1,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (result.code === 0 && result.data) {
|
|
||||||
repoList.value = result.data.list || [];
|
|
||||||
total.value = result.data.total || 0;
|
|
||||||
} else {
|
|
||||||
repoList.value = result.list || result.data?.list || [];
|
|
||||||
total.value = result.total || result.data?.total || 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
updateStats();
|
|
||||||
} catch (error: any) {
|
|
||||||
ElMessage.error(error.message || "获取知识库列表失败");
|
|
||||||
repoList.value = [];
|
|
||||||
total.value = 0;
|
|
||||||
updateStats();
|
|
||||||
} finally {
|
|
||||||
loading.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 刷新界面
|
|
||||||
async function refresh() {
|
|
||||||
try {
|
|
||||||
await fetchRepoList();
|
|
||||||
ElMessage.success('刷新成功');
|
|
||||||
} catch (error) {
|
|
||||||
ElMessage.error('刷新失败');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleCreate() {
|
|
||||||
router.push(`/apps/knowledge/edit/new`);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleView(repo: Knowledge) {
|
|
||||||
router.push(`/apps/knowledge/detail/${repo.id}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleEdit(repo: Knowledge) {
|
|
||||||
router.push(`/apps/knowledge/edit/${repo.id}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleCategory() {
|
|
||||||
router.push(`/apps/knowledge/category`);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleTags() {
|
|
||||||
router.push(`/apps/knowledge/tag`);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleDelete(repo: Knowledge) {
|
|
||||||
ElMessageBox.confirm(`确认删除知识「${repo.title}」?`, "提示", {
|
|
||||||
confirmButtonText: "确定",
|
|
||||||
cancelButtonText: "取消",
|
|
||||||
type: "warning",
|
|
||||||
})
|
|
||||||
.then(async () => {
|
|
||||||
try {
|
|
||||||
await deleteKnowledge(repo.id);
|
|
||||||
ElMessage.success("删除成功");
|
|
||||||
fetchRepoList();
|
|
||||||
} 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(() => {
|
|
||||||
fetchRepoList();
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style lang="less" scoped>
|
|
||||||
.knowledge-home {
|
|
||||||
min-height: 100%;
|
|
||||||
background-color: var(--el-bg-color-page);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 搜索区域
|
|
||||||
.search-section {
|
|
||||||
background: var(--el-bg-color);
|
|
||||||
border-radius: 12px;
|
|
||||||
padding: 40px;
|
|
||||||
margin-bottom: 24px;
|
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
|
|
||||||
border: 1px solid var(--el-border-color-lighter);
|
|
||||||
|
|
||||||
.search-content {
|
|
||||||
max-width: 800px;
|
|
||||||
margin: 0 auto;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-title {
|
|
||||||
font-size: 32px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--el-text-color-primary);
|
|
||||||
margin: 0 0 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-subtitle {
|
|
||||||
font-size: 14px;
|
|
||||||
color: var(--el-text-color-regular);
|
|
||||||
margin: 0 0 32px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-wrapper {
|
|
||||||
display: flex;
|
|
||||||
gap: 12px;
|
|
||||||
margin-bottom: 24px;
|
|
||||||
|
|
||||||
.el-input {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.el-button {
|
|
||||||
min-width: 100px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.hot-tags {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 8px;
|
|
||||||
|
|
||||||
.hot-label {
|
|
||||||
font-size: 13px;
|
|
||||||
color: var(--el-text-color-regular);
|
|
||||||
margin-right: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hot-tag {
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background-color: var(--el-color-primary-light-9);
|
|
||||||
border-color: var(--el-color-primary);
|
|
||||||
color: var(--el-color-primary);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 统计卡片
|
|
||||||
.stats-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
|
||||||
gap: 20px;
|
|
||||||
margin-bottom: 24px;
|
|
||||||
|
|
||||||
.stat-card {
|
|
||||||
background: var(--el-bg-color);
|
|
||||||
border-radius: 12px;
|
|
||||||
padding: 24px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 16px;
|
|
||||||
border: 1px solid var(--el-border-color-lighter);
|
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
|
|
||||||
transition: all 0.3s;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
transform: translateY(-4px);
|
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
|
||||||
border-color: var(--el-color-primary-light-7);
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-icon-wrapper {
|
|
||||||
width: 56px;
|
|
||||||
height: 56px;
|
|
||||||
border-radius: 12px;
|
|
||||||
background: linear-gradient(135deg, #062da3 0%, #4f84ff 100%);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
flex-shrink: 0;
|
|
||||||
|
|
||||||
i {
|
|
||||||
font-size: 24px;
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-content {
|
|
||||||
flex: 1;
|
|
||||||
|
|
||||||
.stat-value {
|
|
||||||
font-size: 28px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--el-text-color-primary);
|
|
||||||
line-height: 1;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-label {
|
|
||||||
font-size: 14px;
|
|
||||||
color: var(--el-text-color-regular);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 知识库列表区域
|
|
||||||
.repos-section {
|
|
||||||
background: var(--el-bg-color);
|
|
||||||
border-radius: 12px;
|
|
||||||
padding: 24px;
|
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
|
|
||||||
border: 1px solid var(--el-border-color-lighter);
|
|
||||||
|
|
||||||
.section-header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 24px;
|
|
||||||
|
|
||||||
.section-title {
|
|
||||||
font-size: 20px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--el-text-color-primary);
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.action-buttons {
|
|
||||||
display: flex;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.repos-tabs {
|
|
||||||
:deep(.el-tabs__header) {
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.repos-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
|
||||||
gap: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.repo-card {
|
|
||||||
background: var(--el-bg-color);
|
|
||||||
border-radius: 12px;
|
|
||||||
border: 1px solid var(--el-border-color-lighter);
|
|
||||||
overflow: hidden;
|
|
||||||
transition: all 0.3s;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
transform: translateY(-4px);
|
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
|
||||||
border-color: var(--el-color-primary-light-7);
|
|
||||||
}
|
|
||||||
|
|
||||||
.repo-header {
|
|
||||||
padding: 20px;
|
|
||||||
border-bottom: 1px solid var(--el-border-color-lighter);
|
|
||||||
display: flex;
|
|
||||||
gap: 12px;
|
|
||||||
|
|
||||||
.repo-icon {
|
|
||||||
width: 48px;
|
|
||||||
height: 48px;
|
|
||||||
border-radius: 10px;
|
|
||||||
background: linear-gradient(135deg, #062da3 0%, #4f84ff 100%);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
flex-shrink: 0;
|
|
||||||
|
|
||||||
i {
|
|
||||||
font-size: 20px;
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.repo-info {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
|
|
||||||
.repo-title {
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--el-text-color-primary);
|
|
||||||
margin: 0 0 8px;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.repo-meta {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
|
|
||||||
.repo-author {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--el-text-color-regular);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.repo-body {
|
|
||||||
padding: 16px 20px;
|
|
||||||
flex: 1;
|
|
||||||
|
|
||||||
.repo-tags {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 6px;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.repo-stats {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 16px;
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--el-text-color-regular);
|
|
||||||
padding-top: 12px;
|
|
||||||
border-top: 1px solid var(--el-border-color-lighter);
|
|
||||||
|
|
||||||
.stat-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.repo-footer {
|
|
||||||
padding: 12px 20px;
|
|
||||||
border-top: 1px solid var(--el-border-color-lighter);
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-end;
|
|
||||||
gap: 8px;
|
|
||||||
background: var(--el-fill-color-lighter);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.pagination-wrapper {
|
|
||||||
margin-top: 24px;
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-end;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 响应式
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.knowledge-home {
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-section {
|
|
||||||
padding: 24px 16px;
|
|
||||||
|
|
||||||
.page-title {
|
|
||||||
font-size: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-wrapper {
|
|
||||||
flex-direction: column;
|
|
||||||
|
|
||||||
.el-button {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-grid {
|
|
||||||
grid-template-columns: repeat(2, 1fr);
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.repos-section {
|
|
||||||
padding: 16px;
|
|
||||||
|
|
||||||
.section-header {
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: 16px;
|
|
||||||
|
|
||||||
.action-buttons {
|
|
||||||
width: 100%;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
|
|
||||||
.el-button {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 100px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.repos-grid {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 480px) {
|
|
||||||
.stats-grid {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
861
pc/src/views/apps/knowledge/workbench/index.vue
Normal file
861
pc/src/views/apps/knowledge/workbench/index.vue
Normal file
@ -0,0 +1,861 @@
|
|||||||
|
<template>
|
||||||
|
<div class="knowledge-home">
|
||||||
|
<!-- 如果当前是子路由(category 或 tag),显示子路由内容 -->
|
||||||
|
<router-view v-if="isSubRoute" />
|
||||||
|
|
||||||
|
<!-- 否则显示知识库列表 -->
|
||||||
|
<template v-else>
|
||||||
|
<!-- 顶部搜索区域 -->
|
||||||
|
<div class="search-section">
|
||||||
|
<div class="search-content">
|
||||||
|
<h1 class="page-title">知识库</h1>
|
||||||
|
<p class="page-subtitle">全国单一窗口同步更新,实时最新</p>
|
||||||
|
|
||||||
|
<!-- 搜索框 -->
|
||||||
|
<div class="search-wrapper">
|
||||||
|
<el-input
|
||||||
|
v-model="keyword"
|
||||||
|
placeholder="请输入关键字、产品编码进行查询"
|
||||||
|
size="large"
|
||||||
|
clearable
|
||||||
|
@keyup.enter="handleSearch"
|
||||||
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<el-icon><Search /></el-icon>
|
||||||
|
</template>
|
||||||
|
</el-input>
|
||||||
|
<el-button type="primary" size="large" @click="handleSearch">
|
||||||
|
查询
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 热门搜索 -->
|
||||||
|
<div class="hot-tags">
|
||||||
|
<span class="hot-label">热门搜索:</span>
|
||||||
|
<el-tag
|
||||||
|
v-for="tag in hotTags"
|
||||||
|
:key="tag"
|
||||||
|
size="small"
|
||||||
|
effect="plain"
|
||||||
|
class="hot-tag"
|
||||||
|
@click="handleHotSearch(tag)"
|
||||||
|
>
|
||||||
|
{{ tag }}
|
||||||
|
</el-tag>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 统计卡片 -->
|
||||||
|
<div class="stats-grid">
|
||||||
|
<div
|
||||||
|
v-for="(stat, index) in statsList"
|
||||||
|
:key="index"
|
||||||
|
class="stat-card"
|
||||||
|
>
|
||||||
|
<div class="stat-icon-wrapper">
|
||||||
|
<i :class="stat.icon"></i>
|
||||||
|
</div>
|
||||||
|
<div class="stat-content">
|
||||||
|
<div class="stat-value">{{ stat.value }}</div>
|
||||||
|
<div class="stat-label">{{ stat.label }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 知识库列表 -->
|
||||||
|
<div class="repos-section">
|
||||||
|
<div class="section-header">
|
||||||
|
<h2 class="section-title">知识库列表</h2>
|
||||||
|
<div class="action-buttons">
|
||||||
|
<el-button type="primary" @click="handleCreate">
|
||||||
|
<el-icon><Plus /></el-icon>
|
||||||
|
新建知识库
|
||||||
|
</el-button>
|
||||||
|
<el-button @click="handleCategory">
|
||||||
|
<el-icon><Folder /></el-icon>
|
||||||
|
分类管理
|
||||||
|
</el-button>
|
||||||
|
<el-button @click="handleTags">
|
||||||
|
<el-icon><PriceTag /></el-icon>
|
||||||
|
标签管理
|
||||||
|
</el-button>
|
||||||
|
<el-button @click="refresh" :loading="loading">
|
||||||
|
<el-icon><Refresh /></el-icon>
|
||||||
|
刷新
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 标签页 -->
|
||||||
|
<el-tabs v-model="activeTab" class="repos-tabs">
|
||||||
|
<el-tab-pane label="个人知识库" name="personal">
|
||||||
|
<div class="repos-grid" v-if="personalList.length > 0">
|
||||||
|
<div
|
||||||
|
v-for="repo in personalList"
|
||||||
|
:key="repo.id"
|
||||||
|
class="repo-card"
|
||||||
|
>
|
||||||
|
<div class="repo-header">
|
||||||
|
<div class="repo-icon">
|
||||||
|
<i class="fa-solid fa-book"></i>
|
||||||
|
</div>
|
||||||
|
<div class="repo-info">
|
||||||
|
<h3 class="repo-title">{{ repo.title }}</h3>
|
||||||
|
<div class="repo-meta">
|
||||||
|
<el-tag size="small" type="info">
|
||||||
|
{{ repo.categoryName || "未分类" }}
|
||||||
|
</el-tag>
|
||||||
|
<span class="repo-author">
|
||||||
|
<el-icon><User /></el-icon>
|
||||||
|
{{ repo.author }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="repo-body">
|
||||||
|
<div class="repo-tags" v-if="repo.tags && parseTags(repo.tags).length > 0">
|
||||||
|
<el-tag
|
||||||
|
v-for="tag in parseTags(repo.tags)"
|
||||||
|
:key="tag"
|
||||||
|
size="small"
|
||||||
|
effect="plain"
|
||||||
|
>
|
||||||
|
{{ tag }}
|
||||||
|
</el-tag>
|
||||||
|
</div>
|
||||||
|
<div class="repo-stats">
|
||||||
|
<div class="stat-item">
|
||||||
|
<el-icon><View /></el-icon>
|
||||||
|
<span>{{ repo.viewCount || 0 }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item">
|
||||||
|
<el-icon><Star /></el-icon>
|
||||||
|
<span>{{ repo.likeCount || 0 }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item">
|
||||||
|
<el-icon><Clock /></el-icon>
|
||||||
|
<span>{{ formatDate(repo.updateTime || repo.createTime) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="repo-footer">
|
||||||
|
<el-button
|
||||||
|
type="primary"
|
||||||
|
link
|
||||||
|
size="small"
|
||||||
|
@click.stop="handleView(repo)"
|
||||||
|
>
|
||||||
|
查看
|
||||||
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
type="success"
|
||||||
|
link
|
||||||
|
size="small"
|
||||||
|
@click.stop="handleEdit(repo)"
|
||||||
|
>
|
||||||
|
编辑
|
||||||
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
type="danger"
|
||||||
|
link
|
||||||
|
size="small"
|
||||||
|
@click.stop="handleDelete(repo)"
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 空状态 -->
|
||||||
|
<el-empty v-else description="暂无个人知识库">
|
||||||
|
<el-button type="primary" @click="handleCreate">
|
||||||
|
<el-icon><Plus /></el-icon>
|
||||||
|
新建知识库
|
||||||
|
</el-button>
|
||||||
|
</el-empty>
|
||||||
|
</el-tab-pane>
|
||||||
|
|
||||||
|
<el-tab-pane label="共享知识库" name="shared">
|
||||||
|
<div class="repos-grid" v-if="sharedList.length > 0">
|
||||||
|
<div
|
||||||
|
v-for="repo in sharedList"
|
||||||
|
:key="repo.id"
|
||||||
|
class="repo-card"
|
||||||
|
>
|
||||||
|
<div class="repo-header">
|
||||||
|
<div class="repo-icon">
|
||||||
|
<i class="fa-solid fa-book"></i>
|
||||||
|
</div>
|
||||||
|
<div class="repo-info">
|
||||||
|
<h3 class="repo-title">{{ repo.title }}</h3>
|
||||||
|
<div class="repo-meta">
|
||||||
|
<el-tag size="small" type="info">
|
||||||
|
{{ repo.categoryName || "未分类" }}
|
||||||
|
</el-tag>
|
||||||
|
<span class="repo-author">
|
||||||
|
<el-icon><User /></el-icon>
|
||||||
|
{{ repo.author }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="repo-body">
|
||||||
|
<div class="repo-tags" v-if="repo.tags && parseTags(repo.tags).length > 0">
|
||||||
|
<el-tag
|
||||||
|
v-for="tag in parseTags(repo.tags)"
|
||||||
|
:key="tag"
|
||||||
|
size="small"
|
||||||
|
effect="plain"
|
||||||
|
>
|
||||||
|
{{ tag }}
|
||||||
|
</el-tag>
|
||||||
|
</div>
|
||||||
|
<div class="repo-stats">
|
||||||
|
<div class="stat-item">
|
||||||
|
<el-icon><View /></el-icon>
|
||||||
|
<span>{{ repo.viewCount || 0 }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item">
|
||||||
|
<el-icon><Star /></el-icon>
|
||||||
|
<span>{{ repo.likeCount || 0 }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item">
|
||||||
|
<el-icon><Clock /></el-icon>
|
||||||
|
<span>{{ formatDate(repo.updateTime || repo.createTime) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="repo-footer">
|
||||||
|
<el-button
|
||||||
|
type="primary"
|
||||||
|
link
|
||||||
|
size="small"
|
||||||
|
@click.stop="handleView(repo)"
|
||||||
|
>
|
||||||
|
查看
|
||||||
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
type="success"
|
||||||
|
link
|
||||||
|
size="small"
|
||||||
|
@click.stop="handleEdit(repo)"
|
||||||
|
>
|
||||||
|
编辑
|
||||||
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
type="danger"
|
||||||
|
link
|
||||||
|
size="small"
|
||||||
|
@click.stop="handleDelete(repo)"
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 空状态 -->
|
||||||
|
<el-empty v-else description="暂无共享知识库" />
|
||||||
|
</el-tab-pane>
|
||||||
|
</el-tabs>
|
||||||
|
|
||||||
|
<!-- 分页 -->
|
||||||
|
<div class="pagination-wrapper" v-if="(activeTab === 'personal' ? personalList.length : sharedList.length) > 0">
|
||||||
|
<el-pagination
|
||||||
|
background
|
||||||
|
layout="prev, pager, next, total"
|
||||||
|
:total="activeTab === 'personal' ? personalList.length : sharedList.length"
|
||||||
|
:page-size="pageSize"
|
||||||
|
:current-page="currentPage"
|
||||||
|
@current-change="handlePageChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, reactive, computed, onMounted } from "vue";
|
||||||
|
import { useRouter, useRoute } from "vue-router";
|
||||||
|
import { ElMessage, ElMessageBox } from "element-plus";
|
||||||
|
import { Search, Plus, Folder, PriceTag, User, View, Star, Clock, Refresh } from "@element-plus/icons-vue";
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
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;
|
||||||
|
share: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Stats {
|
||||||
|
total: number;
|
||||||
|
docCount: number;
|
||||||
|
memberCount: number;
|
||||||
|
viewCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StatItem {
|
||||||
|
label: string;
|
||||||
|
value: string | number;
|
||||||
|
icon: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 路由
|
||||||
|
const router = useRouter();
|
||||||
|
const route = useRoute();
|
||||||
|
|
||||||
|
// 检查当前路由是否是子路由(category、tag、edit 或 detail)
|
||||||
|
const isSubRoute = computed(() => {
|
||||||
|
const path = route.path;
|
||||||
|
return path.includes('/category') || path.includes('/tag') || path.includes('/edit') || path.includes('/detail');
|
||||||
|
});
|
||||||
|
|
||||||
|
// 响应式数据
|
||||||
|
const keyword = ref("");
|
||||||
|
const hotTags = ref(["化妆品", "汽车零部件", "口罩", "工业用品", "食品"]);
|
||||||
|
|
||||||
|
const activeTab = ref('personal');
|
||||||
|
|
||||||
|
const stats = reactive<Stats>({
|
||||||
|
total: 0,
|
||||||
|
docCount: 0,
|
||||||
|
memberCount: 0,
|
||||||
|
viewCount: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const repoList = ref<Knowledge[]>([]);
|
||||||
|
const total = ref(0);
|
||||||
|
const pageSize = ref(12);
|
||||||
|
const currentPage = ref(1);
|
||||||
|
const loading = ref(false);
|
||||||
|
|
||||||
|
// Separate lists based on share field
|
||||||
|
const personalList = computed(() => repoList.value.filter(repo => Number(repo.share) === 0));
|
||||||
|
const sharedList = computed(() => repoList.value.filter(repo => Number(repo.share) === 1));
|
||||||
|
|
||||||
|
// 计算属性
|
||||||
|
const statsList = computed<StatItem[]>(() => [
|
||||||
|
{
|
||||||
|
label: "知识库总数",
|
||||||
|
value: repoList.value.length,
|
||||||
|
icon: "fa fa-book",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "今日新增",
|
||||||
|
value: "12",
|
||||||
|
icon: "fa fa-plus",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "本周更新",
|
||||||
|
value: "28",
|
||||||
|
icon: "fa fa-refresh",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "协作项目",
|
||||||
|
value: "6",
|
||||||
|
icon: "fa fa-users",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 方法
|
||||||
|
function updateStats() {
|
||||||
|
stats.total = total.value;
|
||||||
|
stats.docCount = total.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSearch() {
|
||||||
|
currentPage.value = 1;
|
||||||
|
fetchRepoList();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePageChange(page: number) {
|
||||||
|
currentPage.value = page;
|
||||||
|
fetchRepoList();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重构 fetchRepoList 以支持关键词搜索 (no share filter)
|
||||||
|
async function fetchRepoList() {
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const result = await getKnowledgeList({
|
||||||
|
page: currentPage.value,
|
||||||
|
pageSize: pageSize.value,
|
||||||
|
status: 1,
|
||||||
|
keyword: keyword.value,
|
||||||
|
share: -1,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.code === 0 && result.data) {
|
||||||
|
repoList.value = result.data.list || [];
|
||||||
|
total.value = result.data.total || 0;
|
||||||
|
} else {
|
||||||
|
repoList.value = result.list || result.data?.list || [];
|
||||||
|
total.value = result.total || result.data?.total || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateStats();
|
||||||
|
} catch (error: any) {
|
||||||
|
ElMessage.error(error.message || "获取知识库列表失败");
|
||||||
|
repoList.value = [];
|
||||||
|
total.value = 0;
|
||||||
|
updateStats();
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 刷新界面
|
||||||
|
async function refresh() {
|
||||||
|
try {
|
||||||
|
await fetchRepoList();
|
||||||
|
ElMessage.success('刷新成功');
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error('刷新失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCreate() {
|
||||||
|
router.push(`/apps/knowledge/edit/new`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleView(repo: Knowledge) {
|
||||||
|
router.push(`/apps/knowledge/detail/${repo.id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleEdit(repo: Knowledge) {
|
||||||
|
router.push(`/apps/knowledge/edit/${repo.id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCategory() {
|
||||||
|
router.push(`/apps/knowledge/category`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTags() {
|
||||||
|
router.push(`/apps/knowledge/tag`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDelete(repo: Knowledge) {
|
||||||
|
ElMessageBox.confirm(`确认删除知识「${repo.title}」?`, "提示", {
|
||||||
|
confirmButtonText: "确定",
|
||||||
|
cancelButtonText: "取消",
|
||||||
|
type: "warning",
|
||||||
|
})
|
||||||
|
.then(async () => {
|
||||||
|
try {
|
||||||
|
await deleteKnowledge(repo.id);
|
||||||
|
ElMessage.success("删除成功");
|
||||||
|
fetchRepoList();
|
||||||
|
} 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(() => {
|
||||||
|
fetchRepoList();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="less" scoped>
|
||||||
|
.knowledge-home {
|
||||||
|
min-height: 100%;
|
||||||
|
background-color: var(--el-bg-color-page);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 搜索区域
|
||||||
|
.search-section {
|
||||||
|
background: var(--el-bg-color);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 40px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
|
||||||
|
border: 1px solid var(--el-border-color-lighter);
|
||||||
|
|
||||||
|
.search-content {
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 0 auto;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--el-text-color-primary);
|
||||||
|
margin: 0 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-subtitle {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--el-text-color-regular);
|
||||||
|
margin: 0 0 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-wrapper {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
|
||||||
|
.el-input {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-button {
|
||||||
|
min-width: 100px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.hot-tags {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
|
||||||
|
.hot-label {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--el-text-color-regular);
|
||||||
|
margin-right: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hot-tag {
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: var(--el-color-primary-light-9);
|
||||||
|
border-color: var(--el-color-primary);
|
||||||
|
color: var(--el-color-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 统计卡片
|
||||||
|
.stats-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||||
|
gap: 20px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
|
||||||
|
.stat-card {
|
||||||
|
background: var(--el-bg-color);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 24px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
border: 1px solid var(--el-border-color-lighter);
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
|
||||||
|
transition: all 0.3s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: translateY(-4px);
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||||
|
border-color: var(--el-color-primary-light-7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-icon-wrapper {
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: linear-gradient(135deg, #062da3 0%, #4f84ff 100%);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
i {
|
||||||
|
font-size: 24px;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-content {
|
||||||
|
flex: 1;
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--el-text-color-primary);
|
||||||
|
line-height: 1;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--el-text-color-regular);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 知识库列表区域
|
||||||
|
.repos-section {
|
||||||
|
background: var(--el-bg-color);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 24px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
|
||||||
|
border: 1px solid var(--el-border-color-lighter);
|
||||||
|
|
||||||
|
.section-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--el-text-color-primary);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-buttons {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.repos-tabs {
|
||||||
|
:deep(.el-tabs__header) {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.repos-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.repo-card {
|
||||||
|
background: var(--el-bg-color);
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid var(--el-border-color-lighter);
|
||||||
|
overflow: hidden;
|
||||||
|
transition: all 0.3s;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: translateY(-4px);
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||||
|
border-color: var(--el-color-primary-light-7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.repo-header {
|
||||||
|
padding: 20px;
|
||||||
|
border-bottom: 1px solid var(--el-border-color-lighter);
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
|
||||||
|
.repo-icon {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: linear-gradient(135deg, #062da3 0%, #4f84ff 100%);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
i {
|
||||||
|
font-size: 20px;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.repo-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
|
||||||
|
.repo-title {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--el-text-color-primary);
|
||||||
|
margin: 0 0 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.repo-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
|
||||||
|
.repo-author {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--el-text-color-regular);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.repo-body {
|
||||||
|
padding: 16px 20px;
|
||||||
|
flex: 1;
|
||||||
|
|
||||||
|
.repo-tags {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.repo-stats {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--el-text-color-regular);
|
||||||
|
padding-top: 12px;
|
||||||
|
border-top: 1px solid var(--el-border-color-lighter);
|
||||||
|
|
||||||
|
.stat-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.repo-footer {
|
||||||
|
padding: 12px 20px;
|
||||||
|
border-top: 1px solid var(--el-border-color-lighter);
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
background: var(--el-fill-color-lighter);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-wrapper {
|
||||||
|
margin-top: 24px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 响应式
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.knowledge-home {
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-section {
|
||||||
|
padding: 24px 16px;
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-wrapper {
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
.el-button {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-grid {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.repos-section {
|
||||||
|
padding: 16px;
|
||||||
|
|
||||||
|
.section-header {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 16px;
|
||||||
|
|
||||||
|
.action-buttons {
|
||||||
|
width: 100%;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
|
||||||
|
.el-button {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 100px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.repos-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.stats-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -10,7 +10,12 @@
|
|||||||
|
|
||||||
<!-- 统计卡片 -->
|
<!-- 统计卡片 -->
|
||||||
<div class="stats-grid">
|
<div class="stats-grid">
|
||||||
<div v-for="(stat, index) in stats" :key="index" class="stat-card" :class="stat.type">
|
<div
|
||||||
|
v-for="(stat, index) in stats"
|
||||||
|
:key="index"
|
||||||
|
class="stat-card"
|
||||||
|
:class="stat.type"
|
||||||
|
>
|
||||||
<div class="stat-icon-wrapper">
|
<div class="stat-icon-wrapper">
|
||||||
<el-icon :size="28">
|
<el-icon :size="28">
|
||||||
<component :is="stat.icon" />
|
<component :is="stat.icon" />
|
||||||
@ -20,14 +25,17 @@
|
|||||||
<div class="stat-value">{{ stat.value }}</div>
|
<div class="stat-value">{{ stat.value }}</div>
|
||||||
<div class="stat-label">{{ stat.label }}</div>
|
<div class="stat-label">{{ stat.label }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-trend" :class="stat.change > 0 ? 'up' : stat.change < 0 ? 'down' : 'flat'">
|
<div
|
||||||
|
class="stat-trend"
|
||||||
|
:class="stat.change > 0 ? 'up' : stat.change < 0 ? 'down' : 'flat'"
|
||||||
|
>
|
||||||
<el-icon v-if="stat.change > 0" :size="14">
|
<el-icon v-if="stat.change > 0" :size="14">
|
||||||
<ArrowUp />
|
<ArrowUp />
|
||||||
</el-icon>
|
</el-icon>
|
||||||
<el-icon v-else-if="stat.change < 0" :size="14">
|
<el-icon v-else-if="stat.change < 0" :size="14">
|
||||||
<ArrowDown />
|
<ArrowDown />
|
||||||
</el-icon>
|
</el-icon>
|
||||||
<span>{{ stat.change > 0 ? '+' : '' }}{{ stat.change }}%</span>
|
<span>{{ stat.change > 0 ? "+" : "" }}{{ stat.change }}%</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -92,12 +100,22 @@
|
|||||||
</el-button>
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
<div class="list-content">
|
<div class="list-content">
|
||||||
<div v-for="(task, idx) in paginatedTasks" :key="idx" class="task-item" :class="{ done: task.completed }">
|
<div
|
||||||
|
v-for="(task, idx) in paginatedTasks"
|
||||||
|
:key="idx"
|
||||||
|
class="task-item"
|
||||||
|
:class="{ done: task.completed }"
|
||||||
|
>
|
||||||
<!-- <el-checkbox v-model="task.completed" @change="handleTaskChange(task)" /> -->
|
<!-- <el-checkbox v-model="task.completed" @change="handleTaskChange(task)" /> -->
|
||||||
<div class="task-info">
|
<div class="task-info">
|
||||||
<div class="task-title">
|
<div class="task-title">
|
||||||
{{ task.title }}
|
{{ task.title }}
|
||||||
<el-tag :type="task.priorityTagType || getPriorityType(task.priority)" size="small" effect="plain" :class="'priority-' + task.priority">
|
<el-tag
|
||||||
|
:type="task.priorityTagType || getPriorityType(task.priority)"
|
||||||
|
size="small"
|
||||||
|
effect="plain"
|
||||||
|
:class="'priority-' + task.priority"
|
||||||
|
>
|
||||||
{{ task.priorityLabel || task.priority }}
|
{{ task.priorityLabel || task.priority }}
|
||||||
</el-tag>
|
</el-tag>
|
||||||
</div>
|
</div>
|
||||||
@ -109,10 +127,20 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<el-empty v-if="tasks.length === 0" description="暂无待办任务" :image-size="80" />
|
<el-empty
|
||||||
|
v-if="tasks.length === 0"
|
||||||
|
description="暂无待办任务"
|
||||||
|
:image-size="80"
|
||||||
|
/>
|
||||||
<div v-if="tasks.length > taskPageSize" class="pagination-wrapper">
|
<div v-if="tasks.length > taskPageSize" class="pagination-wrapper">
|
||||||
<el-pagination v-model:current-page="taskCurrentPage" :page-size="taskPageSize" :total="tasks.length"
|
<el-pagination
|
||||||
layout="prev, pager, next" small @current-change="handleTaskPageChange" />
|
v-model:current-page="taskCurrentPage"
|
||||||
|
:page-size="taskPageSize"
|
||||||
|
:total="tasks.length"
|
||||||
|
layout="prev, pager, next"
|
||||||
|
small
|
||||||
|
@current-change="handleTaskPageChange"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -125,7 +153,11 @@
|
|||||||
</el-button> -->
|
</el-button> -->
|
||||||
</div>
|
</div>
|
||||||
<div class="list-content">
|
<div class="list-content">
|
||||||
<div v-for="(activity, idx) in paginatedActivityLogs" :key="idx" class="activity-item">
|
<div
|
||||||
|
v-for="(activity, idx) in paginatedActivityLogs"
|
||||||
|
:key="idx"
|
||||||
|
class="activity-item"
|
||||||
|
>
|
||||||
<div class="activity-icon" :class="activity.type">
|
<div class="activity-icon" :class="activity.type">
|
||||||
<el-icon>
|
<el-icon>
|
||||||
<component :is="getActivityIcon(activity.type)" />
|
<component :is="getActivityIcon(activity.type)" />
|
||||||
@ -136,13 +168,25 @@
|
|||||||
<span class="activity-module">{{ activity.operation }}</span>
|
<span class="activity-module">{{ activity.operation }}</span>
|
||||||
<span class="activity-action">{{ activity.description }}</span>
|
<span class="activity-action">{{ activity.description }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="activity-time">{{ formatTime(activity.timestamp) }}</div>
|
<div class="activity-time">
|
||||||
|
{{ formatTime(activity.timestamp) }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<el-empty v-if="activityLogs.length === 0" description="暂无动态" :image-size="80" />
|
</div>
|
||||||
|
<el-empty
|
||||||
|
v-if="activityLogs.length === 0"
|
||||||
|
description="暂无动态"
|
||||||
|
:image-size="80"
|
||||||
|
/>
|
||||||
<div v-if="totalActivityLogs > pageSize" class="pagination-wrapper">
|
<div v-if="totalActivityLogs > pageSize" class="pagination-wrapper">
|
||||||
<el-pagination v-model:current-page="currentPage" :page-size="pageSize" :total="totalActivityLogs"
|
<el-pagination
|
||||||
layout="prev, pager, next" small @current-change="handlePageChange" />
|
v-model:current-page="currentPage"
|
||||||
|
:page-size="pageSize"
|
||||||
|
:total="totalActivityLogs"
|
||||||
|
layout="prev, pager, next"
|
||||||
|
small
|
||||||
|
@current-change="handlePageChange"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -168,16 +212,28 @@ import {
|
|||||||
} from "@element-plus/icons-vue";
|
} from "@element-plus/icons-vue";
|
||||||
import { getKnowledgeCount } from "@/api/knowledge";
|
import { getKnowledgeCount } from "@/api/knowledge";
|
||||||
import { getTodoTasks } from "@/api/tasks";
|
import { getTodoTasks } from "@/api/tasks";
|
||||||
import { getPlatformStats, getTenantStats, getActivityLogs } from "@/api/dashboard";
|
import {
|
||||||
|
getPlatformStats,
|
||||||
|
getTenantStats,
|
||||||
|
getActivityLogs,
|
||||||
|
} from "@/api/dashboard";
|
||||||
import { useAuthStore } from "@/stores/auth";
|
import { useAuthStore } from "@/stores/auth";
|
||||||
import { useDictStore } from '@/stores/dict'
|
import { useDictStore } from "@/stores/dict";
|
||||||
|
|
||||||
Chart.register(...registerables);
|
Chart.register(...registerables);
|
||||||
|
|
||||||
// 当前日期
|
// 当前日期
|
||||||
const currentDate = computed(() => {
|
const currentDate = computed(() => {
|
||||||
const date = new Date();
|
const date = new Date();
|
||||||
const weekdays = ["星期日", "星期一", "星期二", "星期三", "星期四", "星期五", "星期六"];
|
const weekdays = [
|
||||||
|
"星期日",
|
||||||
|
"星期一",
|
||||||
|
"星期二",
|
||||||
|
"星期三",
|
||||||
|
"星期四",
|
||||||
|
"星期五",
|
||||||
|
"星期六",
|
||||||
|
];
|
||||||
const month = date.getMonth() + 1;
|
const month = date.getMonth() + 1;
|
||||||
const day = date.getDate();
|
const day = date.getDate();
|
||||||
const weekday = weekdays[date.getDay()];
|
const weekday = weekdays[date.getDay()];
|
||||||
@ -228,7 +284,7 @@ const authStore = useAuthStore();
|
|||||||
|
|
||||||
// 判断是否为租户员工登录
|
// 判断是否为租户员工登录
|
||||||
const isEmployee = computed(() => {
|
const isEmployee = computed(() => {
|
||||||
return authStore.user?.type === 'employee';
|
return authStore.user?.type === "employee";
|
||||||
});
|
});
|
||||||
|
|
||||||
// 获取平台统计数据
|
// 获取平台统计数据
|
||||||
@ -241,7 +297,9 @@ const fetchPlatformStats = async () => {
|
|||||||
// 知识库
|
// 知识库
|
||||||
if (data.knowledgeCount) {
|
if (data.knowledgeCount) {
|
||||||
stats.value[0].value = data.knowledgeCount.total?.toString() || "0";
|
stats.value[0].value = data.knowledgeCount.total?.toString() || "0";
|
||||||
stats.value[0].change = parseFloat((data.knowledgeCount.growthRate || 0).toFixed(1));
|
stats.value[0].change = parseFloat(
|
||||||
|
(data.knowledgeCount.growthRate || 0).toFixed(1)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 用户数
|
// 用户数
|
||||||
@ -274,7 +332,9 @@ const fetchTenantStats = async () => {
|
|||||||
// 知识库
|
// 知识库
|
||||||
if (data.knowledgeCount) {
|
if (data.knowledgeCount) {
|
||||||
stats.value[0].value = data.knowledgeCount.total?.toString() || "0";
|
stats.value[0].value = data.knowledgeCount.total?.toString() || "0";
|
||||||
stats.value[0].change = parseFloat((data.knowledgeCount.growthRate || 0).toFixed(1));
|
stats.value[0].change = parseFloat(
|
||||||
|
(data.knowledgeCount.growthRate || 0).toFixed(1)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 员工数
|
// 员工数
|
||||||
@ -307,7 +367,7 @@ const priorityDict = ref<any[]>([]);
|
|||||||
async function fetchPriorityDict() {
|
async function fetchPriorityDict() {
|
||||||
try {
|
try {
|
||||||
const dictStore = useDictStore();
|
const dictStore = useDictStore();
|
||||||
priorityDict.value = await dictStore.getDictItems('task_priority');
|
priorityDict.value = await dictStore.getDictItems("task_priority");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
@ -325,17 +385,25 @@ async function fetchTodoTasksDashboard() {
|
|||||||
date: item.created_time || item.apply_time || item.updated_time || "",
|
date: item.created_time || item.apply_time || item.updated_time || "",
|
||||||
priority: item.priority || "",
|
priority: item.priority || "",
|
||||||
priorityLabel: (() => {
|
priorityLabel: (() => {
|
||||||
const dictItem = priorityDict.value.find((d: any) => String(d.dict_value) === String(item.priority));
|
const dictItem = priorityDict.value.find(
|
||||||
return dictItem?.dict_label || (item.priority ? capitalize(item.priority) : 'Medium');
|
(d: any) => String(d.dict_value) === String(item.priority)
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
dictItem?.dict_label ||
|
||||||
|
(item.priority ? capitalize(item.priority) : "Medium")
|
||||||
|
);
|
||||||
})(),
|
})(),
|
||||||
priorityTagType: (() => {
|
priorityTagType: (() => {
|
||||||
const dictItem = priorityDict.value.find((d: any) => String(d.dict_value) === String(item.priority));
|
const dictItem = priorityDict.value.find(
|
||||||
|
(d: any) => String(d.dict_value) === String(item.priority)
|
||||||
|
);
|
||||||
return dictItem?.dict_tag_type || undefined;
|
return dictItem?.dict_tag_type || undefined;
|
||||||
})(),
|
})(),
|
||||||
completed: item.task_status === 'completed' || item.task_status === 'closed',
|
completed:
|
||||||
|
item.task_status === "completed" || item.task_status === "closed",
|
||||||
}));
|
}));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('获取待办任务失败', e);
|
console.warn("获取待办任务失败", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -377,10 +445,10 @@ const fetchActivityLogs = async () => {
|
|||||||
activityLogs.value = data.data.logs;
|
activityLogs.value = data.data.logs;
|
||||||
totalActivityLogs.value = data.data.logs.length;
|
totalActivityLogs.value = data.data.logs.length;
|
||||||
} else {
|
} else {
|
||||||
console.warn('Unexpected response format:', data);
|
console.warn("Unexpected response format:", data);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to fetch activity logs:', e);
|
console.error("Failed to fetch activity logs:", e);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -391,7 +459,7 @@ const handlePageChange = (page: number) => {
|
|||||||
|
|
||||||
// 格式化时间
|
// 格式化时间
|
||||||
const formatTime = (timestamp: string | Date) => {
|
const formatTime = (timestamp: string | Date) => {
|
||||||
if (!timestamp) return '-';
|
if (!timestamp) return "-";
|
||||||
const date = new Date(timestamp);
|
const date = new Date(timestamp);
|
||||||
if (isNaN(date.getTime())) return String(timestamp);
|
if (isNaN(date.getTime())) return String(timestamp);
|
||||||
|
|
||||||
@ -409,18 +477,18 @@ const formatTime = (timestamp: string | Date) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const year = date.getFullYear();
|
const year = date.getFullYear();
|
||||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||||
const day = String(date.getDate()).padStart(2, '0');
|
const day = String(date.getDate()).padStart(2, "0");
|
||||||
const hours = String(date.getHours()).padStart(2, '0');
|
const hours = String(date.getHours()).padStart(2, "0");
|
||||||
const minutes = String(date.getMinutes()).padStart(2, '0');
|
const minutes = String(date.getMinutes()).padStart(2, "0");
|
||||||
return `${year}-${month}-${day} ${hours}:${minutes}`;
|
return `${year}-${month}-${day} ${hours}:${minutes}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
// 获取活动图标
|
// 获取活动图标
|
||||||
const getActivityIcon = (type: string) => {
|
const getActivityIcon = (type: string) => {
|
||||||
if (type === 'operation') return 'Edit';
|
if (type === "operation") return "Edit";
|
||||||
if (type === 'access') return 'View';
|
if (type === "access") return "View";
|
||||||
return 'Document';
|
return "Document";
|
||||||
};
|
};
|
||||||
|
|
||||||
// 获取优先级类型
|
// 获取优先级类型
|
||||||
@ -455,7 +523,9 @@ onMounted(() => {
|
|||||||
fetchTodoTasksDashboard();
|
fetchTodoTasksDashboard();
|
||||||
|
|
||||||
// 折线图
|
// 折线图
|
||||||
const lineChartEl = document.getElementById("lineChart") as HTMLCanvasElement | null;
|
const lineChartEl = document.getElementById(
|
||||||
|
"lineChart"
|
||||||
|
) as HTMLCanvasElement | null;
|
||||||
if (lineChartEl) {
|
if (lineChartEl) {
|
||||||
new Chart(lineChartEl, {
|
new Chart(lineChartEl, {
|
||||||
type: "line",
|
type: "line",
|
||||||
@ -517,7 +587,9 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 柱状图
|
// 柱状图
|
||||||
const barChartEl = document.getElementById("barChart") as HTMLCanvasElement | null;
|
const barChartEl = document.getElementById(
|
||||||
|
"barChart"
|
||||||
|
) as HTMLCanvasElement | null;
|
||||||
if (barChartEl) {
|
if (barChartEl) {
|
||||||
new Chart(barChartEl, {
|
new Chart(barChartEl, {
|
||||||
type: "bar",
|
type: "bar",
|
||||||
@ -577,7 +649,6 @@ onMounted(() => {
|
|||||||
|
|
||||||
<style lang="less" scoped>
|
<style lang="less" scoped>
|
||||||
.dashboard {
|
.dashboard {
|
||||||
padding: 24px;
|
|
||||||
min-height: 100%;
|
min-height: 100%;
|
||||||
background-color: var(--el-bg-color-page);
|
background-color: var(--el-bg-color-page);
|
||||||
}
|
}
|
||||||
@ -803,7 +874,6 @@ onMounted(() => {
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.task-item {
|
.task-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
@ -847,7 +917,7 @@ onMounted(() => {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.el-tag{
|
.el-tag {
|
||||||
margin-left: 8px !important;
|
margin-left: 8px !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -943,7 +1013,6 @@ onMounted(() => {
|
|||||||
|
|
||||||
// 响应式
|
// 响应式
|
||||||
@media (max-width: 1200px) {
|
@media (max-width: 1200px) {
|
||||||
|
|
||||||
.charts-section,
|
.charts-section,
|
||||||
.lists-section {
|
.lists-section {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
@ -951,10 +1020,6 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.dashboard {
|
|
||||||
padding: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.welcome-section {
|
.welcome-section {
|
||||||
padding: 24px 16px;
|
padding: 24px 16px;
|
||||||
|
|
||||||
|
|||||||
@ -152,14 +152,14 @@
|
|||||||
|
|
||||||
<el-form-item label="菜单类型" prop="MenuType">
|
<el-form-item label="菜单类型" prop="MenuType">
|
||||||
<el-radio-group v-model="currentMenu.MenuType" style="width: 100%">
|
<el-radio-group v-model="currentMenu.MenuType" style="width: 100%">
|
||||||
<el-radio-button :value="1">页面菜单</el-radio-button>
|
<el-radio-button :value="1">目录</el-radio-button>
|
||||||
<el-radio-button :value="2">目录菜单</el-radio-button>
|
<el-radio-button :value="2">页面</el-radio-button>
|
||||||
<el-radio-button :value="3">权限按钮</el-radio-button>
|
<el-radio-button :value="3">按钮</el-radio-button>
|
||||||
</el-radio-group>
|
</el-radio-group>
|
||||||
<div style="margin-top: 8px; font-size: 12px; color: var(--el-text-color-secondary);">
|
<div style="margin-top: 8px; font-size: 12px; color: var(--el-text-color-secondary);">
|
||||||
<div>• 页面菜单:有路由和组件地址,用于<span style="color: var(--el-color-primary);">页面管理</span></div>
|
<div>• 目录:只有路由地址,用于<span style="color: var(--el-color-primary);">目录管理</span>和<span style="color: var(--el-color-primary);">菜单分组</span></div>
|
||||||
<div>• 目录菜单:只有路由地址,用于<span style="color: var(--el-color-primary);">接口管理</span>和<span style="color: var(--el-color-primary);">目录管理</span></div>
|
<div>• 页面:有路由和组件地址,用于<span style="color: var(--el-color-primary);">页面管理</span></div>
|
||||||
<div>• 权限按钮:无路由和组件,仅用于权限控制</div>
|
<div>• 按钮:无路由和组件,用于<span style="color: var(--el-color-primary);">接口管理</span>和<span style="color: var(--el-color-primary);">权限控制</span></div>
|
||||||
</div>
|
</div>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
|
||||||
@ -174,7 +174,7 @@
|
|||||||
<el-form-item
|
<el-form-item
|
||||||
label="组件路径"
|
label="组件路径"
|
||||||
prop="ComponentPath"
|
prop="ComponentPath"
|
||||||
v-if="currentMenu.MenuType === 1"
|
v-if="currentMenu.MenuType === 2"
|
||||||
>
|
>
|
||||||
<el-input
|
<el-input
|
||||||
v-model="currentMenu.ComponentPath"
|
v-model="currentMenu.ComponentPath"
|
||||||
@ -224,7 +224,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from "vue";
|
import { ref, onMounted, watch } from "vue";
|
||||||
import { ElMessage, ElMessageBox, ElForm } from "element-plus";
|
import { ElMessage, ElMessageBox, ElForm } from "element-plus";
|
||||||
import { Plus, CirclePlus, Edit, Delete, Refresh, FolderOpened, Folder } from "@element-plus/icons-vue";
|
import { Plus, CirclePlus, Edit, Delete, Refresh, FolderOpened, Folder } from "@element-plus/icons-vue";
|
||||||
import { getAllMenus, updateMenuStatus, createMenu, updateMenu, deleteMenu } from "@/api/menu";
|
import { getAllMenus, updateMenuStatus, createMenu, updateMenu, deleteMenu } from "@/api/menu";
|
||||||
@ -241,7 +241,7 @@ interface Menu {
|
|||||||
ComponentPath: string;
|
ComponentPath: string;
|
||||||
IsExternal: 0 | 1;
|
IsExternal: 0 | 1;
|
||||||
ExternalUrl: string;
|
ExternalUrl: string;
|
||||||
MenuType: 1 | 2 | 3; // 1:普通菜单 2:分组菜单 3:按钮菜单
|
MenuType: 1 | 2 | 3; // 1:目录 2:页面 3:按钮
|
||||||
Permission: string;
|
Permission: string;
|
||||||
IsShow: 0 | 1;
|
IsShow: 0 | 1;
|
||||||
CreateTime: string;
|
CreateTime: string;
|
||||||
@ -279,13 +279,66 @@ const currentMenu = ref<Partial<Menu>>({
|
|||||||
IsShow: 1,
|
IsShow: 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 表单验证规则
|
||||||
// 表单验证规则
|
// 表单验证规则
|
||||||
const formRules = ref({
|
const formRules = ref({
|
||||||
Name: [{ required: true, message: "请输入菜单名称", trigger: "blur" }],
|
Name: [{ required: true, message: "请输入菜单名称", trigger: "blur" }],
|
||||||
MenuType: [{ required: true, message: "请选择菜单类型", trigger: "change" }],
|
MenuType: [{ required: true, message: "请选择菜单类型", trigger: "change" }],
|
||||||
|
Path: [
|
||||||
|
{
|
||||||
|
required: true,
|
||||||
|
validator: (rule: any, value: any, callback: any) => {
|
||||||
|
if (currentMenu.value.MenuType === 3) {
|
||||||
|
// 按钮类型不需要路径
|
||||||
|
callback();
|
||||||
|
} else if (!value || value.trim() === "") {
|
||||||
|
callback(new Error("请输入路由地址"));
|
||||||
|
} else {
|
||||||
|
callback();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
trigger: "blur"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
ComponentPath: [
|
||||||
|
{
|
||||||
|
required: true,
|
||||||
|
validator: (rule: any, value: any, callback: any) => {
|
||||||
|
if (currentMenu.value.MenuType === 2) {
|
||||||
|
// 页面类型需要组件路径
|
||||||
|
if (!value || value.trim() === "") {
|
||||||
|
callback(new Error("请输入组件路径"));
|
||||||
|
} else {
|
||||||
|
callback();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 目录和按钮类型不需要组件路径
|
||||||
|
callback();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
trigger: "blur"
|
||||||
|
}
|
||||||
|
],
|
||||||
Order: [{ required: true, message: "请输入排序号", trigger: "blur" }],
|
Order: [{ required: true, message: "请输入排序号", trigger: "blur" }],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 监听菜单类型变化,自动清空不相关的字段
|
||||||
|
watch(() => currentMenu.value.MenuType, (newType, oldType) => {
|
||||||
|
if (newType === oldType) return; // 避免初始化时的触发
|
||||||
|
|
||||||
|
if (newType === 1) {
|
||||||
|
// 目录:清空组件路径,保留路径
|
||||||
|
currentMenu.value.ComponentPath = "";
|
||||||
|
} else if (newType === 2) {
|
||||||
|
// 页面:保留路径和组件路径
|
||||||
|
// 不清空,保持现有值
|
||||||
|
} else if (newType === 3) {
|
||||||
|
// 按钮:清空路径和组件路径
|
||||||
|
currentMenu.value.Path = "";
|
||||||
|
currentMenu.value.ComponentPath = "";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// 级联选择器配置(匹配 Pascal 命名)
|
// 级联选择器配置(匹配 Pascal 命名)
|
||||||
const cascaderProps = ref({
|
const cascaderProps = ref({
|
||||||
value: "Id",
|
value: "Id",
|
||||||
@ -445,7 +498,7 @@ const buildMenuTree = (menuList: Menu[]): Menu[] => {
|
|||||||
|
|
||||||
// 获取菜单类型名称
|
// 获取菜单类型名称
|
||||||
const getMenuTypeName = (type: number) => {
|
const getMenuTypeName = (type: number) => {
|
||||||
const typeMap = { 1: "页面菜单", 2: "目录菜单", 3: "权限按钮" };
|
const typeMap = { 1: "目录", 2: "页面", 3: "按钮" };
|
||||||
return typeMap[type as keyof typeof typeMap] || "未知类型";
|
return typeMap[type as keyof typeof typeMap] || "未知类型";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -106,6 +106,9 @@
|
|||||||
<el-icon v-if="data.menu_type === 1" class="menu-icon">
|
<el-icon v-if="data.menu_type === 1" class="menu-icon">
|
||||||
<Folder />
|
<Folder />
|
||||||
</el-icon>
|
</el-icon>
|
||||||
|
<el-icon v-else-if="data.menu_type === 2" class="page-icon">
|
||||||
|
<Document />
|
||||||
|
</el-icon>
|
||||||
<el-icon v-else class="api-icon">
|
<el-icon v-else class="api-icon">
|
||||||
<Link />
|
<Link />
|
||||||
</el-icon>
|
</el-icon>
|
||||||
@ -113,10 +116,13 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="node-info">
|
<div class="node-info">
|
||||||
<el-tag v-if="data.menu_type === 1" type="primary" size="small">
|
<el-tag v-if="data.menu_type === 1" type="primary" size="small">
|
||||||
|
目录
|
||||||
|
</el-tag>
|
||||||
|
<el-tag v-else-if="data.menu_type === 2" type="success" size="small">
|
||||||
页面
|
页面
|
||||||
</el-tag>
|
</el-tag>
|
||||||
<el-tag v-else type="success" size="small">
|
<el-tag v-else type="warning" size="small">
|
||||||
API
|
按钮
|
||||||
</el-tag>
|
</el-tag>
|
||||||
<el-tag v-if="data.permission" type="info" size="small" class="permission-tag">
|
<el-tag v-if="data.permission" type="info" size="small" class="permission-tag">
|
||||||
{{ data.permission }}
|
{{ data.permission }}
|
||||||
@ -157,6 +163,7 @@ import {
|
|||||||
Check,
|
Check,
|
||||||
Folder,
|
Folder,
|
||||||
Link,
|
Link,
|
||||||
|
Document,
|
||||||
Select,
|
Select,
|
||||||
RefreshLeft,
|
RefreshLeft,
|
||||||
} from '@element-plus/icons-vue';
|
} from '@element-plus/icons-vue';
|
||||||
|
|||||||
451
server/controllers/articles.go
Normal file
451
server/controllers/articles.go
Normal file
@ -0,0 +1,451 @@
|
|||||||
|
package controllers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"server/models"
|
||||||
|
"server/services"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/beego/beego/v2/server/web"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ArticlesController 文章管理控制器
|
||||||
|
type ArticlesController struct {
|
||||||
|
web.Controller
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListArticles 获取文章列表
|
||||||
|
// 支持的状态值:
|
||||||
|
// 0=草稿, 1=待审核, 2=已发布, 3=隐藏
|
||||||
|
// 不传status参数或传空值时返回所有状态的文章
|
||||||
|
func (c *ArticlesController) ListArticles() {
|
||||||
|
// 获取查询参数
|
||||||
|
page, _ := c.GetInt("page", 1)
|
||||||
|
pageSize, _ := c.GetInt("pageSize", 10)
|
||||||
|
keyword := c.GetString("keyword")
|
||||||
|
cateStr := c.GetString("cate")
|
||||||
|
statusStr := c.GetString("status")
|
||||||
|
|
||||||
|
// 从JWT上下文中获取租户ID和用户ID
|
||||||
|
tenantId, _ := c.GetInt("tenantId", 0)
|
||||||
|
userId, _ := c.GetInt("userId", 0)
|
||||||
|
|
||||||
|
var cate int
|
||||||
|
var status int8 = -1 // -1表示不筛选状态,显示所有文章
|
||||||
|
|
||||||
|
if cateStr != "" {
|
||||||
|
cate, _ = strconv.Atoi(cateStr)
|
||||||
|
}
|
||||||
|
|
||||||
|
if statusStr != "" {
|
||||||
|
statusInt, _ := strconv.Atoi(statusStr)
|
||||||
|
status = int8(statusInt)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 调用服务层获取文章列表
|
||||||
|
articles, total, err := services.GetArticlesList(tenantId, userId, page, pageSize, keyword, cate, status)
|
||||||
|
if err != nil {
|
||||||
|
c.Data["json"] = map[string]interface{}{
|
||||||
|
"code": 1,
|
||||||
|
"message": "获取文章列表失败: " + err.Error(),
|
||||||
|
"data": nil,
|
||||||
|
}
|
||||||
|
c.ServeJSON()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 格式化返回数据
|
||||||
|
articleList := make([]map[string]interface{}, 0)
|
||||||
|
for _, article := range articles {
|
||||||
|
articleList = append(articleList, map[string]interface{}{
|
||||||
|
"id": article.Id,
|
||||||
|
"title": article.Title,
|
||||||
|
"cate": article.Cate,
|
||||||
|
"image": article.Image,
|
||||||
|
"desc": article.Desc,
|
||||||
|
"author": article.Author,
|
||||||
|
"content": article.Content,
|
||||||
|
"publisher": article.Publisher,
|
||||||
|
"publishdate": article.Publishdate,
|
||||||
|
"sort": article.Sort,
|
||||||
|
"status": article.Status,
|
||||||
|
"views": article.Views,
|
||||||
|
"likes": article.Likes,
|
||||||
|
"is_trans": article.IsTrans,
|
||||||
|
"transurl": article.Transurl,
|
||||||
|
"push": article.Push,
|
||||||
|
"create_time": article.CreateTime,
|
||||||
|
"update_time": article.UpdateTime,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Data["json"] = map[string]interface{}{
|
||||||
|
"code": 0,
|
||||||
|
"message": "获取文章列表成功",
|
||||||
|
"data": map[string]interface{}{
|
||||||
|
"list": articleList,
|
||||||
|
"total": total,
|
||||||
|
"page": page,
|
||||||
|
"pageSize": pageSize,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
c.ServeJSON()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetArticle 获取文章详情
|
||||||
|
func (c *ArticlesController) GetArticle() {
|
||||||
|
// 从URL获取文章ID
|
||||||
|
articleId, err := c.GetInt(":id")
|
||||||
|
if err != nil || articleId <= 0 {
|
||||||
|
c.Data["json"] = map[string]interface{}{
|
||||||
|
"code": 1,
|
||||||
|
"message": "无效的文章ID",
|
||||||
|
"data": nil,
|
||||||
|
}
|
||||||
|
c.ServeJSON()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 调用服务层获取文章详情
|
||||||
|
article, err := services.GetArticleById(articleId)
|
||||||
|
if err != nil {
|
||||||
|
c.Data["json"] = map[string]interface{}{
|
||||||
|
"code": 1,
|
||||||
|
"message": "获取文章详情失败: " + err.Error(),
|
||||||
|
"data": nil,
|
||||||
|
}
|
||||||
|
c.ServeJSON()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if article == nil {
|
||||||
|
c.Data["json"] = map[string]interface{}{
|
||||||
|
"code": 1,
|
||||||
|
"message": "文章不存在",
|
||||||
|
"data": nil,
|
||||||
|
}
|
||||||
|
c.ServeJSON()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 增加浏览量
|
||||||
|
services.IncrementArticleViews(articleId)
|
||||||
|
|
||||||
|
c.Data["json"] = map[string]interface{}{
|
||||||
|
"code": 0,
|
||||||
|
"message": "获取文章详情成功",
|
||||||
|
"data": map[string]interface{}{
|
||||||
|
"id": article.Id,
|
||||||
|
"title": article.Title,
|
||||||
|
"cate": article.Cate,
|
||||||
|
"image": article.Image,
|
||||||
|
"desc": article.Desc,
|
||||||
|
"author": article.Author,
|
||||||
|
"content": article.Content,
|
||||||
|
"publisher": article.Publisher,
|
||||||
|
"publishdate": article.Publishdate,
|
||||||
|
"sort": article.Sort,
|
||||||
|
"status": article.Status,
|
||||||
|
"views": article.Views + 1, // 返回增加后的浏览量
|
||||||
|
"likes": article.Likes,
|
||||||
|
"is_trans": article.IsTrans,
|
||||||
|
"transurl": article.Transurl,
|
||||||
|
"push": article.Push,
|
||||||
|
"create_time": article.CreateTime,
|
||||||
|
"update_time": article.UpdateTime,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
c.ServeJSON()
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateArticle 创建文章
|
||||||
|
func (c *ArticlesController) CreateArticle() {
|
||||||
|
// 定义接收文章数据的结构体
|
||||||
|
var articleData struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
Cate int `json:"cate"`
|
||||||
|
Image string `json:"image"`
|
||||||
|
Desc string `json:"desc"`
|
||||||
|
Author string `json:"author"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
Publisher int `json:"publisher"`
|
||||||
|
Publishdate string `json:"publishdate"`
|
||||||
|
Sort int `json:"sort"`
|
||||||
|
Status int8 `json:"status"`
|
||||||
|
IsTrans string `json:"is_trans"`
|
||||||
|
Transurl string `json:"transurl"`
|
||||||
|
Push string `json:"push"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析请求体JSON数据
|
||||||
|
err := json.Unmarshal(c.Ctx.Input.RequestBody, &articleData)
|
||||||
|
if err != nil {
|
||||||
|
c.Data["json"] = map[string]interface{}{
|
||||||
|
"code": 1,
|
||||||
|
"message": "请求参数格式错误: " + err.Error(),
|
||||||
|
"data": nil,
|
||||||
|
}
|
||||||
|
c.ServeJSON()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 校验必要参数
|
||||||
|
if articleData.Title == "" {
|
||||||
|
c.Data["json"] = map[string]interface{}{
|
||||||
|
"code": 1,
|
||||||
|
"message": "文章标题不能为空",
|
||||||
|
"data": nil,
|
||||||
|
}
|
||||||
|
c.ServeJSON()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if articleData.Content == "" {
|
||||||
|
c.Data["json"] = map[string]interface{}{
|
||||||
|
"code": 1,
|
||||||
|
"message": "文章内容不能为空",
|
||||||
|
"data": nil,
|
||||||
|
}
|
||||||
|
c.ServeJSON()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 构建文章对象
|
||||||
|
var article models.Articles
|
||||||
|
article.Title = articleData.Title
|
||||||
|
article.Cate = articleData.Cate
|
||||||
|
article.Image = articleData.Image
|
||||||
|
article.Desc = articleData.Desc
|
||||||
|
article.Author = articleData.Author
|
||||||
|
article.Content = articleData.Content
|
||||||
|
article.Publisher = articleData.Publisher
|
||||||
|
|
||||||
|
// 处理发布时间
|
||||||
|
if articleData.Publishdate != "" {
|
||||||
|
if publishTime, err := time.Parse("2006-01-02 15:04:05", articleData.Publishdate); err == nil {
|
||||||
|
article.Publishdate = &publishTime
|
||||||
|
} else if publishTime, err := time.Parse("2006-01-02", articleData.Publishdate); err == nil {
|
||||||
|
article.Publishdate = &publishTime
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
article.Sort = articleData.Sort
|
||||||
|
article.Status = articleData.Status
|
||||||
|
article.IsTrans = articleData.IsTrans
|
||||||
|
article.Transurl = articleData.Transurl
|
||||||
|
article.Push = articleData.Push
|
||||||
|
article.CreateTime = time.Now()
|
||||||
|
now := time.Now()
|
||||||
|
article.UpdateTime = &now
|
||||||
|
|
||||||
|
// 调用服务层创建文章
|
||||||
|
newArticle, err := services.CreateArticle(&article)
|
||||||
|
if err != nil {
|
||||||
|
c.Data["json"] = map[string]interface{}{
|
||||||
|
"code": 1,
|
||||||
|
"message": "创建文章失败: " + err.Error(),
|
||||||
|
"data": nil,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
c.Data["json"] = map[string]interface{}{
|
||||||
|
"code": 0,
|
||||||
|
"message": "创建文章成功",
|
||||||
|
"data": map[string]interface{}{
|
||||||
|
"id": newArticle.Id,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.ServeJSON()
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateArticle 更新文章
|
||||||
|
func (c *ArticlesController) UpdateArticle() {
|
||||||
|
// 从URL获取文章ID
|
||||||
|
articleId, err := c.GetInt(":id")
|
||||||
|
if err != nil || articleId <= 0 {
|
||||||
|
c.Data["json"] = map[string]interface{}{
|
||||||
|
"code": 1,
|
||||||
|
"message": "无效的文章ID",
|
||||||
|
"data": nil,
|
||||||
|
}
|
||||||
|
c.ServeJSON()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 定义接收更新数据的结构体
|
||||||
|
var articleData struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
Cate int `json:"cate"`
|
||||||
|
Image string `json:"image"`
|
||||||
|
Desc string `json:"desc"`
|
||||||
|
Author string `json:"author"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
Publisher int `json:"publisher"`
|
||||||
|
Publishdate string `json:"publishdate"`
|
||||||
|
Sort int `json:"sort"`
|
||||||
|
Status int8 `json:"status"`
|
||||||
|
IsTrans string `json:"is_trans"`
|
||||||
|
Transurl string `json:"transurl"`
|
||||||
|
Push string `json:"push"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析请求体JSON数据
|
||||||
|
err = json.Unmarshal(c.Ctx.Input.RequestBody, &articleData)
|
||||||
|
if err != nil {
|
||||||
|
c.Data["json"] = map[string]interface{}{
|
||||||
|
"code": 1,
|
||||||
|
"message": "请求参数格式错误: " + err.Error(),
|
||||||
|
"data": nil,
|
||||||
|
}
|
||||||
|
c.ServeJSON()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 校验必要参数
|
||||||
|
if articleData.Title == "" {
|
||||||
|
c.Data["json"] = map[string]interface{}{
|
||||||
|
"code": 1,
|
||||||
|
"message": "文章标题不能为空",
|
||||||
|
"data": nil,
|
||||||
|
}
|
||||||
|
c.ServeJSON()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if articleData.Content == "" {
|
||||||
|
c.Data["json"] = map[string]interface{}{
|
||||||
|
"code": 1,
|
||||||
|
"message": "文章内容不能为空",
|
||||||
|
"data": nil,
|
||||||
|
}
|
||||||
|
c.ServeJSON()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 构建文章对象
|
||||||
|
var article models.Articles
|
||||||
|
article.Id = articleId
|
||||||
|
article.Title = articleData.Title
|
||||||
|
article.Cate = articleData.Cate
|
||||||
|
article.Image = articleData.Image
|
||||||
|
article.Desc = articleData.Desc
|
||||||
|
article.Author = articleData.Author
|
||||||
|
article.Content = articleData.Content
|
||||||
|
article.Publisher = articleData.Publisher
|
||||||
|
|
||||||
|
// 处理发布时间
|
||||||
|
if articleData.Publishdate != "" {
|
||||||
|
if publishTime, err := time.Parse("2006-01-02 15:04:05", articleData.Publishdate); err == nil {
|
||||||
|
article.Publishdate = &publishTime
|
||||||
|
} else if publishTime, err := time.Parse("2006-01-02", articleData.Publishdate); err == nil {
|
||||||
|
article.Publishdate = &publishTime
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
article.Sort = articleData.Sort
|
||||||
|
article.Status = articleData.Status
|
||||||
|
article.IsTrans = articleData.IsTrans
|
||||||
|
article.Transurl = articleData.Transurl
|
||||||
|
article.Push = articleData.Push
|
||||||
|
now := time.Now()
|
||||||
|
article.UpdateTime = &now
|
||||||
|
|
||||||
|
// 调用服务层更新文章
|
||||||
|
err = services.UpdateArticle(&article)
|
||||||
|
if err != nil {
|
||||||
|
c.Data["json"] = map[string]interface{}{
|
||||||
|
"code": 1,
|
||||||
|
"message": "更新文章失败: " + err.Error(),
|
||||||
|
"data": nil,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
c.Data["json"] = map[string]interface{}{
|
||||||
|
"code": 0,
|
||||||
|
"message": "更新文章成功",
|
||||||
|
"data": nil,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.ServeJSON()
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteArticle 删除文章
|
||||||
|
func (c *ArticlesController) DeleteArticle() {
|
||||||
|
// 从URL获取文章ID
|
||||||
|
articleId, err := c.GetInt(":id")
|
||||||
|
if err != nil || articleId <= 0 {
|
||||||
|
c.Data["json"] = map[string]interface{}{
|
||||||
|
"code": 1,
|
||||||
|
"message": "无效的文章ID",
|
||||||
|
"data": nil,
|
||||||
|
}
|
||||||
|
c.ServeJSON()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 调用服务层删除文章
|
||||||
|
err = services.DeleteArticle(articleId)
|
||||||
|
if err != nil {
|
||||||
|
c.Data["json"] = map[string]interface{}{
|
||||||
|
"code": 1,
|
||||||
|
"message": "删除文章失败: " + err.Error(),
|
||||||
|
"data": nil,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
c.Data["json"] = map[string]interface{}{
|
||||||
|
"code": 0,
|
||||||
|
"message": "删除文章成功",
|
||||||
|
"data": nil,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.ServeJSON()
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateArticleStatus 更新文章状态
|
||||||
|
func (c *ArticlesController) UpdateArticleStatus() {
|
||||||
|
// 从URL获取文章ID
|
||||||
|
articleId, err := c.GetInt(":id")
|
||||||
|
if err != nil || articleId <= 0 {
|
||||||
|
c.Data["json"] = map[string]interface{}{
|
||||||
|
"code": 1,
|
||||||
|
"message": "无效的文章ID",
|
||||||
|
"data": nil,
|
||||||
|
}
|
||||||
|
c.ServeJSON()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 定义接收状态数据的结构体
|
||||||
|
var statusData struct {
|
||||||
|
Status int8 `json:"status"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析请求体JSON数据
|
||||||
|
err = json.Unmarshal(c.Ctx.Input.RequestBody, &statusData)
|
||||||
|
if err != nil {
|
||||||
|
c.Data["json"] = map[string]interface{}{
|
||||||
|
"code": 1,
|
||||||
|
"message": "请求参数格式错误: " + err.Error(),
|
||||||
|
"data": nil,
|
||||||
|
}
|
||||||
|
c.ServeJSON()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 调用服务层更新文章状态
|
||||||
|
err = services.UpdateArticleStatus(articleId, statusData.Status)
|
||||||
|
if err != nil {
|
||||||
|
c.Data["json"] = map[string]interface{}{
|
||||||
|
"code": 1,
|
||||||
|
"message": "更新文章状态失败: " + err.Error(),
|
||||||
|
"data": nil,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
c.Data["json"] = map[string]interface{}{
|
||||||
|
"code": 0,
|
||||||
|
"message": "更新文章状态成功",
|
||||||
|
"data": nil,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.ServeJSON()
|
||||||
|
}
|
||||||
@ -93,6 +93,7 @@ func (c *AuthController) Login() {
|
|||||||
"avatar": user.Avatar,
|
"avatar": user.Avatar,
|
||||||
"nickname": user.Nickname,
|
"nickname": user.Nickname,
|
||||||
"tenant_id": user.TenantId,
|
"tenant_id": user.TenantId,
|
||||||
|
"uid": user.Uid,
|
||||||
"role": user.Role, // 角色ID
|
"role": user.Role, // 角色ID
|
||||||
"type": "user", // 标识是用户登录
|
"type": "user", // 标识是用户登录
|
||||||
}
|
}
|
||||||
|
|||||||
@ -29,6 +29,7 @@ func (c *UserController) GetAllUsers() {
|
|||||||
for _, user := range users {
|
for _, user := range users {
|
||||||
userList = append(userList, map[string]interface{}{
|
userList = append(userList, map[string]interface{}{
|
||||||
"id": user.Id,
|
"id": user.Id,
|
||||||
|
"uid": user.Uid,
|
||||||
"username": user.Username,
|
"username": user.Username,
|
||||||
"email": user.Email,
|
"email": user.Email,
|
||||||
"avatar": user.Avatar,
|
"avatar": user.Avatar,
|
||||||
@ -79,6 +80,7 @@ func (c *UserController) GetTenantUsers() {
|
|||||||
for _, user := range users {
|
for _, user := range users {
|
||||||
userList = append(userList, map[string]interface{}{
|
userList = append(userList, map[string]interface{}{
|
||||||
"id": user.Id,
|
"id": user.Id,
|
||||||
|
"uid": user.Uid,
|
||||||
"username": user.Username,
|
"username": user.Username,
|
||||||
"email": user.Email,
|
"email": user.Email,
|
||||||
"avatar": user.Avatar,
|
"avatar": user.Avatar,
|
||||||
@ -203,6 +205,7 @@ func (c *UserController) GetUserInfo() {
|
|||||||
"message": "查询成功",
|
"message": "查询成功",
|
||||||
"data": map[string]interface{}{
|
"data": map[string]interface{}{
|
||||||
"id": user.Id,
|
"id": user.Id,
|
||||||
|
"uid": user.Uid,
|
||||||
"username": user.Username,
|
"username": user.Username,
|
||||||
"email": user.Email,
|
"email": user.Email,
|
||||||
"avatar": user.Avatar,
|
"avatar": user.Avatar,
|
||||||
@ -297,6 +300,7 @@ func (c *UserController) AddUser() {
|
|||||||
"message": "用户添加成功",
|
"message": "用户添加成功",
|
||||||
"data": map[string]interface{}{
|
"data": map[string]interface{}{
|
||||||
"id": newUser.Id,
|
"id": newUser.Id,
|
||||||
|
"uid": newUser.Uid,
|
||||||
"username": newUser.Username,
|
"username": newUser.Username,
|
||||||
"email": newUser.Email,
|
"email": newUser.Email,
|
||||||
"nickname": newUser.Nickname,
|
"nickname": newUser.Nickname,
|
||||||
|
|||||||
79
server/migrate_add_uid.sql
Normal file
79
server/migrate_add_uid.sql
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
-- 迁移脚本:为用户表添加租户内自增UID字段
|
||||||
|
-- 执行时间:需要手动执行此脚本
|
||||||
|
|
||||||
|
-- 1. 为yz_users表添加uid字段
|
||||||
|
ALTER TABLE yz_users ADD COLUMN uid INT DEFAULT 0 COMMENT '租户内用户ID,自增';
|
||||||
|
|
||||||
|
-- 2. 为现有用户分配uid(使用存储过程或手动更新)
|
||||||
|
-- 方法1:使用存储过程(推荐)
|
||||||
|
DELIMITER //
|
||||||
|
|
||||||
|
CREATE PROCEDURE update_user_uids()
|
||||||
|
BEGIN
|
||||||
|
DECLARE done INT DEFAULT FALSE;
|
||||||
|
DECLARE current_tenant_id INT;
|
||||||
|
DECLARE current_id INT;
|
||||||
|
DECLARE uid_counter INT DEFAULT 1;
|
||||||
|
|
||||||
|
-- 游标用于遍历所有租户
|
||||||
|
DECLARE tenant_cursor CURSOR FOR
|
||||||
|
SELECT DISTINCT tenant_id FROM yz_users WHERE delete_time IS NULL ORDER BY tenant_id;
|
||||||
|
|
||||||
|
-- 游标用于遍历每个租户内的用户
|
||||||
|
DECLARE user_cursor CURSOR FOR
|
||||||
|
SELECT id FROM yz_users
|
||||||
|
WHERE tenant_id = current_tenant_id AND delete_time IS NULL
|
||||||
|
ORDER BY id;
|
||||||
|
|
||||||
|
DECLARE CONTINUE HANDLER FOR NOT FOUND SET done = TRUE;
|
||||||
|
|
||||||
|
OPEN tenant_cursor;
|
||||||
|
|
||||||
|
tenant_loop: LOOP
|
||||||
|
FETCH tenant_cursor INTO current_tenant_id;
|
||||||
|
IF done THEN
|
||||||
|
LEAVE tenant_loop;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
SET uid_counter = 1;
|
||||||
|
SET done = FALSE;
|
||||||
|
|
||||||
|
OPEN user_cursor;
|
||||||
|
|
||||||
|
user_loop: LOOP
|
||||||
|
FETCH user_cursor INTO current_id;
|
||||||
|
IF done THEN
|
||||||
|
LEAVE user_loop;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
UPDATE yz_users SET uid = uid_counter WHERE id = current_id;
|
||||||
|
SET uid_counter = uid_counter + 1;
|
||||||
|
END LOOP user_loop;
|
||||||
|
|
||||||
|
CLOSE user_cursor;
|
||||||
|
SET done = FALSE;
|
||||||
|
END LOOP tenant_loop;
|
||||||
|
|
||||||
|
CLOSE tenant_cursor;
|
||||||
|
END //
|
||||||
|
|
||||||
|
DELIMITER ;
|
||||||
|
|
||||||
|
-- 执行存储过程
|
||||||
|
CALL update_user_uids();
|
||||||
|
|
||||||
|
-- 删除存储过程
|
||||||
|
DROP PROCEDURE update_user_uids();
|
||||||
|
|
||||||
|
-- 3. 创建索引以提高查询性能
|
||||||
|
CREATE INDEX idx_yz_users_tenant_uid ON yz_users(tenant_id, uid);
|
||||||
|
CREATE INDEX idx_yz_users_uid ON yz_users(uid);
|
||||||
|
|
||||||
|
-- 4. 验证迁移结果
|
||||||
|
-- SELECT tenant_id, uid, username FROM yz_users WHERE delete_time IS NULL ORDER BY tenant_id, uid;
|
||||||
|
|
||||||
|
-- 5. 添加注释说明
|
||||||
|
-- uid字段说明:
|
||||||
|
-- - 在每个tenant_id下从1开始递增
|
||||||
|
-- - 不同租户的uid可以重复
|
||||||
|
-- - 用于租户内的用户标识,避免使用全局id
|
||||||
32
server/models/articles.go
Normal file
32
server/models/articles.go
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Articles 文章模型(对应表 yz_articles)
|
||||||
|
type Articles struct {
|
||||||
|
Id int `orm:"auto" json:"id"`
|
||||||
|
Title string `orm:"column(title);size(255)" json:"title"`
|
||||||
|
Cate int `orm:"column(cate);default(0)" json:"cate"`
|
||||||
|
Image string `orm:"column(image);type(text);null" json:"image,omitempty"`
|
||||||
|
Desc string `orm:"column(desc);size(500);null" json:"desc,omitempty"`
|
||||||
|
Author string `orm:"column(author);size(255);null" json:"author,omitempty"`
|
||||||
|
Content string `orm:"column(content);type(text)" json:"content"`
|
||||||
|
Publisher int `orm:"column(publisher);null" json:"publisher,omitempty"`
|
||||||
|
Publishdate *time.Time `orm:"column(publishdate);type(datetime);null" json:"publishdate,omitempty"`
|
||||||
|
Sort int `orm:"column(sort);null" json:"sort,omitempty"`
|
||||||
|
Status int8 `orm:"column(status);default(0)" json:"status"`
|
||||||
|
Views int `orm:"column(views);default(0)" json:"views"`
|
||||||
|
Likes int `orm:"column(likes);default(0)" json:"likes"`
|
||||||
|
IsTrans string `orm:"column(is_trans);size(1);default('0')" json:"is_trans"`
|
||||||
|
Transurl string `orm:"column(transurl);type(text);null" json:"transurl,omitempty"`
|
||||||
|
Push string `orm:"column(push);size(255);default('0')" json:"push"`
|
||||||
|
CreateTime time.Time `orm:"column(create_time);type(datetime);auto_now_add" json:"create_time"`
|
||||||
|
UpdateTime *time.Time `orm:"column(update_time);type(datetime);null" json:"update_time,omitempty"`
|
||||||
|
DeleteTime *time.Time `orm:"column(delete_time);type(datetime);null" json:"delete_time,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Articles) TableName() string {
|
||||||
|
return "yz_articles"
|
||||||
|
}
|
||||||
@ -13,6 +13,7 @@ import (
|
|||||||
// User 用户模型
|
// User 用户模型
|
||||||
type User struct {
|
type User struct {
|
||||||
Id int `orm:"auto"`
|
Id int `orm:"auto"`
|
||||||
|
Uid int `orm:"column(uid);default(0)" json:"uid"` // 租户内用户ID,自增
|
||||||
TenantId int `orm:"column(tenant_id);default(0)" json:"tenant_id"`
|
TenantId int `orm:"column(tenant_id);default(0)" json:"tenant_id"`
|
||||||
Username string
|
Username string
|
||||||
Password string
|
Password string
|
||||||
@ -59,6 +60,7 @@ func Init(version string) {
|
|||||||
orm.RegisterModel(new(DictItem))
|
orm.RegisterModel(new(DictItem))
|
||||||
orm.RegisterModel(new(OperationLog))
|
orm.RegisterModel(new(OperationLog))
|
||||||
orm.RegisterModel(new(AccessLog))
|
orm.RegisterModel(new(AccessLog))
|
||||||
|
orm.RegisterModel(new(Articles))
|
||||||
|
|
||||||
ormConfig, err := beego.AppConfig.String("orm")
|
ormConfig, err := beego.AppConfig.String("orm")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@ -391,4 +391,9 @@ func init() {
|
|||||||
beego.Router("/api/access-logs/user/stats", &controllers.OperationLogController{}, "get:GetUserAccessStats")
|
beego.Router("/api/access-logs/user/stats", &controllers.OperationLogController{}, "get:GetUserAccessStats")
|
||||||
beego.Router("/api/access-logs/clear", &controllers.OperationLogController{}, "post:ClearOldAccessLogs")
|
beego.Router("/api/access-logs/clear", &controllers.OperationLogController{}, "post:ClearOldAccessLogs")
|
||||||
|
|
||||||
|
// 文章管理路由
|
||||||
|
beego.Router("/api/articles", &controllers.ArticlesController{}, "get:ListArticles;post:CreateArticle")
|
||||||
|
beego.Router("/api/articles/:id", &controllers.ArticlesController{}, "get:GetArticle;put:UpdateArticle;delete:DeleteArticle")
|
||||||
|
beego.Router("/api/articles/:id/status", &controllers.ArticlesController{}, "patch:UpdateArticleStatus")
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
217
server/services/articles.go
Normal file
217
server/services/articles.go
Normal file
@ -0,0 +1,217 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"server/models"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/beego/beego/v2/client/orm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GetArticlesList 获取文章列表
|
||||||
|
func GetArticlesList(tenant_id int, user_id int, page, pageSize int, keyword string, cate int, status int8) ([]*models.Articles, int64, error) {
|
||||||
|
o := orm.NewOrm()
|
||||||
|
|
||||||
|
qs := o.QueryTable(new(models.Articles))
|
||||||
|
|
||||||
|
// 过滤已删除的文章
|
||||||
|
qs = qs.Filter("delete_time__isnull", true)
|
||||||
|
|
||||||
|
// 关键词搜索
|
||||||
|
if keyword != "" {
|
||||||
|
cond := orm.NewCondition()
|
||||||
|
cond1 := cond.Or("title__icontains", keyword).Or("content__icontains", keyword).Or("desc__icontains", keyword)
|
||||||
|
qs = qs.SetCond(cond1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 分类筛选
|
||||||
|
if cate > 0 {
|
||||||
|
qs = qs.Filter("cate", cate)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 状态筛选
|
||||||
|
if status >= 0 {
|
||||||
|
qs = qs.Filter("status", status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取总数
|
||||||
|
total, err := qs.Count()
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 分页查询
|
||||||
|
if page <= 0 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
|
if pageSize <= 0 {
|
||||||
|
pageSize = 10
|
||||||
|
}
|
||||||
|
offset := (page - 1) * pageSize
|
||||||
|
|
||||||
|
var articles []*models.Articles
|
||||||
|
_, err = qs.OrderBy("-create_time").Limit(pageSize, offset).All(&articles)
|
||||||
|
|
||||||
|
return articles, total, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetArticleById 根据ID获取文章
|
||||||
|
func GetArticleById(id int) (*models.Articles, error) {
|
||||||
|
o := orm.NewOrm()
|
||||||
|
|
||||||
|
article := &models.Articles{Id: id}
|
||||||
|
err := o.Read(article)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否已删除
|
||||||
|
if article.DeleteTime != nil {
|
||||||
|
return nil, nil // 返回nil表示文章不存在或已删除
|
||||||
|
}
|
||||||
|
|
||||||
|
return article, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateArticle 创建文章
|
||||||
|
func CreateArticle(article *models.Articles) (*models.Articles, error) {
|
||||||
|
o := orm.NewOrm()
|
||||||
|
|
||||||
|
id, err := o.Insert(article)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取创建后的文章
|
||||||
|
article.Id = int(id)
|
||||||
|
return article, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateArticle 更新文章
|
||||||
|
func UpdateArticle(article *models.Articles) error {
|
||||||
|
o := orm.NewOrm()
|
||||||
|
|
||||||
|
_, err := o.Update(article, "title", "cate", "image", "desc", "author", "content", "publisher", "publishdate", "sort", "status", "is_trans", "transurl", "push", "update_time")
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteArticle 删除文章(软删除)
|
||||||
|
func DeleteArticle(id int) error {
|
||||||
|
o := orm.NewOrm()
|
||||||
|
|
||||||
|
// 获取文章
|
||||||
|
article := &models.Articles{Id: id}
|
||||||
|
err := o.Read(article)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置删除时间
|
||||||
|
now := time.Now()
|
||||||
|
article.DeleteTime = &now
|
||||||
|
|
||||||
|
_, err = o.Update(article, "delete_time")
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateArticleStatus 更新文章状态
|
||||||
|
func UpdateArticleStatus(id int, status int8) error {
|
||||||
|
o := orm.NewOrm()
|
||||||
|
|
||||||
|
article := &models.Articles{Id: id}
|
||||||
|
err := o.Read(article)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
article.Status = status
|
||||||
|
now := time.Now()
|
||||||
|
article.UpdateTime = &now
|
||||||
|
|
||||||
|
_, err = o.Update(article, "status", "update_time")
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// IncrementArticleViews 增加文章浏览量
|
||||||
|
func IncrementArticleViews(id int) error {
|
||||||
|
o := orm.NewOrm()
|
||||||
|
|
||||||
|
article := &models.Articles{Id: id}
|
||||||
|
err := o.Read(article)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
article.Views++
|
||||||
|
now := time.Now()
|
||||||
|
article.UpdateTime = &now
|
||||||
|
|
||||||
|
_, err = o.Update(article, "views", "update_time")
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// IncrementArticleLikes 增加文章点赞量
|
||||||
|
func IncrementArticleLikes(id int) error {
|
||||||
|
o := orm.NewOrm()
|
||||||
|
|
||||||
|
article := &models.Articles{Id: id}
|
||||||
|
err := o.Read(article)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
article.Likes++
|
||||||
|
now := time.Now()
|
||||||
|
article.UpdateTime = &now
|
||||||
|
|
||||||
|
_, err = o.Update(article, "likes", "update_time")
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPublishedArticles 获取已发布的文章列表(用于前台展示)
|
||||||
|
func GetPublishedArticles(page, pageSize int, cate int) ([]*models.Articles, int64, error) {
|
||||||
|
return GetArticlesList(0, 0, page, pageSize, "", cate, 2) // status=2 表示已发布,tenant_id=0, user_id=0
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetArticleStats 获取文章统计信息
|
||||||
|
func GetArticleStats() (map[string]int64, error) {
|
||||||
|
o := orm.NewOrm()
|
||||||
|
|
||||||
|
stats := make(map[string]int64)
|
||||||
|
|
||||||
|
// 总文章数
|
||||||
|
total, err := o.QueryTable(new(models.Articles)).Filter("delete_time__isnull", true).Count()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
stats["total"] = total
|
||||||
|
|
||||||
|
// 草稿数
|
||||||
|
draft, err := o.QueryTable(new(models.Articles)).Filter("delete_time__isnull", true).Filter("status", 0).Count()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
stats["draft"] = draft
|
||||||
|
|
||||||
|
// 待审核数
|
||||||
|
pending, err := o.QueryTable(new(models.Articles)).Filter("delete_time__isnull", true).Filter("status", 1).Count()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
stats["pending"] = pending
|
||||||
|
|
||||||
|
// 已发布数
|
||||||
|
published, err := o.QueryTable(new(models.Articles)).Filter("delete_time__isnull", true).Filter("status", 2).Count()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
stats["published"] = published
|
||||||
|
|
||||||
|
// 隐藏数
|
||||||
|
hidden, err := o.QueryTable(new(models.Articles)).Filter("delete_time__isnull", true).Filter("status", 3).Count()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
stats["hidden"] = hidden
|
||||||
|
|
||||||
|
return stats, nil
|
||||||
|
}
|
||||||
@ -144,20 +144,29 @@ func AddUser(username, password, email, nickname, avatar string, tenantId, role,
|
|||||||
return nil, fmt.Errorf("查询用户失败: %v", err)
|
return nil, fmt.Errorf("查询用户失败: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. 生成盐值(每个用户唯一)
|
// 3. 获取该租户下用户的最大Uid,用于生成新的Uid
|
||||||
|
var maxUid int
|
||||||
|
err = o.Raw("SELECT COALESCE(MAX(uid), 0) FROM yz_users WHERE tenant_id = ? AND delete_time IS NULL", tenantId).QueryRow(&maxUid)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("获取最大Uid失败: %v", err)
|
||||||
|
}
|
||||||
|
newUid := maxUid + 1
|
||||||
|
|
||||||
|
// 4. 生成盐值(每个用户唯一)
|
||||||
salt, err := generateUserSalt()
|
salt, err := generateUserSalt()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("生成盐值失败: %v", err)
|
return nil, fmt.Errorf("生成盐值失败: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. 加密密码(结合盐值)
|
// 5. 加密密码(结合盐值)
|
||||||
hashedPassword, err := hashUserPassword(password, salt)
|
hashedPassword, err := hashUserPassword(password, salt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("密码加密失败: %v", err)
|
return nil, fmt.Errorf("密码加密失败: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. 构建用户对象
|
// 6. 构建用户对象
|
||||||
user := &models.User{
|
user := &models.User{
|
||||||
|
Uid: newUid,
|
||||||
TenantId: tenantId,
|
TenantId: tenantId,
|
||||||
Username: username,
|
Username: username,
|
||||||
Password: hashedPassword,
|
Password: hashedPassword,
|
||||||
@ -171,13 +180,13 @@ func AddUser(username, password, email, nickname, avatar string, tenantId, role,
|
|||||||
Status: 1,
|
Status: 1,
|
||||||
}
|
}
|
||||||
|
|
||||||
// 6. 插入数据库
|
// 7. 插入数据库
|
||||||
_, err = o.Insert(user)
|
_, err = o.Insert(user)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("数据库插入失败: %v", err)
|
return nil, fmt.Errorf("数据库插入失败: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 7. 返回新创建的用户对象
|
// 8. 返回新创建的用户对象
|
||||||
return user, nil
|
return user, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user