first commit

This commit is contained in:
brige 2025-02-07 13:10:01 +08:00
parent d7295fa121
commit 7abbe707de
43 changed files with 6588 additions and 0 deletions

3
.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
backend/node_modules/
frontend/node_modules/
backend/uploads/

6
backend/.env Normal file
View 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

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

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

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

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

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

@ -0,0 +1 @@
VUE_APP_API_URL=http://localhost:3000

BIN
frontend/assets/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

5
frontend/babel.config.js Normal file
View File

@ -0,0 +1,5 @@
module.exports = {
presets: [
'@vue/cli-plugin-babel/preset'
]
}

25
frontend/package.json Normal file
View 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
View 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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

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

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

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

View 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

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

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

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

View 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
View 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>&copy; 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>

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

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

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

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

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

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

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

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

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

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

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