diff --git a/backend/routes/admin.js b/backend/routes/admin.js index 9640501..2733384 100644 --- a/backend/routes/admin.js +++ b/backend/routes/admin.js @@ -3,6 +3,7 @@ const router = express.Router(); const bcrypt = require('bcryptjs'); const jwt = require('jsonwebtoken'); const auth = require('../middleware/auth'); +const authSuper = require('../middleware/authSuper'); const { upload, getFileUrls } = require('../middleware/upload'); const { Admin, Activity, Registration, Expense, Photo, Sponsor, Prize } = require('../models'); const fs = require('fs'); @@ -17,7 +18,132 @@ router.post('/login', async (req, res) => { return res.status(401).json({ error: '用户名或密码错误' }); } const token = jwt.sign({ id: admin.id }, process.env.JWT_SECRET, { expiresIn: '7d' }); - res.json({ token, admin: { id: admin.id, username: admin.username, name: admin.name } }); + res.json({ + token, + admin: { + id: admin.id, + username: admin.username, + name: admin.name, + role: admin.role + } + }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +// ========== 管理员管理API(仅超级管理员) ========== + +// 获取管理员列表 +router.get('/admins', authSuper, async (req, res) => { + try { + const admins = await Admin.findAll({ + attributes: ['id', 'username', 'name', 'role', 'createdAt'], + order: [['createdAt', 'ASC']], + raw: true + }); + res.json(admins); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +// 创建管理员 +router.post('/admins', authSuper, async (req, res) => { + try { + const { username, password, name, role } = req.body; + + if (!username || !password) { + return res.status(400).json({ error: '用户名和密码不能为空' }); + } + + const existing = await Admin.findOne({ where: { username } }); + if (existing) { + return res.status(400).json({ error: '用户名已存在' }); + } + + const hashedPassword = bcrypt.hashSync(password, 10); + const admin = await Admin.create({ + username, + password: hashedPassword, + name: name || username, + role: role || 'admin' + }); + + res.json({ + id: admin.id, + username: admin.username, + name: admin.name, + role: admin.role + }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +// 修改管理员信息 +router.put('/admins/:id', authSuper, async (req, res) => { + try { + const { name, role } = req.body; + const admin = await Admin.findByPk(req.params.id); + + if (!admin) { + return res.status(404).json({ error: '管理员不存在' }); + } + + const updateData = {}; + if (name !== undefined) updateData.name = name; + if (role !== undefined) updateData.role = role; + + await Admin.update(updateData, { where: { id: req.params.id } }); + const updated = await Admin.findByPk(req.params.id, { + attributes: ['id', 'username', 'name', 'role'] + }); + + res.json(updated); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +// 修改管理员密码 +router.put('/admins/:id/password', authSuper, async (req, res) => { + try { + const { newPassword } = req.body; + + if (!newPassword) { + return res.status(400).json({ error: '新密码不能为空' }); + } + + const admin = await Admin.findByPk(req.params.id); + if (!admin) { + return res.status(404).json({ error: '管理员不存在' }); + } + + const hashedPassword = bcrypt.hashSync(newPassword, 10); + await Admin.update({ password: hashedPassword }, { where: { id: req.params.id } }); + + res.json({ message: '密码修改成功' }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +// 删除管理员 +router.delete('/admins/:id', authSuper, async (req, res) => { + try { + const admin = await Admin.findByPk(req.params.id); + if (!admin) { + return res.status(404).json({ error: '管理员不存在' }); + } + + // 防止删除超级管理员 + if (admin.role === 'super') { + return res.status(403).json({ error: '不能删除超级管理员' }); + } + + await Admin.destroy({ where: { id: req.params.id } }); + res.json({ message: '删除成功' }); } catch (error) { res.status(500).json({ error: error.message }); } @@ -93,6 +219,16 @@ router.get('/registrations', auth, async (req, res) => { } }); +// 删除报名 +router.delete('/registrations/:id', auth, async (req, res) => { + try { + await Registration.destroy({ where: { id: req.params.id } }); + res.json({ message: '删除成功' }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + // 上传经费明细 router.post('/expenses', auth, upload.array('media', 5), async (req, res) => { try { @@ -274,7 +410,7 @@ router.post('/sponsors', auth, upload.single('logo'), async (req, res) => { // 更新赞助商 router.put('/sponsors/:id', auth, upload.single('logo'), async (req, res) => { try { - const { name, type, link, sortOrder } = req.body; + const { name, type, link, sortOrder, removeLogo } = req.body; const updateData = { name, type, link }; if (sortOrder !== undefined) { @@ -284,6 +420,8 @@ router.put('/sponsors/:id', auth, upload.single('logo'), async (req, res) => { if (req.file) { const [uploaded] = getFileUrls(req.file); updateData.logo = uploaded.url; + } else if (removeLogo === 'true') { + updateData.logo = null; } await Sponsor.update(updateData, { where: { id: req.params.id } }); @@ -307,7 +445,7 @@ router.delete('/sponsors/:id', auth, async (req, res) => { // 添加奖品 router.post('/prizes', auth, upload.single('image'), async (req, res) => { try { - const { activityId, name, level, description, quantity, sortOrder } = req.body; + const { activityId, name, level, description, quantity, sortOrder, source, sponsor } = req.body; let image = null; if (req.file) { const [uploaded] = getFileUrls([req.file]); @@ -320,7 +458,9 @@ router.post('/prizes', auth, upload.single('image'), async (req, res) => { description, image, quantity: quantity || 1, - sortOrder: sortOrder || 0 + sortOrder: sortOrder || 0, + source: source || 'purchase', + sponsor: sponsor || null }); res.json(prize); } catch (error) { @@ -331,12 +471,14 @@ router.post('/prizes', auth, upload.single('image'), async (req, res) => { // 更新奖品 router.put('/prizes/:id', auth, upload.single('image'), async (req, res) => { try { - const { name, level, description, quantity, sortOrder } = req.body; - const updateData = { name, level, description, quantity, sortOrder }; + const { name, level, description, quantity, sortOrder, source, sponsor, removeImage } = req.body; + const updateData = { name, level, description, quantity, sortOrder, source, sponsor }; if (req.file) { const [uploaded] = getFileUrls([req.file]); updateData.image = uploaded.url; + } else if (removeImage === 'true') { + updateData.image = null; } await Prize.update(updateData, { where: { id: req.params.id } }); diff --git a/frontend/src/views/admin/ActivityManage.vue b/frontend/src/views/admin/ActivityManage.vue index 8e44e45..5de5729 100644 --- a/frontend/src/views/admin/ActivityManage.vue +++ b/frontend/src/views/admin/ActivityManage.vue @@ -138,7 +138,7 @@ -
+
@@ -178,7 +178,7 @@ -
+
@@ -214,7 +214,7 @@ -
+
@@ -732,6 +732,22 @@ required /> + + - {{ level }} + {{ level }} + + + + + + + +
+
+

选择来源

+ +
+
+
+ + 群友赞助 +
+
+ + 赞助商提供 +
+
+ + 采购
@@ -880,6 +920,16 @@ 通过 拒绝 + + 删除报名 +
@@ -935,6 +985,7 @@ const { prizes, showPrizeForm, showLevelPicker, + showSourcePicker, prizeImage, prizeForm, prizeLevels, @@ -942,6 +993,7 @@ const { editPrize, onPrizeImageRead, selectLevel, + selectSource, submitPrize, deletePrizeItem, resetPrizeForm @@ -1084,14 +1136,29 @@ const formatDateTime = (dateTime) => { return `${year}-${month}-${day} ${hours}:${minutes}`; }; +// 安全解析datetime-local格式,确保以本地时区创建Date对象 +const parseDateTimeLocal = (dateTimeStr) => { + console.log('[时区转换] 输入的datetime-local字符串:', dateTimeStr); + const [datePart, timePart] = dateTimeStr.split('T'); + const [year, month, day] = datePart.split('-').map(Number); + const [hours, minutes] = timePart.split(':').map(Number); + const localDate = new Date(year, month - 1, day, hours, minutes); + console.log('[时区转换] 创建的本地Date对象:', localDate.toString()); + console.log('[时区转换] 转换为UTC (ISO):', localDate.toISOString()); + console.log('[时区转换] 时区偏移(分钟):', localDate.getTimezoneOffset()); + return localDate; +}; + // 开始时间确认 const confirmStartTime = () => { if (!startTimeInput.value) { showToast('请选择开始时间'); return; } - const selectedDate = new Date(startTimeInput.value); + console.log('[开始时间] 用户选择的时间:', startTimeInput.value); + const selectedDate = parseDateTimeLocal(startTimeInput.value); activityForm.value.startTime = selectedDate.toISOString(); + console.log('[开始时间] 保存到表单的UTC时间:', activityForm.value.startTime); showStartTimePicker.value = false; }; @@ -1101,12 +1168,14 @@ const confirmDeadline = () => { showToast('请选择报名截止时间'); return; } - const selectedDate = new Date(deadlineInput.value); + console.log('[截止时间] 用户选择的时间:', deadlineInput.value); + const selectedDate = parseDateTimeLocal(deadlineInput.value); if (activityForm.value.startTime && selectedDate > new Date(activityForm.value.startTime)) { showToast('报名截止时间不能晚于活动开始时间'); return; } activityForm.value.registrationDeadline = selectedDate.toISOString(); + console.log('[截止时间] 保存到表单的UTC时间:', activityForm.value.registrationDeadline); showDeadlinePicker.value = false; }; @@ -1162,17 +1231,31 @@ const loadData = async () => { } }; +// 添加新奖品 +const addNewPrize = () => { + resetPrizeForm(); + showPrizeForm.value = true; +}; + const editActivity = () => { activityForm.value = { ...activity.value }; + console.log('[编辑活动] 原始活动数据:', activity.value); + // 设置日期时间选择器的值 if (activity.value.startTime) { + console.log('[编辑活动] 数据库的开始时间(UTC):', activity.value.startTime); const startDate = new Date(activity.value.startTime); + console.log('[编辑活动] 转换为本地Date对象:', startDate.toString()); startTimeInput.value = formatDateTimeLocal(startDate); + console.log('[编辑活动] 显示在选择器的时间:', startTimeInput.value); } if (activity.value.registrationDeadline) { + console.log('[编辑活动] 数据库的截止时间(UTC):', activity.value.registrationDeadline); const deadlineDate = new Date(activity.value.registrationDeadline); + console.log('[编辑活动] 转换为本地Date对象:', deadlineDate.toString()); deadlineInput.value = formatDateTimeLocal(deadlineDate); + console.log('[编辑活动] 显示在选择器的时间:', deadlineInput.value); } showActivityForm.value = true; @@ -1283,6 +1366,13 @@ const editExpense = (expense) => { showExpenseForm.value = true; }; +// 添加新经费 +const addNewExpense = () => { + expenseForm.value = { id: null, item: '', amount: '', category: '', note: '', media: [] }; + expenseMediaFiles.value = []; + showExpenseForm.value = true; +}; + const onExpenseMediaRead = (file) => { // 文件读取后的处理,Vant会自动处理 }; @@ -1344,6 +1434,16 @@ const submitSponsor = async () => { duration: 0 }); formData.append('logo', sponsorLogo.value[0].file); + } else if (sponsorLogo.value.length === 0 && sponsorForm.value.logo) { + // 用户删除了logo + formData.append('removeLogo', 'true'); + console.log('用户删除了 Logo'); + loadingToast = showToast({ + type: 'loading', + message: '正在保存...', + forbidClick: true, + duration: 0 + }); } else { console.log('没有 Logo,仅保存数据'); loadingToast = showToast({ @@ -1411,6 +1511,13 @@ const editSponsor = (sponsor) => { showSponsorForm.value = true; }; +// 添加新赞助商 +const addNewSponsor = () => { + sponsorForm.value = { id: null, name: '', type: 'sponsor', link: '', sortOrder: 0 }; + sponsorLogo.value = []; + showSponsorForm.value = true; +}; + const onLogoRead = (file) => { // 文件选择后的回调,显示文件已选择 if (file.file) { @@ -1515,6 +1622,24 @@ const approveFromDetail = async (status) => { } }; +// 删除报名 +const deleteRegistration = async () => { + try { + await showConfirmDialog({ + title: '确认删除', + message: '删除后用户可以重新报名,确定要删除这条报名信息吗?' + }); + await api.deleteRegistration(currentReg.value.id); + showToast('删除成功'); + showRegDetailDialog.value = false; + loadData(); + } catch (error) { + if (error !== 'cancel') { + showToast('删除失败'); + } + } +}; + // 媒体管理相关方法 const uploadPhotos = async (files) => { const fileArray = Array.isArray(files) ? files : [files]; diff --git a/frontend/src/views/admin/usePrizeManage.js b/frontend/src/views/admin/usePrizeManage.js index 57b744a..d6f3498 100644 --- a/frontend/src/views/admin/usePrizeManage.js +++ b/frontend/src/views/admin/usePrizeManage.js @@ -6,6 +6,7 @@ export function usePrizeManage(activityId) { const prizes = ref([]); const showPrizeForm = ref(false); const showLevelPicker = ref(false); + const showSourcePicker = ref(false); const prizeImage = ref([]); const prizeForm = ref({ id: null, @@ -15,10 +16,12 @@ export function usePrizeManage(activityId) { description: '', quantity: 1, sortOrder: 0, + source: 'purchase', + sponsor: '', image: null }); - const prizeLevels = ['一等奖', '二等奖', '三等奖', '参与奖', '伴手礼']; + const prizeLevels = ['特等奖', '一等奖', '二等奖', '三等奖', '四等奖', '参与奖', '伴手礼']; const loadPrizes = async () => { try { @@ -38,6 +41,8 @@ export function usePrizeManage(activityId) { description: prize.description || '', quantity: prize.quantity, sortOrder: prize.sortOrder || 0, + source: prize.source || 'purchase', + sponsor: prize.sponsor || '', image: prize.image }; // 回显图片 - 需要使用完整URL @@ -60,12 +65,25 @@ export function usePrizeManage(activityId) { showLevelPicker.value = false; }; + const selectSource = (source) => { + prizeForm.value.source = source; + if (source === 'purchase') { + prizeForm.value.sponsor = ''; + } + showSourcePicker.value = false; + }; + const submitPrize = async () => { - if (!prizeForm.value.name || !prizeForm.value.level) { + if (!prizeForm.value.name || !prizeForm.value.level || !prizeForm.value.source) { showToast('请填写必填项'); return; } + if (prizeForm.value.source !== 'purchase' && !prizeForm.value.sponsor) { + showToast('请填写赞助者名称'); + return; + } + const loadingToast = showToast({ type: 'loading', message: '保存中...', @@ -81,9 +99,16 @@ export function usePrizeManage(activityId) { formData.append('description', prizeForm.value.description); formData.append('quantity', prizeForm.value.quantity); formData.append('sortOrder', prizeForm.value.sortOrder); + formData.append('source', prizeForm.value.source); + if (prizeForm.value.sponsor) { + formData.append('sponsor', prizeForm.value.sponsor); + } if (prizeForm.value.imageFile) { formData.append('image', prizeForm.value.imageFile); + } else if (prizeImage.value.length === 0 && prizeForm.value.image) { + // 用户删除了图片 + formData.append('removeImage', 'true'); } if (prizeForm.value.id) { @@ -130,6 +155,8 @@ export function usePrizeManage(activityId) { description: '', quantity: 1, sortOrder: 0, + source: 'purchase', + sponsor: '', image: null }; prizeImage.value = []; @@ -139,6 +166,7 @@ export function usePrizeManage(activityId) { prizes, showPrizeForm, showLevelPicker, + showSourcePicker, prizeImage, prizeForm, prizeLevels, @@ -146,6 +174,7 @@ export function usePrizeManage(activityId) { editPrize, onPrizeImageRead, selectLevel, + selectSource, submitPrize, deletePrizeItem, resetPrizeForm