Files
server/xxwz.html
2026-09-01 16:04:15 +08:00

959 lines
28 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 v1.0</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);
}
.reset-help {
margin-top: 8px;
color: var(--muted);
font-size: 12px;
line-height: 1.5;
}
.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 v1.0</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 云函数v1.0: 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 (variant.group.trim().toLowerCase() === "default") return `Layer ${item.layer} 的 Default 是系统保留组名`;
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>
<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 class="reset-help">关闭→开启并发布才会创建新一轮;每名老用户在开启期间最多处理一次。关闭后未登录用户不补做,状态不变再次发布也不会创建新一轮;禁用 Layer 时不生效。</div>
</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("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
function escapeAttr(value) {
return escapeHtml(value);
}
</script>
</body>
</html>