diff --git a/packages/client-screen/src/views/AdminControl.vue b/packages/client-screen/src/views/AdminControl.vue index b18a8dd..b95a475 100644 --- a/packages/client-screen/src/views/AdminControl.vue +++ b/packages/client-screen/src/views/AdminControl.vue @@ -41,6 +41,15 @@ const confirmResetCode = ref(''); const showResetModal = ref(false); const resetScope = ref<'all' | 'voting' | 'lottery'>('all'); +// Advanced cleanup state +const showAdvancedCleanupModal = ref(false); +const confirmCleanupCode = ref(''); +const cleanupLoading = ref(false); +const cleanupOptions = ref({ + lottery: { redis: true, mysql: true }, + voting: { redis: false, mysql: false }, +}); + // Toast notification state const toast = ref<{ show: boolean; message: string; type: 'error' | 'success' | 'info' }>({ show: false, @@ -237,7 +246,7 @@ function goBack() { } // Phase control -function setPhase(phase: 'IDLE' | 'VOTING' | 'LOTTERY' | 'RESULTS') { +function setPhase(phase: 'IDLE' | 'VOTING' | 'LOTTERY' | 'RESULTS' | 'LOTTERY_RESULTS') { admin.setPhase(phase); } @@ -315,6 +324,28 @@ const resetLottery = debounceLeading(() => { admin.controlLottery('reset'); }, 500); +// Redraw current round +const showRedrawConfirm = ref(false); + +async function redrawCurrentRound() { + try { + const res = await fetch('/api/admin/lottery/redraw', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + }); + const data = await res.json(); + if (data.success) { + showToast(data.message || '本轮已重置,可重新抽取', 'success'); + } else { + showToast(data.error || '重抽失败', 'error'); + } + } catch (e) { + showToast('网络错误', 'error'); + } finally { + showRedrawConfirm.value = false; + } +} + // Emergency reset function openResetModal(scope: 'all' | 'voting' | 'lottery') { resetScope.value = scope; @@ -333,6 +364,54 @@ function cancelReset() { confirmResetCode.value = ''; } +// Advanced cleanup +function openAdvancedCleanupModal() { + confirmCleanupCode.value = ''; + cleanupOptions.value = { + lottery: { redis: true, mysql: true }, + voting: { redis: false, mysql: false }, + }; + showAdvancedCleanupModal.value = true; +} + +function cancelAdvancedCleanup() { + showAdvancedCleanupModal.value = false; + confirmCleanupCode.value = ''; +} + +async function confirmAdvancedCleanup() { + if (confirmCleanupCode.value !== 'ADVANCED_CLEANUP') return; + + cleanupLoading.value = true; + try { + const res = await fetch('/api/admin/cleanup', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + lottery: cleanupOptions.value.lottery, + voting: cleanupOptions.value.voting, + confirmCode: confirmCleanupCode.value, + }), + }); + const data = await res.json(); + if (data.success) { + const results = data.data; + let message = '清理完成:'; + if (results.lottery?.mysql) message += `抽奖数据 ${results.lottery.dbCount} 条;`; + if (results.voting?.mysql) message += `投票数据 ${results.voting.dbCount} 条;`; + showToast(message, 'success'); + } else { + showToast(data.error || '清理失败', 'error'); + } + } catch (e) { + showToast('网络错误', 'error'); + } finally { + cleanupLoading.value = false; + showAdvancedCleanupModal.value = false; + confirmCleanupCode.value = ''; + } +} + // Music control function toggleMusic() { if (admin.musicPlaying) { @@ -379,6 +458,7 @@ const phaseLabel = computed(() => { case 'VOTING': return '投票中'; case 'LOTTERY': return '抽奖中'; case 'RESULTS': return '结果展示'; + case 'LOTTERY_RESULTS': return '抽奖结果'; default: return '未知'; } }); @@ -643,7 +723,13 @@ onMounted(() => { :disabled="admin.pendingAction === 'lottery_reset'" @click="resetLottery" > - 重置 + 重置本轮 + + @@ -800,6 +886,13 @@ onMounted(() => { > 结果展示 + @@ -833,15 +926,25 @@ onMounted(() => { + +
+

开发者选项

