Files
chatroom/resources/js/chat-room/shop-controls.js
T
2026-04-25 14:56:04 +08:00

693 lines
20 KiB
JavaScript
Raw 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.
// 商店弹窗业务模块,负责商品加载、购买、赠礼、改名卡和商店按钮事件。
import { escapeHtml } from "./html.js";
const DEFAULT_SHOP_ITEMS_URL = "/shop/items";
const DEFAULT_SHOP_BUY_URL = "/shop/buy";
const DEFAULT_SHOP_RENAME_URL = "/shop/rename";
const WEEK_EFFECT_ICONS = {
fireworks: "🎆",
rain: "🌧",
lightning: "⚡",
snow: "❄️",
sakura: "🌸",
meteors: "🌠",
"gold-rain": "🪙",
hearts: "💖",
confetti: "🎊",
fireflies: "✨",
};
const SHOP_GROUPS = [
{
label: "⚡ 单次特效卡",
desc: "立即播放一次",
type: "instant",
},
{
label: "📅 周卡 7天登录自动播放",
desc: "同时只能激活一种,购新旧失效不退款",
type: "duration",
},
{
label: "💍 求婚戒指",
desc: "存入背包,求婚时消耗(被拒则遗失)",
type: "ring",
},
{
label: "🎣 自动钓鱼卡",
desc: "激活后自动收篼,无需手动点击浮漂",
type: "auto_fishing",
},
{
label: "🎭 道具",
desc: "",
type: "tools",
},
];
let shopControlEventsBound = false;
let shopLoaded = false;
let giftItem = null;
/**
* 读取商店根节点上由 Blade 注入的接口地址。
*
* @returns {{items: string, buy: string, rename: string}}
*/
function getShopUrls() {
const modal = document.getElementById("shop-modal");
return {
items: modal?.dataset.shopItemsUrl || DEFAULT_SHOP_ITEMS_URL,
buy: modal?.dataset.shopBuyUrl || DEFAULT_SHOP_BUY_URL,
rename: modal?.dataset.shopRenameUrl || DEFAULT_SHOP_RENAME_URL,
};
}
/**
* 读取 CSRF Token,给 fetch 请求统一使用。
*
* @returns {string}
*/
function csrfToken() {
return document.querySelector('meta[name="csrf-token"]')?.content ?? "";
}
/**
* 生成 fetch 请求头。
*
* @param {boolean} withJson 是否声明 JSON 请求体
* @returns {Record<string, string>}
*/
function jsonHeaders(withJson = false) {
const headers = {
Accept: "application/json",
"X-CSRF-TOKEN": csrfToken(),
};
if (withJson) {
headers["Content-Type"] = "application/json";
}
return headers;
}
/**
* 打开商店弹窗,首次打开时拉取商品列表。
*
* @returns {void}
*/
export function openShopModal() {
const modal = document.getElementById("shop-modal");
if (!modal) {
return;
}
modal.style.display = "flex";
if (!shopLoaded) {
shopLoaded = true;
fetchShopData();
}
}
/**
* 关闭商店弹窗。
*
* @returns {void}
*/
export function closeShopModal() {
const modal = document.getElementById("shop-modal");
if (modal) {
modal.style.display = "none";
}
}
/**
* 拉取商品数据并渲染列表。
*
* @returns {Promise<void>}
*/
export async function fetchShopData() {
try {
const response = await fetch(getShopUrls().items, {
headers: jsonHeaders(),
});
const data = await response.json();
renderShop(data);
} catch (error) {
showShopToast("⚠ 加载失败,请重试", false);
}
}
/**
* 兼容旧入口,给未迁移脚本按原函数名刷新商店。
*
* @returns {Promise<void>}
*/
export function loadShop() {
return fetchShopData();
}
/**
* 构建分组标题旁边的状态徽章。
*
* @param {Record<string, any>} group 商品分组
* @param {Record<string, any>} data 商店接口数据
* @returns {string}
*/
function buildGroupSuffix(group, data) {
if (group.type === "auto_fishing" && Number(data.auto_fishing_minutes_left || 0) > 0) {
const left = Number(data.auto_fishing_minutes_left || 0);
const leftStr = left >= 60 ? `${Math.floor(left / 60)} 小时` : `${left} 分钟`;
return ` <span style="display:inline-block;margin-left:8px;padding:1px 8px;background:#7c3aed;color:#fff;border-radius:10px;font-size:10px;font-weight:bold;vertical-align:middle;">⏳ 剩余 ${escapeHtml(leftStr)}</span>`;
}
if (group.type === "duration" && data.active_week_effect) {
const effectKey = String(data.active_week_effect);
const effectItem = (data.items || []).find((item) => item.type === "duration" && String(item.slug || "").includes(effectKey));
const effectName = effectItem ? effectItem.name : effectKey;
return ` <span style="display:inline-block;margin-left:8px;padding:1px 8px;background:#16a34a;color:#fff;border-radius:10px;font-size:10px;font-weight:bold;vertical-align:middle;">✅ 已激活:${escapeHtml(effectName)}</span>`;
}
if (group.type === "tools" && Number(data.sign_repair_card_count || 0) > 0) {
return ` <span style="display:inline-block;margin-left:8px;padding:1px 8px;background:#0f766e;color:#fff;border-radius:10px;font-size:10px;font-weight:bold;vertical-align:middle;">🎫 可用 ${Number(data.sign_repair_card_count || 0)} 张</span>`;
}
return "";
}
/**
* 按商品类型筛选当前分组要展示的商品。
*
* @param {Record<string, any>} group 商品分组
* @param {Array<Record<string, any>>} items 商品列表
* @returns {Array<Record<string, any>>}
*/
function filterGroupItems(group, items) {
if (group.type === "tools") {
return items.filter((item) => ["one_time", "sign_repair"].includes(item.type));
}
return items.filter((item) => item.type === group.type);
}
/**
* 渲染商店商品列表和余额状态。
*
* @param {Record<string, any>} data 商店接口数据
* @returns {void}
*/
export function renderShop(data) {
const balance = document.getElementById("shop-jjb");
const badge = document.getElementById("shop-week-badge");
const list = document.getElementById("shop-items-list");
if (!list) {
return;
}
if (balance) {
balance.textContent = Number(data.user_jjb || 0).toLocaleString();
}
if (badge) {
if (data.active_week_effect) {
badge.textContent = `${WEEK_EFFECT_ICONS[data.active_week_effect] ?? ""} 周卡生效中`;
badge.style.display = "inline-block";
} else {
badge.textContent = "";
badge.style.display = "none";
}
}
const items = Array.isArray(data.items) ? data.items : [];
const ringCounts = data.ring_counts || {};
list.innerHTML = "";
SHOP_GROUPS.forEach((group) => {
const groupItems = filterGroupItems(group, items);
if (!groupItems.length) {
return;
}
const header = document.createElement("div");
header.className = "shop-group-header";
header.innerHTML = `${escapeHtml(group.label)}${buildGroupSuffix(group, { ...data, items })}${group.desc ? ` <span>${escapeHtml(group.desc)}</span>` : ""}`;
list.appendChild(header);
groupItems.forEach((item) => {
list.appendChild(createShopCard(item, data, ringCounts));
});
});
}
/**
* 创建单个商品卡片,并挂载购买或使用按钮事件。
*
* @param {Record<string, any>} item 商品数据
* @param {Record<string, any>} data 商店接口数据
* @param {Record<string, number>} ringCounts 戒指持有数量
* @returns {HTMLDivElement}
*/
function createShopCard(item, data, ringCounts) {
const isRename = item.slug === "rename_card";
const canUseRename = isRename && data.has_rename_card;
const isRing = item.type === "ring";
const ownedQty = isRing ? Number(ringCounts[item.id] || 0) : 0;
const isAutoFishing = item.type === "auto_fishing";
const card = document.createElement("div");
const button = document.createElement("button");
card.className = "shop-card";
card.innerHTML = buildShopCardHtml(item, {
isRing,
ownedQty,
isAutoFishing,
});
if (canUseRename) {
button.className = "shop-btn shop-btn-use";
button.textContent = "✦ 使用改名卡";
button.addEventListener("click", openRenameModal);
} else if (item.type === "instant") {
button.className = "shop-btn";
button.textContent = `💰 ${Number(item.price || 0).toLocaleString()}`;
button.addEventListener("click", () => openGiftDialog(item));
} else {
button.className = "shop-btn";
button.textContent = `💰 ${Number(item.price || 0).toLocaleString()}`;
button.addEventListener("click", () => confirmAndBuyItem(item));
}
card.appendChild(button);
return card;
}
/**
* 拼接商品卡片 HTML,所有接口字段都先转义再进入 innerHTML。
*
* @param {Record<string, any>} item 商品数据
* @param {{isRing: boolean, ownedQty: number, isAutoFishing: boolean}} options 渲染选项
* @returns {string}
*/
function buildShopCardHtml(item, options) {
const iconHtml = options.isRing && options.ownedQty > 0
? `<span style="position:relative;display:inline-block;">
<span class="shop-card-icon">${escapeHtml(item.icon)}</span>
<span style="position:absolute;top:-4px;right:-6px;background:#f43f5e;color:#fff;font-size:9px;font-weight:800;min-width:15px;height:15px;border-radius:8px;text-align:center;line-height:15px;padding:0 2px;">${options.ownedQty}</span>
</span>`
: `<span class="shop-card-icon">${escapeHtml(item.icon)}</span>`;
const durationLabel = options.isAutoFishing && Number(item.duration_minutes || 0) > 0
? `<div style="font-size:9px;margin-top:3px;color:#7c3aed;">⏱ 有效期 ${escapeHtml(formatMinutes(item.duration_minutes))}</div>`
: "";
const ringBonus = options.isRing && (Number(item.intimacy_bonus || 0) > 0 || Number(item.charm_bonus || 0) > 0)
? `<div style="font-size:9px;margin-top:3px;display:flex;gap:8px;">
${Number(item.intimacy_bonus || 0) > 0 ? `<span style="color:#f43f5e;">💞 亲密 +${Number(item.intimacy_bonus || 0)}</span>` : ""}
${Number(item.charm_bonus || 0) > 0 ? `<span style="color:#a855f7;">✨ 魅力 +${Number(item.charm_bonus || 0)}</span>` : ""}
</div>`
: "";
return `
<div class="shop-card-top">
${iconHtml}
<span class="shop-card-name">${escapeHtml(item.name)}</span>
</div>
<div class="shop-card-desc">${escapeHtml(item.description ?? "")}</div>
${ringBonus}
${durationLabel}
`;
}
/**
* 格式化分钟数,供自动钓鱼卡有效期展示。
*
* @param {number|string} minutes 有效分钟数
* @returns {string}
*/
function formatMinutes(minutes) {
const value = Number(minutes || 0);
return value >= 60 ? `${Math.floor(value / 60)} 小时` : `${value} 分钟`;
}
/**
* 购买前弹出确认框,补签卡需要先询问数量。
*
* @param {Record<string, any>} item 商品数据
* @returns {Promise<void>}
*/
async function confirmAndBuyItem(item) {
let quantity = 1;
if (item.type === "sign_repair") {
quantity = await window.promptSignRepairQuantity?.(item);
if (quantity === null || quantity === undefined) {
return;
}
}
const confirmMessage = `确认花费 💰 ${Number(Number(item.price || 0) * quantity).toLocaleString()} 金币购买\n${item.name}${quantity > 1 ? ` × ${quantity}` : ""} 吗?`;
const confirmed = await confirmShopPurchase(confirmMessage);
if (confirmed) {
buyItem(item.id, item.name, item.price, "all", "", quantity);
}
}
/**
* 兼容全局弹窗组件缺失时的原生确认。
*
* @param {string} message 确认文案
* @returns {Promise<boolean>}
*/
async function confirmShopPurchase(message) {
if (window.chatDialog?.confirm) {
return Boolean(await window.chatDialog.confirm(message, "确认购买"));
}
return window.confirm(message);
}
/**
* 打开赠礼对话框,缓存当前要赠出的单次特效卡。
*
* @param {Record<string, any>} item 商品数据
* @returns {void}
*/
export function openGiftDialog(item) {
giftItem = item;
const recipient = document.getElementById("gift-recipient");
const message = document.getElementById("gift-message");
const error = document.getElementById("gift-err");
const itemName = document.getElementById("gift-item-name");
const dialog = document.getElementById("gift-dialog");
if (recipient) {
recipient.value = "";
}
if (message) {
message.value = "";
}
if (error) {
error.textContent = "";
}
if (itemName) {
itemName.textContent = `${item.icon} ${item.name}(💰 ${Number(item.price || 0).toLocaleString()}`;
}
if (dialog) {
dialog.style.display = "flex";
}
}
/**
* 关闭赠礼对话框并清理当前商品缓存。
*
* @returns {void}
*/
export function closeGiftDialog() {
const dialog = document.getElementById("gift-dialog");
if (dialog) {
dialog.style.display = "none";
}
giftItem = null;
}
/**
* 确认赠礼,按旧签名调用购买接口。
*
* @returns {void}
*/
export function confirmGift() {
if (!giftItem) {
return;
}
const recipient = document.getElementById("gift-recipient")?.value.trim() ?? "";
const message = document.getElementById("gift-message")?.value.trim() ?? "";
const item = giftItem;
const error = document.getElementById("gift-err");
if (error) {
error.textContent = "";
}
closeGiftDialog();
buyItem(item.id, item.name, item.price, recipient, message);
}
/**
* 购买商品,保留旧全局签名供补签卡等存量脚本复用。
*
* @param {number|string} itemId 商品 ID
* @param {string} name 商品名称
* @param {number|string} price 商品价格
* @param {string} recipient 接收人,空值表示全场
* @param {string} message 赠言
* @param {number} quantity 购买数量
* @returns {Promise<void>}
*/
export async function buyItem(itemId, name, price, recipient, message, quantity = 1) {
try {
const response = await fetch(getShopUrls().buy, {
method: "POST",
headers: jsonHeaders(true),
body: JSON.stringify({
item_id: itemId,
recipient: recipient || "all",
message: message || "",
quantity: quantity || 1,
room_id: window.chatContext?.roomId ?? 0,
}),
});
const data = await response.json();
if (data.status === "success") {
handleBuySuccess(data, name);
return;
}
showShopToast(data.message, false);
} catch (error) {
showShopToast("⚠ 网络异常,请重试", false);
}
}
/**
* 购买成功后同步金币、播放本地特效并刷新商店状态。
*
* @param {Record<string, any>} data 购买接口返回
* @param {string} itemName 商品名称
* @returns {void}
*/
function handleBuySuccess(data, itemName) {
const balance = document.getElementById("shop-jjb");
if (data.jjb !== undefined && balance) {
balance.textContent = Number(data.jjb).toLocaleString();
}
showShopToast(`${itemName} 购买成功!`, true);
// 购买者本地也要立即看到特效,广播只负责其他在线用户。
if (data.play_effect && window.EffectManager) {
window.EffectManager.play(data.play_effect);
}
shopLoaded = false;
setTimeout(() => {
fetchShopData();
shopLoaded = true;
}, 1000);
}
/**
* 显示商店内部 Toast。
*
* @param {string} message 提示内容
* @param {boolean} ok 是否成功
* @returns {void}
*/
export function showShopToast(message, ok) {
const element = document.getElementById("shop-toast");
if (!element) {
return;
}
element.textContent = message;
element.style.display = "block";
element.style.background = ok ? "#064e3b" : "#7f1d1d";
element.style.color = ok ? "#6ee7b7" : "#fca5a5";
clearTimeout(element._shopToastTimer);
element._shopToastTimer = setTimeout(() => {
element.style.display = "none";
}, 3500);
}
/**
* 打开改名卡输入框。
*
* @returns {void}
*/
export function openRenameModal() {
const modal = document.getElementById("shop-rename-overlay");
const input = document.getElementById("rename-input");
const error = document.getElementById("rename-err");
if (modal) {
modal.style.display = "flex";
}
if (input) {
input.focus();
}
if (error) {
error.textContent = "";
}
}
/**
* 关闭改名卡输入框。
*
* @returns {void}
*/
export function closeRenameModal() {
const modal = document.getElementById("shop-rename-overlay");
if (modal) {
modal.style.display = "none";
}
}
/**
* 提交改名卡请求,成功后刷新页面同步昵称。
*
* @returns {Promise<void>}
*/
export async function submitRename() {
const input = document.getElementById("rename-input");
const error = document.getElementById("rename-err");
const newName = input?.value.trim() ?? "";
if (!newName) {
if (error) {
error.textContent = "请输入新昵称";
}
return;
}
try {
const response = await fetch(getShopUrls().rename, {
method: "POST",
headers: jsonHeaders(true),
body: JSON.stringify({
new_name: newName,
}),
});
const data = await response.json();
if (data.status === "success") {
closeRenameModal();
showShopToast(data.message, true);
shopLoaded = false;
setTimeout(() => window.location.reload(), 2000);
return;
}
if (error) {
error.textContent = data.message;
}
} catch (requestError) {
if (error) {
error.textContent = "网络异常,请重试";
}
}
}
/**
* 把模块函数挂到 window,兼容仍在 Blade 内的调用点。
*
* @returns {void}
*/
function exposeShopGlobals() {
window.openShopModal = openShopModal;
window.closeShopModal = closeShopModal;
window.loadShop = loadShop;
window.fetchShopData = fetchShopData;
window.renderShop = renderShop;
window.openGiftDialog = openGiftDialog;
window.closeGiftDialog = closeGiftDialog;
window.confirmGift = confirmGift;
window.buyItem = buyItem;
window.showShopToast = showShopToast;
window.openRenameModal = openRenameModal;
window.closeRenameModal = closeRenameModal;
window.submitRename = submitRename;
}
/**
* 绑定商店按钮、对话框按钮与遮罩关闭事件。
*
* @returns {void}
*/
export function bindShopControls() {
if (typeof window === "undefined" || typeof document === "undefined") {
return;
}
exposeShopGlobals();
if (shopControlEventsBound) {
return;
}
shopControlEventsBound = true;
document.addEventListener("click", (event) => {
if (!(event.target instanceof Element)) {
return;
}
const modal = document.getElementById("shop-modal");
// 点击商店遮罩或关闭按钮都走同一个关闭入口。
if (event.target.closest("[data-shop-modal-close]") || (modal && event.target === modal)) {
event.preventDefault();
closeShopModal();
return;
}
if (event.target.closest("[data-shop-rename-confirm]")) {
event.preventDefault();
submitRename();
return;
}
if (event.target.closest("[data-shop-rename-cancel]")) {
event.preventDefault();
closeRenameModal();
return;
}
if (event.target.closest("[data-shop-gift-confirm]")) {
event.preventDefault();
confirmGift();
return;
}
if (event.target.closest("[data-shop-gift-cancel]")) {
event.preventDefault();
closeGiftDialog();
}
});
}