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

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
@@ -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),
}));
}
// 弹出全屏领取弹窗