优化商店个性装扮体验
This commit is contained in:
@@ -47,9 +47,34 @@ const SHOP_GROUPS = [
|
||||
},
|
||||
];
|
||||
|
||||
const DECORATION_GROUPS = [
|
||||
{
|
||||
label: "💬 消息气泡",
|
||||
desc: "同类型只保留最新购买",
|
||||
type: "msg_bubble",
|
||||
},
|
||||
{
|
||||
label: "🎨 昵称颜色",
|
||||
desc: "同类型只保留最新购买",
|
||||
type: "msg_name_color",
|
||||
},
|
||||
{
|
||||
label: "🖼️ 头像框",
|
||||
desc: "同类型只保留最新购买",
|
||||
type: "avatar_frame",
|
||||
},
|
||||
];
|
||||
|
||||
const DECORATION_TYPE_TO_SLOT = {
|
||||
msg_bubble: "bubble",
|
||||
msg_name_color: "name_color",
|
||||
avatar_frame: "avatar_frame",
|
||||
};
|
||||
|
||||
let shopControlEventsBound = false;
|
||||
let shopLoaded = false;
|
||||
let giftItem = null;
|
||||
let activeDecorations = {};
|
||||
|
||||
/**
|
||||
* 读取商店根节点上由 Blade 注入的接口地址。
|
||||
@@ -106,6 +131,7 @@ export function openShopModal() {
|
||||
}
|
||||
|
||||
modal.style.display = "flex";
|
||||
bindShopTabs();
|
||||
if (!shopLoaded) {
|
||||
shopLoaded = true;
|
||||
fetchShopData();
|
||||
@@ -124,6 +150,45 @@ export function closeShopModal() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 绑定商店 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";
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 拉取商品数据并渲染列表。
|
||||
*
|
||||
@@ -136,6 +201,7 @@ export async function fetchShopData() {
|
||||
});
|
||||
const data = await response.json();
|
||||
renderShop(data);
|
||||
renderDecorations(data);
|
||||
} catch (error) {
|
||||
showShopToast("⚠ 加载失败,请重试", false);
|
||||
}
|
||||
@@ -245,6 +311,90 @@ export function renderShop(data) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染个人装扮商品列表。
|
||||
*
|
||||
* @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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建单个商品卡片,并挂载购买或使用按钮事件。
|
||||
*
|
||||
@@ -302,9 +452,7 @@ function buildShopCardHtml(item, options) {
|
||||
<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 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>` : ""}
|
||||
@@ -319,10 +467,52 @@ function buildShopCardHtml(item, options) {
|
||||
</div>
|
||||
<div class="shop-card-desc">${escapeHtml(item.description ?? "")}</div>
|
||||
${ringBonus}
|
||||
${durationLabel}
|
||||
${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>` : "";
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化分钟数,供自动钓鱼卡有效期展示。
|
||||
*
|
||||
@@ -351,7 +541,11 @@ async function confirmAndBuyItem(item) {
|
||||
}
|
||||
}
|
||||
|
||||
const confirmMessage = `确认花费 💰 ${Number(Number(item.price || 0) * quantity).toLocaleString()} 金币购买\n【${item.name}】${quantity > 1 ? ` × ${quantity}` : ""} 吗?`;
|
||||
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) {
|
||||
@@ -401,7 +595,8 @@ export function openGiftDialog(item) {
|
||||
}
|
||||
|
||||
if (itemName) {
|
||||
itemName.textContent = `${item.icon} ${item.name}(💰 ${Number(item.price || 0).toLocaleString()})`;
|
||||
const validityText = buildValidityText(item);
|
||||
itemName.textContent = `${item.icon} ${item.name}(💰 ${Number(item.price || 0).toLocaleString()}${validityText ? ` · ${validityText}` : ""})`;
|
||||
}
|
||||
|
||||
if (dialog) {
|
||||
@@ -499,6 +694,14 @@ function handleBuySuccess(data, itemName) {
|
||||
|
||||
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);
|
||||
@@ -625,6 +828,7 @@ function exposeShopGlobals() {
|
||||
window.loadShop = loadShop;
|
||||
window.fetchShopData = fetchShopData;
|
||||
window.renderShop = renderShop;
|
||||
window.renderDecorations = renderDecorations;
|
||||
window.openGiftDialog = openGiftDialog;
|
||||
window.closeGiftDialog = closeGiftDialog;
|
||||
window.confirmGift = confirmGift;
|
||||
|
||||
Reference in New Issue
Block a user