// 商店弹窗业务模块,负责商品加载、购买、赠礼、改名卡和商店按钮事件。 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", }, ]; const DECORATION_GROUPS = [ { label: "💬 消息气泡", desc: "同类型只保留最新购买", type: "msg_bubble", }, { label: "🎨 昵称颜色", desc: "同类型只保留最新购买", type: "msg_name_color", }, { label: "🌈 文字颜色", desc: "同类型只保留最新购买", type: "msg_text_color", }, { label: "🖼️ 头像框", desc: "同类型只保留最新购买", type: "avatar_frame", }, ]; const DECORATION_TYPE_TO_SLOT = { msg_bubble: "bubble", msg_name_color: "name_color", msg_text_color: "text_color", avatar_frame: "avatar_frame", }; let shopControlEventsBound = false; let shopLoaded = false; let giftItem = null; let activeDecorations = {}; /** * 读取商店根节点上由 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} */ 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"; bindShopTabs(); if (!shopLoaded) { shopLoaded = true; fetchShopData(); } } /** * 关闭商店弹窗。 * * @returns {void} */ export function closeShopModal() { const modal = document.getElementById("shop-modal"); if (modal) { modal.style.display = "none"; } shopLoaded = false; } /** * 绑定商店 Tab 切换逻辑。 * * @returns {void} */ function bindShopTabs() { const tabsContainer = document.getElementById("shop-tabs"); if (!tabsContainer || tabsContainer.dataset.shopTabsBound) { return; } tabsContainer.dataset.shopTabsBound = "1"; tabsContainer.addEventListener("click", (event) => { const tab = event.target.closest("[data-shop-tab]"); if (!tab) { return; } const tabName = tab.dataset.shopTab; const itemsList = document.getElementById("shop-items-list"); const decorationsList = document.getElementById("shop-decorations-list"); // 切换当前 Tab 的选中状态。 tabsContainer.querySelectorAll(".shop-tab").forEach((btn) => { btn.classList.toggle("active", btn.dataset.shopTab === tabName); }); // 切换对应商品列表,装扮列表需要显式恢复 grid 布局。 if (tabName === "items") { if (itemsList) itemsList.style.display = ""; if (decorationsList) decorationsList.style.display = "none"; } else { if (itemsList) itemsList.style.display = "none"; // 装扮列表在 CSS 中为 display:none,需显式设置为 grid 才能覆盖 if (decorationsList) decorationsList.style.display = "grid"; } }); } /** * 拉取商品数据并渲染列表。 * * @returns {Promise} */ export async function fetchShopData() { try { const response = await fetch(getShopUrls().items, { headers: jsonHeaders(), }); const data = await response.json(); renderShop(data); renderDecorations(data); } catch (error) { showShopToast("⚠ 加载失败,请重试", false); } } /** * 兼容旧入口,给未迁移脚本按原函数名刷新商店。 * * @returns {Promise} */ export function loadShop() { return fetchShopData(); } /** * 构建分组标题旁边的状态徽章。 * * @param {Record} group 商品分组 * @param {Record} 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 ` ⏳ 剩余 ${escapeHtml(leftStr)}`; } 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 ` ✅ 已激活:${escapeHtml(effectName)}`; } if (group.type === "tools" && Number(data.sign_repair_card_count || 0) > 0) { return ` 🎫 可用 ${Number(data.sign_repair_card_count || 0)} 张`; } return ""; } /** * 按商品类型筛选当前分组要展示的商品。 * * @param {Record} group 商品分组 * @param {Array>} items 商品列表 * @returns {Array>} */ 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} 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 ? ` ${escapeHtml(group.desc)}` : ""}`; list.appendChild(header); groupItems.forEach((item) => { list.appendChild(createShopCard(item, data, ringCounts)); }); }); } /** * 渲染个人装扮商品列表。 * * @param {Record} data 商店接口数据 * @returns {void} */ export function renderDecorations(data) { const list = document.getElementById("shop-decorations-list"); if (!list) { return; } // 记录当前激活装扮,渲染时只给当前款式显示"已激活"。 activeDecorations = data.active_decorations || {}; const items = Array.isArray(data.items) ? data.items : []; list.innerHTML = ""; // 购买说明明确同类型替换规则,避免用户误以为可以叠加多款气泡或头像框。 const note = document.createElement("div"); note.className = "decoration-note"; note.innerHTML = "📌 购买说明:每个类型只生效一个,购买同类型新装扮后,旧装扮自动作废且不退款。"; list.appendChild(note); DECORATION_GROUPS.forEach((group) => { const groupItems = items.filter((item) => item.type === group.type); if (!groupItems.length) { return; } // 分组标题(独占一整行),描述文字内嵌到标题中避免单独占一格 const header = document.createElement("div"); header.className = "shop-group-header"; header.innerHTML = `${escapeHtml(group.label)}${group.desc ? ` ${escapeHtml(group.desc)}` : ''}`; list.appendChild(header); groupItems.forEach((item) => { const card = document.createElement("div"); card.className = "shop-card"; // active_decorations 由后端按槽位名索引,先把商品 type 映射到对应槽位。 const slot = DECORATION_TYPE_TO_SLOT[item.type] || null; const activeDeco = slot ? (activeDecorations[slot] || null) : null; const isActiveStyle = activeDeco && activeDeco.style === item.slug; const expiresAt = activeDeco ? activeDeco.expires_at : null; let daysLeft = ""; if (isActiveStyle && expiresAt) { const remaining = Math.max(0, Math.ceil((new Date(expiresAt).getTime() - Date.now()) / (1000 * 60 * 60 * 24))); daysLeft = remaining > 0 ? `剩余 ${remaining} 天` : "即将过期"; } const button = document.createElement("button"); if (isActiveStyle) { button.className = "shop-btn"; button.textContent = "续费 💰 " + Number(item.price || 0).toLocaleString(); } else { button.className = "shop-btn"; button.textContent = "购买 💰 " + Number(item.price || 0).toLocaleString(); } button.addEventListener("click", () => confirmAndBuyItem(item)); // 仅当前已激活的款式显示状态标签,同槽位其他款式保持普通购买状态。 const statusHtml = isActiveStyle ? `已激活${daysLeft ? ' · ' + daysLeft : ''}` : ""; const validityHtml = buildValidityHtml(item); card.innerHTML = `
${escapeHtml(item.icon)} ${escapeHtml(item.name)}
${statusHtml ? `
${statusHtml}
` : ""}
${escapeHtml(item.description ?? "")}
${validityHtml} ${isActiveStyle && expiresAt ? `
⏳ 到期:${escapeHtml(expiresAt.replace('T', ' ').slice(0, 16))}
` : ""} `; card.appendChild(button); list.appendChild(card); }); }); } /** * 创建单个商品卡片,并挂载购买或使用按钮事件。 * * @param {Record} item 商品数据 * @param {Record} data 商店接口数据 * @param {Record} 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} item 商品数据 * @param {{isRing: boolean, ownedQty: number, isAutoFishing: boolean}} options 渲染选项 * @returns {string} */ function buildShopCardHtml(item, options) { const iconHtml = options.isRing && options.ownedQty > 0 ? ` ${escapeHtml(item.icon)} ${options.ownedQty} ` : `${escapeHtml(item.icon)}`; const validityHtml = buildValidityHtml(item); const ringBonus = options.isRing && (Number(item.intimacy_bonus || 0) > 0 || Number(item.charm_bonus || 0) > 0) ? `
${Number(item.intimacy_bonus || 0) > 0 ? `💞 亲密 +${Number(item.intimacy_bonus || 0)}` : ""} ${Number(item.charm_bonus || 0) > 0 ? `✨ 魅力 +${Number(item.charm_bonus || 0)}` : ""}
` : ""; return `
${iconHtml} ${escapeHtml(item.name)}
${escapeHtml(item.description ?? "")}
${ringBonus} ${validityHtml} `; } /** * 生成购买前展示的有效期或生效方式文案。 * * @param {Record} item 商品数据 * @returns {string} */ function buildValidityText(item) { if (Number(item.duration_days || 0) > 0) { return `有效期:${Number(item.duration_days)} 天`; } if (Number(item.duration_minutes || 0) > 0) { return `有效期:${formatMinutes(item.duration_minutes)}`; } if (item.type === "instant") { return "购买后立即播放 1 次"; } if (item.type === "ring") { return "购买后存入背包,求婚时消耗"; } if (["one_time", "sign_repair"].includes(item.type)) { return "购买后存入背包,使用时消耗"; } return ""; } /** * 生成商品卡片里的有效期标签 HTML。 * * @param {Record} item 商品数据 * @returns {string} */ function buildValidityHtml(item) { const text = buildValidityText(item); return text ? `
⏱ ${escapeHtml(text)}
` : ""; } /** * 格式化分钟数,供自动钓鱼卡有效期展示。 * * @param {number|string} minutes 有效分钟数 * @returns {string} */ function formatMinutes(minutes) { const value = Number(minutes || 0); return value >= 60 ? `${Math.floor(value / 60)} 小时` : `${value} 分钟`; } /** * 购买前弹出确认框,补签卡需要先询问数量。 * * @param {Record} item 商品数据 * @returns {Promise} */ async function confirmAndBuyItem(item) { let quantity = 1; if (item.type === "sign_repair") { quantity = await window.promptSignRepairQuantity?.(item); if (quantity === null || quantity === undefined) { return; } } const validityText = buildValidityText(item); const replacementText = DECORATION_TYPE_TO_SLOT[item.type] ? "\n购买说明:同类型只生效最新购买,原有同类型装扮会自动作废且不退款。" : ""; const confirmMessage = `确认花费 💰 ${Number(Number(item.price || 0) * quantity).toLocaleString()} 金币购买\n【${item.name}】${quantity > 1 ? ` × ${quantity}` : ""}${validityText ? `\n${validityText}` : ""}${replacementText}\n\n确定购买吗?`; const confirmed = await confirmShopPurchase(confirmMessage); if (confirmed) { buyItem(item.id, item.name, item.price, "all", "", quantity); } } /** * 兼容全局弹窗组件缺失时的原生确认。 * * @param {string} message 确认文案 * @returns {Promise} */ async function confirmShopPurchase(message) { if (window.chatDialog?.confirm) { return Boolean(await window.chatDialog.confirm(message, "确认购买")); } return window.confirm(message); } /** * 打开赠礼对话框,缓存当前要赠出的单次特效卡。 * * @param {Record} 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) { const validityText = buildValidityText(item); itemName.textContent = `${item.icon} ${item.name}(💰 ${Number(item.price || 0).toLocaleString()}${validityText ? ` · ${validityText}` : ""})`; } 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} */ 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} 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.slot && data.style) { activeDecorations[data.slot] = { style: data.style, expires_at: data.expires_at, }; } // 购买者本地也要立即看到特效,广播只负责其他在线用户。 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} */ 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.renderDecorations = renderDecorations; 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(); } }); }