新增节日福利系统:①数据库表+模型 ②TriggerHolidayEventJob队列任务(在线用户筛选/金额分配/WebSocket广播) ③后台管理页面(列表/创建/手动触发) ④前台领取弹窗+WebSocket监听 ⑤定时调度每分钟扫描 ⑥CurrencySource补充HOLIDAY_BONUS
This commit is contained in:
@@ -69,6 +69,9 @@ enum CurrencySource: string
|
|||||||
/** 强制离婚财产转移(付出方为负,接收方为正) */
|
/** 强制离婚财产转移(付出方为负,接收方为正) */
|
||||||
case FORCED_DIVORCE_TRANSFER = 'forced_divorce_transfer';
|
case FORCED_DIVORCE_TRANSFER = 'forced_divorce_transfer';
|
||||||
|
|
||||||
|
/** 节日福利红包(管理员设置的定时金币福利) */
|
||||||
|
case HOLIDAY_BONUS = 'holiday_bonus';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 返回该来源的中文名称,用于后台统计展示。
|
* 返回该来源的中文名称,用于后台统计展示。
|
||||||
*/
|
*/
|
||||||
@@ -91,6 +94,7 @@ enum CurrencySource: string
|
|||||||
self::WEDDING_ENV_SEND => '发送婚礼红包',
|
self::WEDDING_ENV_SEND => '发送婚礼红包',
|
||||||
self::WEDDING_ENV_RECV => '领取婚礼红包',
|
self::WEDDING_ENV_RECV => '领取婚礼红包',
|
||||||
self::FORCED_DIVORCE_TRANSFER => '强制离婚财产转移',
|
self::FORCED_DIVORCE_TRANSFER => '强制离婚财产转移',
|
||||||
|
self::HOLIDAY_BONUS => '节日福利',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文件功能:节日福利开始广播事件
|
||||||
|
*
|
||||||
|
* 管理员配置的节日活动到达触发时间后,由 TriggerHolidayEventJob 触发,
|
||||||
|
* 通过 Reverb WebSocket 广播给房间内所有在线用户,
|
||||||
|
* 前端收到后弹出领取弹窗和公屏系统消息。
|
||||||
|
*
|
||||||
|
* @author ChatRoom Laravel
|
||||||
|
*
|
||||||
|
* @version 1.0.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace App\Events;
|
||||||
|
|
||||||
|
use App\Models\HolidayEvent;
|
||||||
|
use Illuminate\Broadcasting\Channel;
|
||||||
|
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||||
|
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
|
||||||
|
use Illuminate\Foundation\Events\Dispatchable;
|
||||||
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
|
||||||
|
class HolidayEventStarted implements ShouldBroadcastNow
|
||||||
|
{
|
||||||
|
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param HolidayEvent $event 节日活动实例
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
public readonly HolidayEvent $event,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 广播至房间公共频道(所有在线用户均可收到)。
|
||||||
|
*
|
||||||
|
* @return array<Channel>
|
||||||
|
*/
|
||||||
|
public function broadcastOn(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
new Channel('room.1'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 广播事件名。
|
||||||
|
*/
|
||||||
|
public function broadcastAs(): string
|
||||||
|
{
|
||||||
|
return 'holiday.started';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 广播数据:供前端构建弹窗和公屏消息。
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function broadcastWith(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'event_id' => $this->event->id,
|
||||||
|
'name' => $this->event->name,
|
||||||
|
'description' => $this->event->description,
|
||||||
|
'total_amount' => $this->event->total_amount,
|
||||||
|
'max_claimants' => $this->event->max_claimants,
|
||||||
|
'distribute_type' => $this->event->distribute_type,
|
||||||
|
'fixed_amount' => $this->event->fixed_amount,
|
||||||
|
'claimed_count' => $this->event->claimed_count,
|
||||||
|
'expires_at' => $this->event->expires_at?->toIso8601String(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文件功能:节日福利后台管理控制器
|
||||||
|
*
|
||||||
|
* 管理员可在此创建、编辑、删除节日福利活动,
|
||||||
|
* 也可手动立即触发活动,以及查看领取明细。
|
||||||
|
*
|
||||||
|
* @author ChatRoom Laravel
|
||||||
|
*
|
||||||
|
* @version 1.0.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Admin;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Jobs\TriggerHolidayEventJob;
|
||||||
|
use App\Models\HolidayEvent;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\View\View;
|
||||||
|
|
||||||
|
class HolidayEventController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* 节日福利活动列表页。
|
||||||
|
*/
|
||||||
|
public function index(): View
|
||||||
|
{
|
||||||
|
$events = HolidayEvent::query()
|
||||||
|
->orderByDesc('send_at')
|
||||||
|
->paginate(20);
|
||||||
|
|
||||||
|
return view('admin.holiday-events.index', compact('events'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 新建活动表单页。
|
||||||
|
*/
|
||||||
|
public function create(): View
|
||||||
|
{
|
||||||
|
return view('admin.holiday-events.create');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 保存新活动。
|
||||||
|
*/
|
||||||
|
public function store(Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$data = $request->validate([
|
||||||
|
'name' => 'required|string|max:100',
|
||||||
|
'description' => 'nullable|string|max:500',
|
||||||
|
'total_amount' => 'required|integer|min:1',
|
||||||
|
'max_claimants' => 'required|integer|min:0',
|
||||||
|
'distribute_type' => 'required|in:random,fixed',
|
||||||
|
'min_amount' => 'nullable|integer|min:1',
|
||||||
|
'max_amount' => 'nullable|integer|min:1',
|
||||||
|
'fixed_amount' => 'nullable|integer|min:1',
|
||||||
|
'send_at' => 'required|date',
|
||||||
|
'expire_minutes' => 'required|integer|min:1|max:1440',
|
||||||
|
'repeat_type' => 'required|in:once,daily,weekly,monthly,cron',
|
||||||
|
'cron_expr' => 'nullable|string|max:100',
|
||||||
|
'target_type' => 'required|in:all,vip,level',
|
||||||
|
'target_value' => 'nullable|string|max:50',
|
||||||
|
'enabled' => 'boolean',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$data['status'] = 'pending';
|
||||||
|
$data['enabled'] = $request->boolean('enabled', true);
|
||||||
|
|
||||||
|
HolidayEvent::create($data);
|
||||||
|
|
||||||
|
return redirect()->route('admin.holiday-events.index')->with('success', '节日福利活动创建成功!');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 编辑活动表单页。
|
||||||
|
*/
|
||||||
|
public function edit(HolidayEvent $holidayEvent): View
|
||||||
|
{
|
||||||
|
return view('admin.holiday-events.edit', ['event' => $holidayEvent]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新活动。
|
||||||
|
*/
|
||||||
|
public function update(Request $request, HolidayEvent $holidayEvent): RedirectResponse
|
||||||
|
{
|
||||||
|
$data = $request->validate([
|
||||||
|
'name' => 'required|string|max:100',
|
||||||
|
'description' => 'nullable|string|max:500',
|
||||||
|
'total_amount' => 'required|integer|min:1',
|
||||||
|
'max_claimants' => 'required|integer|min:0',
|
||||||
|
'distribute_type' => 'required|in:random,fixed',
|
||||||
|
'min_amount' => 'nullable|integer|min:1',
|
||||||
|
'max_amount' => 'nullable|integer|min:1',
|
||||||
|
'fixed_amount' => 'nullable|integer|min:1',
|
||||||
|
'send_at' => 'required|date',
|
||||||
|
'expire_minutes' => 'required|integer|min:1|max:1440',
|
||||||
|
'repeat_type' => 'required|in:once,daily,weekly,monthly,cron',
|
||||||
|
'cron_expr' => 'nullable|string|max:100',
|
||||||
|
'target_type' => 'required|in:all,vip,level',
|
||||||
|
'target_value' => 'nullable|string|max:50',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$holidayEvent->update($data);
|
||||||
|
|
||||||
|
return redirect()->route('admin.holiday-events.index')->with('success', '活动已更新!');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 切换活动启用/禁用状态。
|
||||||
|
*/
|
||||||
|
public function toggle(HolidayEvent $holidayEvent): JsonResponse
|
||||||
|
{
|
||||||
|
$holidayEvent->update(['enabled' => ! $holidayEvent->enabled]);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'ok' => true,
|
||||||
|
'enabled' => $holidayEvent->enabled,
|
||||||
|
'message' => $holidayEvent->enabled ? '已启用' : '已禁用',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 手动立即触发活动(管理员操作)。
|
||||||
|
*/
|
||||||
|
public function triggerNow(HolidayEvent $holidayEvent): RedirectResponse
|
||||||
|
{
|
||||||
|
if ($holidayEvent->status !== 'pending') {
|
||||||
|
return back()->with('error', '只有待触发状态的活动才能手动触发。');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置触发时间为当前,立即入队
|
||||||
|
$holidayEvent->update(['send_at' => now()]);
|
||||||
|
TriggerHolidayEventJob::dispatch($holidayEvent);
|
||||||
|
|
||||||
|
return back()->with('success', '活动已触发,请稍后刷新查看状态。');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除活动。
|
||||||
|
*/
|
||||||
|
public function destroy(HolidayEvent $holidayEvent): RedirectResponse
|
||||||
|
{
|
||||||
|
$holidayEvent->delete();
|
||||||
|
|
||||||
|
return redirect()->route('admin.holiday-events.index')->with('success', '活动已删除。');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文件功能:节日福利前台领取控制器
|
||||||
|
*
|
||||||
|
* 用户通过聊天室内弹窗点击"立即领取"调用此接口,
|
||||||
|
* 完成金币入账并返回领取结果。
|
||||||
|
*
|
||||||
|
* @author ChatRoom Laravel
|
||||||
|
*
|
||||||
|
* @version 1.0.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Enums\CurrencySource;
|
||||||
|
use App\Models\HolidayClaim;
|
||||||
|
use App\Models\HolidayEvent;
|
||||||
|
use App\Services\UserCurrencyService;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
class HolidayController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly UserCurrencyService $currency,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户领取节日福利红包。
|
||||||
|
*
|
||||||
|
* 从 holiday_claims 中查找当前用户的待领取记录,
|
||||||
|
* 入账金币并更新活动统计数据。
|
||||||
|
*/
|
||||||
|
public function claim(Request $request, HolidayEvent $event): JsonResponse
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
|
||||||
|
// 活动是否在领取有效期内
|
||||||
|
if (! $event->isClaimable()) {
|
||||||
|
return response()->json(['ok' => false, 'message' => '活动已结束或已过期。']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查找该用户的领取记录(批量插入时已生成)
|
||||||
|
$claim = HolidayClaim::query()
|
||||||
|
->where('event_id', $event->id)
|
||||||
|
->where('user_id', $user->id)
|
||||||
|
->lockForUpdate()
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if (! $claim) {
|
||||||
|
return response()->json(['ok' => false, 'message' => '您不在本次福利名单内,或活动已结束。']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 防止重复领取(claimed_at 为 null 表示未领取)
|
||||||
|
// 由于批量 insert 时直接写入 claimed_at,需要增加一个 is_claimed 字段
|
||||||
|
// 这里用数据库唯一约束保障幂等性:直接返回已领取的提示
|
||||||
|
return DB::transaction(function () use ($event, $claim, $user): JsonResponse {
|
||||||
|
// 金币入账
|
||||||
|
$this->currency->change(
|
||||||
|
$user,
|
||||||
|
'gold',
|
||||||
|
$claim->amount,
|
||||||
|
CurrencySource::HOLIDAY_BONUS,
|
||||||
|
"节日福利:{$event->name}",
|
||||||
|
);
|
||||||
|
|
||||||
|
// 更新活动统计(只在首次领取时)
|
||||||
|
HolidayEvent::query()
|
||||||
|
->where('id', $event->id)
|
||||||
|
->increment('claimed_amount', $claim->amount);
|
||||||
|
|
||||||
|
// 删除领取记录(以此标记"已领取",防止重复调用)
|
||||||
|
$claim->delete();
|
||||||
|
|
||||||
|
// 检查是否已全部领完
|
||||||
|
if ($event->max_claimants > 0) {
|
||||||
|
$remaining = HolidayClaim::where('event_id', $event->id)->count();
|
||||||
|
if ($remaining === 0) {
|
||||||
|
$event->update(['status' => 'completed']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'ok' => true,
|
||||||
|
'message' => "🎉 恭喜!已领取 {$claim->amount} 金币!",
|
||||||
|
'amount' => $claim->amount,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询当前用户在指定活动中的待领取状态。
|
||||||
|
*/
|
||||||
|
public function status(Request $request, HolidayEvent $event): JsonResponse
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
|
||||||
|
$claim = HolidayClaim::query()
|
||||||
|
->where('event_id', $event->id)
|
||||||
|
->where('user_id', $user->id)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'claimable' => $claim !== null && $event->isClaimable(),
|
||||||
|
'amount' => $claim?->amount ?? 0,
|
||||||
|
'expires_at' => $event->expires_at?->toIso8601String(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,261 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文件功能:节日福利触发队列任务
|
||||||
|
*
|
||||||
|
* 由 Laravel 调度器每分钟检查并触发到期的节日活动:
|
||||||
|
* 计算在线用户 → 分配金额 → 写入领取记录 → 广播 WebSocket 事件。
|
||||||
|
* 通过 Horizon 队列异步执行,不阻塞 HTTP 请求。
|
||||||
|
*
|
||||||
|
* @author ChatRoom Laravel
|
||||||
|
*
|
||||||
|
* @version 1.0.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace App\Jobs;
|
||||||
|
|
||||||
|
use App\Events\HolidayEventStarted;
|
||||||
|
use App\Events\MessageSent;
|
||||||
|
use App\Models\HolidayClaim;
|
||||||
|
use App\Models\HolidayEvent;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\ChatStateService;
|
||||||
|
use App\Services\UserCurrencyService;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
|
use Illuminate\Foundation\Queue\Queueable;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Redis;
|
||||||
|
|
||||||
|
class TriggerHolidayEventJob implements ShouldQueue
|
||||||
|
{
|
||||||
|
use Queueable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 最大重试次数(避免因为临时故障无限重试)。
|
||||||
|
*/
|
||||||
|
public int $tries = 3;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param HolidayEvent $event 节日活动记录
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
public readonly HolidayEvent $event,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 执行任务:触发节日福利发放。
|
||||||
|
*/
|
||||||
|
public function handle(
|
||||||
|
ChatStateService $chatState,
|
||||||
|
UserCurrencyService $currency,
|
||||||
|
): void {
|
||||||
|
$event = $this->event->fresh();
|
||||||
|
|
||||||
|
// 防止重复触发
|
||||||
|
if (! $event || $event->status !== 'pending') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$now = now();
|
||||||
|
$expiresAt = $now->copy()->addMinutes($event->expire_minutes);
|
||||||
|
|
||||||
|
// 先标记为 active,防止并发重复触发
|
||||||
|
$updated = HolidayEvent::query()
|
||||||
|
->where('id', $event->id)
|
||||||
|
->where('status', 'pending')
|
||||||
|
->update([
|
||||||
|
'status' => 'active',
|
||||||
|
'triggered_at' => $now,
|
||||||
|
'expires_at' => $expiresAt,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (! $updated) {
|
||||||
|
return; // 已被其他进程触发
|
||||||
|
}
|
||||||
|
|
||||||
|
$event->refresh();
|
||||||
|
|
||||||
|
// 获取在线用户(满足 target_type 条件)
|
||||||
|
$onlineIds = $this->getEligibleOnlineUsers($event, $chatState);
|
||||||
|
|
||||||
|
if (empty($onlineIds)) {
|
||||||
|
// 无合格在线用户,直接标记完成
|
||||||
|
$event->update(['status' => 'completed']);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按 max_claimants 限制人数
|
||||||
|
if ($event->max_claimants > 0 && count($onlineIds) > $event->max_claimants) {
|
||||||
|
shuffle($onlineIds);
|
||||||
|
$onlineIds = array_slice($onlineIds, 0, $event->max_claimants);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算每人金额
|
||||||
|
$amounts = $this->distributeAmounts($event, count($onlineIds));
|
||||||
|
|
||||||
|
DB::transaction(function () use ($event, $onlineIds, $amounts, $now) {
|
||||||
|
$claims = [];
|
||||||
|
|
||||||
|
foreach ($onlineIds as $i => $userId) {
|
||||||
|
$claims[] = [
|
||||||
|
'event_id' => $event->id,
|
||||||
|
'user_id' => $userId,
|
||||||
|
'amount' => $amounts[$i] ?? 0,
|
||||||
|
'claimed_at' => $now,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 批量插入领取记录
|
||||||
|
HolidayClaim::insert($claims);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 广播全房间 WebSocket 事件
|
||||||
|
broadcast(new HolidayEventStarted($event->refresh()));
|
||||||
|
|
||||||
|
// 向聊天室追加系统消息(写入 Redis + 落库)
|
||||||
|
$this->pushSystemMessage($event, count($onlineIds), $chatState);
|
||||||
|
|
||||||
|
// 处理重复活动(计算下次触发时间)
|
||||||
|
$this->scheduleNextRepeat($event);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取满足条件的在线用户 ID 列表。
|
||||||
|
*
|
||||||
|
* @return array<int>
|
||||||
|
*/
|
||||||
|
private function getEligibleOnlineUsers(HolidayEvent $event, ChatStateService $chatState): array
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$key = 'room:1:users';
|
||||||
|
$users = Redis::hgetall($key);
|
||||||
|
if (empty($users)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$usernames = array_keys($users);
|
||||||
|
|
||||||
|
// 根据 user_id 从 Redis value 或数据库查出 ID
|
||||||
|
$ids = [];
|
||||||
|
$fallbacks = [];
|
||||||
|
|
||||||
|
foreach ($users as $username => $jsonInfo) {
|
||||||
|
$info = json_decode($jsonInfo, true);
|
||||||
|
if (isset($info['user_id'])) {
|
||||||
|
$ids[] = (int) $info['user_id'];
|
||||||
|
} else {
|
||||||
|
$fallbacks[] = $username;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! empty($fallbacks)) {
|
||||||
|
$dbIds = User::whereIn('username', $fallbacks)->pluck('id')->map(fn ($id) => (int) $id)->all();
|
||||||
|
$ids = array_merge($ids, $dbIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
$ids = array_values(array_unique($ids));
|
||||||
|
|
||||||
|
// 根据 target_type 过滤
|
||||||
|
return match ($event->target_type) {
|
||||||
|
'vip' => User::whereIn('id', $ids)->whereNotNull('vip_level_id')->pluck('id')->map(fn ($id) => (int) $id)->all(),
|
||||||
|
'level' => User::whereIn('id', $ids)->where('user_level', '>=', (int) ($event->target_value ?? 1))->pluck('id')->map(fn ($id) => (int) $id)->all(),
|
||||||
|
default => $ids,
|
||||||
|
};
|
||||||
|
} catch (\Throwable) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按分配方式计算每人金额数组。
|
||||||
|
*
|
||||||
|
* @return array<int>
|
||||||
|
*/
|
||||||
|
private function distributeAmounts(HolidayEvent $event, int $count): array
|
||||||
|
{
|
||||||
|
if ($count <= 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($event->distribute_type === 'fixed') {
|
||||||
|
// 定额模式:每人相同金额
|
||||||
|
$amount = $event->fixed_amount ?? (int) floor($event->total_amount / $count);
|
||||||
|
|
||||||
|
return array_fill(0, $count, $amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 随机模式:二倍均值算法
|
||||||
|
$total = $event->total_amount;
|
||||||
|
$min = max(1, $event->min_amount ?? 1);
|
||||||
|
$max = $event->max_amount ?? (int) ceil($total * 2 / $count);
|
||||||
|
|
||||||
|
$amounts = [];
|
||||||
|
$remaining = $total;
|
||||||
|
|
||||||
|
for ($i = 0; $i < $count - 1; $i++) {
|
||||||
|
$remainingPeople = $count - $i;
|
||||||
|
$avgDouble = (int) floor($remaining * 2 / $remainingPeople);
|
||||||
|
$cap = max($min, min($max, $avgDouble - 1, $remaining - ($remainingPeople - 1) * $min));
|
||||||
|
$amounts[] = random_int($min, max($min, $cap));
|
||||||
|
$remaining -= end($amounts);
|
||||||
|
}
|
||||||
|
|
||||||
|
$amounts[] = max($min, $remaining);
|
||||||
|
shuffle($amounts);
|
||||||
|
|
||||||
|
return $amounts;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 向聊天室推送系统公告消息并写入 Redis + 落库。
|
||||||
|
*/
|
||||||
|
private function pushSystemMessage(HolidayEvent $event, int $claimCount, ChatStateService $chatState): void
|
||||||
|
{
|
||||||
|
$typeLabel = $event->distribute_type === 'fixed' ? "每人固定 {$event->fixed_amount} 金币" : '随机分配';
|
||||||
|
$content = "🎊 【{$event->name}】节日福利开始啦!总奖池 🪙".number_format($event->total_amount)
|
||||||
|
." 金币,{$typeLabel},共 {$claimCount} 名在线用户可领取!点击弹窗按钮立即领取!";
|
||||||
|
|
||||||
|
$msg = [
|
||||||
|
'id' => $chatState->nextMessageId(1),
|
||||||
|
'room_id' => 1,
|
||||||
|
'from_user' => '系统传音',
|
||||||
|
'to_user' => '大家',
|
||||||
|
'content' => $content,
|
||||||
|
'is_secret' => false,
|
||||||
|
'font_color' => '#f59e0b',
|
||||||
|
'action' => '大声宣告',
|
||||||
|
'sent_at' => now()->toDateTimeString(),
|
||||||
|
];
|
||||||
|
|
||||||
|
$chatState->pushMessage(1, $msg);
|
||||||
|
broadcast(new MessageSent(1, $msg));
|
||||||
|
SaveMessageJob::dispatch($msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理重复活动:计算下次触发时间并重置状态。
|
||||||
|
*/
|
||||||
|
private function scheduleNextRepeat(HolidayEvent $event): void
|
||||||
|
{
|
||||||
|
$nextSendAt = match ($event->repeat_type) {
|
||||||
|
'daily' => $event->send_at->copy()->addDay(),
|
||||||
|
'weekly' => $event->send_at->copy()->addWeek(),
|
||||||
|
'monthly' => $event->send_at->copy()->addMonth(),
|
||||||
|
default => null, // 'once' 或 'cron' 不自动重复
|
||||||
|
};
|
||||||
|
|
||||||
|
if ($nextSendAt) {
|
||||||
|
$event->update([
|
||||||
|
'status' => 'pending',
|
||||||
|
'send_at' => $nextSendAt,
|
||||||
|
'triggered_at' => null,
|
||||||
|
'expires_at' => null,
|
||||||
|
'claimed_count' => 0,
|
||||||
|
'claimed_amount' => 0,
|
||||||
|
]);
|
||||||
|
} else {
|
||||||
|
$event->update(['status' => 'completed']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文件功能:节日福利领取记录模型
|
||||||
|
*
|
||||||
|
* 记录每个用户对每次节日活动的领取信息,保证一人只能领取一次。
|
||||||
|
*
|
||||||
|
* @author ChatRoom Laravel
|
||||||
|
*
|
||||||
|
* @version 1.0.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
class HolidayClaim extends Model
|
||||||
|
{
|
||||||
|
public $timestamps = false;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'event_id',
|
||||||
|
'user_id',
|
||||||
|
'amount',
|
||||||
|
'claimed_at',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 属性类型转换。
|
||||||
|
*/
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'claimed_at' => 'datetime',
|
||||||
|
'amount' => 'integer',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 关联节日活动。
|
||||||
|
*/
|
||||||
|
public function event(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(HolidayEvent::class, 'event_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 关联领取用户。
|
||||||
|
*/
|
||||||
|
public function user(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文件功能:节日福利活动模型
|
||||||
|
*
|
||||||
|
* 管理员在后台配置的定时发放金币活动,
|
||||||
|
* 支持随机/定额两种分配方式,支持一次性或周期性重复。
|
||||||
|
*
|
||||||
|
* @author ChatRoom Laravel
|
||||||
|
*
|
||||||
|
* @version 1.0.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
|
||||||
|
class HolidayEvent extends Model
|
||||||
|
{
|
||||||
|
protected $fillable = [
|
||||||
|
'name',
|
||||||
|
'description',
|
||||||
|
'total_amount',
|
||||||
|
'max_claimants',
|
||||||
|
'distribute_type',
|
||||||
|
'min_amount',
|
||||||
|
'max_amount',
|
||||||
|
'fixed_amount',
|
||||||
|
'send_at',
|
||||||
|
'expire_minutes',
|
||||||
|
'repeat_type',
|
||||||
|
'cron_expr',
|
||||||
|
'target_type',
|
||||||
|
'target_value',
|
||||||
|
'status',
|
||||||
|
'enabled',
|
||||||
|
'triggered_at',
|
||||||
|
'expires_at',
|
||||||
|
'claimed_count',
|
||||||
|
'claimed_amount',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 属性类型转换。
|
||||||
|
*/
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'send_at' => 'datetime',
|
||||||
|
'triggered_at' => 'datetime',
|
||||||
|
'expires_at' => 'datetime',
|
||||||
|
'enabled' => 'boolean',
|
||||||
|
'total_amount' => 'integer',
|
||||||
|
'max_claimants' => 'integer',
|
||||||
|
'min_amount' => 'integer',
|
||||||
|
'max_amount' => 'integer',
|
||||||
|
'fixed_amount' => 'integer',
|
||||||
|
'expire_minutes' => 'integer',
|
||||||
|
'claimed_count' => 'integer',
|
||||||
|
'claimed_amount' => 'integer',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 本次活动的所有领取记录。
|
||||||
|
*/
|
||||||
|
public function claims(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(HolidayClaim::class, 'event_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断活动是否在领取有效期内。
|
||||||
|
*/
|
||||||
|
public function isClaimable(): bool
|
||||||
|
{
|
||||||
|
return $this->status === 'active'
|
||||||
|
&& $this->expires_at
|
||||||
|
&& $this->expires_at->isFuture();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断是否还有剩余领取名额。
|
||||||
|
*/
|
||||||
|
public function hasQuota(): bool
|
||||||
|
{
|
||||||
|
if ($this->max_claimants === 0) {
|
||||||
|
return true; // 不限人数
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->claimed_count < $this->max_claimants;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询待触发的活动(定时任务调用)。
|
||||||
|
*/
|
||||||
|
public static function pendingToTrigger(): \Illuminate\Database\Eloquent\Collection
|
||||||
|
{
|
||||||
|
return static::query()
|
||||||
|
->where('status', 'pending')
|
||||||
|
->where('enabled', true)
|
||||||
|
->where('send_at', '<=', now())
|
||||||
|
->get();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文件功能:节日福利活动表迁移
|
||||||
|
*
|
||||||
|
* 管理员可在后台创建定时发放的节日金币福利活动,
|
||||||
|
* 支持随机/定额两种分配方式,支持周期性重复。
|
||||||
|
*/
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* 创建 holiday_events 表。
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('holiday_events', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
|
||||||
|
// 活动基本信息
|
||||||
|
$table->string('name', 100)->comment('活动名称(公屏广播时显示)');
|
||||||
|
$table->text('description')->nullable()->comment('活动描述/广播文案');
|
||||||
|
|
||||||
|
// 奖励配置
|
||||||
|
$table->unsignedInteger('total_amount')->comment('总金币奖池');
|
||||||
|
$table->unsignedInteger('max_claimants')->default(0)->comment('最大领取人数(0=不限)');
|
||||||
|
$table->enum('distribute_type', ['random', 'fixed'])->default('random')->comment('分配方式:random=随机 fixed=定额');
|
||||||
|
$table->unsignedInteger('min_amount')->default(1)->comment('随机模式最低保底金额');
|
||||||
|
$table->unsignedInteger('max_amount')->nullable()->comment('随机模式单人上限');
|
||||||
|
$table->unsignedInteger('fixed_amount')->nullable()->comment('定额模式每人金额');
|
||||||
|
|
||||||
|
// 时间配置
|
||||||
|
$table->dateTime('send_at')->comment('触发时间');
|
||||||
|
$table->unsignedInteger('expire_minutes')->default(30)->comment('领取有效期(分钟)');
|
||||||
|
|
||||||
|
// 重复设置
|
||||||
|
$table->enum('repeat_type', ['once', 'daily', 'weekly', 'monthly', 'cron'])->default('once');
|
||||||
|
$table->string('cron_expr', 100)->nullable()->comment('自定义 CRON 表达式');
|
||||||
|
|
||||||
|
// 目标用户
|
||||||
|
$table->enum('target_type', ['all', 'vip', 'level'])->default('all')->comment('目标用户类型');
|
||||||
|
$table->string('target_value', 50)->nullable()->comment('vip级别/最低等级值');
|
||||||
|
|
||||||
|
// 状态
|
||||||
|
$table->enum('status', ['pending', 'active', 'completed', 'cancelled'])->default('pending');
|
||||||
|
$table->boolean('enabled')->default(true)->comment('是否启用');
|
||||||
|
$table->dateTime('triggered_at')->nullable()->comment('实际触发时间');
|
||||||
|
$table->dateTime('expires_at')->nullable()->comment('本次领取截止时间');
|
||||||
|
|
||||||
|
// 统计
|
||||||
|
$table->unsignedInteger('claimed_count')->default(0)->comment('已领取人数');
|
||||||
|
$table->unsignedInteger('claimed_amount')->default(0)->comment('已发出金额');
|
||||||
|
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->index(['status', 'send_at']);
|
||||||
|
$table->index('enabled');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 回滚迁移。
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('holiday_events');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文件功能:节日福利领取记录表迁移
|
||||||
|
*
|
||||||
|
* 记录每个用户对每次节日福利活动的领取状态和金额。
|
||||||
|
*/
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* 创建 holiday_claims 表。
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('holiday_claims', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->unsignedBigInteger('event_id')->comment('关联活动 ID');
|
||||||
|
$table->unsignedBigInteger('user_id')->comment('领取用户 ID');
|
||||||
|
$table->unsignedInteger('amount')->comment('实际领取金额');
|
||||||
|
$table->dateTime('claimed_at')->comment('领取时间');
|
||||||
|
|
||||||
|
$table->foreign('event_id')->references('id')->on('holiday_events')->cascadeOnDelete();
|
||||||
|
$table->unique(['event_id', 'user_id'], 'uq_holiday_event_user');
|
||||||
|
$table->index('user_id');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 回滚迁移。
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('holiday_claims');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -94,6 +94,13 @@ export function initChat(roomId) {
|
|||||||
window.dispatchEvent(
|
window.dispatchEvent(
|
||||||
new CustomEvent("chat:wedding-celebration", { detail: e }),
|
new CustomEvent("chat:wedding-celebration", { detail: e }),
|
||||||
);
|
);
|
||||||
|
})
|
||||||
|
// ─── 节日福利:系统定时发放 ────────────────────────────────
|
||||||
|
.listen(".holiday.started", (e) => {
|
||||||
|
console.log("节日福利开始:", e);
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent("chat:holiday.started", { detail: e }),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -135,6 +135,8 @@
|
|||||||
@include('chat.partials.user-actions')
|
@include('chat.partials.user-actions')
|
||||||
{{-- ═══════════ 婚姻系统弹窗组件 ═══════════ --}}
|
{{-- ═══════════ 婚姻系统弹窗组件 ═══════════ --}}
|
||||||
@include('chat.partials.marriage-modals')
|
@include('chat.partials.marriage-modals')
|
||||||
|
{{-- ═══════════ 节日福利弹窗组件 ═══════════ --}}
|
||||||
|
@include('chat.partials.holiday-modal')
|
||||||
|
|
||||||
{{-- 全屏特效系统:管理员烟花/下雨/雷电/下雪 --}}
|
{{-- 全屏特效系统:管理员烟花/下雨/雷电/下雪 --}}
|
||||||
<script src="/js/effects/effect-sounds.js"></script>
|
<script src="/js/effects/effect-sounds.js"></script>
|
||||||
|
|||||||
@@ -0,0 +1,225 @@
|
|||||||
|
{{--
|
||||||
|
文件功能:节日福利弹窗组件
|
||||||
|
|
||||||
|
后台配置的节日活动触发时,通过 WebSocket 广播到达前端,
|
||||||
|
弹出全屏福利领取弹窗,用户点击领取后金币自动入账。
|
||||||
|
|
||||||
|
WebSocket 监听:chat:holiday.started
|
||||||
|
领取接口:POST /holiday/{event}/claim
|
||||||
|
--}}
|
||||||
|
|
||||||
|
{{-- ─── 节日福利领取弹窗 ─── --}}
|
||||||
|
<div id="holiday-event-modal" x-data="holidayEventModal()" x-show="show" x-cloak>
|
||||||
|
<div x-transition:enter="transition ease-out duration-300" x-transition:enter-start="opacity-0 scale-95"
|
||||||
|
x-transition:enter-end="opacity-100 scale-100" x-transition:leave="transition ease-in duration-200"
|
||||||
|
x-transition:leave-start="opacity-100 scale-100" x-transition:leave-end="opacity-0 scale-95"
|
||||||
|
style="position:fixed; inset:0; background:rgba(0,0,0,.65); z-index:9950; display:flex; align-items:center; justify-content:center;">
|
||||||
|
|
||||||
|
<div
|
||||||
|
style="width:400px; max-width:95vw; border-radius:24px; overflow:hidden; text-align:center;
|
||||||
|
box-shadow:0 24px 80px rgba(245,158,11,.4);">
|
||||||
|
|
||||||
|
{{-- 顶部渐变区域 --}}
|
||||||
|
<div style="background:linear-gradient(145deg,#92400e,#b45309,#d97706); padding:28px 24px 20px;">
|
||||||
|
{{-- 主图标动效 --}}
|
||||||
|
<div style="font-size:56px; margin-bottom:10px; animation:holiday-bounce 1.2s infinite;">🎊</div>
|
||||||
|
<div style="color:#fef3c7; font-weight:bold; font-size:20px; margin-bottom:4px;" x-text="eventName">
|
||||||
|
</div>
|
||||||
|
<div style="color:rgba(254,243,199,.8); font-size:13px;" x-text="eventDesc"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- 主体内容 --}}
|
||||||
|
<div style="background:linear-gradient(160deg,#7c2d12,#9a3412); padding:20px 24px;">
|
||||||
|
|
||||||
|
{{-- 奖池信息 --}}
|
||||||
|
<div style="background:rgba(0,0,0,.25); border-radius:14px; padding:12px 16px; margin-bottom:16px;">
|
||||||
|
<div style="color:rgba(254,243,199,.6); font-size:11px; margin-bottom:4px;">🪙 本次节日总奖池</div>
|
||||||
|
<div style="color:#fcd34d; font-size:24px; font-weight:bold;"
|
||||||
|
x-text="totalAmount.toLocaleString() + ' 金币'"></div>
|
||||||
|
<div style="color:rgba(254,243,199,.5); font-size:11px; margin-top:4px;">
|
||||||
|
<span x-show="maxClaimants > 0" x-text="'前 ' + maxClaimants + ' 名在线用户可领取'"></span>
|
||||||
|
<span x-show="maxClaimants === 0">全体在线用户均可领取</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- 有效期 --}}
|
||||||
|
<div style="color:rgba(254,243,199,.55); font-size:11px; margin-bottom:14px;">
|
||||||
|
领取有效期 <strong style="color:#fcd34d;" x-text="expiresIn"></strong>,过期作废
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- 领取按钮 --}}
|
||||||
|
<div x-show="!claimed">
|
||||||
|
<div style="background:rgba(0,0,0,.3); border-radius:16px; padding:5px;">
|
||||||
|
<button x-on:click="doClaim()" :disabled="claiming"
|
||||||
|
style="display:block; width:100%; padding:14px 0; border:none; border-radius:12px;
|
||||||
|
font-size:16px; font-weight:bold; cursor:pointer; transition:all .15s;
|
||||||
|
letter-spacing:1px; color:#fff;"
|
||||||
|
:style="claiming
|
||||||
|
?
|
||||||
|
'background:#b45309; opacity:.65; cursor:not-allowed;' :
|
||||||
|
'background:#d97706; box-shadow:0 2px 12px rgba(0,0,0,.4);'"
|
||||||
|
onmouseover="if(!this.disabled) this.style.filter='brightness(1.12)'"
|
||||||
|
onmouseout="this.style.filter=''">
|
||||||
|
<span x-text="claiming ? '领取中…' : '🎁 立即领取福利'"></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- 已领取 --}}
|
||||||
|
<div x-show="claimed" style="display:none;">
|
||||||
|
<div style="font-size:36px; margin-bottom:6px; color:#fcd34d; font-weight:bold;"
|
||||||
|
x-text="'+' + claimedAmount.toLocaleString() + ' 金币'"></div>
|
||||||
|
<div style="color:#fef3c7; font-size:13px;">🎉 恭喜!节日福利已入账!</div>
|
||||||
|
<div style="color:rgba(254,243,199,.6); font-size:11px; margin-top:4px;">金币已自动到账,新年快乐 🥳</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- 关闭按钮 --}}
|
||||||
|
<div style="background:rgba(120,40,10,.95); padding:14px 20px;">
|
||||||
|
<button x-on:click="close()"
|
||||||
|
style="padding:9px 32px; background:rgba(0,0,0,.35); border:none; border-radius:30px;
|
||||||
|
font-size:12px; color:rgba(254,243,199,.7); cursor:pointer; transition:all .2s;"
|
||||||
|
onmouseover="this.style.background='rgba(0,0,0,.5)'"
|
||||||
|
onmouseout="this.style.background='rgba(0,0,0,.35)'">
|
||||||
|
<span x-text="claimed ? '收好了 ✨' : '关闭'"></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
@keyframes holiday-bounce {
|
||||||
|
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
transform: scale(1) rotate(0deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
25% {
|
||||||
|
transform: scale(1.1) rotate(-5deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
75% {
|
||||||
|
transform: scale(1.1) rotate(5deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
/**
|
||||||
|
* 节日福利弹窗组件
|
||||||
|
* 监听 WebSocket 事件 holiday.started,弹出领取弹窗
|
||||||
|
*/
|
||||||
|
function holidayEventModal() {
|
||||||
|
return {
|
||||||
|
show: false,
|
||||||
|
claiming: false,
|
||||||
|
claimed: false,
|
||||||
|
|
||||||
|
// 活动数据
|
||||||
|
eventId: null,
|
||||||
|
eventName: '',
|
||||||
|
eventDesc: '',
|
||||||
|
totalAmount: 0,
|
||||||
|
maxClaimants: 0,
|
||||||
|
distributeType: 'random',
|
||||||
|
fixedAmount: null,
|
||||||
|
expiresAt: null,
|
||||||
|
expiresIn: '',
|
||||||
|
claimedAmount: 0,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 打开弹窗并填充活动数据
|
||||||
|
*/
|
||||||
|
open(detail) {
|
||||||
|
this.eventId = detail.event_id;
|
||||||
|
this.eventName = detail.name ?? '节日福利';
|
||||||
|
this.eventDesc = detail.description ?? '';
|
||||||
|
this.totalAmount = detail.total_amount ?? 0;
|
||||||
|
this.maxClaimants = detail.max_claimants ?? 0;
|
||||||
|
this.distributeType = detail.distribute_type ?? 'random';
|
||||||
|
this.fixedAmount = detail.fixed_amount;
|
||||||
|
this.expiresAt = detail.expires_at ? new Date(detail.expires_at) : null;
|
||||||
|
this.claimed = false;
|
||||||
|
this.claiming = false;
|
||||||
|
this.claimedAmount = 0;
|
||||||
|
this.updateExpiresIn();
|
||||||
|
this.show = true;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 关闭弹窗
|
||||||
|
*/
|
||||||
|
close() {
|
||||||
|
this.show = false;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新有效期显示文字
|
||||||
|
*/
|
||||||
|
updateExpiresIn() {
|
||||||
|
if (!this.expiresAt) {
|
||||||
|
this.expiresIn = '30 分钟';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const diff = Math.max(0, Math.round((this.expiresAt - Date.now()) / 60000));
|
||||||
|
this.expiresIn = diff > 0 ? diff + ' 分钟' : '即将过期';
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发起领取请求
|
||||||
|
*/
|
||||||
|
async doClaim() {
|
||||||
|
if (this.claiming || this.claimed || !this.eventId) return;
|
||||||
|
this.claiming = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/holiday/${this.eventId}/claim`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Accept': 'application/json',
|
||||||
|
'X-CSRF-TOKEN': document.querySelector('meta[name=csrf-token]').content,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (data.ok) {
|
||||||
|
this.claimed = true;
|
||||||
|
this.claimedAmount = data.amount || 0;
|
||||||
|
} else {
|
||||||
|
window.chatDialog?.alert(data.message || '领取失败,请稍后重试。', '提示', '#f59e0b');
|
||||||
|
if (data.message?.includes('已结束') || data.message?.includes('过期')) {
|
||||||
|
this.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
window.chatDialog?.alert('网络异常,请稍后重试。', '错误', '#cc4444');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.claiming = false;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── WebSocket 事件监听:节日福利开始 ─────────────────────────
|
||||||
|
window.addEventListener('chat:holiday.started', (e) => {
|
||||||
|
const detail = e.detail;
|
||||||
|
|
||||||
|
// 公屏追加系统消息
|
||||||
|
if (typeof appendSystemMessage === 'function') {
|
||||||
|
const typeLabel = detail.distribute_type === 'fixed' ?
|
||||||
|
`每人固定 ${Number(detail.fixed_amount).toLocaleString()} 金币` :
|
||||||
|
'随机金额';
|
||||||
|
const quotaText = detail.max_claimants > 0 ? `前 ${detail.max_claimants} 名` : '全体';
|
||||||
|
appendSystemMessage(
|
||||||
|
`🎊 【${detail.name}】节日福利开始啦!总奖池 🪙${Number(detail.total_amount).toLocaleString()} 金币,${typeLabel},${quotaText}在线用户可领取!`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 弹出全屏领取弹窗
|
||||||
|
const el = document.getElementById('holiday-event-modal');
|
||||||
|
if (el) Alpine.$data(el).open(detail);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@@ -30,3 +30,11 @@ Schedule::job(new \App\Jobs\ExpireWeddingEnvelopes)->hourly();
|
|||||||
|
|
||||||
// 每天 00:05:全量处理婚姻亲密度时间奖励(每日加分)
|
// 每天 00:05:全量处理婚姻亲密度时间奖励(每日加分)
|
||||||
Schedule::job(new \App\Jobs\ProcessMarriageIntimacy)->dailyAt('00:05');
|
Schedule::job(new \App\Jobs\ProcessMarriageIntimacy)->dailyAt('00:05');
|
||||||
|
|
||||||
|
// ──────────── 节日福利定时任务 ────────────────────────────────────
|
||||||
|
|
||||||
|
// 每分钟:扫描并触发到期的节日福利活动
|
||||||
|
Schedule::call(function () {
|
||||||
|
\App\Models\HolidayEvent::pendingToTrigger()
|
||||||
|
->each(fn ($e) => \App\Jobs\TriggerHolidayEventJob::dispatch($e));
|
||||||
|
})->everyMinute()->name('holiday-events:trigger')->withoutOverlapping();
|
||||||
|
|||||||
+20
-1
@@ -107,6 +107,14 @@ Route::middleware(['chat.auth'])->group(function () {
|
|||||||
Route::get('/ceremony/{ceremony}/envelope', [\App\Http\Controllers\WeddingController::class, 'envelopeStatus'])->name('envelope-status');
|
Route::get('/ceremony/{ceremony}/envelope', [\App\Http\Controllers\WeddingController::class, 'envelopeStatus'])->name('envelope-status');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── 节日福利(前台)──────────────────────────────────────────────
|
||||||
|
Route::prefix('holiday')->name('holiday.')->group(function () {
|
||||||
|
// 领取节日福利红包
|
||||||
|
Route::post('/{event}/claim', [\App\Http\Controllers\HolidayController::class, 'claim'])->name('claim');
|
||||||
|
// 查询当前用户在活动中的领取状态
|
||||||
|
Route::get('/{event}/status', [\App\Http\Controllers\HolidayController::class, 'status'])->name('status');
|
||||||
|
});
|
||||||
|
|
||||||
// ---- 第五阶段:具体房间内部聊天核心 ----
|
// ---- 第五阶段:具体房间内部聊天核心 ----
|
||||||
// 进入具体房间界面的初始化
|
// 进入具体房间界面的初始化
|
||||||
Route::get('/room/{id}', [ChatController::class, 'init'])->name('chat.room');
|
Route::get('/room/{id}', [ChatController::class, 'init'])->name('chat.room');
|
||||||
@@ -287,9 +295,20 @@ Route::middleware(['chat.auth', 'chat.has_position'])->prefix('admin')->name('ad
|
|||||||
Route::put('/tiers/{tier}', [\App\Http\Controllers\Admin\MarriageManagerController::class, 'updateTier'])->name('tiers.update');
|
Route::put('/tiers/{tier}', [\App\Http\Controllers\Admin\MarriageManagerController::class, 'updateTier'])->name('tiers.update');
|
||||||
// 强制离婚
|
// 强制离婚
|
||||||
Route::post('/{marriage}/force-dissolve', [\App\Http\Controllers\Admin\MarriageManagerController::class, 'forceDissolve'])->name('force-dissolve');
|
Route::post('/{marriage}/force-dissolve', [\App\Http\Controllers\Admin\MarriageManagerController::class, 'forceDissolve'])->name('force-dissolve');
|
||||||
// 取消求婚
|
|
||||||
Route::post('/{marriage}/cancel-proposal', [\App\Http\Controllers\Admin\MarriageManagerController::class, 'cancelProposal'])->name('cancel-proposal');
|
Route::post('/{marriage}/cancel-proposal', [\App\Http\Controllers\Admin\MarriageManagerController::class, 'cancelProposal'])->name('cancel-proposal');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 📅 节日福利活动管理
|
||||||
|
Route::prefix('holiday-events')->name('holiday-events.')->group(function () {
|
||||||
|
Route::get('/', [\App\Http\Controllers\Admin\HolidayEventController::class, 'index'])->name('index');
|
||||||
|
Route::get('/create', [\App\Http\Controllers\Admin\HolidayEventController::class, 'create'])->name('create');
|
||||||
|
Route::post('/', [\App\Http\Controllers\Admin\HolidayEventController::class, 'store'])->name('store');
|
||||||
|
Route::get('/{holidayEvent}/edit', [\App\Http\Controllers\Admin\HolidayEventController::class, 'edit'])->name('edit');
|
||||||
|
Route::put('/{holidayEvent}', [\App\Http\Controllers\Admin\HolidayEventController::class, 'update'])->name('update');
|
||||||
|
Route::post('/{holidayEvent}/toggle', [\App\Http\Controllers\Admin\HolidayEventController::class, 'toggle'])->name('toggle');
|
||||||
|
Route::post('/{holidayEvent}/trigger-now', [\App\Http\Controllers\Admin\HolidayEventController::class, 'triggerNow'])->name('trigger-now');
|
||||||
|
Route::delete('/{holidayEvent}', [\App\Http\Controllers\Admin\HolidayEventController::class, 'destroy'])->name('destroy');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ──────────────────────────────────────────────────────────────
|
// ──────────────────────────────────────────────────────────────
|
||||||
|
|||||||
Reference in New Issue
Block a user