- 配置页实验徽章/实验详情入口,实验期禁用常规发布 - 创建实验弹窗(复用配置表单+流量/白名单),实验管理弹窗(分组指标/放量/暂停/结束) - 契约新增 5 个实验端点与 lucky-gift:experiment 权限,gen:api 再生成 - vite.config.js 移除已删除 money/index.html 的构建入口,修复 build 阻断 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
491 lines
22 KiB
JavaScript
491 lines
22 KiB
JavaScript
import AppsOutlined from "@mui/icons-material/AppsOutlined";
|
||
import CardGiftcardOutlined from "@mui/icons-material/CardGiftcardOutlined";
|
||
import CasinoOutlined from "@mui/icons-material/CasinoOutlined";
|
||
import InsightsOutlined from "@mui/icons-material/InsightsOutlined";
|
||
import RefreshOutlined from "@mui/icons-material/RefreshOutlined";
|
||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||
import { PERMISSIONS } from "@/app/permissions";
|
||
import { Button } from "@/shared/ui/Button.jsx";
|
||
import { DataState } from "@/shared/ui/DataState.jsx";
|
||
import { PageHead } from "@/shared/ui/PageHead.jsx";
|
||
import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
||
import {
|
||
adjustLuckyGiftPool,
|
||
createLuckyGiftExperiment,
|
||
DEFAULT_POOL_ID,
|
||
endLuckyGiftExperiment,
|
||
fetchLuckyGiftExperiments,
|
||
fetchOpsDashboard,
|
||
saveLuckyGiftConfig,
|
||
setLuckyGiftExperimentOverrides,
|
||
updateLuckyGiftExperiment,
|
||
} from "./api.js";
|
||
import { AppsView } from "./components/AppsView.jsx";
|
||
import { ConfigsView } from "./components/ConfigsView.jsx";
|
||
import { DrawsView } from "./components/DrawsView.jsx";
|
||
import { LuckyGiftConfigDialog } from "./components/LuckyGiftConfigDialog.jsx";
|
||
import { LuckyGiftExperimentManageDialog } from "./components/LuckyGiftExperimentManageDialog.jsx";
|
||
import { LuckyGiftPoolAdjustmentDialog } from "./components/LuckyGiftPoolAdjustmentDialog.jsx";
|
||
import { OpsShell } from "./components/OpsShell.jsx";
|
||
import { OverviewView } from "./components/OverviewView.jsx";
|
||
import { EXPERIMENT_ARM_LABELS, formatTrafficPercent } from "./experimentForm.js";
|
||
import { formatNumber } from "./format.js";
|
||
|
||
const views = [
|
||
{ icon: InsightsOutlined, key: "overview", label: "数据总览" },
|
||
{ icon: CardGiftcardOutlined, key: "configs", label: "幸运礼物配置" },
|
||
{ icon: CasinoOutlined, key: "draws", label: "抽奖记录" },
|
||
{ icon: AppsOutlined, key: "apps", label: "应用列表" },
|
||
];
|
||
const viewKeys = new Set(views.map((view) => view.key));
|
||
|
||
const initialDashboard = {
|
||
apps: [],
|
||
appSummaries: [],
|
||
experiments: [],
|
||
luckyGifts: [],
|
||
poolBalances: [],
|
||
summary: {},
|
||
};
|
||
|
||
// 实验管理权限码与 permissions.ts 常量并行开发:常量尚未生成时回退到后端约定的字面量,
|
||
// 避免部署顺序影响入口显隐;两者最终值一致("lucky-gift:experiment")。
|
||
const EXPERIMENT_PERMISSION = PERMISSIONS.luckyGiftExperiment || "lucky-gift:experiment";
|
||
|
||
export function initialOpsCenterView(search = globalThis.location?.search || "") {
|
||
const requested = new URLSearchParams(search).get("view") || "";
|
||
return viewKeys.has(requested) ? requested : "overview";
|
||
}
|
||
|
||
const denyPermission = () => false;
|
||
|
||
export function OpsCenterApp({ can = denyPermission }) {
|
||
// 主后台旧入口通过 ?view=configs 进入独立 HTML;只接受白名单视图,异常参数仍回到总览。
|
||
const [activeView, setActiveView] = useState(() => initialOpsCenterView());
|
||
const [state, setState] = useState({ data: initialDashboard, error: "", loaded: false, loading: true });
|
||
const [dialog, setDialog] = useState(null);
|
||
const [poolDialog, setPoolDialog] = useState(null);
|
||
const [experimentManage, setExperimentManage] = useState(null);
|
||
const [adjustingPool, setAdjustingPool] = useState(false);
|
||
const [savingConfig, setSavingConfig] = useState(false);
|
||
const [experimentBusy, setExperimentBusy] = useState(false);
|
||
const { showToast } = useToast();
|
||
const canCreditPool = can(PERMISSIONS.luckyGiftPoolCredit);
|
||
const canDebitPool = can(PERMISSIONS.luckyGiftPoolDebit);
|
||
const canUpdateConfig = can(PERMISSIONS.luckyGiftUpdate);
|
||
const canManageExperiment = can(EXPERIMENT_PERMISSION);
|
||
|
||
const loadDashboard = useCallback(async () => {
|
||
setState((current) => ({ ...current, error: "", loading: true }));
|
||
try {
|
||
const data = await fetchOpsDashboard();
|
||
setState({ data, error: "", loaded: true, loading: false });
|
||
} catch (error) {
|
||
// 接口灰度期间保留已加载的数据和页面结构,单次失败只提示、不整页白屏。
|
||
setState((current) => ({
|
||
data: current.data || initialDashboard,
|
||
error: error.message || "运营接口暂不可用",
|
||
loaded: current.loaded,
|
||
loading: false,
|
||
}));
|
||
}
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
loadDashboard();
|
||
}, [loadDashboard]);
|
||
|
||
const { data } = state;
|
||
|
||
const appOptions = useMemo(
|
||
() =>
|
||
data.apps.map((item) => [
|
||
item.app_code ?? item.appCode,
|
||
`${item.app_name ?? item.appName ?? ""} (${item.app_code ?? item.appCode})`,
|
||
]),
|
||
[data.apps],
|
||
);
|
||
|
||
// 抽奖记录页的奖池下拉:配置和真实水位都可能先于对方存在(先发布未入账 / 老池子停用后仍有流水),取并集。
|
||
const poolIDsByApp = useMemo(() => {
|
||
const byApp = new Map();
|
||
const add = (appCode, poolID) => {
|
||
if (!appCode || !poolID) {
|
||
return;
|
||
}
|
||
if (!byApp.has(appCode)) {
|
||
byApp.set(appCode, new Set());
|
||
}
|
||
byApp.get(appCode).add(poolID);
|
||
};
|
||
data.luckyGifts.forEach((config) => add(config.app_code, config.pool_id));
|
||
data.poolBalances.forEach((pool) => add(pool.app_code ?? pool.appCode, pool.pool_id ?? pool.poolId));
|
||
return new Map(Array.from(byApp.entries(), ([appCode, poolIDs]) => [appCode, Array.from(poolIDs).sort()]));
|
||
}, [data.luckyGifts, data.poolBalances]);
|
||
|
||
const openCreateConfig = useCallback(() => {
|
||
const firstAppCode = data.apps
|
||
.map((item) =>
|
||
String(item.app_code ?? item.appCode ?? "")
|
||
.trim()
|
||
.toLowerCase(),
|
||
)
|
||
.find(Boolean);
|
||
if (!firstAppCode) {
|
||
showToast("暂无可添加配置的应用", "warning");
|
||
return;
|
||
}
|
||
// 金额门槛和六维上限按 App 金币口径配置,绝不能复制第一条已发布规则给另一个 App。
|
||
// 仅传规则身份,让 configToForm 生成 disabled dynamic_v3 安全草稿;金额字段保持 0,运营补齐后才能启用。
|
||
setDialog({
|
||
config: {
|
||
app_code: firstAppCode,
|
||
enabled: false,
|
||
is_default: true,
|
||
pool_id: DEFAULT_POOL_ID,
|
||
rule_version: 0,
|
||
strategy_version: "dynamic_v3",
|
||
},
|
||
mode: "create",
|
||
});
|
||
}, [data.apps, showToast]);
|
||
|
||
const openEditConfig = useCallback((config) => {
|
||
setDialog({ config: structuredClone(config), mode: "edit" });
|
||
}, []);
|
||
|
||
const openCreateExperiment = useCallback((config) => {
|
||
// 复用配置弹窗的实验模式:以当前行配置为实验组初始值,当前最新启用版本自动成为对照组。
|
||
setDialog({ config: structuredClone(config), mode: "experiment" });
|
||
}, []);
|
||
|
||
const openPoolAdjustment = useCallback((pool, direction) => {
|
||
if (!pool) {
|
||
return;
|
||
}
|
||
// 幂等键绑定一次弹窗会话,而不是一次 fetch;网络超时或 409 后弹窗保留,后续重试不会重复入账。
|
||
setPoolDialog({
|
||
adjustmentId: createPoolAdjustmentID(),
|
||
direction,
|
||
pool: structuredClone(pool),
|
||
});
|
||
}, []);
|
||
|
||
const handleSaveConfig = useCallback(
|
||
async (config) => {
|
||
setSavingConfig(true);
|
||
try {
|
||
await saveLuckyGiftConfig(config);
|
||
showToast(`已发布 ${config.app_code} / ${config.pool_id} 的新规则版本`, "success");
|
||
setDialog(null);
|
||
await loadDashboard();
|
||
} catch (error) {
|
||
// 保存失败保持弹窗打开,运营修完参数可直接重试,不丢已填内容。
|
||
showToast(error.message || "保存幸运礼物配置失败", "error");
|
||
} finally {
|
||
setSavingConfig(false);
|
||
}
|
||
},
|
||
[loadDashboard, showToast],
|
||
);
|
||
|
||
const handleCreateExperiment = useCallback(
|
||
async (request) => {
|
||
setSavingConfig(true);
|
||
try {
|
||
const result = await createLuckyGiftExperiment(request.config.app_code, request);
|
||
showToast(
|
||
`已创建 AB 实验「${request.name}」:${request.config.app_code} / ${request.config.pool_id},实验组 v${
|
||
result.experiment?.treatment_rule_version || "-"
|
||
} 按 ${formatTrafficPercent(request.treatment_traffic_percent)} 放量`,
|
||
"success",
|
||
);
|
||
setDialog(null);
|
||
await loadDashboard();
|
||
} catch (error) {
|
||
// 创建失败(含活跃实验冲突 409)保持弹窗打开,运营修完参数可直接重试,不丢已填内容。
|
||
showToast(error.message || "创建 AB 实验失败", "error");
|
||
} finally {
|
||
setSavingConfig(false);
|
||
}
|
||
},
|
||
[loadDashboard, showToast],
|
||
);
|
||
|
||
const refreshExperimentDetail = useCallback(async (experiment) => {
|
||
// 统计(with_stats=1)由 owner 按 control/treatment 聚合,代价较高,只在管理弹窗里拉取;
|
||
// dashboard 扇出保持轻量列表。按 pool 过滤后再按 experiment_id 精确匹配。
|
||
const items = await fetchLuckyGiftExperiments(experiment.app_code, {
|
||
poolId: experiment.pool_id,
|
||
withStats: true,
|
||
});
|
||
return items.find((item) => item.experiment_id === experiment.experiment_id) || null;
|
||
}, []);
|
||
|
||
const openExperimentManage = useCallback(
|
||
(experiment) => {
|
||
setExperimentManage({ experiment, statsLoading: true });
|
||
refreshExperimentDetail(experiment)
|
||
.then((detail) => {
|
||
setExperimentManage((current) =>
|
||
current && current.experiment.experiment_id === experiment.experiment_id
|
||
? { experiment: detail || current.experiment, statsLoading: false }
|
||
: current,
|
||
);
|
||
})
|
||
.catch((error) => {
|
||
// 统计加载失败不关闭弹窗:基础字段来自列表数据仍可管理,统计位展示“-”。
|
||
showToast(error.message || "加载实验统计失败", "error");
|
||
setExperimentManage((current) =>
|
||
current && current.experiment.experiment_id === experiment.experiment_id
|
||
? { ...current, statsLoading: false }
|
||
: current,
|
||
);
|
||
});
|
||
},
|
||
[refreshExperimentDetail, showToast],
|
||
);
|
||
|
||
const runExperimentAction = useCallback(
|
||
async (action, successMessage) => {
|
||
const current = experimentManage?.experiment;
|
||
if (!current) {
|
||
return;
|
||
}
|
||
setExperimentBusy(true);
|
||
try {
|
||
const updated = await action(current);
|
||
showToast(successMessage(updated), "success");
|
||
// PATCH/PUT 响应不含 arm_stats:先用响应回填状态字段让弹窗即时一致,再补拉统计;
|
||
// 同时刷新 dashboard,让配置列表的实验徽章与发布禁用状态同步收敛。
|
||
setExperimentManage((state) =>
|
||
state && state.experiment.experiment_id === current.experiment_id
|
||
? { experiment: { ...state.experiment, ...updated }, statsLoading: true }
|
||
: state,
|
||
);
|
||
await loadDashboard();
|
||
try {
|
||
const detail = await refreshExperimentDetail(current);
|
||
setExperimentManage((state) =>
|
||
state && state.experiment.experiment_id === current.experiment_id
|
||
? { experiment: detail || state.experiment, statsLoading: false }
|
||
: state,
|
||
);
|
||
} catch {
|
||
// 操作本身已成功,统计刷新失败不能再报错误 toast;保留已回填的基础字段。
|
||
setExperimentManage((state) => (state ? { ...state, statsLoading: false } : state));
|
||
}
|
||
} catch (error) {
|
||
// 操作失败保留弹窗与输入内容,运营修正后可直接重试。
|
||
showToast(error.message || "实验操作失败", "error");
|
||
} finally {
|
||
setExperimentBusy(false);
|
||
}
|
||
},
|
||
[experimentManage, loadDashboard, refreshExperimentDetail, showToast],
|
||
);
|
||
|
||
const adjustExperimentTraffic = useCallback(
|
||
(percent) =>
|
||
runExperimentAction(
|
||
(current) =>
|
||
updateLuckyGiftExperiment(current.app_code, current.experiment_id, {
|
||
treatment_traffic_percent: percent,
|
||
}),
|
||
() => `已调整实验组流量为 ${formatTrafficPercent(percent)}`,
|
||
),
|
||
[runExperimentAction],
|
||
);
|
||
|
||
const setExperimentStatus = useCallback(
|
||
(status) =>
|
||
runExperimentAction(
|
||
(current) => updateLuckyGiftExperiment(current.app_code, current.experiment_id, { status }),
|
||
() =>
|
||
status === "paused"
|
||
? "实验已暂停,全部流量(含白名单)回到对照组"
|
||
: "实验已恢复,按原分组继续分流",
|
||
),
|
||
[runExperimentAction],
|
||
);
|
||
|
||
const upsertExperimentOverride = useCallback(
|
||
({ arm, userKey }) =>
|
||
runExperimentAction(
|
||
(current) =>
|
||
setLuckyGiftExperimentOverrides(current.app_code, current.experiment_id, {
|
||
removes: [],
|
||
upserts: [{ arm, user_key: userKey }],
|
||
}),
|
||
() => `已把用户 ${userKey} 固定到${EXPERIMENT_ARM_LABELS[arm] || arm}`,
|
||
),
|
||
[runExperimentAction],
|
||
);
|
||
|
||
const removeExperimentOverride = useCallback(
|
||
(userKey) =>
|
||
runExperimentAction(
|
||
(current) =>
|
||
setLuckyGiftExperimentOverrides(current.app_code, current.experiment_id, {
|
||
removes: [userKey],
|
||
upserts: [],
|
||
}),
|
||
() => `已移除白名单用户 ${userKey},该用户回到哈希分流`,
|
||
),
|
||
[runExperimentAction],
|
||
);
|
||
|
||
const endExperiment = useCallback(
|
||
(winnerArm) =>
|
||
runExperimentAction(
|
||
async (current) => {
|
||
const result = await endLuckyGiftExperiment(current.app_code, current.experiment_id, winnerArm);
|
||
return result.experiment;
|
||
},
|
||
(updated) =>
|
||
`实验已结束:${EXPERIMENT_ARM_LABELS[winnerArm]}配置已重新发布为 v${
|
||
updated?.final_rule_version || updated?.winner_rule_version || "-"
|
||
} 并全量生效`,
|
||
),
|
||
[runExperimentAction],
|
||
);
|
||
|
||
const handleAdjustPool = useCallback(
|
||
async (request) => {
|
||
setAdjustingPool(true);
|
||
try {
|
||
const result = await adjustLuckyGiftPool(request);
|
||
const adjustment = result.adjustment || {};
|
||
const action = request.direction === "out" ? "扣减" : "添加";
|
||
showToast(
|
||
`已${action}水位 ${request.appCode} / ${request.poolId} / ${request.strategyVersion}:${formatNumber(
|
||
adjustment.balance_before,
|
||
)} → ${formatNumber(adjustment.balance_after)}`,
|
||
"success",
|
||
);
|
||
setPoolDialog(null);
|
||
await loadDashboard();
|
||
} catch (error) {
|
||
// owner 可能已经成功但响应丢失;保留表单与 adjustment_id,用户重试时由 owner 返回首次结果而不重复调整。
|
||
showToast(error.message || "调整奖池水位失败", "error");
|
||
} finally {
|
||
setAdjustingPool(false);
|
||
}
|
||
},
|
||
[loadDashboard, showToast],
|
||
);
|
||
|
||
const defaultAppCode = data.apps[0]?.app_code ?? data.apps[0]?.appCode ?? "";
|
||
|
||
return (
|
||
<OpsShell activeView={activeView} onViewChange={setActiveView} views={views}>
|
||
<div className="ops-page">
|
||
<PageHead meta="跨应用幸运礼物配置与抽奖数据面板" title="运营配置中心">
|
||
<span className={state.loading ? "ops-status is-loading" : "ops-status"}>
|
||
{state.loading ? "同步中" : "已就绪"}
|
||
</span>
|
||
<Button
|
||
disabled={state.loading}
|
||
startIcon={<RefreshOutlined fontSize="small" />}
|
||
onClick={loadDashboard}
|
||
>
|
||
刷新
|
||
</Button>
|
||
</PageHead>
|
||
{/* 首屏失败走 DataState 的错误重试页;已有数据时刷新失败只弹提示条,保留旧数据可用。 */}
|
||
{state.error && state.loaded ? (
|
||
<div className="ops-alert" role="alert">
|
||
{state.error}
|
||
</div>
|
||
) : null}
|
||
<DataState
|
||
error={state.loaded ? "" : state.error}
|
||
loading={state.loading && !state.loaded}
|
||
onRetry={loadDashboard}
|
||
>
|
||
{activeView === "overview" ? (
|
||
<OverviewView
|
||
canCredit={canCreditPool}
|
||
canDebit={canDebitPool}
|
||
data={data}
|
||
disabled={adjustingPool || state.loading}
|
||
onCredit={(pool) => openPoolAdjustment(pool, "in")}
|
||
onDebit={(pool) => openPoolAdjustment(pool, "out")}
|
||
/>
|
||
) : null}
|
||
{activeView === "configs" ? (
|
||
<ConfigsView
|
||
apps={data.apps}
|
||
canCredit={canCreditPool}
|
||
canDebit={canDebitPool}
|
||
canExperiment={canManageExperiment}
|
||
canUpdate={canUpdateConfig}
|
||
disabled={adjustingPool || state.loading}
|
||
experiments={data.experiments}
|
||
luckyGifts={data.luckyGifts}
|
||
poolBalances={data.poolBalances}
|
||
saving={savingConfig}
|
||
onCreate={openCreateConfig}
|
||
onCreateExperiment={openCreateExperiment}
|
||
onCredit={(pool) => openPoolAdjustment(pool, "in")}
|
||
onDebit={(pool) => openPoolAdjustment(pool, "out")}
|
||
onEdit={openEditConfig}
|
||
onManageExperiment={openExperimentManage}
|
||
/>
|
||
) : null}
|
||
{activeView === "draws" ? (
|
||
<DrawsView apps={data.apps} defaultAppCode={defaultAppCode} poolIDsByApp={poolIDsByApp} />
|
||
) : null}
|
||
{activeView === "apps" ? <AppsView apps={data.apps} /> : null}
|
||
</DataState>
|
||
{dialog ? (
|
||
<LuckyGiftConfigDialog
|
||
appOptions={appOptions}
|
||
config={dialog.config}
|
||
mode={dialog.mode}
|
||
saving={savingConfig}
|
||
onClose={() => setDialog(null)}
|
||
// 实验模式提交的是 {name, treatment_traffic_percent, overrides, config} 创建请求,
|
||
// 其余模式仍是 PUT /lucky-gifts/config 的完整配置对象。
|
||
onSubmit={dialog.mode === "experiment" ? handleCreateExperiment : handleSaveConfig}
|
||
/>
|
||
) : null}
|
||
{experimentManage ? (
|
||
<LuckyGiftExperimentManageDialog
|
||
busy={experimentBusy}
|
||
canManage={canManageExperiment}
|
||
experiment={experimentManage.experiment}
|
||
statsLoading={experimentManage.statsLoading}
|
||
onAdjustTraffic={adjustExperimentTraffic}
|
||
onClose={() => setExperimentManage(null)}
|
||
onEnd={endExperiment}
|
||
onRemoveOverride={removeExperimentOverride}
|
||
onSetStatus={setExperimentStatus}
|
||
onUpsertOverride={upsertExperimentOverride}
|
||
/>
|
||
) : null}
|
||
{poolDialog ? (
|
||
<LuckyGiftPoolAdjustmentDialog
|
||
adjustmentId={poolDialog.adjustmentId}
|
||
direction={poolDialog.direction}
|
||
pool={poolDialog.pool}
|
||
submitting={adjustingPool}
|
||
onClose={() => setPoolDialog(null)}
|
||
onSubmit={handleAdjustPool}
|
||
/>
|
||
) : null}
|
||
</div>
|
||
</OpsShell>
|
||
);
|
||
}
|
||
|
||
let fallbackAdjustmentSequence = 0;
|
||
|
||
function createPoolAdjustmentID() {
|
||
if (typeof globalThis.crypto?.randomUUID === "function") {
|
||
return globalThis.crypto.randomUUID();
|
||
}
|
||
fallbackAdjustmentSequence += 1;
|
||
return `pool-adjust-${Date.now().toString(36)}-${fallbackAdjustmentSequence.toString(36)}`;
|
||
}
|