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();