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.

After

Width:  |  Height:  |  Size: 428 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 331 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 401 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 162 KiB

+95 -3
View File
@@ -5,10 +5,74 @@ const api = axios.create({
timeout: 10000
});
api.interceptors.request.use(config => {
// Token续期状态管理
let isRefreshing = false;
let refreshSubscribers = [];
// 订阅token续期完成事件
const subscribeTokenRefresh = (cb) => {
refreshSubscribers.push(cb);
};
// 通知所有订阅者token已更新
const onTokenRefreshed = (token) => {
refreshSubscribers.forEach(cb => cb(token));
refreshSubscribers = [];
};
// 检查token是否即将过期(剩余时间小于1天)
const shouldRefreshToken = (token) => {
if (!token) return false;
try {
const payload = JSON.parse(atob(token.split('.')[1]));
const exp = payload.exp * 1000;
const now = Date.now();
const oneDay = 24 * 60 * 60 * 1000;
return (exp - now) < oneDay && (exp - now) > 0;
} catch {
return false;
}
};
// 续期token
const refreshToken = async () => {
try {
const { data } = await api.post('/admin/refresh-token', {}, {
_skipRefresh: true // 标记跳过续期检查
});
localStorage.setItem('token', data.token);
return data.token;
} catch (error) {
console.error('Token续期失败:', error);
return null;
}
};
api.interceptors.request.use(async config => {
const token = localStorage.getItem('token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
// 跳过续期请求本身的续期检查
if (!config._skipRefresh && shouldRefreshToken(token)) {
if (!isRefreshing) {
isRefreshing = true;
const newToken = await refreshToken();
isRefreshing = false;
if (newToken) {
config.headers.Authorization = `Bearer ${newToken}`;
onTokenRefreshed(newToken);
}
} else {
// 等待续期完成
return new Promise(resolve => {
subscribeTokenRefresh(newToken => {
config.headers.Authorization = `Bearer ${newToken}`;
resolve(config);
});
});
}
}
}
return config;
});
@@ -17,7 +81,15 @@ api.interceptors.request.use(config => {
api.interceptors.response.use(
response => response,
error => {
// 统一错误处理
// 401错误:token过期或无效,跳转登录页
if (error.response?.status === 401) {
localStorage.removeItem('token');
localStorage.removeItem('admin');
// 避免在登录页重复跳转
if (!window.location.pathname.includes('/admin/login')) {
window.location.href = '/admin/login';
}
}
console.error('API请求错误:', error);
return Promise.reject(error);
}
@@ -40,6 +112,7 @@ export default {
// 活动相关
getActivities: (params) => api.get('/activities', { params }),
getActivity: (id) => api.get(`/activities/${id}`),
getVenueImages: (id) => api.get(`/activities/${id}/venue-images`),
// 报名相关
submitRegistration: (data) => api.post('/registrations', data),
@@ -57,11 +130,14 @@ export default {
// 管理员相关
adminLogin: (data) => api.post('/admin/login', data),
refreshToken: () => api.post('/admin/refresh-token'),
createActivity: (data) => api.post('/admin/activities', data),
updateActivity: (id, data) => api.put(`/admin/activities/${id}`, data),
approveRegistration: (id, status) => api.put(`/admin/registrations/${id}`, { status }),
updateRegistration: (id, data) => api.put(`/admin/registrations/${id}`, data),
checkInRegistration: (id) => api.put(`/admin/registrations/${id}/checkin`),
groupRegistration: (id) => api.put(`/admin/registrations/${id}/group`),
deleteRegistration: (id) => api.delete(`/admin/registrations/${id}`),
getAllRegistrations: (params) => api.get('/admin/registrations', { params }),
addExpense: (data) => api.post('/admin/expenses', data, {
headers: { 'Content-Type': 'multipart/form-data' },
@@ -97,5 +173,21 @@ export default {
headers: { 'Content-Type': 'multipart/form-data' },
timeout: 60000
}),
deletePrize: (id) => api.delete(`/admin/prizes/${id}`)
deletePrize: (id) => api.delete(`/admin/prizes/${id}`),
// 管理员账号管理(仅超级管理员)
getAdmins: () => api.get('/admin/admins'),
createAdmin: (data) => api.post('/admin/admins', data),
updateAdmin: (id, data) => api.put(`/admin/admins/${id}`, data),
updateAdminPassword: (id, data) => api.put(`/admin/admins/${id}/password`, data),
deleteAdmin: (id) => api.delete(`/admin/admins/${id}`),
// 场地图片管理
getVenueImages: (activityId) => api.get('/admin/venue-images', { params: { activityId } }),
uploadVenueImage: (data) => api.post('/admin/venue-images', data, {
headers: { 'Content-Type': 'multipart/form-data' },
timeout: 60000
}),
deleteVenueImage: (id) => api.delete(`/admin/venue-images/${id}`),
updateVenueImageOrder: (id, sortOrder) => api.put(`/admin/venue-images/${id}`, { sortOrder })
};
+13
View File
@@ -47,6 +47,12 @@ const routes = [
name: 'ActivityManage',
component: () => import('../views/admin/ActivityManage.vue'),
meta: { requiresAuth: true }
},
{
path: '/admin/admins',
name: 'AdminManage',
component: () => import('../views/admin/AdminManage.vue'),
meta: { requiresAuth: true, requiresSuper: true }
}
];
@@ -58,6 +64,13 @@ const router = createRouter({
router.beforeEach((to, from, next) => {
if (to.meta.requiresAuth && !localStorage.getItem('token')) {
next('/admin/login');
} else if (to.meta.requiresSuper) {
const admin = JSON.parse(localStorage.getItem('admin') || '{}');
if (admin.role !== 'super') {
next('/admin');
} else {
next();
}
} else {
next();
}
@@ -1,28 +1,77 @@
<template>
<div class="sponsor-danmaku">
<div class="danmaku-item">
<div class="danmaku-card">
<div class="danmaku-avatar">
<img src="/avatars/wang.png" alt="王总" @error="handleImageError" />
</div>
<div class="danmaku-content">
<span class="thank-text">感谢</span>
<span class="sponsor-name">王总</span>
<span class="sponsor-action">赞助的</span>
<span class="sponsor-item">小米商城价值3990积分的物料</span>
<div class="sponsor-danmaku" v-if="memberPrizes.length > 0">
<transition name="danmaku-fade">
<div
v-if="currentIndex >= 0 && currentIndex < memberPrizes.length"
:key="memberPrizes[currentIndex].id"
class="danmaku-item"
>
<div class="danmaku-card">
<div class="danmaku-avatar">
<span class="avatar-placeholder">{{ memberPrizes[currentIndex].sponsor.charAt(0) }}</span>
</div>
<div class="danmaku-content">
<span class="thank-text">感谢</span>
<span class="sponsor-name">{{ memberPrizes[currentIndex].sponsor }}</span>
<span class="sponsor-action">赞助的</span>
<span class="sponsor-item">{{ memberPrizes[currentIndex].name }}</span>
</div>
</div>
</div>
</div>
</transition>
</div>
</template>
<script setup>
// 单条固定弹幕,无需额外逻辑
const handleImageError = (e) => {
// 如果图片加载失败,显示文字占位
e.target.style.display = 'none';
e.target.parentElement.innerHTML = '<span class="avatar-placeholder">王</span>';
import { ref, computed, onMounted, onUnmounted, watch } from 'vue';
const props = defineProps({
prizes: {
type: Array,
default: () => []
}
});
const memberPrizes = computed(() => {
return props.prizes.filter(p => p.source === 'member' && p.sponsor);
});
const currentIndex = ref(-1);
let timer = null;
const startDanmaku = () => {
if (memberPrizes.value.length === 0) return;
currentIndex.value = 0;
timer = setInterval(() => {
currentIndex.value = (currentIndex.value + 1) % memberPrizes.value.length;
}, 15000); // 每15秒切换一条
};
const stopDanmaku = () => {
if (timer) {
clearInterval(timer);
timer = null;
}
};
watch(() => memberPrizes.value.length, (newLen) => {
stopDanmaku();
if (newLen > 0) {
startDanmaku();
}
});
onMounted(() => {
if (memberPrizes.value.length > 0) {
startDanmaku();
}
});
onUnmounted(() => {
stopDanmaku();
});
</script>
<style scoped>
@@ -41,10 +90,19 @@ const handleImageError = (e) => {
position: absolute;
top: 10px;
right: -100%;
animation: danmaku-move 15s linear infinite;
will-change: transform;
pointer-events: auto;
filter: drop-shadow(0 8px 30px rgba(212, 175, 55, 0.6));
animation: danmaku-move 15s linear forwards;
}
.danmaku-fade-enter-active {
animation: danmaku-move 15s linear forwards;
}
.danmaku-fade-leave-active {
opacity: 0;
transition: opacity 0.5s;
}
.danmaku-item:hover {
@@ -128,6 +186,9 @@ const handleImageError = (e) => {
0 0 15px rgba(212, 175, 55, 0.8),
inset 0 0 10px rgba(0, 0, 0, 0.3);
position: relative;
display: flex;
align-items: center;
justify-content: center;
}
.danmaku-avatar::after {
@@ -139,6 +200,7 @@ const handleImageError = (e) => {
height: 200%;
background: linear-gradient(45deg, transparent 30%, rgba(255, 255, 255, 0.3) 50%, transparent 70%);
animation: avatar-shine 4s infinite;
pointer-events: none;
}
@keyframes avatar-shine {
@@ -150,21 +212,10 @@ const handleImageError = (e) => {
}
}
.danmaku-avatar img {
width: 100%;
height: 100%;
object-fit: cover;
position: relative;
z-index: 1;
}
.avatar-placeholder {
font-size: 20px;
font-weight: bold;
background: linear-gradient(135deg, #d4af37 0%, #f4e5a1 50%, #d4af37 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
color: #1a1a1a;
position: relative;
z-index: 1;
}
@@ -214,6 +265,10 @@ const handleImageError = (e) => {
0 0 15px rgba(212, 175, 55, 0.6),
inset 0 1px 0 rgba(255, 255, 255, 0.5);
letter-spacing: 0.5px;
max-width: 300px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
@media (max-width: 768px) {
@@ -240,7 +295,6 @@ const handleImageError = (e) => {
.avatar-placeholder {
font-size: 16px;
}
.danmaku-content {
font-size: 13px;
gap: 6px;
@@ -261,6 +315,6 @@ const handleImageError = (e) => {
.sponsor-item {
font-size: 12px;
padding: 3px 10px;
max-width: 200px;
}
}
</style>
}</style>
+74 -65
View File
@@ -49,7 +49,7 @@
</div>
<!-- 赞助商弹幕 -->
<SponsorDanmaku v-if="activity.danmakuEnabled" />
<SponsorDanmaku v-if="activity.danmakuEnabled" :prizes="prizes" />
<!-- 活动流程 -->
<div class="section">
@@ -106,7 +106,6 @@
<div class="step-item">🎯 签到墙合照 - 打卡留念</div>
<div class="step-item">🏛️ 地标合影 - 记录活动地点</div>
<div class="step-item">🎬 动态全员上车 - 拍摄车队启动瞬间</div>
<div class="step-item">🔥 点火仪式 - 引擎轰鸣</div>
<div class="step-item">✨ 拍摄"泉州"字样 - 城市印记</div>
</div>
<div class="tip-box">
@@ -131,7 +130,7 @@
<div class="detail-content">
<div class="game-list">
<div
v-for="(game, index) in timelineData[2].items"
v-for="(game, index) in gameItems"
:key="'game' + index"
class="game-card"
@click.stop="toggleGame('game' + index)"
@@ -170,9 +169,6 @@
<div class="step-item">🍖 边烤肉边欣赏晚霞</div>
<div class="step-item">🎵 轻松音乐伴随美食时光</div>
</div>
<div class="tip-box">
💡 提示:分享美食照片到朋友圈并@活动官方账号可获得 <span class="score-badge">+1分</span>
</div>
</div>
</div>
</div>
@@ -183,11 +179,11 @@
<div class="timeline-dot"></div>
<div class="timeline-content" @click="toggleTimeline(4)" :class="{ expanded: expandedTimelineIndex === 4 }">
<div class="timeline-time">
<span class="time-text">19:00 - 20:00</span>
<span class="time-text">19:00 - 21:00</span>
<van-icon :name="expandedTimelineIndex === 4 ? 'arrow-up' : 'arrow-down'" class="expand-icon" />
</div>
<div class="timeline-title">篝火歌舞会</div>
<div class="timeline-brief">5个精彩互动游戏,点燃现场欢乐气氛</div>
<div class="timeline-brief">互动游戏、璀璨烟花、积分兑奖,欢乐不停歇</div>
<div class="timeline-detail" :class="{ expanded: expandedTimelineIndex === 4 }">
<div class="detail-content">
<div class="game-list">
@@ -229,8 +225,6 @@
<div class="game-detail" :class="{ expanded: expandedGameId === 'bonfire3' }">
<div class="game-desc">
<strong>玩法:</strong>全员参与,齐唱经典歌曲<br>
<strong>曲目:</strong>《朋友》《真心英雄》等<br>
<strong>得分:</strong>参与即可获得+1分<br>
<strong>氛围:</strong>点燃现场,凝聚团队
</div>
</div>
@@ -245,7 +239,6 @@
<div class="game-desc">
<strong>玩法:</strong>自愿上台展示才艺(唱歌/跳舞/表演)<br>
<strong>准备:</strong>需提前到后台准备BGM<br>
<strong>得分:</strong>参与表演+3分,精彩表演+5分<br>
<strong>鼓励:</strong>展现自我,收获掌声!
</div>
</div>
@@ -261,7 +254,41 @@
<strong>玩法:</strong>主持人邀请小朋友上台互动<br>
<strong>内容:</strong>简单游戏/才艺展示/问答<br>
<strong>礼品:</strong>参与即有小礼物<br>
<strong>得分:</strong>家长陪同参与+2分
</div>
</div>
</div>
<div class="game-card" @click.stop="toggleGame('bonfire6')" :class="{ expanded: expandedGameId === 'bonfire6' }">
<div class="game-header">
<div class="game-title">🎆 璀璨烟火秀</div>
<van-icon :name="expandedGameId === 'bonfire6' ? 'arrow-up' : 'arrow-down'" class="expand-icon" />
</div>
<div class="game-detail" :class="{ expanded: expandedGameId === 'bonfire6' }">
<div class="game-desc">
<strong>环节:</strong>三重烟花盛宴,点亮夜空绽放精彩<br>
<strong>米兰之夜:</strong>大型专业烟火表演,绚丽夺目<br>
<strong>仙女棒:</strong>每车配发手持烟花,拍照打卡<br>
<strong>加特林压轴:</strong>震撼收尾,全场欢呼<br>
</div>
</div>
</div>
<div class="game-card" @click.stop="toggleGame('bonfire7')" :class="{ expanded: expandedGameId === 'bonfire7' }">
<div class="game-header">
<div class="game-title">🏆 积分兑换礼品</div>
<van-icon :name="expandedGameId === 'bonfire7' ? 'arrow-up' : 'arrow-down'" class="expand-icon" />
</div>
<div class="game-detail" :class="{ expanded: expandedGameId === 'bonfire7' }">
<div class="game-desc">
<strong>奖项设置:</strong><br>
🥇 一等奖 - 神秘大奖等你拿<br>
🥈 二等奖 - 精美礼品超值享<br>
🥉 三等奖 - 实用好物带回家<br>
🎖️ 四等奖 - 纪念礼品留回忆<br>
🎊 参与奖 - 人人有份不落空<br>
<br>
✨ 积分越高奖品越丰厚,还可能有惊喜等着你!<br>
📍 凭积分卡到兑奖处领取对应礼品
</div>
</div>
</div>
@@ -271,59 +298,23 @@
</div>
</div>
<!-- 璀璨烟火秀 -->
<div class="timeline-item">
<!-- 自由交流 & 返程 -->
<div class="timeline-item timeline-item-last">
<div class="timeline-dot"></div>
<div class="timeline-content" @click="toggleTimeline(5)" :class="{ expanded: expandedTimelineIndex === 5 }">
<div class="timeline-time">
<span class="time-text">20:00 - 20:30</span>
<span class="time-text">21:00 以后</span>
<van-icon :name="expandedTimelineIndex === 5 ? 'arrow-up' : 'arrow-down'" class="expand-icon" />
</div>
<div class="timeline-title">璀璨烟火秀</div>
<div class="timeline-brief">三重烟花盛宴,点亮夜空绽放精彩</div>
<div class="timeline-title">自由交流 & 返程</div>
<div class="timeline-brief">自由合影留念,整理现场,安全返程</div>
<div class="timeline-detail" :class="{ expanded: expandedTimelineIndex === 5 }">
<div class="detail-content">
<div class="flow-steps">
<div class="step-item">🎆 米兰之夜 - 大型专业烟火表演,绚丽夺目</div>
<div class="step-item">✨ 仙女棒 - 每车配发手持烟花,拍照打卡</div>
<div class="step-item">🔥 加特林压轴 - 震撼收尾,全场欢呼</div>
<div class="step-item">📸 最佳拍摄时机 - 记录璀璨瞬间</div>
</div>
<div class="tip-box">
💡 提示:拍摄烟花视频并分享可获得 <span class="score-badge">+2分</span>
</div>
</div>
</div>
</div>
</div>
<!-- 积分兑奖 & 自由交流 -->
<div class="timeline-item timeline-item-last">
<div class="timeline-dot"></div>
<div class="timeline-content" @click="toggleTimeline(6)" :class="{ expanded: expandedTimelineIndex === 6 }">
<div class="timeline-time">
<span class="time-text">20:30 以后</span>
<van-icon :name="expandedTimelineIndex === 6 ? 'arrow-up' : 'arrow-down'" class="expand-icon" />
</div>
<div class="timeline-title">积分兑奖 & 自由交流</div>
<div class="timeline-brief">凭积分兑换精美礼品,自由合影欢乐交流</div>
<div class="timeline-detail" :class="{ expanded: expandedTimelineIndex === 6 }">
<div class="detail-content">
<div class="game-rule">
<div class="rule-title">🏆 积分兑换礼品</div>
<div class="rule-content">
<strong>积分排行榜:</strong><br>
• 第1名(20分以上):神秘大奖 🎁<br>
• 第2-3名(15-19分):精美礼品 🎀<br>
• 第4-10名(10-14分):纪念品 🎈<br>
• 参与奖(5-9分):小礼物 🎊<br>
<br>
<strong>活动收尾:</strong><br>
• 自由合影留念<br>
• 整理现场垃圾<br>
• 提醒安全驾驶返程<br>
• 期待下次相聚
</div>
<div class="step-item">📸 自由合影留念</div>
<div class="step-item">🧹 整理现场垃圾</div>
<div class="step-item">🚗 提醒安全驾驶返程</div>
<div class="step-item">👋 期待下次相聚</div>
</div>
</div>
</div>
@@ -332,6 +323,18 @@
</div>
</div>
<!-- 场地展示 -->
<div v-if="venueImages.length" class="section">
<h3 class="section-title">场地展示</h3>
<van-swipe class="venue-swipe" :autoplay="3000" indicator-color="white">
<van-swipe-item v-for="(image, index) in venueImages" :key="index">
<div class="venue-image" @click="previewImage(getImageUrl(image))">
<img :src="getImageUrl(image)" :alt="`场地图片${index + 1}`" />
</div>
</van-swipe-item>
</van-swipe>
</div>
<!-- 活动经费 -->
<div v-if="expenses.length && isApproved" class="section">
<h3 class="section-title">活动经费 <span class="count">¥{{ totalExpense }}</span></h3>
@@ -391,6 +394,12 @@
<div class="prize-header">
<span class="prize-level" :class="`level-${prize.level}`">{{ prize.level }}</span>
<span class="prize-name">{{ prize.name }}</span>
<van-icon
v-if="prize.sponsor"
name="info-o"
class="prize-sponsor-icon"
@click="showSponsorInfo(prize)"
/>
</div>
<div v-if="prize.description" class="prize-desc">{{ prize.description }}</div>
<div class="prize-footer">
@@ -447,14 +456,10 @@
<!-- 报名名单 -->
<div class="section">
<h3 class="section-title">报名名单 <span class="count">({{ registrations.length }}人)</span></h3>
<h3 class="section-title">报名名单 <span class="count">({{ totalPeople }}人)</span></h3>
<div class="member-list">
<div v-for="reg in registrations" :key="reg.id" class="member-item">
<div class="member-avatar">{{ reg.name.charAt(0) }}</div>
<div class="member-info">
<div class="member-name">{{ reg.name }}</div>
<div class="member-car">{{ reg.carModel }}</div>
</div>
</div>
</div>
</div>
@@ -549,6 +554,7 @@
maxlength="11"
/>
<van-field
v-model="loginForm.carNumber"
label="车牌号"
>
<template #input>
@@ -587,11 +593,11 @@
<script setup>
import { useActivityDetail } from './useActivityDetail';
import SponsorDanmaku from './SponsorDanmaku.vue';
import { timelineData } from '../timeline-data.js';
import { gameItems } from '../timeline-data.js';
// 获取完整图片URL的辅助函数
const getImageUrl = (path) => {
if (!path) return '';
if (!path || typeof path !== 'string') return '';
if (path.startsWith('http://') || path.startsWith('https://')) {
return path;
}
@@ -606,6 +612,7 @@ const {
prizes,
registrations,
expenses,
venueImages,
userMedia,
showMapPopup,
showLoginDialog,
@@ -618,6 +625,7 @@ const {
expandedTimelineIndex,
expandedGameId,
totalExpense,
totalPeople,
isApproved,
hasRegistered,
formatDate,
@@ -631,7 +639,8 @@ const {
submitLogin,
handleShare,
toggleTimeline,
toggleGame
toggleGame,
showSponsorInfo
} = useActivityDetail();
</script>
File diff suppressed because it is too large Load Diff
@@ -1,6 +1,6 @@
import { ref, computed, onMounted } from 'vue';
import { useRoute } from 'vue-router';
import { showImagePreview, showToast } from 'vant';
import { showImagePreview, showToast, showDialog } from 'vant';
import api from '../../api';
export function useActivityDetail() {
@@ -12,6 +12,7 @@ export function useActivityDetail() {
const prizes = ref([]);
const registrations = ref([]);
const expenses = ref([]);
const venueImages = ref([]);
const userMedia = ref([]);
const showMapPopup = ref(false);
const showLoginDialog = ref(false);
@@ -23,6 +24,8 @@ export function useActivityDetail() {
const carLetter = ref('');
const carDigits = ref('');
const userRegistration = ref(null);
const showPrizeSourcePopup = ref(false);
const currentPrize = ref(null);
let map = null;
const handleLetterInput = (e) => {
@@ -54,6 +57,12 @@ export function useActivityDetail() {
return expenses.value.reduce((sum, e) => sum + parseFloat(e.amount), 0).toFixed(2);
});
const totalPeople = computed(() => {
return registrations.value
.filter(r => r.status === 'approved')
.reduce((sum, r) => sum + (r.peopleCount || 1), 0);
});
const isApproved = computed(() => {
return userRegistration.value && userRegistration.value.status === 'approved';
});
@@ -62,6 +71,41 @@ export function useActivityDetail() {
return userRegistration.value !== null;
});
const prizesByLevel = computed(() => {
const levels = ['特等奖', '一等奖', '二等奖', '三等奖', '四等奖', '参与奖', '伴手礼'];
return levels.map(level => ({
level,
items: prizes.value.filter(p => p.level === level).sort((a, b) => a.sortOrder - b.sortOrder)
})).filter(group => group.items.length > 0);
});
const showPrizeSource = (prize) => {
if (prize.source === 'purchase') return;
currentPrize.value = prize;
showPrizeSourcePopup.value = true;
};
const showSponsorInfo = (prize) => {
if (!prize.sponsor) return;
let sourceText = '';
if (prize.source === 'member') {
sourceText = '群友赞助';
} else if (prize.source === 'sponsor') {
sourceText = '赞助商提供';
} else {
sourceText = '礼品来源';
}
showDialog({
title: sourceText,
message: prize.sponsor,
confirmButtonText: '知道了',
confirmButtonColor: '#FF6700',
className: 'sponsor-dialog'
});
};
const loadData = async () => {
const { data } = await api.getActivity(route.params.id);
activity.value = data.activity;
@@ -73,18 +117,54 @@ export function useActivityDetail() {
sponsors.value = data.sponsors;
prizes.value = data.prizes || [];
expenses.value = data.expenses || [];
venueImages.value = data.venueImages || [];
const { data: regs } = await api.getActivityRegistrations(route.params.id);
registrations.value = regs;
// 检查本地存储的报名信息
const savedReg = localStorage.getItem(`user_reg_${route.params.id}`);
if (savedReg) {
userRegistration.value = JSON.parse(savedReg);
const regData = JSON.parse(savedReg);
// 验证报名是否还存在
try {
const { data: verifyData } = await api.getRegistration(regData.id);
userRegistration.value = verifyData;
// 更新本地存储
localStorage.setItem(`user_reg_${route.params.id}`, JSON.stringify(verifyData));
} catch (error) {
// 报名不存在,清理本地存储
if (error.response?.status === 404) {
localStorage.removeItem(`user_reg_${route.params.id}`);
userRegistration.value = null;
} else {
// 其他错误,保留本地数据
userRegistration.value = regData;
}
}
}
};
const formatDate = (date) => {
return new Date(date).toLocaleString('zh-CN');
if (!date) return '';
// 处理带时区的时间格式 "2026-04-19 06:00:00.000 +00:00"
let dateStr = date;
if (typeof date === 'string') {
// 移除时区部分,只保留日期时间
dateStr = date.replace(/\s*\+\d{2}:\d{2}$/, '').replace(/\.\d{3}/, '');
}
const d = new Date(dateStr);
if (isNaN(d.getTime())) return '';
return d.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
};
const previewImage = (url) => {
@@ -188,7 +268,14 @@ export function useActivityDetail() {
showToast('您的报名未通过审核');
}
} catch (error) {
showToast(error.response?.data?.error || '验证失败');
// 如果报名不存在(被删除),清理本地存储
if (error.response?.status === 404 || error.response?.data?.error?.includes('不存在')) {
localStorage.removeItem(`user_reg_${route.params.id}`);
userRegistration.value = null;
showToast('报名信息不存在,请重新报名');
} else {
showToast(error.response?.data?.error || '验证失败');
}
}
};
@@ -224,13 +311,17 @@ export function useActivityDetail() {
videos,
sponsors,
prizes,
prizesByLevel,
registrations,
expenses,
venueImages,
userMedia,
showMapPopup,
showLoginDialog,
showExpenseVideoPopup,
showPrizeSourcePopup,
currentExpenseVideo,
currentPrize,
loginForm,
carLetter,
carDigits,
@@ -238,6 +329,7 @@ export function useActivityDetail() {
expandedTimelineIndex,
expandedGameId,
totalExpense,
totalPeople,
isApproved,
hasRegistered,
formatDate,
@@ -251,6 +343,8 @@ export function useActivityDetail() {
submitLogin,
handleShare,
toggleTimeline,
toggleGame
toggleGame,
showPrizeSource,
showSponsorInfo
};
}
+2 -19
View File
@@ -136,7 +136,7 @@
<div class="sponsor-logo xiaomi-logo">
<svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
<rect x="10" y="10" width="80" height="80" rx="20" fill="#FF6700"/>
<text x="50" y="65" font-family="Arial, sans-serif" font-size="40" font-weight="bold" fill="white" text-anchor="middle">MI</text>
<text x="50" y="58" font-family="Microsoft YaHei, Arial, sans-serif" font-size="24" font-weight="bold" fill="white" text-anchor="middle">洛江店</text>
</svg>
</div>
<div class="sponsor-info">
@@ -145,24 +145,7 @@
</div>
</div>
<div class="sponsor-card">
<div class="sponsor-logo europark-logo">
<svg viewBox="0 0 200 80" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="europark-gradient" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#4A90E2;stop-opacity:1" />
<stop offset="100%" style="stop-color:#7B68EE;stop-opacity:1" />
</linearGradient>
</defs>
<rect width="200" height="80" rx="12" fill="url(#europark-gradient)"/>
<text x="100" y="50" font-family="Microsoft YaHei, Arial, sans-serif" font-size="32" font-weight="bold" fill="white" text-anchor="middle">欧乐堡</text>
</svg>
</div>
<div class="sponsor-info">
<h3 class="sponsor-name">泉州欧乐堡</h3>
<p class="sponsor-desc">童话世界 · 海洋王国</p>
</div>
</div>
</div>
</div>
+150 -1
View File
@@ -49,6 +49,7 @@
@click="showCarModelPicker = true"
/>
<van-field
v-model="form.carNumber"
label="车牌号"
:rules="[{ required: true, message: '请输入车牌号' }]"
class="car-number-field"
@@ -108,6 +109,46 @@
@cancel="showCarModelPicker = false"
/>
</van-popup>
<!-- 群二维码弹窗 -->
<van-popup
v-model:show="showQrcodeDialog"
:close-on-click-overlay="false"
:overlay-style="{ background: 'rgba(0, 0, 0, 0.8)' }"
>
<div class="qrcode-card">
<div class="qrcode-title">
<van-icon name="success" color="#FF6700" size="32" />
<h2>🎉 报名成功!</h2>
</div>
<div class="qrcode-important">
<van-icon name="warning-o" color="#FF6700" />
<span>重要!请扫码加入活动群</span>
</div>
<div class="qrcode-image-wrapper">
<img src="/qun.jpg" alt="群二维码" class="qrcode-image" />
</div>
<div class="qrcode-tip">
<van-icon name="info-o" />
<span>长按二维码保存图片</span>
</div>
<van-button
type="primary"
block
round
size="large"
color="#FF6700"
@click="closeQrcodeDialog"
class="qrcode-confirm-btn"
>
我知道了
</van-button>
</div>
</van-popup>
</div>
</template>
@@ -121,6 +162,7 @@ const route = useRoute();
const router = useRouter();
const showCarModelPicker = ref(false);
const showQrcodeDialog = ref(false);
const carModels = ref([
{ text: '小米SU7', value: '小米SU7' },
{ text: '小米YU7', value: '小米YU7' },
@@ -225,11 +267,18 @@ const onSubmit = async () => {
message: `您的报名ID: ${data.id}\n已自动登录,请等待管理员审核`
});
router.back();
// 显示醒目的群二维码弹窗
showQrcodeDialog.value = true;
} catch (error) {
showToast('报名失败');
}
};
// 关闭二维码弹窗
const closeQrcodeDialog = () => {
showQrcodeDialog.value = false;
router.back();
};
</script>
<style scoped>
@@ -367,4 +416,104 @@ const onSubmit = async () => {
color: #ddd;
font-weight: 400;
}
/* 群二维码弹窗样式 */
.qrcode-card {
background: #fff;
border-radius: 24px;
padding: 32px 24px;
max-width: 400px;
width: 90vw;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
animation: slideUp 0.4s ease-out;
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(50px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.qrcode-title {
text-align: center;
margin-bottom: 16px;
}
.qrcode-title h2 {
font-size: 24px;
font-weight: bold;
margin: 12px 0 0 0;
background: linear-gradient(135deg, #FF6700 0%, #FF8533 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.qrcode-important {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 12px 20px;
background: linear-gradient(135deg, #FFF5E6 0%, #FFE8CC 100%);
border-radius: 12px;
margin-bottom: 24px;
border: 2px solid #FF6700;
animation: pulse 2s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% {
transform: scale(1);
box-shadow: 0 0 0 0 rgba(255, 103, 0, 0.4);
}
50% {
transform: scale(1.02);
box-shadow: 0 0 0 8px rgba(255, 103, 0, 0);
}
}
.qrcode-important span {
font-size: 16px;
font-weight: bold;
color: #FF6700;
}
.qrcode-image-wrapper {
display: flex;
justify-content: center;
margin-bottom: 20px;
padding: 16px;
background: #f8f8f8;
border-radius: 16px;
}
.qrcode-image {
width: 100%;
max-width: 280px;
height: auto;
border-radius: 12px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.qrcode-tip {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
margin-bottom: 24px;
color: #666;
font-size: 14px;
}
.qrcode-confirm-btn {
font-size: 16px;
font-weight: bold;
height: 48px;
}
</style>
+579 -53
View File
@@ -238,6 +238,13 @@
<div class="stat-desc">已通过</div>
</div>
</div>
<div class="stat-box">
<van-icon name="friends" color="#1989fa" />
<div class="stat-text">
<div class="stat-num">{{ approvedTotalPeople }}</div>
<div class="stat-desc">参与人数</div>
</div>
</div>
<div class="stat-box" :class="{ active: activeFilter === 'rejected' }" @click="setFilter('rejected')">
<van-icon name="close" color="#ee0a24" />
<div class="stat-text">
@@ -266,6 +273,20 @@
<div class="stat-desc">未签到</div>
</div>
</div>
<div class="stat-box" :class="{ active: activeFilter === 'grouped' }" @click="setFilter('grouped')">
<van-icon name="chat-o" color="#1989fa" />
<div class="stat-text">
<div class="stat-num">{{ registrations.filter(r => r.groupStatus).length }}</div>
<div class="stat-desc">已进群</div>
</div>
</div>
<div class="stat-box" :class="{ active: activeFilter === 'notGrouped' }" @click="setFilter('notGrouped')">
<van-icon name="comment-o" color="#999" />
<div class="stat-text">
<div class="stat-num">{{ registrations.filter(r => !r.groupStatus).length }}</div>
<div class="stat-desc">未进群</div>
</div>
</div>
</div>
<!-- 搜索框 -->
@@ -285,11 +306,6 @@
<div class="reg-main">
<div class="reg-top">
<span class="reg-name">{{ reg.name || '未知' }}</span>
<div class="reg-tags">
<van-tag v-if="reg.checkInStatus" type="success" size="medium">
<van-icon name="success" /> 已签到
</van-tag>
</div>
</div>
<div class="reg-info">
<a :href="`tel:${reg.phone}`" class="info-link" @click.stop>
@@ -305,12 +321,46 @@
</div>
</div>
<div class="reg-right">
<div class="status-indicators">
<div
class="status-dot"
:class="{
'status-approved': reg.status === 'approved',
'status-rejected': reg.status === 'rejected',
'status-pending': reg.status === 'pending'
}"
:title="reg.status === 'approved' ? '已审核' : reg.status === 'rejected' ? '已拒绝' : '待审核'"
>
<van-icon v-if="reg.status === 'approved'" name="passed" />
<van-icon v-else-if="reg.status === 'rejected'" name="close" />
<van-icon v-else name="clock-o" />
</div>
<div
class="status-dot"
:class="{ 'status-active': reg.checkInStatus }"
:title="reg.checkInStatus ? '已签到' : '未签到'"
>
<van-icon name="location" />
</div>
<div
class="status-dot"
:class="{ 'status-active': reg.groupStatus }"
:title="reg.groupStatus ? '已进群' : '未进群'"
>
<van-icon name="chat-o" />
</div>
</div>
<van-icon name="arrow" color="#ccc" />
</div>
</div>
</div>
<van-empty v-if="filteredRegistrations.length === 0" :description="regSearchKeyword ? '未找到匹配的报名' : '暂无报名'" />
<!-- 悬浮添加按钮 -->
<div class="float-add-btn" @click="addNewRegistration">
<van-icon name="plus" />
</div>
</div>
</van-tab>
@@ -414,6 +464,47 @@
</van-uploader>
</div>
</van-tab>
<van-tab title="场地管理" name="venue">
<div class="tab-content">
<div class="venue-list">
<div v-for="image in venueImages" :key="image.id" class="venue-item">
<div class="venue-thumb" @click="previewImage(getImageUrl(image.image))">
<van-image :src="getImageUrl(image.image)" fit="cover" />
</div>
<div class="venue-actions">
<van-field
v-model.number="image.sortOrder"
type="number"
label="排序"
placeholder="数字越小越靠前"
@blur="updateVenueOrder(image.id, image.sortOrder)"
/>
<van-button
size="mini"
type="danger"
icon="delete-o"
@click="deleteVenueImage(image.id)"
>删除</van-button>
</div>
</div>
</div>
<van-empty v-if="!venueImages.length" description="暂无场地图片" />
<!-- 悬浮上传按钮 -->
<van-uploader
v-model="venueFiles"
:max-count="1"
:after-read="uploadVenueImage"
accept="image/*"
>
<div class="float-add-btn">
<van-icon name="photograph" />
</div>
</van-uploader>
</div>
</van-tab>
</van-tabs>
<!-- 编辑活动弹窗 -->
@@ -579,7 +670,7 @@
<div class="form-popup">
<div class="popup-header">
<h3>{{ expenseForm.id ? '编辑经费' : '添加经费' }}</h3>
<van-icon name="cross" @click="showExpenseForm = false" />
<van-icon name="cross" @click="closeExpenseForm" />
</div>
<van-form @submit="submitExpense" class="popup-form">
<van-cell-group>
@@ -603,16 +694,33 @@
:max-count="5"
accept="image/*,video/*"
:after-read="onExpenseMediaRead"
:disabled="expenseForm.media.length + expenseMediaFiles.length >= 5"
>
<van-button icon="photograph" type="primary" size="small">
上传 ({{ expenseForm.media.length + expenseMediaFiles.length }}/5)
</van-button>
</van-uploader>
<!-- 已有媒体 -->
<div v-if="expenseForm.media.length" class="existing-media">
<div v-for="(item, index) in expenseForm.media" :key="index" class="media-thumb">
<van-image v-if="item.type === 'image'" :src="item.url" fit="cover" />
<video v-else :src="item.url" class="video-thumb"></video>
<van-icon name="cross" class="remove-icon" @click="removeExpenseMedia(index)" />
<div class="media-section-title">已有媒体</div>
<div class="media-list">
<div v-for="(item, index) in expenseForm.media" :key="'existing-' + index" class="media-thumb">
<van-image v-if="item.type === 'image'" :src="item.url" fit="cover" />
<video v-else :src="item.url" class="video-thumb"></video>
<van-icon name="cross" class="remove-icon" @click="removeExpenseMedia(index)" />
</div>
</div>
</div>
<!-- 新上传媒体 -->
<div v-if="expenseMediaFiles.length" class="existing-media">
<div class="media-section-title">新上传</div>
<div class="media-list">
<div v-for="(item, index) in expenseMediaFiles" :key="'new-' + index" class="media-thumb">
<van-image v-if="item.content" :src="item.content" fit="cover" />
<van-icon name="cross" class="remove-icon" @click="removeNewExpenseMedia(index)" />
</div>
</div>
</div>
</div>
@@ -852,6 +960,82 @@
</div>
</van-popup>
<!-- 修改车牌号弹窗 -->
<van-popup v-model:show="showCarNumberDialog" position="center" round :style="{ width: '80%' }">
<div class="people-count-dialog">
<div class="dialog-header">
<h3>修改车牌号</h3>
<van-icon name="cross" @click="showCarNumberDialog = false" />
</div>
<div class="dialog-content">
<div class="count-info">
<span class="info-label">{{ currentEditReg.name }}</span>
<span class="info-desc">当前车牌号:{{ currentEditReg.carNumber || '未填写' }}</span>
</div>
<van-field v-model="newCarNumber" placeholder="请输入车牌号(如:闽A12345)" />
</div>
<div class="dialog-actions">
<van-button block type="primary" @click="confirmCarNumber">确定</van-button>
</div>
</div>
</van-popup>
<!-- 手动录入报名弹窗 -->
<van-popup v-model:show="showRegForm" position="bottom" round>
<div class="form-popup">
<div class="popup-header">
<h3>手动录入报名</h3>
<van-icon name="cross" @click="closeRegForm" />
</div>
<van-form @submit="submitRegistration" class="popup-form">
<van-cell-group>
<van-field
v-model="regForm.name"
label="姓名"
placeholder="请输入姓名"
required
:rules="[{ required: true, message: '请输入姓名' }]"
/>
<van-field
v-model="regForm.phone"
label="手机号"
type="tel"
placeholder="请输入手机号"
required
:rules="[{ required: true, message: '请输入手机号' }]"
/>
<van-field
v-model="regForm.carModel"
label="车型"
placeholder="请输入车型(选填)"
/>
<van-field
v-model="regForm.carNumber"
label="车牌号"
placeholder="请输入车牌号(选填)"
/>
<van-field
v-model.number="regForm.peopleCount"
type="number"
label="人数"
placeholder="请输入人数"
/>
<van-field
v-model="regForm.note"
label="备注"
type="textarea"
placeholder="备注信息(选填)"
rows="2"
autosize
/>
</van-cell-group>
<div class="form-actions">
<van-button round block type="primary" native-type="submit" size="large">提交报名</van-button>
</div>
</van-form>
</div>
</van-popup>
<!-- 报名详情弹窗 -->
<van-popup v-model:show="showRegDetailDialog" position="bottom" round>
<div class="reg-detail-popup">
@@ -873,8 +1057,9 @@
</div>
<div class="detail-row" v-if="currentReg.carNumber">
<span class="detail-label">车牌号</span>
<span class="detail-value detail-link" @click="copyCarNumber(currentReg.carNumber)">
<span class="detail-value detail-link" @click="editCarNumber(currentReg)">
<van-icon name="certificate" />{{ currentReg.carNumber }}
<van-icon name="edit" size="14" />
</span>
</div>
<div class="detail-row" v-if="currentReg.carModel">
@@ -892,33 +1077,41 @@
<span class="detail-label">签到时间</span>
<span class="detail-value">{{ formatDate(currentReg.checkInTime) }}</span>
</div>
<div class="detail-row">
<span class="detail-label">审核状态</span>
<van-tag :type="currentReg.status === 'approved' ? 'success' : currentReg.status === 'rejected' ? 'danger' : 'warning'">
{{ currentReg.status === 'approved' ? '已通过' : currentReg.status === 'rejected' ? '已拒绝' : '待审核' }}
</van-tag>
</div>
<div class="detail-row">
<span class="detail-label">签到状态</span>
<van-tag :type="currentReg.checkInStatus ? 'success' : 'default'">
{{ currentReg.checkInStatus ? '已签到' : '未签到' }}
</van-tag>
<div class="detail-row status-row">
<span class="detail-label">状态管理</span>
<div class="status-controls">
<div class="status-item">
<span class="status-name">审核(是否交钱)</span>
<van-tag :type="currentReg.status === 'approved' ? 'success' : currentReg.status === 'rejected' ? 'danger' : 'warning'">
{{ currentReg.status === 'approved' ? '已通过' : currentReg.status === 'rejected' ? '已拒绝' : '待审核' }}
</van-tag>
</div>
<div class="status-item">
<span class="status-name">签到(是否到现场)</span>
<van-switch
v-model="currentReg.checkInStatus"
size="20px"
@change="toggleCheckInFromDetail"
active-color="#07c160"
/>
</div>
<div class="status-item">
<span class="status-name">进群(是否加微信群)</span>
<van-switch
v-model="currentReg.groupStatus"
size="20px"
@change="toggleGroupStatusFromDetail"
active-color="#1989fa"
/>
</div>
</div>
</div>
</div>
</div>
<div class="detail-actions">
<van-button
v-if="currentReg.status === 'approved'"
block
size="large"
:type="currentReg.checkInStatus ? 'default' : 'success'"
@click="toggleCheckInFromDetail"
>
{{ currentReg.checkInStatus ? '取消签到' : '签到' }}
</van-button>
<div v-if="currentReg.status === 'pending'" class="action-buttons">
<van-button size="large" type="success" @click="approveFromDetail('approved')">通过</van-button>
<van-button size="large" type="danger" @click="approveFromDetail('rejected')">拒绝</van-button>
<van-button size="large" type="success" @click="approveFromDetail('approved')">通过审核</van-button>
<van-button size="large" type="danger" @click="approveFromDetail('rejected')">拒绝审核</van-button>
</div>
<van-button
block
@@ -926,7 +1119,7 @@
type="danger"
plain
@click="deleteRegistration"
style="margin-top: 12px;"
:style="{ marginTop: currentReg.status === 'pending' ? '12px' : '0' }"
>
删除报名
</van-button>
@@ -965,12 +1158,16 @@ const photoFiles = ref([]);
const pendingPhotos = ref([]);
const expenseMediaFiles = ref([]);
const approvedPhotos = ref([]);
const venueImages = ref([]);
const venueFiles = ref([]);
const showVideoPlayer = ref(false);
const currentVideoUrl = ref('');
const regSearchKeyword = ref('');
const showPeopleCountDialog = ref(false);
const currentEditReg = ref({});
const newPeopleCount = ref(1);
const showCarNumberDialog = ref(false);
const newCarNumber = ref('');
const showRegDetailDialog = ref(false);
const currentReg = ref({});
const activeFilter = ref('all'); // 过滤器状态
@@ -978,6 +1175,16 @@ const activeFilter = ref('all'); // 过滤器状态
const showActivityForm = ref(false);
const showExpenseForm = ref(false);
const showSponsorForm = ref(false);
const showRegForm = ref(false);
const regForm = ref({
name: '',
phone: '',
carModel: '',
carNumber: '',
peopleCount: 1,
note: ''
});
// 奖品管理
const activityId = computed(() => route.params.id);
@@ -1069,6 +1276,13 @@ const totalExpense = computed(() => {
return expenses.value.reduce((sum, e) => sum + parseFloat(e.amount), 0).toFixed(2);
});
// 审核通过的总人数(车主+带的人)
const approvedTotalPeople = computed(() => {
return registrations.value
.filter(r => r.status === 'approved')
.reduce((sum, r) => sum + (r.peopleCount || 1), 0);
});
// 过滤报名列表
const filteredRegistrations = computed(() => {
let result = registrations.value;
@@ -1084,6 +1298,10 @@ const filteredRegistrations = computed(() => {
result = result.filter(r => r.checkInStatus);
} else if (activeFilter.value === 'notCheckedIn') {
result = result.filter(r => !r.checkInStatus);
} else if (activeFilter.value === 'grouped') {
result = result.filter(r => r.groupStatus);
} else if (activeFilter.value === 'notGrouped') {
result = result.filter(r => !r.groupStatus);
}
// 应用搜索关键词
@@ -1212,7 +1430,11 @@ const confirmCustomCategory = () => {
const loadData = async () => {
try {
const { data } = await api.getActivity(route.params.id);
activity.value = data.activity;
activity.value = {
...data.activity,
danmakuEnabled: Boolean(data.activity.danmakuEnabled),
registrationEnabled: Boolean(data.activity.registrationEnabled)
};
expenses.value = data.expenses || [];
sponsors.value = data.sponsors || [];
@@ -1226,6 +1448,9 @@ const loadData = async () => {
// 加载奖品
await loadPrizes();
// 加载场地图片
await loadVenueImages();
} catch (error) {
showToast('加载失败');
}
@@ -1327,8 +1552,8 @@ const toggleDanmaku = async (value) => {
formData.append('category', expenseForm.value.category);
formData.append('note', expenseForm.value.note);
// 添加现有媒体
if (expenseForm.value.media && expenseForm.value.media.length > 0) {
// 只在编辑时添加现有媒体
if (expenseForm.value.id && expenseForm.value.media && expenseForm.value.media.length > 0) {
formData.append('existingMedia', JSON.stringify(expenseForm.value.media));
}
@@ -1346,9 +1571,7 @@ const toggleDanmaku = async (value) => {
await api.addExpense(formData);
showToast('添加成功');
}
showExpenseForm.value = false;
expenseForm.value = { id: null, item: '', amount: '', category: '', note: '', media: [] };
expenseMediaFiles.value = [];
closeExpenseForm();
loadData();
} catch (error) {
showToast(expenseForm.value.id ? '更新失败' : '添加失败');
@@ -1374,13 +1597,76 @@ const addNewExpense = () => {
};
const onExpenseMediaRead = (file) => {
// 文件读取后的处理,Vant会自动处理
// 验证文件大小和类型
const fileItem = Array.isArray(file) ? file[0] : file;
const maxSize = 50 * 1024 * 1024; // 50MB
if (fileItem.file.size > maxSize) {
showToast('文件大小不能超过50MB');
// 移除超大文件
const index = expenseMediaFiles.value.findIndex(f => f.file === fileItem.file);
if (index > -1) {
expenseMediaFiles.value.splice(index, 1);
}
return;
}
};
const removeExpenseMedia = (index) => {
expenseForm.value.media.splice(index, 1);
};
// 添加新报名
const addNewRegistration = () => {
regForm.value = {
name: '',
phone: '',
carModel: '',
carNumber: '',
peopleCount: 1,
note: ''
};
showRegForm.value = true;
};
// 关闭报名表单
const closeRegForm = () => {
showRegForm.value = false;
regForm.value = {
name: '',
phone: '',
carModel: '',
carNumber: '',
peopleCount: 1,
note: ''
};
};
// 提交报名
const submitRegistration = async () => {
try {
await api.submitRegistration({
activityId: activity.value.id,
...regForm.value
});
showToast('报名成功');
closeRegForm();
loadData();
} catch (error) {
showToast('报名失败');
}
};;
const removeNewExpenseMedia = (index) => {
expenseMediaFiles.value.splice(index, 1);
};
const closeExpenseForm = () => {
showExpenseForm.value = false;
expenseForm.value = { id: null, item: '', amount: '', category: '', note: '', media: [] };
expenseMediaFiles.value = [];
};
const deleteExpenseItem = async (id) => {
try {
await showConfirmDialog({
@@ -1592,6 +1878,24 @@ const confirmPeopleCount = async () => {
}
};
// 修改车牌号
const editCarNumber = (reg) => {
currentEditReg.value = reg;
newCarNumber.value = reg.carNumber || '';
showCarNumberDialog.value = true;
};
const confirmCarNumber = async () => {
try {
await api.updateRegistration(currentEditReg.value.id, { carNumber: newCarNumber.value });
showToast('修改成功');
showCarNumberDialog.value = false;
loadData();
} catch (error) {
showToast('修改失败');
}
};
// 显示报名详情
const showRegDetail = (reg) => {
currentReg.value = reg;
@@ -1600,13 +1904,19 @@ const showRegDetail = (reg) => {
// 从详情弹窗签到
const toggleCheckInFromDetail = async () => {
const originalStatus = currentReg.value.checkInStatus;
try {
await api.checkInRegistration(currentReg.value.id);
showToast('操作成功');
showRegDetailDialog.value = false;
loadData();
await loadData();
// 重新打开详情弹窗,显示更新后的数据
const updatedReg = registrations.value.find(r => r.id === currentReg.value.id);
if (updatedReg) {
currentReg.value = updatedReg;
}
} catch (error) {
showToast('操作失败');
currentReg.value.checkInStatus = originalStatus;
}
};
@@ -1622,6 +1932,22 @@ const approveFromDetail = async (status) => {
}
};
// 从详情弹窗切换进群状态
const toggleGroupStatusFromDetail = async () => {
try {
await api.groupRegistration(currentReg.value.id);
showToast('操作成功');
await loadData();
// 重新打开详情弹窗,显示更新后的数据
const updatedReg = registrations.value.find(r => r.id === currentReg.value.id);
if (updatedReg) {
currentReg.value = updatedReg;
}
} catch (error) {
showToast('操作失败');
}
};
// 删除报名
const deleteRegistration = async () => {
try {
@@ -1724,6 +2050,70 @@ const playVideo = (url) => {
showVideoPlayer.value = true;
};
// 场地管理相关方法
const loadVenueImages = async () => {
try {
const { data } = await api.getVenueImages(route.params.id);
venueImages.value = data;
} catch (error) {
console.error('加载场地图片失败:', error);
}
};
const uploadVenueImage = async (file) => {
const loadingToast = showToast({
type: 'loading',
message: '正在上传...',
forbidClick: true,
duration: 0
});
try {
const formData = new FormData();
formData.append('image', file.file);
formData.append('activityId', route.params.id);
formData.append('sortOrder', venueImages.value.length);
await api.uploadVenueImage(formData);
showToast('上传成功');
venueFiles.value = [];
await loadVenueImages();
} catch (error) {
console.error('上传失败:', error);
showToast('上传失败');
} finally {
loadingToast.clear();
}
};
const deleteVenueImage = async (id) => {
try {
await showConfirmDialog({
title: '确认删除',
message: '删除后无法恢复,确定要删除吗?',
});
await api.deleteVenueImage(id);
showToast('删除成功');
await loadVenueImages();
} catch (error) {
if (error !== 'cancel') {
console.error('删除失败:', error);
showToast('删除失败');
}
}
};
const updateVenueOrder = async (id, sortOrder) => {
try {
await api.updateVenueImageOrder(id, sortOrder);
showToast('排序已更新');
await loadVenueImages();
} catch (error) {
console.error('更新失败:', error);
showToast('更新失败');
}
};
// 地图选点相关(与Dashboard相同)
const initMapPicker = async () => {
try {
@@ -2400,6 +2790,71 @@ onMounted(loadData);
font-size: 13px;
}
.reg-right {
display: flex;
align-items: center;
gap: 12px;
flex-shrink: 0;
}
.reg-right .van-icon {
font-size: 16px;
}
.status-indicators {
display: flex;
gap: 6px;
align-items: center;
}
.status-dot {
width: 28px;
height: 28px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
background: #f5f5f5;
transition: all 0.3s ease;
}
.status-dot .van-icon {
font-size: 14px;
color: #999;
}
.status-dot.status-approved {
background: #e8f5e9;
}
.status-dot.status-approved .van-icon {
color: #07c160;
}
.status-dot.status-rejected {
background: #ffebee;
}
.status-dot.status-rejected .van-icon {
color: #ee0a24;
}
.status-dot.status-pending {
background: #fff3e0;
}
.status-dot.status-pending .van-icon {
color: #ffa940;
}
.status-dot.status-active {
background: #e3f2fd;
}
.status-dot.status-active .van-icon {
color: #1989fa;
}
.info-link,
.info-text {
display: inline-flex;
@@ -2441,15 +2896,7 @@ onMounted(loadData);
opacity: 0.7;
}
.reg-right {
display: flex;
align-items: center;
flex-shrink: 0;
}
.reg-right .van-icon {
font-size: 16px;
}
/* 移除旧的 reg-right 样式,已在上面重新定义 */
/* 弹窗 */
.form-popup {
@@ -2759,6 +3206,48 @@ onMounted(loadData);
border-radius: 12px;
}
/* 场地管理 */
.venue-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.venue-item {
background: #fff;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0,0,0,0.04);
border: 1px solid #f0f0f0;
}
.venue-thumb {
width: 100%;
height: 200px;
background: #f5f5f5;
cursor: pointer;
}
.venue-thumb .van-image {
width: 100%;
height: 100%;
}
.venue-actions {
padding: 12px;
display: flex;
gap: 8px;
align-items: center;
}
.venue-actions .van-field {
flex: 1;
}
.venue-actions .van-button {
flex-shrink: 0;
}
/* 分类选择器 */
.category-picker {
padding: 20px;
@@ -2887,9 +3376,19 @@ onMounted(loadData);
}
.existing-media {
margin-top: 12px;
}
.media-section-title {
font-size: 13px;
color: #666;
margin-bottom: 8px;
font-weight: 500;
}
.media-list {
display: flex;
gap: 8px;
margin-top: 12px;
flex-wrap: wrap;
}
@@ -3026,6 +3525,33 @@ onMounted(loadData);
border-bottom: 1px solid #f5f5f5;
}
.detail-row.status-row {
flex-direction: column;
align-items: stretch;
gap: 12px;
}
.status-controls {
display: flex;
flex-direction: column;
gap: 12px;
background: #f7f8fa;
padding: 12px;
border-radius: 8px;
}
.status-item {
display: flex;
justify-content: space-between;
align-items: center;
}
.status-name {
font-size: 14px;
color: #333;
font-weight: 500;
}
.detail-row:last-child {
border-bottom: none;
}
+606
View File
@@ -0,0 +1,606 @@
<template>
<div class="admin-manage">
<div class="header">
<h2>管理员账号管理</h2>
<button @click="showCreateDialog = true" class="btn-primary">
<span>➕</span> 添加管理员
</button>
</div>
<div class="admin-cards">
<div v-for="admin in admins" :key="admin.id" class="admin-card">
<div class="card-header">
<div class="admin-info">
<div class="admin-avatar">
{{ admin.username.charAt(0).toUpperCase() }}
</div>
<div class="admin-details">
<div class="admin-name">{{ admin.name }}</div>
<div class="admin-username">@{{ admin.username }}</div>
</div>
</div>
<span :class="['role-badge', admin.role]">
{{ admin.role === 'super' ? '超级管理员' : '管理员' }}
</span>
</div>
<div class="card-meta">
<span class="meta-item">
<span class="meta-icon">📅</span>
创建于 {{ formatDate(admin.createdAt) }}
</span>
</div>
<div class="card-actions">
<button @click="editAdmin(admin)" class="btn-action btn-edit">
<span>✏️</span> 编辑
</button>
<button @click="changePassword(admin)" class="btn-action btn-password">
<span>🔑</span> 改密码
</button>
<button
v-if="admin.role !== 'super'"
@click="deleteAdmin(admin)"
class="btn-action btn-delete"
>
<span>🗑️</span> 删除
</button>
</div>
</div>
<div v-if="!admins.length" class="empty-state">
<div class="empty-icon">👥</div>
<div class="empty-text">暂无管理员</div>
</div>
</div>
<!-- 创建管理员对话框 -->
<div v-if="showCreateDialog" class="dialog-overlay" @click.self="showCreateDialog = false">
<div class="dialog">
<h3>添加管理员</h3>
<form @submit.prevent="createAdmin">
<div class="form-group">
<label>用户名</label>
<input v-model="newAdmin.username" required placeholder="请输入用户名" />
</div>
<div class="form-group">
<label>姓名</label>
<input v-model="newAdmin.name" required placeholder="请输入姓名" />
</div>
<div class="form-group">
<label>密码</label>
<input v-model="newAdmin.password" type="password" required placeholder="请输入密码" />
</div>
<div class="form-group">
<label>角色</label>
<select v-model="newAdmin.role">
<option value="admin">管理员</option>
<option value="super">超级管理员</option>
</select>
</div>
<div class="dialog-actions">
<button type="button" @click="showCreateDialog = false" class="btn-cancel">取消</button>
<button type="submit" class="btn-primary">创建</button>
</div>
</form>
</div>
</div>
<!-- 编辑管理员对话框 -->
<div v-if="showEditDialog" class="dialog-overlay" @click.self="showEditDialog = false">
<div class="dialog">
<h3>编辑管理员</h3>
<form @submit.prevent="updateAdmin">
<div class="form-group">
<label>用户名</label>
<input :value="editingAdmin.username" disabled />
</div>
<div class="form-group">
<label>姓名</label>
<input v-model="editingAdmin.name" required placeholder="请输入姓名" />
</div>
<div class="form-group">
<label>角色</label>
<select v-model="editingAdmin.role">
<option value="admin">管理员</option>
<option value="super">超级管理员</option>
</select>
</div>
<div class="dialog-actions">
<button type="button" @click="showEditDialog = false" class="btn-cancel">取消</button>
<button type="submit" class="btn-primary">保存</button>
</div>
</form>
</div>
</div>
<!-- 修改密码对话框 -->
<div v-if="showPasswordDialog" class="dialog-overlay" @click.self="showPasswordDialog = false">
<div class="dialog">
<h3>修改密码</h3>
<form @submit.prevent="updatePassword">
<div class="form-group">
<label>用户名</label>
<input :value="passwordAdmin.username" disabled />
</div>
<div class="form-group">
<label>新密码</label>
<input v-model="newPassword" type="password" required placeholder="请输入新密码" />
</div>
<div class="dialog-actions">
<button type="button" @click="showPasswordDialog = false" class="btn-cancel">取消</button>
<button type="submit" class="btn-primary">确认</button>
</div>
</form>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue';
import api from '../../api';
import { showToast } from 'vant';
const admins = ref([]);
const showCreateDialog = ref(false);
const showEditDialog = ref(false);
const showPasswordDialog = ref(false);
const newAdmin = ref({
username: '',
name: '',
password: '',
role: 'admin'
});
const editingAdmin = ref({});
const passwordAdmin = ref({});
const newPassword = ref('');
const loadAdmins = async () => {
try {
const { data } = await api.getAdmins();
console.log('管理员数据:', data);
admins.value = data;
} catch (error) {
console.error('加载管理员列表失败:', error);
console.error('错误详情:', error.response?.data);
if (error.response?.status === 403) {
showToast('需要超级管理员权限');
} else if (error.response?.status === 401) {
showToast('登录已过期,请重新登录');
} else {
showToast(error.response?.data?.error || '加载失败');
}
}
};
const createAdmin = async () => {
try {
await api.createAdmin(newAdmin.value);
showToast('创建成功');
showCreateDialog.value = false;
newAdmin.value = { username: '', name: '', password: '', role: 'admin' };
loadAdmins();
} catch (error) {
showToast(error.response?.data?.error || '创建失败');
}
};
const editAdmin = (admin) => {
editingAdmin.value = { ...admin };
showEditDialog.value = true;
};
const updateAdmin = async () => {
try {
await api.updateAdmin(editingAdmin.value.id, {
name: editingAdmin.value.name,
role: editingAdmin.value.role
});
showToast('更新成功');
showEditDialog.value = false;
loadAdmins();
} catch (error) {
showToast('更新失败');
}
};
const changePassword = (admin) => {
passwordAdmin.value = admin;
newPassword.value = '';
showPasswordDialog.value = true;
};
const updatePassword = async () => {
try {
await api.updateAdminPassword(passwordAdmin.value.id, {
newPassword: newPassword.value
});
showToast('密码修改成功');
showPasswordDialog.value = false;
} catch (error) {
showToast('修改失败');
}
};
const deleteAdmin = async (admin) => {
if (!confirm(`确定删除管理员 ${admin.username} 吗?`)) return;
try {
await api.deleteAdmin(admin.id);
showToast('删除成功');
loadAdmins();
} catch (error) {
showToast(error.response?.data?.error || '删除失败');
}
};
const formatDate = (date) => {
if (!date) return '-';
try {
return new Date(date).toLocaleDateString('zh-CN');
} catch (error) {
return '-';
}
};
onMounted(loadAdmins);
</script>
<style scoped>
.admin-manage {
min-height: 100vh;
background: #f8f9fa;
padding: 20px;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
flex-wrap: wrap;
gap: 16px;
}
.header h2 {
margin: 0;
font-size: 24px;
font-weight: 700;
color: #1a1a1a;
}
.btn-primary {
padding: 12px 24px;
background: linear-gradient(135deg, #4dabf7 0%, #339af0 100%);
color: white;
border: none;
border-radius: 12px;
cursor: pointer;
font-size: 15px;
font-weight: 600;
display: flex;
align-items: center;
gap: 8px;
transition: all 0.3s;
box-shadow: 0 4px 12px rgba(77, 171, 247, 0.3);
}
.btn-primary:hover {
transform: translateY(-2px);
box-shadow: 0 6px 16px rgba(77, 171, 247, 0.4);
}
.btn-primary:active {
transform: translateY(0);
}
.btn-primary span {
font-size: 18px;
}
.admin-cards {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 20px;
}
.admin-card {
background: white;
border-radius: 16px;
padding: 20px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
transition: all 0.3s;
border: 1px solid #f0f0f0;
}
.admin-card:hover {
transform: translateY(-4px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
}
.card-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 16px;
gap: 12px;
}
.admin-info {
display: flex;
align-items: center;
gap: 12px;
flex: 1;
}
.admin-avatar {
width: 48px;
height: 48px;
border-radius: 50%;
background: linear-gradient(135deg, #4dabf7 0%, #339af0 100%);
color: white;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
font-weight: 700;
flex-shrink: 0;
}
.admin-details {
flex: 1;
min-width: 0;
}
.admin-name {
font-size: 16px;
font-weight: 600;
color: #1a1a1a;
margin-bottom: 4px;
}
.admin-username {
font-size: 13px;
color: #999;
}
.role-badge {
padding: 6px 14px;
border-radius: 20px;
font-size: 12px;
font-weight: 600;
white-space: nowrap;
flex-shrink: 0;
}
.role-badge.super {
background: linear-gradient(135deg, #ff6b6b 0%, #ee5a52 100%);
color: white;
box-shadow: 0 2px 8px rgba(255, 107, 107, 0.3);
}
.role-badge.admin {
background: linear-gradient(135deg, #4dabf7 0%, #339af0 100%);
color: white;
box-shadow: 0 2px 8px rgba(77, 171, 247, 0.3);
}
.card-meta {
padding: 12px 0;
border-top: 1px solid #f5f5f5;
border-bottom: 1px solid #f5f5f5;
margin-bottom: 16px;
}
.meta-item {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: #666;
}
.meta-icon {
font-size: 16px;
}
.card-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.btn-action {
flex: 1;
min-width: 80px;
padding: 10px 16px;
border: none;
border-radius: 10px;
cursor: pointer;
font-size: 13px;
font-weight: 600;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
transition: all 0.2s;
}
.btn-action:hover {
transform: translateY(-2px);
}
.btn-action:active {
transform: translateY(0);
}
.btn-edit {
background: #e3f2fd;
color: #1976d2;
}
.btn-edit:hover {
background: #bbdefb;
}
.btn-password {
background: #fff3e0;
color: #f57c00;
}
.btn-password:hover {
background: #ffe0b2;
}
.btn-delete {
background: #ffebee;
color: #d32f2f;
}
.btn-delete:hover {
background: #ffcdd2;
}
.empty-state {
grid-column: 1 / -1;
text-align: center;
padding: 60px 20px;
}
.empty-icon {
font-size: 64px;
margin-bottom: 16px;
opacity: 0.5;
}
.empty-text {
font-size: 16px;
color: #999;
}
.dialog-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 20px;
backdrop-filter: blur(4px);
}
.dialog {
background: white;
padding: 28px;
border-radius: 16px;
width: 100%;
max-width: 440px;
max-height: 90vh;
overflow-y: auto;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
}
.dialog h3 {
margin: 0 0 24px 0;
font-size: 20px;
font-weight: 700;
color: #1a1a1a;
}
.form-group {
margin-bottom: 20px;
}
.form-group label {
display: block;
margin-bottom: 8px;
font-weight: 600;
font-size: 14px;
color: #333;
}
.form-group input,
.form-group select {
width: 100%;
padding: 12px 16px;
border: 2px solid #e8e8e8;
border-radius: 10px;
font-size: 14px;
transition: all 0.2s;
box-sizing: border-box;
}
.form-group input:focus,
.form-group select:focus {
outline: none;
border-color: #4dabf7;
box-shadow: 0 0 0 3px rgba(77, 171, 247, 0.1);
}
.form-group input:disabled {
background: #f5f5f5;
cursor: not-allowed;
color: #999;
}
.form-group input::placeholder {
color: #bbb;
}
.dialog-actions {
display: flex;
justify-content: flex-end;
gap: 12px;
margin-top: 28px;
}
.dialog-actions button {
padding: 12px 24px;
border: none;
border-radius: 10px;
cursor: pointer;
font-size: 14px;
font-weight: 600;
transition: all 0.2s;
}
.btn-cancel {
background: #f5f5f5;
color: #666;
}
.btn-cancel:hover {
background: #e8e8e8;
}
@media (max-width: 768px) {
.admin-manage {
padding: 16px;
}
.header h2 {
font-size: 20px;
}
.admin-cards {
grid-template-columns: 1fr;
}
.btn-action {
min-width: 70px;
padding: 8px 12px;
font-size: 12px;
}
.dialog {
padding: 24px;
}
}
</style>
+57 -5
View File
@@ -5,9 +5,20 @@
<h1>管理后台</h1>
<p>泉友米车友会</p>
</div>
<van-button class="logout-btn" size="small" round @click="logout">
<van-icon name="logout" /> 退出
</van-button>
<div class="header-actions">
<van-button
v-if="isSuper"
class="admin-btn"
size="small"
round
@click="goToAdminManage"
>
<van-icon name="manager-o" /> 管理员
</van-button>
<van-button class="logout-btn" size="small" round @click="logout">
<van-icon name="logout" /> 退出
</van-button>
</div>
</div>
<div class="dashboard-content">
@@ -230,6 +241,7 @@ import api from '../../api';
const router = useRouter();
const activities = ref([]);
const isSuper = ref(false);
const showActivityForm = ref(false);
const showMapPicker = ref(false);
@@ -290,14 +302,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;
};
@@ -307,12 +334,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;
};
@@ -573,10 +602,17 @@ const submitActivity = async () => {
const logout = () => {
localStorage.removeItem('token');
localStorage.removeItem('admin');
router.push('/admin/login');
};
const goToAdminManage = () => {
router.push('/admin/admins');
};
onMounted(() => {
const admin = JSON.parse(localStorage.getItem('admin') || '{}');
isSuper.value = admin.role === 'super';
loadActivities();
});
</script>
@@ -609,6 +645,22 @@ onMounted(() => {
color: #666;
}
.header-actions {
display: flex;
gap: 8px;
}
.admin-btn {
background: #4dabf7;
border: none;
color: white;
font-weight: 500;
}
.admin-btn:active {
background: #339af0;
}
.logout-btn {
background: #f5f5f5;
border: none;
+1
View File
@@ -62,6 +62,7 @@ const onSubmit = async () => {
try {
const { data } = await api.adminLogin(form.value);
localStorage.setItem('token', data.token);
localStorage.setItem('admin', JSON.stringify(data.admin));
showToast('登录成功');
router.push('/admin');
} catch (error) {
@@ -54,6 +54,11 @@
white-space: nowrap;
}
.prize-level-badge.level-特等奖 {
background: linear-gradient(135deg, #FF0000 0%, #FF6B6B 100%);
color: #fff;
}
.prize-level-badge.level-一等奖 {
background: linear-gradient(135deg, #FFD700 0%, #FFA500 100%);
color: #fff;
@@ -69,6 +74,11 @@
color: #fff;
}
.prize-level-badge.level-四等奖 {
background: linear-gradient(135deg, #8B7355 0%, #A0826D 100%);
color: #fff;
}
.prize-level-badge.level-参与奖 {
background: linear-gradient(135deg, #FF6700 0%, #FF8533 100%);
color: #fff;
@@ -163,6 +173,10 @@
cursor: pointer;
transition: all 0.2s;
text-align: center;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
.level-item:active {
+26 -277
View File
@@ -1,92 +1,6 @@
// 活动流程数据
export const timelineData = [
{
title: '签到与集结',
time: '14:00 - 14:30',
brief: '车辆到场、签到登记、领取积分卡',
overview: `
<div class="overview-text">
<p>📍 车辆到达指定地点后,工作人员将引导至临时停车点</p>
<p>✍️ 前往签到处签名确认,扫码加入活动群</p>
<p>🎫 领取专属积分卡,开启精彩旅程</p>
</div>
`,
content: `
<div class="game-rule">
<div class="rule-title">📋 积分卡说明</div>
<div class="rule-content">
• 每人领取一张积分卡(红绿双色章)<br>
• 初始积分:0分<br>
• 游戏得分范围:1~5分<br>
• 游戏扣分范围:1~5分<br>
• 最终积分可兑换礼品
</div>
</div>
`
},
{
title: '观赏素材拍摄',
time: '14:30 - 16:30',
brief: '多点位打卡拍照,记录精彩瞬间',
overview: `
<div class="overview-text">
<p>📸 专业摄影师带队,前往多个精选点位</p>
<p>🚗 全员车前合影、签到墙打卡、地标合照</p>
<p>✨ 完成所有点位拍照可获得 <span class="score-badge">+2分</span></p>
</div>
`,
items: [
{
icon: '🏛️',
name: '宝船门口',
detail: `
<div class="sub-detail">
<p><strong>拍摄内容:</strong></p>
<p>• 全员车前合影</p>
<p>• 签到墙合照</p>
<p>• 个人打卡照</p>
</div>
`
},
{
icon: '🌉',
name: '隧道三点位',
detail: `
<div class="sub-detail">
<p><strong>拍摄内容:</strong></p>
<p>• 地标合影</p>
<p>• 动态全员上车</p>
<p>• 点火瞬间</p>
</div>
`
},
{
icon: '🎨',
name: '特色造型',
detail: `
<div class="sub-detail">
<p><strong>拍摄内容:</strong></p>
<p>• 摆"泉州"两个字造型</p>
<p>• 创意合影</p>
</div>
`
}
]
},
{
title: '互动游戏时间',
time: '16:30 - 18:00',
brief: '10+趣味游戏项目,赢取积分',
overview: `
<div class="overview-text">
<p>🎮 精心准备10+款趣味游戏</p>
<p>🏆 每个游戏都有积分奖励,表现越好得分越高</p>
<p>⚡ 特别设置复活挑战赛,给你翻盘机会</p>
<p class="tip-text">💡 点击下方游戏查看详细规则和得分标准</p>
</div>
`,
items: [
{
// 游戏项目数据
export const gameItems = [
{
icon: '🎯',
name: '套圈圈',
detail: `
@@ -98,8 +12,8 @@ export const timelineData = [
<p>• 套中5个:<span class="score-badge">+5分</span></p>
</div>
`
},
{
},
{
icon: '🏺',
name: '投壶',
detail: `
@@ -111,8 +25,8 @@ export const timelineData = [
<p>• 全部投中:<span class="score-badge">+5分</span></p>
</div>
`
},
{
},
{
icon: '🎲',
name: '摇骰子(067玩法)',
detail: `
@@ -124,8 +38,8 @@ export const timelineData = [
<p>• 摇出5个正豹子:<span class="score-badge">+5分</span>(直接判赢)</p>
</div>
`
},
{
},
{
icon: '🎲',
name: '摇骰子(单骰比大小)',
detail: `
@@ -136,8 +50,8 @@ export const timelineData = [
<p>• 玩家输庄家:<span class="score-badge">-1分</span></p>
</div>
`
},
{
},
{
icon: '🐂',
name: '牛牛',
detail: `
@@ -149,8 +63,8 @@ export const timelineData = [
<p>• 连赢5局:<span class="score-badge">+5分</span></p>
</div>
`
},
{
},
{
icon: '✊',
name: '闽南划拳(两拳倒)',
detail: `
@@ -164,8 +78,8 @@ export const timelineData = [
<p class="highlight-tip">💡 投资机制:可叠加投资,赢得越多分数越高!</p>
</div>
`
},
{
},
{
icon: '⚽',
name: '足球射门',
detail: `
@@ -177,8 +91,8 @@ export const timelineData = [
<p>• 进5球:<span class="score-badge">+5分</span></p>
</div>
`
},
{
},
{
icon: '🎮',
name: '王者荣耀',
detail: `
@@ -193,8 +107,8 @@ export const timelineData = [
<p class="highlight-tip">💡 团队配合最重要,选好英雄!</p>
</div>
`
},
{
},
{
icon: '💧',
name: '抛水瓶',
detail: `
@@ -206,8 +120,8 @@ export const timelineData = [
<p>• 立住5个:<span class="score-badge">+5分</span></p>
</div>
`
},
{
},
{
icon: '🎴',
name: '扑克游戏',
detail: `
@@ -218,8 +132,8 @@ export const timelineData = [
<p>• 输家:<span class="score-badge">-1分</span></p>
</div>
`
},
{
},
{
icon: '🐊',
name: '鳄鱼牙齿/插剑玩具',
detail: `
@@ -233,8 +147,8 @@ export const timelineData = [
<p class="highlight-tip">💡 胆大心细,步步为营!</p>
</div>
`
},
{
},
{
icon: '🪑',
name: '最终复活挑战 - 抢椅子',
detail: `
@@ -248,170 +162,5 @@ export const timelineData = [
<p class="highlight-tip">⚡ 这是翻盘的最佳机会!</p>
</div>
`
}
]
},
{
title: '落日BBQ',
time: '18:00 - 19:30',
brief: '落日余晖下享受美食,拍摄氛围大片',
overview: `
<div class="overview-text">
<p>🌅 在落日余晖下,车辆排成一排</p>
<p>🚗 齐开大灯,营造浪漫氛围</p>
<p>🍖 边烤肉边欣赏晚霞,拍摄氛围感大片</p>
</div>
`,
content: `
<div class="game-rule">
<div class="rule-content">
💡 提示:分享美食照片到朋友圈并@活动官方账号可获得 <span class="score-badge">+1分</span>
</div>
</div>
`
},
{
title: '篝火歌舞会',
time: '19:00 - 20:00',
brief: '围炉夜话,互动游戏,才艺表演',
overview: `
<div class="overview-text">
<p>🔥 点燃篝火,围坐一圈</p>
<p>🎤 击鼓传花、喊数抱团等互动游戏</p>
<p>🎭 才艺表演,展示个人魅力</p>
<p class="tip-text">💡 点击下方查看各个游戏详细规则</p>
</div>
`,
items: [
{
icon: '🥁',
name: '击鼓传花(10轮)',
detail: `
<div class="sub-detail">
<p><strong>游戏规则:</strong>音乐停止时持花者上台,吃柠檬或苦瓜</p>
<p><strong>得分标准:</strong></p>
<p>• 勇敢完成:<span class="score-badge">+2分</span></p>
<p>• 拒绝挑战:<span class="score-badge">-3分</span></p>
</div>
`
},
{
icon: '🤝',
name: '喊数抱团(10轮)',
detail: `
<div class="sub-detail">
<p><strong>游戏规则:</strong>主持人喊数字,参与者按数字抱团,落单者淘汰</p>
<p><strong>惩罚:</strong>上台自我介绍(1分钟)</p>
<p><strong>得分标准:</strong></p>
<p>• 成功抱团:<span class="score-badge">+1分</span></p>
<p>• 落单但完成自我介绍:<span class="score-badge">+2分</span></p>
<p>• 拒绝:<span class="score-badge">-2分</span></p>
</div>
`
},
{
icon: '🎵',
name: '大合唱',
detail: `
<div class="sub-detail">
<p><strong>活动内容:</strong>全员参与,气氛热烈</p>
<p><strong>得分标准:</strong></p>
<p>• 每人:<span class="score-badge">+1分</span></p>
</div>
`
},
{
icon: '🎭',
name: '个人才艺表演',
detail: `
<div class="sub-detail">
<p><strong>活动内容:</strong>自愿上台唱歌、跳舞、表演才艺</p>
<p><strong>提示:</strong>需提前到后台准备BGM</p>
<p><strong>得分标准:</strong></p>
<p>• 参与表演:<span class="score-badge">+3分</span></p>
<p>• 精彩表演:<span class="score-badge">+5分</span></p>
</div>
`
},
{
icon: '👶',
name: '小朋友互动',
detail: `
<div class="sub-detail">
<p><strong>活动内容:</strong>主持人邀请小朋友上台互动,参与即有礼物</p>
<p><strong>得分标准:</strong></p>
<p>• 家长陪同参与:<span class="score-badge">+2分</span></p>
</div>
`
}
]
},
{
title: '璀璨烟火秀',
time: '20:00 - 20:30',
brief: '专业烟火表演,点亮夜空',
overview: `
<div class="overview-text">
<p>🎆 大型专业烟火表演</p>
<p>✨ 每车配发手持仙女棒</p>
<p>🔥 加特林压轴,震撼收尾</p>
</div>
`,
items: [
{
icon: '🌃',
name: '米兰之夜',
detail: `
<div class="sub-detail">
<p><strong>表演内容:</strong>大型专业烟火表演,点亮夜空</p>
</div>
`
},
{
icon: '✨',
name: '仙女棒',
detail: `
<div class="sub-detail">
<p><strong>活动内容:</strong>每车配发手持烟花,拍照打卡</p>
</div>
`
},
{
icon: '🔥',
name: '加特林压轴',
detail: `
<div class="sub-detail">
<p><strong>表演内容:</strong>震撼收尾,全场欢呼</p>
<p class="highlight-tip">💡 拍摄烟花视频并分享可获得 <span class="score-badge">+2分</span></p>
</div>
`
}
]
},
{
title: '积分兑奖 & 自由交流',
time: '20:30 以后',
brief: '积分兑换礼品,自由合影交流',
overview: `
<div class="overview-text">
<p>🏆 根据积分排行榜兑换礼品</p>
<p>📸 自由合影,交流心得</p>
<p>🚗 整理垃圾,安全驾驶返程</p>
</div>
`,
content: `
<div class="game-rule">
<div class="rule-title">🏆 积分兑换礼品</div>
<div class="rule-content">
<strong>积分排行榜:</strong><br>
• 第1名(20分以上):神秘大奖<br>
• 第2-3名(15-19分):精美礼品<br>
• 第4-10名(10-14分):纪念品<br>
• 参与奖(5-9分):小礼物<br>
<br>
随后自由合影、整理垃圾、提醒安全驾驶返程
</div>
</div>
`
}
}
];