+
+ +
+
+
-

紧急操作

+

数据管理

+ +
+
+ + + + @@ -1368,6 +1551,16 @@ $admin-danger: #ef4444; } } + &.warning-outline { + background: transparent; + border-color: $admin-warning; + color: $admin-warning; + + &:hover:not(:disabled) { + background: rgba($admin-warning, 0.1); + } + } + &.outline { background: transparent; border-color: $admin-border; @@ -1535,6 +1728,47 @@ $admin-danger: #ef4444; color: $admin-danger; } } + + &.warning-zone { + padding-top: 20px; + border-top: 1px solid rgba($admin-warning, 0.3); + + h4 { + color: $admin-warning; + } + } + + &.dev-options { + padding-top: 20px; + border-top: 1px solid rgba($admin-warning, 0.3); + + summary { + cursor: pointer; + list-style: none; + + &::-webkit-details-marker { + display: none; + } + + h4 { + display: inline; + color: $admin-warning; + + &::before { + content: '▶ '; + font-size: 0.8em; + } + } + } + + &[open] summary h4::before { + content: '▼ '; + } + + .button-group { + margin-top: 12px; + } + } } // Modal @@ -1609,6 +1843,75 @@ $admin-danger: #ef4444; } } +// Advanced Cleanup Modal Styles +.cleanup-modal { + width: 500px; + max-height: 80vh; + overflow-y: auto; +} + +.cleanup-sections { + display: flex; + flex-direction: column; + gap: 20px; + margin: 20px 0; +} + +.cleanup-section { + background: rgba(255, 255, 255, 0.02); + border: 1px solid $admin-border; + border-radius: 12px; + padding: 16px; + + h4 { + font-size: 14px; + color: $admin-text; + margin: 0 0 12px 0; + display: flex; + align-items: center; + gap: 8px; + } +} + +.cleanup-options { + display: flex; + flex-direction: column; + gap: 10px; + + .checkbox-label { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 12px; + background: rgba(255, 255, 255, 0.03); + border-radius: 8px; + cursor: pointer; + transition: background 0.2s; + + &:hover { + background: rgba(255, 255, 255, 0.05); + } + + input[type="checkbox"] { + width: 18px; + height: 18px; + accent-color: $admin-warning; + } + + span { + font-size: 14px; + color: $admin-text; + font-weight: 500; + } + + small { + margin-left: auto; + font-size: 12px; + color: $admin-text-muted; + } + } +} + // Award Statistics Styles .award-stats { display: grid; @@ -1899,6 +2202,30 @@ $admin-danger: #ef4444; } } + .cleanup-modal { + width: 95vw; + } + + .cleanup-section { + padding: 12px; + + h4 { + font-size: 13px; + } + } + + .cleanup-options .checkbox-label { + padding: 8px 10px; + + span { + font-size: 13px; + } + + small { + font-size: 11px; + } + } + .modal-input input { padding: 10px; font-size: 14px; diff --git a/packages/server/src/routes/admin.routes.ts b/packages/server/src/routes/admin.routes.ts index 92e97f9..c80ea4a 100644 --- a/packages/server/src/routes/admin.routes.ts +++ b/packages/server/src/routes/admin.routes.ts @@ -3,6 +3,7 @@ import multer from 'multer'; import { participantService } from '../services/participant.service'; import { prizeConfigService } from '../services/prize-config.service'; import { programConfigService } from '../services/program-config.service'; +import { adminService } from '../services/admin.service'; const router: IRouter = Router(); const upload = multer({ storage: multer.memoryStorage() }); @@ -204,5 +205,95 @@ router.get('/participants', async (_req, res, next) => { } }); +/** + * GET /api/admin/lottery/results + * Get all lottery results from database + */ +router.get('/lottery/results', async (_req, res, next) => { + try { + const results = await adminService.getLotteryResults(); + return res.json({ + success: true, + data: results, + }); + } catch (error) { + next(error); + } +}); + +/** + * DELETE /api/admin/lottery/history + * Clear all lottery history (requires confirmation code) + */ +router.delete('/lottery/history', async (req, res, next) => { + try { + const { confirmCode } = req.body; + const result = await adminService.clearLotteryHistory(confirmCode); + if (result.success) { + return res.json({ + success: true, + message: result.message, + data: result.cleared, + }); + } else { + return res.status(400).json({ + success: false, + error: result.message, + }); + } + } catch (error) { + next(error); + } +}); + +/** + * POST /api/admin/lottery/redraw + * Redraw current round - clears current round winners and allows re-drawing + */ +router.post('/lottery/redraw', async (_req, res, next) => { + try { + const result = await adminService.redrawCurrentRound(); + if (result.success) { + return res.json({ + success: true, + message: result.message, + }); + } else { + return res.status(400).json({ + success: false, + error: result.message, + }); + } + } catch (error) { + next(error); + } +}); + +/** + * POST /api/admin/cleanup + * Advanced data cleanup with granular control + * Body: { lottery?: { redis?: boolean; mysql?: boolean }, voting?: { redis?: boolean; mysql?: boolean }, confirmCode: string } + */ +router.post('/cleanup', async (req, res, next) => { + try { + const { lottery, voting, confirmCode } = req.body; + const result = await adminService.advancedCleanup({ lottery, voting }, confirmCode); + if (result.success) { + return res.json({ + success: true, + message: result.message, + data: result.results, + }); + } else { + return res.status(400).json({ + success: false, + error: result.message, + }); + } + } catch (error) { + next(error); + } +}); + export default router; diff --git a/packages/server/src/services/admin.service.ts b/packages/server/src/services/admin.service.ts index a7fa97d..3152a51 100644 --- a/packages/server/src/services/admin.service.ts +++ b/packages/server/src/services/admin.service.ts @@ -3,6 +3,7 @@ */ import { EventEmitter } from 'events'; import { redis } from '../config/redis'; +import { prisma } from '../config/db'; import { logger } from '../utils/logger'; import { prizeConfigService } from './prize-config.service'; import { programConfigService } from './program-config.service'; @@ -436,10 +437,14 @@ class AdminService extends EventEmitter { if (this.state.lottery.subPhase !== 'STORM') { return { success: false, message: 'Must be in STORM state to reveal' }; } - // Pick random winners (simplified - in production would use database) + // Pick random winners from participants const winners = this.pickRandomWinners(); this.state.lottery.subPhase = 'REVEAL'; this.state.lottery.currentWinners = winners; + + // Persist winners to database + await this.persistLotteryResults(winners); + // Auto-play fanfare music this.state.music.isPlaying = true; this.state.music.track = 'fanfare'; @@ -478,17 +483,9 @@ class AdminService extends EventEmitter { break; case 'reset': - // 使用服务器配置获取第一轮奖项 - const resetPrizes = prizeConfigService.getPrizes(); - const firstPrize = resetPrizes.find(p => p.round === 1) || resetPrizes[0] || PRIZE_CONFIG[0]; - this.state.lottery = { - round: 1, - subPhase: 'IDLE', - prizeLevel: firstPrize?.level || '第一轮抽奖', - prizeName: firstPrize?.name || '幸运伴手礼', - winnerCount: firstPrize?.winnerCount || 7, - currentWinners: [], - }; + // 仅重置当前轮次状态到 IDLE,不清理数据 + this.state.lottery.subPhase = 'IDLE'; + this.state.lottery.currentWinners = []; break; } await this.saveState(); @@ -500,6 +497,39 @@ class AdminService extends EventEmitter { } } + /** + * Redraw current round - clears current round winners and resets state + * Allows re-drawing the same round + */ + async redrawCurrentRound(): Promise<{ success: boolean; message?: string }> { + try { + const currentRound = this.state.lottery.round; + + // Clear current round winners from Redis + const roundWinnersKey = `draw:round:${currentRound}:winners`; + const winnersToRemove = await redis.smembers(roundWinnersKey); + + if (winnersToRemove.length > 0) { + // Remove from global winners set + await redis.srem('draw:winners', ...winnersToRemove); + // Clear round-specific winners + await redis.del(roundWinnersKey); + } + + // Reset lottery state to IDLE for current round + this.state.lottery.subPhase = 'IDLE'; + this.state.lottery.currentWinners = []; + + await this.saveState(); + logger.warn({ round: currentRound, clearedWinners: winnersToRemove.length }, 'Current round redrawn'); + + return { success: true, message: `第${currentRound}轮已重置,可重新抽取` }; + } catch (error) { + logger.error({ error }, 'Failed to redraw current round'); + return { success: false, message: 'Failed to redraw current round' }; + } + } + /** * Pick random winners from real participants */ @@ -540,6 +570,106 @@ class AdminService extends EventEmitter { return winners; } + /** + * Persist lottery results to database + */ + private async persistLotteryResults(winners: LotteryWinner[]): Promise { + try { + const drawId = `draw-${Date.now()}`; + const prizeLevel = this.state.lottery.prizeLevel; + const prizeName = this.state.lottery.prizeName; + const round = this.state.lottery.round; + + // Create draw results in database + await prisma.drawResult.createMany({ + data: winners.map(winner => ({ + drawId, + prizeLevel, + prizeName, + winnerId: winner.id, + winnerName: winner.name, + winnerDepartment: winner.department, + drawnBy: 'system', + })), + }); + + // Create or update draw session + await prisma.drawSession.upsert({ + where: { id: drawId }, + create: { + id: drawId, + prizeLevel, + prizeName, + totalPrizes: winners.length, + drawnCount: winners.length, + isActive: false, + startedAt: new Date(), + endedAt: new Date(), + }, + update: { + drawnCount: winners.length, + endedAt: new Date(), + }, + }); + + logger.info( + { drawId, round, prizeName, winnerCount: winners.length }, + 'Lottery results persisted to database' + ); + } catch (error) { + logger.error({ error }, 'Failed to persist lottery results'); + // Don't throw - we don't want to break the lottery flow if DB fails + } + } + + /** + * Get all lottery results from database + */ + async getLotteryResults(): Promise<{ + draws: Array<{ + id: string; + prizeLevel: string; + prizeName: string; + winners: Array<{ + id: string; + name: string; + department: string; + }>; + drawnAt: Date; + }>; + }> { + try { + const results = await prisma.drawResult.findMany({ + orderBy: { drawnAt: 'desc' }, + }); + + // Group by drawId + const grouped = new Map(); + for (const result of results) { + const existing = grouped.get(result.drawId) || []; + existing.push(result); + grouped.set(result.drawId, existing); + } + + const draws = Array.from(grouped.entries()).map(([drawId, winners]) => ({ + id: drawId, + prizeLevel: winners[0]?.prizeLevel || '', + prizeName: winners[0]?.prizeName || '', + winners: winners.map(w => ({ + id: w.winnerId, + name: w.winnerName, + department: w.winnerDepartment, + })), + drawnAt: winners[0]?.drawnAt || new Date(), + })); + + return { draws }; + } catch (error) { + logger.error({ error }, 'Failed to get lottery results'); + return { draws: [] }; + } + } + /** * Emergency reset */ @@ -560,8 +690,9 @@ class AdminService extends EventEmitter { awards: programConfigService.getAwards(), allowLateCatch: programSettings.allowLateCatch, }; - // Clear voting data in Redis + // Clear voting data in Redis and Database await this.clearVotingRedisData(); + await this.clearVotingDbData(); } if (scope === 'all' || scope === 'lottery') { @@ -576,6 +707,9 @@ class AdminService extends EventEmitter { winnerCount: firstPrize?.winnerCount || 7, currentWinners: [], }; + // Clear lottery data in Redis and Database + await this.clearLotteryRedisData(); + await this.clearLotteryDbData(); } if (scope === 'all') { @@ -692,6 +826,180 @@ class AdminService extends EventEmitter { } while (cursor !== '0'); } } + + /** + * Clear lottery-related Redis keys + */ + private async clearLotteryRedisData(): Promise { + const patterns = [ + 'draw:winners*', + 'draw:pool:*', + 'draw:history', + 'draw:excluded', + 'draw:temp:*', + ]; + + for (const pattern of patterns) { + let cursor = '0'; + do { + const [nextCursor, keys] = await redis.scan(cursor, 'MATCH', pattern, 'COUNT', 1000); + cursor = nextCursor; + if (keys.length > 0) { + await redis.del(...keys); + } + } while (cursor !== '0'); + } + + logger.info('Lottery Redis data cleared'); + } + + /** + * Clear lottery results from database + */ + private async clearLotteryDbData(): Promise { + try { + // Delete all draw results + await prisma.drawResult.deleteMany({}); + // Delete all draw sessions + await prisma.drawSession.deleteMany({}); + logger.info('Lottery database records cleared'); + } catch (error) { + logger.error({ error }, 'Failed to clear lottery database records'); + // Don't throw - we don't want to break the reset flow if DB fails + } + } + + /** + * Clear voting results from database + */ + private async clearVotingDbData(): Promise { + try { + // Delete all votes + await prisma.vote.deleteMany({}); + // Delete all vote counts + await prisma.voteCount.deleteMany({}); + logger.info('Voting database records cleared'); + } catch (error) { + logger.error({ error }, 'Failed to clear voting database records'); + // Don't throw - we don't want to break the reset flow if DB fails + } + } + + /** + * Clear all lottery data (Redis + Database) + * This is a destructive operation and should be used with caution + */ + async clearLotteryHistory(confirmCode: string): Promise<{ success: boolean; message?: string; cleared?: { db: number; redis: boolean } }> { + if (confirmCode !== 'CLEAR_LOTTERY') { + return { success: false, message: 'Invalid confirmation code. Use CLEAR_LOTTERY to confirm.' }; + } + + try { + // Clear Redis data + await this.clearLotteryRedisData(); + + // Clear database records + const dbResult = await prisma.drawResult.count(); + await this.clearLotteryDbData(); + + logger.warn('Lottery history cleared by admin'); + return { + success: true, + message: 'Lottery history cleared successfully', + cleared: { + db: dbResult, + redis: true, + }, + }; + } catch (error) { + logger.error({ error }, 'Failed to clear lottery history'); + return { success: false, message: 'Failed to clear lottery history' }; + } + } + + /** + * Advanced data cleanup - clears data by type and storage layer + * This provides granular control over what data to clear + */ + async advancedCleanup( + options: { + lottery?: { redis?: boolean; mysql?: boolean }; + voting?: { redis?: boolean; mysql?: boolean }; + }, + confirmCode: string + ): Promise<{ + success: boolean; + message?: string; + results?: { + lottery?: { redis: boolean; mysql: boolean; dbCount: number }; + voting?: { redis: boolean; mysql: boolean; dbCount: number }; + }; + }> { + if (confirmCode !== 'ADVANCED_CLEANUP') { + return { success: false, message: 'Invalid confirmation code. Use ADVANCED_CLEANUP to confirm.' }; + } + + const results: { + lottery?: { redis: boolean; mysql: boolean; dbCount: number }; + voting?: { redis: boolean; mysql: boolean; dbCount: number }; + } = {}; + + try { + // Handle lottery cleanup + if (options.lottery) { + const lotteryResult: { redis: boolean; mysql: boolean; dbCount: number } = { + redis: false, + mysql: false, + dbCount: 0, + }; + + if (options.lottery.redis) { + await this.clearLotteryRedisData(); + lotteryResult.redis = true; + } + + if (options.lottery.mysql) { + lotteryResult.dbCount = await prisma.drawResult.count(); + await this.clearLotteryDbData(); + lotteryResult.mysql = true; + } + + results.lottery = lotteryResult; + } + + // Handle voting cleanup + if (options.voting) { + const votingResult: { redis: boolean; mysql: boolean; dbCount: number } = { + redis: false, + mysql: false, + dbCount: 0, + }; + + if (options.voting.redis) { + await this.clearVotingRedisData(); + votingResult.redis = true; + } + + if (options.voting.mysql) { + votingResult.dbCount = await prisma.vote.count(); + await this.clearVotingDbData(); + votingResult.mysql = true; + } + + results.voting = votingResult; + } + + logger.warn({ options, results }, 'Advanced cleanup executed by admin'); + return { + success: true, + message: 'Advanced cleanup completed successfully', + results, + }; + } catch (error) { + logger.error({ error, options }, 'Failed to execute advanced cleanup'); + return { success: false, message: 'Failed to execute advanced cleanup' }; + } + } } export const adminService = new AdminService();