949 lines
27 KiB
HTML
949 lines
27 KiB
HTML
<!doctype html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<title>AB Test Config</title>
|
||
<style>
|
||
:root {
|
||
--bg: #f5f7fb;
|
||
--panel: #ffffff;
|
||
--line: #d8dee9;
|
||
--text: #1f2937;
|
||
--muted: #64748b;
|
||
--primary: #2563eb;
|
||
--primary-dark: #1d4ed8;
|
||
--danger: #b91c1c;
|
||
--ok: #047857;
|
||
}
|
||
* { box-sizing: border-box; }
|
||
body {
|
||
margin: 0;
|
||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||
color: var(--text);
|
||
background: var(--bg);
|
||
}
|
||
header {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 16px;
|
||
padding: 18px 24px;
|
||
background: var(--panel);
|
||
border-bottom: 1px solid var(--line);
|
||
}
|
||
h1 {
|
||
margin: 0;
|
||
font-size: 20px;
|
||
font-weight: 700;
|
||
letter-spacing: 0;
|
||
}
|
||
main {
|
||
display: grid;
|
||
grid-template-columns: 400px minmax(0, 1fr);
|
||
gap: 18px;
|
||
padding: 18px;
|
||
}
|
||
section, .layer-card {
|
||
background: var(--panel);
|
||
border: 1px solid var(--line);
|
||
border-radius: 8px;
|
||
}
|
||
section {
|
||
padding: 16px;
|
||
}
|
||
label {
|
||
display: block;
|
||
margin: 14px 0 6px;
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
color: #475569;
|
||
}
|
||
input {
|
||
width: 100%;
|
||
height: 38px;
|
||
padding: 8px 10px;
|
||
border: 1px solid #cbd5e1;
|
||
border-radius: 6px;
|
||
color: var(--text);
|
||
font: inherit;
|
||
background: #ffffff;
|
||
}
|
||
input[type="checkbox"] {
|
||
width: 16px;
|
||
height: 16px;
|
||
margin: 0;
|
||
}
|
||
button {
|
||
height: 36px;
|
||
padding: 0 12px;
|
||
border: 1px solid var(--primary);
|
||
border-radius: 6px;
|
||
background: var(--primary);
|
||
color: #ffffff;
|
||
font: inherit;
|
||
cursor: pointer;
|
||
}
|
||
button:hover {
|
||
background: var(--primary-dark);
|
||
border-color: var(--primary-dark);
|
||
}
|
||
button.secondary {
|
||
background: #ffffff;
|
||
color: var(--text);
|
||
border-color: #cbd5e1;
|
||
}
|
||
button.secondary:hover {
|
||
background: #f8fafc;
|
||
border-color: #94a3b8;
|
||
}
|
||
button.danger {
|
||
height: 28px;
|
||
padding: 0 4px;
|
||
background: transparent;
|
||
border-color: transparent;
|
||
color: #64748b;
|
||
font-size: 12px;
|
||
}
|
||
button.danger:hover {
|
||
background: #f1f5f9;
|
||
border-color: transparent;
|
||
color: #334155;
|
||
}
|
||
.top-actions, .card-actions, .group-tools, .ratio-mode {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
flex-wrap: wrap;
|
||
}
|
||
.action-panel {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: flex-end;
|
||
gap: 8px;
|
||
}
|
||
.publish-warning {
|
||
max-width: 430px;
|
||
padding: 8px 10px;
|
||
border: 1px solid #facc15;
|
||
border-radius: 6px;
|
||
background: #fefce8;
|
||
color: #854d0e;
|
||
font-size: 13px;
|
||
line-height: 1.45;
|
||
}
|
||
.settings {
|
||
width: 100%;
|
||
}
|
||
.side-panel {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 14px;
|
||
align-self: start;
|
||
}
|
||
.status {
|
||
min-height: 22px;
|
||
margin-top: 14px;
|
||
font-size: 13px;
|
||
color: var(--primary);
|
||
overflow-wrap: anywhere;
|
||
}
|
||
.json-preview h2 {
|
||
margin: 0 0 10px;
|
||
font-size: 15px;
|
||
letter-spacing: 0;
|
||
}
|
||
.json-preview pre {
|
||
max-height: 420px;
|
||
margin: 0;
|
||
padding: 12px;
|
||
overflow: auto;
|
||
border: 1px solid #dbe3ef;
|
||
border-radius: 6px;
|
||
background: #0f172a;
|
||
color: #e2e8f0;
|
||
font: 12px/1.5 ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||
white-space: pre-wrap;
|
||
overflow-wrap: anywhere;
|
||
}
|
||
.workspace {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 14px;
|
||
min-width: 0;
|
||
background: transparent;
|
||
border: 0;
|
||
padding: 0;
|
||
}
|
||
.layer-list {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 12px;
|
||
}
|
||
.add-layer-btn {
|
||
width: min(280px, 100%);
|
||
height: 48px;
|
||
margin: 4px auto 0;
|
||
font-size: 15px;
|
||
font-weight: 700;
|
||
}
|
||
.layer-card {
|
||
overflow: hidden;
|
||
}
|
||
.card-header {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 12px;
|
||
padding: 14px 16px;
|
||
background: #fbfdff;
|
||
border-bottom: 1px solid #e5eaf2;
|
||
}
|
||
.card-title {
|
||
min-width: 0;
|
||
}
|
||
.card-title strong {
|
||
display: block;
|
||
font-size: 20px;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
.card-body {
|
||
padding: 16px;
|
||
}
|
||
.form-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||
gap: 14px;
|
||
}
|
||
.switch-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
height: 38px;
|
||
margin-top: 34px;
|
||
color: #475569;
|
||
font-size: 14px;
|
||
}
|
||
.switch {
|
||
position: relative;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
color: #475569;
|
||
font-size: 13px;
|
||
cursor: pointer;
|
||
user-select: none;
|
||
}
|
||
.switch input {
|
||
position: absolute;
|
||
opacity: 0;
|
||
pointer-events: none;
|
||
}
|
||
.switch span {
|
||
position: relative;
|
||
width: 42px;
|
||
height: 24px;
|
||
border-radius: 999px;
|
||
background: #cbd5e1;
|
||
transition: background 0.15s ease;
|
||
}
|
||
.switch span::after {
|
||
content: "";
|
||
position: absolute;
|
||
top: 3px;
|
||
left: 3px;
|
||
width: 18px;
|
||
height: 18px;
|
||
border-radius: 50%;
|
||
background: #ffffff;
|
||
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.24);
|
||
transition: transform 0.15s ease;
|
||
}
|
||
.switch input:checked + span {
|
||
background: var(--primary);
|
||
}
|
||
.switch input:checked + span::after {
|
||
transform: translateX(18px);
|
||
}
|
||
.group-head {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 12px;
|
||
margin-top: 22px;
|
||
padding-top: 18px;
|
||
border-top: 1px solid var(--line);
|
||
}
|
||
.group-count {
|
||
width: 84px;
|
||
}
|
||
.group-tools {
|
||
font-size: 16px;
|
||
font-weight: 700;
|
||
}
|
||
.ratio-mode {
|
||
justify-content: flex-start;
|
||
margin-top: 0;
|
||
color: #475569;
|
||
font-size: 13px;
|
||
}
|
||
.groups {
|
||
display: grid;
|
||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||
gap: 12px;
|
||
margin-top: 12px;
|
||
}
|
||
.group-item {
|
||
border: 1px solid #dbe3ef;
|
||
border-radius: 8px;
|
||
padding: 12px;
|
||
background: #fbfdff;
|
||
}
|
||
.group-title {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 8px;
|
||
margin-bottom: 10px;
|
||
color: #334155;
|
||
font-size: 13px;
|
||
font-weight: 700;
|
||
}
|
||
.percent-box {
|
||
display: grid;
|
||
grid-template-columns: minmax(0, 1fr) auto;
|
||
align-items: center;
|
||
gap: 6px;
|
||
padding: 0 10px;
|
||
border: 1px solid #cbd5e1;
|
||
border-radius: 6px;
|
||
background: #ffffff;
|
||
}
|
||
.percent-box input {
|
||
height: 36px;
|
||
padding: 0;
|
||
border: 0;
|
||
border-radius: 0;
|
||
background: transparent;
|
||
}
|
||
.percent-box input:focus {
|
||
outline: none;
|
||
}
|
||
.percent-box:focus-within {
|
||
border-color: var(--primary);
|
||
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.12);
|
||
}
|
||
.percent-box.disabled {
|
||
background: #f1f5f9;
|
||
color: var(--muted);
|
||
}
|
||
.percent-box input:disabled {
|
||
color: var(--muted);
|
||
-webkit-text-fill-color: var(--muted);
|
||
}
|
||
.percent-box input::placeholder {
|
||
color: var(--muted);
|
||
opacity: 0.72;
|
||
}
|
||
.empty {
|
||
padding: 34px 12px;
|
||
color: var(--muted);
|
||
text-align: center;
|
||
background: var(--panel);
|
||
border: 1px solid var(--line);
|
||
border-radius: 8px;
|
||
}
|
||
@media (max-width: 900px) {
|
||
header {
|
||
align-items: flex-start;
|
||
flex-direction: column;
|
||
}
|
||
.action-panel {
|
||
align-items: stretch;
|
||
width: 100%;
|
||
}
|
||
main {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
.form-grid {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
.groups {
|
||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||
}
|
||
.switch-row {
|
||
margin-top: 0;
|
||
}
|
||
.card-header {
|
||
align-items: flex-start;
|
||
flex-direction: column;
|
||
}
|
||
}
|
||
@media (max-width: 560px) {
|
||
.group-head {
|
||
align-items: flex-start;
|
||
flex-direction: column;
|
||
}
|
||
.groups {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<header>
|
||
<h1>AB Test Config</h1>
|
||
<div class="action-panel">
|
||
<div class="top-actions">
|
||
<button id="readBtn" class="secondary">读取配置</button>
|
||
<button id="saveBtn">保存配置</button>
|
||
<button id="loadBtn" class="secondary">刷新线上配置</button>
|
||
<button id="sampleBtn" class="secondary">恢复示例</button>
|
||
</div>
|
||
<div class="publish-warning">请确保前端相应功能已上线,再发布配置!</div>
|
||
</div>
|
||
</header>
|
||
|
||
<main>
|
||
<div class="side-panel">
|
||
<section class="settings">
|
||
<label for="apiUrl">abConfigAdmin 云函数: URL</label>
|
||
<input id="apiUrl" placeholder="https://q6rvwvtnga.sealoshzh.site/abConfigAdmin/">
|
||
|
||
<label for="adminToken">Admin Token</label>
|
||
<input id="adminToken" type="password" placeholder="测试默认: abtest-admin-token">
|
||
|
||
<div class="status" id="status"></div>
|
||
</section>
|
||
|
||
<section class="json-preview">
|
||
<h2>最终生成 JSON</h2>
|
||
<pre id="configPreview"></pre>
|
||
</section>
|
||
</div>
|
||
|
||
<section class="workspace">
|
||
<div id="layerList" class="layer-list"></div>
|
||
<button id="addLayerBtn" class="add-layer-btn">+ 新增 Layer</button>
|
||
</section>
|
||
</main>
|
||
|
||
<script>
|
||
const apiUrl = document.getElementById("apiUrl");
|
||
const adminToken = document.getElementById("adminToken");
|
||
const statusEl = document.getElementById("status");
|
||
const layerList = document.getElementById("layerList");
|
||
const configPreview = document.getElementById("configPreview");
|
||
|
||
let layers = createSampleLayers();
|
||
|
||
apiUrl.value = localStorage.getItem("ab_api_url") || getDefaultApiUrl();
|
||
adminToken.value = localStorage.getItem("ab_admin_token") || "";
|
||
|
||
document.getElementById("readBtn").onclick = readConfig;
|
||
document.getElementById("saveBtn").onclick = saveConfig;
|
||
document.getElementById("loadBtn").onclick = loadList;
|
||
document.getElementById("sampleBtn").onclick = () => {
|
||
layers = createSampleLayers();
|
||
render();
|
||
setStatus("已恢复示例");
|
||
};
|
||
document.getElementById("addLayerBtn").onclick = () => {
|
||
layers.push(createLayer(layers.length));
|
||
render();
|
||
const cards = layerList.querySelectorAll(".layer-card");
|
||
cards[cards.length - 1]?.scrollIntoView({ behavior: "smooth", block: "center" });
|
||
};
|
||
|
||
render();
|
||
|
||
function getDefaultApiUrl() {
|
||
return "https://q6rvwvtnga.sealoshzh.site/abConfigAdmin";
|
||
}
|
||
|
||
function createSampleLayers() {
|
||
return [
|
||
{
|
||
experimentNo: 1,
|
||
layer: "1",
|
||
name: "首页UI",
|
||
enabled: true,
|
||
resetAssignments: false,
|
||
equalSplit: true,
|
||
startTime: 0,
|
||
endTime: 0,
|
||
variants: [
|
||
{ group: "A", ratio: 50 },
|
||
{ group: "B", ratio: 50 }
|
||
]
|
||
},
|
||
{
|
||
experimentNo: 1,
|
||
layer: "2",
|
||
name: "关卡配置",
|
||
enabled: true,
|
||
resetAssignments: false,
|
||
equalSplit: false,
|
||
startTime: 0,
|
||
endTime: 0,
|
||
variants: [
|
||
{ group: "A", ratio: 34 },
|
||
{ group: "B", ratio: 33 },
|
||
{ group: "C", ratio: 33 }
|
||
]
|
||
}
|
||
];
|
||
}
|
||
|
||
function createLayer(index) {
|
||
const layerNo = String(index + 1);
|
||
return {
|
||
experimentNo: 1,
|
||
layer: layerNo,
|
||
name: `Layer ${layerNo}`,
|
||
enabled: true,
|
||
resetAssignments: false,
|
||
equalSplit: true,
|
||
startTime: 0,
|
||
endTime: 0,
|
||
variants: [
|
||
{ group: "A", ratio: 50 },
|
||
{ group: "B", ratio: 50 }
|
||
]
|
||
};
|
||
}
|
||
|
||
function remember() {
|
||
localStorage.setItem("ab_api_url", apiUrl.value.trim());
|
||
localStorage.setItem("ab_admin_token", adminToken.value);
|
||
}
|
||
|
||
async function callAdmin(payload) {
|
||
remember();
|
||
const url = apiUrl.value.trim();
|
||
if (!url) {
|
||
throw new Error("请填写云函数 URL");
|
||
}
|
||
const res = await fetch(url, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ adminToken: adminToken.value, ...payload })
|
||
});
|
||
const text = await res.text();
|
||
try {
|
||
return JSON.parse(text);
|
||
} catch {
|
||
throw new Error("云函数地址返回的不是 JSON,请检查 URL");
|
||
}
|
||
}
|
||
|
||
async function saveConfig() {
|
||
try {
|
||
const config = buildConfig();
|
||
const error = validateLayers(config);
|
||
if (error) {
|
||
setStatus(error, true);
|
||
return;
|
||
}
|
||
|
||
// The legacy save API only upserts submitted experiments. Read the
|
||
// current server list so experiments removed in the UI can be deleted
|
||
// explicitly after the remaining configuration is saved.
|
||
const onlineRet = await callAdmin({ action: "list" });
|
||
if (onlineRet.code !== 1) {
|
||
setStatus(onlineRet.msg || "读取线上配置失败", true);
|
||
return;
|
||
}
|
||
const currentExperimentIds = new Set(config.map(item => item.experimentId));
|
||
const removedExperimentIds = [...new Set(
|
||
(onlineRet.data || [])
|
||
.map(item => item.experimentId)
|
||
.filter(experimentId => experimentId && !currentExperimentIds.has(experimentId))
|
||
)];
|
||
|
||
const ret = await callAdmin({ action: "save", config });
|
||
if (ret.code !== 1) {
|
||
setStatus(ret.msg || "保存失败", true);
|
||
return;
|
||
}
|
||
|
||
for (const experimentId of removedExperimentIds) {
|
||
const deleteRet = await callAdmin({ action: "delete", experimentId });
|
||
if (deleteRet.code !== 1) {
|
||
setStatus(deleteRet.msg || `删除旧方案 ${experimentId} 失败`, true);
|
||
return;
|
||
}
|
||
}
|
||
|
||
setStatus(ret.msg || "保存成功");
|
||
await loadList();
|
||
} catch (e) {
|
||
setStatus(e.message, true);
|
||
}
|
||
}
|
||
|
||
async function readConfig() {
|
||
await loadList("已读取线上配置并覆盖当前页面");
|
||
}
|
||
|
||
async function loadList(successMessage = "已刷新线上配置") {
|
||
try {
|
||
const ret = await callAdmin({ action: "list" });
|
||
if (ret.code !== 1) {
|
||
setStatus(ret.msg || "读取失败", true);
|
||
return;
|
||
}
|
||
const list = (ret.data || []).sort((a, b) => Number(a.layer) - Number(b.layer));
|
||
layers = list.map(normalizeLayer);
|
||
render();
|
||
setStatus(successMessage);
|
||
} catch (e) {
|
||
setStatus(e.message, true);
|
||
}
|
||
}
|
||
|
||
function buildConfig() {
|
||
return layers.map((layer, index) => ({
|
||
layer: String(index + 1),
|
||
experimentId: makeExperimentId(layer, index),
|
||
name: layer.name.trim(),
|
||
enabled: !!layer.enabled,
|
||
resetAssignments: layer.resetAssignments === true,
|
||
startTime: 0,
|
||
endTime: 0,
|
||
variants: getFinalVariants(layer).map(variant => ({
|
||
group: variant.group.trim(),
|
||
ratio: Number(variant.ratio)
|
||
}))
|
||
}));
|
||
}
|
||
|
||
function validateLayers(config) {
|
||
const layerSet = {};
|
||
const experimentSet = {};
|
||
for (const item of config) {
|
||
if (!item.layer) return "Layer 不能为空";
|
||
if (!item.name) return `Layer ${item.layer} 的名字不能为空`;
|
||
if (!item.experimentId) return `Layer ${item.layer} 的实验编号不能为空`;
|
||
if (layerSet[item.layer]) return `Layer ${item.layer} 重复`;
|
||
if (experimentSet[item.experimentId]) return `Layer ${item.layer} 的实验编号重复`;
|
||
layerSet[item.layer] = true;
|
||
experimentSet[item.experimentId] = true;
|
||
if (item.variants.length < 2) return `Layer ${item.layer} 至少需要 2 个组`;
|
||
const groupSet = {};
|
||
let total = 0;
|
||
for (const variant of item.variants) {
|
||
if (!variant.group) return `Layer ${item.layer} 存在空组名`;
|
||
if (groupSet[variant.group]) return `Layer ${item.layer} 组名 ${variant.group} 重复`;
|
||
groupSet[variant.group] = true;
|
||
if (!(variant.ratio > 0)) return `Layer ${item.layer} 的比例必须大于 0`;
|
||
if (variant.ratio > 100) return `Layer ${item.layer} 的比例不能超过 100%`;
|
||
total += variant.ratio;
|
||
}
|
||
if (Math.abs(total - 100) > 0.01) return `Layer ${item.layer} 的比例需要合计为 100%`;
|
||
}
|
||
return "";
|
||
}
|
||
|
||
function normalizeLayer(item) {
|
||
return {
|
||
experimentNo: parseExperimentNo(item.experimentId),
|
||
layer: String(item.layer || ""),
|
||
name: item.name || "",
|
||
enabled: item.enabled !== false,
|
||
resetAssignments: item.resetAssignments === true,
|
||
equalSplit: isEqualSplit(item.variants || []),
|
||
startTime: Number(item.startTime || 0),
|
||
endTime: Number(item.endTime || 0),
|
||
variants: (item.variants || []).map(variant => ({
|
||
group: variant.group || "",
|
||
ratio: Number(variant.ratio || 0)
|
||
}))
|
||
};
|
||
}
|
||
|
||
function render() {
|
||
layerList.innerHTML = "";
|
||
if (!layers.length) {
|
||
layerList.innerHTML = `<div class="empty">暂无 Layer</div>`;
|
||
renderPreview();
|
||
return;
|
||
}
|
||
layers.forEach((layer, index) => {
|
||
layerList.appendChild(renderLayerCard(layer, index));
|
||
});
|
||
renderPreview();
|
||
}
|
||
|
||
function renderPreview() {
|
||
try {
|
||
configPreview.textContent = JSON.stringify(buildConfig(), null, 2);
|
||
} catch (e) {
|
||
configPreview.textContent = e.message;
|
||
}
|
||
}
|
||
|
||
function renderLayerCard(layer, index) {
|
||
const finalVariants = getFinalVariants(layer);
|
||
const card = document.createElement("div");
|
||
card.className = "layer-card";
|
||
card.innerHTML = `
|
||
<div class="card-header">
|
||
<div class="card-title">
|
||
<strong>Layer ${index + 1} ${escapeHtml(layer.name || "未命名")}</strong>
|
||
</div>
|
||
<div class="card-actions">
|
||
<label class="switch" title="启用这个 Layer">
|
||
启用
|
||
<input data-field="enabled" data-index="${index}" type="checkbox" ${layer.enabled ? "checked" : ""}>
|
||
<span></span>
|
||
</label>
|
||
<button class="danger" data-delete="${index}">删除</button>
|
||
</div>
|
||
</div>
|
||
<div class="card-body">
|
||
<div class="form-grid">
|
||
<div>
|
||
<label>Layer 名字</label>
|
||
<input data-field="name" data-index="${index}" value="${escapeAttr(layer.name)}">
|
||
</div>
|
||
<div>
|
||
<label>实验编号</label>
|
||
<input data-field="experimentNo" data-index="${index}" type="number" min="1" step="1" value="${escapeAttr(layer.experimentNo)}">
|
||
</div>
|
||
</div>
|
||
<div class="group-head">
|
||
<div class="group-tools">
|
||
<label class="switch" title="开启后,这个 Layer 的老用户也会按当前配置重新计算分组">
|
||
彻底重新分组
|
||
<input data-field="resetAssignments" data-index="${index}" type="checkbox" ${layer.resetAssignments ? "checked" : ""}>
|
||
<span></span>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
<div class="group-head">
|
||
<div class="group-tools">
|
||
<label for="groupCount${index}" style="margin:0;color:#475569;">分组个数</label>
|
||
<input id="groupCount${index}" data-group-count="${index}" class="group-count" type="number" min="2" max="8" value="${layer.variants.length}">
|
||
</div>
|
||
<div class="ratio-mode">
|
||
<label class="switch" title="自动均分所有组">
|
||
均分
|
||
<input data-equal-split="${index}" type="checkbox" ${layer.equalSplit ? "checked" : ""}>
|
||
<span></span>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
<div class="groups">
|
||
${layer.variants.map((variant, variantIndex) => `
|
||
<div class="group-item">
|
||
<div class="group-title">
|
||
<span>Group ${variantIndex + 1}</span>
|
||
<input data-group="${index}" data-variant="${variantIndex}" value="${escapeAttr(variant.group)}" style="width:72px;height:30px;">
|
||
</div>
|
||
<div class="percent-box ${isRatioReadonly(layer, variantIndex) ? "disabled" : ""}">
|
||
<input data-ratio-number="${index}" data-variant="${variantIndex}" type="number" min="0" max="100" step="0.01" value="${escapeAttr(getRatioInputValue(layer, finalVariants, variantIndex))}" placeholder="${formatRatio(getEqualSplitVariants(layer)[variantIndex].ratio)}" ${isRatioReadonly(layer, variantIndex) ? "disabled" : ""}>
|
||
<span>%</span>
|
||
</div>
|
||
</div>
|
||
`).join("")}
|
||
</div>
|
||
</div>
|
||
`;
|
||
bindCardEvents(card);
|
||
return card;
|
||
}
|
||
|
||
function bindCardEvents(card) {
|
||
card.querySelectorAll("[data-field]").forEach(input => {
|
||
input.oninput = event => {
|
||
const index = Number(event.target.dataset.index);
|
||
const field = event.target.dataset.field;
|
||
if (field === "enabled" || field === "resetAssignments") {
|
||
layers[index][field] = event.target.checked;
|
||
} else if (field === "experimentNo") {
|
||
layers[index][field] = Math.max(1, Math.floor(Number(event.target.value) || 1));
|
||
} else {
|
||
layers[index][field] = event.target.value;
|
||
}
|
||
renderPreview();
|
||
};
|
||
input.onchange = event => {
|
||
const field = event.target.dataset.field;
|
||
if (field === "enabled" || field === "resetAssignments") {
|
||
layers[Number(event.target.dataset.index)][field] = event.target.checked;
|
||
}
|
||
render();
|
||
};
|
||
});
|
||
card.querySelectorAll("[data-delete]").forEach(button => {
|
||
button.onclick = event => {
|
||
layers.splice(Number(event.target.dataset.delete), 1);
|
||
render();
|
||
};
|
||
});
|
||
card.querySelectorAll("[data-group-count]").forEach(input => {
|
||
input.onchange = event => {
|
||
const index = Number(event.target.dataset.groupCount);
|
||
setGroupCount(layers[index], Number(event.target.value));
|
||
render();
|
||
};
|
||
});
|
||
card.querySelectorAll("[data-group]").forEach(input => {
|
||
input.oninput = event => {
|
||
const index = Number(event.target.dataset.group);
|
||
const variantIndex = Number(event.target.dataset.variant);
|
||
layers[index].variants[variantIndex].group = event.target.value;
|
||
renderPreview();
|
||
};
|
||
});
|
||
card.querySelectorAll("[data-equal-split]").forEach(input => {
|
||
input.onchange = event => {
|
||
layers[Number(event.target.dataset.equalSplit)].equalSplit = event.target.checked;
|
||
render();
|
||
};
|
||
});
|
||
card.querySelectorAll("[data-ratio-number]").forEach(input => {
|
||
input.oninput = event => {
|
||
const index = Number(event.target.dataset.ratioNumber);
|
||
const variantIndex = Number(event.target.dataset.variant);
|
||
layers[index].variants[variantIndex].ratio = parseRatio(event.target.value);
|
||
renderPreview();
|
||
};
|
||
input.onchange = () => {
|
||
render();
|
||
};
|
||
});
|
||
}
|
||
|
||
function setGroupCount(layer, count) {
|
||
count = Math.max(2, Math.min(8, Math.floor(count || 2)));
|
||
const names = "ABCDEFGHIJKLMNOPQRSTUVWXYZ".split("");
|
||
while (layer.variants.length < count) {
|
||
layer.variants.push({ group: names[layer.variants.length], ratio: layer.equalSplit ? 0 : "" });
|
||
}
|
||
while (layer.variants.length > count) {
|
||
layer.variants.pop();
|
||
}
|
||
}
|
||
|
||
function getFinalVariants(layer) {
|
||
const variants = layer.variants.map(variant => ({
|
||
group: variant.group,
|
||
ratio: Number(variant.ratio || 0)
|
||
}));
|
||
if (!variants.length) {
|
||
return variants;
|
||
}
|
||
if (layer.equalSplit) {
|
||
return getEqualSplitVariants(layer);
|
||
}
|
||
let used = 0;
|
||
variants.forEach((variant, index) => {
|
||
if (index === variants.length - 1) {
|
||
variant.ratio = roundRatio(100 - used);
|
||
} else {
|
||
variant.ratio = roundRatio(variant.ratio);
|
||
used += variant.ratio;
|
||
}
|
||
});
|
||
return variants;
|
||
}
|
||
|
||
function getEqualSplitVariants(layer) {
|
||
const variants = layer.variants.map(variant => ({
|
||
group: variant.group,
|
||
ratio: 0
|
||
}));
|
||
if (!variants.length) {
|
||
return variants;
|
||
}
|
||
const base = roundRatio(100 / variants.length);
|
||
let used = 0;
|
||
variants.forEach((variant, index) => {
|
||
if (index === variants.length - 1) {
|
||
variant.ratio = roundRatio(100 - used);
|
||
} else {
|
||
variant.ratio = base;
|
||
used += base;
|
||
}
|
||
});
|
||
return variants;
|
||
}
|
||
|
||
function getRatioInputValue(layer, finalVariants, variantIndex) {
|
||
if (isRatioReadonly(layer, variantIndex)) {
|
||
return formatRatio(finalVariants[variantIndex].ratio);
|
||
}
|
||
const value = layer.variants[variantIndex].ratio;
|
||
if (value === "" || value === null || value === undefined) {
|
||
return "";
|
||
}
|
||
return formatRatio(value);
|
||
}
|
||
|
||
function isRatioReadonly(layer, variantIndex) {
|
||
return layer.equalSplit || variantIndex === layer.variants.length - 1;
|
||
}
|
||
|
||
function isEqualSplit(variants) {
|
||
if (!variants.length) {
|
||
return true;
|
||
}
|
||
const expected = getFinalVariants({ equalSplit: true, variants }).map(item => item.ratio);
|
||
return variants.every((variant, index) => Math.abs(Number(variant.ratio || 0) - expected[index]) <= 0.01);
|
||
}
|
||
|
||
function parseRatio(value) {
|
||
if (value === "") {
|
||
return "";
|
||
}
|
||
const number = Number(value);
|
||
if (!Number.isFinite(number)) {
|
||
return 0;
|
||
}
|
||
return roundRatio(number);
|
||
}
|
||
|
||
function roundRatio(value) {
|
||
return Math.round(Number(value || 0) * 100) / 100;
|
||
}
|
||
|
||
function formatRatio(value) {
|
||
return roundRatio(value).toFixed(2);
|
||
}
|
||
|
||
function makeExperimentId(layer, index) {
|
||
const layerCode = String(index + 1);
|
||
const experimentNo = Math.max(1, Math.floor(Number(layer.experimentNo) || 1));
|
||
return `exp_layer_${layerCode}_${String(experimentNo).padStart(3, "0")}`;
|
||
}
|
||
|
||
function parseExperimentNo(experimentId) {
|
||
const match = String(experimentId || "").match(/_(\d+)$/);
|
||
return match ? Math.max(1, Number(match[1])) : 1;
|
||
}
|
||
|
||
function setStatus(message, isError) {
|
||
statusEl.textContent = message;
|
||
statusEl.style.color = isError ? "var(--danger)" : "var(--primary)";
|
||
}
|
||
|
||
function escapeHtml(value) {
|
||
return String(value)
|
||
.replaceAll("&", "&")
|
||
.replaceAll("<", "<")
|
||
.replaceAll(">", ">")
|
||
.replaceAll('"', """)
|
||
.replaceAll("'", "'");
|
||
}
|
||
|
||
function escapeAttr(value) {
|
||
return escapeHtml(value);
|
||
}
|
||
</script>
|
||
</body>
|
||
</html>
|