MatchMaster/server/xxwzUI.html
2026-07-30 12:22:02 +08:00

949 lines
27 KiB
HTML
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.

<!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("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
function escapeAttr(value) {
return escapeHtml(value);
}
</script>
</body>
</html>