hyapp-admin-platform/ops-center/src/experimentForm.js
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

129 lines
5.2 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 { DEFAULT_POOL_ID } from "./api.js";
// AB 实验的分组语义固定为两组control 是创建实验时的最新启用版本treatment 是实验发布的新版本。
export const EXPERIMENT_ARMS = ["control", "treatment"];
export const EXPERIMENT_ARM_LABELS = { control: "对照组", treatment: "实验组" };
// 默认 5% 小流量起步:放量在后端是单调的(只增不减不会把已进组用户踢回),
// 起始值必须保守,创建后可以随时在实验管理里调大。
export const DEFAULT_TREATMENT_TRAFFIC_PERCENT = "5";
export function experimentDraft() {
return {
name: "",
trafficPercent: DEFAULT_TREATMENT_TRAFFIC_PERCENT,
whitelist: [],
};
}
// 白名单在提交前统一 trim + 去重Autocomplete freeSolo 允许粘贴带空白的 ID
// 重复的 user_key 会让 PUT overrides 的 upsert 语义变得不可预测。
export function normalizeWhitelist(values) {
const seen = new Set();
const list = [];
(Array.isArray(values) ? values : []).forEach((value) => {
const userKey = String(value ?? "").trim();
if (!userKey || seen.has(userKey)) {
return;
}
seen.add(userKey);
list.push(userKey);
});
return list;
}
// 0 是合法值:表示只有白名单用户进入实验组(灰度验证),不能按“必填大于 0”校验。
export function trafficPercentError(value) {
const raw = String(value ?? "").trim();
const parsed = Number(raw);
if (!raw || !Number.isFinite(parsed) || parsed < 0 || parsed > 100) {
return "实验组流量必须是 0-100 的百分比0 表示仅白名单用户进入实验组)";
}
return "";
}
export function validateExperimentDraft(draft = {}) {
const errors = [];
if (!String(draft.name || "").trim()) {
errors.push("请填写实验名称");
}
const trafficError = trafficPercentError(draft.trafficPercent);
if (trafficError) {
errors.push(trafficError);
}
return errors;
}
// 组装 POST /experiments 的请求体。config 必须是 formToConfig 的完整输出:
// owner 会把它原子发布为实验组版本enabled 强制 true——停用的实验组无法开奖实验没有意义。
export function buildExperimentCreateRequest(draft = {}, config = {}) {
return {
config: { ...config, enabled: true },
name: String(draft.name || "").trim(),
overrides: normalizeWhitelist(draft.whitelist).map((userKey) => ({ arm: "treatment", user_key: userKey })),
treatment_traffic_percent: Number(draft.trafficPercent),
};
}
// 展示层统一去掉尾零5 -> "5%"、12.5 -> "12.5%",保留最多两位小数与后端浮点精度一致。
export function formatTrafficPercent(value) {
const parsed = Number(value);
if (!Number.isFinite(parsed)) {
return "0%";
}
return `${Number(parsed.toFixed(2))}%`;
}
// paused 也算活跃:暂停只把流量临时切回对照组,实验仍占用该奖池,常规发布仍会被 owner 以 409 拒绝。
export function isActiveExperiment(experiment) {
return experiment?.status === "running" || experiment?.status === "paused";
}
// 实验按 app+pool 维度独占,不携带 strategy_version同一奖池的 fixed_v2/dynamic_v3 配置行
// 共享同一个活跃实验的发布封锁,因此关联键必须与 luckyGiftPoolKey 不同(后者含策略版本)。
export function experimentPoolFamilyKey(item = {}) {
const appCode = String(item.app_code || item.appCode || "")
.trim()
.toLowerCase();
const poolID = String(item.pool_id || item.poolId || DEFAULT_POOL_ID).trim() || DEFAULT_POOL_ID;
return `${appCode}:${poolID}`;
}
export function activeExperimentsByPool(experiments = []) {
const byPool = new Map();
(Array.isArray(experiments) ? experiments : []).forEach((experiment) => {
if (!isActiveExperiment(experiment)) {
return;
}
const key = experimentPoolFamilyKey(experiment);
const current = byPool.get(key);
// 后端保证同一 app+pool 最多一个活跃实验;若数据异常返回多个,保留最近开始的那个,
// 保证入口指向的实验与 owner 分流实际使用的一致。
if (!current || Number(experiment.started_at_ms || 0) > Number(current.started_at_ms || 0)) {
byPool.set(key, experiment);
}
});
return byPool;
}
// 列表与弹窗共用同一份状态徽章口径running 需要带放量比例,运营扫一眼就知道实验体量。
export function experimentStatusBadge(experiment) {
if (!experiment) {
return null;
}
if (experiment.status === "running") {
return { label: `实验中 ${formatTrafficPercent(experiment.treatment_traffic_percent)}`, tone: "warning" };
}
if (experiment.status === "paused") {
return { label: "实验暂停", tone: "warning" };
}
if (experiment.status === "completed") {
return { label: "实验已结束", tone: "stopped" };
}
return null;
}
export function experimentArmStat(experiment, arm) {
return (Array.isArray(experiment?.arm_stats) ? experiment.arm_stats : []).find((stat) => stat.arm === arm) || null;
}