新增节日福利系统:①数据库表+模型 ②TriggerHolidayEventJob队列任务(在线用户筛选/金额分配/WebSocket广播) ③后台管理页面(列表/创建/手动触发) ④前台领取弹窗+WebSocket监听 ⑤定时调度每分钟扫描 ⑥CurrencySource补充HOLIDAY_BONUS

This commit is contained in:
2026-03-01 20:06:53 +08:00
parent a37b04aca0
commit c5fe9faf94
16 changed files with 1504 additions and 1 deletions
@@ -0,0 +1,191 @@
@extends('admin.layouts.app')
@section('title', '创建节日福利活动')
@section('content')
<div class="max-w-3xl mx-auto space-y-6">
<div class="bg-white rounded-xl shadow-sm border border-gray-100 p-6">
<div class="flex items-center gap-3 mb-6">
<a href="{{ route('admin.holiday-events.index') }}" class="text-gray-400 hover:text-gray-600"> 返回列表</a>
<h2 class="text-lg font-bold text-gray-800">🎊 创建节日福利活动</h2>
</div>
@if ($errors->any())
<div class="bg-red-50 border border-red-200 rounded-lg p-4 mb-6">
<ul class="list-disc list-inside text-sm text-red-700 space-y-1">
@foreach ($errors->all() as $error)
<li>{{ $error }}</li>
@endforeach
</ul>
</div>
@endif
<form action="{{ route('admin.holiday-events.store') }}" method="POST" x-data="holidayForm()">
@csrf
{{-- 基础信息 --}}
<div class="mb-6">
<h3 class="text-sm font-bold text-gray-700 mb-3 pb-2 border-b">📋 基础信息</h3>
<div class="grid grid-cols-1 gap-4">
<div>
<label class="block text-xs font-bold text-gray-600 mb-1">活动名称 <span
class="text-red-500">*</span></label>
<input type="text" name="name" value="{{ old('name') }}" required placeholder="例:元旦快乐🎊"
class="w-full border border-gray-300 rounded-lg p-2.5 text-sm focus:border-amber-400 focus:ring-amber-400">
</div>
<div>
<label class="block text-xs font-bold text-gray-600 mb-1">活动描述 <span
class="text-gray-400 font-normal">(可选,公屏广播时显示)</span></label>
<textarea name="description" rows="2" placeholder="例:新年快乐!感谢大家一直以来的陪伴,送上新年礼物!"
class="w-full border border-gray-300 rounded-lg p-2.5 text-sm focus:border-amber-400 focus:ring-amber-400">{{ old('description') }}</textarea>
</div>
</div>
</div>
{{-- 奖励配置 --}}
<div class="mb-6">
<h3 class="text-sm font-bold text-gray-700 mb-3 pb-2 border-b">🪙 奖励配置</h3>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-xs font-bold text-gray-600 mb-1">总金币奖池 <span
class="text-red-500">*</span></label>
<input type="number" name="total_amount" value="{{ old('total_amount', 100000) }}" required
min="1" class="w-full border border-gray-300 rounded-lg p-2.5 text-sm">
</div>
<div>
<label class="block text-xs font-bold text-gray-600 mb-1">可领取人数上限 <span
class="text-gray-400 font-normal">0=不限)</span></label>
<input type="number" name="max_claimants" value="{{ old('max_claimants', 0) }}" min="0"
class="w-full border border-gray-300 rounded-lg p-2.5 text-sm">
</div>
<div class="col-span-2">
<label class="block text-xs font-bold text-gray-600 mb-2">分配方式 <span
class="text-red-500">*</span></label>
<div class="flex gap-4">
<label class="flex items-center gap-2 cursor-pointer">
<input type="radio" name="distribute_type" value="random" x-model="distributeType"
{{ old('distribute_type', 'random') === 'random' ? 'checked' : '' }}>
<span class="text-sm font-bold">🎲 随机分配</span>
<span class="text-xs text-gray-400">(二倍均值算法,每人金额不同)</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="radio" name="distribute_type" value="fixed" x-model="distributeType"
{{ old('distribute_type') === 'fixed' ? 'checked' : '' }}>
<span class="text-sm font-bold">📏 定额发放</span>
<span class="text-xs text-gray-400">(每人相同金额)</span>
</label>
</div>
</div>
{{-- 随机模式配置 --}}
<div x-show="distributeType === 'random'" class="col-span-2">
<div class="grid grid-cols-2 gap-4 bg-purple-50 rounded-lg p-4">
<div>
<label class="block text-xs font-bold text-gray-600 mb-1">最低保底金额</label>
<input type="number" name="min_amount" value="{{ old('min_amount', 100) }}"
min="1" class="w-full border border-gray-300 rounded-lg p-2.5 text-sm">
</div>
<div>
<label class="block text-xs font-bold text-gray-600 mb-1">单人最高上限 <span
class="text-gray-400 font-normal">(可选)</span></label>
<input type="number" name="max_amount" value="{{ old('max_amount') }}" min="1"
class="w-full border border-gray-300 rounded-lg p-2.5 text-sm"
placeholder="不填则自动计算">
</div>
</div>
</div>
{{-- 定额模式配置 --}}
<div x-show="distributeType === 'fixed'" class="col-span-2">
<div class="bg-blue-50 rounded-lg p-4">
<label class="block text-xs font-bold text-gray-600 mb-1">每人固定金额 <span
class="text-red-500">*</span></label>
<input type="number" name="fixed_amount" value="{{ old('fixed_amount', 500) }}"
min="1" class="w-full border border-gray-300 rounded-lg p-2.5 text-sm">
<p class="text-xs text-gray-400 mt-1">💡 总发放 = 固定金额 × 在线人数(受最大领取人数限制)</p>
</div>
</div>
</div>
</div>
{{-- 时间配置 --}}
<div class="mb-6">
<h3 class="text-sm font-bold text-gray-700 mb-3 pb-2 border-b"> 时间配置</h3>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-xs font-bold text-gray-600 mb-1">触发时间 <span
class="text-red-500">*</span></label>
<input type="datetime-local" name="send_at" value="{{ old('send_at') }}" required
class="w-full border border-gray-300 rounded-lg p-2.5 text-sm">
</div>
<div>
<label class="block text-xs font-bold text-gray-600 mb-1">领取有效期(分钟)</label>
<input type="number" name="expire_minutes" value="{{ old('expire_minutes', 30) }}"
min="1" max="1440"
class="w-full border border-gray-300 rounded-lg p-2.5 text-sm">
</div>
<div class="col-span-2">
<label class="block text-xs font-bold text-gray-600 mb-1">重复方式</label>
<select name="repeat_type" class="w-full border border-gray-300 rounded-lg p-2.5 text-sm">
<option value="once" {{ old('repeat_type', 'once') === 'once' ? 'selected' : '' }}>仅一次
</option>
<option value="daily" {{ old('repeat_type') === 'daily' ? 'selected' : '' }}>每天(相同时间)
</option>
<option value="weekly" {{ old('repeat_type') === 'weekly' ? 'selected' : '' }}>每周(相同时间)
</option>
<option value="monthly" {{ old('repeat_type') === 'monthly' ? 'selected' : '' }}>
每月(相同日期时间)</option>
</select>
</div>
</div>
</div>
{{-- 目标用户 --}}
<div class="mb-6">
<h3 class="text-sm font-bold text-gray-700 mb-3 pb-2 border-b">🎯 目标用户</h3>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-xs font-bold text-gray-600 mb-1">用户范围</label>
<select name="target_type" x-model="targetType"
class="w-full border border-gray-300 rounded-lg p-2.5 text-sm">
<option value="all">全部在线用户</option>
<option value="vip"> VIP 用户</option>
<option value="level">指定等级以上</option>
</select>
</div>
<div x-show="targetType === 'level'">
<label class="block text-xs font-bold text-gray-600 mb-1">最低用户等级</label>
<input type="number" name="target_value" value="{{ old('target_value', 1) }}"
min="1" class="w-full border border-gray-300 rounded-lg p-2.5 text-sm"
placeholder="例:10">
</div>
</div>
</div>
{{-- 提交 --}}
<div class="flex gap-3 pt-4 border-t">
<button type="submit"
class="px-8 py-2.5 bg-amber-500 text-white rounded-lg font-bold hover:bg-amber-600 transition shadow-sm">
🎊 创建活动
</button>
<a href="{{ route('admin.holiday-events.index') }}"
class="px-6 py-2.5 bg-gray-100 text-gray-600 rounded-lg font-bold hover:bg-gray-200 transition">
取消
</a>
</div>
</form>
</div>
</div>
<script>
/**
* 节日福利创建表单 Alpine 组件
*/
function holidayForm() {
return {
distributeType: '{{ old('distribute_type', 'random') }}',
targetType: '{{ old('target_type', 'all') }}',
};
}
</script>
@endsection
@@ -0,0 +1,177 @@
@extends('admin.layouts.app')
@section('title', '节日福利管理')
@section('content')
<div class="space-y-6">
{{-- 页头 --}}
<div class="bg-white rounded-xl shadow-sm border border-gray-100 p-6 flex justify-between items-center">
<div>
<h2 class="text-lg font-bold text-gray-800">🎊 节日福利管理</h2>
<p class="text-xs text-gray-500 mt-1">配置定时发放的节日金币福利,系统自动触发广播并分配红包。</p>
</div>
<a href="{{ route('admin.holiday-events.create') }}"
class="px-5 py-2 bg-amber-500 text-white rounded-lg font-bold hover:bg-amber-600 transition text-sm shadow-sm">
创建活动
</a>
</div>
{{-- 成功/错误提示 --}}
@if (session('success'))
<div class="bg-green-50 border border-green-200 text-green-700 px-4 py-3 rounded-lg text-sm">
{{ session('success') }}
</div>
@endif
@if (session('error'))
<div class="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg text-sm">
{{ session('error') }}
</div>
@endif
{{-- 活动列表 --}}
<div class="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden">
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="bg-gray-50 text-gray-600 text-left border-b text-xs font-bold">
<th class="p-3 w-10">ID</th>
<th class="p-3">活动名称</th>
<th class="p-3 w-24">奖池</th>
<th class="p-3 w-20">分配方式</th>
<th class="p-3 w-20">限额人数</th>
<th class="p-3 w-36">触发时间</th>
<th class="p-3 w-16">重复</th>
<th class="p-3 w-16">状态</th>
<th class="p-3 w-16">启用</th>
<th class="p-3 w-40 text-right">操作</th>
</tr>
</thead>
<tbody>
@forelse ($events as $event)
<tr class="border-b hover:bg-gray-50 {{ !$event->enabled ? 'opacity-50' : '' }}">
<td class="p-3 text-gray-400">{{ $event->id }}</td>
<td class="p-3">
<div class="font-bold text-gray-800">{{ $event->name }}</div>
@if ($event->description)
<div class="text-xs text-gray-400 mt-0.5">{{ Str::limit($event->description, 40) }}
</div>
@endif
</td>
<td class="p-3 font-bold text-amber-600">
🪙 {{ number_format($event->total_amount) }}
</td>
<td class="p-3">
@if ($event->distribute_type === 'random')
<span
class="px-2 py-0.5 rounded-full bg-purple-100 text-purple-700 text-xs">随机</span>
@else
<span class="px-2 py-0.5 rounded-full bg-blue-100 text-blue-700 text-xs">定额
{{ number_format($event->fixed_amount) }}</span>
@endif
</td>
<td class="p-3 text-gray-600">
{{ $event->max_claimants === 0 ? '不限' : $event->max_claimants . ' 人' }}
</td>
<td class="p-3 text-gray-600 text-xs">
{{ $event->send_at->format('m-d H:i') }}
</td>
<td class="p-3">
@php
$repeatLabels = [
'once' => '一次',
'daily' => '每天',
'weekly' => '每周',
'monthly' => '每月',
'cron' => 'CRON',
];
@endphp
<span
class="text-xs text-gray-500">{{ $repeatLabels[$event->repeat_type] ?? '-' }}</span>
</td>
<td class="p-3">
@php
$statusMap = [
'pending' => ['待触发', 'bg-yellow-100 text-yellow-700'],
'active' => ['领取中', 'bg-green-100 text-green-700'],
'completed' => ['已结束', 'bg-gray-100 text-gray-500'],
'cancelled' => ['已取消', 'bg-red-100 text-red-600'],
];
[$label, $cls] = $statusMap[$event->status] ?? [
'未知',
'bg-gray-100 text-gray-500',
];
@endphp
<span
class="px-2 py-0.5 rounded-full text-xs {{ $cls }}">{{ $label }}</span>
</td>
<td class="p-3">
<button onclick="toggleHoliday({{ $event->id }}, this)"
class="text-xs px-2 py-1 rounded {{ $event->enabled ? 'bg-green-100 text-green-700' : 'bg-gray-200 text-gray-500' }} cursor-pointer">
{{ $event->enabled ? '启用' : '禁用' }}
</button>
</td>
<td class="p-3 text-right space-x-2">
{{-- 手动触发 --}}
@if ($event->status === 'pending')
<form action="{{ route('admin.holiday-events.trigger-now', $event) }}"
method="POST" class="inline" onsubmit="return confirm('确定立即触发此活动吗?')">
@csrf
<button type="submit"
class="text-xs px-3 py-1 bg-amber-500 text-white rounded-lg hover:bg-amber-600 font-bold">
立即触发
</button>
</form>
@endif
<a href="{{ route('admin.holiday-events.edit', $event) }}"
class="text-xs px-3 py-1 bg-indigo-100 text-indigo-700 rounded-lg hover:bg-indigo-200 font-bold">编辑</a>
<form action="{{ route('admin.holiday-events.destroy', $event) }}" method="POST"
class="inline" onsubmit="return confirm('确定删除此活动吗?')">
@csrf
@method('DELETE')
<button type="submit"
class="text-xs px-3 py-1 bg-red-100 text-red-600 rounded-lg hover:bg-red-200 font-bold">删除</button>
</form>
</td>
</tr>
@empty
<tr>
<td colspan="10" class="p-10 text-center text-gray-400">
暂无节日福利活动,<a href="{{ route('admin.holiday-events.create') }}"
class="text-amber-500 font-bold">立即创建一个</a>
</td>
</tr>
@endforelse
</tbody>
</table>
</div>
@if ($events->hasPages())
<div class="p-4 border-t">{{ $events->links() }}</div>
@endif
</div>
</div>
<script>
/**
* 切换节日活动启用/禁用状态
*/
function toggleHoliday(id, btn) {
fetch(`/admin/holiday-events/${id}/toggle`, {
method: 'POST',
headers: {
'X-CSRF-TOKEN': '{{ csrf_token() }}',
'Accept': 'application/json',
},
})
.then(r => r.json())
.then(data => {
if (data.ok) {
btn.textContent = data.enabled ? '启用' : '禁用';
btn.className = data.enabled ?
'text-xs px-2 py-1 rounded bg-green-100 text-green-700 cursor-pointer' :
'text-xs px-2 py-1 rounded bg-gray-200 text-gray-500 cursor-pointer';
btn.closest('tr').classList.toggle('opacity-50', !data.enabled);
}
});
}
</script>
@endsection