From 7abbe707de03b319bb9b53d5c8b3114718ddf075 Mon Sep 17 00:00:00 2001 From: brige Date: Fri, 7 Feb 2025 13:10:01 +0800 Subject: [PATCH] first commit --- .gitignore | 3 + backend/.env | 6 + backend/middleware/auth.js | 31 + backend/middleware/checkPermission.js | 34 + backend/models/admin.js | 89 +++ backend/models/media.js | 34 + backend/package.json | 23 + backend/routes/admin.js | 185 +++++ backend/routes/messages.js | 65 ++ backend/routes/systemSettings.js | 57 ++ backend/routes/tags.js | 70 ++ backend/scripts/test-db.js | 38 + backend/server.js | 724 ++++++++++++++++++ backend/utils/db.js | 53 ++ backend/websocket.js | 64 ++ database/init.sql | 178 +++++ frontend/.env | 1 + frontend/assets/logo.png | Bin 0 -> 13890 bytes frontend/babel.config.js | 5 + frontend/package.json | 25 + frontend/src/App.vue | 35 + frontend/src/assets/logo.png | Bin 0 -> 13890 bytes frontend/src/components/GameDetail.vue | 116 +++ frontend/src/components/GameList.vue | 229 ++++++ frontend/src/main.js | 30 + frontend/src/router/admin.js | 93 +++ frontend/src/router/index.js | 109 +++ frontend/src/services/api.js | 97 +++ frontend/src/views/About.vue | 400 ++++++++++ frontend/src/views/Categories.vue | 231 ++++++ frontend/src/views/Games.vue | 286 +++++++ frontend/src/views/Home.vue | 418 ++++++++++ .../src/views/admin/CategoryManagement.vue | 282 +++++++ frontend/src/views/admin/Dashboard.vue | 262 +++++++ frontend/src/views/admin/GameManagement.vue | 535 +++++++++++++ frontend/src/views/admin/Layout.vue | 219 ++++++ frontend/src/views/admin/Login.vue | 128 ++++ frontend/src/views/admin/MediaManagement.vue | 311 ++++++++ .../src/views/admin/MessageManagement.vue | 283 +++++++ frontend/src/views/admin/Register.vue | 160 ++++ frontend/src/views/admin/SystemSettings.vue | 198 +++++ frontend/src/views/admin/TagManagement.vue | 180 +++++ frontend/src/views/admin/UserManagement.vue | 301 ++++++++ 43 files changed, 6588 insertions(+) create mode 100644 .gitignore create mode 100644 backend/.env create mode 100644 backend/middleware/auth.js create mode 100644 backend/middleware/checkPermission.js create mode 100644 backend/models/admin.js create mode 100644 backend/models/media.js create mode 100644 backend/package.json create mode 100644 backend/routes/admin.js create mode 100644 backend/routes/messages.js create mode 100644 backend/routes/systemSettings.js create mode 100644 backend/routes/tags.js create mode 100644 backend/scripts/test-db.js create mode 100644 backend/server.js create mode 100644 backend/utils/db.js create mode 100644 backend/websocket.js create mode 100644 database/init.sql create mode 100644 frontend/.env create mode 100644 frontend/assets/logo.png create mode 100644 frontend/babel.config.js create mode 100644 frontend/package.json create mode 100644 frontend/src/App.vue create mode 100644 frontend/src/assets/logo.png create mode 100644 frontend/src/components/GameDetail.vue create mode 100644 frontend/src/components/GameList.vue create mode 100644 frontend/src/main.js create mode 100644 frontend/src/router/admin.js create mode 100644 frontend/src/router/index.js create mode 100644 frontend/src/services/api.js create mode 100644 frontend/src/views/About.vue create mode 100644 frontend/src/views/Categories.vue create mode 100644 frontend/src/views/Games.vue create mode 100644 frontend/src/views/Home.vue create mode 100644 frontend/src/views/admin/CategoryManagement.vue create mode 100644 frontend/src/views/admin/Dashboard.vue create mode 100644 frontend/src/views/admin/GameManagement.vue create mode 100644 frontend/src/views/admin/Layout.vue create mode 100644 frontend/src/views/admin/Login.vue create mode 100644 frontend/src/views/admin/MediaManagement.vue create mode 100644 frontend/src/views/admin/MessageManagement.vue create mode 100644 frontend/src/views/admin/Register.vue create mode 100644 frontend/src/views/admin/SystemSettings.vue create mode 100644 frontend/src/views/admin/TagManagement.vue create mode 100644 frontend/src/views/admin/UserManagement.vue diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2cce8a7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +backend/node_modules/ +frontend/node_modules/ +backend/uploads/ diff --git a/backend/.env b/backend/.env new file mode 100644 index 0000000..9b22c56 --- /dev/null +++ b/backend/.env @@ -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 \ No newline at end of file diff --git a/backend/middleware/auth.js b/backend/middleware/auth.js new file mode 100644 index 0000000..e544fdd --- /dev/null +++ b/backend/middleware/auth.js @@ -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; \ No newline at end of file diff --git a/backend/middleware/checkPermission.js b/backend/middleware/checkPermission.js new file mode 100644 index 0000000..c680797 --- /dev/null +++ b/backend/middleware/checkPermission.js @@ -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; \ No newline at end of file diff --git a/backend/models/admin.js b/backend/models/admin.js new file mode 100644 index 0000000..22eb9ad --- /dev/null +++ b/backend/models/admin.js @@ -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); \ No newline at end of file diff --git a/backend/models/media.js b/backend/models/media.js new file mode 100644 index 0000000..270089c --- /dev/null +++ b/backend/models/media.js @@ -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); \ No newline at end of file diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..bf15280 --- /dev/null +++ b/backend/package.json @@ -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" + } +} diff --git a/backend/routes/admin.js b/backend/routes/admin.js new file mode 100644 index 0000000..dfc9bca --- /dev/null +++ b/backend/routes/admin.js @@ -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; \ No newline at end of file diff --git a/backend/routes/messages.js b/backend/routes/messages.js new file mode 100644 index 0000000..48216ac --- /dev/null +++ b/backend/routes/messages.js @@ -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; \ No newline at end of file diff --git a/backend/routes/systemSettings.js b/backend/routes/systemSettings.js new file mode 100644 index 0000000..1a77c9d --- /dev/null +++ b/backend/routes/systemSettings.js @@ -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; \ No newline at end of file diff --git a/backend/routes/tags.js b/backend/routes/tags.js new file mode 100644 index 0000000..4c7dd97 --- /dev/null +++ b/backend/routes/tags.js @@ -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; \ No newline at end of file diff --git a/backend/scripts/test-db.js b/backend/scripts/test-db.js new file mode 100644 index 0000000..a3e4312 --- /dev/null +++ b/backend/scripts/test-db.js @@ -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(); \ No newline at end of file diff --git a/backend/server.js b/backend/server.js new file mode 100644 index 0000000..537ae74 --- /dev/null +++ b/backend/server.js @@ -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); \ No newline at end of file diff --git a/backend/utils/db.js b/backend/utils/db.js new file mode 100644 index 0000000..754bc18 --- /dev/null +++ b/backend/utils/db.js @@ -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; \ No newline at end of file diff --git a/backend/websocket.js b/backend/websocket.js new file mode 100644 index 0000000..68f08d5 --- /dev/null +++ b/backend/websocket.js @@ -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 +}; \ No newline at end of file diff --git a/database/init.sql b/database/init.sql new file mode 100644 index 0000000..34d8cb3 --- /dev/null +++ b/database/init.sql @@ -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'; -- 将编辑角色升级为管理员 \ No newline at end of file diff --git a/frontend/.env b/frontend/.env new file mode 100644 index 0000000..47b9652 --- /dev/null +++ b/frontend/.env @@ -0,0 +1 @@ +VUE_APP_API_URL=http://localhost:3000 \ No newline at end of file diff --git a/frontend/assets/logo.png b/frontend/assets/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..d062d36897ee89135c93bc54aed83275a897c410 GIT binary patch literal 13890 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 + + + + \ No newline at end of file diff --git a/frontend/src/assets/logo.png b/frontend/src/assets/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..d062d36897ee89135c93bc54aed83275a897c410 GIT binary patch literal 13890 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 +
+
+ +

