Files
chatroom/resources/js/chat-room/vip-controls.js
T
2026-04-25 14:40:54 +08:00

478 lines
16 KiB
JavaScript

// 聊天室 VIP 中心模块,负责弹窗开关、会员数据渲染、支付跳转和专属进退场设置。
import { escapeHtml } from "./html.js";
const VIP_CENTER_URL = "/vip-center";
const VIP_PAYMENT_URL = "/vip/payment";
const VIP_PRESENCE_UPDATE_URL = "/vip-center/presence-settings";
let vipControlEventsBound = false;
let vipData = null;
/**
* 读取 CSRF Token。
*
* @returns {string}
*/
function csrf() {
return document.querySelector('meta[name="csrf-token"]')?.content || "";
}
/**
* 按 ID 获取 DOM 节点。
*
* @param {string} id
* @returns {HTMLElement|null}
*/
function element(id) {
return document.getElementById(id);
}
/**
* 显示全局弹窗提示。
*
* @param {string} message
* @param {string} title
* @param {string} color
* @returns {void}
*/
function showDialog(message, title = "提示", color = "#3b82f6") {
window.chatDialog?.alert?.(message, title, color);
}
/**
* 格式化金额。
*
* @param {unknown} value
* @returns {string}
*/
function formatMoney(value) {
return Number(value || 0).toFixed(2);
}
/**
* 过滤可写入 style 的颜色值。
*
* @param {unknown} value
* @param {string} fallback
* @returns {string}
*/
function sanitizeColor(value, fallback = "#1e293b") {
const color = String(value || "").trim();
if (/^#[0-9a-fA-F]{3}([0-9a-fA-F]{3})?$/.test(color) || /^rgba?\([\d\s,.%]+\)$/.test(color)) {
return color;
}
return fallback;
}
/**
* 生成会员等级按钮 HTML。
*
* @param {object} level
* @param {object} data
* @returns {string}
*/
function renderVipAction(level, data) {
const isCurrent = Boolean(level.is_current);
const isHigher = Boolean(level.is_higher);
const isLower = Boolean(level.is_lower);
let buttonText = "立即购买";
let buttonColor = "#1e293b";
let buttonTextColor = "#fff";
let priceToDisplay = level.price;
let isDisabled = !data.vipPaymentEnabled;
let showUpgradeInfo = false;
if (isCurrent) {
buttonText = "立即续费";
buttonColor = "#f59e0b";
} else if (isHigher && data.user.is_vip) {
buttonText = "补差价升级";
buttonColor = "#4f46e5";
priceToDisplay = level.upgrade_price;
showUpgradeInfo = true;
} else if (isLower) {
buttonText = "无法降级";
buttonColor = "#f1f5f9";
buttonTextColor = "#94a3b8";
isDisabled = true;
}
const upgradeInfo = showUpgradeInfo
? `<div style="font-size:10px; color:#4f46e5; font-weight:bold; margin-bottom:8px;">已省 ¥${formatMoney(Number(level.price || 0) - Number(level.upgrade_price || 0))}</div>`
: "";
const actionHtml = !data.vipPaymentEnabled || isDisabled
? `<button disabled style="width:100%; border:none; border-radius:8px; padding:10px; font-size:13px; font-weight:bold; cursor:not-allowed; transition:all .2s; background:${buttonColor}; color:${buttonTextColor};">${!data.vipPaymentEnabled && !isLower ? "支付暂未开启" : buttonText}</button>`
: `<div style="display:grid; grid-template-columns:1fr 1fr; gap:8px;">
<button data-vip-buy-level="${Number(level.id)}" data-vip-buy-provider="alipay"
style="border:none; border-radius:8px; padding:10px; font-size:13px; font-weight:bold; cursor:pointer; transition:all .2s; background:${buttonColor}; color:${buttonTextColor};">
支付宝
</button>
<button data-vip-buy-level="${Number(level.id)}" data-vip-buy-provider="wechat"
style="border:none; border-radius:8px; padding:10px; font-size:13px; font-weight:bold; cursor:pointer; transition:all .2s; background:#16a34a; color:#fff;">
微信
</button>
</div>
<div style="font-size:10px; color:#64748b; margin-top:8px; text-align:center;">${buttonText}后将跳转到对应支付页面</div>`;
return `
<div style="font-size:18px; font-weight:900; color:#e11d48; margin-bottom:5px;">
¥${formatMoney(priceToDisplay)}
<span style="font-size:11px; font-weight:normal; color:#94a3b8;">/ ${Number(level.duration_days || 0)}天</span>
</div>
${upgradeInfo}
${actionHtml}
`;
}
/**
* 渲染会员等级卡片。
*
* @param {object} level
* @param {object} data
* @returns {string}
*/
function renderVipLevelCard(level, data) {
const isCurrent = Boolean(level.is_current);
const color = sanitizeColor(level.color);
return `
<div class="vip-level-card ${isCurrent ? "current" : ""}">
${isCurrent ? '<span class="vip-level-badge">当前档位</span>' : ""}
<div style="display:flex; align-items:center; gap:10px;">
<span style="font-size:24px;">${escapeHtml(level.icon || "✨")}</span>
<span style="font-size:16px; font-weight:bold; color:${color}">${escapeHtml(level.name || "")}</span>
</div>
<div style="font-size:11px; color:#64748b; line-height:1.5; min-height:33px;">${escapeHtml(level.description || "")}</div>
<div style="margin-top:5px; space-y:6px;">
<div class="vip-feature-item"><span class="vip-feature-icon">✓</span> 经验获取 <b>${Number(level.exp_multiplier || 1)}x</b></div>
<div class="vip-feature-item"><span class="vip-feature-icon">✓</span> 金币获取 <b>${Number(level.jjb_multiplier || 1)}x</b></div>
<div class="vip-feature-item"><span class="vip-feature-icon">✓</span> 专属入场特效 & 横幅</div>
</div>
<div style="margin-top:auto; padding-top:10px;">
${renderVipAction(level, data)}
</div>
</div>
`;
}
/**
* 渲染 VIP 购买记录。
*
* @param {Array<object>} logs
* @returns {void}
*/
function renderVipLogs(logs) {
const logsBody = element("vip-logs-body");
if (!logsBody) {
return;
}
if (!logs.length) {
logsBody.innerHTML = '<tr><td colspan="5" style="padding:40px; text-align:center; color:#94a3b8;">暂无记录</td></tr>';
return;
}
const statusMap = {
created: { text: "待创建", className: "background:#f1f5f9;color:#475569" },
pending: { text: "待支付", className: "background:#fef3c7;color:#b45309" },
paid: { text: "已支付", className: "background:#dcfce7;color:#166534" },
closed: { text: "已关闭", className: "background:#f1f5f9;color:#64748b" },
failed: { text: "失败", className: "background:#fee2e2;color:#991b1b" },
};
logsBody.innerHTML = logs.map((log) => {
const status = statusMap[log.status] || { text: escapeHtml(log.status || "未知"), className: "background:#f1f5f9" };
const openedAt = log.opened_vip_at ? String(log.opened_vip_at).substring(0, 16).replace("T", " ") : "—";
return `
<tr style="border-bottom:1px solid #f1f5f9;">
<td style="padding:10px; font-family:monospace; color:#64748b;">${escapeHtml(log.order_no || "")}</td>
<td style="padding:10px; font-weight:bold;">${escapeHtml(log.vip_name || "")}</td>
<td style="padding:10px; font-weight:bold; color:#e11d48;">¥${formatMoney(log.amount)}</td>
<td style="padding:10px;"><span style="display:inline-block; padding:2px 8px; border-radius:10px; font-size:10px; font-weight:bold; ${status.className}">${status.text}</span></td>
<td style="padding:10px; color:#64748b;">${escapeHtml(openedAt)}</td>
</tr>
`;
}).join("");
}
/**
* 渲染 VIP 个性化设置。
*
* @returns {void}
*/
function renderVipSettings() {
const data = vipData;
const user = data?.user;
const level = user?.vip_level;
if (!data || !user || !level) {
return;
}
element("vip-theme-icon").textContent = level.icon || "✨";
element("vip-theme-name").textContent = level.name || "";
element("vip-join-effect").textContent = data.effectOptions[user.custom_join_effect || level.join_effect] || "无";
element("vip-join-banner").textContent = `风格:${data.bannerStyleOptions[level.join_banner] || "默认"}`;
element("vip-leave-effect").textContent = data.effectOptions[user.custom_leave_effect || level.leave_effect] || "无";
element("vip-leave-banner").textContent = `风格:${data.bannerStyleOptions[level.leave_banner] || "默认"}`;
element("vip-default-join").textContent = level.join_templates?.[0] || "当前档位尚未配置默认欢迎语。";
element("vip-default-leave").textContent = level.leave_templates?.[0] || "当前档位尚未配置默认离开语。";
const badge = element("vip-customize-badge");
const form = element("vip-customize-form");
const notice = element("vip-customize-notice");
if (user.can_customize) {
badge.textContent = "已开启";
badge.style.background = "#dcfce7";
badge.style.color = "#166534";
form.style.display = "block";
notice.style.display = "none";
const joinSelect = element("vip-custom-join-effect");
const leaveSelect = element("vip-custom-leave-effect");
const optionsHtml = Object.entries(data.effectOptions || {})
.map(([key, label]) => `<option value="${escapeHtml(key)}">${escapeHtml(label)}</option>`)
.join("");
joinSelect.innerHTML = optionsHtml;
leaveSelect.innerHTML = optionsHtml;
joinSelect.value = user.custom_join_effect || level.join_effect || "none";
leaveSelect.value = user.custom_leave_effect || level.leave_effect || "none";
element("vip-custom-join").value = user.custom_join_message || "";
element("vip-custom-leave").value = user.custom_leave_message || "";
return;
}
badge.textContent = "未开放";
badge.style.background = "#f1f5f9";
badge.style.color = "#64748b";
form.style.display = "none";
notice.style.display = "block";
notice.textContent = user.is_vip
? "当前会员档位暂未开放个人自定义功能,不过你仍会自动使用本等级配置的专属欢迎语、离开语和华丽特效。"
: "开通会员后,这里会解锁对应等级的专属进退场主题;若等级允许,还能设置你自己的欢迎语和离开语。";
}
/**
* 渲染 VIP 弹窗整体内容。
*
* @returns {void}
*/
function renderVipModal() {
const data = vipData;
if (!data) {
return;
}
element("vip-current-name").textContent = data.user.is_vip ? (data.user.vip_name || "尊贵会员") : "普通用户";
element("vip-current-name").style.color = data.user.vip_level?.color || "#1e293b";
element("vip-expire-time").textContent = data.user.is_vip ? `到期时间:${data.user.hy_time}` : "开通会员享特权";
element("vip-total-amount").textContent = `¥${formatMoney(data.totalAmount)}`;
const grid = element("vip-levels-grid");
grid.innerHTML = (data.vipLevels || []).map((level) => renderVipLevelCard(level, data)).join("");
const settingsButton = element("vip-tabbtn-settings");
if (data.user.is_vip) {
settingsButton.style.display = "block";
renderVipSettings();
} else {
settingsButton.style.display = "none";
}
renderVipLogs(data.paymentLogs || []);
}
/**
* 拉取 VIP 中心数据。
*
* @returns {Promise<void>}
*/
async function fetchVipData() {
try {
const response = await fetch(VIP_CENTER_URL, {
headers: {
Accept: "application/json",
},
});
const json = await response.json();
if (json.status === "success") {
vipData = json.data;
renderVipModal();
}
} catch (error) {
console.error("Failed to fetch VIP data", error);
}
}
/**
* 打开 VIP 中心弹窗。
*
* @returns {void}
*/
export function openVipModal() {
element("vip-modal").style.display = "flex";
void fetchVipData();
}
/**
* 关闭 VIP 中心弹窗。
*
* @returns {void}
*/
export function closeVipModal() {
element("vip-modal").style.display = "none";
}
/**
* 切换 VIP 中心页签。
*
* @param {string} tabName
* @returns {void}
*/
export function switchVipTab(tabName) {
document.querySelectorAll(".vip-tab-btn").forEach((button) => button.classList.remove("active"));
document.querySelectorAll(".vip-view-pane").forEach((pane) => pane.classList.remove("active"));
element(`vip-tabbtn-${tabName}`)?.classList.add("active");
element(`vip-view-${tabName}`)?.classList.add("active");
}
/**
* 创建 VIP 支付表单并打开支付页。
*
* @param {number} levelId
* @param {string} provider
* @returns {void}
*/
export function buyVip(levelId, provider = "alipay") {
const form = document.createElement("form");
form.method = "POST";
form.action = VIP_PAYMENT_URL;
form.target = "_blank";
for (const [name, value] of Object.entries({
_token: csrf(),
vip_level_id: levelId,
provider,
})) {
const input = document.createElement("input");
input.type = "hidden";
input.name = name;
input.value = String(value);
form.appendChild(input);
}
document.body.appendChild(form);
form.submit();
document.body.removeChild(form);
closeVipModal();
const providerText = provider === "wechat" ? "微信支付二维码页" : "支付宝支付页";
showDialog(`正在为您打开${providerText},请在新页面完成支付。`, "支付提示", "#3b82f6");
}
/**
* 保存 VIP 专属进退场设置。
*
* @returns {Promise<void>}
*/
export async function saveVipPresenceSettings() {
try {
const response = await fetch(VIP_PRESENCE_UPDATE_URL, {
method: "PUT",
headers: {
"Content-Type": "application/json",
"Accept": "application/json",
"X-CSRF-TOKEN": csrf(),
},
body: JSON.stringify({
custom_join_message: element("vip-custom-join").value,
custom_leave_message: element("vip-custom-leave").value,
custom_join_effect: element("vip-custom-join-effect").value,
custom_leave_effect: element("vip-custom-leave-effect").value,
}),
});
const json = await response.json();
if (json.status === "success") {
showDialog(json.message, "提示", "#10b981");
await fetchVipData();
return;
}
showDialog(json.message || "保存失败", "提示", "#f59e0b");
} catch (error) {
showDialog("网络异常,请重试", "错误", "#ef4444");
}
}
/**
* 绑定 VIP 中心所有按钮事件,并挂载兼容全局函数。
*
* @returns {void}
*/
export function bindVipControls() {
if (typeof window === "undefined") {
return;
}
window.openVipModal = openVipModal;
window.closeVipModal = closeVipModal;
window.switchVipTab = switchVipTab;
window.buyVip = buyVip;
window.saveVipPresenceSettings = saveVipPresenceSettings;
if (vipControlEventsBound || typeof document === "undefined") {
return;
}
vipControlEventsBound = true;
document.addEventListener("click", (event) => {
if (!(event.target instanceof Element)) {
return;
}
const tabButton = event.target.closest("[data-vip-tab]");
if (tabButton) {
event.preventDefault();
switchVipTab(tabButton.getAttribute("data-vip-tab") || "");
return;
}
if (event.target.closest("[data-vip-modal-close]")) {
event.preventDefault();
closeVipModal();
return;
}
const buyButton = event.target.closest("[data-vip-buy-level]");
if (buyButton) {
event.preventDefault();
const levelId = Number.parseInt(buyButton.getAttribute("data-vip-buy-level") || "", 10);
const provider = buyButton.getAttribute("data-vip-buy-provider") || "alipay";
if (Number.isInteger(levelId)) {
buyVip(levelId, provider);
}
return;
}
if (event.target.closest("[data-vip-save-presence]")) {
event.preventDefault();
void saveVipPresenceSettings();
return;
}
const modal = element("vip-modal");
if (modal && event.target === modal) {
closeVipModal();
}
});
}