Files

661 lines
26 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{{--
文件功能:节日福利弹窗组件
后台配置的节日福利批次触发时,通过 WebSocket 广播到达前端,
弹出全屏福利领取弹窗,用户点击领取后金币自动入账。
WebSocket 监听:chat:holiday.started
领取接口:POST /holiday/runs/{run}/claim
状态接口:GET /holiday/runs/{run}/status
--}}
{{-- ─── 节日福利领取弹窗 ─── --}}
<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 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>
</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 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,.26); border-radius:999px; padding:10px 16px; margin-top:6px;">
<div style="display:flex; justify-content:center;">
<button x-on:click="doClaim()" :disabled="claiming || loadingStatus || !claimable"
onmouseover="if(!this.disabled)this.style.transform='translateY(-2px) scale(1.04)'"
onmouseout="this.style.transform=''"
:style="claiming || loadingStatus || !claimable
?
'min-width:220px; padding:12px 28px; border-radius:999px; border:none; cursor:not-allowed; display:inline-flex; align-items:center; justify-content:center; gap:8px; box-sizing:border-box; transition:all .25s; font-size:18px; font-weight:900; letter-spacing:1px; background:#c8b89a; color:rgba(100,50,20,.45); box-shadow:none;'
:
'min-width:220px; padding:12px 28px; border-radius:999px; border:none; cursor:pointer; display:inline-flex; align-items:center; justify-content:center; gap:8px; box-sizing:border-box; transition:all .25s; font-size:18px; font-weight:900; letter-spacing:1px; background:#f5e6c8; color:#8b3a1a; box-shadow:0 8px 28px rgba(0,0,0,.28), inset 0 -4px 10px rgba(139,58,26,.12); animation:holiday-claim-pulse 1.8s ease-in-out infinite;'">
<span style="font-size:22px; line-height:1;">🎁</span>
<span x-text="claimButtonText()"></span>
</button>
</div>
</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);
}
}
@keyframes holiday-claim-pulse {
0%,
100% {
box-shadow: 0 8px 28px rgba(0, 0, 0, .28), inset 0 -4px 10px rgba(139, 58, 26, .12);
}
50% {
box-shadow: 0 10px 34px rgba(245, 230, 200, .18), 0 12px 30px rgba(0, 0, 0, .32), inset 0 -4px 10px rgba(139, 58, 26, .16);
}
}
</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 ?? {};
}
/**
* 缓存最近广播过的节日福利批次详情,供公屏消息按钮二次打开弹窗。
*/
window.__holidayRuns = window.__holidayRuns || {};
/**
* 从公屏系统消息中打开指定节日福利批次弹窗。
*
* @param {number|string} runId
*/
window.openHolidayRunFromSystemMessage = function(runId) {
const normalizedRunId = String(runId);
const detail = window.__holidayRuns?.[normalizedRunId];
const el = document.getElementById('holiday-event-modal');
if (!el || typeof Alpine === 'undefined') {
return;
}
if (!detail) {
window.chatDialog?.alert('当前福利批次信息未缓存,请等待下一轮广播或刷新页面后重试。', '提示', '#f59e0b');
return;
}
Alpine.$data(el).open(detail);
};
/**
* 构建公屏系统消息里的领取按钮,便于多个消息场景复用一致视觉。
*
* @param {number|string|null} runId
* @param {string} label
* @returns {string}
*/
function buildHolidayClaimActionButton(runId, label = '🎁 立即领取') {
if (!runId) {
return '';
}
return ` <button type="button"
onclick="openHolidayRunFromSystemMessage(${JSON.stringify(String(runId))})"
style="display:inline-flex; align-items:center; gap:4px; margin-left:8px; padding:3px 10px; border:none; border-radius:999px; background:linear-gradient(135deg,#f59e0b,#d97706); color:#fff; font-size:12px; font-weight:bold; cursor:pointer; box-shadow:0 2px 6px rgba(0,0,0,.22); vertical-align:middle;"
title="点击领取本轮节日福利">${label}</button>`;
}
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}` : '';
const runId = firstHolidayDefined(detail.run_id, detail.id);
const claimButtonHtml = buildHolidayClaimActionButton(runId);
return [
`🎊 【${detail.name}】${roundText}开始啦!`,
`总奖池 💰${Number(detail.total_amount).toLocaleString()} 金币`,
amountText,
quotaText,
scheduleText,
].filter(Boolean).join('') + claimButtonHtml;
}
/**
* 节日福利弹窗组件
* 监听 WebSocket 事件 holiday.started,弹出领取弹窗
*/
function holidayEventModal() {
return {
show: false,
loadingStatus: false,
claiming: false,
claimable: true,
claimed: false,
expiresTimer: null,
autoCloseTimer: 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) {
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.stopAutoCloseTimer();
this.statusHint = holidayDetail.run_id
? '当前奖励按本轮福利批次发放,请在有效期内领取。'
: '兼容旧活动通道,等待主线广播升级';
this.updateExpiresIn();
this.startExpiresTimer();
this.show = true;
this.syncStatus();
},
/**
* 关闭弹窗
*/
close() {
this.show = false;
this.stopExpiresTimer();
this.stopAutoCloseTimer();
},
/**
* 启动倒计时刷新
*/
startExpiresTimer() {
this.stopExpiresTimer();
this.expiresTimer = window.setInterval(() => this.updateExpiresIn(), 30000);
},
/**
* 停止倒计时刷新
*/
stopExpiresTimer() {
if (this.expiresTimer) {
window.clearInterval(this.expiresTimer);
this.expiresTimer = null;
}
},
/**
* 启动领取成功后的自动关闭计时器。
*/
startAutoCloseTimer() {
this.stopAutoCloseTimer();
this.autoCloseTimer = window.setTimeout(() => this.close(), 3000);
},
/**
* 停止自动关闭计时器。
*/
stopAutoCloseTimer() {
if (this.autoCloseTimer) {
window.clearTimeout(this.autoCloseTimer);
this.autoCloseTimer = null;
}
},
/**
* 更新有效期显示文字
*/
updateExpiresIn() {
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;
}
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.claimable) {
return;
}
const claimUrls = this.buildClaimUrls();
if (claimUrls.length === 0) {
window.chatDialog?.alert('缺少福利批次标识,暂时无法领取。', '提示', '#f59e0b');
return;
}
this.claiming = true;
try {
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 (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 ?? '本轮福利已入账。'} 3 秒后自动关闭。`;
if (typeof appendSystemMessage === 'function') {
const username = window.chatContext?.username ?? '当前用户';
const roundText = this.roundLabel ? `【${this.roundLabel}】` : '';
appendSystemMessage(
`🌟 <b>${username}</b> 领取了【${this.eventName}】${roundText},获得 <b>${claimedAmount.toLocaleString()}</b> 金币!${buildHolidayClaimActionButton(this.runId)}`
);
}
this.startAutoCloseTimer();
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;
}
},
};
}
// ─── WebSocket 事件监听:节日福利开始 ─────────────────────────
window.addEventListener('chat:holiday.started', (e) => {
const detail = normalizeHolidayPayload(e.detail);
if (detail.run_id !== undefined && detail.run_id !== null) {
window.__holidayRuns[String(detail.run_id)] = detail;
}
// 公屏追加系统消息
if (typeof appendSystemMessage === 'function') {
appendSystemMessage(buildHolidaySystemMessage({
...detail,
round_label: buildHolidayRoundLabel(detail),
}));
}
// 弹出全屏领取弹窗
const el = document.getElementById('holiday-event-modal');
if (el) Alpine.$data(el).open(detail);
});
</script>