import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useAppScope } from "@/app/app-scope/AppScopeProvider.jsx"; import { getVipConfig, getVipProgram, updateVipConfig, updateVipProgram } from "@/features/vip-config/api"; import { p1BenefitCatalog, vipProgramDefaults } from "@/features/vip-config/constants.js"; import { normalizeVipBenefitMetadata } from "@/features/vip-config/metadata.js"; import { useVipConfigAbilities } from "@/features/vip-config/permissions.js"; import { vipConfigFormSchema, vipProgramFormSchema } from "@/features/vip-config/schema.js"; import { listResourceGroups, listResources } from "@/features/resources/api"; import { parseForm } from "@/shared/forms/validation"; import { useToast } from "@/shared/ui/ToastProvider.jsx"; const dayMillis = 24 * 60 * 60 * 1000; export function useVipConfigPage() { const abilities = useVipConfigAbilities(); const { appCode } = useAppScope(); const { showToast } = useToast(); const initialProgram = defaultProgram(appCode); const [program, setProgram] = useState(initialProgram); const [config, setConfig] = useState({ levels: defaultLevels(initialProgram) }); const [programForm, setProgramForm] = useState(programToForm(initialProgram)); const [form, setForm] = useState({ levels: defaultLevels(initialProgram).map((level) => levelToForm(level, initialProgram)), }); const [resourceGroups, setResourceGroups] = useState([]); const [resources, setResources] = useState([]); const [resourcesLoading, setResourcesLoading] = useState(false); const [editorMode, setEditorMode] = useState(""); const [error, setError] = useState(null); const [loading, setLoading] = useState(false); const [saving, setSaving] = useState(false); const reloadSequence = useRef(0); const resourceLoadSequence = useRef(0); const resourcesApp = useRef(""); const activeCount = useMemo( () => (config.levels || []).filter((level) => level.status === "active").length, [config.levels], ); const reload = useCallback(async () => { const sequence = ++reloadSequence.current; setLoading(true); setError(null); try { const [remoteProgram, remoteConfig, groups] = await Promise.all([ getVipProgram(), getVipConfig(), listResourceGroups({ page: 1, page_size: 100, status: "active" }), ]); if (sequence !== reloadSequence.current) { return; } const nextProgram = normalizeProgram(remoteProgram, appCode); const levels = completeVipLevels(remoteConfig.levels || [], nextProgram); setProgram(nextProgram); setProgramForm(programToForm(nextProgram)); setConfig({ ...remoteConfig, levels }); setForm({ levels: levels.map((level) => levelToForm(level, nextProgram)) }); setResourceGroups(groups.items || []); } catch (err) { if (sequence !== reloadSequence.current) { return; } setError(err); showToast(err.message || "加载 VIP 配置失败", "error"); } finally { if (sequence === reloadSequence.current) { setLoading(false); } } }, [appCode, showToast]); const loadResources = useCallback(async () => { if (resourcesApp.current === appCode) { return; } const sequence = ++resourceLoadSequence.current; setResourcesLoading(true); try { const resourceItems = await listAllVipResources(); if (sequence !== resourceLoadSequence.current) { return; } setResources(resourceItems.filter((resource) => resource.resourceType !== "vip_trial_card")); resourcesApp.current = appCode; } catch (err) { if (sequence === resourceLoadSequence.current) { // 资源目录只影响装扮权益绑定;读取失败不关闭等级编辑,运营可退出后重进并重试。 setResources([]); showToast(err.message || "加载权益资源失败", "error"); } } finally { if (sequence === resourceLoadSequence.current) { setResourcesLoading(false); } } }, [appCode, showToast]); useEffect(() => { // X-App-Code 是隔离边界;切换 App 时必须丢弃上一 App 的编辑态并重新读取完整配置,避免跨 App 误保存。 const fallback = defaultProgram(appCode); setProgram(fallback); setProgramForm(programToForm(fallback)); setConfig({ levels: defaultLevels(fallback) }); setForm({ levels: defaultLevels(fallback).map((level) => levelToForm(level, fallback)) }); setResources([]); setResourcesLoading(false); resourcesApp.current = ""; resourceLoadSequence.current += 1; setEditorMode(""); void reload(); }, [appCode, reload]); const openProgramEditor = () => { setProgramForm(programToForm(program)); setEditorMode("program"); }; const openLevelsEditor = () => { setForm({ levels: completeVipLevels(config.levels || [], program).map((level) => levelToForm(level, program)), }); setEditorMode("levels"); // 总览不依赖几百页资源目录;仅进入权益编辑时异步加载,避免阻塞 VIP 表格首屏。 void loadResources(); }; const closeEditor = () => { if (saving) { return; } setProgramForm(programToForm(program)); setForm({ levels: completeVipLevels(config.levels || [], program).map((level) => levelToForm(level, program)), }); setEditorMode(""); }; const updateProgramField = (patch) => { setProgramForm((current) => { const next = { ...current, ...patch }; if (patch.programType && patch.programType !== current.programType) { // 体系决定产品已确认的等级数量;不允许切换体系后遗留另一体系的 9/10 级配置。 next.levelCount = String(vipProgramDefaults[patch.programType].levelCount); } return next; }); }; const updateLevel = (levelNumber, patch) => { setForm((current) => ({ ...current, levels: (current.levels || []).map((item) => (item.level === levelNumber ? { ...item, ...patch } : item)), })); }; const updateBenefit = (levelNumber, benefitCode, patch) => { setForm((current) => ({ ...current, levels: (current.levels || []).map((level) => level.level !== levelNumber ? level : { ...level, benefits: (level.benefits || []).map((benefit) => benefit.benefitCode === benefitCode ? { ...benefit, ...patch, // 目录占位项只有在运营实际修改后才转成显式等级配置,避免打开并保存就补齐整张表。 catalogPlaceholder: false, } : benefit, ), }, ), })); }; const updateAllBenefits = (levelNumber, status) => { setForm((current) => ({ ...current, levels: (current.levels || []).map((level) => level.level !== levelNumber ? level : { ...level, // “全选”只改变当前等级已有权益行,不按目录补齐缺项,保持 target_only 的最终快照语义。 benefits: (level.benefits || []).map((benefit) => ({ ...benefit, status, // 全选开启属于明确配置;取消全选时仍不把从未配置过的目录占位项写入数据库。 catalogPlaceholder: status === "active" ? false : benefit.catalogPlaceholder, })), }, ), })); }; const submitProgram = async (event) => { event.preventDefault(); if (!abilities.canUpdate) { return; } let payload; try { payload = programPayloadFromForm(programForm); } catch (err) { showToast(err.message || "VIP 体系参数不正确", "error"); return; } setSaving(true); try { const saved = normalizeProgram(await updateVipProgram(payload), appCode); setProgram(saved); setProgramForm(programToForm(saved)); setEditorMode(""); showToast("VIP 体系已保存", "success"); await reload(); } catch (err) { showToast(err.message || "保存 VIP 体系失败", "error"); } finally { setSaving(false); } }; const submitLevels = async (event) => { event.preventDefault(); if (!abilities.canUpdate) { return; } let payload; try { payload = vipConfigPayloadFromForm(form, program); } catch (err) { showToast(err.message || "VIP 等级参数不正确", "error"); return; } setSaving(true); try { const saved = await updateVipConfig(payload); const levels = completeVipLevels(saved.levels || [], program); setConfig({ ...saved, levels }); setForm({ levels: levels.map((level) => levelToForm(level, program)) }); setEditorMode(""); showToast("VIP 等级与权益已保存", "success"); } catch (err) { showToast(err.message || "保存 VIP 等级失败", "error"); } finally { setSaving(false); } }; return { abilities, activeCount, appCode, closeEditor, config, editorMode, error, form, loading, openLevelsEditor, openProgramEditor, program, programForm, reload, resourceGroups, resources, resourcesLoading, saving, submitLevels, submitProgram, updateAllBenefits, updateBenefit, updateLevel, updateProgramField, }; } async function listAllVipResources() { const items = []; const pageSize = 100; for (let page = 1; page <= 200; page += 1) { const result = await listResources({ page, page_size: pageSize, status: "active" }); items.push(...(result.items || [])); if (items.length >= Number(result.total || 0) || (result.items || []).length < pageSize) { return items; } } throw new Error("权益资源数量超过后台可加载上限"); } function defaultProgram(appCode) { const programType = String(appCode || "").toLowerCase() === "fami" ? "tiered_privilege_v1" : "legacy_timed"; const defaults = vipProgramDefaults[programType]; return { appCode: String(appCode || "").toLowerCase(), programType, levelCount: defaults.levelCount, status: "active", configVersion: 0, sameLevelExpiryPolicy: defaults.sameLevelExpiryPolicy, upgradeExpiryPolicy: defaults.upgradeExpiryPolicy, downgradePurchasePolicy: defaults.downgradePurchasePolicy, benefitInheritancePolicy: defaults.benefitInheritancePolicy, grantMode: defaults.grantMode, trialCardEnabled: defaults.trialCardEnabled, }; } function normalizeProgram(program, appCode) { const fallback = defaultProgram(appCode); const programType = program?.programType === "tiered_privilege_v1" ? "tiered_privilege_v1" : "legacy_timed"; const defaults = vipProgramDefaults[programType]; const levelCount = Number(program?.levelCount || defaults.levelCount); return { ...fallback, ...program, appCode: program?.appCode || fallback.appCode, programType, levelCount: Number.isInteger(levelCount) && levelCount > 0 ? levelCount : defaults.levelCount, status: program?.status === "disabled" ? "disabled" : "active", sameLevelExpiryPolicy: program?.sameLevelExpiryPolicy || defaults.sameLevelExpiryPolicy, upgradeExpiryPolicy: program?.upgradeExpiryPolicy || defaults.upgradeExpiryPolicy, downgradePurchasePolicy: program?.downgradePurchasePolicy || defaults.downgradePurchasePolicy, benefitInheritancePolicy: program?.benefitInheritancePolicy || defaults.benefitInheritancePolicy, grantMode: program?.grantMode || defaults.grantMode, trialCardEnabled: typeof program?.trialCardEnabled === "boolean" ? program.trialCardEnabled : defaults.trialCardEnabled, }; } function programToForm(program) { return { enabled: program.status !== "disabled", levelCount: String(program.levelCount || vipProgramDefaults[program.programType].levelCount), programType: program.programType, }; } function programPayloadFromForm(form) { const parsed = parseForm(vipProgramFormSchema, form); const defaults = vipProgramDefaults[parsed.programType]; const levelCount = Number(parsed.levelCount || 0); if (!Number.isInteger(levelCount) || levelCount <= 0 || levelCount > 20) { throw new Error("VIP 等级数量必须为 1 至 20"); } return { programType: parsed.programType, levelCount, sameLevelExpiryPolicy: defaults.sameLevelExpiryPolicy, upgradeExpiryPolicy: defaults.upgradeExpiryPolicy, downgradePurchasePolicy: defaults.downgradePurchasePolicy, benefitInheritancePolicy: defaults.benefitInheritancePolicy, grantMode: defaults.grantMode, trialCardEnabled: defaults.trialCardEnabled, status: parsed.enabled ? "active" : "disabled", }; } function defaultLevels(program) { return Array.from({ length: program.levelCount }, (_, index) => defaultLevel(index + 1)); } function defaultLevel(level) { return { level, name: `VIP${level}`, status: "disabled", priceCoin: 0, durationMs: 30 * dayMillis, rewardResourceGroupId: 0, requiredRechargeCoinAmount: 0, rechargeGateRequired: false, sortOrder: level * 10, // target_only 下新等级从空权益快照开始;后台不能依据历史 unlockLevel 猜测并开启权益。 benefits: [], }; } export function completeVipLevels(levels, program) { const byLevel = new Map((levels || []).map((level) => [Number(level.level), level])); return defaultLevels(program).map((fallback) => { const existing = byLevel.get(fallback.level); if (!existing) { return fallback; } const merged = { ...fallback, ...existing }; merged.benefits = program.programType === "tiered_privilege_v1" ? explicitP1Benefits(existing.benefits || []) : existing.benefits || []; return merged; }); } function explicitP1Benefits(benefits) { // 每级权益是最终快照:只保留该等级真实返回的行,缺项既不按 unlockLevel 补齐,也不从体系目录继承。 return (benefits || []).map((benefit) => benefit.benefitCode === "daily_coin_rebate" ? { ...benefit, trialEnabled: false } : benefit, ); } export function levelToForm(level, program) { return { level: Number(level.level || 0), name: level.name || `VIP${level.level}`, status: level.status || "disabled", priceCoin: level.priceCoin ? String(level.priceCoin) : "", durationDays: level.durationMs ? String(Math.max(1, Math.round(Number(level.durationMs) / dayMillis))) : "30", rewardResourceGroupId: level.rewardResourceGroupId ? String(level.rewardResourceGroupId) : "", sortOrder: String(level.sortOrder || Number(level.level || 0) * 10), benefits: benefitFormsForLevel(level, program), }; } function benefitFormsForLevel(level, program) { const explicitBenefits = level.benefits || []; if (program?.programType !== "tiered_privilege_v1") { return explicitBenefits.map((benefit, index) => benefitToForm(benefit, level.level, index, false)); } const catalog = completeP1BenefitCatalog(program?.benefits || []); const explicitByCode = new Map(explicitBenefits.map((benefit) => [benefit.benefitCode, benefit])); const catalogCodes = new Set(catalog.map((benefit) => benefit.benefitCode)); const rows = catalog.map((template, index) => { const explicit = explicitByCode.get(template.benefitCode); return benefitToForm(explicit || template, level.level, index, !explicit); }); // 服务端可能先上线新权益而当前前端目录尚未更新;显式行必须保留,不能因目录版本差异在保存时被删除。 explicitBenefits.forEach((benefit, index) => { if (!catalogCodes.has(benefit.benefitCode)) { rows.push(benefitToForm(benefit, level.level, catalog.length + index, false)); } }); return rows; } function completeP1BenefitCatalog(remoteCatalog) { const remoteByCode = new Map(remoteCatalog.map((benefit) => [benefit.benefitCode, benefit])); const builtInCodes = new Set(p1BenefitCatalog.map((benefit) => benefit.benefitCode)); const catalog = p1BenefitCatalog.map((template) => ({ ...template, // 服务端最高等级行含最新名称、类型与执行域;内置目录只负责保证被删除的已知权益仍可重新配置。 ...(remoteByCode.get(template.benefitCode) || {}), })); remoteCatalog.forEach((benefit) => { if (!builtInCodes.has(benefit.benefitCode)) { catalog.push(benefit); } }); return catalog; } function benefitToForm(benefit, level, index, catalogPlaceholder) { const benefitCode = benefit.benefitCode || ""; return { benefitCode, name: benefit.name || benefitCode, benefitType: benefit.benefitType === "decoration" ? "decoration" : "function", unlockLevel: Number(benefit.unlockLevel || level || 1), status: catalogPlaceholder ? "disabled" : benefit.status === "active" ? "active" : "disabled", trialEnabled: benefitCode === "daily_coin_rebate" ? false : Boolean(benefit.trialEnabled), resourceId: catalogPlaceholder ? "" : benefit.resourceId ? String(benefit.resourceId) : "", resourceType: catalogPlaceholder ? "" : benefit.resourceType || "", executionScope: ["wallet", "room", "user", "notice"].includes(benefit.executionScope) ? benefit.executionScope : "wallet", autoEquip: Boolean(benefit.autoEquip), sortOrder: String(benefit.sortOrder || (index + 1) * 10), // 高等级目录只提供字段模板;等级未配置的参数必须从空对象开始,不能复制高等级返现金额、文案或资源。 metadataJson: catalogPlaceholder ? "{}" : benefit.metadataJson || "{}", catalogPlaceholder, }; } export function vipConfigPayloadFromForm(form, program) { const parsed = parseForm(vipConfigFormSchema, form); if (parsed.levels.length !== program.levelCount) { throw new Error(`当前体系必须完整配置 VIP1 至 VIP${program.levelCount}`); } const levels = parsed.levels.map((level) => levelPayload(level, program)); return { levels }; } function levelPayload(level, program) { const priceCoin = Number(level.priceCoin || 0); const durationDays = Number(level.durationDays || 0); const rewardResourceGroupId = Number(level.rewardResourceGroupId || 0); const sortOrder = Number(level.sortOrder || level.level * 10); const status = level.status === "active" ? "active" : "disabled"; if (!Number.isInteger(priceCoin) || priceCoin < 0) { throw new Error(`VIP${level.level} 购买金币数不正确`); } if (!Number.isInteger(durationDays) || durationDays <= 0) { throw new Error(`VIP${level.level} 有效天数必须大于 0`); } if (!Number.isInteger(sortOrder) || sortOrder < 0) { throw new Error(`VIP${level.level} 排序不能小于 0`); } if (status === "active" && priceCoin <= 0) { throw new Error(`VIP${level.level} 启用时购买金币数必须大于 0`); } if (!Number.isInteger(rewardResourceGroupId) || rewardResourceGroupId < 0) { throw new Error(`VIP${level.level} 奖励资源组不正确`); } // Fami 的等级礼包是可选表现资源;经典 Lalu VIP 仍沿用启用等级必须配置礼包的既有约束。 if (status === "active" && program.programType !== "tiered_privilege_v1" && rewardResourceGroupId <= 0) { throw new Error(`VIP${level.level} 启用时必须选择奖励资源组`); } const benefits = (level.benefits || []) // 未触碰的目录占位项只服务复选 UI;显式禁用行和运营修改后的占位项仍需提交,保持最终快照可审计。 .filter((benefit) => !benefit.catalogPlaceholder || benefit.status === "active") .map((benefit) => benefitPayload(benefit, level.level)); if (program.programType === "tiered_privilege_v1") { const benefitCodes = new Set(benefits.map((benefit) => benefit.benefitCode)); if (benefitCodes.size !== benefits.length) { throw new Error(`VIP${level.level} 权益编码不能重复`); } } return { level: Number(level.level), name: String(level.name || "").trim(), status, priceCoin, durationMs: durationDays * dayMillis, rewardResourceGroupId, requiredRechargeCoinAmount: 0, sortOrder, benefits, }; } function benefitPayload(benefit, level) { const rawMetadataJson = String(benefit.metadataJson || "{}").trim() || "{}"; const sortOrder = Number(benefit.sortOrder || 0); const resourceId = Number(benefit.resourceId || 0); if (!Number.isInteger(sortOrder) || sortOrder < 0) { throw new Error(`VIP${level} 的 ${benefit.name || benefit.benefitCode} 排序不能小于 0`); } try { const parsedConfig = JSON.parse(rawMetadataJson); if (!parsedConfig || Array.isArray(parsedConfig) || typeof parsedConfig !== "object") { throw new Error("config must be an object"); } } catch { throw new Error(`VIP${level} 的 ${benefit.name || benefit.benefitCode} 元数据必须是 JSON 对象`); } if (!Number.isInteger(resourceId) || resourceId < 0) { throw new Error(`VIP${level} 的 ${benefit.name || benefit.benefitCode} 资源不正确`); } const status = benefit.status === "active" ? "active" : "disabled"; const metadataJson = normalizeVipBenefitMetadata({ benefitCode: benefit.benefitCode, label: `VIP${level} 的 ${benefit.name || benefit.benefitCode}`, metadataJson: rawMetadataJson, status, }); return { benefitCode: String(benefit.benefitCode || "").trim(), name: String(benefit.name || "").trim(), benefitType: benefit.benefitType === "decoration" ? "decoration" : "function", unlockLevel: Number(benefit.unlockLevel || level), status, trialEnabled: benefit.benefitCode === "daily_coin_rebate" ? false : Boolean(benefit.trialEnabled), resourceId, resourceType: resourceId > 0 ? String(benefit.resourceType || "").trim() : "", executionScope: benefit.executionScope, autoEquip: Boolean(benefit.autoEquip), sortOrder, metadataJson, }; }