资源相关
This commit is contained in:
parent
dc8b9e488d
commit
169ada93b5
@ -354,6 +354,30 @@
|
|||||||
"x-permissions": ["lucky-gift:view"]
|
"x-permissions": ["lucky-gift:view"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/admin/ops-center/lucky-gifts/config/versions": {
|
||||||
|
"get": {
|
||||||
|
"operationId": "listLuckyGiftConfigVersions",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"$ref": "#/components/responses/EmptyResponse"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"x-permission": "lucky-gift:view",
|
||||||
|
"x-permissions": ["lucky-gift:view"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/admin/ops-center/lucky-gifts/config/rollback": {
|
||||||
|
"post": {
|
||||||
|
"operationId": "rollbackLuckyGiftConfig",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"$ref": "#/components/responses/EmptyResponse"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"x-permission": "lucky-gift:update",
|
||||||
|
"x-permissions": ["lucky-gift:update"]
|
||||||
|
}
|
||||||
|
},
|
||||||
"/admin/ops-center/lucky-gifts/draws": {
|
"/admin/ops-center/lucky-gifts/draws": {
|
||||||
"get": {
|
"get": {
|
||||||
"operationId": "listLuckyGiftDraws",
|
"operationId": "listLuckyGiftDraws",
|
||||||
|
|||||||
@ -15,8 +15,10 @@ import {
|
|||||||
createLuckyGiftExperiment,
|
createLuckyGiftExperiment,
|
||||||
DEFAULT_POOL_ID,
|
DEFAULT_POOL_ID,
|
||||||
endLuckyGiftExperiment,
|
endLuckyGiftExperiment,
|
||||||
|
fetchLuckyGiftConfigVersions,
|
||||||
fetchLuckyGiftExperiments,
|
fetchLuckyGiftExperiments,
|
||||||
fetchOpsDashboard,
|
fetchOpsDashboard,
|
||||||
|
rollbackLuckyGiftConfig,
|
||||||
saveLuckyGiftConfig,
|
saveLuckyGiftConfig,
|
||||||
setLuckyGiftExperimentOverrides,
|
setLuckyGiftExperimentOverrides,
|
||||||
updateLuckyGiftExperiment,
|
updateLuckyGiftExperiment,
|
||||||
@ -27,6 +29,7 @@ import { DrawsView } from "./components/DrawsView.jsx";
|
|||||||
import { LuckyGiftConfigDialog } from "./components/LuckyGiftConfigDialog.jsx";
|
import { LuckyGiftConfigDialog } from "./components/LuckyGiftConfigDialog.jsx";
|
||||||
import { LuckyGiftExperimentManageDialog } from "./components/LuckyGiftExperimentManageDialog.jsx";
|
import { LuckyGiftExperimentManageDialog } from "./components/LuckyGiftExperimentManageDialog.jsx";
|
||||||
import { LuckyGiftPoolAdjustmentDialog } from "./components/LuckyGiftPoolAdjustmentDialog.jsx";
|
import { LuckyGiftPoolAdjustmentDialog } from "./components/LuckyGiftPoolAdjustmentDialog.jsx";
|
||||||
|
import { LuckyGiftRollbackDialog } from "./components/LuckyGiftRollbackDialog.jsx";
|
||||||
import { OpsShell } from "./components/OpsShell.jsx";
|
import { OpsShell } from "./components/OpsShell.jsx";
|
||||||
import { OverviewView } from "./components/OverviewView.jsx";
|
import { OverviewView } from "./components/OverviewView.jsx";
|
||||||
import { EXPERIMENT_ARM_LABELS, formatTrafficPercent } from "./experimentForm.js";
|
import { EXPERIMENT_ARM_LABELS, formatTrafficPercent } from "./experimentForm.js";
|
||||||
@ -50,6 +53,7 @@ const initialDashboard = {
|
|||||||
apps: [],
|
apps: [],
|
||||||
appSummaries: [],
|
appSummaries: [],
|
||||||
experiments: [],
|
experiments: [],
|
||||||
|
giftHostDiamondRatiosByApp: {},
|
||||||
luckyGifts: [],
|
luckyGifts: [],
|
||||||
poolBalances: [],
|
poolBalances: [],
|
||||||
summary: {},
|
summary: {},
|
||||||
@ -72,10 +76,12 @@ export function OpsCenterApp({ can = denyPermission }) {
|
|||||||
const [state, setState] = useState({ data: initialDashboard, error: "", loaded: false, loading: true });
|
const [state, setState] = useState({ data: initialDashboard, error: "", loaded: false, loading: true });
|
||||||
const [dialog, setDialog] = useState(null);
|
const [dialog, setDialog] = useState(null);
|
||||||
const [poolDialog, setPoolDialog] = useState(null);
|
const [poolDialog, setPoolDialog] = useState(null);
|
||||||
|
const [rollbackDialog, setRollbackDialog] = useState(null);
|
||||||
const [experimentManage, setExperimentManage] = useState(null);
|
const [experimentManage, setExperimentManage] = useState(null);
|
||||||
const [profileRefreshKey, setProfileRefreshKey] = useState(0);
|
const [profileRefreshKey, setProfileRefreshKey] = useState(0);
|
||||||
const [adjustingPool, setAdjustingPool] = useState(false);
|
const [adjustingPool, setAdjustingPool] = useState(false);
|
||||||
const [savingConfig, setSavingConfig] = useState(false);
|
const [savingConfig, setSavingConfig] = useState(false);
|
||||||
|
const [rollbackBusy, setRollbackBusy] = useState(false);
|
||||||
const [experimentBusy, setExperimentBusy] = useState(false);
|
const [experimentBusy, setExperimentBusy] = useState(false);
|
||||||
const { showToast } = useToast();
|
const { showToast } = useToast();
|
||||||
const canCreditPool = can(PERMISSIONS.luckyGiftPoolCredit);
|
const canCreditPool = can(PERMISSIONS.luckyGiftPoolCredit);
|
||||||
@ -175,6 +181,64 @@ export function OpsCenterApp({ can = denyPermission }) {
|
|||||||
setDialog({ config: structuredClone(config), mode: "experiment" });
|
setDialog({ config: structuredClone(config), mode: "experiment" });
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const loadRollbackVersions = useCallback(async (config) => {
|
||||||
|
const identity = `${config.app_code}/${config.pool_id}`;
|
||||||
|
setRollbackDialog((current) =>
|
||||||
|
current && current.identity === identity
|
||||||
|
? { ...current, error: "", loading: true }
|
||||||
|
: { config: structuredClone(config), error: "", identity, loading: true, versions: [] },
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
const versions = await fetchLuckyGiftConfigVersions(config.app_code, config.pool_id);
|
||||||
|
setRollbackDialog((current) =>
|
||||||
|
current && current.identity === identity ? { ...current, loading: false, versions } : current,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
setRollbackDialog((current) =>
|
||||||
|
current && current.identity === identity
|
||||||
|
? { ...current, error: error.message || "获取历史版本失败", loading: false }
|
||||||
|
: current,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const openRollback = useCallback(
|
||||||
|
(config) => {
|
||||||
|
// 列表只提供当前最新版本;打开时再按 app+pool 读取不可变版本链,避免 dashboard 跨应用首屏额外扇出。
|
||||||
|
loadRollbackVersions(config);
|
||||||
|
},
|
||||||
|
[loadRollbackVersions],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleRollback = useCallback(
|
||||||
|
async (targetRuleVersion) => {
|
||||||
|
const current = rollbackDialog?.config;
|
||||||
|
if (!current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setRollbackBusy(true);
|
||||||
|
try {
|
||||||
|
const result = await rollbackLuckyGiftConfig({
|
||||||
|
appCode: current.app_code,
|
||||||
|
poolId: current.pool_id,
|
||||||
|
targetRuleVersion,
|
||||||
|
});
|
||||||
|
showToast(
|
||||||
|
`已将 v${result.sourceRuleVersion || targetRuleVersion} 的配置发布为 v${result.config.rule_version}`,
|
||||||
|
"success",
|
||||||
|
);
|
||||||
|
setRollbackDialog(null);
|
||||||
|
await loadDashboard();
|
||||||
|
} catch (error) {
|
||||||
|
// 冲突或网络失败时保留版本选择;owner 仍会重新校验当前版本与活跃实验,防止过期页面误发布。
|
||||||
|
showToast(error.message || "回滚幸运礼物版本失败", "error");
|
||||||
|
} finally {
|
||||||
|
setRollbackBusy(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[loadDashboard, rollbackDialog, showToast],
|
||||||
|
);
|
||||||
|
|
||||||
const openPoolAdjustment = useCallback((pool, direction) => {
|
const openPoolAdjustment = useCallback((pool, direction) => {
|
||||||
if (!pool) {
|
if (!pool) {
|
||||||
return;
|
return;
|
||||||
@ -438,13 +502,14 @@ export function OpsCenterApp({ can = denyPermission }) {
|
|||||||
experiments={data.experiments}
|
experiments={data.experiments}
|
||||||
luckyGifts={data.luckyGifts}
|
luckyGifts={data.luckyGifts}
|
||||||
poolBalances={data.poolBalances}
|
poolBalances={data.poolBalances}
|
||||||
saving={savingConfig}
|
saving={savingConfig || rollbackBusy}
|
||||||
onCreate={openCreateConfig}
|
onCreate={openCreateConfig}
|
||||||
onCreateExperiment={openCreateExperiment}
|
onCreateExperiment={openCreateExperiment}
|
||||||
onCredit={(pool) => openPoolAdjustment(pool, "in")}
|
onCredit={(pool) => openPoolAdjustment(pool, "in")}
|
||||||
onDebit={(pool) => openPoolAdjustment(pool, "out")}
|
onDebit={(pool) => openPoolAdjustment(pool, "out")}
|
||||||
onEdit={openEditConfig}
|
onEdit={openEditConfig}
|
||||||
onManageExperiment={openExperimentManage}
|
onManageExperiment={openExperimentManage}
|
||||||
|
onRollback={openRollback}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{activeView === "draws" ? (
|
{activeView === "draws" ? (
|
||||||
@ -466,6 +531,7 @@ export function OpsCenterApp({ can = denyPermission }) {
|
|||||||
<LuckyGiftConfigDialog
|
<LuckyGiftConfigDialog
|
||||||
appOptions={appOptions}
|
appOptions={appOptions}
|
||||||
config={dialog.config}
|
config={dialog.config}
|
||||||
|
giftHostDiamondRatiosByApp={data.giftHostDiamondRatiosByApp}
|
||||||
mode={dialog.mode}
|
mode={dialog.mode}
|
||||||
saving={savingConfig}
|
saving={savingConfig}
|
||||||
onClose={() => setDialog(null)}
|
onClose={() => setDialog(null)}
|
||||||
@ -504,6 +570,18 @@ export function OpsCenterApp({ can = denyPermission }) {
|
|||||||
onSubmit={handleAdjustPool}
|
onSubmit={handleAdjustPool}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
{rollbackDialog ? (
|
||||||
|
<LuckyGiftRollbackDialog
|
||||||
|
config={rollbackDialog.config}
|
||||||
|
error={rollbackDialog.error}
|
||||||
|
loading={rollbackDialog.loading}
|
||||||
|
submitting={rollbackBusy}
|
||||||
|
versions={rollbackDialog.versions}
|
||||||
|
onClose={() => setRollbackDialog(null)}
|
||||||
|
onRetry={() => loadRollbackVersions(rollbackDialog.config)}
|
||||||
|
onSubmit={handleRollback}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</OpsShell>
|
</OpsShell>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -7,8 +7,10 @@ import {
|
|||||||
createLuckyGiftExperiment,
|
createLuckyGiftExperiment,
|
||||||
endLuckyGiftExperiment,
|
endLuckyGiftExperiment,
|
||||||
fetchLuckyGiftDraws,
|
fetchLuckyGiftDraws,
|
||||||
|
fetchLuckyGiftConfigVersions,
|
||||||
fetchLuckyGiftExperiments,
|
fetchLuckyGiftExperiments,
|
||||||
fetchOpsDashboard,
|
fetchOpsDashboard,
|
||||||
|
rollbackLuckyGiftConfig,
|
||||||
saveLuckyGiftConfig,
|
saveLuckyGiftConfig,
|
||||||
setLuckyGiftExperimentOverrides,
|
setLuckyGiftExperimentOverrides,
|
||||||
updateLuckyGiftExperiment,
|
updateLuckyGiftExperiment,
|
||||||
@ -21,10 +23,12 @@ vi.mock("./api.js", () => ({
|
|||||||
createLuckyGiftExperiment: vi.fn(),
|
createLuckyGiftExperiment: vi.fn(),
|
||||||
endLuckyGiftExperiment: vi.fn(),
|
endLuckyGiftExperiment: vi.fn(),
|
||||||
fetchLuckyGiftDraws: vi.fn(),
|
fetchLuckyGiftDraws: vi.fn(),
|
||||||
|
fetchLuckyGiftConfigVersions: vi.fn(),
|
||||||
fetchLuckyGiftExperiments: vi.fn(),
|
fetchLuckyGiftExperiments: vi.fn(),
|
||||||
fetchOpsDashboard: vi.fn(),
|
fetchOpsDashboard: vi.fn(),
|
||||||
luckyGiftPoolKey: (item = {}) =>
|
luckyGiftPoolKey: (item = {}) =>
|
||||||
`${item.app_code || item.appCode}:${item.pool_id || item.poolId}:${item.strategy_version || item.strategyVersion || "fixed_v2"}`,
|
`${item.app_code || item.appCode}:${item.pool_id || item.poolId}:${item.strategy_version || item.strategyVersion || "fixed_v2"}`,
|
||||||
|
rollbackLuckyGiftConfig: vi.fn(),
|
||||||
saveLuckyGiftConfig: vi.fn(),
|
saveLuckyGiftConfig: vi.fn(),
|
||||||
setLuckyGiftExperimentOverrides: vi.fn(),
|
setLuckyGiftExperimentOverrides: vi.fn(),
|
||||||
updateLuckyGiftExperiment: vi.fn(),
|
updateLuckyGiftExperiment: vi.fn(),
|
||||||
@ -55,6 +59,11 @@ beforeEach(() => {
|
|||||||
adjustment: { balance_after: 39_559_017, balance_before: 38_559_017 },
|
adjustment: { balance_after: 39_559_017, balance_before: 38_559_017 },
|
||||||
idempotent_replay: false,
|
idempotent_replay: false,
|
||||||
});
|
});
|
||||||
|
fetchLuckyGiftConfigVersions.mockResolvedValue([]);
|
||||||
|
rollbackLuckyGiftConfig.mockResolvedValue({
|
||||||
|
config: { app_code: "lalu", pool_id: "super_lucky", rule_version: 22 },
|
||||||
|
sourceRuleVersion: 20,
|
||||||
|
});
|
||||||
fetchLuckyGiftDraws.mockResolvedValue({
|
fetchLuckyGiftDraws.mockResolvedValue({
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
@ -76,6 +85,10 @@ beforeEach(() => {
|
|||||||
});
|
});
|
||||||
fetchOpsDashboard.mockResolvedValue({
|
fetchOpsDashboard.mockResolvedValue({
|
||||||
experiments: [mockRunningExperiment()],
|
experiments: [mockRunningExperiment()],
|
||||||
|
giftHostDiamondRatiosByApp: {
|
||||||
|
lalu: { lucky: 10, super_lucky: 1 },
|
||||||
|
yumi: { lucky: 10, super_lucky: 1 },
|
||||||
|
},
|
||||||
apps: [
|
apps: [
|
||||||
{ app_code: "lalu", app_name: "Lalu", source: "registry", status: "active", updated_at_ms: 1767000000000 },
|
{ app_code: "lalu", app_name: "Lalu", source: "registry", status: "active", updated_at_ms: 1767000000000 },
|
||||||
{ app_code: "yumi", app_name: "Yumi", source: "fixed", status: "active" },
|
{ app_code: "yumi", app_name: "Yumi", source: "fixed", status: "active" },
|
||||||
@ -415,7 +428,7 @@ test("opens config dialog from a published pool row and saves a new version", as
|
|||||||
expect(saveLuckyGiftConfig).toHaveBeenCalledWith(
|
expect(saveLuckyGiftConfig).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
app_code: "lalu",
|
app_code: "lalu",
|
||||||
anchor_rate_percent: 1,
|
anchor_rate_percent: 10,
|
||||||
initial_pool_coins: 0,
|
initial_pool_coins: 0,
|
||||||
jackpot_multipliers: [200, 500, 1000],
|
jackpot_multipliers: [200, 500, 1000],
|
||||||
pool_id: "lucky",
|
pool_id: "lucky",
|
||||||
@ -432,7 +445,8 @@ test("blocks an invalid enabled dynamic rule before calling the save API", async
|
|||||||
renderApp();
|
renderApp();
|
||||||
await screen.findByText("运营应用");
|
await screen.findByText("运营应用");
|
||||||
fireEvent.click(screen.getByRole("button", { name: /幸运礼物配置/ }));
|
fireEvent.click(screen.getByRole("button", { name: /幸运礼物配置/ }));
|
||||||
await screen.findAllByText("lucky");
|
// dashboard 已完成加载,切换本地视图和打开弹窗都是同步状态;避免在全量并发测试中额外轮询文本。
|
||||||
|
expect(screen.getAllByText("lucky").length).toBeGreaterThan(0);
|
||||||
fireEvent.click(screen.getByRole("button", { name: "新增版本 lalu / lucky / dynamic_v3" }));
|
fireEvent.click(screen.getByRole("button", { name: "新增版本 lalu / lucky / dynamic_v3" }));
|
||||||
|
|
||||||
const dialog = await screen.findByRole("dialog");
|
const dialog = await screen.findByRole("dialog");
|
||||||
@ -450,7 +464,7 @@ test("blocks an invalid enabled dynamic rule before calling the save API", async
|
|||||||
function mockDynamicConfig() {
|
function mockDynamicConfig() {
|
||||||
return {
|
return {
|
||||||
anchor_daily_payout_cap: 6_000_000,
|
anchor_daily_payout_cap: 6_000_000,
|
||||||
anchor_rate_percent: 1,
|
anchor_rate_percent: 10,
|
||||||
control_band_percent: 3,
|
control_band_percent: 3,
|
||||||
device_daily_payout_cap: 4_000_000,
|
device_daily_payout_cap: 4_000_000,
|
||||||
effective_from_ms: 0,
|
effective_from_ms: 0,
|
||||||
@ -470,15 +484,15 @@ function mockDynamicConfig() {
|
|||||||
max_single_payout: 1_000_000,
|
max_single_payout: 1_000_000,
|
||||||
normal_max_equivalent_draws: 20_000,
|
normal_max_equivalent_draws: 20_000,
|
||||||
novice_max_equivalent_draws: 2_000,
|
novice_max_equivalent_draws: 2_000,
|
||||||
pool_rate_percent: 98,
|
pool_rate_percent: 89,
|
||||||
profit_rate_percent: 1,
|
profit_rate_percent: 1,
|
||||||
recharge_boost_factor_percent: 110,
|
recharge_boost_factor_percent: 110,
|
||||||
recharge_boost_window_ms: 300_000,
|
recharge_boost_window_ms: 300_000,
|
||||||
room_hourly_payout_cap: 5_000_000,
|
room_hourly_payout_cap: 5_000_000,
|
||||||
settlement_window_wager: 1_000_000,
|
settlement_window_wager: 1_000_000,
|
||||||
stages: mockStages(98),
|
stages: mockStages(89),
|
||||||
strategy_version: "dynamic_v3",
|
strategy_version: "dynamic_v3",
|
||||||
target_rtp_percent: 98,
|
target_rtp_percent: 89,
|
||||||
user_daily_payout_cap: 3_000_000,
|
user_daily_payout_cap: 3_000_000,
|
||||||
user_hourly_payout_cap: 2_000_000,
|
user_hourly_payout_cap: 2_000_000,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import { apiRequest } from "@/shared/api/request";
|
|||||||
export const OPS_API_PREFIX = "/api/v1/admin/ops-center";
|
export const OPS_API_PREFIX = "/api/v1/admin/ops-center";
|
||||||
export const DEFAULT_POOL_ID = "default";
|
export const DEFAULT_POOL_ID = "default";
|
||||||
const USER_24H_RTP_DISABLED_FACTOR_PERCENT = 100;
|
const USER_24H_RTP_DISABLED_FACTOR_PERCENT = 100;
|
||||||
|
const GIFT_DIAMOND_RATIOS_PATH = "/v1/admin/operations/gift-diamond-ratios";
|
||||||
|
|
||||||
export async function fetchOpsDashboard(query = {}) {
|
export async function fetchOpsDashboard(query = {}) {
|
||||||
// Ops Center 是跨 App 的运营面板:先拉应用主数据,再按 app_code 扇出拉取奖池配置、余额和抽奖汇总。
|
// Ops Center 是跨 App 的运营面板:先拉应用主数据,再按 app_code 扇出拉取奖池配置、余额和抽奖汇总。
|
||||||
@ -14,21 +15,45 @@ export async function fetchOpsDashboard(query = {}) {
|
|||||||
|
|
||||||
const perApp = await Promise.all(
|
const perApp = await Promise.all(
|
||||||
appCodes.map(async (appCode) => {
|
appCodes.map(async (appCode) => {
|
||||||
const [luckyGifts, poolBalances, drawSummary, experiments] = await Promise.all([
|
const [luckyGifts, poolBalances, drawSummary, experiments, giftHostDiamondRatios] = await Promise.all([
|
||||||
fetchAppLuckyGiftConfigs(appCode),
|
fetchAppLuckyGiftConfigs(appCode),
|
||||||
opsRequest("/lucky-gifts/pools", { query: { app_code: appCode } }).then(normalizeItems),
|
opsRequest("/lucky-gifts/pools", { query: { app_code: appCode } }).then(normalizeItems),
|
||||||
opsRequest("/lucky-gifts/summary", { query: { app_code: appCode } }),
|
opsRequest("/lucky-gifts/summary", { query: { app_code: appCode } }),
|
||||||
// 实验列表驱动配置行的实验徽章和发布禁用;该接口比面板后上线,请求失败时降级为空列表
|
// 实验列表驱动配置行的实验徽章和发布禁用;该接口比面板后上线,请求失败时降级为空列表
|
||||||
// 而不是整页白屏——活跃实验期间的常规发布仍由 owner 以 409 兜底拒绝,降级是安全的。
|
// 而不是整页白屏——活跃实验期间的常规发布仍由 owner 以 409 兜底拒绝,降级是安全的。
|
||||||
fetchLuckyGiftExperiments(appCode).catch(() => []),
|
fetchLuckyGiftExperiments(appCode).catch(() => []),
|
||||||
|
// 主播钻石是 wallet 的应用级礼物配置,不属于幸运礼物规则。读取失败时保留配置列表可查看,
|
||||||
|
// 但把 null 传到编辑弹窗并禁止发布,绝不能把返还金币比例或前端默认值当成主播钻石。
|
||||||
|
fetchGiftHostDiamondRatios(appCode).catch(() => null),
|
||||||
]);
|
]);
|
||||||
return { appCode, drawSummary, experiments, luckyGifts, poolBalances };
|
return { appCode, drawSummary, experiments, giftHostDiamondRatios, luckyGifts, poolBalances };
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
return normalizeDashboard({ apps, perApp });
|
return normalizeDashboard({ apps, perApp });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchGiftHostDiamondRatios(appCode) {
|
||||||
|
const normalizedAppCode = normalizeAppCodeValue(appCode);
|
||||||
|
const payload = await apiRequest(GIFT_DIAMOND_RATIOS_PATH, {
|
||||||
|
// Ops Center 跨应用扇出读取,不能使用主后台当前选中的 X-App-Code;每个请求必须显式绑定
|
||||||
|
// 当前行应用,否则 lalu 的主播钻石比例会被错误复用到其他应用。
|
||||||
|
headers: { "X-App-Code": normalizedAppCode },
|
||||||
|
method: "GET",
|
||||||
|
query: { region_id: 0 },
|
||||||
|
});
|
||||||
|
return Object.fromEntries(
|
||||||
|
normalizeItems(payload)
|
||||||
|
.map((item) => [
|
||||||
|
String(item.giftTypeCode ?? item.gift_type_code ?? "")
|
||||||
|
.trim()
|
||||||
|
.toLowerCase(),
|
||||||
|
finitePercentOrNull(item.ratioPercent ?? item.ratio_percent),
|
||||||
|
])
|
||||||
|
.filter(([giftType, ratio]) => giftType && ratio !== null),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async function fetchAppLuckyGiftConfigs(appCode) {
|
async function fetchAppLuckyGiftConfigs(appCode) {
|
||||||
// 必须用复数 /configs:它返回该应用每个奖池的最新已发布规则版本。
|
// 必须用复数 /configs:它返回该应用每个奖池的最新已发布规则版本。
|
||||||
// 单数 /config 只回默认奖池(无配置时是服务端草稿),会把 lalu 的 lucky/super_lucky 等已发布奖池整体漏掉。
|
// 单数 /config 只回默认奖池(无配置时是服务端草稿),会把 lalu 的 lucky/super_lucky 等已发布奖池整体漏掉。
|
||||||
@ -185,6 +210,35 @@ export async function saveLuckyGiftConfig(config = {}) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchLuckyGiftConfigVersions(appCode, poolId) {
|
||||||
|
const normalizedAppCode = normalizeAppCodeValue(appCode);
|
||||||
|
const payload = await opsRequest("/lucky-gifts/config/versions", {
|
||||||
|
query: {
|
||||||
|
app_code: normalizedAppCode,
|
||||||
|
pool_id: String(poolId || DEFAULT_POOL_ID).trim() || DEFAULT_POOL_ID,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
// 历史版本必须展示 owner 保存的不可变快照,不能套用当前礼物分成或表单默认值,
|
||||||
|
// 否则运营看到的目标版本与实际回滚内容会不一致。
|
||||||
|
return normalizeItems(payload).map((config) => normalizeLuckyGiftConfig(config, normalizedAppCode));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function rollbackLuckyGiftConfig({ appCode, poolId, targetRuleVersion } = {}) {
|
||||||
|
const normalizedAppCode = normalizeAppCodeValue(appCode);
|
||||||
|
const payload = await opsRequest("/lucky-gifts/config/rollback", {
|
||||||
|
body: { target_rule_version: numberValue(targetRuleVersion) },
|
||||||
|
method: "POST",
|
||||||
|
query: {
|
||||||
|
app_code: normalizedAppCode,
|
||||||
|
pool_id: String(poolId || DEFAULT_POOL_ID).trim() || DEFAULT_POOL_ID,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
sourceRuleVersion: numberValue(payload?.source_rule_version ?? payload?.sourceRuleVersion),
|
||||||
|
config: normalizeLuckyGiftConfig(payload?.config || {}, normalizedAppCode),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export async function adjustLuckyGiftPool({
|
export async function adjustLuckyGiftPool({
|
||||||
adjustmentId,
|
adjustmentId,
|
||||||
amountCoins,
|
amountCoins,
|
||||||
@ -395,6 +449,9 @@ export function normalizeDashboard({ apps = [], perApp = [] } = {}) {
|
|||||||
const appSummaries = perApp
|
const appSummaries = perApp
|
||||||
.map((entry) => normalizeDrawSummary(entry.drawSummary, entry.appCode))
|
.map((entry) => normalizeDrawSummary(entry.drawSummary, entry.appCode))
|
||||||
.filter((summary) => summary.app_code);
|
.filter((summary) => summary.app_code);
|
||||||
|
const giftHostDiamondRatiosByApp = Object.fromEntries(
|
||||||
|
perApp.map((entry) => [normalizeAppCodeValue(entry.appCode), entry.giftHostDiamondRatios ?? null]),
|
||||||
|
);
|
||||||
|
|
||||||
const publishedConfigs = luckyGifts.filter((config) => !config.is_default);
|
const publishedConfigs = luckyGifts.filter((config) => !config.is_default);
|
||||||
const currentConfigByPool = new Map();
|
const currentConfigByPool = new Map();
|
||||||
@ -438,7 +495,7 @@ export function normalizeDashboard({ apps = [], perApp = [] } = {}) {
|
|||||||
totalSpentCoins: sumBy(appSummaries, "total_spent_coins"),
|
totalSpentCoins: sumBy(appSummaries, "total_spent_coins"),
|
||||||
};
|
};
|
||||||
|
|
||||||
return { apps, appSummaries, experiments, luckyGifts, poolBalances, summary };
|
return { apps, appSummaries, experiments, giftHostDiamondRatiosByApp, luckyGifts, poolBalances, summary };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function normalizePoolBalance(pool = {}, fallbackAppCode = "") {
|
export function normalizePoolBalance(pool = {}, fallbackAppCode = "") {
|
||||||
@ -1061,6 +1118,11 @@ function numberValue(value) {
|
|||||||
return Number.isFinite(parsed) ? parsed : 0;
|
return Number.isFinite(parsed) ? parsed : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function finitePercentOrNull(value) {
|
||||||
|
const parsed = Number(value);
|
||||||
|
return Number.isFinite(parsed) && parsed >= 0 && parsed <= 100 ? parsed : null;
|
||||||
|
}
|
||||||
|
|
||||||
function numberArray(values) {
|
function numberArray(values) {
|
||||||
if (!Array.isArray(values)) {
|
if (!Array.isArray(values)) {
|
||||||
return [];
|
return [];
|
||||||
|
|||||||
@ -61,9 +61,11 @@ test("refreshes a stale access token before retrying an ops-center request", asy
|
|||||||
|
|
||||||
test("loads every pool config per app through the plural configs endpoint", async () => {
|
test("loads every pool config per app through the plural configs endpoint", async () => {
|
||||||
const calls = [];
|
const calls = [];
|
||||||
vi.spyOn(window, "fetch").mockImplementation(async (url) => {
|
const requests = [];
|
||||||
|
vi.spyOn(window, "fetch").mockImplementation(async (url, init) => {
|
||||||
const text = String(url);
|
const text = String(url);
|
||||||
calls.push(text);
|
calls.push(text);
|
||||||
|
requests.push({ init, url: text });
|
||||||
if (text.endsWith("/apps")) {
|
if (text.endsWith("/apps")) {
|
||||||
return jsonResponse({ items: [{ app_code: "lalu", status: "active" }] });
|
return jsonResponse({ items: [{ app_code: "lalu", status: "active" }] });
|
||||||
}
|
}
|
||||||
@ -118,6 +120,15 @@ test("loads every pool config per app through the plural configs endpoint", asyn
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (text.includes("/admin/operations/gift-diamond-ratios?")) {
|
||||||
|
return jsonResponse({
|
||||||
|
items: [
|
||||||
|
{ giftTypeCode: "lucky", ratioPercent: "10.00", returnCoinRatioPercent: "0.00" },
|
||||||
|
{ giftTypeCode: "super_lucky", ratioPercent: "1.00", returnCoinRatioPercent: "0.00" },
|
||||||
|
],
|
||||||
|
regionId: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
return jsonResponse({ items: [] });
|
return jsonResponse({ items: [] });
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -130,7 +141,12 @@ test("loads every pool config per app through the plural configs endpoint", asyn
|
|||||||
"http://localhost:3000/api/v1/admin/ops-center/lucky-gifts/summary?app_code=lalu",
|
"http://localhost:3000/api/v1/admin/ops-center/lucky-gifts/summary?app_code=lalu",
|
||||||
// dashboard 扇出不带 with_stats/status:实验列表只驱动实验徽章和发布封锁。
|
// dashboard 扇出不带 with_stats/status:实验列表只驱动实验徽章和发布封锁。
|
||||||
"http://localhost:3000/api/v1/admin/ops-center/lucky-gifts/experiments?app_code=lalu",
|
"http://localhost:3000/api/v1/admin/ops-center/lucky-gifts/experiments?app_code=lalu",
|
||||||
|
"http://localhost:3000/api/v1/admin/operations/gift-diamond-ratios?region_id=0",
|
||||||
]);
|
]);
|
||||||
|
const ratioRequest = requests.find((request) => request.url.includes("gift-diamond-ratios"));
|
||||||
|
expect(ratioRequest.init.headers["X-App-Code"]).toBe("lalu");
|
||||||
|
// 幸运礼物资金预留只读取主播钻石 ratioPercent;线上返还金币为 0,不进入该表单。
|
||||||
|
expect(data.giftHostDiamondRatiosByApp).toEqual({ lalu: { lucky: 10, super_lucky: 1 } });
|
||||||
expect(data.experiments).toEqual([
|
expect(data.experiments).toEqual([
|
||||||
expect.objectContaining({ app_code: "lalu", experiment_id: "exp-1", pool_id: "lucky", status: "running" }),
|
expect.objectContaining({ app_code: "lalu", experiment_id: "exp-1", pool_id: "lucky", status: "running" }),
|
||||||
]);
|
]);
|
||||||
|
|||||||
@ -25,6 +25,7 @@ export function ConfigsView({
|
|||||||
onDebit,
|
onDebit,
|
||||||
onEdit,
|
onEdit,
|
||||||
onManageExperiment,
|
onManageExperiment,
|
||||||
|
onRollback,
|
||||||
poolBalances,
|
poolBalances,
|
||||||
saving,
|
saving,
|
||||||
}) {
|
}) {
|
||||||
@ -72,6 +73,7 @@ export function ConfigsView({
|
|||||||
onDebit,
|
onDebit,
|
||||||
onEdit,
|
onEdit,
|
||||||
onManageExperiment,
|
onManageExperiment,
|
||||||
|
onRollback,
|
||||||
saving,
|
saving,
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
@ -86,6 +88,7 @@ export function ConfigsView({
|
|||||||
onDebit,
|
onDebit,
|
||||||
onEdit,
|
onEdit,
|
||||||
onManageExperiment,
|
onManageExperiment,
|
||||||
|
onRollback,
|
||||||
saving,
|
saving,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@ -139,6 +142,7 @@ function buildColumns({
|
|||||||
onDebit,
|
onDebit,
|
||||||
onEdit,
|
onEdit,
|
||||||
onManageExperiment,
|
onManageExperiment,
|
||||||
|
onRollback,
|
||||||
saving,
|
saving,
|
||||||
}) {
|
}) {
|
||||||
const columns = [
|
const columns = [
|
||||||
@ -248,6 +252,18 @@ function buildColumns({
|
|||||||
{item.is_default ? "发布配置" : "新增版本"}
|
{item.is_default ? "发布配置" : "新增版本"}
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
|
{canUpdate && !item.is_default ? (
|
||||||
|
<Button
|
||||||
|
aria-label={`回滚版本 ${identity}`}
|
||||||
|
// 回滚同样会发布一个新版本,必须服从实验对奖池的发布独占约束。
|
||||||
|
disabled={saving || hasActiveExperiment}
|
||||||
|
size="small"
|
||||||
|
title={hasActiveExperiment ? "实验进行中,结束实验后才能回滚" : undefined}
|
||||||
|
onClick={() => onRollback(item)}
|
||||||
|
>
|
||||||
|
回滚版本
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
{hasActiveExperiment ? (
|
{hasActiveExperiment ? (
|
||||||
<Button
|
<Button
|
||||||
aria-label={`实验详情 ${identity}`}
|
aria-label={`实验详情 ${identity}`}
|
||||||
@ -275,7 +291,7 @@ function buildColumns({
|
|||||||
width: `${
|
width: `${
|
||||||
(canCredit ? 110 : 0) +
|
(canCredit ? 110 : 0) +
|
||||||
(canDebit ? 110 : 0) +
|
(canDebit ? 110 : 0) +
|
||||||
(canUpdate ? 110 : 0) +
|
(canUpdate ? 220 : 0) +
|
||||||
(hasExperimentEntry ? 128 : 0) +
|
(hasExperimentEntry ? 128 : 0) +
|
||||||
20
|
20
|
||||||
}px`,
|
}px`,
|
||||||
|
|||||||
@ -5,10 +5,12 @@ import DialogTitle from "@mui/material/DialogTitle";
|
|||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { Button } from "@/shared/ui/Button.jsx";
|
import { Button } from "@/shared/ui/Button.jsx";
|
||||||
import {
|
import {
|
||||||
|
applyGiftHostDiamondRatio,
|
||||||
applyDynamicStrategyDefaults,
|
applyDynamicStrategyDefaults,
|
||||||
configToForm,
|
configToForm,
|
||||||
correctAllStageProbabilities,
|
correctAllStageProbabilities,
|
||||||
formToConfig,
|
formToConfig,
|
||||||
|
giftHostDiamondRatioForPool,
|
||||||
nextStageMultiplier,
|
nextStageMultiplier,
|
||||||
stageSummary,
|
stageSummary,
|
||||||
validateLuckyGiftForm,
|
validateLuckyGiftForm,
|
||||||
@ -24,11 +26,31 @@ import { LuckyGiftConfigSections } from "./LuckyGiftConfigSections.jsx";
|
|||||||
import { LuckyGiftExperimentFields } from "./LuckyGiftExperimentFields.jsx";
|
import { LuckyGiftExperimentFields } from "./LuckyGiftExperimentFields.jsx";
|
||||||
import { LuckyGiftStageDesigner } from "./LuckyGiftStageDesigner.jsx";
|
import { LuckyGiftStageDesigner } from "./LuckyGiftStageDesigner.jsx";
|
||||||
|
|
||||||
export function LuckyGiftConfigDialog({ appOptions = [], config, mode = "edit", onClose, onSubmit, saving }) {
|
export function LuckyGiftConfigDialog({
|
||||||
|
appOptions = [],
|
||||||
|
config,
|
||||||
|
giftHostDiamondRatiosByApp = {},
|
||||||
|
mode = "edit",
|
||||||
|
onClose,
|
||||||
|
onSubmit,
|
||||||
|
saving,
|
||||||
|
}) {
|
||||||
const isExperiment = mode === "experiment";
|
const isExperiment = mode === "experiment";
|
||||||
|
const isCreate = mode === "create";
|
||||||
// 实验组配置由 owner 原子发布并立即参与分流开奖,enabled 强制 true 且开关锁定;
|
// 实验组配置由 owner 原子发布并立即参与分流开奖,enabled 强制 true 且开关锁定;
|
||||||
// 以当前行配置为初始值,运营在其上修改出实验组版本。
|
// 以当前行配置为初始值,运营在其上修改出实验组版本。
|
||||||
const [form, setForm] = useState(() => configToForm(isExperiment ? { ...config, enabled: true } : config));
|
const [form, setForm] = useState(() => {
|
||||||
|
const initial = configToForm(isExperiment ? { ...config, enabled: true } : config);
|
||||||
|
const ratio = resolveHostDiamondRatio(giftHostDiamondRatiosByApp, initial);
|
||||||
|
if (initial.strategy_version !== "dynamic_v3" || ratio === null) return initial;
|
||||||
|
// 未发布草稿按接口真实比例重建资金基线;已发布版本只替换历史快照中的主播钻石比例,
|
||||||
|
// 保留运营现有的目标 RTP/奖池比例,若二者不闭合则由发布校验明确提示。
|
||||||
|
const rebalance =
|
||||||
|
isCreate ||
|
||||||
|
(config.is_default ?? config.isDefault) === true ||
|
||||||
|
Number(config.rule_version ?? config.ruleVersion ?? 0) <= 0;
|
||||||
|
return applyGiftHostDiamondRatio(initial, ratio, { rebalance });
|
||||||
|
});
|
||||||
const [experiment, setExperiment] = useState(experimentDraft);
|
const [experiment, setExperiment] = useState(experimentDraft);
|
||||||
const [activeStage, setActiveStage] = useState("novice");
|
const [activeStage, setActiveStage] = useState("novice");
|
||||||
const [validationErrors, setValidationErrors] = useState([]);
|
const [validationErrors, setValidationErrors] = useState([]);
|
||||||
@ -52,11 +74,15 @@ export function LuckyGiftConfigDialog({ appOptions = [], config, mode = "edit",
|
|||||||
setValidationErrors([]);
|
setValidationErrors([]);
|
||||||
setForm((current) => {
|
setForm((current) => {
|
||||||
const next = { ...current, [field]: value };
|
const next = { ...current, [field]: value };
|
||||||
|
const hostDiamondRatio = resolveHostDiamondRatio(giftHostDiamondRatiosByApp, next);
|
||||||
if (field === "strategy_version" && value === "dynamic_v3" && current.strategy_version !== "dynamic_v3") {
|
if (field === "strategy_version" && value === "dynamic_v3" && current.strategy_version !== "dynamic_v3") {
|
||||||
const dynamicDraft = applyDynamicStrategyDefaults(next);
|
const dynamicDraft = applyDynamicStrategyDefaults(next, { anchorRatePercent: hostDiamondRatio });
|
||||||
// 常规模式安全草稿保持停用;实验模式下实验组必须直接可运行,启用态不允许被安全草稿覆盖回停用。
|
// 常规模式安全草稿保持停用;实验模式下实验组必须直接可运行,启用态不允许被安全草稿覆盖回停用。
|
||||||
return isExperiment ? { ...dynamicDraft, enabled: true } : dynamicDraft;
|
return isExperiment ? { ...dynamicDraft, enabled: true } : dynamicDraft;
|
||||||
}
|
}
|
||||||
|
if (["app_code", "pool_id"].includes(field) && next.strategy_version === "dynamic_v3") {
|
||||||
|
return applyGiftHostDiamondRatio(next, hostDiamondRatio, { rebalance: true });
|
||||||
|
}
|
||||||
// 目标 RTP 决定各奖档概率的闭合解,改动后全部阶段重算,避免展示的概率和将要发布的值不一致。
|
// 目标 RTP 决定各奖档概率的闭合解,改动后全部阶段重算,避免展示的概率和将要发布的值不一致。
|
||||||
return field === "target_rtp_percent" ? correctAllStageProbabilities(next) : next;
|
return field === "target_rtp_percent" ? correctAllStageProbabilities(next) : next;
|
||||||
});
|
});
|
||||||
@ -141,9 +167,14 @@ export function LuckyGiftConfigDialog({ appOptions = [], config, mode = "edit",
|
|||||||
// 实验模式即使开关被绕过也按启用校验:提交的实验组配置必须直接可运行,
|
// 实验模式即使开关被绕过也按启用校验:提交的实验组配置必须直接可运行,
|
||||||
// 校验口径与最终 payload 完全一致,避免“校验通过、发布被拒”的错位。
|
// 校验口径与最终 payload 完全一致,避免“校验通过、发布被拒”的错位。
|
||||||
const submittedForm = isExperiment ? { ...form, enabled: true } : form;
|
const submittedForm = isExperiment ? { ...form, enabled: true } : form;
|
||||||
const errors = isExperiment
|
const formErrors = isExperiment
|
||||||
? [...validateExperimentDraft(experiment), ...validateLuckyGiftForm(submittedForm)]
|
? [...validateExperimentDraft(experiment), ...validateLuckyGiftForm(submittedForm)]
|
||||||
: validateLuckyGiftForm(submittedForm);
|
: validateLuckyGiftForm(submittedForm);
|
||||||
|
const errors =
|
||||||
|
submittedForm.strategy_version === "dynamic_v3" &&
|
||||||
|
resolveHostDiamondRatio(giftHostDiamondRatiosByApp, submittedForm) === null
|
||||||
|
? ["未读取到当前应用的主播钻石比例,请刷新页面后再发布", ...formErrors]
|
||||||
|
: formErrors;
|
||||||
if (errors.length) {
|
if (errors.length) {
|
||||||
setValidationErrors(errors);
|
setValidationErrors(errors);
|
||||||
return;
|
return;
|
||||||
@ -155,7 +186,6 @@ export function LuckyGiftConfigDialog({ appOptions = [], config, mode = "edit",
|
|||||||
onSubmit(formToConfig(config, form));
|
onSubmit(formToConfig(config, form));
|
||||||
};
|
};
|
||||||
|
|
||||||
const isCreate = mode === "create";
|
|
||||||
const title = isExperiment
|
const title = isExperiment
|
||||||
? "创建 AB 实验"
|
? "创建 AB 实验"
|
||||||
: isCreate
|
: isCreate
|
||||||
@ -195,7 +225,7 @@ export function LuckyGiftConfigDialog({ appOptions = [], config, mode = "edit",
|
|||||||
label={form.strategy_version === "dynamic_v3" ? "资金拆分" : "奖池比例"}
|
label={form.strategy_version === "dynamic_v3" ? "资金拆分" : "奖池比例"}
|
||||||
value={
|
value={
|
||||||
form.strategy_version === "dynamic_v3"
|
form.strategy_version === "dynamic_v3"
|
||||||
? `奖池 ${form.pool_rate_percent}% / 平台 ${form.profit_rate_percent}% / 收礼返币 ${form.anchor_rate_percent}%(只读)`
|
? `奖池 ${form.pool_rate_percent}% / 平台 ${form.profit_rate_percent}% / 主播钻石 ${form.anchor_rate_percent}%(接口读取)`
|
||||||
: `${form.pool_rate_percent}%`
|
: `${form.pool_rate_percent}%`
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@ -245,6 +275,9 @@ export function LuckyGiftConfigDialog({ appOptions = [], config, mode = "edit",
|
|||||||
appOptions={appOptions}
|
appOptions={appOptions}
|
||||||
experimentMode={isExperiment}
|
experimentMode={isExperiment}
|
||||||
form={form}
|
form={form}
|
||||||
|
hostDiamondRatioAvailable={
|
||||||
|
resolveHostDiamondRatio(giftHostDiamondRatiosByApp, form) !== null
|
||||||
|
}
|
||||||
isCreate={isCreate}
|
isCreate={isCreate}
|
||||||
onChange={updateField}
|
onChange={updateField}
|
||||||
/>
|
/>
|
||||||
@ -276,6 +309,13 @@ export function LuckyGiftConfigDialog({ appOptions = [], config, mode = "edit",
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveHostDiamondRatio(ratiosByApp, form) {
|
||||||
|
const appCode = String(form?.app_code || "")
|
||||||
|
.trim()
|
||||||
|
.toLowerCase();
|
||||||
|
return giftHostDiamondRatioForPool(ratiosByApp?.[appCode], form?.pool_id);
|
||||||
|
}
|
||||||
|
|
||||||
function ConfigMetric({ label, value }) {
|
function ConfigMetric({ label, value }) {
|
||||||
return (
|
return (
|
||||||
<div className="ops-config-metric">
|
<div className="ops-config-metric">
|
||||||
|
|||||||
@ -238,12 +238,12 @@ test("fixed_v2 hides only dynamic controls and preserves immutable snapshot fiel
|
|||||||
|
|
||||||
test("saves an ordinary tier and the independent jackpot list when they share a multiplier", () => {
|
test("saves an ordinary tier and the independent jackpot list when they share a multiplier", () => {
|
||||||
const onSubmit = vi.fn();
|
const onSubmit = vi.fn();
|
||||||
const stages = stagesFor(98).map((stage) =>
|
const stages = stagesFor(89).map((stage) =>
|
||||||
stage.stage === "normal"
|
stage.stage === "normal"
|
||||||
? {
|
? {
|
||||||
...stage,
|
...stage,
|
||||||
tiers: [
|
tiers: [
|
||||||
{ ...stage.tiers[0], probability_percent: 1.99 },
|
{ ...stage.tiers[0], probability_percent: 10.99 },
|
||||||
stage.tiers[1],
|
stage.tiers[1],
|
||||||
{
|
{
|
||||||
enabled: true,
|
enabled: true,
|
||||||
@ -303,6 +303,10 @@ function renderDialog(config, overrides = {}) {
|
|||||||
["yumi", "Yumi"],
|
["yumi", "Yumi"],
|
||||||
]}
|
]}
|
||||||
config={config}
|
config={config}
|
||||||
|
giftHostDiamondRatiosByApp={{
|
||||||
|
lalu: { lucky: 10, super_lucky: 1 },
|
||||||
|
yumi: { lucky: 10, super_lucky: 1 },
|
||||||
|
}}
|
||||||
mode="edit"
|
mode="edit"
|
||||||
saving={false}
|
saving={false}
|
||||||
onClose={vi.fn()}
|
onClose={vi.fn()}
|
||||||
@ -315,7 +319,7 @@ function renderDialog(config, overrides = {}) {
|
|||||||
function dynamicConfig(overrides = {}) {
|
function dynamicConfig(overrides = {}) {
|
||||||
return {
|
return {
|
||||||
anchor_daily_payout_cap: 6_000_000,
|
anchor_daily_payout_cap: 6_000_000,
|
||||||
anchor_rate_percent: 1,
|
anchor_rate_percent: 10,
|
||||||
app_code: "lalu",
|
app_code: "lalu",
|
||||||
control_band_percent: 3,
|
control_band_percent: 3,
|
||||||
device_daily_payout_cap: 4_000_000,
|
device_daily_payout_cap: 4_000_000,
|
||||||
@ -338,16 +342,16 @@ function dynamicConfig(overrides = {}) {
|
|||||||
normal_max_equivalent_draws: 20_000,
|
normal_max_equivalent_draws: 20_000,
|
||||||
novice_max_equivalent_draws: 2_000,
|
novice_max_equivalent_draws: 2_000,
|
||||||
pool_id: "lucky",
|
pool_id: "lucky",
|
||||||
pool_rate_percent: 98,
|
pool_rate_percent: 89,
|
||||||
profit_rate_percent: 1,
|
profit_rate_percent: 1,
|
||||||
recharge_boost_factor_percent: 110,
|
recharge_boost_factor_percent: 110,
|
||||||
recharge_boost_window_ms: 300_000,
|
recharge_boost_window_ms: 300_000,
|
||||||
room_hourly_payout_cap: 5_000_000,
|
room_hourly_payout_cap: 5_000_000,
|
||||||
rule_version: 3,
|
rule_version: 3,
|
||||||
settlement_window_wager: 1_000_000,
|
settlement_window_wager: 1_000_000,
|
||||||
stages: stagesFor(98),
|
stages: stagesFor(89),
|
||||||
strategy_version: "dynamic_v3",
|
strategy_version: "dynamic_v3",
|
||||||
target_rtp_percent: 98,
|
target_rtp_percent: 89,
|
||||||
user_daily_payout_cap: 3_000_000,
|
user_daily_payout_cap: 3_000_000,
|
||||||
user_hourly_payout_cap: 2_000_000,
|
user_hourly_payout_cap: 2_000_000,
|
||||||
...overrides,
|
...overrides,
|
||||||
|
|||||||
@ -16,7 +16,14 @@ const DYNAMIC_SECTIONS = [
|
|||||||
["limits", "限额"],
|
["limits", "限额"],
|
||||||
];
|
];
|
||||||
|
|
||||||
export function LuckyGiftConfigSections({ appOptions, experimentMode = false, form, isCreate, onChange }) {
|
export function LuckyGiftConfigSections({
|
||||||
|
appOptions,
|
||||||
|
experimentMode = false,
|
||||||
|
form,
|
||||||
|
hostDiamondRatioAvailable = true,
|
||||||
|
isCreate,
|
||||||
|
onChange,
|
||||||
|
}) {
|
||||||
const scrollRef = useRef(null);
|
const scrollRef = useRef(null);
|
||||||
const sections = useMemo(
|
const sections = useMemo(
|
||||||
() => (form.strategy_version === "dynamic_v3" ? [...BASE_SECTIONS, ...DYNAMIC_SECTIONS] : BASE_SECTIONS),
|
() => (form.strategy_version === "dynamic_v3" ? [...BASE_SECTIONS, ...DYNAMIC_SECTIONS] : BASE_SECTIONS),
|
||||||
@ -290,9 +297,9 @@ export function LuckyGiftConfigSections({ appOptions, experimentMode = false, fo
|
|||||||
</div>
|
</div>
|
||||||
{form.strategy_version === "dynamic_v3" ? (
|
{form.strategy_version === "dynamic_v3" ? (
|
||||||
<p className="ops-field-note">
|
<p className="ops-field-note">
|
||||||
收礼返币比例由礼物返币配置管理,本页不再提供修改。当前规则使用值为
|
{hostDiamondRatioAvailable
|
||||||
{` ${formatCompactNumber(Number(form.anchor_rate_percent || 0))}%`}
|
? `主播钻石比例由礼物配置读取,当前为 ${formatCompactNumber(Number(form.anchor_rate_percent || 0))}%;本页不提供修改。`
|
||||||
;发布前需与实际返币比例一致,否则 V3 真实开奖会拒绝账务结算。
|
: "未读取到当前应用的主播钻石比例,刷新成功前不能发布 V3 配置。"}
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
|||||||
123
ops-center/src/components/LuckyGiftRollbackDialog.jsx
Normal file
123
ops-center/src/components/LuckyGiftRollbackDialog.jsx
Normal file
@ -0,0 +1,123 @@
|
|||||||
|
import Dialog from "@mui/material/Dialog";
|
||||||
|
import DialogActions from "@mui/material/DialogActions";
|
||||||
|
import DialogContent from "@mui/material/DialogContent";
|
||||||
|
import DialogTitle from "@mui/material/DialogTitle";
|
||||||
|
import Radio from "@mui/material/Radio";
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { Button } from "@/shared/ui/Button.jsx";
|
||||||
|
import { DataState } from "@/shared/ui/DataState.jsx";
|
||||||
|
import { TimeText } from "@/shared/ui/TimeText.jsx";
|
||||||
|
import { formatPercent } from "../format.js";
|
||||||
|
import { OpsStatusBadge } from "./OpsStatusBadge.jsx";
|
||||||
|
|
||||||
|
export function LuckyGiftRollbackDialog({
|
||||||
|
config,
|
||||||
|
error,
|
||||||
|
loading,
|
||||||
|
onClose,
|
||||||
|
onRetry,
|
||||||
|
onSubmit,
|
||||||
|
submitting,
|
||||||
|
versions,
|
||||||
|
}) {
|
||||||
|
const candidates = useMemo(
|
||||||
|
() =>
|
||||||
|
(Array.isArray(versions) ? versions : []).filter(
|
||||||
|
(item) => Number(item.rule_version) > 0 && Number(item.rule_version) < Number(config.rule_version),
|
||||||
|
),
|
||||||
|
[config.rule_version, versions],
|
||||||
|
);
|
||||||
|
const [selectedVersion, setSelectedVersion] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// 历史接口按版本倒序返回,默认选择上一版能减少误操作跨度;若刷新后该版本不存在则重新收敛。
|
||||||
|
setSelectedVersion((current) =>
|
||||||
|
candidates.some((item) => Number(item.rule_version) === current)
|
||||||
|
? current
|
||||||
|
: Number(candidates[0]?.rule_version || 0),
|
||||||
|
);
|
||||||
|
}, [candidates]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
fullWidth
|
||||||
|
maxWidth="lg"
|
||||||
|
open
|
||||||
|
aria-labelledby="lucky-gift-rollback-title"
|
||||||
|
onClose={submitting ? undefined : onClose}
|
||||||
|
>
|
||||||
|
<DialogTitle id="lucky-gift-rollback-title">
|
||||||
|
回滚幸运礼物版本
|
||||||
|
<small>
|
||||||
|
{config.app_code} / {config.pool_id} / 当前 v{config.rule_version}
|
||||||
|
</small>
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogContent dividers className="ops-rollback-content">
|
||||||
|
<div className="ops-rollback-warning" role="note">
|
||||||
|
回滚会复制所选历史快照并发布为一个新版本,历史版本不会被修改。V2 与 V3 的奖池水位保持独立,切换策略不会搬移、清空或重新注资。
|
||||||
|
</div>
|
||||||
|
<DataState error={error} loading={loading} onRetry={onRetry} reportPageLoading={false}>
|
||||||
|
{candidates.length ? (
|
||||||
|
<div className="ops-rollback-table" role="radiogroup" aria-label="可回滚版本">
|
||||||
|
<div className="ops-rollback-row ops-rollback-head" aria-hidden="true">
|
||||||
|
<span />
|
||||||
|
<span>版本</span>
|
||||||
|
<span>策略</span>
|
||||||
|
<span>状态</span>
|
||||||
|
<span>目标 RTP</span>
|
||||||
|
<span>进入奖池</span>
|
||||||
|
<span>平台 / 主播</span>
|
||||||
|
<span>发布时间</span>
|
||||||
|
</div>
|
||||||
|
{candidates.map((item) => {
|
||||||
|
const version = Number(item.rule_version);
|
||||||
|
const selected = selectedVersion === version;
|
||||||
|
return (
|
||||||
|
<label className={selected ? "ops-rollback-row is-selected" : "ops-rollback-row"} key={version}>
|
||||||
|
<Radio
|
||||||
|
checked={selected}
|
||||||
|
disabled={submitting}
|
||||||
|
inputProps={{ "aria-label": `选择版本 v${version}` }}
|
||||||
|
size="small"
|
||||||
|
value={version}
|
||||||
|
onChange={() => setSelectedVersion(version)}
|
||||||
|
/>
|
||||||
|
<strong>v{version}</strong>
|
||||||
|
<span>{item.strategy_version || "fixed_v2"}</span>
|
||||||
|
<span>
|
||||||
|
{item.enabled ? (
|
||||||
|
<OpsStatusBadge label="已启用" tone="succeeded" />
|
||||||
|
) : (
|
||||||
|
<OpsStatusBadge label="已停用" />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span>{formatPercent(item.target_rtp_percent)}</span>
|
||||||
|
<span>{formatPercent(item.pool_rate_percent)}</span>
|
||||||
|
<span>
|
||||||
|
{formatPercent(item.profit_rate_percent)} / {formatPercent(item.anchor_rate_percent)}
|
||||||
|
</span>
|
||||||
|
<TimeText value={item.created_at_ms} />
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="ops-rollback-empty">当前奖池还没有可回滚的历史版本。</div>
|
||||||
|
)}
|
||||||
|
</DataState>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button disabled={submitting} onClick={onClose}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
disabled={submitting || loading || Boolean(error) || selectedVersion <= 0}
|
||||||
|
variant="danger"
|
||||||
|
onClick={() => onSubmit(selectedVersion)}
|
||||||
|
>
|
||||||
|
{selectedVersion > 0 ? `确认回滚到 v${selectedVersion}` : "确认回滚"}
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -247,9 +247,40 @@ export function formToConfig(config, form) {
|
|||||||
return output;
|
return output;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function giftTypeForLuckyGiftPool(poolID) {
|
||||||
|
// room/wallet 把任意非空幸运奖池归入 lucky,只有 super_lucky 家族使用超级幸运礼物比例;
|
||||||
|
// 测试池和外部自定义池因此沿用普通幸运礼物口径,不要求运营额外维护一份映射。
|
||||||
|
return String(poolID || "")
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.includes("super_lucky")
|
||||||
|
? "super_lucky"
|
||||||
|
: "lucky";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function giftHostDiamondRatioForPool(ratios, poolID) {
|
||||||
|
const value = Number(ratios?.[giftTypeForLuckyGiftPool(poolID)]);
|
||||||
|
return Number.isFinite(value) && value >= 0 && value <= 100 ? value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyGiftHostDiamondRatio(form, ratio, { rebalance = false } = {}) {
|
||||||
|
if (!Number.isFinite(ratio) || ratio < 0 || ratio > 100) return form;
|
||||||
|
const next = { ...form, anchor_rate_percent: formNumber(ratio) };
|
||||||
|
if (!rebalance || next.strategy_version !== DYNAMIC_STRATEGY) return next;
|
||||||
|
|
||||||
|
// anchor_rate_percent 是历史协议名;V3 实际使用 wallet 的主播钻石事实,不读取收礼返币。
|
||||||
|
// 新奖池只把可配置的平台比例和接口返回的主播钻石预留扣除,剩余资金进入公共奖池。
|
||||||
|
const availablePoolRate = roundedPercent(100 - numberFromForm(next.profit_rate_percent) - ratio);
|
||||||
|
next.pool_rate_percent = formNumber(Math.max(availablePoolRate, 0));
|
||||||
|
next.target_rtp_percent = formNumber(
|
||||||
|
Math.min(numberFromForm(next.target_rtp_percent), Math.max(availablePoolRate, 0)),
|
||||||
|
);
|
||||||
|
return correctAllStageProbabilities(next);
|
||||||
|
}
|
||||||
|
|
||||||
// 从历史 fixed_v2 显式切换到 dynamic_v3 时,先停用并载入安全草稿;金额型上限仍保持 0,
|
// 从历史 fixed_v2 显式切换到 dynamic_v3 时,先停用并载入安全草稿;金额型上限仍保持 0,
|
||||||
// 运营补齐后才能通过启用校验,避免一次策略切换直接发布无法结算的规则。
|
// 运营补齐后才能通过启用校验,避免一次策略切换直接发布无法结算的规则。
|
||||||
export function applyDynamicStrategyDefaults(form) {
|
export function applyDynamicStrategyDefaults(form, { anchorRatePercent = null } = {}) {
|
||||||
const next = {
|
const next = {
|
||||||
...form,
|
...form,
|
||||||
enabled: false,
|
enabled: false,
|
||||||
@ -261,15 +292,24 @@ export function applyDynamicStrategyDefaults(form) {
|
|||||||
next[field] = String(value);
|
next[field] = String(value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (Number.isFinite(anchorRatePercent) && anchorRatePercent >= 0 && anchorRatePercent <= 100) {
|
||||||
|
next.anchor_rate_percent = formNumber(anchorRatePercent);
|
||||||
|
const availablePoolRate = Math.max(
|
||||||
|
roundedPercent(100 - numberFromForm(next.profit_rate_percent) - anchorRatePercent),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
next.pool_rate_percent = formNumber(availablePoolRate);
|
||||||
|
next.target_rtp_percent = formNumber(Math.min(dynamicDefaults.target_rtp_percent, availablePoolRate));
|
||||||
|
}
|
||||||
next.stages = stageOptions.map(([stage]) => {
|
next.stages = stageOptions.map(([stage]) => {
|
||||||
const [min7d, min30d] = defaultRechargeThreshold(stage);
|
const [min7d, min30d] = defaultRechargeThreshold(stage);
|
||||||
return {
|
return {
|
||||||
min_recharge_7d_coins: String(min7d),
|
min_recharge_7d_coins: String(min7d),
|
||||||
min_recharge_30d_coins: String(min30d),
|
min_recharge_30d_coins: String(min30d),
|
||||||
stage,
|
stage,
|
||||||
// fixed_v2 可能含历史高倍基础档;切换策略时必须回到 owner 的 98% 安全基线,
|
// fixed_v2 可能含历史高倍基础档;切换策略时必须按当前资金可用 RTP 重建安全基线,
|
||||||
// 不能只改策略名后把旧概率意外带入 dynamic_v3 的第一版。
|
// 不能只改策略名后把旧概率意外带入 dynamic_v3 的第一版。
|
||||||
tiers: correctedDefaultTiers(stage, dynamicDefaults.target_rtp_percent),
|
tiers: correctedDefaultTiers(stage, numberFromForm(next.target_rtp_percent)),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
return next;
|
return next;
|
||||||
@ -371,8 +411,7 @@ export function validateLuckyGiftForm(form) {
|
|||||||
errors.push("关闭 V2 高水位加权时,加权系数与恢复水位必须为 0");
|
errors.push("关闭 V2 高水位加权时,加权系数与恢复水位必须为 0");
|
||||||
} else {
|
} else {
|
||||||
if (boostThreshold < 0) errors.push("V2 高水位加权阈值不能小于 0");
|
if (boostThreshold < 0) errors.push("V2 高水位加权阈值不能小于 0");
|
||||||
if (boostFactor <= 100 || boostFactor > 1000)
|
if (boostFactor <= 100 || boostFactor > 1000) errors.push("V2 高水位加权系数必须大于 100% 且不超过 1000%");
|
||||||
errors.push("V2 高水位加权系数必须大于 100% 且不超过 1000%");
|
|
||||||
if (boostRecover <= 0 || boostRecover > boostThreshold)
|
if (boostRecover <= 0 || boostRecover > boostThreshold)
|
||||||
errors.push("V2 加权恢复水位必须大于 0 且不高于加权阈值");
|
errors.push("V2 加权恢复水位必须大于 0 且不高于加权阈值");
|
||||||
}
|
}
|
||||||
@ -380,14 +419,14 @@ export function validateLuckyGiftForm(form) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (number("profit_rate_percent") < 0 || number("anchor_rate_percent") < 0)
|
if (number("profit_rate_percent") < 0 || number("anchor_rate_percent") < 0)
|
||||||
errors.push("平台盈利和主播收益比例不能小于 0");
|
errors.push("平台比例和主播钻石比例不能小于 0");
|
||||||
if (
|
if (
|
||||||
percentToPPM(number("pool_rate_percent")) +
|
percentToPPM(number("pool_rate_percent")) +
|
||||||
percentToPPM(number("profit_rate_percent")) +
|
percentToPPM(number("profit_rate_percent")) +
|
||||||
percentToPPM(number("anchor_rate_percent")) !==
|
percentToPPM(number("anchor_rate_percent")) !==
|
||||||
PPM_SCALE
|
PPM_SCALE
|
||||||
)
|
)
|
||||||
errors.push("奖池、平台盈利和主播收益比例合计必须等于 100%");
|
errors.push("奖池、平台和主播钻石比例合计必须等于 100%");
|
||||||
if (number("initial_pool_coins") !== 0) errors.push("dynamic_v3 初始奖池必须为 0,请通过水位操作注资");
|
if (number("initial_pool_coins") !== 0) errors.push("dynamic_v3 初始奖池必须为 0,请通过水位操作注资");
|
||||||
if (number("loss_streak_guarantee") <= 0) errors.push("连续未中奖保护次数必须大于 0");
|
if (number("loss_streak_guarantee") <= 0) errors.push("连续未中奖保护次数必须大于 0");
|
||||||
if (number("low_watermark_coins") <= 0 || number("high_watermark_coins") <= number("low_watermark_coins"))
|
if (number("low_watermark_coins") <= 0 || number("high_watermark_coins") <= number("low_watermark_coins"))
|
||||||
@ -626,6 +665,10 @@ function percentToPPM(value) {
|
|||||||
return Math.round(Number(value || 0) * 10_000);
|
return Math.round(Number(value || 0) * 10_000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function roundedPercent(value) {
|
||||||
|
return Math.round(Number(value || 0) * 10_000) / 10_000;
|
||||||
|
}
|
||||||
|
|
||||||
function unique(values) {
|
function unique(values) {
|
||||||
return Array.from(new Set(values));
|
return Array.from(new Set(values));
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,12 @@
|
|||||||
import { describe, expect, test } from "vitest";
|
import { describe, expect, test } from "vitest";
|
||||||
import { applyDynamicStrategyDefaults, configToForm, formToConfig, validateLuckyGiftForm } from "./configForm.js";
|
import {
|
||||||
|
applyDynamicStrategyDefaults,
|
||||||
|
applyGiftHostDiamondRatio,
|
||||||
|
configToForm,
|
||||||
|
formToConfig,
|
||||||
|
giftHostDiamondRatioForPool,
|
||||||
|
validateLuckyGiftForm,
|
||||||
|
} from "./configForm.js";
|
||||||
|
|
||||||
describe("ops center lucky gift dynamic_v3 form", () => {
|
describe("ops center lucky gift dynamic_v3 form", () => {
|
||||||
test("loads safe dynamic defaults without inventing app-specific monetary caps", () => {
|
test("loads safe dynamic defaults without inventing app-specific monetary caps", () => {
|
||||||
@ -112,27 +119,47 @@ describe("ops center lucky gift dynamic_v3 form", () => {
|
|||||||
strategy_version: "fixed_v2",
|
strategy_version: "fixed_v2",
|
||||||
target_rtp_percent: 95,
|
target_rtp_percent: 95,
|
||||||
});
|
});
|
||||||
const dynamic = applyDynamicStrategyDefaults(legacy);
|
const dynamic = applyDynamicStrategyDefaults(legacy, { anchorRatePercent: 10 });
|
||||||
|
|
||||||
expect(dynamic).toMatchObject({
|
expect(dynamic).toMatchObject({
|
||||||
|
anchor_rate_percent: "10",
|
||||||
enabled: false,
|
enabled: false,
|
||||||
initial_pool_coins: "0",
|
initial_pool_coins: "0",
|
||||||
pool_rate_percent: "98",
|
pool_rate_percent: "89",
|
||||||
strategy_version: "dynamic_v3",
|
strategy_version: "dynamic_v3",
|
||||||
|
target_rtp_percent: "89",
|
||||||
});
|
});
|
||||||
expect(dynamic.stages.map((stage) => stage.tiers.map((tier) => Number(tier.multiplier)))).toEqual([
|
expect(dynamic.stages.map((stage) => stage.tiers.map((tier) => Number(tier.multiplier)))).toEqual([
|
||||||
[0, 0.5, 1, 2],
|
[0, 0.5, 1, 2],
|
||||||
[0, 0.5, 1, 2],
|
[0, 0.5, 1, 2],
|
||||||
[0, 0.5, 1, 2],
|
[0, 0.5, 1, 2],
|
||||||
]);
|
]);
|
||||||
expect(dynamic.stages.map((stage) => stage.tiers.map((tier) => Number(tier.probabilityPercent)))).toEqual([
|
expect(
|
||||||
[5, 4, 86, 5],
|
dynamic.stages.every(
|
||||||
[5, 4, 86, 5],
|
(stage) => stage.tiers.reduce((sum, tier) => sum + Number(tier.probabilityPercent), 0) === 100,
|
||||||
[5, 4, 86, 5],
|
),
|
||||||
]);
|
).toBe(true);
|
||||||
expect(validateLuckyGiftForm(dynamic)).toEqual([]);
|
expect(validateLuckyGiftForm(dynamic)).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("uses the wallet host diamond ratio and ignores the return-coin ratio", () => {
|
||||||
|
const ratios = { lucky: 10, super_lucky: 1 };
|
||||||
|
expect(giftHostDiamondRatioForPool(ratios, "lucky")).toBe(10);
|
||||||
|
expect(giftHostDiamondRatioForPool(ratios, "campaign_super_lucky_v2")).toBe(1);
|
||||||
|
|
||||||
|
const existing = configToForm({
|
||||||
|
...completeDynamicConfig(),
|
||||||
|
anchor_rate_percent: 1,
|
||||||
|
pool_id: "lucky",
|
||||||
|
pool_rate_percent: 89,
|
||||||
|
target_rtp_percent: 89,
|
||||||
|
stages: targetStages(89),
|
||||||
|
});
|
||||||
|
const synced = applyGiftHostDiamondRatio(existing, ratios.lucky);
|
||||||
|
expect(synced).toMatchObject({ anchor_rate_percent: "10", pool_rate_percent: "89" });
|
||||||
|
expect(validateLuckyGiftForm(synced)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
test("rejects invalid disabled draft values that the owner never accepts", () => {
|
test("rejects invalid disabled draft values that the owner never accepts", () => {
|
||||||
const draft = configToForm({ app_code: "lalu", pool_id: "default", strategy_version: "dynamic_v3" });
|
const draft = configToForm({ app_code: "lalu", pool_id: "default", strategy_version: "dynamic_v3" });
|
||||||
const errors = validateLuckyGiftForm({
|
const errors = validateLuckyGiftForm({
|
||||||
@ -146,7 +173,7 @@ describe("ops center lucky gift dynamic_v3 form", () => {
|
|||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(errors).toContain("平台盈利和主播收益比例不能小于 0");
|
expect(errors).toContain("平台比例和主播钻石比例不能小于 0");
|
||||||
expect(errors).toContain("大奖消费门槛不能小于 0");
|
expect(errors).toContain("大奖消费门槛不能小于 0");
|
||||||
expect(errors).toContain("单次返奖上限不能小于 0");
|
expect(errors).toContain("单次返奖上限不能小于 0");
|
||||||
expect(errors).toContain("充值阶段门槛不能小于 0");
|
expect(errors).toContain("充值阶段门槛不能小于 0");
|
||||||
@ -272,6 +299,23 @@ function dynamicStage(stage, min7d, min30d) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function targetStages(targetRTP) {
|
||||||
|
return [
|
||||||
|
targetStage("novice", 0, 0, targetRTP),
|
||||||
|
targetStage("normal", 10_000, 20_000, targetRTP),
|
||||||
|
targetStage("advanced", 20_000, 30_000, targetRTP),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function targetStage(stage, min7d, min30d, targetRTP) {
|
||||||
|
return {
|
||||||
|
min_recharge_30d_coins: min30d,
|
||||||
|
min_recharge_7d_coins: min7d,
|
||||||
|
stage,
|
||||||
|
tiers: [tier(`${stage}_none`, 0, 100 - targetRTP), tier(`${stage}_1x`, 1, targetRTP)],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function fixedStages() {
|
function fixedStages() {
|
||||||
return ["novice", "normal", "advanced"].map((stage) => ({
|
return ["novice", "normal", "advanced"].map((stage) => ({
|
||||||
stage,
|
stage,
|
||||||
|
|||||||
@ -184,6 +184,72 @@ body {
|
|||||||
padding: var(--space-3);
|
padding: var(--space-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ops-rollback-content {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-4);
|
||||||
|
min-height: 320px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#lucky-gift-rollback-title small {
|
||||||
|
display: block;
|
||||||
|
margin-top: var(--space-1);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-rollback-warning {
|
||||||
|
border: 1px solid var(--warning-border);
|
||||||
|
border-radius: var(--radius-control);
|
||||||
|
background: var(--warning-surface);
|
||||||
|
color: var(--warning);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.6;
|
||||||
|
padding: var(--space-3) var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-rollback-table {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-control);
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-rollback-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 48px 70px 120px 112px 100px 100px 144px minmax(176px, 1fr);
|
||||||
|
align-items: center;
|
||||||
|
min-width: 940px;
|
||||||
|
min-height: 58px;
|
||||||
|
border-bottom: 1px solid var(--row-border);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0 var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-rollback-row:last-child {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-rollback-row.is-selected {
|
||||||
|
background: var(--primary-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-rollback-head {
|
||||||
|
min-height: 44px;
|
||||||
|
background: var(--bg-card-strong);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: default;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-rollback-empty {
|
||||||
|
display: grid;
|
||||||
|
min-height: 180px;
|
||||||
|
place-items: center;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
/* ---- 幸运礼物配置弹窗 ---- */
|
/* ---- 幸运礼物配置弹窗 ---- */
|
||||||
|
|
||||||
.MuiDialog-paper.ops-config-dialog-paper {
|
.MuiDialog-paper.ops-config-dialog-paper {
|
||||||
|
|||||||
@ -107,6 +107,8 @@ export const resourceTypeFilters = [
|
|||||||
["gift", "礼物"],
|
["gift", "礼物"],
|
||||||
["mic_seat_icon", "麦位图标"],
|
["mic_seat_icon", "麦位图标"],
|
||||||
["mic_seat_animation", "麦位动效"],
|
["mic_seat_animation", "麦位动效"],
|
||||||
|
["room_border", "房间边框"],
|
||||||
|
["room_name_style", "房间名称样式"],
|
||||||
["emoji_pack", "表情包"],
|
["emoji_pack", "表情包"],
|
||||||
["vip_trial_card", "VIP体验卡"],
|
["vip_trial_card", "VIP体验卡"],
|
||||||
];
|
];
|
||||||
@ -134,9 +136,7 @@ export const resourceShopTypeFilters = [
|
|||||||
...resourceTypeFilters.filter(([value]) => resourceShopSellableTypes.includes(value)),
|
...resourceTypeFilters.filter(([value]) => resourceShopSellableTypes.includes(value)),
|
||||||
];
|
];
|
||||||
|
|
||||||
export const resourceGroupAssetOptions = [
|
export const resourceGroupAssetOptions = [["COIN", "金币"]];
|
||||||
["COIN", "金币"],
|
|
||||||
];
|
|
||||||
|
|
||||||
export const resourceGroupAssetLabels = Object.fromEntries(resourceGroupAssetOptions);
|
export const resourceGroupAssetLabels = Object.fromEntries(resourceGroupAssetOptions);
|
||||||
|
|
||||||
|
|||||||
@ -53,7 +53,14 @@ import {
|
|||||||
resourceShopSellableTypes,
|
resourceShopSellableTypes,
|
||||||
} from "@/features/resources/constants.js";
|
} from "@/features/resources/constants.js";
|
||||||
import { useResourceAbilities } from "@/features/resources/permissions.js";
|
import { useResourceAbilities } from "@/features/resources/permissions.js";
|
||||||
import { resourceMetadataJSON } from "@/features/resources/resourceMetadata.js";
|
import {
|
||||||
|
resourceMetadataJSON,
|
||||||
|
resourceRequiresExplicitVisualFormat,
|
||||||
|
roomBorderFormatFromMetadata,
|
||||||
|
roomBorderFormatMetadataJSON,
|
||||||
|
roomNameStyleFormFromMetadata,
|
||||||
|
roomNameStyleMetadataJSON,
|
||||||
|
} from "@/features/resources/resourceMetadata.js";
|
||||||
import {
|
import {
|
||||||
emojiPackFormSchema,
|
emojiPackFormSchema,
|
||||||
giftFormSchema,
|
giftFormSchema,
|
||||||
@ -105,6 +112,8 @@ const emptyResourceForm = (resource = {}) => ({
|
|||||||
priceType: resource.resourceId ? resource.priceType || (resource.coinPrice > 0 ? "coin" : "free") : "coin",
|
priceType: resource.resourceId ? resource.priceType || (resource.coinPrice > 0 ? "coin" : "free") : "coin",
|
||||||
resourceCode: resource.resourceCode || "",
|
resourceCode: resource.resourceCode || "",
|
||||||
resourceType: resource.resourceType || "avatar_frame",
|
resourceType: resource.resourceType || "avatar_frame",
|
||||||
|
roomBorderFormat: roomBorderFormatFromMetadata(resource.metadataJson),
|
||||||
|
...roomNameStyleFormFromMetadata(resource.metadataJson),
|
||||||
walletAssetAmount: resource.walletAssetAmount ? String(resource.walletAssetAmount) : "",
|
walletAssetAmount: resource.walletAssetAmount ? String(resource.walletAssetAmount) : "",
|
||||||
});
|
});
|
||||||
const emptyEmojiPackForm = () => ({
|
const emptyEmojiPackForm = () => ({
|
||||||
@ -189,9 +198,7 @@ export function applyGiftResourceSelection(form, resource, fillIdentity = true)
|
|||||||
export function buildGiftResourceDrafts(form, resourceIds = [], resources = []) {
|
export function buildGiftResourceDrafts(form, resourceIds = [], resources = []) {
|
||||||
const resourcesById = new Map(resources.map((resource) => [String(resource.resourceId), resource]));
|
const resourcesById = new Map(resources.map((resource) => [String(resource.resourceId), resource]));
|
||||||
const existingDraftsByResourceId = new Map(
|
const existingDraftsByResourceId = new Map(
|
||||||
(form.items || [])
|
(form.items || []).filter((item) => item?.resourceId).map((item) => [String(item.resourceId), item]),
|
||||||
.filter((item) => item?.resourceId)
|
|
||||||
.map((item) => [String(item.resourceId), item]),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return resourceIds
|
return resourceIds
|
||||||
@ -681,7 +688,10 @@ export function useResourceListPage() {
|
|||||||
deletedResourceIds.push(targetResources[0].resourceId);
|
deletedResourceIds.push(targetResources[0].resourceId);
|
||||||
}
|
}
|
||||||
hideResourceRows(deletedResourceIds);
|
hideResourceRows(deletedResourceIds);
|
||||||
showToast(targetResources.length > 1 ? `已删除 ${deletedResourceIds.length} 个资源` : "资源已删除", "success");
|
showToast(
|
||||||
|
targetResources.length > 1 ? `已删除 ${deletedResourceIds.length} 个资源` : "资源已删除",
|
||||||
|
"success",
|
||||||
|
);
|
||||||
return deletedResourceIds;
|
return deletedResourceIds;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showToast(err.message || "删除资源失败", "error");
|
showToast(err.message || "删除资源失败", "error");
|
||||||
@ -1667,7 +1677,10 @@ export function useGiftListPage() {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
shouldReload = true;
|
shouldReload = true;
|
||||||
showToast(`已创建 ${createdDrafts.length} 个礼物,剩余礼物创建失败:${err.message || "操作失败"}`, "error");
|
showToast(
|
||||||
|
`已创建 ${createdDrafts.length} 个礼物,剩余礼物创建失败:${err.message || "操作失败"}`,
|
||||||
|
"error",
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
showToast(err.message || "操作失败", "error");
|
showToast(err.message || "操作失败", "error");
|
||||||
}
|
}
|
||||||
@ -2019,10 +2032,10 @@ export function useResourceGrantListPage() {
|
|||||||
}
|
}
|
||||||
if (abilities.canGrantVip) {
|
if (abilities.canGrantVip) {
|
||||||
optionRequests.push(
|
optionRequests.push(
|
||||||
Promise.all([
|
Promise.all([getVipProgram(), getVipConfig()]).then(([program, config]) => ({
|
||||||
getVipProgram(),
|
data: { config, program },
|
||||||
getVipConfig(),
|
type: "vip",
|
||||||
]).then(([program, config]) => ({ data: { config, program }, type: "vip" })),
|
})),
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
setVipProgram({ programType: "legacy_timed", trialCardEnabled: false });
|
setVipProgram({ programType: "legacy_timed", trialCardEnabled: false });
|
||||||
@ -2138,12 +2151,7 @@ export function useResourceGrantListPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const revokeGrant = async (grant) => {
|
const revokeGrant = async (grant) => {
|
||||||
if (
|
if (!abilities.canRevokeGrant || !grant?.grantId || grant.status !== "succeeded" || !isRevocableGrant(grant)) {
|
||||||
!abilities.canRevokeGrant ||
|
|
||||||
!grant?.grantId ||
|
|
||||||
grant.status !== "succeeded" ||
|
|
||||||
!isRevocableGrant(grant)
|
|
||||||
) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const accepted = await confirm({
|
const accepted = await confirm({
|
||||||
@ -2231,6 +2239,13 @@ function buildResourcePayload(form) {
|
|||||||
const isLevelAvatarFrame = resourceType === "avatar_frame" && form.avatarFrameKind === "level";
|
const isLevelAvatarFrame = resourceType === "avatar_frame" && form.avatarFrameKind === "level";
|
||||||
const isLevelBadge = resourceType === "badge" && form.badgeKind === "level";
|
const isLevelBadge = resourceType === "badge" && form.badgeKind === "level";
|
||||||
const animationUrl = form.animationUrl.trim();
|
const animationUrl = form.animationUrl.trim();
|
||||||
|
let metadataJson = form.metadataJson;
|
||||||
|
if (resourceRequiresExplicitVisualFormat(resourceType)) {
|
||||||
|
metadataJson = roomBorderFormatMetadataJSON(metadataJson, form.roomBorderFormat);
|
||||||
|
}
|
||||||
|
if (resourceType === "room_name_style") {
|
||||||
|
metadataJson = roomNameStyleMetadataJSON(metadataJson, form);
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
amount: isCoin ? Number(form.walletAssetAmount) : 0,
|
amount: isCoin ? Number(form.walletAssetAmount) : 0,
|
||||||
animationUrl,
|
animationUrl,
|
||||||
@ -2241,7 +2256,7 @@ function buildResourcePayload(form) {
|
|||||||
coinPrice: form.priceType === "coin" ? Number(form.coinPrice) : 0,
|
coinPrice: form.priceType === "coin" ? Number(form.coinPrice) : 0,
|
||||||
levelTrack: isLevelBadge || isLevelAvatarFrame ? form.levelTrack : undefined,
|
levelTrack: isLevelBadge || isLevelAvatarFrame ? form.levelTrack : undefined,
|
||||||
managerGrantEnabled: Boolean(form.managerGrantEnabled),
|
managerGrantEnabled: Boolean(form.managerGrantEnabled),
|
||||||
metadataJson: resourceMetadataJSON(resourceType, form.metadataJson),
|
metadataJson: resourceMetadataJSON(resourceType, metadataJson),
|
||||||
name: form.name.trim(),
|
name: form.name.trim(),
|
||||||
previewUrl: form.previewUrl.trim(),
|
previewUrl: form.previewUrl.trim(),
|
||||||
priceType: form.priceType,
|
priceType: form.priceType,
|
||||||
@ -2604,12 +2619,18 @@ function isVipGrantRecord(grant) {
|
|||||||
}
|
}
|
||||||
const source = String(grant?.grantSource || "").trim();
|
const source = String(grant?.grantSource || "").trim();
|
||||||
const commandId = String(grant?.commandId || "").trim();
|
const commandId = String(grant?.commandId || "").trim();
|
||||||
return source === "admin_grant" || source === "activity_grant" || source === "vip_purchase" || commandId.startsWith("vip_reward:");
|
return (
|
||||||
|
source === "admin_grant" ||
|
||||||
|
source === "activity_grant" ||
|
||||||
|
source === "vip_purchase" ||
|
||||||
|
commandId.startsWith("vip_reward:")
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function isVipTrialCardRecord(grant) {
|
function isVipTrialCardRecord(grant) {
|
||||||
return (
|
return (
|
||||||
grant?.grantSubjectType === "vip_trial_card" || String(grant?.grantSource || "").trim() === "vip_trial_card_grant"
|
grant?.grantSubjectType === "vip_trial_card" ||
|
||||||
|
String(grant?.grantSource || "").trim() === "vip_trial_card_grant"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -82,6 +82,12 @@ import {
|
|||||||
import {
|
import {
|
||||||
metadataWithProfileCardLayout,
|
metadataWithProfileCardLayout,
|
||||||
removeProfileCardLayoutMetadata,
|
removeProfileCardLayoutMetadata,
|
||||||
|
resourceRequiresExplicitVisualFormat,
|
||||||
|
roomBorderFormatFromMetadata,
|
||||||
|
roomBorderFormatMetadataJSON,
|
||||||
|
roomBorderFormatOptions,
|
||||||
|
roomNameStyleFormFromMetadata,
|
||||||
|
roomNameStyleInputError,
|
||||||
} from "@/features/resources/resourceMetadata.js";
|
} from "@/features/resources/resourceMetadata.js";
|
||||||
import { uploadFilesBatch } from "@/shared/api/upload";
|
import { uploadFilesBatch } from "@/shared/api/upload";
|
||||||
import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
||||||
@ -89,6 +95,24 @@ import styles from "@/features/resources/resources.module.css";
|
|||||||
|
|
||||||
const resourceTypeOptions = resourceTypeFilters.filter(([value]) => value && value !== "emoji_pack");
|
const resourceTypeOptions = resourceTypeFilters.filter(([value]) => value && value !== "emoji_pack");
|
||||||
const emptyBatchProgress = { label: "", running: false, value: 0 };
|
const emptyBatchProgress = { label: "", running: false, value: 0 };
|
||||||
|
const roomBorderFormatLabels = {
|
||||||
|
gif: "GIF",
|
||||||
|
jpeg: "JPEG",
|
||||||
|
mp4: "MP4",
|
||||||
|
pag: "PAG",
|
||||||
|
png: "PNG",
|
||||||
|
svga: "SVGA",
|
||||||
|
webp: "WebP",
|
||||||
|
};
|
||||||
|
|
||||||
|
function visualResourceFormatFromFile(file) {
|
||||||
|
const filename = String(file?.name || "").toLowerCase();
|
||||||
|
let extension = filename.includes(".") ? filename.split(".").pop() : "";
|
||||||
|
if (extension === "jpg") {
|
||||||
|
extension = "jpeg";
|
||||||
|
}
|
||||||
|
return roomBorderFormatOptions.includes(extension) ? extension : "";
|
||||||
|
}
|
||||||
|
|
||||||
const baseColumns = [
|
const baseColumns = [
|
||||||
{
|
{
|
||||||
@ -216,7 +240,9 @@ export function ResourceListPage() {
|
|||||||
checked={selectedIdSet.has(resource.resourceId)}
|
checked={selectedIdSet.has(resource.resourceId)}
|
||||||
disabled={!canSelectResources || batchMp4Running || deletingResources}
|
disabled={!canSelectResources || batchMp4Running || deletingResources}
|
||||||
slotProps={{
|
slotProps={{
|
||||||
input: { "aria-label": `选择 ${resource.name || resource.resourceCode || resource.resourceId}` },
|
input: {
|
||||||
|
"aria-label": `选择 ${resource.name || resource.resourceCode || resource.resourceId}`,
|
||||||
|
},
|
||||||
}}
|
}}
|
||||||
size="small"
|
size="small"
|
||||||
onChange={(event) => toggleResourceSelection(resource, event.target.checked)}
|
onChange={(event) => toggleResourceSelection(resource, event.target.checked)}
|
||||||
@ -392,7 +418,9 @@ export function ResourceListPage() {
|
|||||||
}
|
}
|
||||||
updates.push({ layout: confirmedLayout, metadataJson, resource, resourceId: resource.resourceId });
|
updates.push({ layout: confirmedLayout, metadataJson, resource, resourceId: resource.resourceId });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
failures.push(`${resource.name || resource.resourceCode || resource.resourceId}: ${err.message || "解析失败"}`);
|
failures.push(
|
||||||
|
`${resource.name || resource.resourceCode || resource.resourceId}: ${err.message || "解析失败"}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (failures.length) {
|
if (failures.length) {
|
||||||
@ -898,7 +926,9 @@ function Mp4BatchResultDialog({ loading, onClose, onSubmit, open, result }) {
|
|||||||
</span>
|
</span>
|
||||||
<span className={styles.mp4ResultCode}>{resource?.resourceCode || "-"}</span>
|
<span className={styles.mp4ResultCode}>{resource?.resourceCode || "-"}</span>
|
||||||
</td>
|
</td>
|
||||||
<td>{resourceTypeLabels[resource?.resourceType] || resource?.resourceType || "-"}</td>
|
<td>
|
||||||
|
{resourceTypeLabels[resource?.resourceType] || resource?.resourceType || "-"}
|
||||||
|
</td>
|
||||||
<td>{mp4LayoutLabel(layout)}</td>
|
<td>{mp4LayoutLabel(layout)}</td>
|
||||||
<td>
|
<td>
|
||||||
{layout?.video_w || "-"}x{layout?.video_h || "-"}
|
{layout?.video_w || "-"}x{layout?.video_h || "-"}
|
||||||
@ -1013,7 +1043,11 @@ function ResourceFormDialog({ disabled, form, loading, mode, onClose, onSubmit,
|
|||||||
detectedProfileCardLayout = await detectProfileCardLayout(file);
|
detectedProfileCardLayout = await detectProfileCardLayout(file);
|
||||||
}
|
}
|
||||||
// 只返回当次素材的检测增量,上传成功后再合并到最新表单,避免异步旧快照覆盖用户新修改。
|
// 只返回当次素材的检测增量,上传成功后再合并到最新表单,避免异步旧快照覆盖用户新修改。
|
||||||
return { mp4Layout: detectedMp4Layout, profileCardLayout: detectedProfileCardLayout };
|
return {
|
||||||
|
mp4Layout: detectedMp4Layout,
|
||||||
|
profileCardLayout: detectedProfileCardLayout,
|
||||||
|
roomBorderFormat: visualResourceFormatFromFile(file),
|
||||||
|
};
|
||||||
};
|
};
|
||||||
const handleAnimationChange = (animationUrl, selectionResult) => {
|
const handleAnimationChange = (animationUrl, selectionResult) => {
|
||||||
setForm((previous) => {
|
setForm((previous) => {
|
||||||
@ -1021,6 +1055,9 @@ function ResourceFormDialog({ disabled, form, loading, mode, onClose, onSubmit,
|
|||||||
if (!animationUrl) {
|
if (!animationUrl) {
|
||||||
// 清除素材时,两类从该素材派生的布局元数据都必须同步清理。
|
// 清除素材时,两类从该素材派生的布局元数据都必须同步清理。
|
||||||
metadataJson = removeProfileCardLayoutMetadata(removeMp4AlphaLayoutMetadata(metadataJson));
|
metadataJson = removeProfileCardLayoutMetadata(removeMp4AlphaLayoutMetadata(metadataJson));
|
||||||
|
if (resourceRequiresExplicitVisualFormat(previous.resourceType)) {
|
||||||
|
metadataJson = roomBorderFormatMetadataJSON(metadataJson, "");
|
||||||
|
}
|
||||||
} else if (selectionResult) {
|
} else if (selectionResult) {
|
||||||
metadataJson = selectionResult.mp4Layout
|
metadataJson = selectionResult.mp4Layout
|
||||||
? mergeMp4AlphaLayoutMetadata(metadataJson, selectionResult.mp4Layout)
|
? mergeMp4AlphaLayoutMetadata(metadataJson, selectionResult.mp4Layout)
|
||||||
@ -1029,8 +1066,24 @@ function ResourceFormDialog({ disabled, form, loading, mode, onClose, onSubmit,
|
|||||||
previous.resourceType === "profile_card" && selectionResult.profileCardLayout
|
previous.resourceType === "profile_card" && selectionResult.profileCardLayout
|
||||||
? metadataWithProfileCardLayout(metadataJson, selectionResult.profileCardLayout)
|
? metadataWithProfileCardLayout(metadataJson, selectionResult.profileCardLayout)
|
||||||
: removeProfileCardLayoutMetadata(metadataJson);
|
: removeProfileCardLayoutMetadata(metadataJson);
|
||||||
|
if (resourceRequiresExplicitVisualFormat(previous.resourceType)) {
|
||||||
|
// 换素材时格式必须跟本次文件一起更新;未知扩展名清空并要求运营显式选择,
|
||||||
|
// 不能沿用上一份素材的格式把 PAG 误报成 SVGA。
|
||||||
|
metadataJson = roomBorderFormatMetadataJSON(metadataJson, selectionResult.roomBorderFormat);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return { ...previous, animationUrl, metadataJson };
|
return {
|
||||||
|
...previous,
|
||||||
|
animationUrl,
|
||||||
|
metadataJson,
|
||||||
|
roomBorderFormat: resourceRequiresExplicitVisualFormat(previous.resourceType)
|
||||||
|
? selectionResult
|
||||||
|
? selectionResult.roomBorderFormat
|
||||||
|
: animationUrl
|
||||||
|
? previous.roomBorderFormat
|
||||||
|
: ""
|
||||||
|
: previous.roomBorderFormat,
|
||||||
|
};
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
const handleSubmit = (event) => {
|
const handleSubmit = (event) => {
|
||||||
@ -1074,24 +1127,42 @@ function ResourceFormDialog({ disabled, form, loading, mode, onClose, onSubmit,
|
|||||||
value={form.resourceType}
|
value={form.resourceType}
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
const resourceType = event.target.value;
|
const resourceType = event.target.value;
|
||||||
setForm((previous) => ({
|
setForm((previous) => {
|
||||||
...previous,
|
const roomNameStyleFields =
|
||||||
avatarFrameKind:
|
resourceType === "room_name_style"
|
||||||
resourceType === "avatar_frame" ? previous.avatarFrameKind || "normal" : "normal",
|
? roomNameStyleFormFromMetadata(previous.metadataJson)
|
||||||
badgeForm: resourceType === "badge" ? previous.badgeForm || "tile" : previous.badgeForm,
|
: {
|
||||||
badgeKind: resourceType === "badge" ? previous.badgeKind || "normal" : "normal",
|
roomNameAngleDegrees: "",
|
||||||
levelTrack:
|
roomNameStops: "",
|
||||||
resourceType === "badge" ||
|
roomNameTextColors: "",
|
||||||
(resourceType === "avatar_frame" && previous.avatarFrameKind === "level")
|
};
|
||||||
? previous.levelTrack || ""
|
const roomBorderFormat = resourceRequiresExplicitVisualFormat(resourceType)
|
||||||
: "",
|
? roomBorderFormatFromMetadata(previous.metadataJson)
|
||||||
metadataJson:
|
: "";
|
||||||
resourceType === "profile_card"
|
return {
|
||||||
? previous.metadataJson
|
...previous,
|
||||||
: removeProfileCardLayoutMetadata(previous.metadataJson),
|
avatarFrameKind:
|
||||||
resourceType,
|
resourceType === "avatar_frame"
|
||||||
walletAssetAmount: "",
|
? previous.avatarFrameKind || "normal"
|
||||||
}));
|
: "normal",
|
||||||
|
badgeForm:
|
||||||
|
resourceType === "badge" ? previous.badgeForm || "tile" : previous.badgeForm,
|
||||||
|
badgeKind: resourceType === "badge" ? previous.badgeKind || "normal" : "normal",
|
||||||
|
levelTrack:
|
||||||
|
resourceType === "badge" ||
|
||||||
|
(resourceType === "avatar_frame" && previous.avatarFrameKind === "level")
|
||||||
|
? previous.levelTrack || ""
|
||||||
|
: "",
|
||||||
|
metadataJson:
|
||||||
|
resourceType === "profile_card"
|
||||||
|
? previous.metadataJson
|
||||||
|
: removeProfileCardLayoutMetadata(previous.metadataJson),
|
||||||
|
...roomNameStyleFields,
|
||||||
|
resourceType,
|
||||||
|
roomBorderFormat,
|
||||||
|
walletAssetAmount: "",
|
||||||
|
};
|
||||||
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{resourceTypeOptions.map(([value, label]) => (
|
{resourceTypeOptions.map(([value, label]) => (
|
||||||
@ -1239,12 +1310,41 @@ function ResourceFormDialog({ disabled, form, loading, mode, onClose, onSubmit,
|
|||||||
</AdminFormFieldGrid>
|
</AdminFormFieldGrid>
|
||||||
</AdminFormSection>
|
</AdminFormSection>
|
||||||
<AdminFormSection title="素材">
|
<AdminFormSection title="素材">
|
||||||
|
{form.resourceType === "room_name_style" ? (
|
||||||
|
<RoomNameStyleEditor disabled={disabled} form={form} setForm={setForm} />
|
||||||
|
) : null}
|
||||||
|
{resourceRequiresExplicitVisualFormat(form.resourceType) ? (
|
||||||
|
<AdminFormFieldGrid>
|
||||||
|
<TextField
|
||||||
|
disabled={disabled}
|
||||||
|
helperText="跟随资源快照返回,Flutter 不从 URL 后缀猜格式"
|
||||||
|
label={form.resourceType === "mic_seat_animation" ? "麦位动效素材格式" : "房间边框素材格式"}
|
||||||
|
required
|
||||||
|
select
|
||||||
|
value={form.roomBorderFormat}
|
||||||
|
onChange={(event) => {
|
||||||
|
const roomBorderFormat = event.target.value;
|
||||||
|
setForm((previous) => ({
|
||||||
|
...previous,
|
||||||
|
metadataJson: roomBorderFormatMetadataJSON(previous.metadataJson, roomBorderFormat),
|
||||||
|
roomBorderFormat,
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{roomBorderFormatOptions.map((format) => (
|
||||||
|
<MenuItem key={format} value={format}>
|
||||||
|
{roomBorderFormatLabels[format] || format}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
</AdminFormFieldGrid>
|
||||||
|
) : null}
|
||||||
<AdminFormFieldGrid columns="repeat(2, minmax(0, 1fr))">
|
<AdminFormFieldGrid columns="repeat(2, minmax(0, 1fr))">
|
||||||
<UploadField
|
<UploadField
|
||||||
key={open ? "resource-preview-open" : "resource-preview-closed"}
|
key={open ? "resource-preview-open" : "resource-preview-closed"}
|
||||||
disabled={disabled || uploadDisabled}
|
disabled={disabled || uploadDisabled}
|
||||||
kind="file"
|
kind="file"
|
||||||
label="资源封面"
|
label={form.resourceType === "room_name_style" ? "样式预览图(可选)" : "资源封面"}
|
||||||
showSourceActions
|
showSourceActions
|
||||||
value={form.previewUrl}
|
value={form.previewUrl}
|
||||||
onChange={(previewUrl) => setForm((previous) => ({ ...previous, previewUrl }))}
|
onChange={(previewUrl) => setForm((previous) => ({ ...previous, previewUrl }))}
|
||||||
@ -1254,7 +1354,7 @@ function ResourceFormDialog({ disabled, form, loading, mode, onClose, onSubmit,
|
|||||||
key={open ? "resource-animation-open" : "resource-animation-closed"}
|
key={open ? "resource-animation-open" : "resource-animation-closed"}
|
||||||
disabled={disabled || uploadDisabled}
|
disabled={disabled || uploadDisabled}
|
||||||
kind="file"
|
kind="file"
|
||||||
label="动效素材"
|
label={form.resourceType === "room_name_style" ? "样式动效(可选)" : "动效素材"}
|
||||||
mp4Layout={mp4Layout}
|
mp4Layout={mp4Layout}
|
||||||
showSourceActions
|
showSourceActions
|
||||||
value={form.animationUrl}
|
value={form.animationUrl}
|
||||||
@ -1301,6 +1401,38 @@ function ResourceFormDialog({ disabled, form, loading, mode, onClose, onSubmit,
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function RoomNameStyleEditor({ disabled, form, setForm }) {
|
||||||
|
const styleError = roomNameStyleInputError(form);
|
||||||
|
return (
|
||||||
|
<AdminFormFieldGrid>
|
||||||
|
<TextField
|
||||||
|
disabled={disabled}
|
||||||
|
error={Boolean(styleError)}
|
||||||
|
helperText={styleError || "支持 1 至 8 个 #RRGGBB / #RRGGBBAA,使用逗号分隔"}
|
||||||
|
label="房名渐变颜色"
|
||||||
|
required
|
||||||
|
value={form.roomNameTextColors}
|
||||||
|
onChange={(event) => setForm({ ...form, roomNameTextColors: event.target.value })}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
disabled={disabled}
|
||||||
|
helperText="可选;数量需与颜色一致,按 0 至 1 递增,例如 0, 0.5, 1"
|
||||||
|
label="渐变位置"
|
||||||
|
value={form.roomNameStops}
|
||||||
|
onChange={(event) => setForm({ ...form, roomNameStops: event.target.value })}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
disabled={disabled}
|
||||||
|
helperText="可选;Flutter 按服务端快照直接渲染"
|
||||||
|
label="渐变角度"
|
||||||
|
type="number"
|
||||||
|
value={form.roomNameAngleDegrees}
|
||||||
|
onChange={(event) => setForm({ ...form, roomNameAngleDegrees: event.target.value })}
|
||||||
|
/>
|
||||||
|
</AdminFormFieldGrid>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function Mp4AlphaLayoutEditor({ disabled, metadataJson, onChange }) {
|
function Mp4AlphaLayoutEditor({ disabled, metadataJson, onChange }) {
|
||||||
const layout = mp4AlphaLayoutFromMetadata(metadataJson);
|
const layout = mp4AlphaLayoutFromMetadata(metadataJson);
|
||||||
if (!layout) {
|
if (!layout) {
|
||||||
|
|||||||
@ -2,6 +2,12 @@ import { profileCardLayoutFromMetadata } from "@/features/resources/profileCardL
|
|||||||
import { mp4AlphaLayoutFromMetadata, mp4AlphaLayoutKey } from "@/features/resources/mp4AlphaLayout.js";
|
import { mp4AlphaLayoutFromMetadata, mp4AlphaLayoutKey } from "@/features/resources/mp4AlphaLayout.js";
|
||||||
|
|
||||||
export const emptyResourceMetadata = "{}";
|
export const emptyResourceMetadata = "{}";
|
||||||
|
const roomNameColorPattern = /^#[0-9a-fA-F]{6}([0-9a-fA-F]{2})?$/;
|
||||||
|
export const roomBorderFormatOptions = ["png", "jpeg", "webp", "gif", "svga", "pag", "mp4"];
|
||||||
|
|
||||||
|
export function resourceRequiresExplicitVisualFormat(resourceType) {
|
||||||
|
return resourceType === "mic_seat_animation" || resourceType === "room_border";
|
||||||
|
}
|
||||||
|
|
||||||
export function resourceMetadataJSON(resourceType, metadataJson) {
|
export function resourceMetadataJSON(resourceType, metadataJson) {
|
||||||
const metadata = {};
|
const metadata = {};
|
||||||
@ -14,9 +20,130 @@ export function resourceMetadataJSON(resourceType, metadataJson) {
|
|||||||
metadata.animation_format = "mp4";
|
metadata.animation_format = "mp4";
|
||||||
metadata[mp4AlphaLayoutKey] = mp4AlphaLayout;
|
metadata[mp4AlphaLayoutKey] = mp4AlphaLayout;
|
||||||
}
|
}
|
||||||
|
if (resourceRequiresExplicitVisualFormat(resourceType)) {
|
||||||
|
const format = roomBorderFormatFromMetadata(metadataJson);
|
||||||
|
if (format) {
|
||||||
|
metadata.format = format;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (resourceType === "room_name_style") {
|
||||||
|
const style = roomNameStyleFromMetadata(metadataJson);
|
||||||
|
if (style) {
|
||||||
|
metadata.text_colors = style.textColors;
|
||||||
|
if (style.stops.length) {
|
||||||
|
metadata.stops = style.stops;
|
||||||
|
}
|
||||||
|
if (style.angleDegrees !== null) {
|
||||||
|
metadata.angle_degrees = style.angleDegrees;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
return Object.keys(metadata).length ? JSON.stringify(metadata) : emptyResourceMetadata;
|
return Object.keys(metadata).length ? JSON.stringify(metadata) : emptyResourceMetadata;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 麦位动效和房间边框的格式跟资源记录一起保存,客户端不需要从 CDN URL 后缀猜渲染器。
|
||||||
|
export function roomBorderFormatMetadataJSON(metadataJson, value) {
|
||||||
|
const metadata = parseMetadataObject(metadataJson);
|
||||||
|
const format = String(value || "")
|
||||||
|
.trim()
|
||||||
|
.toLowerCase();
|
||||||
|
if (roomBorderFormatOptions.includes(format)) {
|
||||||
|
metadata.format = format;
|
||||||
|
} else {
|
||||||
|
delete metadata.format;
|
||||||
|
}
|
||||||
|
return stringifyMetadata(metadata);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function roomBorderFormatFromMetadata(metadataJson) {
|
||||||
|
const metadata = parseMetadataObject(metadataJson);
|
||||||
|
const format = String(metadata.format || metadata.animation_format || "")
|
||||||
|
.trim()
|
||||||
|
.toLowerCase();
|
||||||
|
return roomBorderFormatOptions.includes(format) ? format : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
// 房名颜色属于资源目录事实。后台把结构化输入归一成 room-service 唯一读取的 snake_case,
|
||||||
|
// Flutter 只消费房间快照,不能再按 VIP 等级维护另一份渐变配置。
|
||||||
|
export function roomNameStyleMetadataJSON(metadataJson, input) {
|
||||||
|
const style = parseRoomNameStyleInput(input);
|
||||||
|
if (!style) {
|
||||||
|
return emptyResourceMetadata;
|
||||||
|
}
|
||||||
|
const metadata = parseMetadataObject(metadataJson);
|
||||||
|
delete metadata.textColors;
|
||||||
|
delete metadata.angleDegrees;
|
||||||
|
metadata.text_colors = style.textColors;
|
||||||
|
if (style.stops.length) {
|
||||||
|
metadata.stops = style.stops;
|
||||||
|
} else {
|
||||||
|
delete metadata.stops;
|
||||||
|
}
|
||||||
|
if (style.angleDegrees !== null) {
|
||||||
|
metadata.angle_degrees = style.angleDegrees;
|
||||||
|
} else {
|
||||||
|
delete metadata.angle_degrees;
|
||||||
|
}
|
||||||
|
return stringifyMetadata(metadata);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function roomNameStyleFromMetadata(metadataJson) {
|
||||||
|
const metadata = parseMetadataObject(metadataJson);
|
||||||
|
const textColors = Array.isArray(metadata.text_colors)
|
||||||
|
? metadata.text_colors.map((value) => String(value).trim()).filter(Boolean)
|
||||||
|
: Array.isArray(metadata.textColors)
|
||||||
|
? metadata.textColors.map((value) => String(value).trim()).filter(Boolean)
|
||||||
|
: [];
|
||||||
|
const stops = Array.isArray(metadata.stops) ? metadata.stops.map(Number) : [];
|
||||||
|
const rawAngle = metadata.angle_degrees ?? metadata.angleDegrees;
|
||||||
|
const angleDegrees = rawAngle === undefined || rawAngle === null || rawAngle === "" ? null : Number(rawAngle);
|
||||||
|
const style = { angleDegrees, stops, textColors };
|
||||||
|
return validateRoomNameStyle(style) ? null : style;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function roomNameStyleFormFromMetadata(metadataJson) {
|
||||||
|
const style = roomNameStyleFromMetadata(metadataJson);
|
||||||
|
return {
|
||||||
|
roomNameAngleDegrees:
|
||||||
|
style?.angleDegrees === null || style?.angleDegrees === undefined ? "" : String(style.angleDegrees),
|
||||||
|
roomNameStops: style?.stops?.join(", ") || "",
|
||||||
|
roomNameTextColors: style?.textColors?.join(", ") || "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseRoomNameStyleInput(input) {
|
||||||
|
const textColors = splitRoomNameStyleValues(input?.roomNameTextColors);
|
||||||
|
const stopTokens = splitRoomNameStyleValues(input?.roomNameStops);
|
||||||
|
const stops = stopTokens.map(Number);
|
||||||
|
const rawAngle = String(input?.roomNameAngleDegrees ?? "").trim();
|
||||||
|
const angleDegrees = rawAngle === "" ? null : Number(rawAngle);
|
||||||
|
const style = { angleDegrees, stops, textColors };
|
||||||
|
return validateRoomNameStyle(style) ? null : style;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function roomNameStyleInputError(input) {
|
||||||
|
const textColors = splitRoomNameStyleValues(input?.roomNameTextColors);
|
||||||
|
if (!textColors.length || textColors.length > 8 || textColors.some((color) => !roomNameColorPattern.test(color))) {
|
||||||
|
return "请输入 1 至 8 个 #RRGGBB 或 #RRGGBBAA 颜色";
|
||||||
|
}
|
||||||
|
const stopTokens = splitRoomNameStyleValues(input?.roomNameStops);
|
||||||
|
if (stopTokens.length) {
|
||||||
|
const stops = stopTokens.map(Number);
|
||||||
|
if (
|
||||||
|
stops.length !== textColors.length ||
|
||||||
|
stops.some((stop) => !Number.isFinite(stop) || stop < 0 || stop > 1) ||
|
||||||
|
stops.some((stop, index) => index > 0 && stop < stops[index - 1])
|
||||||
|
) {
|
||||||
|
return "渐变位置必须与颜色数量一致,并按 0 至 1 递增";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const rawAngle = String(input?.roomNameAngleDegrees ?? "").trim();
|
||||||
|
if (rawAngle !== "" && !Number.isFinite(Number(rawAngle))) {
|
||||||
|
return "渐变角度必须是有效数字";
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
export function removeProfileCardLayoutMetadata(metadataJson) {
|
export function removeProfileCardLayoutMetadata(metadataJson) {
|
||||||
const metadata = parseMetadataObject(metadataJson);
|
const metadata = parseMetadataObject(metadataJson);
|
||||||
delete metadata.profile_card_layout;
|
delete metadata.profile_card_layout;
|
||||||
@ -55,3 +182,32 @@ function stringifyMetadata(metadata) {
|
|||||||
});
|
});
|
||||||
return Object.keys(clean).length ? JSON.stringify(clean) : emptyResourceMetadata;
|
return Object.keys(clean).length ? JSON.stringify(clean) : emptyResourceMetadata;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function splitRoomNameStyleValues(value) {
|
||||||
|
return String(value || "")
|
||||||
|
.split(/[\s,,]+/)
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateRoomNameStyle(style) {
|
||||||
|
if (
|
||||||
|
!style.textColors.length ||
|
||||||
|
style.textColors.length > 8 ||
|
||||||
|
style.textColors.some((color) => !roomNameColorPattern.test(color))
|
||||||
|
) {
|
||||||
|
return "invalid colors";
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
style.stops.length &&
|
||||||
|
(style.stops.length !== style.textColors.length ||
|
||||||
|
style.stops.some((stop) => !Number.isFinite(stop) || stop < 0 || stop > 1) ||
|
||||||
|
style.stops.some((stop, index) => index > 0 && stop < style.stops[index - 1]))
|
||||||
|
) {
|
||||||
|
return "invalid stops";
|
||||||
|
}
|
||||||
|
if (style.angleDegrees !== null && !Number.isFinite(style.angleDegrees)) {
|
||||||
|
return "invalid angle";
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|||||||
@ -1,6 +1,11 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { cpRelationTypeOptions, resourceIdentityAutoGrantTypes } from "@/features/resources/constants.js";
|
import { cpRelationTypeOptions, resourceIdentityAutoGrantTypes } from "@/features/resources/constants.js";
|
||||||
import { hasUnconfirmedMp4Layout } from "@/features/resources/mp4AlphaLayout.js";
|
import { hasUnconfirmedMp4Layout } from "@/features/resources/mp4AlphaLayout.js";
|
||||||
|
import {
|
||||||
|
resourceRequiresExplicitVisualFormat,
|
||||||
|
roomBorderFormatOptions,
|
||||||
|
roomNameStyleInputError,
|
||||||
|
} from "@/features/resources/resourceMetadata.js";
|
||||||
|
|
||||||
const resourceTypes = [
|
const resourceTypes = [
|
||||||
"avatar_frame",
|
"avatar_frame",
|
||||||
@ -13,6 +18,8 @@ const resourceTypes = [
|
|||||||
"gift",
|
"gift",
|
||||||
"mic_seat_icon",
|
"mic_seat_icon",
|
||||||
"mic_seat_animation",
|
"mic_seat_animation",
|
||||||
|
"room_border",
|
||||||
|
"room_name_style",
|
||||||
"emoji_pack",
|
"emoji_pack",
|
||||||
"vip_trial_card",
|
"vip_trial_card",
|
||||||
];
|
];
|
||||||
@ -36,7 +43,7 @@ const optionalWalletAssetTypeSchema = z
|
|||||||
|
|
||||||
export const resourceCreateFormSchema = z
|
export const resourceCreateFormSchema = z
|
||||||
.object({
|
.object({
|
||||||
animationUrl: z.string().trim().min(1, "请上传动效素材").max(512, "动效素材不能超过 512 个字符"),
|
animationUrl: z.string().trim().max(512, "动效素材不能超过 512 个字符"),
|
||||||
avatarFrameKind: z.enum(avatarFrameKinds).optional(),
|
avatarFrameKind: z.enum(avatarFrameKinds).optional(),
|
||||||
badgeForm: z.enum(badgeForms).optional(),
|
badgeForm: z.enum(badgeForms).optional(),
|
||||||
badgeKind: z.enum(badgeKinds).optional(),
|
badgeKind: z.enum(badgeKinds).optional(),
|
||||||
@ -46,13 +53,37 @@ export const resourceCreateFormSchema = z
|
|||||||
metadataJson: z.string().trim().max(4096, "资源元数据不能超过 4096 个字符").optional(),
|
metadataJson: z.string().trim().max(4096, "资源元数据不能超过 4096 个字符").optional(),
|
||||||
name: z.string().trim().min(1, "请输入资源名称").max(128, "资源名称不能超过 128 个字符"),
|
name: z.string().trim().min(1, "请输入资源名称").max(128, "资源名称不能超过 128 个字符"),
|
||||||
coinPrice: z.union([z.string(), z.number()]).optional(),
|
coinPrice: z.union([z.string(), z.number()]).optional(),
|
||||||
previewUrl: z.string().trim().min(1, "请上传资源封面").max(512, "资源封面不能超过 512 个字符"),
|
previewUrl: z.string().trim().max(512, "资源封面不能超过 512 个字符"),
|
||||||
priceType: z.enum(resourcePriceTypes, "请选择价格类型"),
|
priceType: z.enum(resourcePriceTypes, "请选择价格类型"),
|
||||||
resourceCode: z.string().trim().min(1, "请输入资源编码").max(96, "资源编码不能超过 96 个字符"),
|
resourceCode: z.string().trim().min(1, "请输入资源编码").max(96, "资源编码不能超过 96 个字符"),
|
||||||
resourceType: z.enum(resourceTypes, "请选择资源类型"),
|
resourceType: z.enum(resourceTypes, "请选择资源类型"),
|
||||||
|
roomBorderFormat: z.string().optional(),
|
||||||
|
roomNameAngleDegrees: z.union([z.string(), z.number()]).optional(),
|
||||||
|
roomNameStops: z.string().optional(),
|
||||||
|
roomNameTextColors: z.string().optional(),
|
||||||
walletAssetAmount: z.union([z.string(), z.number()]).optional(),
|
walletAssetAmount: z.union([z.string(), z.number()]).optional(),
|
||||||
})
|
})
|
||||||
.superRefine((value, context) => {
|
.superRefine((value, context) => {
|
||||||
|
// 彩色房名是纯样式资源,不要求伪造图片 URL;其他既有资源继续保持封面和素材的原校验边界。
|
||||||
|
if (value.resourceType !== "room_name_style") {
|
||||||
|
if (!value.previewUrl) {
|
||||||
|
context.addIssue({ code: "custom", message: "请上传资源封面", path: ["previewUrl"] });
|
||||||
|
}
|
||||||
|
if (!value.animationUrl) {
|
||||||
|
context.addIssue({ code: "custom", message: "请上传动效素材", path: ["animationUrl"] });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const styleError = roomNameStyleInputError(value);
|
||||||
|
if (styleError) {
|
||||||
|
context.addIssue({ code: "custom", message: styleError, path: ["roomNameTextColors"] });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
resourceRequiresExplicitVisualFormat(value.resourceType) &&
|
||||||
|
!roomBorderFormatOptions.includes(value.roomBorderFormat)
|
||||||
|
) {
|
||||||
|
context.addIssue({ code: "custom", message: "请选择视觉资源素材格式", path: ["roomBorderFormat"] });
|
||||||
|
}
|
||||||
if (value.resourceType !== "coin") {
|
if (value.resourceType !== "coin") {
|
||||||
if (value.resourceType === "badge" && !value.badgeForm) {
|
if (value.resourceType === "badge" && !value.badgeForm) {
|
||||||
context.addIssue({
|
context.addIssue({
|
||||||
@ -527,7 +558,10 @@ export const resourceGrantFormSchema = z
|
|||||||
path: ["groupId"],
|
path: ["groupId"],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if ((value.subjectType === "vip" || value.subjectType === "vip_trial_card") && (!Number.isInteger(vipLevel) || vipLevel <= 0)) {
|
if (
|
||||||
|
(value.subjectType === "vip" || value.subjectType === "vip_trial_card") &&
|
||||||
|
(!Number.isInteger(vipLevel) || vipLevel <= 0)
|
||||||
|
) {
|
||||||
context.addIssue({
|
context.addIssue({
|
||||||
code: "custom",
|
code: "custom",
|
||||||
message: "请选择VIP等级",
|
message: "请选择VIP等级",
|
||||||
|
|||||||
@ -37,6 +37,14 @@ export const vipGrantModeLabels = {
|
|||||||
trial_card: "VIP体验卡",
|
trial_card: "VIP体验卡",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 这三项由 room/wallet 按真实资源和 entitlement 执行。后台必须限定目录类型,
|
||||||
|
// 否则把头像框等任意资源绑定进房间权益后,只会在用户实际应用时才失败。
|
||||||
|
export const vipBenefitResourceTypes = {
|
||||||
|
colored_room_name: "room_name_style",
|
||||||
|
mic_skin: "mic_seat_animation",
|
||||||
|
room_border: "room_border",
|
||||||
|
};
|
||||||
|
|
||||||
// P1 的 34 项权益用于目录展示和兼容服务端契约;unlockLevel 只是历史字段,后台不据此补齐、开启或继承权益。
|
// P1 的 34 项权益用于目录展示和兼容服务端契约;unlockLevel 只是历史字段,后台不据此补齐、开启或继承权益。
|
||||||
export const p1BenefitCatalog = [
|
export const p1BenefitCatalog = [
|
||||||
benefit("vip_badge_identity", "VIP标识", 1, "function", "user", false),
|
benefit("vip_badge_identity", "VIP标识", 1, "function", "user", false),
|
||||||
@ -56,7 +64,7 @@ export const p1BenefitCatalog = [
|
|||||||
benefit("mic_skin", "麦克风皮肤", 4, "decoration", "room", true),
|
benefit("mic_skin", "麦克风皮肤", 4, "decoration", "room", true),
|
||||||
benefit("room_border", "房间边框", 4, "decoration", "room", true),
|
benefit("room_border", "房间边框", 4, "decoration", "room", true),
|
||||||
benefit("vehicle", "VIP座驾", 4, "decoration", "room", true),
|
benefit("vehicle", "VIP座驾", 4, "decoration", "room", true),
|
||||||
benefit("colored_room_name", "VIP彩色房间名称", 5, "function", "room", false),
|
benefit("colored_room_name", "VIP彩色房间名称", 5, "decoration", "room", false),
|
||||||
benefit("colored_nickname", "VIP彩色昵称", 5, "function", "user", false),
|
benefit("colored_nickname", "VIP彩色昵称", 5, "function", "user", false),
|
||||||
benefit("colored_id", "VIP彩色ID", 5, "function", "user", false),
|
benefit("colored_id", "VIP彩色ID", 5, "function", "user", false),
|
||||||
benefit("gift_tray_skin", "送礼托盘皮肤", 5, "function", "room", false),
|
benefit("gift_tray_skin", "送礼托盘皮肤", 5, "function", "room", false),
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useAppScope } from "@/app/app-scope/AppScopeProvider.jsx";
|
import { useAppScope } from "@/app/app-scope/AppScopeProvider.jsx";
|
||||||
import { getVipConfig, getVipProgram, updateVipConfig, updateVipProgram } from "@/features/vip-config/api";
|
import { getVipConfig, getVipProgram, updateVipConfig, updateVipProgram } from "@/features/vip-config/api";
|
||||||
import { p1BenefitCatalog, vipProgramDefaults } from "@/features/vip-config/constants.js";
|
import { p1BenefitCatalog, vipBenefitResourceTypes, vipProgramDefaults } from "@/features/vip-config/constants.js";
|
||||||
import { normalizeVipBenefitMetadata } from "@/features/vip-config/metadata.js";
|
import { normalizeVipBenefitMetadata } from "@/features/vip-config/metadata.js";
|
||||||
import { useVipConfigAbilities } from "@/features/vip-config/permissions.js";
|
import { useVipConfigAbilities } from "@/features/vip-config/permissions.js";
|
||||||
import { vipConfigFormSchema, vipProgramFormSchema } from "@/features/vip-config/schema.js";
|
import { vipConfigFormSchema, vipProgramFormSchema } from "@/features/vip-config/schema.js";
|
||||||
@ -456,10 +456,11 @@ function completeP1BenefitCatalog(remoteCatalog) {
|
|||||||
|
|
||||||
function benefitToForm(benefit, level, index, catalogPlaceholder) {
|
function benefitToForm(benefit, level, index, catalogPlaceholder) {
|
||||||
const benefitCode = benefit.benefitCode || "";
|
const benefitCode = benefit.benefitCode || "";
|
||||||
|
const expectedResourceType = vipBenefitResourceTypes[benefitCode] || "";
|
||||||
return {
|
return {
|
||||||
benefitCode,
|
benefitCode,
|
||||||
name: benefit.name || benefitCode,
|
name: benefit.name || benefitCode,
|
||||||
benefitType: benefit.benefitType === "decoration" ? "decoration" : "function",
|
benefitType: Boolean(expectedResourceType) || benefit.benefitType === "decoration" ? "decoration" : "function",
|
||||||
unlockLevel: Number(benefit.unlockLevel || level || 1),
|
unlockLevel: Number(benefit.unlockLevel || level || 1),
|
||||||
status: catalogPlaceholder ? "disabled" : benefit.status === "active" ? "active" : "disabled",
|
status: catalogPlaceholder ? "disabled" : benefit.status === "active" ? "active" : "disabled",
|
||||||
trialEnabled: benefitCode === "daily_coin_rebate" ? false : Boolean(benefit.trialEnabled),
|
trialEnabled: benefitCode === "daily_coin_rebate" ? false : Boolean(benefit.trialEnabled),
|
||||||
@ -537,6 +538,7 @@ function benefitPayload(benefit, level) {
|
|||||||
const rawMetadataJson = String(benefit.metadataJson || "{}").trim() || "{}";
|
const rawMetadataJson = String(benefit.metadataJson || "{}").trim() || "{}";
|
||||||
const sortOrder = Number(benefit.sortOrder || 0);
|
const sortOrder = Number(benefit.sortOrder || 0);
|
||||||
const resourceId = Number(benefit.resourceId || 0);
|
const resourceId = Number(benefit.resourceId || 0);
|
||||||
|
const expectedResourceType = vipBenefitResourceTypes[benefit.benefitCode] || "";
|
||||||
if (!Number.isInteger(sortOrder) || sortOrder < 0) {
|
if (!Number.isInteger(sortOrder) || sortOrder < 0) {
|
||||||
throw new Error(`VIP${level} 的 ${benefit.name || benefit.benefitCode} 排序不能小于 0`);
|
throw new Error(`VIP${level} 的 ${benefit.name || benefit.benefitCode} 排序不能小于 0`);
|
||||||
}
|
}
|
||||||
@ -552,6 +554,14 @@ function benefitPayload(benefit, level) {
|
|||||||
throw new Error(`VIP${level} 的 ${benefit.name || benefit.benefitCode} 资源不正确`);
|
throw new Error(`VIP${level} 的 ${benefit.name || benefit.benefitCode} 资源不正确`);
|
||||||
}
|
}
|
||||||
const status = benefit.status === "active" ? "active" : "disabled";
|
const status = benefit.status === "active" ? "active" : "disabled";
|
||||||
|
if (expectedResourceType && status === "active" && resourceId <= 0) {
|
||||||
|
throw new Error(`VIP${level} 的 ${benefit.name || benefit.benefitCode} 必须绑定真实资源`);
|
||||||
|
}
|
||||||
|
if (expectedResourceType && resourceId > 0 && benefit.resourceType !== expectedResourceType) {
|
||||||
|
throw new Error(
|
||||||
|
`VIP${level} 的 ${benefit.name || benefit.benefitCode} 只能绑定 ${expectedResourceType} 类型资源`,
|
||||||
|
);
|
||||||
|
}
|
||||||
const metadataJson = normalizeVipBenefitMetadata({
|
const metadataJson = normalizeVipBenefitMetadata({
|
||||||
benefitCode: benefit.benefitCode,
|
benefitCode: benefit.benefitCode,
|
||||||
label: `VIP${level} 的 ${benefit.name || benefit.benefitCode}`,
|
label: `VIP${level} 的 ${benefit.name || benefit.benefitCode}`,
|
||||||
@ -561,7 +571,7 @@ function benefitPayload(benefit, level) {
|
|||||||
return {
|
return {
|
||||||
benefitCode: String(benefit.benefitCode || "").trim(),
|
benefitCode: String(benefit.benefitCode || "").trim(),
|
||||||
name: String(benefit.name || "").trim(),
|
name: String(benefit.name || "").trim(),
|
||||||
benefitType: benefit.benefitType === "decoration" ? "decoration" : "function",
|
benefitType: Boolean(expectedResourceType) || benefit.benefitType === "decoration" ? "decoration" : "function",
|
||||||
unlockLevel: Number(benefit.unlockLevel || level),
|
unlockLevel: Number(benefit.unlockLevel || level),
|
||||||
status,
|
status,
|
||||||
trialEnabled: benefit.benefitCode === "daily_coin_rebate" ? false : Boolean(benefit.trialEnabled),
|
trialEnabled: benefit.benefitCode === "daily_coin_rebate" ? false : Boolean(benefit.trialEnabled),
|
||||||
|
|||||||
@ -17,6 +17,7 @@ import TextField from "@mui/material/TextField";
|
|||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { ResourceSelectField } from "@/features/resources/components/ResourceSelectDrawer.jsx";
|
import { ResourceSelectField } from "@/features/resources/components/ResourceSelectDrawer.jsx";
|
||||||
import {
|
import {
|
||||||
|
vipBenefitResourceTypes,
|
||||||
vipGrantModeLabels,
|
vipGrantModeLabels,
|
||||||
vipProgramDefaults,
|
vipProgramDefaults,
|
||||||
vipProgramLabels,
|
vipProgramLabels,
|
||||||
@ -603,7 +604,11 @@ function BenefitCard({ benefit, disabled, onChange, onSelect, selected }) {
|
|||||||
function BenefitInspector({ benefit, disabled, level, page }) {
|
function BenefitInspector({ benefit, disabled, level, page }) {
|
||||||
const rebateBenefit = benefit.benefitCode === "daily_coin_rebate";
|
const rebateBenefit = benefit.benefitCode === "daily_coin_rebate";
|
||||||
const onlineNoticeBenefit = benefit.benefitCode === "online_global_notice";
|
const onlineNoticeBenefit = benefit.benefitCode === "online_global_notice";
|
||||||
const decorationBenefit = benefit.benefitType === "decoration";
|
const expectedResourceType = vipBenefitResourceTypes[benefit.benefitCode] || "";
|
||||||
|
const decorationBenefit = Boolean(expectedResourceType) || benefit.benefitType === "decoration";
|
||||||
|
const selectableResources = expectedResourceType
|
||||||
|
? page.resources.filter((resource) => resource.resourceType === expectedResourceType)
|
||||||
|
: page.resources;
|
||||||
const metadataValid = Boolean(readVipBenefitMetadata(benefit.metadataJson));
|
const metadataValid = Boolean(readVipBenefitMetadata(benefit.metadataJson));
|
||||||
const onChange = (patch) => page.updateBenefit(level, benefit.benefitCode, patch);
|
const onChange = (patch) => page.updateBenefit(level, benefit.benefitCode, patch);
|
||||||
|
|
||||||
@ -675,9 +680,9 @@ function BenefitInspector({ benefit, disabled, level, page }) {
|
|||||||
drawerTitle={`选择 ${benefit.name} 资源`}
|
drawerTitle={`选择 ${benefit.name} 资源`}
|
||||||
label="绑定资源"
|
label="绑定资源"
|
||||||
loading={page.resourcesLoading}
|
loading={page.resourcesLoading}
|
||||||
placeholder="可不绑定资源"
|
placeholder={benefit.status === "active" ? "请选择权益资源" : "停用时可暂不绑定"}
|
||||||
required={false}
|
required={benefit.status === "active"}
|
||||||
resources={page.resources}
|
resources={selectableResources}
|
||||||
value={benefit.resourceId}
|
value={benefit.resourceId}
|
||||||
onChange={(resourceId, resource) =>
|
onChange={(resourceId, resource) =>
|
||||||
onChange({ resourceId, resourceType: resource?.resourceType || "" })
|
onChange({ resourceId, resourceType: resource?.resourceType || "" })
|
||||||
@ -713,7 +718,7 @@ function BenefitInspector({ benefit, disabled, level, page }) {
|
|||||||
<summary>高级设置</summary>
|
<summary>高级设置</summary>
|
||||||
<div className={styles.advancedFields}>
|
<div className={styles.advancedFields}>
|
||||||
<TextField
|
<TextField
|
||||||
disabled={disabled}
|
disabled={disabled || Boolean(expectedResourceType)}
|
||||||
label="权益类型"
|
label="权益类型"
|
||||||
select
|
select
|
||||||
size="small"
|
size="small"
|
||||||
|
|||||||
@ -238,6 +238,7 @@ export const API_OPERATIONS = {
|
|||||||
listLevelConfig: "listLevelConfig",
|
listLevelConfig: "listLevelConfig",
|
||||||
listLoginLogs: "listLoginLogs",
|
listLoginLogs: "listLoginLogs",
|
||||||
listLuckyGiftConfigs: "listLuckyGiftConfigs",
|
listLuckyGiftConfigs: "listLuckyGiftConfigs",
|
||||||
|
listLuckyGiftConfigVersions: "listLuckyGiftConfigVersions",
|
||||||
listLuckyGiftDraws: "listLuckyGiftDraws",
|
listLuckyGiftDraws: "listLuckyGiftDraws",
|
||||||
listLuckyGiftExperiments: "listLuckyGiftExperiments",
|
listLuckyGiftExperiments: "listLuckyGiftExperiments",
|
||||||
listLuckyGiftPoolBalances: "listLuckyGiftPoolBalances",
|
listLuckyGiftPoolBalances: "listLuckyGiftPoolBalances",
|
||||||
@ -326,6 +327,7 @@ export const API_OPERATIONS = {
|
|||||||
retryRoomTurnoverRewardSettlement: "retryRoomTurnoverRewardSettlement",
|
retryRoomTurnoverRewardSettlement: "retryRoomTurnoverRewardSettlement",
|
||||||
revokeAppUserResource: "revokeAppUserResource",
|
revokeAppUserResource: "revokeAppUserResource",
|
||||||
revokeResourceGrant: "revokeResourceGrant",
|
revokeResourceGrant: "revokeResourceGrant",
|
||||||
|
rollbackLuckyGiftConfig: "rollbackLuckyGiftConfig",
|
||||||
savePolicyTemplate: "savePolicyTemplate",
|
savePolicyTemplate: "savePolicyTemplate",
|
||||||
search: "search",
|
search: "search",
|
||||||
selfGameStatisticsOverview: "selfGameStatisticsOverview",
|
selfGameStatisticsOverview: "selfGameStatisticsOverview",
|
||||||
@ -1996,6 +1998,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
|||||||
permission: "lucky-gift:view",
|
permission: "lucky-gift:view",
|
||||||
permissions: ["lucky-gift:view"]
|
permissions: ["lucky-gift:view"]
|
||||||
},
|
},
|
||||||
|
listLuckyGiftConfigVersions: {
|
||||||
|
method: "GET",
|
||||||
|
operationId: API_OPERATIONS.listLuckyGiftConfigVersions,
|
||||||
|
path: "/v1/admin/ops-center/lucky-gifts/config/versions",
|
||||||
|
permission: "lucky-gift:view",
|
||||||
|
permissions: ["lucky-gift:view"]
|
||||||
|
},
|
||||||
listLuckyGiftDraws: {
|
listLuckyGiftDraws: {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
operationId: API_OPERATIONS.listLuckyGiftDraws,
|
operationId: API_OPERATIONS.listLuckyGiftDraws,
|
||||||
@ -2600,6 +2609,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
|||||||
permission: "resource-grant:revoke",
|
permission: "resource-grant:revoke",
|
||||||
permissions: ["resource-grant:revoke"]
|
permissions: ["resource-grant:revoke"]
|
||||||
},
|
},
|
||||||
|
rollbackLuckyGiftConfig: {
|
||||||
|
method: "POST",
|
||||||
|
operationId: API_OPERATIONS.rollbackLuckyGiftConfig,
|
||||||
|
path: "/v1/admin/ops-center/lucky-gifts/config/rollback",
|
||||||
|
permission: "lucky-gift:update",
|
||||||
|
permissions: ["lucky-gift:update"]
|
||||||
|
},
|
||||||
savePolicyTemplate: {
|
savePolicyTemplate: {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
operationId: API_OPERATIONS.savePolicyTemplate,
|
operationId: API_OPERATIONS.savePolicyTemplate,
|
||||||
|
|||||||
56
src/shared/api/generated/schema.d.ts
vendored
56
src/shared/api/generated/schema.d.ts
vendored
@ -276,6 +276,38 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/admin/ops-center/lucky-gifts/config/versions": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get: operations["listLuckyGiftConfigVersions"];
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/admin/ops-center/lucky-gifts/config/rollback": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
post: operations["rollbackLuckyGiftConfig"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/admin/ops-center/lucky-gifts/draws": {
|
"/admin/ops-center/lucky-gifts/draws": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@ -8478,6 +8510,30 @@ export interface operations {
|
|||||||
200: components["responses"]["EmptyResponse"];
|
200: components["responses"]["EmptyResponse"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
listLuckyGiftConfigVersions: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
200: components["responses"]["EmptyResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
rollbackLuckyGiftConfig: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
200: components["responses"]["EmptyResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
listLuckyGiftDraws: {
|
listLuckyGiftDraws: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user