Files
chatroom/resources/js/chat-room/shop-controls.js
T

945 lines
29 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",
},
];
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<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";
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<void>}
*/
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<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>} 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 ? ` <span>${escapeHtml(group.desc)}</span>` : ''}`;
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
? `<span class="decoration-status active">已激活${daysLeft ? ' · ' + daysLeft : ''}</span>`
: "";
const validityHtml = buildValidityHtml(item);
card.innerHTML = `
<div class="shop-card-top">
<span class="shop-card-icon">${escapeHtml(item.icon)}</span>
<span class="shop-card-name">${escapeHtml(item.name)}</span>
</div>
${statusHtml ? `<div class="decoration-status-line">${statusHtml}</div>` : ""}
<div class="shop-card-desc">${escapeHtml(item.description ?? "")}</div>
${validityHtml}
${isActiveStyle && expiresAt ? `<div class="decoration-expiry">⏳ 到期:${escapeHtml(expiresAt.replace('T', ' ').slice(0, 16))}</div>` : ""}
`;
card.appendChild(button);
list.appendChild(card);
});
});
}
/**
* 创建单个商品卡片,并挂载购买或使用按钮事件。
*
* @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 validityHtml = buildValidityHtml(item);
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}
${validityHtml}
`;
}
/**
* 生成购买前展示的有效期或生效方式文案。
*
* @param {Record<string, any>} 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<string, any>} item 商品数据
* @returns {string}
*/
function buildValidityHtml(item) {
const text = buildValidityText(item);
return text ? `<div class="shop-validity">⏱ ${escapeHtml(text)}</div>` : "";
}
/**
* 格式化分钟数,供自动钓鱼卡有效期展示。
*
* @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;
}
}
// 个性装扮支持多份购买(叠加天数),弹出数量选择
if (DECORATION_TYPE_TO_SLOT[item.type] && item.type !== "sign_repair") {
quantity = await window.promptQuantity?.(`购买多份【${item.name}】可叠加天数\n已激活的同款续购自动延长,无需一次买满`, 1, 99) ?? 1;
if (quantity === null || quantity === undefined) {
return;
}
}
const validityText = buildValidityText(item);
const confirmMessage = `确认花费 💰 ${Number(Number(item.price || 0) * quantity).toLocaleString()} 金币购买\n${item.name}${quantity > 1 ? ` × ${quantity}` : ""}${validityText ? `\n${validityText}` : ""}\n\n确定购买吗?`;
const confirmed = await confirmShopPurchase(confirmMessage);
if (confirmed) {
buyItem(item.id, item.name, item.price, "all", "", quantity, item.type);
}
}
/**
* 通用数量输入弹窗。
*
* @param {string} hint 提示文案
* @param {number} [minVal=1] 最小值
* @param {number} [maxVal=99] 最大值
* @returns {Promise<number|null>} 返回数量,用户取消返回 null
*/
window.promptQuantity = async (hint, minVal = 1, maxVal = 99) => {
if (window.chatDialog?.prompt) {
const result = await window.chatDialog.prompt(hint, "1", "购买数量");
if (result === null || result === undefined) return null;
const val = parseInt(result, 10);
if (isNaN(val) || val < minVal || val > maxVal) {
window.chatDialog?.alert?.(`请输入 ${minVal}~${maxVal} 之间的整数`);
return null;
}
return val;
}
const result = window.prompt(`${hint}\n数量(${minVal}~${maxVal}):`, "1");
if (result === null) return null;
const val = parseInt(result, 10);
if (isNaN(val) || val < minVal || val > maxVal) {
alert(`请输入 ${minVal}~${maxVal} 之间的整数`);
return null;
}
return val;
};
/**
* 兼容全局弹窗组件缺失时的原生确认。
*
* @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) {
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, 1, item.type);
}
/**
* 购买商品,保留旧全局签名供补签卡等存量脚本复用。
*
* @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, itemType = '') {
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, itemType);
return;
}
showShopToast(data.message, false);
} catch (error) {
showShopToast("⚠ 网络异常,请重试", false);
}
}
/**
* 购买成功后同步金币、播放本地特效并刷新商店状态。
*
* @param {Record<string, any>} data 购买接口返回
* @param {string} itemName 商品名称
* @returns {void}
*/
function handleBuySuccess(data, itemName, itemType = '') {
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();
}, 300);
// 自动钓鱼卡购买后立即开启自动钓鱼
if (itemType === "auto_fishing" && typeof window.checkAndAutoStartFishing === "function") {
if (!window.chatContext) window.chatContext = {};
window.chatContext.autoFishingMinutesLeft = Number(data.auto_fishing_minutes_left || 1);
window.chatContext.fishingCooldownSeconds = 0;
setTimeout(() => window.checkAndAutoStartFishing(), 500);
}
}
/**
* 显示商店内部 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.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();
}
});
}