{{ game.title }}

+
+ +
+
+ +
+ +
+
{{ game.category?.name || '未分类' }}
+

{{ game.description }}

+ +
+
+
+ + + + + \ No newline at end of file diff --git a/frontend/src/components/GameList.vue b/frontend/src/components/GameList.vue new file mode 100644 index 0000000..237044f --- /dev/null +++ b/frontend/src/components/GameList.vue @@ -0,0 +1,229 @@ + + + + + \ No newline at end of file diff --git a/frontend/src/main.js b/frontend/src/main.js new file mode 100644 index 0000000..e8ef873 --- /dev/null +++ b/frontend/src/main.js @@ -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') \ No newline at end of file diff --git a/frontend/src/router/admin.js b/frontend/src/router/admin.js new file mode 100644 index 0000000..2731ee9 --- /dev/null +++ b/frontend/src/router/admin.js @@ -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; \ No newline at end of file diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js new file mode 100644 index 0000000..ff9c043 --- /dev/null +++ b/frontend/src/router/index.js @@ -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 \ No newline at end of file diff --git a/frontend/src/services/api.js b/frontend/src/services/api.js new file mode 100644 index 0000000..fe4fb7c --- /dev/null +++ b/frontend/src/services/api.js @@ -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 +} \ No newline at end of file diff --git a/frontend/src/views/About.vue b/frontend/src/views/About.vue new file mode 100644 index 0000000..b276ccc --- /dev/null +++ b/frontend/src/views/About.vue @@ -0,0 +1,400 @@ + + + + + \ No newline at end of file diff --git a/frontend/src/views/Categories.vue b/frontend/src/views/Categories.vue new file mode 100644 index 0000000..6db372e --- /dev/null +++ b/frontend/src/views/Categories.vue @@ -0,0 +1,231 @@ + + + + + \ No newline at end of file diff --git a/frontend/src/views/Games.vue b/frontend/src/views/Games.vue new file mode 100644 index 0000000..4e08489 --- /dev/null +++ b/frontend/src/views/Games.vue @@ -0,0 +1,286 @@ + + + + + \ No newline at end of file diff --git a/frontend/src/views/Home.vue b/frontend/src/views/Home.vue new file mode 100644 index 0000000..73b1799 --- /dev/null +++ b/frontend/src/views/Home.vue @@ -0,0 +1,418 @@ + + + + + \ No newline at end of file diff --git a/frontend/src/views/admin/CategoryManagement.vue b/frontend/src/views/admin/CategoryManagement.vue new file mode 100644 index 0000000..9d05991 --- /dev/null +++ b/frontend/src/views/admin/CategoryManagement.vue @@ -0,0 +1,282 @@ + + + + + \ No newline at end of file diff --git a/frontend/src/views/admin/Dashboard.vue b/frontend/src/views/admin/Dashboard.vue new file mode 100644 index 0000000..ef09abf --- /dev/null +++ b/frontend/src/views/admin/Dashboard.vue @@ -0,0 +1,262 @@ + + + + + \ No newline at end of file diff --git a/frontend/src/views/admin/GameManagement.vue b/frontend/src/views/admin/GameManagement.vue new file mode 100644 index 0000000..7110ec7 --- /dev/null +++ b/frontend/src/views/admin/GameManagement.vue @@ -0,0 +1,535 @@ + + + + + \ No newline at end of file diff --git a/frontend/src/views/admin/Layout.vue b/frontend/src/views/admin/Layout.vue new file mode 100644 index 0000000..61f75e0 --- /dev/null +++ b/frontend/src/views/admin/Layout.vue @@ -0,0 +1,219 @@ + + + + + \ No newline at end of file diff --git a/frontend/src/views/admin/Login.vue b/frontend/src/views/admin/Login.vue new file mode 100644 index 0000000..34c169e --- /dev/null +++ b/frontend/src/views/admin/Login.vue @@ -0,0 +1,128 @@ + + + + + \ No newline at end of file diff --git a/frontend/src/views/admin/MediaManagement.vue b/frontend/src/views/admin/MediaManagement.vue new file mode 100644 index 0000000..025a681 --- /dev/null +++ b/frontend/src/views/admin/MediaManagement.vue @@ -0,0 +1,311 @@ + + + + + \ No newline at end of file diff --git a/frontend/src/views/admin/MessageManagement.vue b/frontend/src/views/admin/MessageManagement.vue new file mode 100644 index 0000000..e0cb16f --- /dev/null +++ b/frontend/src/views/admin/MessageManagement.vue @@ -0,0 +1,283 @@ + + + + + \ No newline at end of file diff --git a/frontend/src/views/admin/Register.vue b/frontend/src/views/admin/Register.vue new file mode 100644 index 0000000..33eba87 --- /dev/null +++ b/frontend/src/views/admin/Register.vue @@ -0,0 +1,160 @@ + + + + + \ No newline at end of file diff --git a/frontend/src/views/admin/SystemSettings.vue b/frontend/src/views/admin/SystemSettings.vue new file mode 100644 index 0000000..70dbb1d --- /dev/null +++ b/frontend/src/views/admin/SystemSettings.vue @@ -0,0 +1,198 @@ + + + + + \ No newline at end of file diff --git a/frontend/src/views/admin/TagManagement.vue b/frontend/src/views/admin/TagManagement.vue new file mode 100644 index 0000000..aca1a50 --- /dev/null +++ b/frontend/src/views/admin/TagManagement.vue @@ -0,0 +1,180 @@ + + + + + \ No newline at end of file diff --git a/frontend/src/views/admin/UserManagement.vue b/frontend/src/views/admin/UserManagement.vue new file mode 100644 index 0000000..647f8fd --- /dev/null +++ b/frontend/src/views/admin/UserManagement.vue @@ -0,0 +1,301 @@ + + + + + \ No newline at end of file