升级节日福利年度调度与批次领取

This commit is contained in:
2026-04-21 17:53:11 +08:00
parent 5a6446b832
commit a066580014
25 changed files with 2362 additions and 536 deletions
+173 -2
View File
@@ -3,6 +3,176 @@ import "./bootstrap";
// 这个文件负责处理浏览器与 Laravel Reverb WebSocket 服务器的通信。
// 通过 Presence Channel 实现聊天室的核心监听。
function isHolidayObject(value) {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
function firstHolidayDefined(...values) {
for (const value of values) {
if (value !== undefined && value !== null && value !== "") {
return value;
}
}
return null;
}
function pickHolidayObject(...values) {
for (const value of values) {
if (isHolidayObject(value)) {
return value;
}
}
return {};
}
function mergeHolidayObjects(...values) {
return Object.assign({}, ...values.filter(isHolidayObject));
}
function toHolidayNumber(value, fallback = null) {
if (value === undefined || value === null || value === "") {
return fallback;
}
const parsedValue = Number(value);
return Number.isFinite(parsedValue) ? parsedValue : fallback;
}
export function normalizeHolidayBroadcastEvent(payload = {}) {
const runPayload = pickHolidayObject(payload.run, payload.holiday_run);
const snapshotPayload = pickHolidayObject(
payload.snapshot,
payload.run_snapshot,
payload.batch_snapshot,
runPayload.snapshot,
runPayload.batch_snapshot,
);
const eventPayload = pickHolidayObject(
payload.event,
payload.holiday_event,
runPayload.event,
runPayload.template,
snapshotPayload.event,
snapshotPayload.template,
);
const mergedPayload = mergeHolidayObjects(
eventPayload,
payload,
runPayload,
snapshotPayload,
);
const fixedAmount = toHolidayNumber(
firstHolidayDefined(
snapshotPayload.fixed_amount,
runPayload.fixed_amount,
payload.fixed_amount,
),
);
return {
...payload,
...mergedPayload,
run_id: firstHolidayDefined(
payload.run_id,
runPayload.run_id,
runPayload.id,
snapshotPayload.run_id,
),
event_id: firstHolidayDefined(
payload.event_id,
runPayload.event_id,
snapshotPayload.event_id,
eventPayload.id,
),
name: firstHolidayDefined(
snapshotPayload.name,
runPayload.name,
payload.name,
eventPayload.name,
"节日福利",
),
description: firstHolidayDefined(
snapshotPayload.description,
runPayload.description,
payload.description,
eventPayload.description,
"",
),
total_amount:
toHolidayNumber(
firstHolidayDefined(
snapshotPayload.total_amount,
runPayload.total_amount,
payload.total_amount,
),
0,
) ?? 0,
max_claimants:
toHolidayNumber(
firstHolidayDefined(
snapshotPayload.max_claimants,
runPayload.max_claimants,
payload.max_claimants,
),
0,
) ?? 0,
distribute_type: firstHolidayDefined(
snapshotPayload.distribute_type,
runPayload.distribute_type,
payload.distribute_type,
fixedAmount !== null ? "fixed" : "random",
),
fixed_amount: fixedAmount,
scheduled_for: firstHolidayDefined(
snapshotPayload.scheduled_for,
runPayload.scheduled_for,
payload.scheduled_for,
snapshotPayload.send_at,
runPayload.send_at,
payload.send_at,
),
expires_at: firstHolidayDefined(
snapshotPayload.expires_at,
runPayload.expires_at,
payload.expires_at,
snapshotPayload.claim_deadline_at,
runPayload.claim_deadline_at,
payload.claim_deadline_at,
),
repeat_type: firstHolidayDefined(
snapshotPayload.repeat_type,
runPayload.repeat_type,
payload.repeat_type,
eventPayload.repeat_type,
),
round_no: firstHolidayDefined(
snapshotPayload.round_no,
runPayload.round_no,
payload.round_no,
snapshotPayload.sequence,
runPayload.sequence,
payload.sequence,
snapshotPayload.issue_no,
runPayload.issue_no,
payload.issue_no,
),
round_label: firstHolidayDefined(
snapshotPayload.round_label,
runPayload.round_label,
payload.round_label,
snapshotPayload.batch_label,
runPayload.batch_label,
payload.batch_label,
),
snapshot: mergeHolidayObjects(eventPayload, runPayload, snapshotPayload),
};
}
window.normalizeHolidayBroadcastEvent = normalizeHolidayBroadcastEvent;
export function initChat(roomId) {
if (!roomId) {
console.error("未提供 roomId,无法初始化 WebSocket 连接。");
@@ -113,9 +283,10 @@ export function initChat(roomId) {
})
// ─── 节日福利:系统定时发放 ────────────────────────────────
.listen(".holiday.started", (e) => {
console.log("节日福利开始:", e);
const holidayPayload = normalizeHolidayBroadcastEvent(e);
console.log("节日福利批次开始:", holidayPayload);
window.dispatchEvent(
new CustomEvent("chat:holiday.started", { detail: e }),
new CustomEvent("chat:holiday.started", { detail: holidayPayload }),
);
})
// ─── 百家乐:开局 & 结算 ──────────────────────────────────
@@ -3,189 +3,10 @@
@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>
@include('admin.holiday-events.partials.form', [
'action' => route('admin.holiday-events.store'),
'pageTitle' => '🎊 创建节日福利活动',
'pageDescription' => '支持一次性、周期性与 yearly 年度节日调度配置。',
'submitLabel' => '🎊 创建活动',
])
@endsection
@@ -0,0 +1,14 @@
@extends('admin.layouts.app')
@section('title', '编辑节日福利活动')
@section('content')
@include('admin.holiday-events.partials.form', [
'event' => $event,
'action' => route('admin.holiday-events.update', $event),
'method' => 'PUT',
'pageTitle' => '✏️ 编辑节日福利活动',
'pageDescription' => '可继续维护旧规则,也可切换到 yearly 年度节日高级调度。',
'submitLabel' => '💾 保存修改',
])
@endsection
@@ -8,7 +8,7 @@
<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>
<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">
@@ -62,6 +62,14 @@
</td>
<td class="p-3 text-gray-600 text-xs">
{{ $event->send_at->format('m-d H:i') }}
@if ($event->repeat_type === 'yearly')
<div class="mt-1 text-[11px] text-amber-600">
{{ data_get($event, 'schedule_month', $event->send_at?->format('n')) }}{{ data_get($event, 'schedule_day', $event->send_at?->format('j')) }}
· {{ data_get($event, 'duration_days', 1) }}
· 每天{{ data_get($event, 'daily_occurrences', 1) }}
· 间隔{{ data_get($event, 'occurrence_interval_minutes', 60) }}分钟
</div>
@endif
</td>
<td class="p-3">
@php
@@ -70,6 +78,7 @@
'daily' => '每天',
'weekly' => '每周',
'monthly' => '每月',
'yearly' => '每年',
'cron' => 'CRON',
];
@endphp
@@ -0,0 +1,327 @@
@php
$holidayEvent = $event ?? null;
$isEdit = (bool) $holidayEvent;
$inputClass = 'w-full rounded-lg border border-gray-300 p-2.5 text-sm text-gray-700 focus:border-amber-400 focus:ring-amber-400';
$panelClass = 'rounded-xl border border-gray-100 bg-white p-6 shadow-sm';
$fieldValue = static fn(string $key, mixed $default = null): mixed => old($key, data_get($holidayEvent, $key, $default));
$sendAtValue = old('send_at');
if ($sendAtValue === null && $holidayEvent?->send_at) {
$sendAtValue = $holidayEvent->send_at->format('Y-m-d\TH:i');
}
$repeatType = (string) $fieldValue('repeat_type', 'once');
$distributeType = (string) $fieldValue('distribute_type', 'random');
$targetType = (string) $fieldValue('target_type', 'all');
$targetValue = old('target_value', data_get($holidayEvent, 'target_value', $targetType === 'level' ? 1 : null));
$scheduleMonth = old('schedule_month', data_get($holidayEvent, 'schedule_month', $holidayEvent?->send_at?->format('n') ?? now()->format('n')));
$scheduleDay = old('schedule_day', data_get($holidayEvent, 'schedule_day', $holidayEvent?->send_at?->format('j') ?? now()->format('j')));
$scheduleTime = old('schedule_time', data_get($holidayEvent, 'schedule_time', $holidayEvent?->send_at?->format('H:i') ?? '20:00'));
$durationDays = old('duration_days', data_get($holidayEvent, 'duration_days', 1));
$dailyOccurrences = old('daily_occurrences', data_get($holidayEvent, 'daily_occurrences', 1));
$occurrenceIntervalMinutes = old('occurrence_interval_minutes', data_get($holidayEvent, 'occurrence_interval_minutes', 60));
@endphp
<div class="max-w-4xl mx-auto space-y-6">
<div class="{{ $panelClass }}">
<div class="mb-6 flex items-center justify-between gap-4">
<div class="flex items-center gap-3">
<a href="{{ route('admin.holiday-events.index') }}" class="text-sm text-gray-400 transition hover:text-gray-600"> 返回列表</a>
<div>
<h2 class="text-lg font-bold text-gray-800">{{ $pageTitle }}</h2>
<p class="mt-1 text-xs text-gray-500">{{ $pageDescription }}</p>
</div>
</div>
@if ($isEdit && data_get($holidayEvent, 'status'))
<span class="rounded-full bg-gray-100 px-3 py-1 text-xs font-medium text-gray-500">
当前状态:{{ data_get($holidayEvent, 'status') }}
</span>
@endif
</div>
@if ($errors->any())
<div class="mb-6 rounded-lg border border-red-200 bg-red-50 p-4">
<div class="mb-2 text-sm font-bold text-red-700">提交失败,请检查以下字段:</div>
<ul class="list-inside list-disc space-y-1 text-sm text-red-700">
@foreach ($errors->all() as $error)
<li>{{ $error }}</li>
@endforeach
</ul>
</div>
@endif
<form action="{{ $action }}" method="POST"
x-data="holidayEventForm(@js([
'repeatType' => $repeatType,
'distributeType' => $distributeType,
'targetType' => $targetType,
'scheduleMonth' => (string) $scheduleMonth,
'scheduleDay' => (string) $scheduleDay,
'scheduleTime' => (string) $scheduleTime,
'durationDays' => (string) $durationDays,
'dailyOccurrences' => (string) $dailyOccurrences,
'occurrenceIntervalMinutes' => (string) $occurrenceIntervalMinutes,
]))"
class="space-y-6">
@csrf
@isset($method)
@method($method)
@endisset
<div>
<h3 class="mb-3 border-b pb-2 text-sm font-bold text-gray-700">📋 基础信息</h3>
<div class="grid grid-cols-1 gap-4">
<div>
<label for="name" class="mb-1 block text-xs font-bold text-gray-600">活动名称 <span class="text-red-500">*</span></label>
<input id="name" type="text" name="name" value="{{ $fieldValue('name') }}" required
placeholder="例:元旦快乐🎊" class="{{ $inputClass }}">
</div>
<div>
<label for="description" class="mb-1 block text-xs font-bold text-gray-600">活动描述 <span
class="font-normal text-gray-400">(可选,公屏广播时显示)</span></label>
<textarea id="description" name="description" rows="3" placeholder="例:新年快乐!感谢大家一直以来的陪伴,送上新年礼物!"
class="{{ $inputClass }}">{{ $fieldValue('description') }}</textarea>
</div>
</div>
</div>
<div>
<h3 class="mb-3 border-b pb-2 text-sm font-bold text-gray-700">💰 奖励配置</h3>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<label for="total_amount" class="mb-1 block text-xs font-bold text-gray-600">总金币奖池 <span class="text-red-500">*</span></label>
<input id="total_amount" type="number" name="total_amount"
value="{{ $fieldValue('total_amount', 100000) }}" min="1" required class="{{ $inputClass }}">
</div>
<div>
<label for="max_claimants" class="mb-1 block text-xs font-bold text-gray-600">可领取人数上限 <span
class="font-normal text-gray-400">0 = 不限)</span></label>
<input id="max_claimants" type="number" name="max_claimants"
value="{{ $fieldValue('max_claimants', 0) }}" min="0" class="{{ $inputClass }}">
</div>
<div class="md:col-span-2">
<label class="mb-2 block text-xs font-bold text-gray-600">分配方式 <span class="text-red-500">*</span></label>
<div class="grid gap-3 md:grid-cols-2">
<label
class="flex cursor-pointer items-start gap-3 rounded-xl border border-purple-100 bg-purple-50 px-4 py-3 transition hover:border-purple-200">
<input type="radio" name="distribute_type" value="random" x-model="distributeType"
class="mt-1" />
<span>
<span class="block text-sm font-bold text-gray-800">🎲 随机分配</span>
<span class="mt-1 block text-xs text-gray-500">二倍均值算法,每位用户领取金额不同。</span>
</span>
</label>
<label
class="flex cursor-pointer items-start gap-3 rounded-xl border border-blue-100 bg-blue-50 px-4 py-3 transition hover:border-blue-200">
<input type="radio" name="distribute_type" value="fixed" x-model="distributeType"
class="mt-1" />
<span>
<span class="block text-sm font-bold text-gray-800">📏 定额发放</span>
<span class="mt-1 block text-xs text-gray-500">每位用户领取固定金额,更方便控制总量。</span>
</span>
</label>
</div>
</div>
<div x-show="distributeType === 'random'" x-transition.opacity class="md:col-span-2">
<div class="grid gap-4 rounded-xl border border-purple-100 bg-purple-50 p-4 md:grid-cols-2">
<div>
<label for="min_amount" class="mb-1 block text-xs font-bold text-gray-600">最低保底金额</label>
<input id="min_amount" type="number" name="min_amount"
value="{{ $fieldValue('min_amount', 100) }}" min="1" class="{{ $inputClass }}">
</div>
<div>
<label for="max_amount" class="mb-1 block text-xs font-bold text-gray-600">单人最高上限 <span
class="font-normal text-gray-400">(可选)</span></label>
<input id="max_amount" type="number" name="max_amount" value="{{ $fieldValue('max_amount') }}"
min="1" placeholder="不填则由后端自动计算" class="{{ $inputClass }}">
</div>
</div>
</div>
<div x-show="distributeType === 'fixed'" x-transition.opacity class="md:col-span-2">
<div class="rounded-xl border border-blue-100 bg-blue-50 p-4">
<label for="fixed_amount" class="mb-1 block text-xs font-bold text-gray-600">每人固定金额 <span
class="text-red-500">*</span></label>
<input id="fixed_amount" type="number" name="fixed_amount"
value="{{ $fieldValue('fixed_amount', 500) }}" min="1" class="{{ $inputClass }}">
<p class="mt-2 text-xs text-gray-500">总发放金额 = 固定金额 × 领取人数(仍受领取人数上限控制)。</p>
</div>
</div>
</div>
</div>
<div>
<h3 class="mb-3 border-b pb-2 text-sm font-bold text-gray-700"> 时间配置</h3>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<label for="send_at" class="mb-1 block text-xs font-bold text-gray-600"
x-text="repeatType === 'once' ? '触发时间 *' : '首次触发 / 基准时间 *'"></label>
<input id="send_at" type="datetime-local" name="send_at" value="{{ $sendAtValue }}" required
class="{{ $inputClass }}">
<p class="mt-1 text-xs text-gray-400">
`once/daily/weekly/monthly` 使用该时间直接调度;`yearly` 则把它作为首次触发与兼容基准值。
</p>
</div>
<div>
<label for="expire_minutes" class="mb-1 block text-xs font-bold text-gray-600">领取有效期(分钟)</label>
<input id="expire_minutes" type="number" name="expire_minutes"
value="{{ $fieldValue('expire_minutes', 30) }}" min="1" max="1440"
class="{{ $inputClass }}">
</div>
<div class="md:col-span-2">
<label for="repeat_type" class="mb-1 block text-xs font-bold text-gray-600">重复方式</label>
<select id="repeat_type" name="repeat_type" x-model="repeatType" class="{{ $inputClass }}">
<option value="once">仅一次</option>
<option value="daily">每天(相同时间)</option>
<option value="weekly">每周(相同时间)</option>
<option value="monthly">每月(相同日期时间)</option>
<option value="yearly">每年节日(高级调度)</option>
<option value="cron">高级 CRON</option>
</select>
<div class="mt-3 grid gap-3 text-xs text-gray-500 md:grid-cols-2">
<div class="rounded-lg border border-gray-200 bg-gray-50 px-4 py-3" x-show="repeatType === 'once'">
活动只会在指定 `send_at` 触发一次。
</div>
<div class="rounded-lg border border-gray-200 bg-gray-50 px-4 py-3" x-show="repeatType === 'daily'">
`send_at` 的时分为基准,每天自动重复一次。
</div>
<div class="rounded-lg border border-gray-200 bg-gray-50 px-4 py-3" x-show="repeatType === 'weekly'">
`send_at` 的星期与时间为基准,每周自动重复。
</div>
<div class="rounded-lg border border-gray-200 bg-gray-50 px-4 py-3" x-show="repeatType === 'monthly'">
`send_at` 的日期与时间为基准,每月自动重复。
</div>
<div class="rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-amber-700"
x-show="repeatType === 'yearly'">
每年同一节日重复,可配置连续多天与每天多次发送。
</div>
<div class="rounded-lg border border-slate-200 bg-slate-50 px-4 py-3 text-slate-600"
x-show="repeatType === 'cron'">
兼容旧版 CRON 配置活动,适合保留现有高级规则。
</div>
</div>
</div>
<div class="md:col-span-2" x-show="repeatType === 'yearly'" x-transition.opacity>
<div class="space-y-4 rounded-2xl border border-amber-200 bg-gradient-to-br from-amber-50 via-orange-50 to-white p-5">
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<h4 class="text-sm font-bold text-amber-700">🎯 年度节日高级调度</h4>
<p class="mt-1 text-xs text-amber-700/80">用于 yearly:指定每年节日日期、连续天数以及每天的多次发送频率。</p>
</div>
<div class="rounded-full bg-white/80 px-3 py-1 text-xs font-medium text-amber-700 shadow-sm"
x-text="yearlySummary()"></div>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-3">
<div>
<label for="schedule_month" class="mb-1 block text-xs font-bold text-gray-600">节日月份</label>
<input id="schedule_month" type="number" name="schedule_month" min="1" max="12"
x-model="scheduleMonth" value="{{ $scheduleMonth }}" class="{{ $inputClass }}"
placeholder="1-12">
</div>
<div>
<label for="schedule_day" class="mb-1 block text-xs font-bold text-gray-600">节日日期</label>
<input id="schedule_day" type="number" name="schedule_day" min="1" max="31"
x-model="scheduleDay" value="{{ $scheduleDay }}" class="{{ $inputClass }}"
placeholder="1-31">
</div>
<div>
<label for="schedule_time" class="mb-1 block text-xs font-bold text-gray-600">首个发送时刻</label>
<input id="schedule_time" type="time" name="schedule_time" x-model="scheduleTime"
value="{{ $scheduleTime }}" class="{{ $inputClass }}">
</div>
<div>
<label for="duration_days" class="mb-1 block text-xs font-bold text-gray-600">连续天数</label>
<input id="duration_days" type="number" name="duration_days" min="1" max="31"
x-model="durationDays" value="{{ $durationDays }}" class="{{ $inputClass }}">
</div>
<div>
<label for="daily_occurrences" class="mb-1 block text-xs font-bold text-gray-600">每天发送次数</label>
<input id="daily_occurrences" type="number" name="daily_occurrences" min="1" max="24"
x-model="dailyOccurrences" value="{{ $dailyOccurrences }}" class="{{ $inputClass }}">
</div>
<div>
<label for="occurrence_interval_minutes"
class="mb-1 block text-xs font-bold text-gray-600">发送间隔(分钟)</label>
<input id="occurrence_interval_minutes" type="number" name="occurrence_interval_minutes"
min="1" max="1440" x-model="occurrenceIntervalMinutes"
value="{{ $occurrenceIntervalMinutes }}" class="{{ $inputClass }}">
</div>
</div>
</div>
</div>
<div class="md:col-span-2" x-show="repeatType === 'cron'" x-transition.opacity>
<div class="rounded-xl border border-slate-200 bg-slate-50 p-4">
<label for="cron_expr" class="mb-1 block text-xs font-bold text-gray-600">CRON 表达式</label>
<input id="cron_expr" type="text" name="cron_expr" value="{{ $fieldValue('cron_expr') }}"
class="{{ $inputClass }}" placeholder="例:0 9 * * 1">
</div>
</div>
</div>
</div>
<div>
<h3 class="mb-3 border-b pb-2 text-sm font-bold text-gray-700">🎯 目标用户</h3>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<label for="target_type" class="mb-1 block text-xs font-bold text-gray-600">用户范围</label>
<select id="target_type" name="target_type" x-model="targetType" class="{{ $inputClass }}">
<option value="all">全部在线用户</option>
<option value="vip"> VIP 用户</option>
<option value="level">指定等级以上</option>
</select>
</div>
<div x-show="targetType !== 'all'" x-transition.opacity>
<label for="target_value" class="mb-1 block text-xs font-bold text-gray-600"
x-text="targetType === 'vip' ? 'VIP 目标值 / 备注' : '最低用户等级'"></label>
<input id="target_value" x-bind:type="targetType === 'level' ? 'number' : 'text'" name="target_value"
value="{{ $targetValue }}" x-bind:min="targetType === 'level' ? 1 : null"
class="{{ $inputClass }}" :placeholder="targetType === 'vip' ? '例:gold / 3 / 高级档位' : '例:10'">
</div>
<div x-show="targetType === 'vip'" x-transition.opacity class="md:col-span-2">
<div class="rounded-lg border border-indigo-100 bg-indigo-50 px-4 py-3 text-xs text-indigo-700">
当前仍沿用后端 `target_type=vip` 逻辑,若主线后端升级为指定 VIP 档位,可继续复用 `target_value`
</div>
</div>
</div>
</div>
<div class="flex flex-wrap gap-3 border-t pt-4">
<button type="submit"
class="rounded-lg bg-amber-500 px-8 py-2.5 font-bold text-white shadow-sm transition hover:bg-amber-600">
{{ $submitLabel }}
</button>
<a href="{{ route('admin.holiday-events.index') }}"
class="rounded-lg bg-gray-100 px-6 py-2.5 font-bold text-gray-600 transition hover:bg-gray-200">
取消
</a>
</div>
</form>
</div>
</div>
<script>
function holidayEventForm(config) {
return {
repeatType: config.repeatType ?? 'once',
distributeType: config.distributeType ?? 'random',
targetType: config.targetType ?? 'all',
scheduleMonth: config.scheduleMonth ?? '1',
scheduleDay: config.scheduleDay ?? '1',
scheduleTime: config.scheduleTime ?? '20:00',
durationDays: config.durationDays ?? '1',
dailyOccurrences: config.dailyOccurrences ?? '1',
occurrenceIntervalMinutes: config.occurrenceIntervalMinutes ?? '60',
yearlySummary() {
return `每年 ${this.scheduleMonth} 月 ${this.scheduleDay} 日 ${this.scheduleTime},连续 ${this.durationDays} 天,每天 ${this.dailyOccurrences} 次 / ${this.occurrenceIntervalMinutes} 分钟`;
},
};
}
</script>
@@ -1,11 +1,12 @@
{{--
文件功能:节日福利弹窗组件
后台配置的节日活动触发时,通过 WebSocket 广播到达前端,
后台配置的节日福利批次触发时,通过 WebSocket 广播到达前端,
弹出全屏福利领取弹窗,用户点击领取后金币自动入账。
WebSocket 监听:chat:holiday.started
领取接口:POST /holiday/{event}/claim
领取接口:POST /holiday/runs/{run}/claim
状态接口:GET /holiday/runs/{run}/status
--}}
{{-- ─── 节日福利领取弹窗 ─── --}}
@@ -23,6 +24,9 @@
<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 x-show="roundLabel"
style="display:none; margin-bottom:10px; color:#fde68a; font-size:11px; font-weight:bold; letter-spacing:1px;"
x-text="roundLabel"></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>
@@ -33,34 +37,43 @@
{{-- 奖池信息 --}}
<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: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 style="color:rgba(254,243,199,.5); font-size:11px; margin-top:4px;">
<span x-show="distributeType === 'fixed' && fixedAmount !== null"
x-text="'每人固定 ' + Number(fixedAmount).toLocaleString() + ' 金币'"></span>
<span x-show="distributeType !== 'fixed' || fixedAmount === null">本轮按随机金额发放</span>
</div>
</div>
{{-- 有效期 --}}
<div style="color:rgba(254,243,199,.55); font-size:11px; margin-bottom:14px;">
<div x-show="scheduledForText" style="display:none; margin-bottom:4px;">
发放时间 <strong style="color:#fcd34d;" x-text="scheduledForText"></strong>
</div>
领取有效期 <strong style="color:#fcd34d;" x-text="expiresIn"></strong>,过期作废
<div x-show="statusHint" style="display:none; margin-top:6px;" x-text="statusHint"></div>
</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"
<button x-on:click="doClaim()" :disabled="claiming || loadingStatus || !claimable"
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
:style="claiming || loadingStatus || !claimable
?
'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>
<span x-text="claimButtonText()"></span>
</button>
</div>
</div>
@@ -69,8 +82,8 @@
<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 style="color:#fef3c7; font-size:13px;">🎉 恭喜!本轮节日福利已入账!</div>
<div style="color:rgba(254,243,199,.6); font-size:11px; margin-top:4px;">金币已自动到账,后续轮次开始时会继续提醒你 </div>
</div>
</div>
@@ -107,6 +120,143 @@
</style>
<script>
function firstHolidayDefined(...values) {
for (const value of values) {
if (value !== undefined && value !== null && value !== '') {
return value;
}
}
return null;
}
function toHolidayNumber(value, fallback = 0) {
if (value === undefined || value === null || value === '') {
return fallback;
}
const parsedValue = Number(value);
return Number.isFinite(parsedValue) ? parsedValue : fallback;
}
function parseHolidayDate(value) {
if (!value) {
return null;
}
const parsedDate = new Date(value);
return Number.isNaN(parsedDate.getTime()) ? null : parsedDate;
}
function formatHolidayDate(value) {
const parsedDate = parseHolidayDate(value);
if (!parsedDate) {
return '';
}
return new Intl.DateTimeFormat('zh-CN', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
}).format(parsedDate);
}
function formatHolidayRemaining(expiresAt) {
if (!expiresAt) {
return '以后端状态为准';
}
const remainingMilliseconds = expiresAt.getTime() - Date.now();
if (remainingMilliseconds <= 0) {
return '已过期';
}
const totalMinutes = Math.ceil(remainingMilliseconds / 60000);
if (totalMinutes < 60) {
return `${totalMinutes} 分钟`;
}
if (totalMinutes < 24 * 60) {
const hours = Math.floor(totalMinutes / 60);
const minutes = totalMinutes % 60;
return minutes > 0 ? `${hours} 小时 ${minutes} 分钟` : `${hours} 小时`;
}
const days = Math.floor(totalMinutes / (24 * 60));
const hours = Math.floor((totalMinutes % (24 * 60)) / 60);
return hours > 0 ? `${days} 天 ${hours} 小时` : `${days} 天`;
}
function buildHolidayRoundLabel(detail) {
const explicitLabel = firstHolidayDefined(detail.round_label, detail.batch_label, detail.run_label);
if (explicitLabel) {
return explicitLabel;
}
const roundNumber = toHolidayNumber(detail.round_no, 0);
if (detail.repeat_type === 'yearly') {
return roundNumber > 0 ? `年度第 ${roundNumber} 轮福利` : '年度福利批次';
}
if (roundNumber > 0) {
return `第 ${roundNumber} 轮福利`;
}
return detail.scheduled_for ? '本轮定时福利' : '当前福利批次';
}
function buildHolidayDescription(detail) {
if (detail.description) {
return detail.description;
}
const amountText = detail.distribute_type === 'fixed' && detail.fixed_amount !== null
? `每人固定 ${Number(detail.fixed_amount).toLocaleString()} 金币`
: '随机金额发放';
const quotaText = detail.max_claimants > 0
? `前 ${detail.max_claimants} 名在线用户可领取`
: '在线用户均可领取';
return `本轮福利已开启,${amountText}${quotaText}。`;
}
function normalizeHolidayPayload(detail) {
if (typeof window.normalizeHolidayBroadcastEvent === 'function') {
return window.normalizeHolidayBroadcastEvent(detail);
}
return detail ?? {};
}
function buildHolidaySystemMessage(detail) {
const quotaText = detail.max_claimants > 0
? `前 ${detail.max_claimants} 名在线用户可领取`
: '在线用户均可领取';
const amountText = detail.distribute_type === 'fixed' && detail.fixed_amount !== null
? `每人固定 ${Number(detail.fixed_amount).toLocaleString()} 金币`
: '随机金额发放';
const scheduleText = detail.scheduled_for ? `发放时间 ${formatHolidayDate(detail.scheduled_for)}` : null;
const roundText = detail.round_label ? ` ${detail.round_label}` : '';
return [
`🎊 【${detail.name}】${roundText}开始啦!`,
`总奖池 💰${Number(detail.total_amount).toLocaleString()} 金币`,
amountText,
quotaText,
scheduleText,
].filter(Boolean).join('');
}
/**
* 节日福利弹窗组件
* 监听 WebSocket 事件 holiday.started,弹出领取弹窗
@@ -114,38 +264,75 @@
function holidayEventModal() {
return {
show: false,
loadingStatus: false,
claiming: false,
claimable: true,
claimed: false,
expiresTimer: null,
// 活动数据
eventId: null,
runId: null,
legacyEventId: null,
eventName: '',
eventDesc: '',
roundLabel: '',
totalAmount: 0,
maxClaimants: 0,
distributeType: 'random',
fixedAmount: null,
scheduledFor: null,
scheduledForText: '',
expiresAt: null,
expiresIn: '',
claimedAmount: 0,
statusHint: '',
claimButtonText() {
if (this.loadingStatus) {
return '同步领取状态中…';
}
if (this.claiming) {
return '领取中…';
}
if (!this.claimable) {
return '当前不可领取';
}
return '🎁 立即领取福利';
},
/**
* 打开弹窗并填充活动数据
*/
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;
const holidayDetail = normalizeHolidayPayload(detail);
this.runId = holidayDetail.run_id ?? null;
this.legacyEventId = holidayDetail.event_id ?? null;
this.eventName = holidayDetail.name ?? '节日福利';
this.eventDesc = buildHolidayDescription(holidayDetail);
this.roundLabel = buildHolidayRoundLabel(holidayDetail);
this.totalAmount = toHolidayNumber(holidayDetail.total_amount, 0);
this.maxClaimants = toHolidayNumber(holidayDetail.max_claimants, 0);
this.distributeType = holidayDetail.distribute_type ?? 'random';
this.fixedAmount = firstHolidayDefined(holidayDetail.fixed_amount, null);
this.scheduledFor = parseHolidayDate(holidayDetail.scheduled_for);
this.scheduledForText = formatHolidayDate(holidayDetail.scheduled_for);
this.expiresAt = parseHolidayDate(holidayDetail.expires_at);
this.claimable = true;
this.claimed = false;
this.loadingStatus = false;
this.claiming = false;
this.claimedAmount = 0;
this.statusHint = holidayDetail.run_id
? '当前奖励按本轮福利批次发放,请在有效期内领取。'
: '兼容旧活动通道,等待主线广播升级';
this.updateExpiresIn();
this.startExpiresTimer();
this.show = true;
this.syncStatus();
},
/**
@@ -153,69 +340,222 @@
*/
close() {
this.show = false;
this.stopExpiresTimer();
},
/**
* 启动倒计时刷新
*/
startExpiresTimer() {
this.stopExpiresTimer();
this.expiresTimer = window.setInterval(() => this.updateExpiresIn(), 30000);
},
/**
* 停止倒计时刷新
*/
stopExpiresTimer() {
if (this.expiresTimer) {
window.clearInterval(this.expiresTimer);
this.expiresTimer = null;
}
},
/**
* 更新有效期显示文字
*/
updateExpiresIn() {
if (!this.expiresAt) {
this.expiresIn = '30 分钟';
this.expiresIn = formatHolidayRemaining(this.expiresAt);
},
/**
* 构建状态接口候选地址
*/
buildStatusUrls() {
const urls = [];
if (this.runId) {
urls.push(`/holiday/runs/${encodeURIComponent(this.runId)}/status`);
}
if (this.legacyEventId) {
urls.push(`/holiday/${encodeURIComponent(this.legacyEventId)}/status`);
}
return urls;
},
/**
* 构建领取接口候选地址
*/
buildClaimUrls() {
const urls = [];
if (this.runId) {
urls.push(`/holiday/runs/${encodeURIComponent(this.runId)}/claim`);
}
if (this.legacyEventId) {
urls.push(`/holiday/${encodeURIComponent(this.legacyEventId)}/claim`);
}
return urls;
},
/**
* 读取当前批次领取状态
*/
async syncStatus() {
const statusUrls = this.buildStatusUrls();
if (statusUrls.length === 0) {
this.claimable = false;
this.statusHint = '缺少 run_id,无法查询当前福利状态。';
return;
}
const diff = Math.max(0, Math.round((this.expiresAt - Date.now()) / 60000));
this.expiresIn = diff > 0 ? diff + ' 分钟' : '即将过期';
this.loadingStatus = true;
try {
for (const url of statusUrls) {
const response = await fetch(url, {
headers: {
'Accept': 'application/json',
'X-Requested-With': 'XMLHttpRequest',
},
});
if (response.status === 404 || response.status === 405) {
continue;
}
const data = await response.json();
this.applyStatus(data);
return;
}
this.statusHint = '状态接口尚未切换完成,可直接尝试领取。';
} catch {
this.statusHint = '状态同步失败,可直接尝试领取。';
} finally {
this.loadingStatus = false;
}
},
/**
* 应用后端返回的状态快照
*/
applyStatus(data) {
if (data.expires_at) {
this.expiresAt = parseHolidayDate(data.expires_at);
this.updateExpiresIn();
}
const statusValue = firstHolidayDefined(data.status, data.claim_status);
const alreadyClaimed = Boolean(data.claimed ?? data.has_claimed ?? false) || ['claimed', 'received', 'paid']
.includes(statusValue);
const amount = toHolidayNumber(firstHolidayDefined(data.claimed_amount, data.amount), 0);
if (alreadyClaimed) {
this.claimed = true;
this.claimable = false;
this.claimedAmount = amount;
this.statusHint = data.message ?? '本轮福利已领取。';
return;
}
if (data.claimable === false || data.can_claim === false) {
this.claimable = false;
this.statusHint = data.message ?? '当前不在可领取名单或领取窗口已关闭。';
return;
}
this.claimable = true;
this.statusHint = data.message ?? this.statusHint;
},
/**
* 发起领取请求
*/
async doClaim() {
if (this.claiming || this.claimed || !this.eventId) return;
if (this.claiming || this.claimed || !this.claimable) {
return;
}
const claimUrls = this.buildClaimUrls();
if (claimUrls.length === 0) {
window.chatDialog?.alert('缺少福利批次标识,暂时无法领取。', '提示', '#f59e0b');
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();
for (const url of claimUrls) {
const res = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name=csrf-token]').content,
},
});
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();
if (res.status === 404 || res.status === 405) {
continue;
}
const data = await res.json();
const claimedAmount = toHolidayNumber(firstHolidayDefined(data.claimed_amount, data.amount), 0);
const alreadyClaimed = Boolean(data.claimed ?? data.has_claimed ?? false) || data.message?.includes('已领取');
if (data.ok || alreadyClaimed) {
this.claimed = true;
this.claimable = false;
this.claimedAmount = claimedAmount;
this.statusHint = data.message ?? '本轮福利已入账。';
if (window.__chatUser && data.balance !== undefined) {
window.__chatUser.jjb = data.balance;
}
return;
}
this.statusHint = data.message ?? '领取失败,请稍后重试。';
window.chatDialog?.alert(this.statusHint, '提示', '#f59e0b');
if (data.message?.includes('已结束') || data.message?.includes('过期')) {
this.claimable = false;
this.close();
} else {
this.syncStatus();
}
return;
}
window.chatDialog?.alert('领取接口尚未切换完成,请稍后再试。', '提示', '#f59e0b');
} catch {
window.chatDialog?.alert('网络异常,请稍后重试。', '错误', '#cc4444');
} finally {
this.claiming = false;
}
this.claiming = false;
},
};
}
// ─── WebSocket 事件监听:节日福利开始 ─────────────────────────
window.addEventListener('chat:holiday.started', (e) => {
const detail = e.detail;
const detail = normalizeHolidayPayload(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}在线用户可领取!`
);
appendSystemMessage(buildHolidaySystemMessage({
...detail,
round_label: buildHolidayRoundLabel(detail),
}));
}
// 弹出全屏领取弹窗