增加cms模块

This commit is contained in:
李志强 2026-01-05 17:38:34 +08:00
parent 3c0f01eb36
commit 09cbd721a0
24 changed files with 3158 additions and 955 deletions

54
pc/src/api/article.js Normal file
View 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 },
});
}

View File

@ -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=1menu_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
@ -300,10 +302,14 @@ 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);

View File

@ -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>

View File

@ -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

View File

@ -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);
} }
} }

View 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>

View 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>

View 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>

View File

@ -0,0 +1,8 @@
<template>
<router-view />
</template>
<script setup></script>
<style lang="scss" scoped></style>

View 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>

View File

@ -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();
// categorytagedit 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>

View 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();
// categorytagedit 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>

View File

@ -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;
@ -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;

View File

@ -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] || "未知类型";
}; };

View File

@ -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';

View 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()
}

View File

@ -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", // 标识是用户登录
} }

View File

@ -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,

View 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
View 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"
}

View File

@ -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 {

View File

@ -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
View 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
}

View File

@ -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
} }