945 lines
29 KiB
JavaScript
945 lines
29 KiB
JavaScript
// 商店弹窗业务模块,负责商品加载、购买、赠礼、改名卡和商店按钮事件。
|
||
|
||
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();
|
||
}
|
||
});
|
||
}
|