12.28最新版

leijiabao_branch
bao 4 months ago
parent 8469cd6ff3
commit 610e72d667

@ -11,6 +11,14 @@ CREATE TABLE users (
register_time DATETIME DEFAULT CURRENT_TIMESTAMP
);
INSERT INTO users (username, email, password, role)
VALUES (
'admin123',
'admin@pgfplots.com',
'$2b$10$kkbK9FE66BSuZt.zEyitl.G8WqkK2yUmusQ53HWkFKRzLq..4iuc2',
'admin'
);
CREATE TABLE data_file (
data_id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
@ -80,7 +88,7 @@ CREATE TABLE notice (
notice_id INT AUTO_INCREMENT PRIMARY KEY,
title VARCHAR(100) NOT NULL,
content TEXT NOT NULL,
admin_id INT, -- 发布通知的管理员ID
admin_id INT, -- 发布通知的管理员ID这里默认通知都是发给所有用户所以没有用户id
is_read BOOLEAN DEFAULT FALSE,
created_time DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (admin_id) REFERENCES users(user_id)

@ -17,6 +17,8 @@ const verificationRoutes = require('./routes/verification');
const datasetsRoutes = require('./routes/datasets');
const compileRouter = require('./routes/compile');
const historyRouter = require('./routes/history');
const noticeRouter = require('./routes/notice');
const AdminNoticeRouter = require('./routes/AdminNotice');
// 路由注册
app.use('/api/auth', authRoutes);
@ -26,6 +28,8 @@ app.use('/api/verification', verificationRoutes);
app.use('/api/datasets', datasetsRoutes);
app.use('/api/compile', compileRouter);
app.use('/api/history', historyRouter);
app.use('/api/notice', noticeRouter);
app.use('/api/admin/notices', AdminNoticeRouter);
//静态文件服务查看pdf
app.use('/storage', express.static(path.join(__dirname, 'storage')));

@ -0,0 +1,335 @@
// backend/routes/AdminNotice.js
const express = require('express');
const router = express.Router();
// 使用内存临时数据库模拟数据
let notices = [
{
notice_id: 1,
title: '系统维护通知',
content: '系统将于本周六凌晨2点进行维护预计耗时2小时。',
admin_id: 1,
admin_name: 'admin123',
is_read: false,
created_time: new Date('2024-01-15 10:00:00')
},
{
notice_id: 2,
title: '新功能上线',
content: '数据可视化分析功能已上线,欢迎使用。',
admin_id: 1,
admin_name: 'admin123',
is_read: true,
created_time: new Date('2024-01-10 14:30:00')
}
];
let nextNoticeId = 3;
// 获取通知列表(管理员)
router.get('/', async (req, res) => {
try {
const {
page = 1,
pageSize = 10,
keyword = '',
startDate = '',
endDate = ''
} = req.query;
const offset = (page - 1) * pageSize;
// 过滤数据
let filteredNotices = [...notices];
if (keyword) {
filteredNotices = filteredNotices.filter(notice =>
notice.title.includes(keyword) || notice.content.includes(keyword)
);
}
if (startDate) {
const start = new Date(startDate);
filteredNotices = filteredNotices.filter(notice =>
new Date(notice.created_time) >= start
);
}
if (endDate) {
const end = new Date(endDate);
filteredNotices = filteredNotices.filter(notice =>
new Date(notice.created_time) <= end
);
}
const total = filteredNotices.length;
// 分页
const paginatedNotices = filteredNotices
.sort((a, b) => new Date(b.created_time) - new Date(a.created_time))
.slice(offset, offset + parseInt(pageSize));
// 统计数据
const readCount = notices.filter(notice => notice.is_read).length;
const unreadCount = notices.filter(notice => !notice.is_read).length;
res.json({
code: 200,
message: '获取通知列表成功',
data: {
notices: paginatedNotices,
pagination: {
page: parseInt(page),
pageSize: parseInt(pageSize),
total,
totalPages: Math.ceil(total / pageSize)
},
statistics: {
total_count: notices.length,
read_count: readCount,
unread_count: unreadCount
}
}
});
} catch (error) {
console.error('获取通知列表时出错:', error);
res.status(500).json({
code: 500,
message: '服务器内部错误',
data: null
});
}
});
// 获取通知详情
router.get('/:id', async (req, res) => {
try {
const { id } = req.params;
const notice = notices.find(n => n.notice_id === parseInt(id));
if (!notice) {
return res.status(404).json({
code: 404,
message: '通知不存在',
data: null
});
}
res.json({
code: 200,
message: '获取通知详情成功',
data: notice
});
} catch (error) {
console.error('获取通知详情时出错:', error);
res.status(500).json({
code: 500,
message: '服务器内部错误',
data: null
});
}
});
// 创建通知
router.post('/', async (req, res) => {
try {
const { title, content } = req.body;
const adminId = 1; // 模拟管理员ID
if (!title || !content) {
return res.status(400).json({
code: 400,
message: '标题和内容不能为空',
data: null
});
}
const newNotice = {
notice_id: nextNoticeId++,
title,
content,
admin_id: adminId,
admin_name: 'admin123',
is_read: false,
created_time: new Date()
};
notices.push(newNotice);
res.json({
code: 200,
message: '通知创建成功',
data: {
notice_id: newNotice.notice_id
}
});
} catch (error) {
console.error('创建通知时出错:', error);
res.status(500).json({
code: 500,
message: '服务器内部错误',
data: null
});
}
});
// 更新通知
router.put('/:id', async (req, res) => {
try {
const { id } = req.params;
const { title, content } = req.body;
if (!title || !content) {
return res.status(400).json({
code: 400,
message: '标题和内容不能为空',
data: null
});
}
const noticeIndex = notices.findIndex(n => n.notice_id === parseInt(id));
if (noticeIndex === -1) {
return res.status(404).json({
code: 404,
message: '通知不存在',
data: null
});
}
// 更新通知
notices[noticeIndex] = {
...notices[noticeIndex],
title,
content
};
res.json({
code: 200,
message: '通知更新成功',
data: null
});
} catch (error) {
console.error('更新通知时出错:', error);
res.status(500).json({
code: 500,
message: '服务器内部错误',
data: null
});
}
});
// 删除通知
router.delete('/:id', async (req, res) => {
try {
const { id } = req.params;
const noticeIndex = notices.findIndex(n => n.notice_id === parseInt(id));
if (noticeIndex === -1) {
return res.status(404).json({
code: 404,
message: '通知不存在',
data: null
});
}
// 删除通知
notices.splice(noticeIndex, 1);
res.json({
code: 200,
message: '通知删除成功',
data: null
});
} catch (error) {
console.error('删除通知时出错:', error);
res.status(500).json({
code: 500,
message: '服务器内部错误',
data: null
});
}
});
// 批量删除通知
router.delete('/', async (req, res) => {
try {
const { ids } = req.body;
if (!ids || !Array.isArray(ids) || ids.length === 0) {
return res.status(400).json({
code: 400,
message: '请选择要删除的通知',
data: null
});
}
// 批量删除通知
notices = notices.filter(notice => !ids.includes(notice.notice_id));
res.json({
code: 200,
message: '批量删除成功',
data: null
});
} catch (error) {
console.error('批量删除通知时出错:', error);
res.status(500).json({
code: 500,
message: '服务器内部错误',
data: null
});
}
});
// 获取通知统计数据
router.get('/statistics/overview', async (req, res) => {
try {
// 模拟最近30天的通知统计
const recentStats = [
{ date: '2024-01-15', count: 1, read_count: 0 },
{ date: '2024-01-10', count: 1, read_count: 1 }
];
// 模拟管理员发布统计
const adminStats = [
{
user_id: 1,
username: 'admin123',
notice_count: 2,
read_count: 1
}
];
// 阅读率统计
const total = notices.length;
const readCount = notices.filter(notice => notice.is_read).length;
const readRatePercent = total > 0 ? ((readCount / total) * 100).toFixed(2) : 0;
res.json({
code: 200,
message: '获取统计数据成功',
data: {
recent: recentStats,
adminStats,
summary: {
total,
readCount,
unreadCount: total - readCount,
readRate: parseFloat(readRatePercent)
}
}
});
} catch (error) {
console.error('获取统计数据时出错:', error);
res.status(500).json({
code: 500,
message: '服务器内部错误',
data: null
});
}
});
module.exports = router;

@ -10,6 +10,57 @@ const { exec } = require('child_process');
const util = require('util');
const execPromise = util.promisify(exec);
// 获取PDF URL接口 这里构建的URL跟generation_path保持部分一致
router.get('/:history_id/pdf-url', authenticateToken, async (req, res) => {
try {
const { history_id } = req.params;
const user_id = req.user.user_id;
// 查询历史记录
const query = `
SELECT generation_path
FROM generation_history
WHERE history_id = ? AND user_id = ?
`;
const [records] = await db.query(query, [history_id, user_id]);
if (records.length === 0) {
return res.status(404).json({
success: false,
message: '历史记录不存在'
});
}
const record = records[0];
if (!record.generation_path) {
return res.status(404).json({
success: false,
message: '该记录尚未生成PDF'
});
}
// 构建完整的PDF URL
const pdfUrl = `/storage/generated_charts/user${user_id}/hist${history_id}.pdf`;
res.json({
success: true,
data: {
pdf_url: pdfUrl,
exists: fs.existsSync(path.join(__dirname, '..', '..', record.generation_path))
}
});
} catch (error) {
console.error('获取PDF URL失败:', error);
res.status(500).json({
success: false,
message: error.message
});
}
});
// LaTeX编译接口 - 只保留核心功能
router.post('/:history_id', authenticateToken, async (req, res) => {
let tempDir = null;

@ -38,7 +38,7 @@ const checkAdmin = async (req, res, next) => {
}
};
//提交反馈
//用户提交反馈
router.post('/', authenticateToken, async (req, res) => {
try {
const { type, content } = req.body;
@ -90,7 +90,66 @@ router.post('/', authenticateToken, async (req, res) => {
}
});
// 获取所有反馈 - 需要认证和管理员权限
// 用户获取自己的反馈列表 - 需要认证
router.get('/user/my-feedbacks', authenticateToken, async (req, res) => {
try {
const user_id = req.user.user_id;
const { page = 1, limit = 10 } = req.query;
// 参数处理 - 确保转换为数字类型
const pageNum = Math.max(1, parseInt(page) || 1);
const limitNum = Math.min(100, Math.max(1, parseInt(limit) || 10));
const offset = (pageNum - 1) * limitNum;
// 修改处1: 使用 db.query() 替代 db.execute()
const query = `
SELECT
feedback_id,
type,
content,
answer,
feedback_time,
answer_time,
CASE
WHEN answer IS NOT NULL THEN '已回复'
ELSE '待回复'
END as status
FROM feedback
WHERE user_id = ?
ORDER BY feedback_time DESC
LIMIT ? OFFSET ?
`;
// 修改处2: 将 db.execute() 改为 db.query()
const [feedbacks] = await db.query(query, [user_id, limitNum, offset]);
// 修改处3: 总数查询也使用 db.query() 保持一致性
const countQuery = 'SELECT COUNT(*) as total FROM feedback WHERE user_id = ?';
const [countResult] = await db.query(countQuery, [user_id]);
const total = countResult[0].total;
res.json({
success: true,
data: feedbacks,
pagination: {
page: pageNum,
limit: limitNum,
total,
pages: Math.ceil(total / limitNum)
},
message: '获取反馈列表成功'
});
} catch (error) {
console.error('获取用户反馈列表错误:', error);
res.status(500).json({
success: false,
message: '服务器内部错误: ' + error.message
});
}
});
// 管理员获取所有反馈 - 需要认证和管理员权限
router.get('/', authenticateToken, checkAdmin, async (req, res) => {
try {
const { page = 1, limit = 10, type } = req.query;
@ -154,7 +213,7 @@ router.get('/', authenticateToken, checkAdmin, async (req, res) => {
}
});
// 根据ID获取反馈 - 需要认证和管理员权限
// 管理员根据反馈ID查看反馈详情 - 需要认证和管理员权限
router.get('/:id', authenticateToken, checkAdmin, async (req, res) => {
try {
const feedbackId = parseInt(req.params.id);
@ -189,7 +248,7 @@ router.get('/:id', authenticateToken, checkAdmin, async (req, res) => {
}
});
// 回复反馈 - 需要认证和管理员权限
// 管理员回复反馈 - 需要认证和管理员权限
router.put('/:id/reply', authenticateToken, checkAdmin, async (req, res) => {
try {
const feedbackId = parseInt(req.params.id);
@ -255,7 +314,7 @@ router.put('/:id/reply', authenticateToken, checkAdmin, async (req, res) => {
}
});
// 根据ID删除反馈 - 需要认证和管理员权限
// 管理员根据ID删除反馈 - 需要认证和管理员权限
router.delete('/:id', authenticateToken, checkAdmin, async (req, res) => {
try {
const feedbackId = parseInt(req.params.id);

@ -1,4 +1,4 @@
// backend/routes/mynotice.js
// backend/routes/notice.js
const express = require('express');
const router = express.Router();
const dbModule = require('../db');
@ -191,4 +191,5 @@ function getFeedbackTypeText(type) {
return typeMap[type] || type;
}
module.exports = router;

@ -1,5 +1,4 @@
<!-- src/Admin.vue -->
<!-- 作为管理员的主界面 -->
<template>
<div class="admin-welcome">
<!-- 欢迎横幅 -->
@ -40,56 +39,6 @@
</div>
</div>
<!-- 快速操作 - 修改为四宫格布局 -->
<div class="quick-actions">
<h2 class="section-title">快速操作</h2>
<div class="four-grid-container">
<!-- 第一行 -->
<div class="grid-row">
<div class="grid-item" @click="goToUserManagement">
<div class="grid-icon" style="background: #4caf50;">
<span class="icon-text">👤</span>
</div>
<div class="grid-content">
<h3>用户管理</h3>
<p>查看和管理所有注册用户</p>
</div>
</div>
<div class="grid-item" @click="goToFeedbackManagement">
<div class="grid-icon" style="background: #ff9800;">
<span class="icon-text">💬</span>
</div>
<div class="grid-content">
<h3>反馈管理</h3>
<p>处理用户反馈和建议</p>
</div>
</div>
</div>
<!-- 第二行 -->
<div class="grid-row">
<div class="grid-item" @click="goToSystemLogs">
<div class="grid-icon" style="background: #607d8b;">
<span class="icon-text">📋</span>
</div>
<div class="grid-content">
<h3>系统日志</h3>
<p>查看系统运行状态</p>
</div>
</div>
<div class="grid-item" @click="goToNoticeManagement">
<div class="grid-icon" style="background: #e91e63;">
<span class="icon-text">📢</span>
</div>
<div class="grid-content">
<h3>通知管理</h3>
<p>发布系统通知</p>
</div>
</div>
</div>
</div>
</div>
<!-- 管理员信息 -->
<div class="admin-info-section">
<h2 class="section-title">管理员信息</h2>
@ -116,14 +65,8 @@
</template>
<script>
import { useRouter } from 'vue-router'
export default {
name: 'AdminWelcome',
setup() {
const router = useRouter()
return { router }
},
data() {
return {
admin: {},
@ -148,7 +91,6 @@ export default {
setInterval(this.updateCurrentTime, 1000)
},
methods: {
// Admin.vue loadAdminData
loadAdminData() {
const adminUser = localStorage.getItem('adminUser')
if (adminUser) {
@ -195,34 +137,6 @@ export default {
} catch (error) {
console.error('获取系统统计失败:', error)
}
},
goToUserManagement() {
alert('跳转到用户管理')
// 使
// this.$router.push('/admin/users')
},
goToFeedbackManagement() {
//
// URL
const currentOrigin = window.location.origin
const adminFeedbackUrl = `${currentOrigin}/admin/feedback`
console.log('在新标签页打开:', adminFeedbackUrl)
//
window.open(adminFeedbackUrl, '_blank')
},
goToSystemLogs() {
alert('跳转到系统日志')
// this.$router.push('/admin/logs')
},
goToNoticeManagement() {
alert('跳转到通知管理')
// this.$router.push('/admin/notices')
}
}
}
@ -299,9 +213,9 @@ export default {
margin-top: 5px;
}
/* 快速操作 - 四宫格布局 */
.quick-actions {
max-width: 900px; /* 调整宽度适应四宫格 */
/* 管理员信息 */
.admin-info-section {
max-width: 1200px;
margin: 0 auto 30px;
padding: 0 20px;
}
@ -309,85 +223,10 @@ export default {
.section-title {
font-size: 24px;
color: #333;
margin-bottom: 30px; /* 增加底部间距 */
font-weight: 600;
text-align: center; /* 标题居中 */
}
/* 四宫格容器 */
.four-grid-container {
display: flex;
flex-direction: column;
gap: 20px;
}
/* 网格行 */
.grid-row {
display: flex;
gap: 20px;
}
/* 网格项 */
.grid-item {
flex: 1;
background: white;
border-radius: 12px;
overflow: hidden;
cursor: pointer;
transition: all 0.3s;
border: 1px solid #eaeaea;
display: flex;
flex-direction: column;
}
.grid-item:hover {
transform: translateY(-5px);
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.1);
border-color: #dc3545;
}
/* 图标区域 */
.grid-icon {
height: 120px;
display: flex;
align-items: center;
justify-content: center;
}
.icon-text {
font-size: 48px;
}
/* 内容区域 */
.grid-content {
padding: 20px;
text-align: center;
flex-grow: 1;
display: flex;
flex-direction: column;
justify-content: center;
}
.grid-content h3 {
font-size: 18px;
color: #333;
margin-bottom: 10px;
margin-bottom: 20px;
font-weight: 600;
}
.grid-content p {
color: #666;
font-size: 14px;
line-height: 1.5;
}
/* 管理员信息 */
.admin-info-section {
max-width: 1200px;
margin: 0 auto 30px;
padding: 0 20px;
}
.info-card {
background: white;
border-radius: 12px;
@ -428,53 +267,6 @@ export default {
display: inline-block;
}
/* 系统状态 */
.system-status {
max-width: 1200px;
margin: 0 auto 30px;
padding: 0 20px;
}
.status-cards {
display: flex;
gap: 20px;
flex-wrap: wrap;
}
.status-card {
background: white;
border-radius: 12px;
padding: 25px;
display: flex;
align-items: center;
gap: 15px;
min-width: 250px;
border: 1px solid #eaeaea;
}
.status-icon {
font-size: 30px;
}
.status-content h3 {
font-size: 18px;
color: #333;
margin-bottom: 5px;
}
.status-content p {
color: #666;
font-size: 14px;
}
.status-normal {
border-left: 4px solid #4caf50;
}
.status-warning {
border-left: 4px solid #ff9800;
}
/* 响应式设计 */
@media (max-width: 768px) {
.admin-stats {
@ -486,30 +278,13 @@ export default {
min-width: auto;
}
.actions-grid {
grid-template-columns: 1fr;
}
.status-cards {
.info-row {
flex-direction: column;
}
.status-card {
width: 100%;
.info-label {
min-width: auto;
}
/* 在移动设备上将四宫格变为垂直布局 */
.grid-row {
flex-direction: column;
}
.grid-item {
margin-bottom: 20px;
}
.grid-item:last-child {
margin-bottom: 0;
margin-bottom: 5px;
}
}
</style>

@ -46,8 +46,18 @@
<!-- 管理员登录后显示路由视图容器 -->
<div v-else-if="isAdminAuthenticated" class="admin-main">
<!-- 这里添加路由视图容器 -->
<router-view></router-view>
<div class="admin-layout">
<!-- 管理员侧边栏组件 -->
<AdminSidebar
:collapsed="isAdminSidebarCollapsed"
@toggle="handleAdminSidebarToggle"
/>
<!-- 主内容区域 -->
<div class="admin-content-area">
<router-view></router-view>
</div>
</div>
</div>
</main>
</div>
@ -58,7 +68,7 @@ import Auth from './components/TheAuth.vue';
import CommonSidebar from './components/CommonSidebar.vue';
import CommonNavbar from './components/CommonNavbar.vue';
import AdminNavbar from './components/AdminNavbar.vue';
// Admin
import AdminSidebar from './components/AdminSidebar.vue';
export default {
name: 'App',
@ -66,11 +76,13 @@ export default {
Auth,
CommonSidebar,
CommonNavbar,
AdminNavbar
AdminNavbar,
AdminSidebar
},
data() {
return {
isSidebarCollapsed: false,
isAdminSidebarCollapsed: false,
isAdminAuthenticated: false,
currentAdmin: null
}
@ -106,8 +118,6 @@ export default {
try {
this.currentAdmin = JSON.parse(adminUser)
this.isAdminAuthenticated = true
// /admin
// URL
console.log('管理员已认证,当前路径:', window.location.pathname)
} catch (e) {
console.error('解析管理员信息失败:', e)
@ -176,6 +186,10 @@ export default {
handleSidebarToggle(isCollapsed) {
this.isSidebarCollapsed = isCollapsed;
},
handleAdminSidebarToggle(isCollapsed) {
this.isAdminSidebarCollapsed = isCollapsed;
},
goToChangeInformation() {
this.$router.push('/change-information');
@ -209,6 +223,7 @@ body {
.admin-main {
min-height: calc(100vh - 60px); /* 减去导航栏高度 */
background: #f8f9fa;
padding: 20px;
}
.app-layout {
@ -217,6 +232,12 @@ body {
gap: 20px;
}
.admin-layout {
display: flex;
min-height: calc(100vh - 100px);
gap: 20px;
}
.content-area {
flex: 1;
background: white;
@ -226,9 +247,24 @@ body {
overflow: auto;
}
.admin-content-area {
flex: 1;
background: white;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
padding: 20px;
overflow: auto;
}
@media (max-width: 768px) {
.app-layout {
.app-layout,
.admin-layout {
flex-direction: column;
}
.content-area,
.admin-content-area {
padding: 15px;
}
}
</style>

@ -0,0 +1,190 @@
<!-- src/components/AdminSidebar.vue -->
<template>
<aside class="admin-sidebar" :class="{ 'sidebar-collapsed': isCollapsed }">
<div class="sidebar-header">
<h3>管理菜单</h3>
<button class="toggle-sidebar" @click="toggleSidebar">
{{ isCollapsed ? '▶' : '◀' }}
</button>
</div>
<nav class="sidebar-nav">
<ul>
<li>
<router-link to="/admin" class="nav-item" active-class="active">
<span class="icon">🏠</span>
<span class="text">首页</span>
</router-link>
</li>
<li>
<router-link to="/admin/user" class="nav-item" active-class="active">
<span class="icon">👤</span>
<span class="text">用户管理</span>
</router-link>
</li>
<li>
<router-link to="/admin/feedback" class="nav-item" active-class="active">
<span class="icon">💬</span>
<span class="text">反馈管理</span>
</router-link>
</li>
<li>
<router-link to="/admin/log" class="nav-item" active-class="active">
<span class="icon">📋</span>
<span class="text">系统日志</span>
</router-link>
</li>
<li>
<router-link to="/admin/notice" class="nav-item" active-class="active">
<span class="icon">📢</span>
<span class="text">通知管理</span>
</router-link>
</li>
</ul>
</nav>
</aside>
</template>
<script>
export default {
name: 'AdminSidebar',
props: {
collapsed: {
type: Boolean,
default: false
}
},
data() {
return {
isCollapsed: this.collapsed
}
},
watch: {
collapsed(newVal) {
this.isCollapsed = newVal;
}
},
methods: {
toggleSidebar() {
this.isCollapsed = !this.isCollapsed;
this.$emit('toggle', this.isCollapsed);
}
}
}
</script>
<style scoped>
.admin-sidebar {
width: 250px;
background: white;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
padding: 20px;
transition: width 0.3s ease;
flex-shrink: 0;
margin-right: 20px;
}
.sidebar-collapsed {
width: 90px;
}
.sidebar-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
padding-bottom: 15px;
border-bottom: 1px solid #eee;
}
.sidebar-header h3 {
color: #333;
font-size: 18px;
white-space: nowrap;
overflow: hidden;
}
.sidebar-collapsed .sidebar-header h3 {
display: none;
}
.toggle-sidebar {
background: none;
border: none;
cursor: pointer;
font-size: 16px;
color: #666;
padding: 5px;
border-radius: 4px;
}
.toggle-sidebar:hover {
background: #f0f0f0;
}
.sidebar-nav ul {
list-style: none;
}
.sidebar-nav li {
margin-bottom: 10px;
}
.nav-item {
display: flex;
align-items: center;
padding: 12px 15px;
text-decoration: none;
color: #555;
border-radius: 6px;
transition: all 0.2s;
white-space: nowrap;
overflow: hidden;
}
.nav-item:hover {
background: #f0f7ff;
color: #3498db;
}
.nav-item.active {
background: #3498db;
color: white;
}
.nav-item .icon {
margin-right: 12px;
font-size: 18px;
flex-shrink: 0;
}
.sidebar-collapsed .nav-item .text {
display: none;
}
.sidebar-collapsed .nav-item .icon {
margin-right: 0;
}
/* 响应式设计 */
@media (max-width: 768px) {
.admin-sidebar {
width: 100%;
margin-right: 0;
margin-bottom: 20px;
}
.sidebar-collapsed {
width: 100%;
}
.sidebar-collapsed .sidebar-header h3,
.sidebar-collapsed .nav-item .text {
display: block;
}
.toggle-sidebar {
display: none;
}
}
</style>

@ -1,3 +1,19 @@
const originalOnError = window.onerror;
window.onerror = function(message, source, lineno, colno, error) {
if (message && message.includes('ResizeObserver loop completed with undelivered notifications')) {
// 忽略该错误
return true;
}
return originalOnError ? originalOnError(message, source, lineno, colno, error) : false;
};
// 针对 Promise 未捕获的错误也可以加上
window.addEventListener('error', (e) => {
if (e.message === 'ResizeObserver loop completed with undelivered notifications') {
e.stopImmediatePropagation();
}
});
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
@ -9,4 +25,4 @@ const app = createApp(App)
app.use(router)
app.use(store)
app.use(ElementPlus)
app.mount('#app')
app.mount('#app')

@ -26,6 +26,24 @@ const router = createRouter({
component: () => import('../views/AdminFeedback.vue'),
meta: { requiresAdmin: true }
},
{
path: '/admin/log',
name: 'AdminLog',
component: () => import('../views/AdminLog.vue'),
meta: { requiresAdmin: true }
},
{
path: '/admin/notice',
name: 'AdminNotice',
component: () => import('../views/AdminNotice.vue'),
meta: { requiresAdmin: true }
},
{
path: '/admin/user',
name: 'AdminUser',
component: () => import('../views/AdminUser.vue'),
meta: { requiresAdmin: true }
},
{
path: '/login',
name: 'Login',

File diff suppressed because it is too large Load Diff

@ -0,0 +1,658 @@
<template>
<div class="admin-notice-container">
<div class="header">
<h1>通知管理</h1>
<el-button type="primary" @click="handleCreate">
<el-icon><Plus /></el-icon>
</el-button>
</div>
<!-- 统计卡片 -->
<el-row :gutter="20" class="statistics-row">
<el-col :span="6">
<el-card shadow="hover">
<div class="stat-card">
<el-icon class="stat-icon total"><Document /></el-icon>
<div class="stat-content">
<div class="stat-number">{{ statistics.total_count || 0 }}</div>
<div class="stat-label">总通知数</div>
</div>
</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="hover">
<div class="stat-card">
<el-icon class="stat-icon read"><Check /></el-icon>
<div class="stat-content">
<div class="stat-number">{{ statistics.read_count || 0 }}</div>
<div class="stat-label">已读通知</div>
</div>
</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="hover">
<div class="stat-card">
<el-icon class="stat-icon unread"><Bell /></el-icon>
<div class="stat-content">
<div class="stat-number">{{ statistics.unread_count || 0 }}</div>
<div class="stat-label">未读通知</div>
</div>
</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="hover">
<div class="stat-card">
<el-icon class="stat-icon rate"><TrendCharts /></el-icon>
<div class="stat-content">
<div class="stat-number">
{{ statistics.read_count && statistics.total_count
? ((statistics.read_count / statistics.total_count) * 100).toFixed(1)
: '0' }}%
</div>
<div class="stat-label">阅读率</div>
</div>
</div>
</el-card>
</el-col>
</el-row>
<!-- 搜索和过滤 -->
<el-card class="filter-card">
<el-form :model="filterForm" @submit.prevent="handleSearch">
<el-row :gutter="20">
<el-col :span="8">
<el-form-item label="关键词">
<el-input
v-model="filterForm.keyword"
placeholder="搜索标题或内容"
clearable
@clear="handleSearch"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="开始日期">
<el-date-picker
v-model="filterForm.startDate"
type="date"
placeholder="选择开始日期"
value-format="YYYY-MM-DD"
@change="handleSearch"
/>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="结束日期">
<el-date-picker
v-model="filterForm.endDate"
type="date"
placeholder="选择结束日期"
value-format="YYYY-MM-DD"
@change="handleSearch"
/>
</el-form-item>
</el-col>
</el-row>
<div class="filter-buttons">
<el-button type="primary" @click="handleSearch">
<el-icon><Search /></el-icon>
</el-button>
<el-button @click="handleReset">
<el-icon><Refresh /></el-icon>
</el-button>
<el-button type="danger" :disabled="selectedIds.length === 0" @click="handleBatchDelete">
<el-icon><Delete /></el-icon>
</el-button>
</div>
</el-form>
</el-card>
<!-- 通知列表 -->
<el-card class="table-card">
<el-table
v-loading="loading"
:data="noticeList"
@selection-change="handleSelectionChange"
style="width: 100%"
>
<el-table-column type="selection" width="55" />
<el-table-column prop="notice_id" label="ID" width="80" />
<el-table-column prop="title" label="标题" min-width="150">
<template #default="{ row }">
<div class="title-cell">
<span :class="{ 'unread-title': !row.is_read }">{{ row.title }}</span>
<el-tag v-if="!row.is_read" size="small" type="warning"></el-tag>
</div>
</template>
</el-table-column>
<el-table-column prop="content" label="内容" min-width="200">
<template #default="{ row }">
<div class="content-preview">{{ formatContent(row.content) }}</div>
</template>
</el-table-column>
<el-table-column prop="admin_name" label="发布者" width="120" />
<el-table-column prop="created_time" label="发布时间" width="180">
<template #default="{ row }">
{{ formatTime(row.created_time) }}
</template>
</el-table-column>
<el-table-column label="状态" width="100">
<template #default="{ row }">
<el-tag :type="row.is_read ? 'success' : 'warning'" size="small">
{{ row.is_read ? '已读' : '未读' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="200" fixed="right">
<template #default="{ row }">
<el-button type="primary" link @click="handleView(row)">
<el-icon><View /></el-icon>
</el-button>
<el-button type="warning" link @click="handleEdit(row)">
<el-icon><Edit /></el-icon>
</el-button>
<el-button type="danger" link @click="handleDelete(row)">
<el-icon><Delete /></el-icon>
</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div class="pagination">
<el-pagination
v-model:current-page="pagination.page"
v-model:page-size="pagination.pageSize"
:total="pagination.total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
</div>
</el-card>
<!-- 查看/编辑对话框 -->
<el-dialog
v-model="dialogVisible"
:title="dialogTitle"
width="600px"
:close-on-click-modal="false"
>
<el-form
ref="noticeFormRef"
:model="noticeForm"
:rules="noticeRules"
label-width="80px"
>
<el-form-item label="标题" prop="title">
<el-input
v-model="noticeForm.title"
placeholder="请输入通知标题"
maxlength="100"
show-word-limit
/>
</el-form-item>
<el-form-item label="内容" prop="content">
<el-input
v-model="noticeForm.content"
type="textarea"
:rows="8"
placeholder="请输入通知内容"
maxlength="1000"
show-word-limit
/>
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSubmit" :loading="submitting">
{{ isEditMode ? '更新' : '发布' }}
</el-button>
</span>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, onMounted, computed, nextTick } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
Plus,
Document,
Check,
Bell,
TrendCharts,
Search,
Refresh,
Delete,
View,
Edit
} from '@element-plus/icons-vue'
//
const loading = ref(false)
const dialogVisible = ref(false)
const submitting = ref(false)
const isEditMode = ref(false)
const selectedIds = ref([])
//
const noticeList = ref([])
const statistics = reactive({
total_count: 0,
read_count: 0,
unread_count: 0
})
//
const pagination = reactive({
page: 1,
pageSize: 10,
total: 0,
totalPages: 0
})
//
const filterForm = reactive({
keyword: '',
startDate: '',
endDate: ''
})
//
const noticeForm = reactive({
notice_id: null,
title: '',
content: ''
})
const noticeFormRef = ref(null)
//
const noticeRules = {
title: [
{ required: true, message: '请输入标题', trigger: 'blur' },
{ min: 1, max: 100, message: '标题长度在1到100个字符', trigger: 'blur' }
],
content: [
{ required: true, message: '请输入内容', trigger: 'blur' },
{ min: 1, max: 1000, message: '内容长度在1到1000个字符', trigger: 'blur' }
]
}
//
const dialogTitle = computed(() => {
return isEditMode.value ? '编辑通知' : '发布新通知'
})
//
const formatTime = (time) => {
if (!time) return ''
try {
const date = new Date(time)
//
if (isNaN(date.getTime())) {
return time
}
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
const hours = String(date.getHours()).padStart(2, '0')
const minutes = String(date.getMinutes()).padStart(2, '0')
const seconds = String(date.getSeconds()).padStart(2, '0')
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
} catch (error) {
console.error('日期格式化错误:', error)
return time
}
}
const formatContent = (content) => {
if (!content) return ''
return content.length > 50 ? content.substring(0, 50) + '...' : content
}
//
const loadNotices = async () => {
loading.value = true
try {
const params = {
page: pagination.page,
pageSize: pagination.pageSize,
...filterForm
}
//
const queryParams = new URLSearchParams()
Object.keys(params).forEach(key => {
if (params[key] !== undefined && params[key] !== '') {
queryParams.append(key, params[key])
}
})
const response = await fetch(`http://localhost:3000/api/admin/notices?${queryParams}`)
const result = await response.json()
if (result.code === 200) {
noticeList.value = result.data.notices
pagination.total = result.data.pagination.total
pagination.totalPages = result.data.pagination.totalPages
Object.assign(statistics, result.data.statistics)
} else {
ElMessage.error(result.message || '加载失败')
}
} catch (error) {
console.error('加载通知列表失败:', error)
ElMessage.error('网络错误,请重试')
} finally {
loading.value = false
}
}
//
const handleSearch = () => {
pagination.page = 1
loadNotices()
}
//
const handleReset = () => {
filterForm.keyword = ''
filterForm.startDate = ''
filterForm.endDate = ''
pagination.page = 1
loadNotices()
}
//
const handleSelectionChange = (selection) => {
selectedIds.value = selection.map(item => item.notice_id)
}
//
const handleSizeChange = (size) => {
pagination.pageSize = size
pagination.page = 1
loadNotices()
}
const handleCurrentChange = (page) => {
pagination.page = page
loadNotices()
}
//
const handleCreate = () => {
isEditMode.value = false
noticeForm.notice_id = null
noticeForm.title = ''
noticeForm.content = ''
dialogVisible.value = true
nextTick(() => {
noticeFormRef.value?.clearValidate()
})
}
//
const handleView = (row) => {
ElMessageBox.alert(row.content, `通知详情 - ${row.title}`, {
confirmButtonText: '关闭',
customClass: 'notice-detail-dialog',
dangerouslyUseHTMLString: false
})
}
//
const handleEdit = (row) => {
isEditMode.value = true
noticeForm.notice_id = row.notice_id
noticeForm.title = row.title
noticeForm.content = row.content
dialogVisible.value = true
nextTick(() => {
noticeFormRef.value?.clearValidate()
})
}
//
const handleDelete = async (row) => {
try {
await ElMessageBox.confirm(
`确定要删除通知"${row.title}"吗?`,
'删除确认',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}
)
const response = await fetch(`http://localhost:3000/api/admin/notices/${row.notice_id}`, {
method: 'DELETE'
})
const result = await response.json()
if (result.code === 200) {
ElMessage.success('删除成功')
loadNotices()
} else {
ElMessage.error(result.message || '删除失败')
}
} catch (error) {
if (error !== 'cancel') {
console.error('删除失败:', error)
ElMessage.error('删除失败')
}
}
}
//
const handleBatchDelete = async () => {
if (selectedIds.value.length === 0) {
ElMessage.warning('请选择要删除的通知')
return
}
try {
await ElMessageBox.confirm(
`确定要删除选中的 ${selectedIds.value.length} 条通知吗?`,
'批量删除确认',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}
)
const response = await fetch('http://localhost:3000/api/admin/notices', {
method: 'DELETE',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ ids: selectedIds.value })
})
const result = await response.json()
if (result.code === 200) {
ElMessage.success('批量删除成功')
selectedIds.value = []
loadNotices()
} else {
ElMessage.error(result.message || '删除失败')
}
} catch (error) {
if (error !== 'cancel') {
console.error('批量删除失败:', error)
ElMessage.error('删除失败')
}
}
}
//
const handleSubmit = async () => {
if (!noticeFormRef.value) return
const valid = await noticeFormRef.value.validate()
if (!valid) return
submitting.value = true
try {
const url = isEditMode.value
? `http://localhost:3000/api/admin/notices/${noticeForm.notice_id}`
: 'http://localhost:3000/api/admin/notices'
const method = isEditMode.value ? 'PUT' : 'POST'
const response = await fetch(url, {
method,
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
title: noticeForm.title,
content: noticeForm.content
})
})
const result = await response.json()
if (result.code === 200) {
ElMessage.success(isEditMode.value ? '更新成功' : '发布成功')
dialogVisible.value = false
loadNotices()
} else {
ElMessage.error(result.message || '操作失败')
}
} catch (error) {
console.error('提交失败:', error)
ElMessage.error('网络错误,请重试')
} finally {
submitting.value = false
}
}
//
onMounted(() => {
loadNotices()
})
</script>
<style scoped>
.admin-notice-container {
padding: 20px;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.statistics-row {
margin-bottom: 20px;
}
.stat-card {
display: flex;
align-items: center;
padding: 10px;
}
.stat-icon {
font-size: 40px;
margin-right: 15px;
}
.stat-icon.total {
color: #409EFF;
}
.stat-icon.read {
color: #67C23A;
}
.stat-icon.unread {
color: #E6A23C;
}
.stat-icon.rate {
color: #F56C6C;
}
.stat-content {
flex: 1;
}
.stat-number {
font-size: 24px;
font-weight: bold;
margin-bottom: 5px;
}
.stat-label {
color: #909399;
font-size: 14px;
}
.filter-card {
margin-bottom: 20px;
}
.filter-buttons {
display: flex;
gap: 10px;
justify-content: flex-end;
}
.table-card {
margin-bottom: 20px;
}
.pagination {
display: flex;
justify-content: center;
margin-top: 20px;
}
.title-cell {
display: flex;
align-items: center;
gap: 8px;
}
.unread-title {
font-weight: bold;
}
.content-preview {
color: #606266;
line-height: 1.5;
}
.notice-detail-dialog {
max-width: 80%;
}
.notice-detail-dialog .el-message-box__content {
max-height: 60vh;
overflow-y: auto;
white-space: pre-wrap;
word-wrap: break-word;
}
</style>

File diff suppressed because it is too large Load Diff

@ -366,7 +366,6 @@ const fileInputRef = ref(null)
// PDF
const pdfDialogVisible = ref(false)
const currentPdfUrl = ref('')
const currentPdfName = ref('')
const STORAGE_KEY = 'chart_chat_messages'
@ -730,10 +729,9 @@ const compileToPDF = async (message) => {
})
const pdfUrl = response.data.data.pdf_url
const historyId = response.data.data.history_id
ElMessage.success('PDF生成成功')
await showPdfPreview(pdfUrl, historyId)
await showPdfPreview(pdfUrl)
} catch (error) {
console.error('编译失败:', error)
@ -751,7 +749,7 @@ const compileToPDF = async (message) => {
}
// PDF
const showPdfPreview = async (pdfUrl, historyId) => {
const showPdfPreview = async (pdfUrl) => {
try {
let fullPdfUrl = pdfUrl
if (!pdfUrl.startsWith('http')) {
@ -759,7 +757,6 @@ const showPdfPreview = async (pdfUrl, historyId) => {
}
currentPdfUrl.value = fullPdfUrl
currentPdfName.value = `chart-${historyId}.pdf`
pdfDialogVisible.value = true
} catch (error) {
@ -773,7 +770,6 @@ const cleanupPdfUrl = () => {
window.URL.revokeObjectURL(currentPdfUrl.value)
}
currentPdfUrl.value = ''
currentPdfName.value = ''
}
//

@ -76,6 +76,36 @@
</el-tooltip>
</template>
</el-table-column>
<el-table-column label="生成的PDF" width="150">
<template #default="{ row }">
<div class="pdf-cell">
<el-button
v-if="row.generation_path"
size="small"
type="warning"
@click="previewExistingPdf(row.id)"
:loading="previewingPdfId === row.id"
>
📊 预览PDF
</el-button>
<el-tooltip
v-else
content="该记录尚未生成PDF点击生成"
placement="top"
>
<el-button
size="small"
type="info"
plain
@click="compileToPDF(row.id)"
>
生成PDF
</el-button>
</el-tooltip>
</div>
</template>
</el-table-column>
<el-table-column prop="created_at" label="生成时间" width="180" sortable>
<template #default="{ row }">
@ -213,7 +243,7 @@
width="100%"
height="600px"
>
<p>您的浏览器不支持PDF预览<a :href="currentPdfUrl" download>点击下载</a></p>
<!-- <p>您的浏览器不支持PDF预览<a :href="currentPdfUrl" download>点击下载</a></p> -->
</object>
</div>
</div>
@ -248,6 +278,7 @@ import {
// API
const API_BASE_URL = 'http://localhost:3000/api'
const previewingPdfId = ref(null)
// token
const getAuthToken = () => {
@ -429,13 +460,12 @@ const compileToPDF = async (historyId) => {
// PDF URL
const pdfUrl = response.data.data.pdf_url
// const historyId = response.data.data.history_id
//
ElMessage.success('PDF生成成功')
// PDF
await showPdfPreview(pdfUrl, historyId)
await showPdfPreview(pdfUrl)
} catch (error) {
console.error('编译失败:', error)
@ -453,19 +483,58 @@ const compileToPDF = async (historyId) => {
}
// PDF使URL
const showPdfPreview = async (pdfUrl, historyId) => {
// PDF
const previewExistingPdf = async (historyId) => {
if (!historyId) {
ElMessage.warning('无效的历史记录ID');
return;
}
previewingPdfId.value = historyId;
try {
const token = getAuthToken();
if (!token) {
ElMessage.error('请先登录');
return;
}
// PDF URL
const response = await axios.get(`${API_BASE_URL}/compile/${historyId}/pdf-url`, {
headers: {
'Authorization': `Bearer ${token}`
}
});
if (response.data.success && response.data.data.exists) {
const pdfUrl = response.data.data.pdf_url;
await showPdfPreview(pdfUrl);
} else {
ElMessage.warning('PDF文件不存在请先生成PDF');
}
} catch (error) {
console.error('预览PDF失败:', error);
if (error.response?.status === 404) {
ElMessage.error('PDF文件不存在请先生成PDF');
} else {
ElMessage.error('预览失败: ' + (error.response?.data?.message || error.message));
}
} finally {
previewingPdfId.value = null;
}
};
// PDF使URLBlob URL
const showPdfPreview = async (pdfUrl) => {
try {
// URLhttp://localhost:3000
let fullPdfUrl = pdfUrl
if (!pdfUrl.startsWith('http')) {
// URL
fullPdfUrl = `http://localhost:3000${pdfUrl}`
}
// PDF URL
currentPdfUrl.value = fullPdfUrl
currentPdfName.value = `chart-${historyId}.pdf`
pdfDialogVisible.value = true
} catch (error) {
@ -564,6 +633,28 @@ onMounted(() => {
<!-- 样式部分保持不变 -->
<style scoped>
.pdf-cell {
display: flex;
justify-content:left;
}
.pdf-preview-container {
width: 100%;
height: 100%;
}
.pdf-viewer object {
border: 1px solid #e0e0e0;
border-radius: 4px;
}
/* 响应式调整 */
@media (max-width: 768px) {
.pdf-cell .el-button {
font-size: 12px;
padding: 6px 8px;
}
}
.history-container {
padding: 20px;
background-color: #f5f7fa;

@ -1,4 +1,3 @@
<!-- src/views/MyNotice.vue -->
<template>
<div class="my-notice-container">
<!-- 头部 -->
@ -79,17 +78,21 @@
<div class="notice-content">
<div class="notice-header">
<h4 class="notice-title">{{ notice.title }}</h4>
<span class="notice-time">{{ formatTime(notice.time) }}</span>
<span class="notice-time">{{ format_ago_Time(notice.time) }}</span>
</div>
<div class="notice-body">
<p class="notice-text">{{ notice.content }}</p>
<!-- 新增反馈时间显示 -->
<div v-if="notice.type === 'feedback'" class="feedback-time">
<span class="notice-text">反馈时间</span>
<span class="notice-text">{{ formatDateTime(notice.feedbackTime) }}</span>
</div>
<!-- 如果是反馈且有回复 -->
<div v-if="notice.type === 'feedback' && notice.reply" class="notice-reply">
<div class="reply-header">
<span class="reply-label">管理员回复</span>
<span class="reply-time">{{ formatTime(notice.replyTime) }}</span>
</div>
<p class="reply-content">{{ notice.reply }}</p>
</div>
@ -99,6 +102,9 @@
<span class="feedback-type" :class="notice.feedbackType">
类型{{ getFeedbackTypeText(notice.feedbackType) }}
</span>
<span class="feedback-status">
状态{{ notice.reply ? '已回复' : '待回复' }}
</span>
</div>
</div>
@ -125,6 +131,8 @@
</template>
<script>
import axios from 'axios'
const API_BASE_URL = 'http://localhost:3000/api'
export default {
name: 'MyNotice',
data() {
@ -137,15 +145,11 @@ export default {
{ id: 'feedback', label: '我的反馈' },
{ id: 'system', label: '系统通知' }
],
//
notices: []
notices: [],
unreadCount: 0
};
},
computed: {
//
unreadCount() {
return this.notices.filter(notice => !notice.isRead).length;
},
//
feedbackNotices() {
return this.notices.filter(notice => notice.type === 'feedback');
@ -168,85 +172,118 @@ export default {
},
mounted() {
this.fetchNotices();
//
this.pollUnreadCount();
},
beforeUnmount() {
if (this.pollTimer) {
clearInterval(this.pollTimer);
}
},
methods: {
// token
getAuthToken() {
// token
const possibleTokens = [
localStorage.getItem('token'),
sessionStorage.getItem('token'),
localStorage.getItem('authToken'),
sessionStorage.getItem('authToken'),
localStorage.getItem('userToken'),
sessionStorage.getItem('userToken')
]
// token
for (let token of possibleTokens) {
if (token && token !== 'null' && token !== 'undefined') {
// token
return token.replace(/^["']|["']$/g, '').trim()
}
}
return null
},
//
async fetchNotices() {
this.loading = true;
try {
// API
const response = await this.$axios.get('/api/mynotice');
if (response.data.code === 200) {
this.notices = response.data.data.notices;
// token
const token = this.getAuthToken()
if (!token) {
this.errorMessage = '未找到认证信息,请重新登录'
this.loading = false
return
}
//
const [systemResponse, feedbackResponse] = await Promise.all([
//
axios.get(`${API_BASE_URL}/notice`),
//
axios.get(`${API_BASE_URL}/feedback/user/my-feedbacks`, {
headers: {
'Authorization': `Bearer ${token}`
}
})
]);
const systemNotices = [];
const feedbackNotices = [];
//
if (systemResponse.data.code === 200) {
systemNotices.push(...systemResponse.data.data.notices || []);
this.unreadCount = systemResponse.data.data.unreadCount || 0;
} else {
console.error('获取通知失败:', response.data.message);
console.error('获取系统通知失败:', systemResponse.data.message);
}
//
if (feedbackResponse.data.success) {
const feedbacks = feedbackResponse.data.data || [];
//
feedbackNotices.push(...feedbacks
.filter(feedback => feedback.answer) //
.map(feedback => this.convertFeedbackToNotice(feedback))
);
console.log('获取到反馈通知:', feedbackNotices.length);
} else {
console.error('获取反馈通知失败:', feedbackResponse.data.message);
}
//
this.notices = [...systemNotices, ...feedbackNotices].sort((a, b) => {
return new Date(b.time) - new Date(a.time);
});
} catch (error) {
console.error('获取通知时出错:', error);
// 使
this.useMockData();
this.$message.error('获取通知时出错: ' + error.message);
//
this.notices = [];
this.unreadCount = 0;
} finally {
this.loading = false;
}
},
// 使
useMockData() {
//
const mockNotices = [
{
id: 1,
type: 'feedback',
title: '关于图表导出功能的建议',
content: '建议增加导出为PDF格式的功能这样更方便分享和打印。',
feedbackType: 'suggestion',
time: new Date(Date.now() - 3600000 * 2), // 2
isRead: false,
reply: '感谢您的建议我们已经在开发计划中预计下个版本会增加PDF导出功能。',
replyTime: new Date(Date.now() - 3600000 * 1) // 1
},
{
id: 2,
type: 'feedback',
title: '界面颜色太刺眼',
content: '数据可视化页面的背景色太亮,长时间使用眼睛容易疲劳。',
feedbackType: 'ui',
time: new Date(Date.now() - 86400000 * 2), // 2
isRead: true,
reply: '我们已收到您的反馈,会在下个版本中增加深色模式选项。',
replyTime: new Date(Date.now() - 86400000 * 1) // 1
},
{
id: 3,
type: 'system',
title: '系统维护通知',
content: '为了提升系统性能我们将于本周六凌晨2:00-4:00进行系统维护期间服务将不可用。',
time: new Date(Date.now() - 86400000 * 3), // 3
isRead: false
},
{
id: 4,
type: 'system',
title: '新功能上线',
content: '图表智能推荐功能已上线,系统会根据您的数据自动推荐最合适的图表类型。',
time: new Date(Date.now() - 86400000 * 5), // 5
isRead: true
},
{
id: 5,
type: 'feedback',
title: '数据导入失败问题',
content: '导入超过10MB的CSV文件时系统会报错提示内存不足。',
feedbackType: 'bug',
time: new Date(Date.now() - 86400000 * 7), // 7
isRead: false,
reply: null
}
];
this.notices = mockNotices;
//
convertFeedbackToNotice(feedback) {
return {
id: `feedback_${feedback.feedback_id}`,
type: 'feedback',
title: '反馈回复通知',
content: `反馈内容:${feedback.content.substring(0, 50)}${feedback.content.length > 50 ? '...' : ''}`,
reply: feedback.answer,
feedbackTime: feedback.feedback_time,
time: feedback.answer_time,//
isRead: true, //
feedbackType: feedback.type
};
},
//
refreshNotices() {
this.fetchNotices();
@ -256,14 +293,24 @@ export default {
async markAsRead(notice) {
try {
// API
const response = await this.$axios.post(`/api/mynotice/read/${notice.id}`);
const response = await axios.post(`${API_BASE_URL}/notice/read/${notice.id}`);
if (response.data.code === 200) {
//
notice.isRead = true;
//
this.updateUnreadCount();
//
if (notice.type === 'system') {
//
await this.fetchUnreadCount();
}
} else {
this.$message.error('标记已读失败: ' + response.data.message);
}
} catch (error) {
console.error('标记已读失败:', error);
//
notice.isRead = true;
this.$message.error('标记已读失败: ' + error.message);
}
},
@ -272,18 +319,32 @@ export default {
this.isMarking = true;
try {
// API
const response = await this.$axios.post('/api/mynotice/read-all');
const response = await axios.post(`${API_BASE_URL}/notice/read-all`);
if (response.data.code === 200) {
//
this.notices.forEach(notice => {
notice.isRead = true;
if (notice.type === 'system') {
//
notice.isRead = true;
} else if (notice.type === 'feedback') {
//
//
}
});
//
this.updateUnreadCount();
//
await this.fetchUnreadCount();
this.$message.success('全部通知已标记为已读');
} else {
this.$message.error('标记全部已读失败: ' + response.data.message);
}
} catch (error) {
console.error('标记全部已读失败:', error);
//
this.notices.forEach(notice => {
notice.isRead = true;
});
this.$message.error('标记全部已读失败: ' + error.message);
} finally {
this.isMarking = false;
}
@ -296,10 +357,60 @@ export default {
}
},
//
formatTime(date) {
//
async fetchUnreadCount() {
try {
const response = await axios.get(`${API_BASE_URL}/notice/unread-count`);
if (response.data.code === 200) {
this.unreadCount = response.data.data.unreadCount;
}
} catch (error) {
console.error('获取未读数量失败:', error);
// 使
this.updateUnreadCount();
}
},
//
updateUnreadCount() {
this.unreadCount = this.notices.filter(notice => !notice.isRead).length;
},
//
pollUnreadCount() {
//
this.pollTimer = setInterval(() => {
this.fetchUnreadCount();
}, 60000); // 60
},
//
formatDateTime(date) {
if (!date) return '未知时间';
try {
return new Date(date).toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
} catch (error) {
return '无效时间';
}
},
//
format_ago_Time(date) {
if (!date) return '未知时间';
const now = new Date();
const noticeDate = new Date(date);
//
if (isNaN(noticeDate.getTime())) {
return '无效时间';
}
const diffMs = now - noticeDate;
const diffHours = diffMs / (1000 * 60 * 60);
@ -312,7 +423,13 @@ export default {
} else if (diffHours < 24 * 30) {
return `${Math.floor(diffHours / 24)}天前`;
} else {
return noticeDate.toLocaleDateString();
return noticeDate.toLocaleDateString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
}
},
@ -320,8 +437,8 @@ export default {
getFeedbackTypeText(type) {
const typeMap = {
'suggestion': '功能建议',
'ui': '界面优化',
'bug': '产品bug',
'ui': '界面问题',
'bug': 'BUG反馈',
'other': '其他问题'
};
return typeMap[type] || type;
@ -345,6 +462,7 @@ export default {
</script>
<style scoped>
/* 样式部分保持不变 */
.my-notice-container {
max-width: 1200px;
margin: 0 auto;
@ -639,11 +757,6 @@ export default {
font-size: 14px;
}
.reply-time {
font-size: 12px;
color: #999;
}
.reply-content {
margin: 0;
color: #555;
@ -653,6 +766,9 @@ export default {
/* 反馈信息 */
.feedback-info {
margin-top: 10px;
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.feedback-type {
@ -663,6 +779,14 @@ export default {
color: #3498db;
}
.feedback-status {
font-size: 12px;
padding: 2px 8px;
border-radius: 4px;
background: #f0f0f0;
color: #666;
}
.feedback-type.suggestion {
background: #e8f5e9;
color: #2ecc71;

Loading…
Cancel
Save