feat: 添加管理员管理和场地图片功能

This commit is contained in:
Yvan
2026-04-17 18:09:02 +08:00
parent 2530ceb6b4
commit fd49a9de88
53 changed files with 4225 additions and 1424 deletions
Binary file not shown.
+49
View File
@@ -0,0 +1,49 @@
const { Expense } = require('./models');
async function fixExpenseMedia() {
try {
console.log('开始修复经费媒体数据...');
const expenses = await Expense.findAll();
console.log(`找到 ${expenses.length} 条经费记录`);
let fixedCount = 0;
for (const expense of expenses) {
let needUpdate = false;
let media = expense.media || [];
// 检查是否有异常数据
if (Array.isArray(media) && media.length > 5) {
console.log(`经费 #${expense.id} 有 ${media.length} 个媒体文件,超过限制`);
media = media.slice(0, 5);
needUpdate = true;
}
// 检查是否有无效的媒体项
const validMedia = media.filter(item => {
return item && item.url && (item.type === 'image' || item.type === 'video');
});
if (validMedia.length !== media.length) {
console.log(`经费 #${expense.id} 有无效媒体项,从 ${media.length} 清理到 ${validMedia.length}`);
media = validMedia;
needUpdate = true;
}
if (needUpdate) {
await expense.update({ media });
fixedCount++;
console.log(`✓ 修复经费 #${expense.id}`);
}
}
console.log(`\n修复完成!共修复 ${fixedCount} 条记录`);
process.exit(0);
} catch (error) {
console.error('修复失败:', error);
process.exit(1);
}
}
fixExpenseMedia();
+50 -23
View File
@@ -5,39 +5,66 @@ const { Admin, sequelize } = require('./models');
async function initAdmin() {
try {
// 使用 force: false 避免 alter 表时的问题
// 如果表不存在则创建,存在则跳过
await sequelize.sync({ force: false });
// 检查是否已存在管理员账号
const existingAdmin = await Admin.findOne({ where: { username: 'admin' } });
// 检查role字段是否存在
const [results] = await sequelize.query("PRAGMA table_info(Admins);");
const hasRoleColumn = results.some(col => col.name === 'role');
if (existingAdmin) {
console.log('✓ 管理员账号已存在');
console.log(' 用户名: admin');
console.log(' 提示: 如需重置密码,请删除数据库文件后重新初始化');
process.exit(0);
if (!hasRoleColumn) {
console.log('⚠️ role字段不存在,请先运行迁移: node backend/migrations/index.js');
console.log(' 或运行: npm run migrate (在backend目录)');
process.exit(1);
return;
}
const hashedPassword = bcrypt.hashSync('admin123', 10);
await Admin.create({
username: 'admin',
password: hashedPassword,
name: '管理员'
});
// 检查yvan超级管理员
const yvan = await Admin.findOne({ where: { username: 'yvan' } });
if (!yvan) {
const hashedPassword = bcrypt.hashSync('Xu950329.', 10);
await Admin.create({
username: 'yvan',
password: hashedPassword,
name: 'Yvan',
role: 'super'
});
console.log('✓ 超级管理员yvan创建成功');
} else {
// 确保yvan是super角色
if (yvan.role !== 'super') {
await yvan.update({ role: 'super' });
console.log('✓ 超级管理员yvan角色已更新');
} else {
console.log('✓ 超级管理员yvan已存在');
}
}
console.log('✓ 管理员账号创建成功');
console.log(' 用户名: admin');
console.log(' 密码: admin123');
// 检查默认管理员
const admin = await Admin.findOne({ where: { username: 'admin' } });
if (!admin) {
const hashedPassword = bcrypt.hashSync('admin123', 10);
await Admin.create({
username: 'admin',
password: hashedPassword,
name: '管理员',
role: 'admin'
});
console.log('✓ 默认管理员admin创建成功');
} else {
// 确保admin是admin角色
if (!admin.role || admin.role === 'super') {
await admin.update({ role: 'admin' });
console.log('✓ 默认管理员admin角色已更新');
} else {
console.log('✓ 默认管理员admin已存在');
}
}
console.log('\n初始化完成!');
process.exit(0);
} catch (error) {
console.error('✗ 初始化失败:', error.message);
if (error.name === 'SequelizeUniqueConstraintError') {
console.error(' 原因: 管理员账号已存在');
} else {
console.error(' 详细错误:', error);
}
console.error(' 详细错误:', error);
process.exit(1);
}
}
+27
View File
@@ -0,0 +1,27 @@
const jwt = require('jsonwebtoken');
const { Admin } = require('../models');
const authSuper = async (req, res, next) => {
const token = req.header('Authorization')?.replace('Bearer ', '');
if (!token) {
return res.status(401).json({ error: '未授权访问' });
}
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
const admin = await Admin.findByPk(decoded.id);
if (!admin || admin.role !== 'super') {
return res.status(403).json({ error: '需要超级管理员权限' });
}
req.adminId = decoded.id;
req.admin = admin;
next();
} catch (error) {
res.status(401).json({ error: '无效的token' });
}
};
module.exports = authSuper;
@@ -0,0 +1,39 @@
module.exports = {
async up(manager) {
console.log(' - 添加奖品来源字段...');
// 添加source字段,默认值为purchase
await manager.executeSql(`
ALTER TABLE Prizes ADD COLUMN source TEXT DEFAULT 'purchase';
`);
// 添加sponsor字段
await manager.executeSql(`
ALTER TABLE Prizes ADD COLUMN sponsor TEXT;
`);
// 为现有数据设置默认值
await manager.executeSql(`
UPDATE Prizes SET source = 'purchase' WHERE source IS NULL;
`);
console.log(' - 奖品来源字段添加完成');
},
async down(manager) {
console.log(' - 移除奖品来源字段...');
// SQLite不支持DROP COLUMN,需要重建表
await manager.executeSql(`
CREATE TABLE Prizes_backup AS SELECT
id, activityId, name, level, description, image, quantity, sortOrder, createdAt, updatedAt
FROM Prizes;
`);
await manager.executeSql(`DROP TABLE Prizes;`);
await manager.executeSql(`ALTER TABLE Prizes_backup RENAME TO Prizes;`);
console.log(' - 奖品来源字段移除完成');
}
};
@@ -0,0 +1,74 @@
module.exports = {
async up(manager) {
console.log(' - 添加特等奖等级...');
// SQLite不支持直接修改CHECK约束,需要重建表
// 1. 创建新表结构(包含特等奖)
await manager.executeSql(`
CREATE TABLE Prizes_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
activityId INTEGER NOT NULL,
name TEXT NOT NULL,
level TEXT NOT NULL CHECK(level IN ('特等奖', '一等奖', '二等奖', '三等奖', '参与奖', '伴手礼')),
description TEXT,
image TEXT,
quantity INTEGER DEFAULT 1,
sortOrder INTEGER DEFAULT 0,
source TEXT DEFAULT 'purchase',
sponsor TEXT,
createdAt DATETIME NOT NULL,
updatedAt DATETIME NOT NULL
);
`);
// 2. 复制数据
await manager.executeSql(`
INSERT INTO Prizes_new
SELECT * FROM Prizes;
`);
// 3. 删除旧表
await manager.executeSql(`DROP TABLE Prizes;`);
// 4. 重命名新表
await manager.executeSql(`ALTER TABLE Prizes_new RENAME TO Prizes;`);
console.log(' - 特等奖等级添加完成');
},
async down(manager) {
console.log(' - 移除特等奖等级...');
// 1. 创建旧表结构(不包含特等奖)
await manager.executeSql(`
CREATE TABLE Prizes_old (
id INTEGER PRIMARY KEY AUTOINCREMENT,
activityId INTEGER NOT NULL,
name TEXT NOT NULL,
level TEXT NOT NULL CHECK(level IN ('一等奖', '二等奖', '三等奖', '参与奖', '伴手礼')),
description TEXT,
image TEXT,
quantity INTEGER DEFAULT 1,
sortOrder INTEGER DEFAULT 0,
source TEXT DEFAULT 'purchase',
sponsor TEXT,
createdAt DATETIME NOT NULL,
updatedAt DATETIME NOT NULL
);
`);
// 2. 复制数据(排除特等奖)
await manager.executeSql(`
INSERT INTO Prizes_old
SELECT * FROM Prizes WHERE level != '特等奖';
`);
// 3. 删除新表
await manager.executeSql(`DROP TABLE Prizes;`);
// 4. 重命名旧表
await manager.executeSql(`ALTER TABLE Prizes_old RENAME TO Prizes;`);
console.log(' - 特等奖等级移除完成');
}
};
+12
View File
@@ -0,0 +1,12 @@
// 添加管理员角色字段
module.exports = {
async up(manager) {
console.log(' - 添加 Admins.role 字段...');
await manager.executeSql('ALTER TABLE Admins ADD COLUMN role TEXT DEFAULT "admin";');
console.log(' - 角色字段添加完成');
},
async down(manager) {
await manager.executeSql('ALTER TABLE Admins DROP COLUMN role;');
}
};
@@ -0,0 +1,75 @@
// 添加四等奖等级
module.exports = {
async up(manager) {
console.log(' - 添加四等奖等级...');
// SQLite不支持直接修改CHECK约束,需要重建表
// 1. 创建新表结构(包含四等奖)
await manager.executeSql(`
CREATE TABLE Prizes_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
activityId INTEGER NOT NULL,
name TEXT NOT NULL,
level TEXT NOT NULL CHECK(level IN ('特等奖', '一等奖', '二等奖', '三等奖', '四等奖', '参与奖', '伴手礼')),
description TEXT,
image TEXT,
quantity INTEGER DEFAULT 1,
sortOrder INTEGER DEFAULT 0,
source TEXT DEFAULT 'purchase',
sponsor TEXT,
createdAt DATETIME NOT NULL,
updatedAt DATETIME NOT NULL
);
`);
// 2. 复制数据
await manager.executeSql(`
INSERT INTO Prizes_new
SELECT * FROM Prizes;
`);
// 3. 删除旧表
await manager.executeSql(`DROP TABLE Prizes;`);
// 4. 重命名新表
await manager.executeSql(`ALTER TABLE Prizes_new RENAME TO Prizes;`);
console.log(' - 四等奖等级添加完成');
},
async down(manager) {
console.log(' - 移除四等奖等级...');
// 1. 创建旧表结构(不包含四等奖)
await manager.executeSql(`
CREATE TABLE Prizes_old (
id INTEGER PRIMARY KEY AUTOINCREMENT,
activityId INTEGER NOT NULL,
name TEXT NOT NULL,
level TEXT NOT NULL CHECK(level IN ('特等奖', '一等奖', '二等奖', '三等奖', '参与奖', '伴手礼')),
description TEXT,
image TEXT,
quantity INTEGER DEFAULT 1,
sortOrder INTEGER DEFAULT 0,
source TEXT DEFAULT 'purchase',
sponsor TEXT,
createdAt DATETIME NOT NULL,
updatedAt DATETIME NOT NULL
);
`);
// 2. 复制数据(排除四等奖)
await manager.executeSql(`
INSERT INTO Prizes_old
SELECT * FROM Prizes WHERE level != '四等奖';
`);
// 3. 删除新表
await manager.executeSql(`DROP TABLE Prizes;`);
// 4. 重命名旧表
await manager.executeSql(`ALTER TABLE Prizes_old RENAME TO Prizes;`);
console.log(' - 四等奖等级移除完成');
}
};
@@ -0,0 +1,20 @@
/**
* 迁移 0009: 添加进群状态字段
* 为 Registrations 表添加 groupStatus 字段
*/
module.exports = {
async up(manager) {
console.log(' - 添加 Registrations.groupStatus 字段...');
await manager.executeSql(`
ALTER TABLE Registrations ADD COLUMN groupStatus INTEGER DEFAULT 0;
`);
},
async down(manager) {
console.log(' - 移除 Registrations.groupStatus 字段...');
await manager.executeSql(`
ALTER TABLE Registrations DROP COLUMN groupStatus;
`);
}
};
+6 -1
View File
@@ -16,7 +16,12 @@ const Admin = sequelize.define('Admin', {
type: DataTypes.STRING,
allowNull: false
},
name: DataTypes.STRING
name: DataTypes.STRING,
role: {
type: DataTypes.STRING,
allowNull: false,
defaultValue: 'admin'
}
});
module.exports = Admin;
+10 -1
View File
@@ -16,7 +16,7 @@ const Prize = sequelize.define('Prize', {
allowNull: false
},
level: {
type: DataTypes.ENUM('一等奖', '二等奖', '三等奖', '参与奖', '伴手礼'),
type: DataTypes.ENUM('特等奖', '一等奖', '二等奖', '三等奖', '四等奖', '参与奖', '伴手礼'),
allowNull: false
},
description: DataTypes.TEXT,
@@ -29,6 +29,15 @@ const Prize = sequelize.define('Prize', {
type: DataTypes.INTEGER,
defaultValue: 0,
comment: '排序值,数字越小越靠前'
},
source: {
type: DataTypes.ENUM('member', 'sponsor', 'purchase'),
defaultValue: 'purchase',
comment: '来源: member-群友赞助, sponsor-赞助商提供, purchase-采购'
},
sponsor: {
type: DataTypes.STRING,
comment: '赞助者名称(群友或赞助商)'
}
});
+5 -1
View File
@@ -34,7 +34,11 @@ const Registration = sequelize.define('Registration', {
type: DataTypes.BOOLEAN,
defaultValue: false
},
checkInTime: DataTypes.DATE
checkInTime: DataTypes.DATE,
groupStatus: {
type: DataTypes.BOOLEAN,
defaultValue: false
}
});
module.exports = Registration;
+25
View File
@@ -0,0 +1,25 @@
const { DataTypes } = require('sequelize');
const sequelize = require('./db');
const VenueImage = sequelize.define('VenueImage', {
id: {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true
},
activityId: {
type: DataTypes.INTEGER,
allowNull: false
},
image: {
type: DataTypes.STRING,
allowNull: false
},
sortOrder: {
type: DataTypes.INTEGER,
defaultValue: 0,
comment: '排序值,数字越小越靠前'
}
});
module.exports = VenueImage;
+3 -1
View File
@@ -6,6 +6,7 @@ const Photo = require('./Photo');
const Sponsor = require('./Sponsor');
const Prize = require('./Prize');
const Admin = require('./Admin');
const VenueImage = require('./VenueImage');
// 同步数据库
// 使用 force: false 避免 SQLite alter 表时的约束冲突问题
@@ -23,5 +24,6 @@ module.exports = {
Photo,
Sponsor,
Prize,
Admin
Admin,
VenueImage
};
+1102 -1
View File
File diff suppressed because it is too large Load Diff
+56 -10
View File
@@ -1,6 +1,6 @@
const express = require('express');
const router = express.Router();
const { Activity, Photo, Sponsor, Expense, Registration, Prize } = require('../models');
const { Activity, Photo, Sponsor, Expense, Registration, Prize, VenueImage } = require('../models');
const { upload, getFileUrls } = require('../middleware/upload');
// 获取活动列表
@@ -8,7 +8,11 @@ router.get('/activities', async (req, res) => {
try {
const { status } = req.query;
const where = status ? { status } : {};
const activities = await Activity.findAll({ where, order: [['startTime', 'DESC']] });
const activities = await Activity.findAll({
where,
order: [['startTime', 'DESC']],
raw: true
});
res.json(activities);
} catch (error) {
res.status(500).json({ error: error.message });
@@ -18,22 +22,42 @@ router.get('/activities', async (req, res) => {
// 获取活动详情
router.get('/activities/:id', async (req, res) => {
try {
const activity = await Activity.findByPk(req.params.id);
const activity = await Activity.findByPk(req.params.id, { raw: true });
if (!activity) {
return res.status(404).json({ error: '活动不存在' });
}
// 只返回已审核通过的照片
const photos = await Photo.findAll({
where: { activityId: req.params.id, status: 'approved' }
where: { activityId: req.params.id, status: 'approved' },
raw: true
});
const sponsors = await Sponsor.findAll({
where: { activityId: req.params.id },
order: [['sortOrder', 'ASC'], ['id', 'ASC']]
order: [['sortOrder', 'ASC'], ['id', 'ASC']],
raw: true
});
const expenses = await Expense.findAll({ where: { activityId: req.params.id } });
const expenses = await Expense.findAll({
where: { activityId: req.params.id },
raw: true
});
// 手动解析JSON字段
const parsedExpenses = expenses.map(expense => ({
...expense,
media: typeof expense.media === 'string' ? JSON.parse(expense.media || '[]') : (expense.media || [])
}));
const prizes = await Prize.findAll({
where: { activityId: req.params.id },
order: [['sortOrder', 'ASC'], ['id', 'ASC']]
order: [['sortOrder', 'ASC'], ['id', 'ASC']],
raw: true
});
const venueImages = await VenueImage.findAll({
where: { activityId: req.params.id },
order: [['sortOrder', 'ASC'], ['id', 'ASC']],
raw: true
});
res.json({ activity, photos, sponsors, expenses, prizes });
res.json({ activity, photos, sponsors, expenses: parsedExpenses, prizes, venueImages: venueImages.map(img => img.image).filter(Boolean) });
} catch (error) {
res.status(500).json({ error: error.message });
}
@@ -42,8 +66,30 @@ router.get('/activities/:id', async (req, res) => {
// 获取活动经费(公开接口)
router.get('/activities/:id/expenses', async (req, res) => {
try {
const expenses = await Expense.findAll({ where: { activityId: req.params.id } });
res.json(expenses);
const expenses = await Expense.findAll({
where: { activityId: req.params.id },
raw: true
});
// 手动解析JSON字段
const parsedExpenses = expenses.map(expense => ({
...expense,
media: typeof expense.media === 'string' ? JSON.parse(expense.media || '[]') : (expense.media || [])
}));
res.json(parsedExpenses);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// 获取场地图片列表
router.get('/activities/:id/venue-images', async (req, res) => {
try {
const images = await VenueImage.findAll({
where: { activityId: req.params.id },
order: [['sortOrder', 'ASC'], ['id', 'ASC']],
raw: true
});
res.json(images.map(img => img.image).filter(Boolean));
} catch (error) {
res.status(500).json({ error: error.message });
}
+44 -3
View File
@@ -32,6 +32,20 @@ router.post('/login', async (req, res) => {
}
});
// Token续期
router.post('/refresh-token', auth, async (req, res) => {
try {
const admin = await Admin.findByPk(req.adminId);
if (!admin) {
return res.status(401).json({ error: '用户不存在' });
}
const token = jwt.sign({ id: admin.id }, process.env.JWT_SECRET, { expiresIn: '7d' });
res.json({ token });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// ========== 管理员管理API(仅超级管理员) ==========
// 获取管理员列表
@@ -173,10 +187,12 @@ router.put('/activities/:id', auth, async (req, res) => {
// 审核报名
router.put('/registrations/:id', auth, async (req, res) => {
try {
const { status, peopleCount } = req.body;
const { status, peopleCount, carNumber, groupStatus } = req.body;
const updateData = {};
if (status) updateData.status = status;
if (peopleCount !== undefined) updateData.peopleCount = peopleCount;
if (carNumber !== undefined) updateData.carNumber = carNumber;
if (groupStatus !== undefined) updateData.groupStatus = groupStatus;
await Registration.update(updateData, { where: { id: req.params.id } });
const registration = await Registration.findByPk(req.params.id);
@@ -207,6 +223,26 @@ router.put('/registrations/:id/checkin', auth, async (req, res) => {
}
});
// 进群/取消进群
router.put('/registrations/:id/group', auth, async (req, res) => {
try {
const registration = await Registration.findByPk(req.params.id);
if (!registration) {
return res.status(404).json({ error: '报名信息不存在' });
}
const newStatus = !registration.groupStatus;
await Registration.update({
groupStatus: newStatus
}, { where: { id: req.params.id } });
const updated = await Registration.findByPk(req.params.id);
res.json(updated);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// 获取所有报名(含待审核)
router.get('/registrations', auth, async (req, res) => {
try {
@@ -264,8 +300,13 @@ router.get('/expenses', auth, async (req, res) => {
try {
const { activityId } = req.query;
const where = activityId ? { activityId } : {};
const expenses = await Expense.findAll({ where });
res.json(expenses);
const expenses = await Expense.findAll({ where, raw: true });
// 手动解析JSON字段
const parsedExpenses = expenses.map(expense => ({
...expense,
media: typeof expense.media === 'string' ? JSON.parse(expense.media || '[]') : (expense.media || [])
}));
res.json(parsedExpenses);
} catch (error) {
res.status(500).json({ error: error.message });
}
+4 -3
View File
@@ -16,7 +16,7 @@ router.post('/registrations', async (req, res) => {
activityId, name, phone, carModel, carNumber, note
});
res.json(registration);
res.json(registration.toJSON());
} catch (error) {
res.status(500).json({ error: error.message });
}
@@ -25,7 +25,7 @@ router.post('/registrations', async (req, res) => {
// 查询报名状态
router.get('/registrations/:id', async (req, res) => {
try {
const registration = await Registration.findByPk(req.params.id);
const registration = await Registration.findByPk(req.params.id, { raw: true });
res.json(registration);
} catch (error) {
res.status(500).json({ error: error.message });
@@ -36,7 +36,8 @@ router.get('/registrations/:id', async (req, res) => {
router.get('/activities/:id/registrations', async (req, res) => {
try {
const registrations = await Registration.findAll({
where: { activityId: req.params.id, status: 'approved' }
where: { activityId: req.params.id, status: 'approved' },
raw: true
});
res.json(registrations);
} catch (error) {
+66
View File
@@ -0,0 +1,66 @@
const express = require('express');
const router = express.Router();
const auth = require('../middleware/auth');
const { upload, getFileUrls } = require('../middleware/upload');
const { VenueImage } = require('../models');
// 获取场地图片列表
router.get('/venue-images', auth, async (req, res) => {
try {
const { activityId } = req.query;
const images = await VenueImage.findAll({
where: { activityId },
order: [['sortOrder', 'ASC'], ['id', 'ASC']],
raw: true
});
res.json(images);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// 上传场地图片
router.post('/venue-images', auth, upload.single('image'), async (req, res) => {
try {
const { activityId, sortOrder } = req.body;
if (!req.file) {
return res.status(400).json({ error: '请选择图片' });
}
const [uploaded] = getFileUrls([req.file]);
const image = await VenueImage.create({
activityId,
image: uploaded.url,
sortOrder: sortOrder || 0
});
res.json(image);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// 更新场地图片排序
router.put('/venue-images/:id', auth, async (req, res) => {
try {
const { sortOrder } = req.body;
await VenueImage.update({ sortOrder }, { where: { id: req.params.id } });
const image = await VenueImage.findByPk(req.params.id);
res.json(image);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// 删除场地图片
router.delete('/venue-images/:id', auth, async (req, res) => {
try {
await VenueImage.destroy({ where: { id: req.params.id } });
res.json({ message: '删除成功' });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
module.exports = router;
+1
View File
@@ -16,6 +16,7 @@ app.use('/uploads', express.static(path.join(__dirname, 'uploads')));
app.use('/api', require('./routes/activity'));
app.use('/api', require('./routes/registration'));
app.use('/api/admin', require('./routes/admin'));
app.use('/api/admin', require('./routes/venue'));
// 生产环境下提供前端静态文件
if (process.env.NODE_ENV === 'production') {