2026-07-11 02:26:23 +08:00

576 lines
24 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.

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,
};
}