MatchMaster/server/xxwz.html

968 lines
28 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 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="请输入服务器配置的 Admin Token">
<div class="reset-help">须与目标云函数的 AB_ADMIN_TOKEN 环境变量一致;仅未设置时使用默认值 abtest-admin-token。</div>
<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) {
const url = apiUrl.value.trim();
if (!url) {
throw new Error("请填写云函数 URL");
}
if (!adminToken.value) {
throw new Error("请填写 Admin Token");
}
remember();
const res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ adminToken: adminToken.value, ...payload })
});
const text = await res.text();
let ret;
try {
ret = JSON.parse(text);
} catch {
throw new Error("云函数地址返回的不是 JSON,请检查 URL");
}
if (ret.msg === "unauthorized") {
throw new Error("鉴权失败(unauthorized):请确认云函数 URL 指向正确环境,Admin Token 与该环境的 AB_ADMIN_TOKEN 一致,且未多输入空格。abtest-admin-token 仅在服务器未设置 AB_ADMIN_TOKEN 时有效。");
}
return ret;
}
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>