first commit
This commit is contained in:
parent
d7295fa121
commit
7abbe707de
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
backend/node_modules/
|
||||
frontend/node_modules/
|
||||
backend/uploads/
|
6
backend/.env
Normal file
6
backend/.env
Normal file
@ -0,0 +1,6 @@
|
||||
PORT=3000
|
||||
DB_HOST=127.0.0.1
|
||||
DB_USER=root
|
||||
DB_PASSWORD=05WXZcu
|
||||
DB_DATABASE=game_categories
|
||||
JWT_SECRET=8f4c9d6e2a7b1f5k9m3n8p4q7r2t5v8x0z
|
31
backend/middleware/auth.js
Normal file
31
backend/middleware/auth.js
Normal file
@ -0,0 +1,31 @@
|
||||
const jwt = require('jsonwebtoken');
|
||||
const db = require('../utils/db');
|
||||
|
||||
const auth = async (req, res, next) => {
|
||||
try {
|
||||
const token = req.header('Authorization').replace('Bearer ', '');
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||
|
||||
const [admins] = await db.query(
|
||||
'SELECT a.*, GROUP_CONCAT(ap.permission) as permissions FROM admins a ' +
|
||||
'LEFT JOIN admin_permissions ap ON a.id = ap.admin_id ' +
|
||||
'WHERE a.id = ? AND a.status = "active" ' +
|
||||
'GROUP BY a.id',
|
||||
[decoded.id]
|
||||
);
|
||||
|
||||
if (!admins[0]) {
|
||||
throw new Error();
|
||||
}
|
||||
|
||||
req.admin = {
|
||||
...admins[0],
|
||||
permissions: admins[0].permissions ? admins[0].permissions.split(',') : []
|
||||
};
|
||||
next();
|
||||
} catch (error) {
|
||||
res.status(401).json({ message: '请先登录' });
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = auth;
|
34
backend/middleware/checkPermission.js
Normal file
34
backend/middleware/checkPermission.js
Normal file
@ -0,0 +1,34 @@
|
||||
const checkPermission = (requiredPermission) => {
|
||||
return (req, res, next) => {
|
||||
if (!req.admin) {
|
||||
return res.status(401).json({ message: '请先登录' });
|
||||
}
|
||||
|
||||
if (req.admin.role === 'superadmin') {
|
||||
return next(); // 超级管理员拥有所有权限
|
||||
}
|
||||
|
||||
// 添加默认权限映射
|
||||
const rolePermissions = {
|
||||
admin: [
|
||||
'game:manage',
|
||||
'category:manage',
|
||||
'media:manage',
|
||||
'message:manage' // 添加消息管理权限
|
||||
],
|
||||
editor: [
|
||||
'game:manage',
|
||||
'media:manage'
|
||||
]
|
||||
};
|
||||
|
||||
if (!req.admin.permissions.includes(requiredPermission) &&
|
||||
!(rolePermissions[req.admin.role] || []).includes(requiredPermission)) {
|
||||
return res.status(403).json({ message: '没有操作权限' });
|
||||
}
|
||||
|
||||
next();
|
||||
};
|
||||
};
|
||||
|
||||
module.exports = checkPermission;
|
89
backend/models/admin.js
Normal file
89
backend/models/admin.js
Normal file
@ -0,0 +1,89 @@
|
||||
const mongoose = require('mongoose');
|
||||
const bcrypt = require('bcryptjs');
|
||||
|
||||
const adminSchema = new mongoose.Schema({
|
||||
username: {
|
||||
type: String,
|
||||
required: true,
|
||||
unique: true
|
||||
},
|
||||
password: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
email: {
|
||||
type: String,
|
||||
required: true,
|
||||
unique: true
|
||||
},
|
||||
role: {
|
||||
type: String,
|
||||
enum: ['superadmin', 'admin', 'editor'],
|
||||
default: 'editor'
|
||||
},
|
||||
permissions: [{
|
||||
type: String,
|
||||
enum: [
|
||||
'user:manage', // 用户管理权限
|
||||
'game:manage', // 游戏管理权限
|
||||
'category:manage', // 分类管理权限
|
||||
'media:manage', // 媒体管理权限
|
||||
'system:manage' // 系统管理权限
|
||||
]
|
||||
}],
|
||||
status: {
|
||||
type: String,
|
||||
enum: ['active', 'inactive'],
|
||||
default: 'active'
|
||||
},
|
||||
lastLogin: Date,
|
||||
createdBy: {
|
||||
type: mongoose.Schema.Types.ObjectId,
|
||||
ref: 'Admin'
|
||||
},
|
||||
createdAt: {
|
||||
type: Date,
|
||||
default: Date.now
|
||||
}
|
||||
});
|
||||
|
||||
// 密码加密
|
||||
adminSchema.pre('save', async function(next) {
|
||||
if (this.isModified('password')) {
|
||||
this.password = await bcrypt.hash(this.password, 10);
|
||||
}
|
||||
next();
|
||||
});
|
||||
|
||||
// 根据角色设置默认权限
|
||||
adminSchema.pre('save', function(next) {
|
||||
if (this.isNew || this.isModified('role')) {
|
||||
switch (this.role) {
|
||||
case 'superadmin':
|
||||
this.permissions = [
|
||||
'user:manage',
|
||||
'game:manage',
|
||||
'category:manage',
|
||||
'media:manage',
|
||||
'system:manage'
|
||||
];
|
||||
break;
|
||||
case 'admin':
|
||||
this.permissions = [
|
||||
'game:manage',
|
||||
'category:manage',
|
||||
'media:manage'
|
||||
];
|
||||
break;
|
||||
case 'editor':
|
||||
this.permissions = [
|
||||
'game:manage',
|
||||
'media:manage'
|
||||
];
|
||||
break;
|
||||
}
|
||||
}
|
||||
next();
|
||||
});
|
||||
|
||||
module.exports = mongoose.model('Admin', adminSchema);
|
34
backend/models/media.js
Normal file
34
backend/models/media.js
Normal file
@ -0,0 +1,34 @@
|
||||
const mongoose = require('mongoose');
|
||||
|
||||
const mediaSchema = new mongoose.Schema({
|
||||
filename: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
originalname: String,
|
||||
mimetype: String,
|
||||
size: Number,
|
||||
url: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
type: {
|
||||
type: String,
|
||||
enum: ['image', 'video', 'audio'],
|
||||
required: true
|
||||
},
|
||||
gameId: {
|
||||
type: mongoose.Schema.Types.ObjectId,
|
||||
ref: 'Game'
|
||||
},
|
||||
uploadedBy: {
|
||||
type: mongoose.Schema.Types.ObjectId,
|
||||
ref: 'Admin'
|
||||
},
|
||||
uploadedAt: {
|
||||
type: Date,
|
||||
default: Date.now
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = mongoose.model('Media', mediaSchema);
|
23
backend/package.json
Normal file
23
backend/package.json
Normal file
@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "game-category-backend",
|
||||
"version": "1.0.0",
|
||||
"main": "server.js",
|
||||
"dependencies": {
|
||||
"bcryptjs": "^2.4.3",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.0.3",
|
||||
"express": "^4.17.1",
|
||||
"jsonwebtoken": "^9.0.0",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"mysql2": "^2.3.3",
|
||||
"ws": "^8.18.0"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "node server.js",
|
||||
"dev": "nodemon server.js",
|
||||
"test-db": "node scripts/test-db.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^2.0.12"
|
||||
}
|
||||
}
|
185
backend/routes/admin.js
Normal file
185
backend/routes/admin.js
Normal file
@ -0,0 +1,185 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const db = require('../utils/db');
|
||||
const auth = require('../middleware/auth');
|
||||
const checkPermission = require('../middleware/checkPermission');
|
||||
const bcrypt = require('bcryptjs');
|
||||
|
||||
// 获取用户列表
|
||||
router.get('/users', auth, checkPermission('user:manage'), async (req, res) => {
|
||||
try {
|
||||
const [users] = await db.query(`
|
||||
SELECT a.*,
|
||||
GROUP_CONCAT(ap.permission) as permissions
|
||||
FROM admins a
|
||||
LEFT JOIN admin_permissions ap ON a.id = ap.admin_id
|
||||
GROUP BY a.id
|
||||
`);
|
||||
|
||||
users.forEach(user => {
|
||||
user.permissions = user.permissions ? user.permissions.split(',') : [];
|
||||
delete user.password;
|
||||
});
|
||||
|
||||
res.json(users);
|
||||
} catch (error) {
|
||||
res.status(500).json({ message: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// 添加用户
|
||||
router.post('/users', auth, checkPermission('user:manage'), async (req, res) => {
|
||||
const connection = await db.getConnection();
|
||||
await connection.beginTransaction();
|
||||
|
||||
try {
|
||||
const { username, email, password, role, status, permissions } = req.body;
|
||||
|
||||
// 验证必填字段
|
||||
if (!username || !email || !password) {
|
||||
return res.status(400).json({ message: '用户名、邮箱和密码为必填项' });
|
||||
}
|
||||
|
||||
// 检查用户名是否已存在
|
||||
const [existingUsers] = await connection.query(
|
||||
'SELECT id FROM admins WHERE username = ?',
|
||||
[username]
|
||||
);
|
||||
|
||||
if (existingUsers.length > 0) {
|
||||
return res.status(400).json({ message: '用户名已存在' });
|
||||
}
|
||||
|
||||
// 加密密码
|
||||
const hashedPassword = await bcrypt.hash(password, 10);
|
||||
|
||||
// 插入用户记录
|
||||
const [result] = await connection.query(
|
||||
'INSERT INTO admins (username, email, password, role, status) VALUES (?, ?, ?, ?, ?)',
|
||||
[username, email, hashedPassword, role || 'editor', status || 'active']
|
||||
);
|
||||
|
||||
// 添加权限
|
||||
if (permissions && permissions.length > 0) {
|
||||
const permissionValues = permissions.map(permission => [result.insertId, permission]);
|
||||
await connection.query(
|
||||
'INSERT INTO admin_permissions (admin_id, permission) VALUES ?',
|
||||
[permissionValues]
|
||||
);
|
||||
}
|
||||
|
||||
await connection.commit();
|
||||
res.status(201).json({ message: '用户创建成功' });
|
||||
} catch (error) {
|
||||
await connection.rollback();
|
||||
res.status(500).json({ message: error.message });
|
||||
} finally {
|
||||
connection.release();
|
||||
}
|
||||
});
|
||||
|
||||
// 更新用户
|
||||
router.put('/users/:id', auth, checkPermission('user:manage'), async (req, res) => {
|
||||
const connection = await db.getConnection();
|
||||
await connection.beginTransaction();
|
||||
|
||||
try {
|
||||
const { username, email, password, role, status, permissions } = req.body;
|
||||
const userId = req.params.id;
|
||||
|
||||
// 检查用户是否存在
|
||||
const [existingUser] = await connection.query(
|
||||
'SELECT role FROM admins WHERE id = ?',
|
||||
[userId]
|
||||
);
|
||||
|
||||
if (!existingUser.length) {
|
||||
return res.status(404).json({ message: '用户不存在' });
|
||||
}
|
||||
|
||||
// 不允许非超级管理员修改超级管理员
|
||||
if (existingUser[0].role === 'superadmin' && req.admin.role !== 'superadmin') {
|
||||
return res.status(403).json({ message: '无权修改超级管理员' });
|
||||
}
|
||||
|
||||
// 更新用户基本信息
|
||||
let updateQuery = 'UPDATE admins SET username = ?, email = ?';
|
||||
let updateParams = [username, email];
|
||||
|
||||
if (password) {
|
||||
const hashedPassword = await bcrypt.hash(password, 10);
|
||||
updateQuery += ', password = ?';
|
||||
updateParams.push(hashedPassword);
|
||||
}
|
||||
|
||||
if (role) {
|
||||
updateQuery += ', role = ?';
|
||||
updateParams.push(role);
|
||||
}
|
||||
|
||||
if (status) {
|
||||
updateQuery += ', status = ?';
|
||||
updateParams.push(status);
|
||||
}
|
||||
|
||||
updateQuery += ' WHERE id = ?';
|
||||
updateParams.push(userId);
|
||||
|
||||
await connection.query(updateQuery, updateParams);
|
||||
|
||||
// 更新权限
|
||||
if (permissions) {
|
||||
await connection.query('DELETE FROM admin_permissions WHERE admin_id = ?', [userId]);
|
||||
|
||||
if (permissions.length > 0) {
|
||||
const permissionValues = permissions.map(permission => [userId, permission]);
|
||||
await connection.query(
|
||||
'INSERT INTO admin_permissions (admin_id, permission) VALUES ?',
|
||||
[permissionValues]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await connection.commit();
|
||||
res.json({ message: '用户更新成功' });
|
||||
} catch (error) {
|
||||
await connection.rollback();
|
||||
res.status(500).json({ message: error.message });
|
||||
} finally {
|
||||
connection.release();
|
||||
}
|
||||
});
|
||||
|
||||
// 删除用户
|
||||
router.delete('/users/:id', auth, checkPermission('user:manage'), async (req, res) => {
|
||||
try {
|
||||
const userId = req.params.id;
|
||||
|
||||
// 检查用户是否存在
|
||||
const [existingUser] = await db.query(
|
||||
'SELECT role FROM admins WHERE id = ?',
|
||||
[userId]
|
||||
);
|
||||
|
||||
if (!existingUser.length) {
|
||||
return res.status(404).json({ message: '用户不存在' });
|
||||
}
|
||||
|
||||
// 不允许删除超级管理员
|
||||
if (existingUser[0].role === 'superadmin') {
|
||||
return res.status(403).json({ message: '不能删除超级管理员' });
|
||||
}
|
||||
|
||||
// 不能删除自己
|
||||
if (userId === req.admin.id) {
|
||||
return res.status(400).json({ message: '不能删除自己的账号' });
|
||||
}
|
||||
|
||||
await db.query('DELETE FROM admins WHERE id = ?', [userId]);
|
||||
res.json({ message: '用户删除成功' });
|
||||
} catch (error) {
|
||||
res.status(500).json({ message: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
65
backend/routes/messages.js
Normal file
65
backend/routes/messages.js
Normal file
@ -0,0 +1,65 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const db = require('../utils/db');
|
||||
const auth = require('../middleware/auth');
|
||||
const checkPermission = require('../middleware/checkPermission');
|
||||
const { broadcastNewMessage } = require('../websocket');
|
||||
|
||||
// 提交留言
|
||||
router.post('/', async (req, res) => {
|
||||
try {
|
||||
const { name, email, message } = req.body;
|
||||
|
||||
const [result] = await db.query(
|
||||
'INSERT INTO messages (name, email, content) VALUES (?, ?, ?)',
|
||||
[name, email, message]
|
||||
);
|
||||
|
||||
// 获取新插入的消息详情
|
||||
const [newMessage] = await db.query(
|
||||
'SELECT * FROM messages WHERE id = ?',
|
||||
[result.insertId]
|
||||
);
|
||||
|
||||
// 广播新消息通知
|
||||
broadcastNewMessage(newMessage[0]);
|
||||
|
||||
res.status(201).json({ message: '留言提交成功' });
|
||||
} catch (error) {
|
||||
res.status(500).json({ message: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// 获取留言列表(需要管理员权限)
|
||||
router.get('/', auth, checkPermission('message:manage'), async (req, res) => {
|
||||
try {
|
||||
const [messages] = await db.query(`
|
||||
SELECT m.*, a.username as reader_name
|
||||
FROM messages m
|
||||
LEFT JOIN admins a ON m.read_by = a.id
|
||||
ORDER BY m.created_at DESC
|
||||
`);
|
||||
res.json(messages);
|
||||
} catch (error) {
|
||||
res.status(500).json({ message: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// 标记留言为已读
|
||||
router.put('/:id/read', auth, checkPermission('message:manage'), async (req, res) => {
|
||||
try {
|
||||
await db.query(
|
||||
`UPDATE messages
|
||||
SET status = 'read',
|
||||
read_by = ?,
|
||||
read_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?`,
|
||||
[req.admin.id, req.params.id]
|
||||
);
|
||||
res.json({ message: '标记成功' });
|
||||
} catch (error) {
|
||||
res.status(500).json({ message: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
57
backend/routes/systemSettings.js
Normal file
57
backend/routes/systemSettings.js
Normal file
@ -0,0 +1,57 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const db = require('../utils/db');
|
||||
const auth = require('../middleware/auth');
|
||||
const checkPermission = require('../middleware/checkPermission');
|
||||
|
||||
// 获取所有设置
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const [settings] = await db.query('SELECT * FROM system_settings');
|
||||
const formattedSettings = settings.reduce((acc, setting) => {
|
||||
if (!acc[setting.category]) {
|
||||
acc[setting.category] = {};
|
||||
}
|
||||
acc[setting.category][setting.key] = {
|
||||
value: setting.value,
|
||||
description: setting.description
|
||||
};
|
||||
return acc;
|
||||
}, {});
|
||||
res.json(formattedSettings);
|
||||
} catch (error) {
|
||||
res.status(500).json({ message: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// 获取指定类别的设置
|
||||
router.get('/:category', async (req, res) => {
|
||||
try {
|
||||
const [settings] = await db.query(
|
||||
'SELECT * FROM system_settings WHERE category = ?',
|
||||
[req.params.category]
|
||||
);
|
||||
res.json(settings);
|
||||
} catch (error) {
|
||||
res.status(500).json({ message: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// 更新设置(需要管理员权限)
|
||||
router.put('/:category/:key', auth, checkPermission('system:manage'), async (req, res) => {
|
||||
try {
|
||||
const { category, key } = req.params;
|
||||
const { value } = req.body;
|
||||
|
||||
await db.query(
|
||||
'UPDATE system_settings SET value = ? WHERE category = ? AND `key` = ?',
|
||||
[value, category, key]
|
||||
);
|
||||
|
||||
res.json({ message: '设置更新成功' });
|
||||
} catch (error) {
|
||||
res.status(500).json({ message: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
70
backend/routes/tags.js
Normal file
70
backend/routes/tags.js
Normal file
@ -0,0 +1,70 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const db = require('../utils/db');
|
||||
const auth = require('../middleware/auth');
|
||||
const checkPermission = require('../middleware/checkPermission');
|
||||
|
||||
// 获取所有标签
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const [tags] = await db.query('SELECT * FROM game_tag_master ORDER BY name');
|
||||
res.json(tags);
|
||||
} catch (error) {
|
||||
res.status(500).json({ message: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// 添加标签
|
||||
router.post('/', auth, checkPermission('tag:manage'), async (req, res) => {
|
||||
try {
|
||||
const { name, description } = req.body;
|
||||
|
||||
if (!name) {
|
||||
return res.status(400).json({ message: '标签名称不能为空' });
|
||||
}
|
||||
|
||||
const [result] = await db.query(
|
||||
'INSERT INTO game_tag_master (name, description) VALUES (?, ?)',
|
||||
[name, description || '']
|
||||
);
|
||||
|
||||
res.status(201).json({
|
||||
message: '标签添加成功',
|
||||
id: result.insertId
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({ message: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// 更新标签
|
||||
router.put('/:id', auth, checkPermission('tag:manage'), async (req, res) => {
|
||||
try {
|
||||
const { name, description } = req.body;
|
||||
|
||||
if (!name) {
|
||||
return res.status(400).json({ message: '标签名称不能为空' });
|
||||
}
|
||||
|
||||
await db.query(
|
||||
'UPDATE game_tag_master SET name = ?, description = ? WHERE id = ?',
|
||||
[name, description || '', req.params.id]
|
||||
);
|
||||
|
||||
res.json({ message: '标签更新成功' });
|
||||
} catch (error) {
|
||||
res.status(500).json({ message: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// 删除标签
|
||||
router.delete('/:id', auth, checkPermission('tag:manage'), async (req, res) => {
|
||||
try {
|
||||
await db.query('DELETE FROM game_tag_master WHERE id = ?', [req.params.id]);
|
||||
res.json({ message: '标签删除成功' });
|
||||
} catch (error) {
|
||||
res.status(500).json({ message: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
38
backend/scripts/test-db.js
Normal file
38
backend/scripts/test-db.js
Normal file
@ -0,0 +1,38 @@
|
||||
require('dotenv').config();
|
||||
const mysql = require('mysql2/promise');
|
||||
|
||||
async function testConnection() {
|
||||
console.log('Testing database connection...');
|
||||
console.log('Connection details:', {
|
||||
host: process.env.DB_HOST,
|
||||
user: process.env.DB_USER,
|
||||
database: process.env.DB_DATABASE
|
||||
});
|
||||
|
||||
try {
|
||||
const connection = await mysql.createConnection({
|
||||
host: process.env.DB_HOST,
|
||||
user: process.env.DB_USER,
|
||||
password: process.env.DB_PASSWORD,
|
||||
database: process.env.DB_DATABASE
|
||||
});
|
||||
|
||||
console.log('Connection established successfully');
|
||||
|
||||
// 测试查询
|
||||
const [rows] = await connection.query('SELECT 1 as test');
|
||||
console.log('Test query result:', rows);
|
||||
|
||||
// 测试admins表
|
||||
const [admins] = await connection.query('SELECT COUNT(*) as count FROM admins');
|
||||
console.log('Admins count:', admins[0].count);
|
||||
|
||||
await connection.end();
|
||||
console.log('Connection closed');
|
||||
} catch (error) {
|
||||
console.error('Connection test failed:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
testConnection();
|
724
backend/server.js
Normal file
724
backend/server.js
Normal file
@ -0,0 +1,724 @@
|
||||
// 确保在最开始就加载环境变量
|
||||
require('dotenv').config();
|
||||
console.log('Environment loaded:', {
|
||||
DB_HOST: process.env.DB_HOST,
|
||||
DB_USER: process.env.DB_USER,
|
||||
DB_DATABASE: process.env.DB_DATABASE,
|
||||
PORT: process.env.PORT
|
||||
});
|
||||
|
||||
const express = require('express');
|
||||
const http = require('http');
|
||||
const cors = require('cors');
|
||||
const multer = require('multer');
|
||||
const path = require('path');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const bcrypt = require('bcryptjs');
|
||||
const auth = require('./middleware/auth');
|
||||
const checkPermission = require('./middleware/checkPermission');
|
||||
const db = require('./utils/db');
|
||||
const app = express();
|
||||
const systemSettingsRoutes = require('./routes/systemSettings');
|
||||
const messagesRoutes = require('./routes/messages');
|
||||
const tagsRoutes = require('./routes/tags');
|
||||
const adminRoutes = require('./routes/admin');
|
||||
const server = http.createServer(app);
|
||||
const { initWebSocket } = require('./websocket');
|
||||
|
||||
// 请求日志中间件
|
||||
app.use((req, res, next) => {
|
||||
console.log(`${new Date().toISOString()} ${req.method} ${req.url}`);
|
||||
next();
|
||||
});
|
||||
|
||||
// 中间件
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
|
||||
// 配置文件上传
|
||||
const storage = multer.diskStorage({
|
||||
destination: (req, file, cb) => {
|
||||
let uploadPath = 'uploads';
|
||||
if (file.mimetype.startsWith('image/')) {
|
||||
uploadPath += '/images';
|
||||
} else if (file.mimetype.startsWith('video/')) {
|
||||
uploadPath += '/videos';
|
||||
}
|
||||
// 确保目录存在
|
||||
require('fs').mkdirSync(path.join(__dirname, uploadPath), { recursive: true });
|
||||
cb(null, uploadPath);
|
||||
},
|
||||
filename: (req, file, cb) => {
|
||||
cb(null, Date.now() + path.extname(file.originalname));
|
||||
}
|
||||
});
|
||||
|
||||
const upload = multer({
|
||||
storage,
|
||||
limits: {
|
||||
fileSize: 10 * 1024 * 1024, // 10MB limit
|
||||
},
|
||||
fileFilter: (req, file, cb) => {
|
||||
const allowedTypes = /jpeg|jpg|png|gif|mp4|webm/;
|
||||
const extname = allowedTypes.test(path.extname(file.originalname).toLowerCase());
|
||||
const mimetype = allowedTypes.test(file.mimetype);
|
||||
if (extname && mimetype) {
|
||||
return cb(null, true);
|
||||
}
|
||||
cb(new Error('只支持图片和视频文件!'));
|
||||
}
|
||||
});
|
||||
|
||||
// API路由
|
||||
app.get('/api/categories', async (req, res) => {
|
||||
try {
|
||||
const [categories] = await db.query('SELECT * FROM categories');
|
||||
res.json(categories);
|
||||
} catch (error) {
|
||||
res.status(500).json({ message: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/games', async (req, res) => {
|
||||
try {
|
||||
const [games] = await db.query(`
|
||||
SELECT g.*, c.name as category_name,
|
||||
GROUP_CONCAT(DISTINCT gp.platform) as platforms,
|
||||
GROUP_CONCAT(DISTINCT gt.tag) as tags
|
||||
FROM games g
|
||||
LEFT JOIN categories c ON g.category_id = c.id
|
||||
LEFT JOIN game_platforms gp ON g.id = gp.game_id
|
||||
LEFT JOIN game_tags gt ON g.id = gt.game_id
|
||||
GROUP BY g.id
|
||||
`);
|
||||
|
||||
// 处理平台和标签字符串
|
||||
games.forEach(game => {
|
||||
game.platforms = game.platforms ? game.platforms.split(',') : [];
|
||||
game.tags = game.tags ? game.tags.split(',') : [];
|
||||
});
|
||||
|
||||
res.json(games);
|
||||
} catch (error) {
|
||||
res.status(500).json({ message: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/games/:id', async (req, res) => {
|
||||
try {
|
||||
const game = await Game.findById(req.params.id);
|
||||
if (!game) {
|
||||
return res.status(404).json({ message: 'Game not found' });
|
||||
}
|
||||
|
||||
// 获取游戏对应的分类信息
|
||||
const category = await Category.findById(game.categoryId);
|
||||
const gameWithCategory = {
|
||||
...game.toObject(),
|
||||
category
|
||||
};
|
||||
|
||||
res.json(gameWithCategory);
|
||||
} catch (error) {
|
||||
res.status(500).json({ message: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/games/search', async (req, res) => {
|
||||
try {
|
||||
const { query, category } = req.query;
|
||||
let searchQuery = {};
|
||||
|
||||
if (query) {
|
||||
searchQuery.$or = [
|
||||
{ title: new RegExp(query, 'i') },
|
||||
{ description: new RegExp(query, 'i') }
|
||||
];
|
||||
}
|
||||
|
||||
if (category) {
|
||||
searchQuery.categoryId = category;
|
||||
}
|
||||
|
||||
const games = await Game.find(searchQuery);
|
||||
res.json(games);
|
||||
} catch (error) {
|
||||
res.status(500).json({ message: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// 添加游戏
|
||||
app.post('/api/games', auth, upload.single('image'), async (req, res) => {
|
||||
try {
|
||||
const { title, description, category_id, developer, release_date, platforms, tags } = req.body;
|
||||
|
||||
// 验证必填字段
|
||||
if (!title || !category_id) {
|
||||
return res.status(400).json({ message: '标题和分类为必填项' });
|
||||
}
|
||||
|
||||
// 解析平台和标签数据(因为是通过FormData传输的)
|
||||
const platformsArray = Array.isArray(platforms) ? platforms : platforms ? [platforms] : [];
|
||||
const tagsArray = Array.isArray(tags) ? tags : tags ? [tags] : [];
|
||||
|
||||
const [result] = await db.query(
|
||||
'INSERT INTO games (title, description, category_id, developer, release_date, image) VALUES (?, ?, ?, ?, ?, ?)',
|
||||
[
|
||||
title,
|
||||
description || '',
|
||||
parseInt(category_id),
|
||||
developer || '',
|
||||
release_date || null,
|
||||
req.file ? req.file.path : null
|
||||
]
|
||||
);
|
||||
|
||||
const gameId = result.insertId;
|
||||
|
||||
// 添加平台信息
|
||||
if (platformsArray.length > 0) {
|
||||
const platformValues = platformsArray.map(platform => [gameId, platform]);
|
||||
await db.query(
|
||||
'INSERT INTO game_platforms (game_id, platform) VALUES ?',
|
||||
[platformValues]
|
||||
);
|
||||
}
|
||||
|
||||
// 添加标签信息
|
||||
if (tagsArray.length > 0) {
|
||||
const tagValues = tagsArray.map(tag => [gameId, tag]);
|
||||
await db.query(
|
||||
'INSERT INTO game_tags (game_id, tag) VALUES ?',
|
||||
[tagValues]
|
||||
);
|
||||
}
|
||||
|
||||
res.status(201).json({
|
||||
message: '游戏添加成功',
|
||||
id: gameId
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error adding game:', error);
|
||||
res.status(500).json({
|
||||
message: '添加游戏失败',
|
||||
error: process.env.NODE_ENV === 'development' ? error.message : undefined
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 管理员注册
|
||||
app.post('/api/admin/register', async (req, res) => {
|
||||
try {
|
||||
const { username, password, email } = req.body;
|
||||
|
||||
// 验证必填字段
|
||||
if (!username || !password || !email) {
|
||||
return res.status(400).json({ message: '用户名、密码和邮箱都是必填项' });
|
||||
}
|
||||
|
||||
// 检查用户名是否已存在
|
||||
const [existingUsers] = await db.query(
|
||||
'SELECT id FROM admins WHERE username = ?',
|
||||
[username]
|
||||
);
|
||||
|
||||
if (existingUsers.length > 0) {
|
||||
return res.status(400).json({ message: '用户名已存在' });
|
||||
}
|
||||
|
||||
// 检查邮箱是否已存在
|
||||
const [existingEmails] = await db.query(
|
||||
'SELECT id FROM admins WHERE email = ?',
|
||||
[email]
|
||||
);
|
||||
|
||||
if (existingEmails.length > 0) {
|
||||
return res.status(400).json({ message: '邮箱已被使用' });
|
||||
}
|
||||
|
||||
// 加密密码
|
||||
const hashedPassword = await bcrypt.hash(password, 10);
|
||||
console.log('Hashed password:', hashedPassword);
|
||||
|
||||
// 开始事务
|
||||
const connection = await db.getConnection();
|
||||
await connection.beginTransaction();
|
||||
|
||||
try {
|
||||
// 插入管理员记录
|
||||
const [result] = await connection.query(
|
||||
'INSERT INTO admins (username, password, email, role, status) VALUES (?, ?, ?, ?, ?)',
|
||||
[username, hashedPassword, email, 'editor', 'active']
|
||||
);
|
||||
|
||||
// 插入默认权限
|
||||
await connection.query(
|
||||
'INSERT INTO admin_permissions (admin_id, permission) VALUES (?, ?), (?, ?)',
|
||||
[result.insertId, 'game:manage', result.insertId, 'media:manage']
|
||||
);
|
||||
|
||||
await connection.commit();
|
||||
|
||||
// 生成JWT令牌
|
||||
const token = jwt.sign(
|
||||
{ id: result.insertId, role: 'editor' },
|
||||
process.env.JWT_SECRET,
|
||||
{ expiresIn: '24h' }
|
||||
);
|
||||
|
||||
res.status(201).json({
|
||||
message: '注册成功',
|
||||
token,
|
||||
admin: {
|
||||
username,
|
||||
role: 'editor',
|
||||
permissions: ['game:manage', 'media:manage']
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
await connection.rollback();
|
||||
throw error;
|
||||
} finally {
|
||||
connection.release();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Registration error:', error);
|
||||
res.status(500).json({ message: '注册失败', error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// 管理员登录
|
||||
app.post('/api/admin/login', async (req, res) => {
|
||||
try {
|
||||
const { username, password } = req.body;
|
||||
console.log('Login attempt:', { username }); // 添加登录尝试日志
|
||||
|
||||
const [admins] = await db.query(
|
||||
'SELECT a.*, GROUP_CONCAT(ap.permission) as permissions FROM admins a ' +
|
||||
'LEFT JOIN admin_permissions ap ON a.id = ap.admin_id ' +
|
||||
'WHERE a.username = ? AND a.status = "active" ' +
|
||||
'GROUP BY a.id',
|
||||
[username]
|
||||
);
|
||||
|
||||
console.log('Query result:', admins); // 添加查询结果日志
|
||||
|
||||
const admin = admins[0];
|
||||
if (!admin) {
|
||||
console.log('Admin not found');
|
||||
return res.status(401).json({ message: '用户名或密码错误' });
|
||||
}
|
||||
|
||||
console.log('Comparing password for:', admin.username);
|
||||
console.log('Encrypted password:', admin.password);
|
||||
const hashedPassword = await bcrypt.hash(password, 10);
|
||||
console.log('Hashed password:', hashedPassword);
|
||||
const isMatch = await bcrypt.compare(password, admin.password);
|
||||
console.log('Password match result:', isMatch);
|
||||
|
||||
if (!isMatch) {
|
||||
console.log('Password does not match');
|
||||
return res.status(401).json({ message: '用户名或密码错误' });
|
||||
}
|
||||
|
||||
// 更新最后登录时间
|
||||
await db.query(
|
||||
'UPDATE admins SET last_login = NOW() WHERE id = ?',
|
||||
[admin.id]
|
||||
);
|
||||
|
||||
const token = jwt.sign(
|
||||
{ id: admin.id, role: admin.role },
|
||||
process.env.JWT_SECRET,
|
||||
{ expiresIn: '24h' }
|
||||
);
|
||||
|
||||
console.log('Login successful for:', admin.username);
|
||||
|
||||
res.json({
|
||||
token,
|
||||
admin: {
|
||||
username: admin.username,
|
||||
role: admin.role,
|
||||
permissions: admin.permissions ? admin.permissions.split(',') : []
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Login error:', error);
|
||||
res.status(500).json({ message: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// 媒体文件上传
|
||||
app.post('/api/media/upload', auth, upload.single('file'), async (req, res) => {
|
||||
try {
|
||||
if (!req.file) {
|
||||
return res.status(400).json({ message: '请选择要上传的文件' });
|
||||
}
|
||||
|
||||
const media = new Media({
|
||||
filename: req.file.filename,
|
||||
originalname: req.file.originalname,
|
||||
mimetype: req.file.mimetype,
|
||||
size: req.file.size,
|
||||
url: `/uploads/${req.file.filename}`,
|
||||
type: req.file.mimetype.startsWith('image/') ? 'image' : 'video',
|
||||
uploadedBy: req.admin.id
|
||||
});
|
||||
|
||||
await media.save();
|
||||
res.json(media);
|
||||
} catch (error) {
|
||||
res.status(500).json({ message: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// 获取媒体文件列表
|
||||
app.get('/api/media', auth, async (req, res) => {
|
||||
try {
|
||||
const { type, gameId } = req.query;
|
||||
let query = {};
|
||||
|
||||
if (type) query.type = type;
|
||||
if (gameId) query.gameId = gameId;
|
||||
|
||||
const media = await Media.find(query)
|
||||
.populate('uploadedBy', 'username')
|
||||
.sort('-uploadedAt');
|
||||
|
||||
res.json(media);
|
||||
} catch (error) {
|
||||
res.status(500).json({ message: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// 删除媒体文件
|
||||
app.delete('/api/media/:id', auth, async (req, res) => {
|
||||
try {
|
||||
const media = await Media.findById(req.params.id);
|
||||
if (!media) {
|
||||
return res.status(404).json({ message: '文件不存在' });
|
||||
}
|
||||
|
||||
// 删除物理文件
|
||||
const filePath = path.join(__dirname, media.url);
|
||||
fs.unlink(filePath, async (err) => {
|
||||
if (err) {
|
||||
console.error('Error deleting file:', err);
|
||||
}
|
||||
await media.remove();
|
||||
res.json({ message: '文件已删除' });
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({ message: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// 更新游戏
|
||||
app.put('/api/games/:id', auth, upload.single('image'), async (req, res) => {
|
||||
try {
|
||||
const { title, description, category_id, developer, release_date, platforms, tags } = req.body;
|
||||
|
||||
// 验证游戏是否存在
|
||||
const [existingGame] = await db.query(
|
||||
'SELECT * FROM games WHERE id = ?',
|
||||
[req.params.id]
|
||||
);
|
||||
|
||||
if (!existingGame.length) {
|
||||
return res.status(404).json({ message: '游戏不存在' });
|
||||
}
|
||||
|
||||
// 更新游戏基本信息
|
||||
await db.query(
|
||||
`UPDATE games SET
|
||||
title = ?,
|
||||
description = ?,
|
||||
category_id = ?,
|
||||
developer = ?,
|
||||
release_date = ?,
|
||||
image = ?
|
||||
WHERE id = ?`,
|
||||
[
|
||||
title,
|
||||
description || '',
|
||||
parseInt(category_id),
|
||||
developer || '',
|
||||
release_date || null,
|
||||
req.file ? req.file.path : existingGame[0].image,
|
||||
req.params.id
|
||||
]
|
||||
);
|
||||
|
||||
// 更新平台信息
|
||||
if (platforms) {
|
||||
// 先删除旧的平台信息
|
||||
await db.query('DELETE FROM game_platforms WHERE game_id = ?', [req.params.id]);
|
||||
|
||||
// 添加新的平台信息
|
||||
if (platforms.length > 0) {
|
||||
const platformValues = platforms.map(platform => [req.params.id, platform]);
|
||||
await db.query(
|
||||
'INSERT INTO game_platforms (game_id, platform) VALUES ?',
|
||||
[platformValues]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 更新标签信息
|
||||
if (tags) {
|
||||
// 先删除旧的标签信息
|
||||
await db.query('DELETE FROM game_tags WHERE game_id = ?', [req.params.id]);
|
||||
|
||||
// 添加新的标签信息
|
||||
if (tags.length > 0) {
|
||||
const tagValues = tags.map(tag => [req.params.id, tag]);
|
||||
await db.query(
|
||||
'INSERT INTO game_tags (game_id, tag) VALUES ?',
|
||||
[tagValues]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ message: '游戏更新成功' });
|
||||
} catch (error) {
|
||||
console.error('Error updating game:', error);
|
||||
res.status(500).json({ message: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// 添加统计API路由
|
||||
app.get('/api/stats', auth, async (req, res) => {
|
||||
try {
|
||||
const [gamesCount, categoriesCount, mediaCount] = await Promise.all([
|
||||
Game.countDocuments(),
|
||||
Category.countDocuments(),
|
||||
Media.countDocuments()
|
||||
]);
|
||||
|
||||
res.json({
|
||||
gamesCount,
|
||||
categoriesCount,
|
||||
mediaCount
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({ message: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// 用户管理API
|
||||
app.get('/api/admins', auth, checkPermission('user:manage'), async (req, res) => {
|
||||
try {
|
||||
const admins = await Admin.find()
|
||||
.select('-password')
|
||||
.populate('createdBy', 'username');
|
||||
res.json(admins);
|
||||
} catch (error) {
|
||||
res.status(500).json({ message: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/admins', auth, checkPermission('user:manage'), async (req, res) => {
|
||||
try {
|
||||
const { username, email, password, role } = req.body;
|
||||
|
||||
const admin = new Admin({
|
||||
username,
|
||||
email,
|
||||
password,
|
||||
role,
|
||||
createdBy: req.admin.id
|
||||
});
|
||||
|
||||
await admin.save();
|
||||
res.status(201).json({ message: '用户创建成功' });
|
||||
} catch (error) {
|
||||
res.status(500).json({ message: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.put('/api/admins/:id', auth, checkPermission('user:manage'), async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const updates = req.body;
|
||||
|
||||
// 不允许修改超级管理员
|
||||
const admin = await Admin.findById(id);
|
||||
if (admin.role === 'superadmin' && req.admin.role !== 'superadmin') {
|
||||
return res.status(403).json({ message: '无权修改超级管理员' });
|
||||
}
|
||||
|
||||
// 如果要修改密码,需要加密
|
||||
if (updates.password) {
|
||||
updates.password = await bcrypt.hash(updates.password, 10);
|
||||
}
|
||||
|
||||
const updatedAdmin = await Admin.findByIdAndUpdate(
|
||||
id,
|
||||
updates,
|
||||
{ new: true }
|
||||
).select('-password');
|
||||
|
||||
res.json(updatedAdmin);
|
||||
} catch (error) {
|
||||
res.status(500).json({ message: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/admins/:id', auth, checkPermission('user:manage'), async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
// 不允许删除超级管理员
|
||||
const admin = await Admin.findById(id);
|
||||
if (admin.role === 'superadmin') {
|
||||
return res.status(403).json({ message: '不能删除超级管理员' });
|
||||
}
|
||||
|
||||
// 不能删除自己
|
||||
if (id === req.admin.id) {
|
||||
return res.status(400).json({ message: '不能删除自己的账号' });
|
||||
}
|
||||
|
||||
await Admin.findByIdAndDelete(id);
|
||||
res.json({ message: '用户删除成功' });
|
||||
} catch (error) {
|
||||
res.status(500).json({ message: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// 获取当前用户信息
|
||||
app.get('/api/admins/me', auth, async (req, res) => {
|
||||
try {
|
||||
const admin = await Admin.findById(req.admin.id)
|
||||
.select('-password');
|
||||
res.json(admin);
|
||||
} catch (error) {
|
||||
res.status(500).json({ message: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// 修改密码
|
||||
app.post('/api/admins/change-password', auth, async (req, res) => {
|
||||
try {
|
||||
const { oldPassword, newPassword } = req.body;
|
||||
|
||||
const admin = await Admin.findById(req.admin.id);
|
||||
const isMatch = await bcrypt.compare(oldPassword, admin.password);
|
||||
|
||||
if (!isMatch) {
|
||||
return res.status(400).json({ message: '原密码错误' });
|
||||
}
|
||||
|
||||
admin.password = newPassword;
|
||||
await admin.save();
|
||||
|
||||
res.json({ message: '密码修改成功' });
|
||||
} catch (error) {
|
||||
res.status(500).json({ message: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// 系统设置路由
|
||||
app.use('/api/settings', systemSettingsRoutes);
|
||||
|
||||
// 留言管理路由
|
||||
app.use('/api/messages', messagesRoutes);
|
||||
|
||||
// 标签管理路由
|
||||
app.use('/api/tags', tagsRoutes);
|
||||
|
||||
// 用户管理路由
|
||||
app.use('/api/admin', adminRoutes);
|
||||
|
||||
// 提供静态文件访问
|
||||
app.use('/uploads', express.static(path.join(__dirname, 'uploads')));
|
||||
|
||||
// 删除游戏
|
||||
app.delete('/api/games/:id', auth, async (req, res) => {
|
||||
try {
|
||||
// 验证游戏是否存在
|
||||
const [existingGame] = await db.query(
|
||||
'SELECT * FROM games WHERE id = ?',
|
||||
[req.params.id]
|
||||
);
|
||||
|
||||
if (!existingGame.length) {
|
||||
return res.status(404).json({ message: '游戏不存在' });
|
||||
}
|
||||
|
||||
// 开始事务
|
||||
const connection = await db.getConnection();
|
||||
await connection.beginTransaction();
|
||||
|
||||
try {
|
||||
// 删除游戏图片
|
||||
if (existingGame[0].image) {
|
||||
const imagePath = path.join(__dirname, existingGame[0].image);
|
||||
try {
|
||||
if (require('fs').existsSync(imagePath)) {
|
||||
require('fs').unlinkSync(imagePath);
|
||||
console.log(`Deleted image file: ${imagePath}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting image file:', error);
|
||||
// 继续执行,不因文件删除失败而中断整个操作
|
||||
}
|
||||
}
|
||||
|
||||
// 删除相关的平台信息
|
||||
await connection.query('DELETE FROM game_platforms WHERE game_id = ?', [req.params.id]);
|
||||
|
||||
// 删除相关的标签信息
|
||||
await connection.query('DELETE FROM game_tags WHERE game_id = ?', [req.params.id]);
|
||||
|
||||
// 删除游戏记录
|
||||
await connection.query('DELETE FROM games WHERE id = ?', [req.params.id]);
|
||||
|
||||
await connection.commit();
|
||||
console.log(`Successfully deleted game with ID: ${req.params.id}`);
|
||||
res.json({ message: '游戏删除成功' });
|
||||
} catch (error) {
|
||||
await connection.rollback();
|
||||
console.error('Transaction failed:', error);
|
||||
throw error;
|
||||
} finally {
|
||||
connection.release();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting game:', error);
|
||||
res.status(500).json({
|
||||
message: '删除游戏失败',
|
||||
error: process.env.NODE_ENV === 'development' ? error.message : undefined
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 获取管理员数量
|
||||
app.get('/api/admin/count', auth, async (req, res) => {
|
||||
try {
|
||||
const [result] = await db.query('SELECT COUNT(*) as count FROM admins');
|
||||
res.json({ count: result[0].count });
|
||||
} catch (error) {
|
||||
console.error('Error getting admin count:', error);
|
||||
res.status(500).json({ message: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// 错误处理中间件
|
||||
app.use((err, req, res, next) => {
|
||||
console.error('Server error:', err);
|
||||
res.status(500).json({
|
||||
message: 'Internal server error',
|
||||
error: process.env.NODE_ENV === 'development' ? err.message : undefined
|
||||
});
|
||||
});
|
||||
|
||||
// 启动服务器
|
||||
const PORT = process.env.PORT || 3000;
|
||||
server.listen(PORT, () => {
|
||||
console.log(`Server is running on port ${PORT}`);
|
||||
});
|
||||
|
||||
// 初始化 WebSocket
|
||||
initWebSocket(server);
|
53
backend/utils/db.js
Normal file
53
backend/utils/db.js
Normal file
@ -0,0 +1,53 @@
|
||||
const mysql = require('mysql2/promise');
|
||||
|
||||
// 打印数据库连接配置(不包含密码)
|
||||
console.log('Connecting to database:', {
|
||||
host: process.env.DB_HOST,
|
||||
user: process.env.DB_USER,
|
||||
database: process.env.DB_DATABASE
|
||||
});
|
||||
|
||||
const pool = mysql.createPool({
|
||||
host: process.env.DB_HOST,
|
||||
user: process.env.DB_USER,
|
||||
password: process.env.DB_PASSWORD,
|
||||
database: process.env.DB_DATABASE,
|
||||
waitForConnections: true,
|
||||
connectionLimit: 10,
|
||||
queueLimit: 0,
|
||||
// 添加额外的连接选项
|
||||
connectTimeout: 10000, // 10 seconds
|
||||
ssl: {
|
||||
// 允许自签名证书
|
||||
rejectUnauthorized: false
|
||||
}
|
||||
});
|
||||
|
||||
// 测试数据库连接
|
||||
pool.getConnection()
|
||||
.then(connection => {
|
||||
console.log('Database connected successfully');
|
||||
// 测试查询
|
||||
return connection.query('SELECT 1')
|
||||
.then(() => {
|
||||
console.log('Database query test successful');
|
||||
connection.release();
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Database query test failed:', error);
|
||||
connection.release();
|
||||
throw error;
|
||||
});
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Database connection error:', {
|
||||
code: error.code,
|
||||
errno: error.errno,
|
||||
sqlState: error.sqlState,
|
||||
sqlMessage: error.sqlMessage
|
||||
});
|
||||
// 如果是致命错误,终止程序
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
module.exports = pool;
|
64
backend/websocket.js
Normal file
64
backend/websocket.js
Normal file
@ -0,0 +1,64 @@
|
||||
const WebSocket = require('ws');
|
||||
const jwt = require('jsonwebtoken');
|
||||
|
||||
let wss = null;
|
||||
|
||||
const initWebSocket = (server) => {
|
||||
wss = new WebSocket.Server({ server });
|
||||
|
||||
wss.on('connection', (ws, req) => {
|
||||
// 验证管理员身份
|
||||
const token = req.url.split('=')[1];
|
||||
if (!token) {
|
||||
ws.close();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||
ws.adminId = decoded.id;
|
||||
ws.isAlive = true;
|
||||
|
||||
ws.on('pong', () => {
|
||||
ws.isAlive = true;
|
||||
});
|
||||
|
||||
ws.on('close', () => {
|
||||
ws.isAlive = false;
|
||||
});
|
||||
} catch (error) {
|
||||
ws.close();
|
||||
}
|
||||
});
|
||||
|
||||
// 心跳检测
|
||||
const interval = setInterval(() => {
|
||||
wss.clients.forEach((ws) => {
|
||||
if (ws.isAlive === false) return ws.terminate();
|
||||
ws.isAlive = false;
|
||||
ws.ping();
|
||||
});
|
||||
}, 30000);
|
||||
|
||||
wss.on('close', () => {
|
||||
clearInterval(interval);
|
||||
});
|
||||
};
|
||||
|
||||
const broadcastNewMessage = (message) => {
|
||||
if (!wss) return;
|
||||
|
||||
wss.clients.forEach((client) => {
|
||||
if (client.readyState === WebSocket.OPEN) {
|
||||
client.send(JSON.stringify({
|
||||
type: 'new_message',
|
||||
data: message
|
||||
}));
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
initWebSocket,
|
||||
broadcastNewMessage
|
||||
};
|
178
database/init.sql
Normal file
178
database/init.sql
Normal file
@ -0,0 +1,178 @@
|
||||
-- 创建数据库
|
||||
DROP DATABASE IF EXISTS game_categories;
|
||||
CREATE DATABASE game_categories DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
USE game_categories;
|
||||
|
||||
-- 管理员表
|
||||
CREATE TABLE admins (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
username VARCHAR(50) NOT NULL UNIQUE,
|
||||
password VARCHAR(255) NOT NULL,
|
||||
email VARCHAR(100) NOT NULL UNIQUE,
|
||||
role ENUM('superadmin', 'admin', 'editor') DEFAULT 'editor',
|
||||
status ENUM('active', 'inactive') DEFAULT 'active',
|
||||
last_login DATETIME,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB;
|
||||
|
||||
-- 管理员权限表
|
||||
CREATE TABLE admin_permissions (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
admin_id INT NOT NULL,
|
||||
permission VARCHAR(50) NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (admin_id) REFERENCES admins(id) ON DELETE CASCADE,
|
||||
UNIQUE KEY unique_admin_permission (admin_id, permission)
|
||||
) ENGINE=InnoDB;
|
||||
|
||||
-- 分类表
|
||||
CREATE TABLE categories (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
name VARCHAR(50) NOT NULL,
|
||||
description TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB;
|
||||
|
||||
-- 游戏表
|
||||
CREATE TABLE games (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
title VARCHAR(100) NOT NULL,
|
||||
description TEXT,
|
||||
category_id INT,
|
||||
image VARCHAR(255),
|
||||
release_date DATE,
|
||||
developer VARCHAR(100),
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB;
|
||||
|
||||
-- 游戏平台关联表
|
||||
CREATE TABLE game_platforms (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
game_id INT,
|
||||
platform VARCHAR(50),
|
||||
FOREIGN KEY (game_id) REFERENCES games(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB;
|
||||
|
||||
-- 游戏标签关联表
|
||||
CREATE TABLE game_tags (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
game_id INT,
|
||||
tag VARCHAR(50),
|
||||
FOREIGN KEY (game_id) REFERENCES games(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB;
|
||||
|
||||
-- 游戏标签主表
|
||||
CREATE TABLE game_tag_master (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
name VARCHAR(50) NOT NULL UNIQUE,
|
||||
description TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB;
|
||||
|
||||
-- 系统配置表
|
||||
CREATE TABLE system_settings (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
category VARCHAR(50) NOT NULL,
|
||||
`key` VARCHAR(50) NOT NULL,
|
||||
value TEXT,
|
||||
description VARCHAR(255),
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY unique_setting (`category`, `key`)
|
||||
) ENGINE=InnoDB;
|
||||
|
||||
-- 留言表
|
||||
CREATE TABLE messages (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
email VARCHAR(100) NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
status ENUM('unread', 'read') DEFAULT 'unread',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
read_by INT,
|
||||
read_at TIMESTAMP NULL,
|
||||
FOREIGN KEY (read_by) REFERENCES admins(id)
|
||||
) ENGINE=InnoDB;
|
||||
|
||||
-- 创建索引
|
||||
CREATE INDEX idx_games_category ON games(category_id);
|
||||
CREATE INDEX idx_games_title ON games(title);
|
||||
|
||||
-- 初始化数据
|
||||
-- 创建超级管理员账号 (密码: 123qwe)
|
||||
INSERT INTO admins (username, password, email, role, status) VALUES
|
||||
('admin', '$2a$10$YqFCm8pRCKOPtPDzqvwsUOQY7ZeUFNHDHI8Qc3ej3wqgNWO6GEBqe', 'admin@example.com', 'superadmin', 'active');
|
||||
|
||||
-- 添加管理员权限
|
||||
INSERT INTO admin_permissions (admin_id, permission) VALUES
|
||||
(1, 'user:manage'),
|
||||
(1, 'game:manage'),
|
||||
(1, 'category:manage'),
|
||||
(1, 'media:manage'),
|
||||
(1, 'system:manage'),
|
||||
(1, 'message:manage');
|
||||
|
||||
-- 插入游戏分类
|
||||
INSERT INTO categories (name, description) VALUES
|
||||
('动作游戏', '包含格斗、射击等动作元素的游戏'),
|
||||
('角色扮演', '玩家可以扮演角色进行冒险的游戏'),
|
||||
('策略游戏', '需要战略思维的游戏'),
|
||||
('体育竞技', '模拟各种体育运动的游戏'),
|
||||
('休闲益智', '简单有趣的休闲游戏');
|
||||
|
||||
-- 插入示例游戏数据
|
||||
INSERT INTO games (title, description, category_id, developer, release_date) VALUES
|
||||
('魔兽世界', '著名的大型多人在线角色扮演游戏', 2, 'Blizzard', '2004-11-23'),
|
||||
('FIFA 23', '最新的足球体育游戏', 4, 'EA Sports', '2022-09-30'),
|
||||
('俄罗斯方块', '经典的休闲益智游戏', 5, 'Various', '1984-06-06');
|
||||
|
||||
-- 添加游戏平台数据
|
||||
INSERT INTO game_platforms (game_id, platform) VALUES
|
||||
(1, 'PC'),
|
||||
(1, 'Mac'),
|
||||
(2, 'PC'),
|
||||
(2, 'PS5'),
|
||||
(2, 'Xbox'),
|
||||
(3, 'PC'),
|
||||
(3, 'Mobile');
|
||||
|
||||
-- 添加游戏标签
|
||||
INSERT INTO game_tags (game_id, tag) VALUES
|
||||
(1, 'MMORPG'),
|
||||
(1, '奇幻'),
|
||||
(2, '体育'),
|
||||
(2, '足球'),
|
||||
(3, '益智'),
|
||||
(3, '经典');
|
||||
|
||||
-- 插入一些默认标签
|
||||
INSERT INTO game_tag_master (name, description) VALUES
|
||||
('RPG', '角色扮演游戏'),
|
||||
('动作', '动作类游戏'),
|
||||
('策略', '策略类游戏'),
|
||||
('射击', '射击类游戏'),
|
||||
('冒险', '冒险类游戏'),
|
||||
('体育', '体育类游戏'),
|
||||
('竞速', '竞速类游戏'),
|
||||
('模拟', '模拟类游戏');
|
||||
|
||||
-- 插入初始配置数据
|
||||
INSERT INTO system_settings (category, `key`, value, description) VALUES
|
||||
-- 公司信息
|
||||
('company', 'name', 'Game Categories', '公司名称'),
|
||||
('company', 'description', 'Game Categories 成立于2024年,是一家专注于游戏分类和推荐的创新型科技公司。', '公司简介'),
|
||||
('company', 'mission', '让每个玩家都能找到最适合自己的游戏,创造快乐的游戏体验。', '公司使命'),
|
||||
('company', 'vision', '成为全球领先的游戏分类和推荐平台,引领游戏文化的发展。', '公司愿景'),
|
||||
-- 联系方式
|
||||
('contact', 'address', '北京市朝阳区xxx大厦', '公司地址'),
|
||||
('contact', 'email', 'contact@example.com', '联系邮箱'),
|
||||
('contact', 'phone', '+86 123 4567 8900', '联系电话'),
|
||||
-- 核心价值观
|
||||
('values', 'value1', '{"title":"用户至上","description":"始终以用户需求为中心,提供最优质的服务"}', '核心价值观1'),
|
||||
('values', 'value2', '{"title":"创新驱动","description":"持续创新,推动技术与产品的进步"}', '核心价值观2'),
|
||||
('values', 'value3', '{"title":"品质保证","description":"严格把控质量,确保服务的可靠性"}', '核心价值观3'),
|
||||
('values', 'value4', '{"title":"开放共赢","description":"与合作伙伴共同成长,实现价值共享"}', '核心价值观4');
|
||||
|
||||
-- 更新默认权限设置
|
||||
UPDATE admins SET role = 'admin' WHERE role = 'editor'; -- 将编辑角色升级为管理员
|
1
frontend/.env
Normal file
1
frontend/.env
Normal file
@ -0,0 +1 @@
|
||||
VUE_APP_API_URL=http://localhost:3000
|
BIN
frontend/assets/logo.png
Normal file
BIN
frontend/assets/logo.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 14 KiB |
5
frontend/babel.config.js
Normal file
5
frontend/babel.config.js
Normal file
@ -0,0 +1,5 @@
|
||||
module.exports = {
|
||||
presets: [
|
||||
'@vue/cli-plugin-babel/preset'
|
||||
]
|
||||
}
|
25
frontend/package.json
Normal file
25
frontend/package.json
Normal file
@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "game-category-frontend",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"serve": "vue-cli-service serve",
|
||||
"build": "vue-cli-service build",
|
||||
"lint": "vue-cli-service lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"vue": "^3.2.0",
|
||||
"vue-router": "^4.0.0",
|
||||
"axios": "^0.21.1",
|
||||
"element-plus": "^2.3.0",
|
||||
"@element-plus/icons-vue": "^2.1.0",
|
||||
"core-js": "^3.6.5",
|
||||
"echarts": "^5.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vue/cli-plugin-babel": "~4.5.0",
|
||||
"@vue/cli-plugin-router": "~4.5.0",
|
||||
"@vue/cli-service": "~4.5.0",
|
||||
"@vue/compiler-sfc": "^3.2.0"
|
||||
}
|
||||
}
|
35
frontend/src/App.vue
Normal file
35
frontend/src/App.vue
Normal file
@ -0,0 +1,35 @@
|
||||
<template>
|
||||
<router-view></router-view>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
#app {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.page-container {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 20px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
color: #2c3e50;
|
||||
}
|
||||
</style>
|
BIN
frontend/src/assets/logo.png
Normal file
BIN
frontend/src/assets/logo.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 14 KiB |
116
frontend/src/components/GameDetail.vue
Normal file
116
frontend/src/components/GameDetail.vue
Normal file
@ -0,0 +1,116 @@
|
||||
<template>
|
||||
<div class="game-detail" v-if="game">
|
||||
<div class="game-header">
|
||||
<button class="back-button" @click="$router.go(-1)">← 返回</button>
|
||||
<h1>{{ game.title }}</h1>
|
||||
</div>
|
||||
|
||||
<div class="game-content">
|
||||
<div class="game-image">
|
||||
<img :src="game.image" :alt="game.title">
|
||||
</div>
|
||||
|
||||
<div class="game-info">
|
||||
<div class="category-tag">{{ game.category?.name || '未分类' }}</div>
|
||||
<p class="description">{{ game.description }}</p>
|
||||
<div class="metadata">
|
||||
<p><strong>发布日期:</strong> {{ formatDate(game.releaseDate) }}</p>
|
||||
<p><strong>开发商:</strong> {{ game.developer }}</p>
|
||||
<p><strong>支持平台:</strong> {{ game.platforms.join(', ') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import axios from 'axios';
|
||||
|
||||
export default {
|
||||
name: 'GameDetail',
|
||||
data() {
|
||||
return {
|
||||
game: null
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async fetchGameDetail() {
|
||||
try {
|
||||
const response = await axios.get(`http://localhost:3000/api/games/${this.$route.params.id}`);
|
||||
this.game = response.data;
|
||||
} catch (error) {
|
||||
console.error('Error fetching game details:', error);
|
||||
}
|
||||
},
|
||||
formatDate(date) {
|
||||
return new Date(date).toLocaleDateString();
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.fetchGameDetail();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.game-detail {
|
||||
padding: 20px;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.game-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.back-button {
|
||||
padding: 8px 16px;
|
||||
margin-right: 20px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background: #42b983;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.game-content {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 30px;
|
||||
}
|
||||
|
||||
.game-image img {
|
||||
width: 100%;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.category-tag {
|
||||
display: inline-block;
|
||||
padding: 4px 12px;
|
||||
background: #42b983;
|
||||
color: white;
|
||||
border-radius: 20px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.description {
|
||||
font-size: 1.1em;
|
||||
line-height: 1.6;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.metadata {
|
||||
background: #f5f5f5;
|
||||
padding: 15px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.game-content {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
229
frontend/src/components/GameList.vue
Normal file
229
frontend/src/components/GameList.vue
Normal file
@ -0,0 +1,229 @@
|
||||
<template>
|
||||
<div class="game-list">
|
||||
<div class="search-bar">
|
||||
<input
|
||||
type="text"
|
||||
v-model="searchQuery"
|
||||
placeholder="搜索游戏..."
|
||||
@input="debounceSearch"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="categories">
|
||||
<button
|
||||
v-for="category in categories"
|
||||
:key="category.id"
|
||||
@click="selectCategory(category.id)"
|
||||
:class="{ active: selectedCategory === category.id }"
|
||||
>
|
||||
{{ category.name }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="games-grid">
|
||||
<div v-for="game in filteredGames"
|
||||
:key="game.id"
|
||||
class="game-card"
|
||||
@click="navigateToGame(game._id)">
|
||||
<div class="game-image">
|
||||
<img :src="game.image" :alt="game.title">
|
||||
<div class="game-rating" v-if="game.rating">
|
||||
{{ game.rating.toFixed(1) }}
|
||||
</div>
|
||||
</div>
|
||||
<h3>{{ game.title }}</h3>
|
||||
<p class="game-description">{{ truncateText(game.description) }}</p>
|
||||
<div class="game-tags">
|
||||
<span v-for="tag in game.tags" :key="tag" class="tag">
|
||||
{{ tag }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="loading">
|
||||
加载中...
|
||||
</div>
|
||||
|
||||
<div v-if="!loading && filteredGames.length === 0" class="no-results">
|
||||
没有找到相关游戏
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import axios from 'axios';
|
||||
import { debounce } from 'lodash';
|
||||
|
||||
export default {
|
||||
name: 'GameList',
|
||||
data() {
|
||||
return {
|
||||
games: [],
|
||||
categories: [],
|
||||
selectedCategory: null,
|
||||
searchQuery: '',
|
||||
loading: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
filteredGames() {
|
||||
if (!this.selectedCategory) return this.games;
|
||||
return this.games.filter(game => game.categoryId === this.selectedCategory);
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async fetchGames() {
|
||||
try {
|
||||
const response = await axios.get('http://localhost:3000/api/games');
|
||||
this.games = response.data;
|
||||
} catch (error) {
|
||||
console.error('Error fetching games:', error);
|
||||
}
|
||||
},
|
||||
async fetchCategories() {
|
||||
try {
|
||||
const response = await axios.get('http://localhost:3000/api/categories');
|
||||
this.categories = response.data;
|
||||
} catch (error) {
|
||||
console.error('Error fetching categories:', error);
|
||||
}
|
||||
},
|
||||
selectCategory(categoryId) {
|
||||
this.selectedCategory = categoryId;
|
||||
},
|
||||
truncateText(text, length = 100) {
|
||||
if (text.length <= length) return text;
|
||||
return text.substring(0, length) + '...';
|
||||
},
|
||||
navigateToGame(gameId) {
|
||||
this.$router.push(`/game/${gameId}`);
|
||||
},
|
||||
debounceSearch: debounce(function() {
|
||||
this.searchGames();
|
||||
}, 300),
|
||||
async searchGames() {
|
||||
this.loading = true;
|
||||
try {
|
||||
const response = await axios.get('http://localhost:3000/api/games/search', {
|
||||
params: {
|
||||
query: this.searchQuery,
|
||||
category: this.selectedCategory
|
||||
}
|
||||
});
|
||||
this.games = response.data;
|
||||
} catch (error) {
|
||||
console.error('Error searching games:', error);
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.fetchGames();
|
||||
this.fetchCategories();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.game-list {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.categories {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.categories button {
|
||||
margin: 0 10px;
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
background: #f0f0f0;
|
||||
}
|
||||
|
||||
.categories button.active {
|
||||
background: #42b983;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.games-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
||||
gap: 20px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.game-card {
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 8px;
|
||||
padding: 15px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.game-card:hover {
|
||||
transform: translateY(-5px);
|
||||
}
|
||||
|
||||
.game-card img {
|
||||
width: 100%;
|
||||
height: 150px;
|
||||
object-fit: cover;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.game-card h3 {
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.search-bar {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.search-bar input {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
padding: 10px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.game-image {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.game-rating {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
color: white;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.game-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.tag {
|
||||
background: #f0f0f0;
|
||||
padding: 4px 8px;
|
||||
border-radius: 12px;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.loading, .no-results {
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
color: #666;
|
||||
}
|
||||
</style>
|
30
frontend/src/main.js
Normal file
30
frontend/src/main.js
Normal file
@ -0,0 +1,30 @@
|
||||
import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import ElementPlus from 'element-plus'
|
||||
import 'element-plus/dist/index.css'
|
||||
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
|
||||
import axios from 'axios'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
// 配置axios
|
||||
axios.defaults.baseURL = process.env.VUE_APP_API_URL
|
||||
|
||||
// 添加请求拦截器
|
||||
axios.interceptors.request.use(config => {
|
||||
const token = localStorage.getItem('admin_token')
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
return config
|
||||
})
|
||||
|
||||
// 注册Element Plus图标
|
||||
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
|
||||
app.component(key, component)
|
||||
}
|
||||
|
||||
app.use(ElementPlus)
|
||||
app.use(router)
|
||||
app.mount('#app')
|
93
frontend/src/router/admin.js
Normal file
93
frontend/src/router/admin.js
Normal file
@ -0,0 +1,93 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router';
|
||||
import AdminLogin from '../views/admin/Login.vue';
|
||||
import AdminLayout from '../views/admin/Layout.vue';
|
||||
import Dashboard from '../views/admin/Dashboard.vue';
|
||||
import GameManagement from '../views/admin/GameManagement.vue';
|
||||
import MediaManagement from '../views/admin/MediaManagement.vue';
|
||||
import CategoryManagement from '../views/admin/CategoryManagement.vue';
|
||||
import UserManagement from '../views/admin/UserManagement.vue';
|
||||
import axios from 'axios';
|
||||
|
||||
const routes = [
|
||||
{
|
||||
path: '/admin/login',
|
||||
name: 'AdminLogin',
|
||||
component: AdminLogin,
|
||||
meta: { requiresAuth: false }
|
||||
},
|
||||
{
|
||||
path: '/admin',
|
||||
component: AdminLayout,
|
||||
meta: { requiresAuth: true },
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
name: 'Dashboard',
|
||||
component: Dashboard
|
||||
},
|
||||
{
|
||||
path: 'games',
|
||||
name: 'GameManagement',
|
||||
component: GameManagement
|
||||
},
|
||||
{
|
||||
path: 'media',
|
||||
name: 'MediaManagement',
|
||||
component: MediaManagement
|
||||
},
|
||||
{
|
||||
path: 'categories',
|
||||
name: 'CategoryManagement',
|
||||
component: CategoryManagement
|
||||
},
|
||||
{
|
||||
path: 'users',
|
||||
name: 'UserManagement',
|
||||
component: UserManagement,
|
||||
meta: { requiresPermission: 'user:manage' }
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes
|
||||
});
|
||||
|
||||
// 路由守卫
|
||||
router.beforeEach(async (to, from, next) => {
|
||||
const token = localStorage.getItem('admin_token');
|
||||
|
||||
if (to.matched.some(record => record.meta.requiresAuth)) {
|
||||
if (!token) {
|
||||
next('/admin/login');
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查权限
|
||||
if (to.meta.requiresPermission) {
|
||||
try {
|
||||
const response = await axios.get('http://localhost:3000/api/admins/me', {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
|
||||
const user = response.data;
|
||||
if (user.role !== 'superadmin' &&
|
||||
!user.permissions.includes(to.meta.requiresPermission)) {
|
||||
next('/admin'); // 重定向到仪表盘
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
next('/admin/login');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
next();
|
||||
} else {
|
||||
next();
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
109
frontend/src/router/index.js
Normal file
109
frontend/src/router/index.js
Normal file
@ -0,0 +1,109 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import AdminLogin from '../views/admin/Login.vue'
|
||||
import AdminLayout from '../views/admin/Layout.vue'
|
||||
import Dashboard from '../views/admin/Dashboard.vue'
|
||||
import GameManagement from '../views/admin/GameManagement.vue'
|
||||
import CategoryManagement from '../views/admin/CategoryManagement.vue'
|
||||
import UserManagement from '../views/admin/UserManagement.vue'
|
||||
|
||||
const routes = [
|
||||
{
|
||||
path: '/',
|
||||
name: 'Home',
|
||||
component: () => import('../views/Home.vue')
|
||||
},
|
||||
{
|
||||
path: '/games',
|
||||
name: 'Games',
|
||||
component: () => import('../views/Games.vue')
|
||||
},
|
||||
{
|
||||
path: '/categories',
|
||||
name: 'Categories',
|
||||
component: () => import('../views/Categories.vue')
|
||||
},
|
||||
{
|
||||
path: '/about',
|
||||
name: 'About',
|
||||
component: () => import('../views/About.vue')
|
||||
},
|
||||
{
|
||||
path: '/admin/login',
|
||||
name: 'AdminLogin',
|
||||
component: AdminLogin,
|
||||
meta: { requiresAuth: false }
|
||||
},
|
||||
{
|
||||
path: '/admin/register',
|
||||
name: 'AdminRegister',
|
||||
component: () => import('../views/admin/Register.vue'),
|
||||
meta: { requiresAuth: false }
|
||||
},
|
||||
{
|
||||
path: '/admin',
|
||||
component: AdminLayout,
|
||||
meta: { requiresAuth: true },
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
name: 'Dashboard',
|
||||
component: Dashboard
|
||||
},
|
||||
{
|
||||
path: 'games',
|
||||
name: 'GameManagement',
|
||||
component: GameManagement
|
||||
},
|
||||
{
|
||||
path: 'categories',
|
||||
name: 'CategoryManagement',
|
||||
component: CategoryManagement
|
||||
},
|
||||
{
|
||||
path: 'users',
|
||||
name: 'UserManagement',
|
||||
component: UserManagement,
|
||||
meta: { requiresPermission: 'user:manage' }
|
||||
},
|
||||
{
|
||||
path: 'messages',
|
||||
name: 'MessageManagement',
|
||||
component: () => import('../views/admin/MessageManagement.vue'),
|
||||
meta: { requiresPermission: 'message:manage' }
|
||||
},
|
||||
{
|
||||
path: 'tags',
|
||||
name: 'TagManagement',
|
||||
component: () => import('../views/admin/TagManagement.vue'),
|
||||
meta: { requiresPermission: 'tag:manage' }
|
||||
},
|
||||
{
|
||||
path: 'settings',
|
||||
name: 'SystemSettings',
|
||||
component: () => import('../views/admin/SystemSettings.vue'),
|
||||
meta: { requiresPermission: 'system:manage' }
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes
|
||||
})
|
||||
|
||||
// 路由守卫
|
||||
router.beforeEach((to, from, next) => {
|
||||
const token = localStorage.getItem('admin_token')
|
||||
if (to.matched.some(record => record.meta.requiresAuth)) {
|
||||
if (!token) {
|
||||
next('/admin/login')
|
||||
} else {
|
||||
next()
|
||||
}
|
||||
} else {
|
||||
next()
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
97
frontend/src/services/api.js
Normal file
97
frontend/src/services/api.js
Normal file
@ -0,0 +1,97 @@
|
||||
import axios from 'axios'
|
||||
|
||||
// 设置基础URL
|
||||
axios.defaults.baseURL = process.env.VUE_APP_API_URL || 'http://localhost:3000'
|
||||
|
||||
// 添加请求拦截器用于调试
|
||||
axios.interceptors.request.use(config => {
|
||||
console.log('API Request:', {
|
||||
url: config.url,
|
||||
method: config.method,
|
||||
data: config.data
|
||||
});
|
||||
return config;
|
||||
});
|
||||
|
||||
// 添加响应拦截器用于调试
|
||||
axios.interceptors.response.use(
|
||||
response => {
|
||||
console.log('API Response:', {
|
||||
url: response.config.url,
|
||||
status: response.status,
|
||||
data: response.data
|
||||
});
|
||||
return response;
|
||||
},
|
||||
error => {
|
||||
console.error('API Error:', {
|
||||
url: error.config?.url,
|
||||
status: error.response?.status,
|
||||
data: error.response?.data
|
||||
});
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
export const login = async (username, password) => {
|
||||
console.log('API: Sending login request');
|
||||
try {
|
||||
const response = await axios.post('/api/admin/login', { username, password })
|
||||
console.log('API: Login response:', response.data);
|
||||
return response.data
|
||||
} catch (error) {
|
||||
console.error('API: Login error:', error);
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export const register = async ({ username, password, email }) => {
|
||||
console.log('API: Sending register request');
|
||||
try {
|
||||
const response = await axios.post('/api/admin/register', {
|
||||
username,
|
||||
password,
|
||||
email
|
||||
})
|
||||
console.log('API: Register response:', response.data);
|
||||
return response.data
|
||||
} catch (error) {
|
||||
console.error('API: Register error:', error);
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export const getGames = async () => {
|
||||
const response = await axios.get('/api/games')
|
||||
return response.data
|
||||
}
|
||||
|
||||
export const getCategories = async () => {
|
||||
const response = await axios.get('/api/categories')
|
||||
return response.data
|
||||
}
|
||||
|
||||
export const getAdmins = async () => {
|
||||
const response = await axios.get('/api/admins')
|
||||
return response.data
|
||||
}
|
||||
|
||||
export const createAdmin = async (data) => {
|
||||
const response = await axios.post('/api/admins', data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
export const updateAdmin = async (id, data) => {
|
||||
const response = await axios.put(`/api/admins/${id}`, data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
export const deleteAdmin = async (id) => {
|
||||
const response = await axios.delete(`/api/admins/${id}`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
export const getStats = async () => {
|
||||
const response = await axios.get('/api/stats')
|
||||
return response.data
|
||||
}
|
400
frontend/src/views/About.vue
Normal file
400
frontend/src/views/About.vue
Normal file
@ -0,0 +1,400 @@
|
||||
<template>
|
||||
<div class="about-page">
|
||||
<!-- 顶部导航 -->
|
||||
<header class="header">
|
||||
<div class="header-content">
|
||||
<nav class="nav-menu">
|
||||
<div class="logo">
|
||||
<router-link to="/">
|
||||
<img src="@/assets/logo.png" alt="Logo" class="logo-image">
|
||||
</router-link>
|
||||
</div>
|
||||
<router-link to="/" class="nav-item">首页</router-link>
|
||||
<router-link to="/games" class="nav-item">游戏</router-link>
|
||||
<router-link to="/categories" class="nav-item">分类</router-link>
|
||||
<router-link to="/about" class="nav-item">关于我们</router-link>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 公司简介部分 -->
|
||||
<section class="company-intro">
|
||||
<div class="container">
|
||||
<div class="intro-content">
|
||||
<div class="intro-text">
|
||||
<h2>公司简介</h2>
|
||||
<p>{{ companyInfo.description }}</p>
|
||||
</div>
|
||||
<div class="intro-image">
|
||||
<el-image src="https://via.placeholder.com/500x300" fit="cover" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 使命愿景部分 -->
|
||||
<section class="mission-vision">
|
||||
<div class="container">
|
||||
<div class="mission-box">
|
||||
<el-icon class="icon"><Aim /></el-icon>
|
||||
<h3>我们的使命</h3>
|
||||
<p>{{ companyInfo.mission }}</p>
|
||||
</div>
|
||||
<div class="vision-box">
|
||||
<el-icon class="icon"><Star /></el-icon>
|
||||
<h3>我们的愿景</h3>
|
||||
<p>{{ companyInfo.vision }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 核心价值观 -->
|
||||
<section class="values">
|
||||
<div class="container">
|
||||
<h2>核心价值观</h2>
|
||||
<div class="values-grid">
|
||||
<div v-for="(value, key) in companyValues"
|
||||
:key="key"
|
||||
class="value-card">
|
||||
<el-icon class="icon" v-if="valueIcons[key]">
|
||||
<component :is="valueIcons[key]" />
|
||||
</el-icon>
|
||||
<h3>{{ value.title }}</h3>
|
||||
<p>{{ value.description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 联系我们 -->
|
||||
<section class="contact">
|
||||
<div class="container">
|
||||
<h2>联系我们</h2>
|
||||
<div class="contact-grid">
|
||||
<div class="contact-info">
|
||||
<div class="info-item">
|
||||
<el-icon><Location /></el-icon>
|
||||
<span>地址:{{ contactInfo.address }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<el-icon><Message /></el-icon>
|
||||
<span>邮箱:{{ contactInfo.email }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<el-icon><Phone /></el-icon>
|
||||
<span>电话:{{ contactInfo.phone }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="contact-form">
|
||||
<el-form :model="form" label-width="80px">
|
||||
<el-form-item label="姓名">
|
||||
<el-input v-model="form.name" placeholder="请输入您的姓名" />
|
||||
</el-form-item>
|
||||
<el-form-item label="邮箱">
|
||||
<el-input v-model="form.email" placeholder="请输入您的邮箱" />
|
||||
</el-form-item>
|
||||
<el-form-item label="留言">
|
||||
<el-input v-model="form.message" type="textarea" rows="4" placeholder="请输入您的留言" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="submitForm">提交</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import axios from 'axios'
|
||||
import {
|
||||
Aim,
|
||||
Star,
|
||||
UserFilled,
|
||||
Connection,
|
||||
CircleCheck,
|
||||
Share,
|
||||
Location,
|
||||
Message,
|
||||
Phone
|
||||
} from '@element-plus/icons-vue'
|
||||
|
||||
export default {
|
||||
name: 'About',
|
||||
components: {
|
||||
Aim,
|
||||
Star,
|
||||
UserFilled,
|
||||
Connection,
|
||||
CircleCheck,
|
||||
Share,
|
||||
Location,
|
||||
Message,
|
||||
Phone
|
||||
},
|
||||
setup() {
|
||||
const companyInfo = ref({
|
||||
name: '',
|
||||
description: '',
|
||||
mission: '',
|
||||
vision: ''
|
||||
})
|
||||
|
||||
const contactInfo = ref({
|
||||
address: '',
|
||||
email: '',
|
||||
phone: ''
|
||||
})
|
||||
|
||||
const companyValues = ref({})
|
||||
|
||||
const valueIcons = {
|
||||
value1: 'UserFilled',
|
||||
value2: 'Connection',
|
||||
value3: 'CircleCheck',
|
||||
value4: 'Share'
|
||||
}
|
||||
|
||||
const loadCompanyInfo = async () => {
|
||||
try {
|
||||
const response = await axios.get('/api/settings')
|
||||
|
||||
// 设置公司信息
|
||||
companyInfo.value = {
|
||||
name: response.data.company.name.value,
|
||||
description: response.data.company.description.value,
|
||||
mission: response.data.company.mission.value,
|
||||
vision: response.data.company.vision.value
|
||||
}
|
||||
|
||||
// 设置联系信息
|
||||
contactInfo.value = {
|
||||
address: response.data.contact.address.value,
|
||||
email: response.data.contact.email.value,
|
||||
phone: response.data.contact.phone.value
|
||||
}
|
||||
|
||||
// 设置核心价值观
|
||||
for (const key in response.data.values) {
|
||||
try {
|
||||
companyValues.value[key] = JSON.parse(response.data.values[key].value)
|
||||
} catch (e) {
|
||||
console.error(`Error parsing value for ${key}:`, e)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading company info:', error)
|
||||
ElMessage.error('加载公司信息失败')
|
||||
}
|
||||
}
|
||||
|
||||
const form = ref({
|
||||
name: '',
|
||||
email: '',
|
||||
message: ''
|
||||
})
|
||||
|
||||
const submitForm = async () => {
|
||||
if (!form.value.name || !form.value.email || !form.value.message) {
|
||||
ElMessage.warning('请填写完整信息');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await axios.post('/api/messages', {
|
||||
name: form.value.name,
|
||||
email: form.value.email,
|
||||
message: form.value.message
|
||||
});
|
||||
|
||||
ElMessage.success('留言已提交,我们会尽快回复您!');
|
||||
form.value = {
|
||||
name: '',
|
||||
email: '',
|
||||
message: ''
|
||||
};
|
||||
} catch (error) {
|
||||
ElMessage.error('留言提交失败,请稍后重试');
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadCompanyInfo)
|
||||
|
||||
return {
|
||||
form,
|
||||
submitForm,
|
||||
companyInfo,
|
||||
contactInfo,
|
||||
companyValues,
|
||||
valueIcons
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.about-page {
|
||||
min-height: 100vh;
|
||||
padding-top: 100px;
|
||||
}
|
||||
|
||||
.header {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: white;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 1rem;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.nav-menu {
|
||||
display: flex;
|
||||
gap: 2rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.logo {
|
||||
margin: 0 2rem 0 0;
|
||||
}
|
||||
|
||||
.logo-image {
|
||||
height: 40px;
|
||||
width: auto;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
color: #333;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
transition: color 0.3s;
|
||||
padding: 0.5rem 1rem;
|
||||
}
|
||||
|
||||
.nav-item:hover {
|
||||
color: #409EFF;
|
||||
background-color: rgba(64, 158, 255, 0.1);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
section {
|
||||
margin-bottom: 4rem;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 2rem;
|
||||
color: #2c3e50;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.intro-content {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 2rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.intro-text p {
|
||||
font-size: 1.1rem;
|
||||
line-height: 1.6;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.mission-vision .container {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.mission-box, .vision-box {
|
||||
padding: 2rem;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.icon {
|
||||
font-size: 3rem;
|
||||
color: #409EFF;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.values-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.value-card {
|
||||
padding: 2rem;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.value-card h3 {
|
||||
margin: 1rem 0;
|
||||
color: #2c3e50;
|
||||
}
|
||||
|
||||
.contact-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 2fr;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.contact-info {
|
||||
padding: 2rem;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.info-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.info-item .el-icon {
|
||||
color: #409EFF;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.contact-form {
|
||||
padding: 2rem;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.intro-content,
|
||||
.mission-vision .container,
|
||||
.contact-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
231
frontend/src/views/Categories.vue
Normal file
231
frontend/src/views/Categories.vue
Normal file
@ -0,0 +1,231 @@
|
||||
<template>
|
||||
<div class="categories-page">
|
||||
<!-- 顶部导航复用 -->
|
||||
<header class="header">
|
||||
<div class="header-content">
|
||||
<nav class="nav-menu">
|
||||
<div class="logo">
|
||||
<router-link to="/">
|
||||
<img src="@/assets/logo.png" alt="Logo" class="logo-image">
|
||||
</router-link>
|
||||
</div>
|
||||
<router-link to="/" class="nav-item">首页</router-link>
|
||||
<router-link to="/games" class="nav-item">游戏</router-link>
|
||||
<router-link to="/categories" class="nav-item">分类</router-link>
|
||||
<router-link to="/about" class="nav-item">关于我们</router-link>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="page-content">
|
||||
<h2>游戏分类</h2>
|
||||
|
||||
<div class="categories-grid">
|
||||
<div v-for="category in categories"
|
||||
:key="category.id"
|
||||
class="category-card"
|
||||
@click="viewCategoryGames(category.id)">
|
||||
<div class="category-icon">
|
||||
<el-icon><component :is="getRandomIcon()" /></el-icon>
|
||||
</div>
|
||||
<div class="category-info">
|
||||
<h3>{{ category.name }}</h3>
|
||||
<p>{{ category.description }}</p>
|
||||
<div class="game-count">
|
||||
{{ getGameCount(category.id) }} 个游戏
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { getCategories, getGames } from '@/services/api'
|
||||
import axios from 'axios'
|
||||
import {
|
||||
GamepadSquare,
|
||||
Collection,
|
||||
Trophy,
|
||||
Magic,
|
||||
Sunny
|
||||
} from '@element-plus/icons-vue'
|
||||
|
||||
export default {
|
||||
name: 'Categories',
|
||||
components: {
|
||||
GamepadSquare,
|
||||
Collection,
|
||||
Trophy,
|
||||
Magic,
|
||||
Sunny
|
||||
},
|
||||
setup() {
|
||||
const router = useRouter()
|
||||
const categories = ref([])
|
||||
const games = ref([])
|
||||
const companyInfo = ref({ name: 'Game Categories' })
|
||||
|
||||
const icons = [
|
||||
'GamepadSquare',
|
||||
'Collection',
|
||||
'Trophy',
|
||||
'Magic',
|
||||
'Sunny'
|
||||
]
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
const [categoriesData, gamesData, settingsData] = await Promise.all([
|
||||
getCategories(),
|
||||
getGames(),
|
||||
axios.get('/api/settings')
|
||||
])
|
||||
|
||||
categories.value = categoriesData
|
||||
games.value = gamesData
|
||||
companyInfo.value.name = settingsData.data.company.name.value
|
||||
} catch (error) {
|
||||
console.error('Error loading data:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const getGameCount = (categoryId) => {
|
||||
return games.value.filter(game => game.category_id === categoryId).length
|
||||
}
|
||||
|
||||
const getRandomIcon = () => {
|
||||
return icons[Math.floor(Math.random() * icons.length)]
|
||||
}
|
||||
|
||||
const viewCategoryGames = (categoryId) => {
|
||||
router.push({
|
||||
path: '/games',
|
||||
query: { category: categoryId }
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(loadData)
|
||||
|
||||
return {
|
||||
categories,
|
||||
companyInfo,
|
||||
getGameCount,
|
||||
getRandomIcon,
|
||||
viewCategoryGames
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.categories-page {
|
||||
min-height: 100vh;
|
||||
padding-top: 80px;
|
||||
}
|
||||
|
||||
.header {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: white;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.page-content {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
text-align: center;
|
||||
color: #2c3e50;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.categories-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 30px;
|
||||
}
|
||||
|
||||
.category-card {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 30px;
|
||||
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
|
||||
transition: transform 0.3s;
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.category-card:hover {
|
||||
transform: translateY(-5px);
|
||||
}
|
||||
|
||||
.category-icon {
|
||||
font-size: 3rem;
|
||||
color: #409EFF;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.category-info h3 {
|
||||
margin: 0 0 10px;
|
||||
color: #2c3e50;
|
||||
}
|
||||
|
||||
.category-info p {
|
||||
color: #666;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.game-count {
|
||||
color: #409EFF;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 复用导航样式 */
|
||||
.header-content {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 1rem;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.nav-menu {
|
||||
display: flex;
|
||||
gap: 2rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.logo {
|
||||
margin: 0 2rem 0 0;
|
||||
}
|
||||
|
||||
.logo-image {
|
||||
height: 40px;
|
||||
width: auto;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
color: #333;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
transition: color 0.3s;
|
||||
padding: 0.5rem 1rem;
|
||||
}
|
||||
|
||||
.nav-item:hover {
|
||||
color: #409EFF;
|
||||
background-color: rgba(64, 158, 255, 0.1);
|
||||
border-radius: 4px;
|
||||
}
|
||||
</style>
|
286
frontend/src/views/Games.vue
Normal file
286
frontend/src/views/Games.vue
Normal file
@ -0,0 +1,286 @@
|
||||
<template>
|
||||
<div class="games-page">
|
||||
<!-- 顶部导航复用 -->
|
||||
<header class="header">
|
||||
<div class="header-content">
|
||||
<nav class="nav-menu">
|
||||
<div class="logo">
|
||||
<router-link to="/">
|
||||
<img src="@/assets/logo.png" alt="Logo" class="logo-image">
|
||||
</router-link>
|
||||
</div>
|
||||
<router-link to="/" class="nav-item">首页</router-link>
|
||||
<router-link to="/games" class="nav-item">游戏</router-link>
|
||||
<router-link to="/categories" class="nav-item">分类</router-link>
|
||||
<router-link to="/about" class="nav-item">关于我们</router-link>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="page-content">
|
||||
<div class="filters">
|
||||
<el-select v-model="selectedCategory" placeholder="选择分类" clearable>
|
||||
<el-option label="全部分类" value="" />
|
||||
<el-option
|
||||
v-for="category in categories"
|
||||
:key="category.id"
|
||||
:label="category.name"
|
||||
:value="category.id"
|
||||
/>
|
||||
</el-select>
|
||||
|
||||
<el-input
|
||||
v-model="searchQuery"
|
||||
placeholder="搜索游戏..."
|
||||
prefix-icon="Search"
|
||||
clearable
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="games-grid">
|
||||
<div v-for="game in filteredGames"
|
||||
:key="game.id"
|
||||
class="game-card"
|
||||
@click="viewGameDetail(game.id)">
|
||||
<div class="game-image">
|
||||
<img :src="getImageUrl(game.image)" :alt="game.title">
|
||||
</div>
|
||||
<div class="game-info">
|
||||
<h3>{{ game.title }}</h3>
|
||||
<p class="category">{{ getCategoryName(game.category_id) }}</p>
|
||||
<p class="description">{{ game.description }}</p>
|
||||
<div class="game-tags">
|
||||
<span v-for="tag in game.tags" :key="tag" class="tag">{{ tag }}</span>
|
||||
</div>
|
||||
<div class="game-meta">
|
||||
<span class="developer">{{ game.developer }}</span>
|
||||
<span class="release-date">{{ formatDate(game.release_date) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { getGames, getCategories } from '@/services/api'
|
||||
import axios from 'axios'
|
||||
|
||||
export default {
|
||||
name: 'Games',
|
||||
setup() {
|
||||
const router = useRouter()
|
||||
const games = ref([])
|
||||
const categories = ref([])
|
||||
const selectedCategory = ref('')
|
||||
const searchQuery = ref('')
|
||||
const companyInfo = ref({ name: 'Game Categories' })
|
||||
|
||||
const filteredGames = computed(() => {
|
||||
return games.value.filter(game => {
|
||||
const categoryMatch = !selectedCategory.value || game.category_id === selectedCategory.value
|
||||
const searchMatch = !searchQuery.value ||
|
||||
game.title.toLowerCase().includes(searchQuery.value.toLowerCase()) ||
|
||||
game.description.toLowerCase().includes(searchQuery.value.toLowerCase())
|
||||
return categoryMatch && searchMatch
|
||||
})
|
||||
})
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
const [gamesData, categoriesData, settingsData] = await Promise.all([
|
||||
getGames(),
|
||||
getCategories(),
|
||||
axios.get('/api/settings')
|
||||
])
|
||||
|
||||
games.value = gamesData
|
||||
categories.value = categoriesData
|
||||
companyInfo.value.name = settingsData.data.company.name.value
|
||||
} catch (error) {
|
||||
console.error('Error loading data:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const getCategoryName = (categoryId) => {
|
||||
const category = categories.value.find(c => c.id === categoryId)
|
||||
return category ? category.name : '未分类'
|
||||
}
|
||||
|
||||
const formatDate = (date) => {
|
||||
return new Date(date).toLocaleDateString()
|
||||
}
|
||||
|
||||
const viewGameDetail = (gameId) => {
|
||||
router.push(`/games/${gameId}`)
|
||||
}
|
||||
|
||||
const getImageUrl = (imagePath) => {
|
||||
if (!imagePath) {
|
||||
return '/placeholder.jpg';
|
||||
}
|
||||
return `http://localhost:3000/${imagePath}`;
|
||||
};
|
||||
|
||||
onMounted(loadData)
|
||||
|
||||
return {
|
||||
games,
|
||||
categories,
|
||||
selectedCategory,
|
||||
searchQuery,
|
||||
filteredGames,
|
||||
companyInfo,
|
||||
getCategoryName,
|
||||
formatDate,
|
||||
viewGameDetail,
|
||||
getImageUrl
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.games-page {
|
||||
min-height: 100vh;
|
||||
padding-top: 80px;
|
||||
}
|
||||
|
||||
.header {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: white;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.page-content {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.filters {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.games-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 30px;
|
||||
}
|
||||
|
||||
.game-card {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
|
||||
transition: transform 0.3s;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.game-card:hover {
|
||||
transform: translateY(-5px);
|
||||
}
|
||||
|
||||
.game-image img {
|
||||
width: 100%;
|
||||
height: 200px;
|
||||
object-fit: cover;
|
||||
border-radius: 8px 8px 0 0;
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
.game-info {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.game-info h3 {
|
||||
margin: 0 0 10px;
|
||||
font-size: 1.2rem;
|
||||
color: #2c3e50;
|
||||
}
|
||||
|
||||
.category {
|
||||
color: #409EFF;
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.description {
|
||||
color: #666;
|
||||
margin-bottom: 15px;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.game-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.tag {
|
||||
background: #f0f2f5;
|
||||
padding: 4px 12px;
|
||||
border-radius: 20px;
|
||||
font-size: 0.8rem;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.game-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 0.9rem;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
/* 复用Home.vue的导航样式 */
|
||||
.header-content {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 1rem;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.nav-menu {
|
||||
display: flex;
|
||||
gap: 2rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.logo {
|
||||
margin: 0 2rem 0 0;
|
||||
}
|
||||
|
||||
.logo-image {
|
||||
height: 40px;
|
||||
width: auto;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
color: #333;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
transition: color 0.3s;
|
||||
padding: 0.5rem 1rem;
|
||||
}
|
||||
|
||||
.nav-item:hover {
|
||||
color: #409EFF;
|
||||
background-color: rgba(64, 158, 255, 0.1);
|
||||
border-radius: 4px;
|
||||
}
|
||||
</style>
|
418
frontend/src/views/Home.vue
Normal file
418
frontend/src/views/Home.vue
Normal file
@ -0,0 +1,418 @@
|
||||
<template>
|
||||
<div class="home">
|
||||
<!-- 顶部导航 -->
|
||||
<header class="header">
|
||||
<div class="header-content">
|
||||
<nav class="nav-menu">
|
||||
<div class="logo">
|
||||
<router-link to="/">
|
||||
<img src="@/assets/logo.png" alt="Logo" class="logo-image">
|
||||
</router-link>
|
||||
</div>
|
||||
<router-link to="/" class="nav-item">首页</router-link>
|
||||
<router-link to="/games" class="nav-item">游戏</router-link>
|
||||
<router-link to="/categories" class="nav-item">分类</router-link>
|
||||
<router-link to="/about" class="nav-item">关于我们</router-link>
|
||||
<!-- <router-link to="/admin" class="nav-item admin-link">管理后台</router-link> -->
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 主横幅 -->
|
||||
<section class="hero">
|
||||
<div class="hero-content">
|
||||
<h1>发现精彩游戏世界</h1>
|
||||
<p>探索、分享、创造属于你的游戏体验</p>
|
||||
<router-link to="/games" class="cta-button">立即探索</router-link>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 特色游戏 -->
|
||||
<section class="featured-games">
|
||||
<h2>热门游戏</h2>
|
||||
<div class="game-grid">
|
||||
<div v-for="game in featuredGames" :key="game.id" class="game-card">
|
||||
<div class="game-image">
|
||||
<img :src="getImageUrl(game.image)" :alt="game.title">
|
||||
</div>
|
||||
<div class="game-info">
|
||||
<h3>{{ game.title }}</h3>
|
||||
<p>{{ game.description }}</p>
|
||||
<div class="game-tags">
|
||||
<span v-for="tag in game.tags" :key="tag" class="tag">{{ tag }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 游戏分类 -->
|
||||
<section class="categories">
|
||||
<h2>游戏分类</h2>
|
||||
<div class="category-grid">
|
||||
<div v-for="category in categories" :key="category.id" class="category-card">
|
||||
<div class="category-icon">
|
||||
<el-icon><component :is="category.icon" /></el-icon>
|
||||
</div>
|
||||
<h3>{{ category.name }}</h3>
|
||||
<p>{{ category.description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 底部信息 -->
|
||||
<footer class="footer">
|
||||
<div class="footer-content">
|
||||
<div class="footer-section">
|
||||
<h4>关于我们</h4>
|
||||
<p>{{ companyInfo.description }}</p>
|
||||
</div>
|
||||
<div class="footer-section">
|
||||
<h4>联系方式</h4>
|
||||
<p>邮箱:{{ contactInfo.email }}</p>
|
||||
<p>电话:{{ contactInfo.phone }}</p>
|
||||
<p>地址:{{ contactInfo.address }}</p>
|
||||
</div>
|
||||
<div class="footer-section">
|
||||
<h4>关注我们</h4>
|
||||
<div class="social-links">
|
||||
<a href="#" class="social-link"><el-icon><Message /></el-icon></a>
|
||||
<a href="#" class="social-link"><el-icon><ChatDotRound /></el-icon></a>
|
||||
<a href="#" class="social-link"><el-icon><Share /></el-icon></a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer-bottom">
|
||||
<p>© 2024 {{ companyInfo.name }}. All rights reserved.</p>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { getGames, getCategories } from '@/services/api'
|
||||
import axios from 'axios'
|
||||
import {
|
||||
Message,
|
||||
ChatDotRound,
|
||||
Share,
|
||||
GamepadSquare,
|
||||
Collection,
|
||||
Trophy,
|
||||
Magic,
|
||||
Sunny
|
||||
} from '@element-plus/icons-vue'
|
||||
|
||||
export default {
|
||||
name: 'Home',
|
||||
components: {
|
||||
Message,
|
||||
ChatDotRound,
|
||||
Share
|
||||
},
|
||||
setup() {
|
||||
const featuredGames = ref([])
|
||||
const categories = ref([])
|
||||
const companyInfo = ref({
|
||||
name: '',
|
||||
description: '',
|
||||
mission: '',
|
||||
vision: ''
|
||||
})
|
||||
|
||||
const contactInfo = ref({
|
||||
address: '',
|
||||
email: '',
|
||||
phone: ''
|
||||
})
|
||||
|
||||
const getImageUrl = (imagePath) => {
|
||||
if (!imagePath) {
|
||||
return '/placeholder.jpg';
|
||||
}
|
||||
return `http://localhost:3000/${imagePath}`;
|
||||
};
|
||||
|
||||
const loadCompanyInfo = async () => {
|
||||
try {
|
||||
const response = await axios.get('/api/settings')
|
||||
|
||||
// 设置公司信息
|
||||
companyInfo.value = {
|
||||
name: response.data.company.name.value,
|
||||
description: response.data.company.description.value,
|
||||
mission: response.data.company.mission.value,
|
||||
vision: response.data.company.vision.value
|
||||
}
|
||||
|
||||
// 设置联系信息
|
||||
contactInfo.value = {
|
||||
address: response.data.contact.address.value,
|
||||
email: response.data.contact.email.value,
|
||||
phone: response.data.contact.phone.value
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading company info:', error)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const gamesData = await getGames()
|
||||
featuredGames.value = gamesData.slice(0, 6)
|
||||
|
||||
const categoriesData = await getCategories()
|
||||
categories.value = categoriesData.map(cat => ({
|
||||
...cat,
|
||||
icon: [GamepadSquare, Collection, Trophy, Magic, Sunny][Math.floor(Math.random() * 5)]
|
||||
}))
|
||||
|
||||
await loadCompanyInfo()
|
||||
} catch (error) {
|
||||
console.error('Failed to load data:', error)
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
featuredGames,
|
||||
categories,
|
||||
companyInfo,
|
||||
contactInfo,
|
||||
getImageUrl
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.home {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.header {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: white;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 1rem;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.logo {
|
||||
margin: 0 2rem 0 0;
|
||||
}
|
||||
|
||||
.logo-image {
|
||||
height: 40px;
|
||||
width: auto;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.nav-menu {
|
||||
display: flex;
|
||||
gap: 2rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
color: #333;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
transition: color 0.3s;
|
||||
padding: 0.5rem 1rem;
|
||||
}
|
||||
|
||||
.nav-item:hover {
|
||||
color: #409EFF;
|
||||
background-color: rgba(64, 158, 255, 0.1);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.admin-link {
|
||||
color: #409EFF;
|
||||
font-weight: bold;
|
||||
border: 1px solid #409EFF;
|
||||
border-radius: 4px;
|
||||
padding: 0.5rem 1rem;
|
||||
}
|
||||
|
||||
.admin-link:hover {
|
||||
color: #1c92d2;
|
||||
background-color: rgba(64, 158, 255, 0.1);
|
||||
border-color: #1c92d2;
|
||||
}
|
||||
|
||||
.hero {
|
||||
height: 100vh;
|
||||
background: linear-gradient(135deg, #1c92d2, #f2fcfe);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.hero-content h1 {
|
||||
font-size: 3rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.hero-content p {
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.cta-button {
|
||||
display: inline-block;
|
||||
padding: 1rem 2rem;
|
||||
background: white;
|
||||
color: #409EFF;
|
||||
text-decoration: none;
|
||||
border-radius: 4px;
|
||||
font-weight: bold;
|
||||
transition: transform 0.3s;
|
||||
}
|
||||
|
||||
.cta-button:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.featured-games, .categories {
|
||||
padding: 4rem 2rem;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
h2 {
|
||||
text-align: center;
|
||||
font-size: 2rem;
|
||||
margin-bottom: 2rem;
|
||||
color: #2c3e50;
|
||||
}
|
||||
|
||||
.game-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.game-card {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
|
||||
transition: transform 0.3s;
|
||||
}
|
||||
|
||||
.game-card:hover {
|
||||
transform: translateY(-5px);
|
||||
}
|
||||
|
||||
.game-image img {
|
||||
width: 100%;
|
||||
height: 200px;
|
||||
object-fit: cover;
|
||||
border-radius: 8px 8px 0 0;
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
.game-info {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.game-info h3 {
|
||||
margin: 0 0 1rem;
|
||||
color: #2c3e50;
|
||||
}
|
||||
|
||||
.game-tags {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.tag {
|
||||
padding: 0.25rem 0.75rem;
|
||||
background: #f0f2f5;
|
||||
border-radius: 20px;
|
||||
font-size: 0.875rem;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.category-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.category-card {
|
||||
padding: 2rem;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
text-align: center;
|
||||
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
|
||||
transition: transform 0.3s;
|
||||
}
|
||||
|
||||
.category-card:hover {
|
||||
transform: translateY(-5px);
|
||||
}
|
||||
|
||||
.category-icon {
|
||||
font-size: 2.5rem;
|
||||
color: #409EFF;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.footer {
|
||||
background: #2c3e50;
|
||||
color: white;
|
||||
padding: 4rem 2rem 2rem;
|
||||
}
|
||||
|
||||
.footer-content {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.footer-section h4 {
|
||||
margin: 0 0 1rem;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.social-links {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.social-link {
|
||||
color: white;
|
||||
font-size: 1.5rem;
|
||||
transition: color 0.3s;
|
||||
}
|
||||
|
||||
.social-link:hover {
|
||||
color: #409EFF;
|
||||
}
|
||||
|
||||
.footer-bottom {
|
||||
text-align: center;
|
||||
margin-top: 2rem;
|
||||
padding-top: 2rem;
|
||||
border-top: 1px solid rgba(255,255,255,0.1);
|
||||
}
|
||||
</style>
|
282
frontend/src/views/admin/CategoryManagement.vue
Normal file
282
frontend/src/views/admin/CategoryManagement.vue
Normal file
@ -0,0 +1,282 @@
|
||||
<template>
|
||||
<div class="category-management">
|
||||
<div class="page-header">
|
||||
<h2>分类管理</h2>
|
||||
<button class="add-btn" @click="showCategoryModal = true">
|
||||
添加分类
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="categories-grid">
|
||||
<div v-for="category in categories"
|
||||
:key="category._id"
|
||||
class="category-card">
|
||||
<div class="category-info">
|
||||
<h3>{{ category.name }}</h3>
|
||||
<p>{{ category.description }}</p>
|
||||
<p class="game-count">
|
||||
游戏数量: {{ getGameCount(category._id) }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="category-actions">
|
||||
<button class="edit-btn" @click="editCategory(category)">编辑</button>
|
||||
<button class="delete-btn"
|
||||
@click="deleteCategory(category._id)"
|
||||
:disabled="getGameCount(category._id) > 0">
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 分类表单模态框 -->
|
||||
<div v-if="showCategoryModal" class="modal">
|
||||
<div class="modal-content">
|
||||
<h3>{{ editingCategory ? '编辑分类' : '添加分类' }}</h3>
|
||||
<form @submit.prevent="submitCategory">
|
||||
<div class="form-group">
|
||||
<label>名称</label>
|
||||
<input type="text" v-model="categoryForm.name" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>描述</label>
|
||||
<textarea v-model="categoryForm.description" rows="4"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="modal-actions">
|
||||
<button type="button" @click="showCategoryModal = false">取消</button>
|
||||
<button type="submit" class="submit-btn">
|
||||
{{ editingCategory ? '保存' : '添加' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import axios from 'axios';
|
||||
|
||||
export default {
|
||||
name: 'CategoryManagement',
|
||||
data() {
|
||||
return {
|
||||
categories: [],
|
||||
games: [],
|
||||
showCategoryModal: false,
|
||||
editingCategory: null,
|
||||
categoryForm: {
|
||||
name: '',
|
||||
description: ''
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async fetchCategories() {
|
||||
try {
|
||||
const response = await axios.get('http://localhost:3000/api/categories');
|
||||
this.categories = response.data;
|
||||
} catch (error) {
|
||||
console.error('Error fetching categories:', error);
|
||||
}
|
||||
},
|
||||
async fetchGames() {
|
||||
try {
|
||||
const response = await axios.get('http://localhost:3000/api/games');
|
||||
this.games = response.data;
|
||||
} catch (error) {
|
||||
console.error('Error fetching games:', error);
|
||||
}
|
||||
},
|
||||
getGameCount(categoryId) {
|
||||
return this.games.filter(game => game.categoryId === categoryId).length;
|
||||
},
|
||||
editCategory(category) {
|
||||
this.editingCategory = category;
|
||||
this.categoryForm = { ...category };
|
||||
this.showCategoryModal = true;
|
||||
},
|
||||
async deleteCategory(id) {
|
||||
if (this.getGameCount(id) > 0) {
|
||||
alert('该分类下还有游戏,无法删除');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!confirm('确定要删除这个分类吗?')) return;
|
||||
|
||||
try {
|
||||
await axios.delete(`http://localhost:3000/api/categories/${id}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem('admin_token')}`
|
||||
}
|
||||
});
|
||||
this.fetchCategories();
|
||||
} catch (error) {
|
||||
console.error('Error deleting category:', error);
|
||||
}
|
||||
},
|
||||
async submitCategory() {
|
||||
try {
|
||||
const data = { ...this.categoryForm };
|
||||
if (this.editingCategory) {
|
||||
await axios.put(
|
||||
`http://localhost:3000/api/categories/${this.editingCategory._id}`,
|
||||
data,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem('admin_token')}`
|
||||
}
|
||||
}
|
||||
);
|
||||
} else {
|
||||
await axios.post('http://localhost:3000/api/categories', data, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem('admin_token')}`
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
this.showCategoryModal = false;
|
||||
this.fetchCategories();
|
||||
} catch (error) {
|
||||
console.error('Error saving category:', error);
|
||||
}
|
||||
},
|
||||
resetForm() {
|
||||
this.categoryForm = {
|
||||
name: '',
|
||||
description: ''
|
||||
};
|
||||
this.editingCategory = null;
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.fetchCategories();
|
||||
this.fetchGames();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.category-management {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.categories-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.category-card {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.category-info h3 {
|
||||
margin: 0 0 10px 0;
|
||||
}
|
||||
|
||||
.category-info p {
|
||||
color: #666;
|
||||
margin: 0 0 15px 0;
|
||||
}
|
||||
|
||||
.game-count {
|
||||
font-size: 0.9em;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.category-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
button {
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.add-btn {
|
||||
background: #42b983;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.edit-btn {
|
||||
background: #3498db;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.delete-btn {
|
||||
background: #e74c3c;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.modal {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0,0,0,0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: white;
|
||||
padding: 30px;
|
||||
border-radius: 8px;
|
||||
width: 90%;
|
||||
max-width: 500px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
input, textarea {
|
||||
width: 100%;
|
||||
padding: 8px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.submit-btn {
|
||||
background: #42b983;
|
||||
color: white;
|
||||
}
|
||||
</style>
|
262
frontend/src/views/admin/Dashboard.vue
Normal file
262
frontend/src/views/admin/Dashboard.vue
Normal file
@ -0,0 +1,262 @@
|
||||
<template>
|
||||
<div class="dashboard">
|
||||
<!-- 统计卡片 -->
|
||||
<div class="stat-cards">
|
||||
<el-card class="stat-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<el-icon><GamepadSquare /></el-icon>
|
||||
<span>游戏总数</span>
|
||||
</div>
|
||||
</template>
|
||||
<div class="stat-value">{{ stats.gamesCount }}</div>
|
||||
</el-card>
|
||||
|
||||
<el-card class="stat-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<el-icon><Collection /></el-icon>
|
||||
<span>分类总数</span>
|
||||
</div>
|
||||
</template>
|
||||
<div class="stat-value">{{ stats.categoriesCount }}</div>
|
||||
</el-card>
|
||||
|
||||
<el-card class="stat-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<el-icon><Message /></el-icon>
|
||||
<span>未读留言</span>
|
||||
</div>
|
||||
</template>
|
||||
<div class="stat-value">{{ stats.unreadMessages }}</div>
|
||||
</el-card>
|
||||
|
||||
<el-card class="stat-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<el-icon><User /></el-icon>
|
||||
<span>管理员数量</span>
|
||||
</div>
|
||||
</template>
|
||||
<div class="stat-value">{{ stats.adminCount }}</div>
|
||||
</el-card>
|
||||
</div>
|
||||
|
||||
<!-- 最近添加的游戏 -->
|
||||
<el-card class="recent-games">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>最近添加的游戏</span>
|
||||
<el-button type="primary" size="small" @click="$router.push('/admin/games')">
|
||||
查看全部
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<el-table :data="recentGames" style="width: 100%">
|
||||
<el-table-column prop="title" label="游戏名称" />
|
||||
<el-table-column prop="category_name" label="分类" width="120" />
|
||||
<el-table-column prop="created_at" label="添加时间" width="180">
|
||||
<template #default="scope">
|
||||
{{ formatDate(scope.row.created_at) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
|
||||
<!-- 系统信息 -->
|
||||
<div class="system-info">
|
||||
<el-card class="quick-actions">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>快捷操作</span>
|
||||
</div>
|
||||
</template>
|
||||
<div class="action-buttons">
|
||||
<el-button type="primary" @click="$router.push('/admin/games')">
|
||||
<el-icon><Plus /></el-icon>添加游戏
|
||||
</el-button>
|
||||
<el-button type="success" @click="$router.push('/admin/categories')">
|
||||
<el-icon><FolderAdd /></el-icon>添加分类
|
||||
</el-button>
|
||||
<el-button type="warning" @click="$router.push('/admin/messages')">
|
||||
<el-icon><Message /></el-icon>查看留言
|
||||
</el-button>
|
||||
<el-button type="info" @click="$router.push('/admin/settings')">
|
||||
<el-icon><Setting /></el-icon>系统设置
|
||||
</el-button>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-card class="system-status">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>系统状态</span>
|
||||
</div>
|
||||
</template>
|
||||
<div class="status-list">
|
||||
<div class="status-item">
|
||||
<span class="label">系统版本:</span>
|
||||
<span class="value">v1.0.0</span>
|
||||
</div>
|
||||
<div class="status-item">
|
||||
<span class="label">最后更新:</span>
|
||||
<span class="value">{{ formatDate(new Date()) }}</span>
|
||||
</div>
|
||||
<div class="status-item">
|
||||
<span class="label">运行状态:</span>
|
||||
<el-tag type="success">正常</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import axios from 'axios'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import {
|
||||
GamepadSquare,
|
||||
Collection,
|
||||
Message,
|
||||
User,
|
||||
Plus,
|
||||
FolderAdd,
|
||||
Setting
|
||||
} from '@element-plus/icons-vue'
|
||||
|
||||
export default {
|
||||
name: 'Dashboard',
|
||||
components: {
|
||||
GamepadSquare,
|
||||
Collection,
|
||||
Message,
|
||||
User,
|
||||
Plus,
|
||||
FolderAdd,
|
||||
Setting
|
||||
},
|
||||
setup() {
|
||||
const stats = ref({
|
||||
gamesCount: 0,
|
||||
categoriesCount: 0,
|
||||
unreadMessages: 0,
|
||||
adminCount: 0
|
||||
})
|
||||
const recentGames = ref([])
|
||||
|
||||
const loadDashboardData = async () => {
|
||||
try {
|
||||
// 获取统计数据
|
||||
const [gamesRes, categoriesRes, messagesRes, adminCountRes] = await Promise.all([
|
||||
axios.get('/api/games'),
|
||||
axios.get('/api/categories'),
|
||||
axios.get('/api/messages'),
|
||||
axios.get('/api/admin/count')
|
||||
])
|
||||
|
||||
stats.value = {
|
||||
gamesCount: gamesRes.data.length,
|
||||
categoriesCount: categoriesRes.data.length,
|
||||
unreadMessages: messagesRes.data.filter(m => m.status === 'unread').length,
|
||||
adminCount: adminCountRes.data.count
|
||||
}
|
||||
|
||||
// 获取最近添加的5个游戏
|
||||
recentGames.value = gamesRes.data
|
||||
.filter(game => game.title && game.category_name) // 确保数据完整
|
||||
.sort((a, b) => new Date(b.created_at) - new Date(a.created_at))
|
||||
.slice(0, Math.min(5, gamesRes.data.length)) // 确保不超过5个且不超过实际数量
|
||||
} catch (error) {
|
||||
console.error('Error loading dashboard data:', error)
|
||||
ElMessage.error('加载数据失败')
|
||||
}
|
||||
}
|
||||
|
||||
const formatDate = (date) => {
|
||||
return new Date(date).toLocaleString()
|
||||
}
|
||||
|
||||
onMounted(loadDashboardData)
|
||||
|
||||
return {
|
||||
stats,
|
||||
recentGames,
|
||||
formatDate
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dashboard {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.stat-cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||
gap: 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 36px;
|
||||
font-weight: bold;
|
||||
color: #409EFF;
|
||||
}
|
||||
|
||||
.recent-games {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.recent-games .card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.system-info {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.status-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.status-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.status-item .label {
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.status-item .value {
|
||||
font-weight: 500;
|
||||
}
|
||||
</style>
|
535
frontend/src/views/admin/GameManagement.vue
Normal file
535
frontend/src/views/admin/GameManagement.vue
Normal file
@ -0,0 +1,535 @@
|
||||
<template>
|
||||
<div class="game-management">
|
||||
<div class="page-header">
|
||||
<h2>游戏管理</h2>
|
||||
<button class="add-btn" @click="showAddGameModal">
|
||||
添加游戏
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="filters">
|
||||
<select v-model="selectedCategory">
|
||||
<option value="">所有分类</option>
|
||||
<option v-for="category in categories"
|
||||
:key="category.id"
|
||||
:value="category.id">
|
||||
{{ category.name }}
|
||||
</option>
|
||||
</select>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
v-model="searchQuery"
|
||||
placeholder="搜索游戏..."
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="games-table">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>封面</th>
|
||||
<th>标题</th>
|
||||
<th>分类</th>
|
||||
<th>发布日期</th>
|
||||
<th>开发商</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="game in filteredGames" :key="game._id">
|
||||
<td>
|
||||
<img :src="getImageUrl(game.image)"
|
||||
:alt="game.title"
|
||||
class="game-thumbnail">
|
||||
</td>
|
||||
<td>{{ game.title }}</td>
|
||||
<td>{{ getCategoryName(game.category_id) }}</td>
|
||||
<td>{{ formatDate(game.release_date) }}</td>
|
||||
<td>{{ game.developer }}</td>
|
||||
<td>
|
||||
<button class="edit-btn" @click="editGame(game)">编辑</button>
|
||||
<button class="delete-btn" @click="deleteGame(game.id)">删除</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- 游戏表单模态框 -->
|
||||
<div v-if="showGameModal" class="modal">
|
||||
<div class="modal-content">
|
||||
<h3>{{ editingGame ? '编辑游戏' : '添加游戏' }}</h3>
|
||||
<form @submit.prevent="submitGame">
|
||||
<div class="form-group">
|
||||
<label>标题</label>
|
||||
<input type="text" v-model="gameForm.title" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>分类</label>
|
||||
<select v-model="gameForm.category_id" required>
|
||||
<option v-for="category in categories"
|
||||
:key="category.id"
|
||||
:value="category.id">
|
||||
{{ category.name }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>描述</label>
|
||||
<textarea v-model="gameForm.description" rows="4"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>封面图片</label>
|
||||
<div class="image-upload">
|
||||
<input type="file"
|
||||
@change="handleImageSelect"
|
||||
accept="image/*">
|
||||
<img v-if="imagePreview"
|
||||
:src="imagePreview"
|
||||
class="preview-image">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>发布日期</label>
|
||||
<input type="date" v-model="gameForm.release_date">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>开发商</label>
|
||||
<input type="text" v-model="gameForm.developer">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>支持平台</label>
|
||||
<div class="platforms-checkboxes">
|
||||
<label v-for="platform in platforms" :key="platform">
|
||||
<input type="checkbox"
|
||||
:value="platform"
|
||||
v-model="gameForm.platforms">
|
||||
{{ platform }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>标签</label>
|
||||
<el-select
|
||||
v-model="gameForm.tags"
|
||||
multiple
|
||||
filterable
|
||||
placeholder="请选择标签"
|
||||
>
|
||||
<el-option
|
||||
v-for="tag in availableTags"
|
||||
:key="tag.id"
|
||||
:label="tag.name"
|
||||
:value="tag.name"
|
||||
>
|
||||
<span>{{ tag.name }}</span>
|
||||
<small style="color: #8492a6; margin-left: 8px">{{ tag.description }}</small>
|
||||
</el-option>
|
||||
</el-select>
|
||||
</div>
|
||||
|
||||
<div class="modal-actions">
|
||||
<button type="button" @click="showGameModal = false">取消</button>
|
||||
<button type="submit" class="submit-btn">
|
||||
{{ editingGame ? '保存' : '添加' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import axios from 'axios';
|
||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||
|
||||
export default {
|
||||
name: 'GameManagement',
|
||||
data() {
|
||||
return {
|
||||
games: [],
|
||||
categories: [],
|
||||
selectedCategory: '',
|
||||
searchQuery: '',
|
||||
showGameModal: false,
|
||||
editingGame: null,
|
||||
availableTags: [],
|
||||
imagePreview: null,
|
||||
gameForm: {
|
||||
title: '',
|
||||
category_id: '',
|
||||
description: '',
|
||||
image: '',
|
||||
release_date: '',
|
||||
developer: '',
|
||||
platforms: [],
|
||||
tags: []
|
||||
},
|
||||
platforms: ['PC', 'PS4', 'PS5', 'Xbox', 'Switch', 'Mobile'],
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
filteredGames() {
|
||||
return this.games.filter(game => {
|
||||
const categoryMatch = !this.selectedCategory ||
|
||||
game.category_id === this.selectedCategory;
|
||||
const searchMatch = !this.searchQuery ||
|
||||
game.title.toLowerCase().includes(this.searchQuery.toLowerCase());
|
||||
return categoryMatch && searchMatch;
|
||||
});
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async fetchGames() {
|
||||
try {
|
||||
const response = await axios.get('http://localhost:3000/api/games', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem('admin_token')}`
|
||||
}
|
||||
});
|
||||
this.games = response.data;
|
||||
} catch (error) {
|
||||
console.error('Error fetching games:', error);
|
||||
}
|
||||
},
|
||||
async fetchCategories() {
|
||||
try {
|
||||
const response = await axios.get('http://localhost:3000/api/categories');
|
||||
console.log('获取到的分类数据:', response.data);
|
||||
this.categories = response.data;
|
||||
} catch (error) {
|
||||
console.error('Error fetching categories:', error);
|
||||
ElMessage.error('获取分类列表失败');
|
||||
}
|
||||
},
|
||||
getCategoryName(categoryId) {
|
||||
const category = this.categories.find(c => c.id === categoryId);
|
||||
return category ? category.name : '未分类';
|
||||
},
|
||||
formatDate(date) {
|
||||
return new Date(date).toLocaleDateString();
|
||||
},
|
||||
getImageUrl(imagePath) {
|
||||
if (!imagePath) {
|
||||
return '/placeholder.jpg';
|
||||
}
|
||||
return `http://localhost:3000/${imagePath}`;
|
||||
},
|
||||
editGame(game) {
|
||||
this.resetForm();
|
||||
this.editingGame = game;
|
||||
this.gameForm = {
|
||||
title: game.title,
|
||||
category_id: game.category_id,
|
||||
description: game.description,
|
||||
image: null,
|
||||
release_date: game.release_date,
|
||||
developer: game.developer,
|
||||
platforms: [...game.platforms],
|
||||
tags: [...game.tags]
|
||||
};
|
||||
this.imagePreview = this.getImageUrl(game.image);
|
||||
this.showGameModal = true;
|
||||
},
|
||||
async deleteGame(id) {
|
||||
try {
|
||||
// 使用 Element Plus 的确认对话框
|
||||
await ElMessageBox.confirm(
|
||||
'确定要删除这个游戏吗?此操作不可恢复',
|
||||
'警告',
|
||||
{
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
}
|
||||
);
|
||||
|
||||
await axios.delete(`http://localhost:3000/api/games/${id}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem('admin_token')}`
|
||||
}
|
||||
});
|
||||
ElMessage.success('游戏删除成功');
|
||||
this.fetchGames();
|
||||
} catch (error) {
|
||||
if (error.name === 'CanceledError') {
|
||||
return; // 用户取消删除
|
||||
}
|
||||
console.error('Error deleting game:', error);
|
||||
ElMessage.error(error.response?.data?.message || '删除失败,请重试');
|
||||
}
|
||||
},
|
||||
handleImageSelect(event) {
|
||||
const file = event.target.files[0];
|
||||
if (file) {
|
||||
if (!file.type.startsWith('image/')) {
|
||||
ElMessage.error('请选择图片文件');
|
||||
return;
|
||||
}
|
||||
if (file.size > 10 * 1024 * 1024) { // 10MB
|
||||
ElMessage.error('图片大小不能超过10MB');
|
||||
return;
|
||||
}
|
||||
|
||||
this.gameForm.image = file;
|
||||
this.imagePreview = URL.createObjectURL(file);
|
||||
}
|
||||
},
|
||||
async fetchTags() {
|
||||
try {
|
||||
const response = await axios.get('/api/tags')
|
||||
this.availableTags = response.data
|
||||
} catch (error) {
|
||||
console.error('Error fetching tags:', error)
|
||||
ElMessage.error('获取标签列表失败')
|
||||
}
|
||||
},
|
||||
resetForm() {
|
||||
this.gameForm = {
|
||||
title: '',
|
||||
category_id: '',
|
||||
description: '',
|
||||
image: null,
|
||||
release_date: '',
|
||||
developer: '',
|
||||
platforms: [],
|
||||
tags: []
|
||||
};
|
||||
this.editingGame = null;
|
||||
this.imagePreview = null;
|
||||
const fileInput = document.querySelector('input[type="file"]');
|
||||
if (fileInput) {
|
||||
fileInput.value = '';
|
||||
}
|
||||
},
|
||||
showAddGameModal() {
|
||||
this.resetForm();
|
||||
this.editingGame = null;
|
||||
this.showGameModal = true;
|
||||
},
|
||||
async submitGame() {
|
||||
try {
|
||||
console.log('提交的表单数据:', {
|
||||
title: this.gameForm.title,
|
||||
category_id: this.gameForm.category_id
|
||||
});
|
||||
|
||||
// 验证必填字段
|
||||
if (!this.gameForm.title || !this.gameForm.category_id) {
|
||||
console.log('验证失败:', {
|
||||
title: !!this.gameForm.title,
|
||||
category_id: !!this.gameForm.category_id
|
||||
});
|
||||
ElMessage.warning('请填写游戏标题和选择分类');
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('title', this.gameForm.title);
|
||||
formData.append('description', this.gameForm.description);
|
||||
formData.append('category_id', String(this.gameForm.category_id));
|
||||
formData.append('developer', this.gameForm.developer);
|
||||
if (this.gameForm.release_date) {
|
||||
formData.append('release_date', this.gameForm.release_date);
|
||||
}
|
||||
|
||||
// 处理图片上传
|
||||
const imageInput = document.querySelector('input[type="file"]');
|
||||
if (imageInput && imageInput.files[0]) {
|
||||
formData.append('image', this.gameForm.image);
|
||||
}
|
||||
|
||||
// 处理平台数据
|
||||
this.gameForm.platforms.forEach(platform => {
|
||||
formData.append('platforms[]', platform);
|
||||
});
|
||||
|
||||
// 处理标签数据
|
||||
this.gameForm.tags.forEach(tag => {
|
||||
formData.append('tags[]', tag);
|
||||
});
|
||||
|
||||
const headers = {
|
||||
Authorization: `Bearer ${localStorage.getItem('admin_token')}`,
|
||||
};
|
||||
|
||||
if (this.editingGame) {
|
||||
await axios.put(`http://localhost:3000/api/games/${this.editingGame.id}`, formData, {
|
||||
headers
|
||||
});
|
||||
ElMessage.success('游戏更新成功');
|
||||
} else {
|
||||
await axios.post('http://localhost:3000/api/games', formData, {
|
||||
headers
|
||||
});
|
||||
ElMessage.success('游戏添加成功');
|
||||
}
|
||||
|
||||
this.showGameModal = false;
|
||||
this.fetchGames();
|
||||
} catch (error) {
|
||||
console.error('Error saving game:', error);
|
||||
console.error('Error response:', error.response?.data);
|
||||
ElMessage.error(error.response?.data?.message || '保存失败,请重试');
|
||||
}
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.fetchGames();
|
||||
this.fetchCategories();
|
||||
this.fetchTags();
|
||||
},
|
||||
beforeUnmount() {
|
||||
if (this.imagePreview) {
|
||||
URL.revokeObjectURL(this.imagePreview);
|
||||
}
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.game-management {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.filters {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.filters select,
|
||||
.filters input {
|
||||
padding: 8px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.games-table {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
th, td {
|
||||
padding: 12px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #ddd;
|
||||
}
|
||||
|
||||
th {
|
||||
background: #f5f5f5;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.game-thumbnail {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
object-fit: cover;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.edit-btn, .delete-btn {
|
||||
padding: 4px 8px;
|
||||
margin: 0 4px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.edit-btn {
|
||||
background: #3498db;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.delete-btn {
|
||||
background: #e74c3c;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.modal {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0,0,0,0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: white;
|
||||
padding: 30px;
|
||||
border-radius: 8px;
|
||||
width: 90%;
|
||||
max-width: 600px;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
input, select, textarea {
|
||||
width: 100%;
|
||||
padding: 8px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.preview-image {
|
||||
max-width: 200px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.platforms-checkboxes {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.platforms-checkboxes label {
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.submit-btn {
|
||||
background: #42b983;
|
||||
color: white;
|
||||
}
|
||||
</style>
|
219
frontend/src/views/admin/Layout.vue
Normal file
219
frontend/src/views/admin/Layout.vue
Normal file
@ -0,0 +1,219 @@
|
||||
<template>
|
||||
<el-container class="admin-layout">
|
||||
<el-aside width="250px">
|
||||
<div class="logo">
|
||||
<img src="@/assets/logo.png" alt="Logo" class="logo-image">
|
||||
<h2>管理后台</h2>
|
||||
</div>
|
||||
<el-menu
|
||||
router
|
||||
:default-active="$route.path"
|
||||
background-color="#2c3e50"
|
||||
text-color="#fff"
|
||||
active-text-color="#42b983"
|
||||
>
|
||||
<el-menu-item index="/admin">
|
||||
<el-icon><HomeFilled /></el-icon>
|
||||
<span>仪表盘</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/admin/games">
|
||||
<el-icon><HelpFilled /></el-icon>
|
||||
<span>游戏管理</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/admin/categories">
|
||||
<el-icon><Collection /></el-icon>
|
||||
<span>分类管理</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/admin/users" v-if="hasPermission('user:manage')">
|
||||
<el-icon><User /></el-icon>
|
||||
<span>用户管理</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/admin/messages">
|
||||
<el-icon><Message /></el-icon>
|
||||
<span>留言管理</span>
|
||||
<el-badge
|
||||
v-if="unreadMessageCount > 0"
|
||||
:value="unreadMessageCount"
|
||||
class="message-badge"
|
||||
/>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/admin/tags">
|
||||
<el-icon><Collection /></el-icon>
|
||||
<span>标签管理</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/admin/settings">
|
||||
<el-icon><Setting /></el-icon>
|
||||
<span>系统设置</span>
|
||||
</el-menu-item>
|
||||
</el-menu>
|
||||
</el-aside>
|
||||
|
||||
<el-container>
|
||||
<el-header>
|
||||
<div class="header-right">
|
||||
<span>{{ username }}</span>
|
||||
<el-button type="text" @click="handleLogout">退出</el-button>
|
||||
</div>
|
||||
</el-header>
|
||||
|
||||
<el-main>
|
||||
<router-view></router-view>
|
||||
</el-main>
|
||||
</el-container>
|
||||
</el-container>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { ref, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import axios from 'axios'
|
||||
import { ElNotification } from 'element-plus'
|
||||
|
||||
export default {
|
||||
name: 'AdminLayout',
|
||||
setup() {
|
||||
const router = useRouter()
|
||||
const username = ref(localStorage.getItem('admin_username') || '')
|
||||
const unreadMessageCount = ref(0)
|
||||
const ws = ref(null)
|
||||
|
||||
// 监听未读消息数量更新事件
|
||||
const updateUnreadCount = (event) => {
|
||||
unreadMessageCount.value = event.detail
|
||||
}
|
||||
|
||||
const connectWebSocket = () => {
|
||||
const token = localStorage.getItem('admin_token')
|
||||
if (!token) return
|
||||
|
||||
const wsUrl = `ws://localhost:3000?token=${token}`
|
||||
ws.value = new WebSocket(wsUrl)
|
||||
|
||||
ws.value.onmessage = (event) => {
|
||||
const data = JSON.parse(event.data)
|
||||
if (data.type === 'new_message') {
|
||||
// 更新未读消息数量
|
||||
unreadMessageCount.value++
|
||||
|
||||
// 显示通知
|
||||
ElNotification({
|
||||
title: '新留言',
|
||||
message: `来自 ${data.data.name} 的新留言`,
|
||||
type: 'info',
|
||||
duration: 5000,
|
||||
onClick: () => {
|
||||
router.push('/admin/messages')
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
ws.value.onclose = () => {
|
||||
setTimeout(connectWebSocket, 5000) // 断线重连
|
||||
}
|
||||
}
|
||||
|
||||
const loadUnreadCount = async () => {
|
||||
try {
|
||||
const response = await axios.get('/api/messages')
|
||||
unreadMessageCount.value = response.data.filter(m => m.status === 'unread').length
|
||||
} catch (error) {
|
||||
console.error('Error loading unread count:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleLogout = () => {
|
||||
localStorage.removeItem('admin_token')
|
||||
localStorage.removeItem('admin_username')
|
||||
router.push('/admin/login')
|
||||
}
|
||||
|
||||
const hasPermission = (permission) => {
|
||||
const permissions = JSON.parse(localStorage.getItem('admin_permissions') || '[]')
|
||||
return permissions.includes(permission)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadUnreadCount()
|
||||
connectWebSocket()
|
||||
window.addEventListener('unread-messages-update', updateUnreadCount)
|
||||
const timer = setInterval(loadUnreadCount, 60000)
|
||||
|
||||
onUnmounted(() => {
|
||||
clearInterval(timer)
|
||||
if (ws.value) {
|
||||
ws.value.close()
|
||||
}
|
||||
window.removeEventListener('unread-messages-update', updateUnreadCount)
|
||||
})
|
||||
})
|
||||
|
||||
return {
|
||||
username,
|
||||
unreadMessageCount,
|
||||
handleLogout,
|
||||
hasPermission
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.admin-layout {
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.el-aside {
|
||||
background: #2c3e50;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.logo {
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.logo-image {
|
||||
height: 40px;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.logo h2 {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.el-header {
|
||||
background: white;
|
||||
border-bottom: 1px solid #ddd;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.el-main {
|
||||
background: #f5f6fa;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.el-menu {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.el-menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
</style>
|
128
frontend/src/views/admin/Login.vue
Normal file
128
frontend/src/views/admin/Login.vue
Normal file
@ -0,0 +1,128 @@
|
||||
<template>
|
||||
<div class="login-page">
|
||||
<el-card class="login-box">
|
||||
<h2>管理员登录</h2>
|
||||
<el-form @submit.prevent="handleLogin">
|
||||
<el-form-item>
|
||||
<el-input
|
||||
v-model="form.username"
|
||||
placeholder="用户名"
|
||||
prefix-icon="User"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-input
|
||||
v-model="form.password"
|
||||
type="password"
|
||||
placeholder="密码"
|
||||
prefix-icon="Lock"
|
||||
show-password
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-button type="primary" @click="handleLogin" :loading="loading" block>
|
||||
{{ loading ? '登录中...' : '登录' }}
|
||||
</el-button>
|
||||
<div class="form-footer">
|
||||
<router-link to="/admin/register">没有账号?去注册</router-link>
|
||||
</div>
|
||||
</el-form>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { ref, reactive } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { login } from '@/services/api'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
export default {
|
||||
name: 'AdminLogin',
|
||||
setup() {
|
||||
const router = useRouter()
|
||||
const loading = ref(false)
|
||||
const form = reactive({
|
||||
username: '',
|
||||
password: ''
|
||||
})
|
||||
|
||||
const handleLogin = async () => {
|
||||
if (!form.username || !form.password) {
|
||||
ElMessage.warning('请输入用户名和密码')
|
||||
return
|
||||
}
|
||||
|
||||
console.log('Attempting login with username:', form.username);
|
||||
loading.value = true
|
||||
try {
|
||||
console.log('Sending login request...');
|
||||
const { token, admin } = await login(form.username, form.password)
|
||||
console.log('Login response received:', { admin });
|
||||
|
||||
localStorage.setItem('admin_token', token)
|
||||
localStorage.setItem('admin_username', admin.username)
|
||||
localStorage.setItem('admin_permissions', JSON.stringify(admin.permissions || []))
|
||||
|
||||
console.log('Stored credentials in localStorage');
|
||||
router.push('/admin')
|
||||
} catch (error) {
|
||||
console.error('Login error:', error);
|
||||
console.error('Error response:', error.response);
|
||||
let message = '登录失败'
|
||||
if (error.response && error.response.data) {
|
||||
message = error.response.data.message || message
|
||||
}
|
||||
ElMessage.error(message)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
form,
|
||||
loading,
|
||||
handleLogin
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.login-page {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #f5f6fa;
|
||||
}
|
||||
|
||||
.login-box {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
color: #2c3e50;
|
||||
}
|
||||
|
||||
.el-form-item {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.form-footer {
|
||||
margin-top: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.form-footer a {
|
||||
color: #409EFF;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.form-footer a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
</style>
|
311
frontend/src/views/admin/MediaManagement.vue
Normal file
311
frontend/src/views/admin/MediaManagement.vue
Normal file
@ -0,0 +1,311 @@
|
||||
<template>
|
||||
<div class="media-management">
|
||||
<div class="page-header">
|
||||
<h2>媒体管理</h2>
|
||||
<button class="upload-btn" @click="showUploadModal = true">
|
||||
上传文件
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="filters">
|
||||
<select v-model="selectedType">
|
||||
<option value="">所有类型</option>
|
||||
<option value="image">图片</option>
|
||||
<option value="video">视频</option>
|
||||
</select>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
v-model="searchQuery"
|
||||
placeholder="搜索文件名..."
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="media-grid">
|
||||
<div v-for="item in filteredMedia"
|
||||
:key="item._id"
|
||||
class="media-item">
|
||||
<div class="media-preview">
|
||||
<img v-if="item.type === 'image'" :src="item.url" :alt="item.originalname">
|
||||
<video v-else-if="item.type === 'video'" :src="item.url" controls></video>
|
||||
</div>
|
||||
<div class="media-info">
|
||||
<p class="filename">{{ item.originalname }}</p>
|
||||
<p class="upload-info">
|
||||
上传者: {{ item.uploadedBy.username }}
|
||||
<br>
|
||||
时间: {{ formatDate(item.uploadedAt) }}
|
||||
</p>
|
||||
<button class="delete-btn" @click="deleteMedia(item._id)">删除</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 上传模态框 -->
|
||||
<div v-if="showUploadModal" class="modal">
|
||||
<div class="modal-content">
|
||||
<h3>上传文件</h3>
|
||||
<div class="upload-area"
|
||||
@drop.prevent="handleDrop"
|
||||
@dragover.prevent
|
||||
@click="triggerFileInput">
|
||||
<input
|
||||
type="file"
|
||||
ref="fileInput"
|
||||
@change="handleFileSelect"
|
||||
accept="image/*,video/*"
|
||||
style="display: none"
|
||||
>
|
||||
<p>点击或拖拽文件到此处上传</p>
|
||||
<p class="supported-formats">支持的格式: JPG, PNG, GIF, MP4, WEBM</p>
|
||||
</div>
|
||||
<div class="upload-progress" v-if="uploading">
|
||||
<div class="progress-bar" :style="{ width: uploadProgress + '%' }"></div>
|
||||
<span>{{ uploadProgress }}%</span>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button @click="showUploadModal = false">取消</button>
|
||||
<button
|
||||
class="upload-submit"
|
||||
@click="submitUpload"
|
||||
:disabled="!selectedFile || uploading"
|
||||
>
|
||||
上传
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import axios from 'axios';
|
||||
|
||||
export default {
|
||||
name: 'MediaManagement',
|
||||
data() {
|
||||
return {
|
||||
mediaList: [],
|
||||
selectedType: '',
|
||||
searchQuery: '',
|
||||
showUploadModal: false,
|
||||
selectedFile: null,
|
||||
uploading: false,
|
||||
uploadProgress: 0
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
filteredMedia() {
|
||||
return this.mediaList.filter(item => {
|
||||
const typeMatch = !this.selectedType || item.type === this.selectedType;
|
||||
const searchMatch = !this.searchQuery ||
|
||||
item.originalname.toLowerCase().includes(this.searchQuery.toLowerCase());
|
||||
return typeMatch && searchMatch;
|
||||
});
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async fetchMediaList() {
|
||||
try {
|
||||
const response = await axios.get('http://localhost:3000/api/media', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem('admin_token')}`
|
||||
}
|
||||
});
|
||||
this.mediaList = response.data;
|
||||
} catch (error) {
|
||||
console.error('Error fetching media:', error);
|
||||
}
|
||||
},
|
||||
formatDate(date) {
|
||||
return new Date(date).toLocaleString();
|
||||
},
|
||||
triggerFileInput() {
|
||||
this.$refs.fileInput.click();
|
||||
},
|
||||
handleFileSelect(event) {
|
||||
this.selectedFile = event.target.files[0];
|
||||
},
|
||||
handleDrop(event) {
|
||||
this.selectedFile = event.dataTransfer.files[0];
|
||||
},
|
||||
async submitUpload() {
|
||||
if (!this.selectedFile) return;
|
||||
|
||||
this.uploading = true;
|
||||
const formData = new FormData();
|
||||
formData.append('file', this.selectedFile);
|
||||
|
||||
try {
|
||||
await axios.post('http://localhost:3000/api/media/upload', formData, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem('admin_token')}`,
|
||||
'Content-Type': 'multipart/form-data'
|
||||
},
|
||||
onUploadProgress: (progressEvent) => {
|
||||
this.uploadProgress = Math.round(
|
||||
(progressEvent.loaded * 100) / progressEvent.total
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
this.showUploadModal = false;
|
||||
this.fetchMediaList();
|
||||
} catch (error) {
|
||||
console.error('Error uploading file:', error);
|
||||
} finally {
|
||||
this.uploading = false;
|
||||
this.uploadProgress = 0;
|
||||
this.selectedFile = null;
|
||||
}
|
||||
},
|
||||
async deleteMedia(id) {
|
||||
if (!confirm('确定要删除这个文件吗?')) return;
|
||||
|
||||
try {
|
||||
await axios.delete(`http://localhost:3000/api/media/${id}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem('admin_token')}`
|
||||
}
|
||||
});
|
||||
this.fetchMediaList();
|
||||
} catch (error) {
|
||||
console.error('Error deleting media:', error);
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.fetchMediaList();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.media-management {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.filters {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.filters select,
|
||||
.filters input {
|
||||
padding: 8px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.media-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.media-item {
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.media-preview {
|
||||
height: 200px;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
.media-preview img,
|
||||
.media-preview video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.media-info {
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
.filename {
|
||||
font-weight: bold;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.upload-info {
|
||||
font-size: 0.9em;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.modal {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0,0,0,0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: white;
|
||||
padding: 30px;
|
||||
border-radius: 8px;
|
||||
width: 90%;
|
||||
max-width: 500px;
|
||||
}
|
||||
|
||||
.upload-area {
|
||||
border: 2px dashed #ddd;
|
||||
padding: 40px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.upload-progress {
|
||||
margin: 20px 0;
|
||||
background: #f0f0f0;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
height: 20px;
|
||||
background: #42b983;
|
||||
transition: width 0.3s;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
button {
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.upload-btn {
|
||||
background: #42b983;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.delete-btn {
|
||||
background: #e74c3c;
|
||||
color: white;
|
||||
padding: 4px 8px;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
</style>
|
283
frontend/src/views/admin/MessageManagement.vue
Normal file
283
frontend/src/views/admin/MessageManagement.vue
Normal file
@ -0,0 +1,283 @@
|
||||
<template>
|
||||
<div class="message-management">
|
||||
<div class="page-header">
|
||||
<h2>留言管理</h2>
|
||||
</div>
|
||||
|
||||
<el-table :data="messages" style="width: 100%">
|
||||
<el-table-column prop="name" label="姓名" width="120" />
|
||||
<el-table-column prop="email" label="邮箱" width="200" />
|
||||
<el-table-column prop="content" label="内容">
|
||||
<template #default="scope">
|
||||
<div class="message-preview">
|
||||
{{ getPreviewContent(scope.row.content) }}
|
||||
<el-button
|
||||
v-if="scope.row.content.length > 50"
|
||||
type="text"
|
||||
@click="showMessageDetail(scope.row)"
|
||||
>
|
||||
查看更多
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="status" label="状态" width="100">
|
||||
<template #default="scope">
|
||||
<el-tag :type="scope.row.status === 'unread' ? 'warning' : 'success'">
|
||||
{{ scope.row.status === 'unread' ? '未读' : '已读' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="created_at" label="留言时间" width="180">
|
||||
<template #default="scope">
|
||||
{{ formatDate(scope.row.created_at) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column v-if="isAdmin" prop="read_info" label="阅读信息" width="200">
|
||||
<template #default="scope">
|
||||
<div v-if="scope.row.read_by">
|
||||
<div>阅读者: {{ scope.row.reader_name }}</div>
|
||||
<div>阅读时间: {{ formatDate(scope.row.read_at) }}</div>
|
||||
</div>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="160" align="center">
|
||||
<template #default="scope">
|
||||
<div class="action-buttons">
|
||||
<el-button
|
||||
v-if="scope.row.status === 'unread'"
|
||||
size="small"
|
||||
type="primary"
|
||||
@click="showMessageDetail(scope.row)"
|
||||
>
|
||||
阅读
|
||||
</el-button>
|
||||
<el-button
|
||||
size="small"
|
||||
type="danger"
|
||||
@click="deleteMessage(scope.row.id)"
|
||||
>
|
||||
删除
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 留言详情对话框 -->
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
title="留言详情"
|
||||
width="600px"
|
||||
:before-close="handleDialogClose"
|
||||
>
|
||||
<div v-if="selectedMessage" class="message-detail">
|
||||
<div class="detail-item">
|
||||
<label>姓名:</label>
|
||||
<span>{{ selectedMessage.name }}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<label>邮箱:</label>
|
||||
<span>{{ selectedMessage.email }}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<label>留言时间:</label>
|
||||
<span>{{ formatDate(selectedMessage.created_at) }}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<label>内容:</label>
|
||||
<div class="message-content">{{ selectedMessage.content }}</div>
|
||||
</div>
|
||||
<div v-if="isAdmin && selectedMessage.read_by" class="detail-item">
|
||||
<label>阅读信息:</label>
|
||||
<div>
|
||||
<p>阅读者: {{ selectedMessage.reader_name }}</p>
|
||||
<p>阅读时间: {{ formatDate(selectedMessage.read_at) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { ref, onMounted, computed, onUnmounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import axios from 'axios'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
export default {
|
||||
name: 'MessageManagement',
|
||||
setup() {
|
||||
const router = useRouter()
|
||||
const messages = ref([])
|
||||
const dialogVisible = ref(false)
|
||||
const selectedMessage = ref(null)
|
||||
const currentUserRole = localStorage.getItem('admin_role')
|
||||
const ws = ref(null)
|
||||
const unreadCount = ref(0)
|
||||
|
||||
const isAdmin = computed(() => {
|
||||
return ['superadmin', 'admin'].includes(currentUserRole)
|
||||
})
|
||||
|
||||
const connectWebSocket = () => {
|
||||
const token = localStorage.getItem('admin_token')
|
||||
if (!token) return
|
||||
|
||||
const wsUrl = `ws://localhost:3000?token=${token}`
|
||||
ws.value = new WebSocket(wsUrl)
|
||||
|
||||
ws.value.onmessage = (event) => {
|
||||
const data = JSON.parse(event.data)
|
||||
if (data.type === 'new_message') {
|
||||
// 将新消息添加到列表开头
|
||||
messages.value.unshift(data.data)
|
||||
}
|
||||
}
|
||||
|
||||
ws.value.onclose = () => {
|
||||
setTimeout(connectWebSocket, 5000) // 断线重连
|
||||
}
|
||||
}
|
||||
|
||||
const fetchMessages = async () => {
|
||||
try {
|
||||
const response = await axios.get('/api/messages')
|
||||
messages.value = response.data
|
||||
unreadCount.value = messages.value.filter(m => m.status === 'unread').length
|
||||
window.dispatchEvent(new CustomEvent('unread-messages-update', {
|
||||
detail: unreadCount.value
|
||||
}))
|
||||
} catch (error) {
|
||||
ElMessage.error('获取留言列表失败')
|
||||
}
|
||||
}
|
||||
|
||||
const getPreviewContent = (content) => {
|
||||
return content.length > 50 ? content.slice(0, 50) + '...' : content
|
||||
}
|
||||
|
||||
const showMessageDetail = async (message) => {
|
||||
selectedMessage.value = message
|
||||
dialogVisible.value = true
|
||||
|
||||
// 如果是未读消息,标记为已读
|
||||
if (message.status === 'unread') {
|
||||
try {
|
||||
await axios.put(`/api/messages/${message.id}/read`)
|
||||
message.status = 'read'
|
||||
unreadCount.value--
|
||||
window.dispatchEvent(new CustomEvent('unread-messages-update', {
|
||||
detail: unreadCount.value
|
||||
}))
|
||||
// 重新获取消息列表以更新阅读信息
|
||||
fetchMessages()
|
||||
} catch (error) {
|
||||
console.error('Error marking message as read:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const deleteMessage = async (id) => {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
'确定要删除这条留言吗?此操作不可恢复',
|
||||
'警告',
|
||||
{
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
}
|
||||
)
|
||||
|
||||
await axios.delete(`/api/messages/${id}`)
|
||||
ElMessage.success('留言删除成功')
|
||||
messages.value = messages.value.filter(msg => msg.id !== id)
|
||||
} catch (error) {
|
||||
if (error !== 'cancel') {
|
||||
ElMessage.error('删除失败')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const formatDate = (date) => {
|
||||
return new Date(date).toLocaleString()
|
||||
}
|
||||
|
||||
const handleDialogClose = (done) => {
|
||||
selectedMessage.value = null
|
||||
done()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchMessages()
|
||||
connectWebSocket()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (ws.value) {
|
||||
ws.value.close()
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
messages,
|
||||
dialogVisible,
|
||||
selectedMessage,
|
||||
isAdmin,
|
||||
getPreviewContent,
|
||||
showMessageDetail,
|
||||
deleteMessage,
|
||||
formatDate,
|
||||
handleDialogClose
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.message-management {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.message-preview {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.message-detail {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.detail-item {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.detail-item label {
|
||||
font-weight: bold;
|
||||
margin-right: 10px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.message-content {
|
||||
margin-top: 10px;
|
||||
white-space: pre-wrap;
|
||||
line-height: 1.5;
|
||||
background: #f8f9fa;
|
||||
padding: 15px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
</style>
|
160
frontend/src/views/admin/Register.vue
Normal file
160
frontend/src/views/admin/Register.vue
Normal file
@ -0,0 +1,160 @@
|
||||
<template>
|
||||
<div class="register-page">
|
||||
<el-card class="register-box">
|
||||
<h2>管理员注册</h2>
|
||||
<el-form @submit.prevent="handleRegister">
|
||||
<el-form-item>
|
||||
<el-input
|
||||
v-model="form.username"
|
||||
placeholder="用户名"
|
||||
prefix-icon="User"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-input
|
||||
v-model="form.email"
|
||||
type="email"
|
||||
placeholder="邮箱"
|
||||
prefix-icon="Message"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-input
|
||||
v-model="form.password"
|
||||
type="password"
|
||||
placeholder="密码"
|
||||
prefix-icon="Lock"
|
||||
show-password
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-input
|
||||
v-model="form.confirmPassword"
|
||||
type="password"
|
||||
placeholder="确认密码"
|
||||
prefix-icon="Lock"
|
||||
show-password
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-button type="primary" @click="handleRegister" :loading="loading" block>
|
||||
{{ loading ? '注册中...' : '注册' }}
|
||||
</el-button>
|
||||
<div class="form-footer">
|
||||
<router-link to="/admin/login">已有账号?去登录</router-link>
|
||||
</div>
|
||||
</el-form>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { ref, reactive } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { register } from '@/services/api'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
export default {
|
||||
name: 'AdminRegister',
|
||||
setup() {
|
||||
const router = useRouter()
|
||||
const loading = ref(false)
|
||||
const form = reactive({
|
||||
username: '',
|
||||
email: '',
|
||||
password: '',
|
||||
confirmPassword: ''
|
||||
})
|
||||
|
||||
const validateForm = () => {
|
||||
if (!form.username || !form.email || !form.password || !form.confirmPassword) {
|
||||
ElMessage.warning('请填写所有字段')
|
||||
return false
|
||||
}
|
||||
|
||||
if (form.password !== form.confirmPassword) {
|
||||
ElMessage.warning('两次输入的密码不一致')
|
||||
return false
|
||||
}
|
||||
|
||||
if (form.password.length < 6) {
|
||||
ElMessage.warning('密码长度至少6位')
|
||||
return false
|
||||
}
|
||||
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
if (!emailRegex.test(form.email)) {
|
||||
ElMessage.warning('请输入有效的邮箱地址')
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
const handleRegister = async () => {
|
||||
if (!validateForm()) return
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const { token, admin } = await register(form)
|
||||
localStorage.setItem('admin_token', token)
|
||||
localStorage.setItem('admin_username', admin.username)
|
||||
localStorage.setItem('admin_permissions', JSON.stringify(admin.permissions || []))
|
||||
|
||||
ElMessage.success('注册成功')
|
||||
router.push('/admin')
|
||||
} catch (error) {
|
||||
console.error('Registration error:', error)
|
||||
ElMessage.error(error.response?.data?.message || '注册失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
form,
|
||||
loading,
|
||||
handleRegister
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.register-page {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #f5f6fa;
|
||||
}
|
||||
|
||||
.register-box {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
color: #2c3e50;
|
||||
}
|
||||
|
||||
.el-form-item {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.form-footer {
|
||||
margin-top: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.form-footer a {
|
||||
color: #409EFF;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.form-footer a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
</style>
|
198
frontend/src/views/admin/SystemSettings.vue
Normal file
198
frontend/src/views/admin/SystemSettings.vue
Normal file
@ -0,0 +1,198 @@
|
||||
<template>
|
||||
<div class="system-settings">
|
||||
<el-card>
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<h2>系统设置</h2>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-tabs v-model="activeTab">
|
||||
<!-- 公司信息设置 -->
|
||||
<el-tab-pane label="公司信息" name="company">
|
||||
<el-form :model="settings.company" label-width="100px">
|
||||
<el-form-item label="公司名称">
|
||||
<el-input v-model="settings.company.name.value" />
|
||||
</el-form-item>
|
||||
<el-form-item label="公司简介">
|
||||
<el-input type="textarea" v-model="settings.company.description.value" rows="4" />
|
||||
</el-form-item>
|
||||
<el-form-item label="公司使命">
|
||||
<el-input type="textarea" v-model="settings.company.mission.value" rows="3" />
|
||||
</el-form-item>
|
||||
<el-form-item label="公司愿景">
|
||||
<el-input type="textarea" v-model="settings.company.vision.value" rows="3" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- 联系方式设置 -->
|
||||
<el-tab-pane label="联系方式" name="contact">
|
||||
<el-form :model="settings.contact" label-width="100px">
|
||||
<el-form-item label="公司地址">
|
||||
<el-input v-model="settings.contact.address.value" />
|
||||
</el-form-item>
|
||||
<el-form-item label="联系邮箱">
|
||||
<el-input v-model="settings.contact.email.value" />
|
||||
</el-form-item>
|
||||
<el-form-item label="联系电话">
|
||||
<el-input v-model="settings.contact.phone.value" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- 核心价值观设置 -->
|
||||
<el-tab-pane label="核心价值观" name="values">
|
||||
<el-form :model="settings.values" label-width="100px">
|
||||
<div v-for="i in 4" :key="i" class="value-item">
|
||||
<h3>核心价值观 {{ i }}</h3>
|
||||
<el-form-item label="标题">
|
||||
<el-input v-model="valueSettings[`value${i}`].title" />
|
||||
</el-form-item>
|
||||
<el-form-item label="描述">
|
||||
<el-input type="textarea" v-model="valueSettings[`value${i}`].description" rows="2" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
</el-form>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
|
||||
<div class="form-actions">
|
||||
<el-button type="primary" @click="saveSettings" :loading="saving">
|
||||
保存设置
|
||||
</el-button>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import axios from 'axios'
|
||||
|
||||
export default {
|
||||
name: 'SystemSettings',
|
||||
setup() {
|
||||
const activeTab = ref('company')
|
||||
const saving = ref(false)
|
||||
const settings = reactive({
|
||||
company: {
|
||||
name: { value: '' },
|
||||
description: { value: '' },
|
||||
mission: { value: '' },
|
||||
vision: { value: '' }
|
||||
},
|
||||
contact: {
|
||||
address: { value: '' },
|
||||
email: { value: '' },
|
||||
phone: { value: '' }
|
||||
},
|
||||
values: {
|
||||
value1: { value: '{"title":"","description":""}' },
|
||||
value2: { value: '{"title":"","description":""}' },
|
||||
value3: { value: '{"title":"","description":""}' },
|
||||
value4: { value: '{"title":"","description":""}' }
|
||||
}
|
||||
})
|
||||
const valueSettings = reactive({
|
||||
value1: { title: '', description: '' },
|
||||
value2: { title: '', description: '' },
|
||||
value3: { title: '', description: '' },
|
||||
value4: { title: '', description: '' }
|
||||
})
|
||||
|
||||
// 加载设置
|
||||
const loadSettings = async () => {
|
||||
try {
|
||||
const response = await axios.get('/api/settings')
|
||||
// 合并数据,保持响应式
|
||||
for (const category in response.data) {
|
||||
if (settings[category]) {
|
||||
for (const key in response.data[category]) {
|
||||
if (settings[category][key]) {
|
||||
settings[category][key].value = response.data[category][key].value
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 处理核心价值观数据
|
||||
for (const key in settings.values) {
|
||||
try {
|
||||
const parsedValue = JSON.parse(settings.values[key].value)
|
||||
valueSettings[key] = parsedValue
|
||||
} catch (e) {
|
||||
console.error(`Error parsing value for ${key}:`, e)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading settings:', error)
|
||||
ElMessage.error('加载设置失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 保存设置
|
||||
const saveSettings = async () => {
|
||||
saving.value = true
|
||||
try {
|
||||
// 保存公司信息和联系方式
|
||||
for (const category of ['company', 'contact']) {
|
||||
for (const key in settings[category]) {
|
||||
await axios.put(`/api/settings/${category}/${key}`, {
|
||||
value: settings[category][key].value
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 保存核心价值观
|
||||
for (const key in valueSettings) {
|
||||
await axios.put(`/api/settings/values/${key}`, {
|
||||
value: JSON.stringify(valueSettings[key])
|
||||
})
|
||||
}
|
||||
|
||||
ElMessage.success('设置保存成功')
|
||||
} catch (error) {
|
||||
ElMessage.error('保存设置失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadSettings)
|
||||
|
||||
return {
|
||||
activeTab,
|
||||
settings,
|
||||
valueSettings,
|
||||
saving,
|
||||
saveSettings
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.system-settings {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.value-item {
|
||||
margin-bottom: 20px;
|
||||
padding: 20px;
|
||||
border: 1px solid #eee;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
margin-top: 20px;
|
||||
text-align: right;
|
||||
}
|
||||
</style>
|
180
frontend/src/views/admin/TagManagement.vue
Normal file
180
frontend/src/views/admin/TagManagement.vue
Normal file
@ -0,0 +1,180 @@
|
||||
<template>
|
||||
<div class="tag-management">
|
||||
<div class="page-header">
|
||||
<h2>标签管理</h2>
|
||||
<button class="add-btn" @click="showAddTagModal">添加标签</button>
|
||||
</div>
|
||||
|
||||
<div class="tags-table">
|
||||
<el-table :data="tags" style="width: 100%">
|
||||
<el-table-column prop="name" label="标签名称" width="180" />
|
||||
<el-table-column prop="description" label="描述" />
|
||||
<el-table-column prop="created_at" label="创建时间" width="180">
|
||||
<template #default="scope">
|
||||
{{ formatDate(scope.row.created_at) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="150">
|
||||
<template #default="scope">
|
||||
<el-button size="small" @click="editTag(scope.row)">编辑</el-button>
|
||||
<el-button
|
||||
size="small"
|
||||
type="danger"
|
||||
@click="deleteTag(scope.row.id)"
|
||||
>删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<!-- 标签表单对话框 -->
|
||||
<el-dialog
|
||||
:title="editingTag ? '编辑标签' : '添加标签'"
|
||||
v-model="dialogVisible"
|
||||
width="500px"
|
||||
>
|
||||
<el-form :model="tagForm" label-width="80px">
|
||||
<el-form-item label="标签名称" required>
|
||||
<el-input v-model="tagForm.name" />
|
||||
</el-form-item>
|
||||
<el-form-item label="描述">
|
||||
<el-input type="textarea" v-model="tagForm.description" rows="3" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<span class="dialog-footer">
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="submitTag">确定</el-button>
|
||||
</span>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import axios from 'axios'
|
||||
|
||||
export default {
|
||||
name: 'TagManagement',
|
||||
setup() {
|
||||
const tags = ref([])
|
||||
const dialogVisible = ref(false)
|
||||
const editingTag = ref(null)
|
||||
const tagForm = ref({
|
||||
name: '',
|
||||
description: ''
|
||||
})
|
||||
|
||||
const fetchTags = async () => {
|
||||
try {
|
||||
const response = await axios.get('/api/tags')
|
||||
tags.value = response.data
|
||||
} catch (error) {
|
||||
ElMessage.error('获取标签列表失败')
|
||||
}
|
||||
}
|
||||
|
||||
const showAddTagModal = () => {
|
||||
editingTag.value = null
|
||||
tagForm.value = {
|
||||
name: '',
|
||||
description: ''
|
||||
}
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
const editTag = (tag) => {
|
||||
editingTag.value = tag
|
||||
tagForm.value = {
|
||||
name: tag.name,
|
||||
description: tag.description
|
||||
}
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
const submitTag = async () => {
|
||||
try {
|
||||
if (!tagForm.value.name) {
|
||||
ElMessage.warning('请输入标签名称')
|
||||
return
|
||||
}
|
||||
|
||||
if (editingTag.value) {
|
||||
await axios.put(`/api/tags/${editingTag.value.id}`, tagForm.value)
|
||||
ElMessage.success('标签更新成功')
|
||||
} else {
|
||||
await axios.post('/api/tags', tagForm.value)
|
||||
ElMessage.success('标签添加成功')
|
||||
}
|
||||
|
||||
dialogVisible.value = false
|
||||
fetchTags()
|
||||
} catch (error) {
|
||||
ElMessage.error(error.response?.data?.message || '操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
const deleteTag = async (id) => {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定要删除这个标签吗?', '警告', {
|
||||
type: 'warning'
|
||||
})
|
||||
|
||||
await axios.delete(`/api/tags/${id}`)
|
||||
ElMessage.success('标签删除成功')
|
||||
fetchTags()
|
||||
} catch (error) {
|
||||
if (error !== 'cancel') {
|
||||
ElMessage.error('删除失败')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const formatDate = (date) => {
|
||||
return new Date(date).toLocaleString()
|
||||
}
|
||||
|
||||
onMounted(fetchTags)
|
||||
|
||||
return {
|
||||
tags,
|
||||
dialogVisible,
|
||||
editingTag,
|
||||
tagForm,
|
||||
showAddTagModal,
|
||||
editTag,
|
||||
submitTag,
|
||||
deleteTag,
|
||||
formatDate
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.tag-management {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.add-btn {
|
||||
background: #409EFF;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 8px 16px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.add-btn:hover {
|
||||
background: #66b1ff;
|
||||
}
|
||||
</style>
|
301
frontend/src/views/admin/UserManagement.vue
Normal file
301
frontend/src/views/admin/UserManagement.vue
Normal file
@ -0,0 +1,301 @@
|
||||
<template>
|
||||
<div class="user-management">
|
||||
<div class="page-header">
|
||||
<h2>用户管理</h2>
|
||||
<el-button type="primary" @click="showAddUserDialog">
|
||||
添加管理员
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 用户列表 -->
|
||||
<el-table :data="users" style="width: 100%">
|
||||
<el-table-column prop="username" label="用户名" width="180" />
|
||||
<el-table-column prop="email" label="邮箱" width="220" />
|
||||
<el-table-column prop="role" label="角色" width="120">
|
||||
<template #default="scope">
|
||||
<el-tag :type="getRoleType(scope.row.role)">
|
||||
{{ getRoleLabel(scope.row.role) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="status" label="状态" width="100">
|
||||
<template #default="scope">
|
||||
<el-tag :type="scope.row.status === 'active' ? 'success' : 'danger'">
|
||||
{{ scope.row.status === 'active' ? '正常' : '禁用' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="last_login" label="最后登录" width="180">
|
||||
<template #default="scope">
|
||||
{{ formatDate(scope.row.last_login) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="200">
|
||||
<template #default="scope">
|
||||
<el-button
|
||||
size="small"
|
||||
@click="editUser(scope.row)"
|
||||
:disabled="scope.row.role === 'superadmin' && currentUserRole !== 'superadmin'"
|
||||
>
|
||||
编辑
|
||||
</el-button>
|
||||
<el-button
|
||||
size="small"
|
||||
type="danger"
|
||||
@click="deleteUser(scope.row)"
|
||||
:disabled="
|
||||
scope.row.role === 'superadmin' ||
|
||||
scope.row.id === currentUserId
|
||||
"
|
||||
>
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 用户表单对话框 -->
|
||||
<el-dialog
|
||||
:title="editingUser ? '编辑管理员' : '添加管理员'"
|
||||
v-model="dialogVisible"
|
||||
width="500px"
|
||||
>
|
||||
<el-form :model="userForm" label-width="100px" ref="userFormRef">
|
||||
<el-form-item
|
||||
label="用户名"
|
||||
prop="username"
|
||||
:rules="[
|
||||
{ required: true, message: '请输入用户名', trigger: 'blur' },
|
||||
{ min: 3, max: 20, message: '长度在 3 到 20 个字符', trigger: 'blur' }
|
||||
]"
|
||||
>
|
||||
<el-input v-model="userForm.username" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item
|
||||
label="邮箱"
|
||||
prop="email"
|
||||
:rules="[
|
||||
{ required: true, message: '请输入邮箱', trigger: 'blur' },
|
||||
{ type: 'email', message: '请输入正确的邮箱地址', trigger: 'blur' }
|
||||
]"
|
||||
>
|
||||
<el-input v-model="userForm.email" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item
|
||||
label="密码"
|
||||
prop="password"
|
||||
:rules="[
|
||||
{ required: !editingUser, message: '请输入密码', trigger: 'blur' },
|
||||
{ min: 6, message: '密码长度不能小于6位', trigger: 'blur' }
|
||||
]"
|
||||
>
|
||||
<el-input v-model="userForm.password" type="password" show-password />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="角色" prop="role">
|
||||
<el-select v-model="userForm.role" placeholder="请选择角色">
|
||||
<el-option label="管理员" value="admin" />
|
||||
<el-option label="编辑" value="editor" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="状态" prop="status">
|
||||
<el-switch
|
||||
v-model="userForm.status"
|
||||
:active-value="'active'"
|
||||
:inactive-value="'inactive'"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="权限">
|
||||
<el-checkbox-group v-model="userForm.permissions">
|
||||
<el-checkbox label="user:manage">用户管理</el-checkbox>
|
||||
<el-checkbox label="game:manage">游戏管理</el-checkbox>
|
||||
<el-checkbox label="category:manage">分类管理</el-checkbox>
|
||||
<el-checkbox label="tag:manage">标签管理</el-checkbox>
|
||||
<el-checkbox label="message:manage">留言管理</el-checkbox>
|
||||
<el-checkbox label="system:manage">系统设置</el-checkbox>
|
||||
</el-checkbox-group>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<span class="dialog-footer">
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="submitForm">确定</el-button>
|
||||
</span>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import axios from 'axios'
|
||||
|
||||
export default {
|
||||
name: 'UserManagement',
|
||||
setup() {
|
||||
const users = ref([])
|
||||
const dialogVisible = ref(false)
|
||||
const editingUser = ref(null)
|
||||
const userFormRef = ref(null)
|
||||
const currentUserId = localStorage.getItem('admin_id')
|
||||
const currentUserRole = localStorage.getItem('admin_role')
|
||||
|
||||
const userForm = ref({
|
||||
username: '',
|
||||
email: '',
|
||||
password: '',
|
||||
role: 'editor',
|
||||
status: 'active',
|
||||
permissions: []
|
||||
})
|
||||
|
||||
const fetchUsers = async () => {
|
||||
try {
|
||||
const response = await axios.get('/api/admin/users')
|
||||
users.value = response.data
|
||||
} catch (error) {
|
||||
ElMessage.error('获取用户列表失败')
|
||||
}
|
||||
}
|
||||
|
||||
const showAddUserDialog = () => {
|
||||
editingUser.value = null
|
||||
userForm.value = {
|
||||
username: '',
|
||||
email: '',
|
||||
password: '',
|
||||
role: 'editor',
|
||||
status: 'active',
|
||||
permissions: []
|
||||
}
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
const editUser = (user) => {
|
||||
editingUser.value = user
|
||||
userForm.value = {
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
password: '',
|
||||
role: user.role,
|
||||
status: user.status,
|
||||
permissions: user.permissions || []
|
||||
}
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
const submitForm = async () => {
|
||||
if (!userFormRef.value) return
|
||||
|
||||
try {
|
||||
await userFormRef.value.validate()
|
||||
|
||||
if (editingUser.value) {
|
||||
await axios.put(`/api/admin/users/${editingUser.value.id}`, userForm.value)
|
||||
ElMessage.success('用户更新成功')
|
||||
} else {
|
||||
await axios.post('/api/admin/users', userForm.value)
|
||||
ElMessage.success('用户添加成功')
|
||||
}
|
||||
|
||||
dialogVisible.value = false
|
||||
fetchUsers()
|
||||
} catch (error) {
|
||||
console.error('Error submitting form:', error)
|
||||
ElMessage.error(error.response?.data?.message || '操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
const deleteUser = async (user) => {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
'确定要删除这个用户吗?此操作不可恢复',
|
||||
'警告',
|
||||
{
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
}
|
||||
)
|
||||
|
||||
await axios.delete(`/api/admin/users/${user.id}`)
|
||||
ElMessage.success('用户删除成功')
|
||||
fetchUsers()
|
||||
} catch (error) {
|
||||
if (error !== 'cancel') {
|
||||
ElMessage.error('删除失败')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const getRoleType = (role) => {
|
||||
const types = {
|
||||
superadmin: 'danger',
|
||||
admin: 'warning',
|
||||
editor: 'info'
|
||||
}
|
||||
return types[role] || 'info'
|
||||
}
|
||||
|
||||
const getRoleLabel = (role) => {
|
||||
const labels = {
|
||||
superadmin: '超级管理员',
|
||||
admin: '管理员',
|
||||
editor: '编辑'
|
||||
}
|
||||
return labels[role] || role
|
||||
}
|
||||
|
||||
const formatDate = (date) => {
|
||||
return date ? new Date(date).toLocaleString() : '从未登录'
|
||||
}
|
||||
|
||||
onMounted(fetchUsers)
|
||||
|
||||
return {
|
||||
users,
|
||||
dialogVisible,
|
||||
editingUser,
|
||||
userForm,
|
||||
userFormRef,
|
||||
currentUserId,
|
||||
currentUserRole,
|
||||
showAddUserDialog,
|
||||
editUser,
|
||||
deleteUser,
|
||||
submitForm,
|
||||
getRoleType,
|
||||
getRoleLabel,
|
||||
formatDate
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.user-management {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.el-tag {
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
:deep(.el-checkbox-group) {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
</style>
|
Loading…
Reference in New Issue
Block a user