Files
chatroom/resources/js/chat-room/compact-shop-panel.js
T
2026-04-25 18:27:48 +08:00

587 lines
16 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.
// 紧凑商店面板模块,兼容旧右侧嵌入式 shop-panel 视图。
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 COMPACT_SHOP_GROUPS = [
{
label: "⚡ 单次特效卡",
desc: "立即播放一次",
type: "instant",
},
{
label: "📅 周卡・7天登录自动播放",
desc: "同时只能激活一种,购新旧失效无退款",
type: "duration",
},
{
label: "💍 求婚戒指",
desc: "购买后存入背包,求婚时消耗(若被拒绝则遗失)",
type: "ring",
},
{
label: "🎭 道具",
desc: "",
type: "tools",
},
];
let compactShopLoaded = false;
let compactShopEventsBound = false;
/**
* 判断当前页面是否存在旧紧凑商店面板。
*
* @returns {boolean}
*/
function hasCompactShopPanel() {
return Boolean(document.getElementById("shop-panel"));
}
/**
* 读取紧凑商店面板接口地址,优先使用 Blade 写入的命名路由。
*
* @returns {{items: string, buy: string, rename: string}}
*/
function compactShopUrls() {
const panel = document.getElementById("shop-panel");
return {
items: panel?.dataset.shopItemsUrl || DEFAULT_SHOP_ITEMS_URL,
buy: panel?.dataset.shopBuyUrl || DEFAULT_SHOP_BUY_URL,
rename: panel?.dataset.shopRenameUrl || DEFAULT_SHOP_RENAME_URL,
};
}
/**
* 读取 CSRF Token。
*
* @returns {string}
*/
function csrfToken() {
return document.querySelector('meta[name="csrf-token"]')?.content ?? "";
}
/**
* 生成商店接口请求头。
*
* @param {boolean} withJson 是否声明 JSON 请求体
* @returns {Record<string, string>}
*/
function shopHeaders(withJson = false) {
const headers = {
Accept: "application/json",
"X-CSRF-TOKEN": csrfToken(),
};
if (withJson) {
headers["Content-Type"] = "application/json";
}
return headers;
}
/**
* 打开商店 Tab 时按需加载商品。
*
* @returns {void}
*/
export function loadCompactShop() {
if (!hasCompactShopPanel() || compactShopLoaded) {
return;
}
compactShopLoaded = true;
fetchCompactShopData();
}
/**
* 拉取紧凑商店商品数据。
*
* @returns {Promise<void>}
*/
export async function fetchCompactShopData() {
try {
const response = await fetch(compactShopUrls().items, {
headers: shopHeaders(),
});
const data = await response.json();
renderCompactShop(data);
} catch (error) {
showCompactShopToast("⚠ 加载失败,请刷新重试", false);
}
}
/**
* 渲染紧凑商店商品列表。
*
* @param {Record<string, any>} data 商店接口数据
* @returns {void}
*/
export function renderCompactShop(data) {
const balance = document.getElementById("shop-jjb");
const badge = document.getElementById("shop-week-badge");
const itemsElement = document.getElementById("shop-items-list");
if (!itemsElement) {
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";
}
}
itemsElement.textContent = "";
COMPACT_SHOP_GROUPS.forEach((group) => {
const groupItems = filterCompactShopGroup(group, data.items || []);
if (!groupItems.length) {
return;
}
const section = document.createElement("div");
section.style.marginBottom = "10px";
section.appendChild(createCompactShopGroupLabel(group));
if (group.desc) {
const description = document.createElement("div");
description.className = "shop-group-desc";
description.textContent = group.desc;
section.appendChild(description);
}
groupItems.forEach((item) => {
section.appendChild(createCompactShopCard(item, data));
});
itemsElement.appendChild(section);
});
}
/**
* 筛选分组商品。
*
* @param {Record<string, any>} group 分组配置
* @param {Array<Record<string, any>>} items 商品列表
* @returns {Array<Record<string, any>>}
*/
function filterCompactShopGroup(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>} group 分组配置
* @returns {HTMLDivElement}
*/
function createCompactShopGroupLabel(group) {
const label = document.createElement("div");
label.className = "shop-group-label";
label.textContent = group.label;
return label;
}
/**
* 创建紧凑商品卡片。
*
* @param {Record<string, any>} item 商品数据
* @param {Record<string, any>} data 商店接口数据
* @returns {HTMLDivElement}
*/
function createCompactShopCard(item, data) {
const isRename = item.slug === "rename_card";
const canUseRename = isRename && data.has_rename_card;
const isRing = item.type === "ring";
const ownedQty = isRing ? Number((data.ring_counts || {})[item.id] || 0) : 0;
const card = document.createElement("div");
const row = document.createElement("div");
const button = document.createElement("button");
card.className = "shop-card";
row.className = "shop-card-row";
row.appendChild(createCompactShopIcon(item, isRing, ownedQty));
row.appendChild(createCompactShopName(item));
if (canUseRename) {
button.className = "shop-btn shop-btn-use";
button.textContent = "使用";
button.addEventListener("click", openCompactRenameModal);
} else {
button.className = "shop-btn";
button.textContent = `💰 ${Number(item.price || 0).toLocaleString()}`;
button.addEventListener("click", () => buyCompactShopItem(item.id, item.name, item.price, item.type));
}
row.appendChild(button);
card.appendChild(row);
if (item.description) {
const description = document.createElement("div");
description.className = "shop-card-desc";
description.textContent = item.description;
card.appendChild(description);
}
if (isRing && (Number(item.intimacy_bonus || 0) > 0 || Number(item.charm_bonus || 0) > 0)) {
card.appendChild(createCompactRingBonus(item));
}
return card;
}
/**
* 创建商品图标区域,戒指商品会显示持有数量。
*
* @param {Record<string, any>} item 商品数据
* @param {boolean} isRing 是否戒指商品
* @param {number} ownedQty 已持有数量
* @returns {HTMLSpanElement}
*/
function createCompactShopIcon(item, isRing, ownedQty) {
const wrapper = document.createElement("span");
const icon = document.createElement("span");
wrapper.style.cssText = "position:relative; flex-shrink:0; width:28px; text-align:center;";
icon.className = "shop-card-icon";
icon.textContent = item.icon;
wrapper.appendChild(icon);
if (isRing && ownedQty > 0) {
const badge = document.createElement("span");
badge.style.cssText = "position:absolute; top:-4px; right:-4px; background:#f43f5e; color:#fff; font-size:8px; font-weight:800; min-width:14px; height:14px; border-radius:7px; text-align:center; line-height:14px; padding:0 2px;";
badge.textContent = ownedQty;
wrapper.appendChild(badge);
}
return wrapper;
}
/**
* 创建商品名称节点。
*
* @param {Record<string, any>} item 商品数据
* @returns {HTMLSpanElement}
*/
function createCompactShopName(item) {
const name = document.createElement("span");
name.className = "shop-card-name";
name.title = item.name;
name.textContent = item.name;
return name;
}
/**
* 创建戒指加成信息。
*
* @param {Record<string, any>} item 商品数据
* @returns {HTMLDivElement}
*/
function createCompactRingBonus(item) {
const bonus = document.createElement("div");
bonus.style.cssText = "font-size:9px; color:#f43f5e; margin-top:3px; display:flex; gap:8px;";
if (Number(item.intimacy_bonus || 0) > 0) {
const intimacy = document.createElement("span");
intimacy.textContent = `💞 亲密 +${item.intimacy_bonus}`;
bonus.appendChild(intimacy);
}
if (Number(item.charm_bonus || 0) > 0) {
const charm = document.createElement("span");
charm.style.color = "#a855f7";
charm.textContent = `✨ 魅力 +${item.charm_bonus}`;
bonus.appendChild(charm);
}
return bonus;
}
/**
* 购买紧凑商店商品。
*
* @param {number|string} itemId 商品 ID
* @param {string} name 商品名称
* @param {number|string} price 商品单价
* @param {string} typeOrRecipient 商品类型或接收人
* @param {string} message 赠言
* @param {number|null} presetQuantity 预设数量
* @returns {Promise<void>}
*/
export async function buyCompactShopItem(itemId, name, price, typeOrRecipient = "", message = "", presetQuantity = null) {
const knownTypes = ["instant", "duration", "one_time", "ring", "auto_fishing", "sign_repair"];
const type = knownTypes.includes(typeOrRecipient) ? typeOrRecipient : "";
const recipient = type === "" ? (typeOrRecipient || "all") : "all";
let quantity = Number(presetQuantity || 1);
if (type === "sign_repair") {
quantity = await window.promptSignRepairQuantity?.({
name,
price,
description: "补签卡只能补签本月漏掉的未签到日期,不能补签上月或更早日期。",
});
if (quantity === null || quantity === undefined) {
return;
}
}
if (presetQuantity !== null) {
submitCompactShopPurchase(itemId, recipient, message, quantity);
return;
}
const notice = type === "sign_repair" ? "\n说明:补签卡只能补签本月未签到日期。" : "";
const confirmed = await window.chatDialog.confirm(
`确定花费 ${Number(Number(price || 0) * quantity).toLocaleString()} 金币购买【${name}${quantity > 1 ? ` × ${quantity}` : ""} 吗?${notice}`,
"确认购买",
"#336699",
);
if (confirmed) {
submitCompactShopPurchase(itemId, recipient, message, quantity);
}
}
/**
* 提交紧凑商店购买请求并刷新状态。
*
* @param {number|string} itemId 商品 ID
* @param {string} recipient 接收人
* @param {string} message 赠言
* @param {number} quantity 数量
* @returns {Promise<void>}
*/
async function submitCompactShopPurchase(itemId, recipient, message, quantity) {
try {
const response = await fetch(compactShopUrls().buy, {
method: "POST",
headers: shopHeaders(true),
body: JSON.stringify({
item_id: itemId,
room_id: window.chatContext?.roomId ?? 0,
recipient,
message: message || "",
quantity: quantity || 1,
}),
});
const data = await response.json();
showCompactShopToast(data.message, data.status === "success");
if (data.status === "success") {
handleCompactShopPurchaseSuccess(data);
}
} catch (error) {
showCompactShopToast("⚠ 网络异常,请重试", false);
}
}
/**
* 处理购买成功后的金币、特效和列表刷新。
*
* @param {Record<string, any>} data 接口返回
* @returns {void}
*/
function handleCompactShopPurchaseSuccess(data) {
const balance = document.getElementById("shop-jjb");
if (data.jjb !== undefined && balance) {
balance.textContent = Number(data.jjb).toLocaleString();
}
if (data.play_effect && window.EffectManager) {
window.EffectManager.play(data.play_effect);
}
compactShopLoaded = false;
setTimeout(() => {
fetchCompactShopData();
compactShopLoaded = true;
}, 800);
}
/**
* 展示紧凑商店 Toast。
*
* @param {string} message 提示文案
* @param {boolean} ok 是否成功
* @returns {void}
*/
export function showCompactShopToast(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._compactShopTimer);
element._compactShopTimer = setTimeout(() => {
element.style.display = "none";
}, 3500);
}
/**
* 打开改名卡弹窗。
*
* @returns {void}
*/
export function openCompactRenameModal() {
const modal = document.getElementById("rename-modal");
const input = document.getElementById("rename-input");
const error = document.getElementById("rename-err");
if (modal) {
modal.style.display = "flex";
}
input?.focus();
if (error) {
error.textContent = "";
}
}
/**
* 关闭改名卡弹窗。
*
* @returns {void}
*/
export function closeCompactRenameModal() {
const modal = document.getElementById("rename-modal");
if (modal) {
modal.style.display = "none";
}
}
/**
* 提交改名卡请求。
*
* @returns {Promise<void>}
*/
export async function submitCompactRename() {
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(compactShopUrls().rename, {
method: "POST",
headers: shopHeaders(true),
body: JSON.stringify({
new_name: newName,
}),
});
const data = await response.json();
if (data.status === "success") {
closeCompactRenameModal();
showCompactShopToast(data.message, true);
compactShopLoaded = false;
setTimeout(() => window.location.reload(), 2000);
return;
}
if (error) {
error.textContent = data.message;
}
} catch (requestError) {
if (error) {
error.textContent = "网络异常,请重试";
}
}
}
/**
* 仅当旧紧凑商店面板存在时挂载旧全局函数,避免覆盖当前主商店弹窗。
*
* @returns {void}
*/
function exposeCompactShopGlobals() {
if (!hasCompactShopPanel()) {
return;
}
window.loadShop = loadCompactShop;
window.buyItem = buyCompactShopItem;
window.showShopToast = showCompactShopToast;
window.openRenameModal = openCompactRenameModal;
window.closeRenameModal = closeCompactRenameModal;
window.submitRename = submitCompactRename;
}
/**
* 绑定紧凑商店按钮事件。
*
* @returns {void}
*/
export function bindCompactShopPanelControls() {
if (typeof window === "undefined" || typeof document === "undefined") {
return;
}
exposeCompactShopGlobals();
if (!hasCompactShopPanel() || compactShopEventsBound) {
return;
}
compactShopEventsBound = true;
document.addEventListener("click", (event) => {
if (!(event.target instanceof Element)) {
return;
}
if (event.target.closest("[data-shop-rename-confirm]")) {
event.preventDefault();
submitCompactRename();
return;
}
if (event.target.closest("[data-shop-rename-cancel]")) {
event.preventDefault();
closeCompactRenameModal();
}
});
}