zhx f8c7c50316 ops-center 幸运礼物 AB 实验 UI 与契约;清理 money 残留构建入口
- 配置页实验徽章/实验详情入口,实验期禁用常规发布
- 创建实验弹窗(复用配置表单+流量/白名单),实验管理弹窗(分组指标/放量/暂停/结束)
- 契约新增 5 个实验端点与 lucky-gift:experiment 权限,gen:api 再生成
- vite.config.js 移除已删除 money/index.html 的构建入口,修复 build 阻断

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 00:22:29 +08:00

491 lines
22 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 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)}`;
}