初始化奇妙小屏幕控制器项目

This commit is contained in:
2026-09-08 22:56:52 +08:00
commit 8d368de3b5
491 changed files with 67678 additions and 0 deletions
@@ -0,0 +1,2 @@
"""Build-gated UI copy editor support."""
@@ -0,0 +1,3 @@
"""This flag is changed only when producing a dedicated editing build."""
UI_COPY_EDITOR_BUILD_ENABLED = False
@@ -0,0 +1,213 @@
#ui-copy-editor-toggle {
position: fixed;
z-index: 1400;
right: max(16px, env(safe-area-inset-right));
bottom: max(16px, env(safe-area-inset-bottom));
min-width: 104px;
min-height: 48px;
border: 1px solid #61d6b4;
border-radius: 999px;
background: #0f5f50;
color: #fff;
box-shadow: 0 12px 32px rgb(0 0 0 / 45%);
font-weight: 800;
}
#ui-copy-editor-panel {
position: fixed;
z-index: 1390;
inset: 0 0 0 auto;
width: min(420px, calc(100vw - 24px));
overflow: auto;
padding: 18px 18px calc(84px + env(safe-area-inset-bottom));
border-left: 1px solid #374151;
background: #0d1118;
color: #f5f7fb;
box-shadow: -16px 0 48px rgb(0 0 0 / 55%);
font: 14px/1.45 system-ui, sans-serif;
}
#ui-copy-editor-panel[hidden] { display: none; }
.ui-copy-editor-header,
.ui-copy-editor-modes,
.ui-copy-editor-tools,
.ui-copy-list-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.ui-copy-editor-header { margin-bottom: 12px; }
.ui-copy-editor-header strong { display: block; font-size: 18px; }
.ui-copy-editor-header span { color: #9ca3af; font-size: 12px; }
.ui-copy-editor-header button { min-width: 44px; min-height: 44px; font-size: 24px; }
.ui-copy-editor-modes { margin-bottom: 10px; }
.ui-copy-editor-modes button { flex: 1; min-height: 42px; }
.ui-copy-editor-modes button.active { border-color: #61d6b4; background: #173b35; color: #8ff2d6; }
.ui-copy-save-status {
min-height: 38px;
margin: 8px 0;
padding: 9px 10px;
border-radius: 8px;
background: #171d27;
color: #b9c2d0;
}
.ui-copy-save-status[data-state="saved"] { color: #72e4c4; }
.ui-copy-save-status[data-state="pending"] { color: #ffd27a; }
.ui-copy-save-status[data-state="error"],
.ui-copy-save-status[data-state="conflict"] { color: #ff9b9b; }
.ui-copy-editor-tools { margin-bottom: 12px; }
.ui-copy-editor-tools button,
.ui-copy-editor-tools a {
min-height: 40px;
padding: 9px 12px;
border: 1px solid #394252;
border-radius: 8px;
background: #171d27;
color: #f5f7fb;
text-decoration: none;
}
.ui-copy-search,
.ui-copy-selected-form label { display: grid; gap: 6px; margin: 10px 0; }
.ui-copy-search input,
.ui-copy-selected-form input,
.ui-copy-selected-form textarea,
.ui-copy-selected-form select {
width: 100%;
min-height: 42px;
box-sizing: border-box;
border: 1px solid #3b4658;
border-radius: 8px;
background: #111722;
color: #fff;
padding: 9px 10px;
font: inherit;
}
.ui-copy-selected-form textarea { resize: vertical; }
.ui-copy-list-heading { margin: 14px 0 8px; color: #cdd5e1; }
.ui-copy-list { display: grid; gap: 6px; max-height: 40vh; overflow: auto; padding-right: 4px; }
.ui-copy-list-item {
display: grid;
gap: 3px;
width: 100%;
min-height: 48px;
padding: 9px 10px;
border: 1px solid #293242;
border-radius: 8px;
background: #141a24;
color: #f5f7fb;
text-align: left;
}
.ui-copy-list-item.active { border-color: #61d6b4; background: #17332f; }
.ui-copy-list-text { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ui-copy-list-meta { color: #8e9aab; font-size: 11px; }
.ui-copy-selected-form {
margin-top: 14px;
padding: 14px;
border: 1px solid #344054;
border-radius: 10px;
background: #141a24;
}
.ui-copy-selected-form h3 { margin: 0 0 4px; font-size: 16px; overflow-wrap: anywhere; }
.ui-copy-selected-form > p { margin: 4px 0 8px; color: #9ca8b8; overflow-wrap: anywhere; }
.ui-copy-form-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 10px; }
.ui-copy-form-actions > button { flex: 1; min-height: 44px; }
.ui-copy-confirm-button {
min-height: 46px;
border-color: #61d6b4 !important;
background: #0f5f50 !important;
color: #fff !important;
font-weight: 800;
}
.ui-copy-danger-button {
width: 100%;
min-height: 44px;
margin-top: 10px;
border: 1px solid #ff7777 !important;
background: #552126 !important;
color: #ffd6d6 !important;
font-weight: 800;
}
.ui-copy-danger-button:disabled { opacity: .6; }
.ui-copy-delete-confirmation {
margin-top: 10px;
padding: 12px;
border: 1px solid #ff7777;
border-radius: 8px;
background: #30171b;
}
.ui-copy-delete-confirmation[hidden] { display: none; }
.ui-copy-delete-confirmation p { margin: 6px 0; color: #ffd6d6; }
.ui-copy-form-error {
margin: 8px 0 !important;
padding: 9px 10px;
border-left: 3px solid #ff7777;
border-radius: 5px;
background: #30171b;
color: #ffb2b2 !important;
}
.ui-copy-form-error[hidden] { display: none; }
.ui-copy-checkbox { grid-template-columns: auto 1fr !important; align-items: center; }
.ui-copy-checkbox input { width: 22px; min-height: 22px; }
.ui-copy-placeholder-note { color: #ffd27a !important; }
[data-ui-copy-editor-mode="select"] [data-ui-copy-id] { cursor: crosshair !important; }
[data-ui-copy-editor-mode="select"] [data-ui-copy-id]:hover,
.ui-copy-selected {
outline: 3px solid #61d6b4 !important;
outline-offset: 3px;
border-radius: 3px;
}
.ui-copy-anchor-candidate { outline: 2px dashed #d8a8ff !important; outline-offset: 3px; cursor: crosshair !important; }
.ui-copy-anchor-candidate:hover { outline-style: solid !important; background-image: linear-gradient(rgb(126 71 177 / 10%), rgb(126 71 177 / 10%)); }
.ui-copy-custom-insertion {
box-sizing: border-box;
max-width: min(100%, 58rem);
margin: 10px auto;
padding: 10px 12px;
border-left: 3px solid #61d6b4;
border-radius: 6px;
background: #151c26;
color: #e7edf6;
overflow-wrap: anywhere;
white-space: pre-wrap;
}
@media (max-width: 40rem) {
#ui-copy-editor-panel {
inset: auto 0 0 0;
width: auto;
max-height: min(78vh, 720px);
padding: 14px 14px calc(78px + env(safe-area-inset-bottom));
border-top: 1px solid #374151;
border-left: 0;
border-radius: 18px 18px 0 0;
box-shadow: 0 -16px 48px rgb(0 0 0 / 55%);
}
.ui-copy-list { max-height: 24vh; }
html[data-ui-copy-editor-selection="false"][data-ui-copy-editor-mode="select"] #ui-copy-editor-panel,
html[data-ui-copy-editor-selection="false"][data-ui-copy-editor-mode="insert"] #ui-copy-editor-panel {
max-height: 172px;
padding-bottom: calc(12px + env(safe-area-inset-bottom));
overflow: hidden;
}
html[data-ui-copy-editor-selection="false"][data-ui-copy-editor-mode="select"] #ui-copy-editor-panel :is(.ui-copy-editor-tools, .ui-copy-search, .ui-copy-list-heading, .ui-copy-list, .ui-copy-selected-form),
html[data-ui-copy-editor-selection="false"][data-ui-copy-editor-mode="insert"] #ui-copy-editor-panel :is(.ui-copy-editor-tools, .ui-copy-search, .ui-copy-list-heading, .ui-copy-list, .ui-copy-selected-form) {
display: none !important;
}
}
@media (prefers-reduced-motion: no-preference) {
#ui-copy-editor-toggle { transition: transform 120ms ease; }
#ui-copy-editor-toggle:active { transform: scale(.97); }
}
@@ -0,0 +1,605 @@
const bootstrap = globalThis.__UI_COPY_EDITOR_BUILD__;
if (!bootstrap) {
throw new Error("UI copy editor bootstrap is missing");
}
const runtime = await import(bootstrap.runtime_url);
const placeholderPattern = /\{([a-zA-Z][a-zA-Z0-9_]*)\}/g;
const functionalValuePattern = /^[+\-]?(?:\d+(?:\.\d+)?|#[0-9a-fA-F]{3,8})(?:\s*(?:%|°|Hz|ms|px|V))?$/;
const [catalogResponse, draftResponse] = await Promise.all([
fetch(bootstrap.catalog_url, { cache: "no-store" }),
fetch(bootstrap.api_url, { cache: "no-store" }),
]);
if (!catalogResponse.ok || !draftResponse.ok) {
throw new Error("无法载入文案目录或草稿");
}
const catalog = await catalogResponse.json();
let draft = await draftResponse.json();
let etag = draftResponse.headers.get("ETag") || `"${draft.revision}"`;
let mode = "browse";
let selectedCopyId = null;
let selectedAnchorId = null;
let selectedInsertionId = null;
let saveBusy = false;
const history = [];
const staticTextNodes = new Map();
const toggle = document.createElement("button");
toggle.id = "ui-copy-editor-toggle";
toggle.type = "button";
toggle.textContent = "文案编辑";
toggle.setAttribute("aria-controls", "ui-copy-editor-panel");
toggle.setAttribute("aria-expanded", "false");
const panel = document.createElement("aside");
panel.id = "ui-copy-editor-panel";
panel.hidden = true;
panel.setAttribute("aria-label", "文案编辑器");
panel.innerHTML = `
<div class="ui-copy-editor-header">
<div><strong>文案编辑器</strong><span>输入不会立即改变页面</span></div>
<button id="ui-copy-editor-close" type="button" aria-label="关闭文案编辑器">×</button>
</div>
<div class="ui-copy-editor-modes" role="group" aria-label="编辑模式">
<button type="button" data-mode="browse">浏览页面</button>
<button type="button" data-mode="select">选择文字</button>
<button type="button" data-mode="insert">添加提示</button>
</div>
<div id="ui-copy-save-status" class="ui-copy-save-status" role="status" aria-live="polite">草稿已载入,等待操作</div>
<div class="ui-copy-editor-tools">
<button id="ui-copy-undo" type="button" disabled>撤销上次确认</button>
<a href="${bootstrap.export_url}" download>导出草稿</a>
</div>
<label class="ui-copy-search">搜索全部程序文案
<input id="ui-copy-search-input" type="search" autocomplete="off" placeholder="输入文字或 copy_id">
</label>
<div class="ui-copy-list-heading"><span>已登记文案与新增提示</span><span id="ui-copy-list-count"></span></div>
<div id="ui-copy-list" class="ui-copy-list"></div>
<form id="ui-copy-selected-form" class="ui-copy-selected-form" hidden novalidate>
<h3 id="ui-copy-selected-title"></h3>
<p id="ui-copy-selected-source"></p>
<label>文字内容<textarea id="ui-copy-text" rows="4" maxlength="2000"></textarea></label>
<p id="ui-copy-placeholder-note" class="ui-copy-placeholder-note" hidden></p>
<label id="ui-copy-font-label">字号(8–64 CSS px)
<input id="ui-copy-font-size" type="number" min="8" max="64" step="1" placeholder="保持默认字号">
</label>
<p id="ui-copy-form-error" class="ui-copy-form-error" role="alert" hidden></p>
<div class="ui-copy-form-actions">
<button id="ui-copy-confirm" class="ui-copy-confirm-button" type="submit">确定修改</button>
<button id="ui-copy-restore" type="button">恢复默认</button>
</div>
<button id="ui-copy-delete" class="ui-copy-danger-button" type="button">删除这行文字</button>
<div id="ui-copy-delete-confirmation" class="ui-copy-delete-confirmation" hidden>
<strong>确定隐藏这行文字?</strong>
<p>只隐藏文字,不会删除按钮、输入框或功能区域。</p>
<div class="ui-copy-form-actions">
<button id="ui-copy-delete-confirm" class="ui-copy-danger-button" type="button">确认删除文字</button>
<button id="ui-copy-delete-cancel" type="button">取消</button>
</div>
</div>
</form>
<form id="ui-copy-insertion-form" class="ui-copy-selected-form" hidden novalidate>
<h3 id="ui-copy-insertion-title">新增提示文字</h3>
<p id="ui-copy-insertion-source"></p>
<label>文字内容<textarea id="ui-copy-insertion-text" rows="4" maxlength="2000"></textarea></label>
<label>字号(8–64 CSS px)<input id="ui-copy-insertion-size" type="number" min="8" max="64" value="16" required></label>
<label>插入位置<select id="ui-copy-insertion-placement"><option value="before">锚点上方</option><option value="after">锚点下方</option></select></label>
<p id="ui-copy-insertion-error" class="ui-copy-form-error" role="alert" hidden></p>
<button class="ui-copy-confirm-button" type="submit">确定修改</button>
<button id="ui-copy-insertion-delete" class="ui-copy-danger-button" type="button" hidden>删除这条新增提示</button>
<div id="ui-copy-insertion-delete-confirmation" class="ui-copy-delete-confirmation" hidden>
<strong>确定删除这条新增提示?</strong>
<div class="ui-copy-form-actions">
<button id="ui-copy-insertion-delete-confirm" class="ui-copy-danger-button" type="button">确认删除提示</button>
<button id="ui-copy-insertion-delete-cancel" type="button">取消</button>
</div>
</div>
</form>
`;
document.body.append(toggle, panel);
const elements = {
status: panel.querySelector("#ui-copy-save-status"),
list: panel.querySelector("#ui-copy-list"),
count: panel.querySelector("#ui-copy-list-count"),
search: panel.querySelector("#ui-copy-search-input"),
undo: panel.querySelector("#ui-copy-undo"),
copyForm: panel.querySelector("#ui-copy-selected-form"),
copyTitle: panel.querySelector("#ui-copy-selected-title"),
copySource: panel.querySelector("#ui-copy-selected-source"),
text: panel.querySelector("#ui-copy-text"),
font: panel.querySelector("#ui-copy-font-size"),
fontLabel: panel.querySelector("#ui-copy-font-label"),
placeholderNote: panel.querySelector("#ui-copy-placeholder-note"),
formError: panel.querySelector("#ui-copy-form-error"),
restore: panel.querySelector("#ui-copy-restore"),
delete: panel.querySelector("#ui-copy-delete"),
deleteConfirmation: panel.querySelector("#ui-copy-delete-confirmation"),
insertionForm: panel.querySelector("#ui-copy-insertion-form"),
insertionTitle: panel.querySelector("#ui-copy-insertion-title"),
insertionSource: panel.querySelector("#ui-copy-insertion-source"),
insertionText: panel.querySelector("#ui-copy-insertion-text"),
insertionSize: panel.querySelector("#ui-copy-insertion-size"),
insertionPlacement: panel.querySelector("#ui-copy-insertion-placement"),
insertionError: panel.querySelector("#ui-copy-insertion-error"),
insertionDelete: panel.querySelector("#ui-copy-insertion-delete"),
insertionDeleteConfirmation: panel.querySelector("#ui-copy-insertion-delete-confirmation"),
};
function normalizedText(value) {
return String(value).replace(/\s+/g, " ").trim();
}
function editableTextNodes(element) {
return Array.from(element.childNodes).filter((node) => {
if (node.nodeType !== Node.TEXT_NODE) return false;
const text = normalizedText(node.nodeValue || "");
return text && text !== "-" && !functionalValuePattern.test(text);
});
}
function wrapTextNode(node, text) {
const span = document.createElement("span");
span.className = "ui-copy-editable-text";
span.textContent = text;
const raw = node.nodeValue || "";
const leading = raw.match(/^\s*/)?.[0] || "";
const trailing = raw.match(/\s*$/)?.[0] || "";
node.replaceWith(document.createTextNode(leading), span, document.createTextNode(trailing));
return span;
}
function bindStaticCatalog() {
for (const [copyId, item] of Object.entries(catalog.items)) {
const binding = item.render;
if (!binding || binding.type === "dynamic_template") continue;
let host;
try {
host = document.querySelector(binding.selector);
} catch (_error) {
continue;
}
if (!host || panel.contains(host)) continue;
if (binding.type === "attribute") {
runtime.registerStaticUiCopy(copyId, host, host, item.text, item.kind, binding.attribute);
continue;
}
if (!staticTextNodes.has(binding.selector)) {
staticTextNodes.set(binding.selector, editableTextNodes(host));
}
const node = staticTextNodes.get(binding.selector)[binding.text_index];
let target;
if (node?.isConnected) target = host.tagName === "OPTION" ? host : wrapTextNode(node, item.text);
else if (binding.default_hidden) {
target = document.createElement("span");
target.className = "ui-copy-editable-text";
host.append(target);
} else continue;
runtime.registerStaticUiCopy(copyId, target, host, item.text, item.kind, null, binding.default_hidden);
}
}
function bindAnchors() {
for (const [anchorId, anchor] of Object.entries(catalog.anchors)) {
let element;
try {
element = document.querySelector(anchor.selector);
} catch (_error) {
continue;
}
if (element && !panel.contains(element)) element.dataset.uiCopyAnchorId = anchorId;
}
}
function setStatus(text, state = "idle") {
elements.status.textContent = text;
elements.status.dataset.state = state;
}
function showError(element, text = "") {
element.textContent = text;
element.hidden = !text;
}
function clone(value) {
return JSON.parse(JSON.stringify(value));
}
function writableDocument(value) {
return {
schema_version: value.schema_version,
base_catalog_digest: value.base_catalog_digest,
revision: value.revision,
updated_at: value.updated_at,
overrides: clone(value.overrides),
insertions: clone(value.insertions),
};
}
function placeholders(value) {
return Array.from(String(value).matchAll(placeholderPattern), (match) => match[1]).sort();
}
function samePlaceholders(left, right) {
return JSON.stringify(placeholders(left)) === JSON.stringify(placeholders(right));
}
function effectiveText(copyId) {
return draft.overrides[copyId]?.text ?? catalog.items[copyId].text;
}
function insertionElementId(id) {
return `ui-copy-insertion-${id}`;
}
function applyInsertions() {
document.querySelectorAll(".ui-copy-custom-insertion").forEach((element) => element.remove());
for (const insertion of draft.insertions) {
const anchor = document.querySelector(`[data-ui-copy-anchor-id="${CSS.escape(insertion.anchor_id)}"]`);
if (!anchor) continue;
const element = document.createElement("p");
element.id = insertionElementId(insertion.id);
element.className = "ui-copy-custom-insertion";
element.dataset.uiCopyInsertionId = insertion.id;
element.textContent = insertion.text;
element.style.fontSize = `${insertion.font_size_px}px`;
anchor.insertAdjacentElement(insertion.placement === "before" ? "beforebegin" : "afterend", element);
}
}
function updateSelectionOutline() {
document.querySelectorAll(".ui-copy-selected").forEach((element) => element.classList.remove("ui-copy-selected"));
if (selectedCopyId) {
runtime.uiCopyRecords(selectedCopyId).forEach((record) => record.target.classList.add("ui-copy-selected"));
} else if (selectedInsertionId) {
document.getElementById(insertionElementId(selectedInsertionId))?.classList.add("ui-copy-selected");
}
}
function renderList() {
const query = normalizedText(elements.search.value).toLocaleLowerCase();
const entries = Object.entries(catalog.items)
.filter(([copyId, item]) => `${copyId} ${item.text} ${effectiveText(copyId)} ${item.scope}`.toLocaleLowerCase().includes(query))
.map(([copyId, item]) => ({ type: "copy", id: copyId, text: effectiveText(copyId), meta: `${item.scope} · ${item.render.type}` }));
for (const insertion of draft.insertions) {
if (`${insertion.id} ${insertion.text}`.toLocaleLowerCase().includes(query)) {
entries.push({ type: "insertion", id: insertion.id, text: insertion.text, meta: "新增提示" });
}
}
elements.list.replaceChildren();
for (const entry of entries) {
const button = document.createElement("button");
button.type = "button";
button.className = "ui-copy-list-item";
button.classList.toggle("active", entry.id === selectedCopyId || entry.id === selectedInsertionId);
const text = document.createElement("span");
text.className = "ui-copy-list-text";
text.textContent = entry.text || "(已隐藏)";
const meta = document.createElement("span");
meta.className = "ui-copy-list-meta";
meta.textContent = entry.type === "copy" && !runtime.uiCopyRecords(entry.id).length
? `${entry.meta} · 当前未显示`
: entry.meta;
button.append(text, meta);
button.addEventListener("click", () => entry.type === "copy" ? selectCopy(entry.id) : selectInsertion(entry.id));
elements.list.append(button);
}
elements.count.textContent = String(entries.length);
}
function resetCopyFields(copyId) {
const item = catalog.items[copyId];
const override = draft.overrides[copyId] || {};
elements.text.value = override.text ?? item.text;
elements.font.value = override.font_size_px ?? "";
const effectiveHidden = Object.hasOwn(override, "hidden") ? override.hidden : Boolean(item.render.default_hidden);
elements.delete.textContent = effectiveHidden ? "恢复显示这行文字" : "删除这行文字";
elements.delete.disabled = item.kind === "html_attribute";
elements.restore.disabled = !draft.overrides[copyId];
elements.fontLabel.hidden = item.kind === "html_attribute";
elements.delete.hidden = item.kind === "html_attribute";
const tokens = placeholders(item.text);
elements.placeholderNote.hidden = tokens.length === 0;
elements.placeholderNote.textContent = tokens.length ? `必须原样保留变量:${tokens.map((token) => `{${token}}`).join("、")}` : "";
elements.deleteConfirmation.hidden = true;
showError(elements.formError);
}
function selectCopy(copyId) {
if (!catalog.items[copyId]) return;
selectedCopyId = copyId;
selectedInsertionId = null;
selectedAnchorId = null;
const item = catalog.items[copyId];
elements.copyTitle.textContent = copyId;
elements.copySource.textContent = `${item.scope} · ${item.source}`;
resetCopyFields(copyId);
elements.copyForm.hidden = false;
elements.insertionForm.hidden = true;
document.documentElement.dataset.uiCopyEditorSelection = "true";
updateSelectionOutline();
renderList();
elements.text.focus();
}
function selectInsertion(insertionId) {
const insertion = draft.insertions.find((item) => item.id === insertionId);
if (!insertion) return;
selectedCopyId = null;
selectedAnchorId = insertion.anchor_id;
selectedInsertionId = insertionId;
elements.insertionTitle.textContent = "修改新增提示";
elements.insertionSource.textContent = `${insertion.workspace_id} · ${insertion.anchor_id}`;
elements.insertionText.value = insertion.text;
elements.insertionSize.value = insertion.font_size_px;
elements.insertionPlacement.value = insertion.placement;
elements.insertionDelete.hidden = false;
elements.insertionDeleteConfirmation.hidden = true;
showError(elements.insertionError);
elements.copyForm.hidden = true;
elements.insertionForm.hidden = false;
document.documentElement.dataset.uiCopyEditorSelection = "true";
updateSelectionOutline();
renderList();
}
function selectAnchor(anchorId) {
const anchor = catalog.anchors[anchorId];
if (!anchor) return;
selectedCopyId = null;
selectedInsertionId = null;
selectedAnchorId = anchorId;
elements.insertionTitle.textContent = "新增提示文字";
elements.insertionSource.textContent = `${anchor.scope} · ${anchor.selector}`;
elements.insertionText.value = "";
elements.insertionSize.value = "16";
elements.insertionPlacement.value = "after";
elements.insertionDelete.hidden = true;
elements.insertionDeleteConfirmation.hidden = true;
showError(elements.insertionError);
elements.copyForm.hidden = true;
elements.insertionForm.hidden = false;
document.documentElement.dataset.uiCopyEditorSelection = "true";
updateSelectionOutline();
renderList();
elements.insertionText.focus();
}
function setMode(nextMode) {
mode = nextMode;
document.documentElement.dataset.uiCopyEditorMode = mode;
document.documentElement.dataset.uiCopyEditorSelection = String(Boolean(selectedCopyId || selectedInsertionId || selectedAnchorId));
panel.querySelectorAll("[data-mode]").forEach((button) => button.classList.toggle("active", button.dataset.mode === mode));
document.querySelectorAll("[data-ui-copy-anchor-id]").forEach((element) => {
element.classList.toggle("ui-copy-anchor-candidate", mode === "insert");
});
if (mode === "select") setStatus("点击页面中带轮廓的程序文案;原控件不会被触发");
else if (mode === "insert") setStatus("点击页面中的虚线区域,选择新增提示位置");
else setStatus("浏览模式:页面功能保持可用");
}
async function commitCandidate(candidate, { recordHistory = true, afterSave = null } = {}) {
if (saveBusy) return false;
saveBusy = true;
setStatus("正在保存,页面尚未改变…", "saving");
const previous = writableDocument(draft);
try {
const response = await fetch(bootstrap.api_url, {
method: "PUT",
headers: { "Content-Type": "application/json", "If-Match": etag },
body: JSON.stringify(writableDocument(candidate)),
});
if (!response.ok) {
let message = `保存失败(${response.status})`;
try {
const detail = await response.json();
if (detail?.detail) message = detail.detail;
} catch (_error) {}
if (response.status === 409) setStatus(`草稿冲突:${message}。页面和输入内容均未改变,请刷新后重试。`, "conflict");
else setStatus(`${message}。页面和输入内容均未改变,可直接重试。`, "error");
return false;
}
const saved = await response.json();
etag = response.headers.get("ETag") || `"${saved.revision}"`;
if (recordHistory) history.push(previous);
draft = saved;
runtime.setUiCopyDraft(draft);
applyInsertions();
afterSave?.();
elements.undo.disabled = history.length === 0;
updateSelectionOutline();
renderList();
setStatus(`已保存修订 ${draft.revision},页面已更新`, "saved");
return true;
} catch (error) {
setStatus(`保存失败:${error.message}。页面和输入内容均未改变,可直接重试。`, "error");
return false;
} finally {
saveBusy = false;
}
}
function copyCandidate(copyId, override) {
const candidate = writableDocument(draft);
if (Object.keys(override).length) candidate.overrides[copyId] = override;
else delete candidate.overrides[copyId];
return candidate;
}
elements.copyForm.addEventListener("input", () => {
showError(elements.formError);
setStatus("修改暂存在编辑框中;点击“确定修改”前页面不会改变", "pending");
});
elements.copyForm.addEventListener("submit", async (event) => {
event.preventDefault();
const copyId = selectedCopyId;
if (!copyId) return;
const item = catalog.items[copyId];
const prior = draft.overrides[copyId] || {};
const text = elements.text.value;
if (!text.trim()) {
resetCopyFields(copyId);
showError(elements.formError, "文字内容不能留空。已恢复到本次编辑前的文字,没有发送保存请求。");
setStatus("未保存:空文字已恢复,页面没有改变", "error");
return;
}
if (!samePlaceholders(text, item.text)) {
showError(elements.formError, "变量占位符不能删除、增加或改名。");
return;
}
const override = {};
if (text !== item.text) override.text = text;
if (item.kind !== "html_attribute" && elements.font.value !== "") {
const size = Number(elements.font.value);
if (!Number.isInteger(size) || size < 8 || size > 64) {
showError(elements.formError, "字号必须是 8 到 64 之间的整数。");
return;
}
override.font_size_px = size;
}
if (Object.hasOwn(prior, "hidden")) override.hidden = prior.hidden;
await commitCandidate(copyCandidate(copyId, override), { afterSave: () => resetCopyFields(copyId) });
});
elements.restore.addEventListener("click", async () => {
if (!selectedCopyId) return;
const copyId = selectedCopyId;
await commitCandidate(copyCandidate(copyId, {}), { afterSave: () => resetCopyFields(copyId) });
});
elements.delete.addEventListener("click", () => {
if (!selectedCopyId) return;
const item = catalog.items[selectedCopyId];
const current = draft.overrides[selectedCopyId] || {};
const effectiveHidden = Object.hasOwn(current, "hidden") ? current.hidden : Boolean(item.render.default_hidden);
if (effectiveHidden) {
const override = { ...current, hidden: false };
void commitCandidate(copyCandidate(selectedCopyId, override), { afterSave: () => resetCopyFields(selectedCopyId) });
return;
}
elements.deleteConfirmation.hidden = false;
elements.deleteConfirmation.querySelector("#ui-copy-delete-confirm").focus();
});
panel.querySelector("#ui-copy-delete-cancel").addEventListener("click", () => { elements.deleteConfirmation.hidden = true; });
panel.querySelector("#ui-copy-delete-confirm").addEventListener("click", async () => {
if (!selectedCopyId) return;
const copyId = selectedCopyId;
const override = { ...(draft.overrides[copyId] || {}), hidden: true };
await commitCandidate(copyCandidate(copyId, override), { afterSave: () => resetCopyFields(copyId) });
});
elements.insertionForm.addEventListener("input", () => {
showError(elements.insertionError);
setStatus("新增提示的修改尚未确认;页面不会改变", "pending");
});
elements.insertionForm.addEventListener("submit", async (event) => {
event.preventDefault();
if (!selectedAnchorId) return;
const text = elements.insertionText.value;
if (!text.trim()) {
const existing = draft.insertions.find((item) => item.id === selectedInsertionId);
if (existing) elements.insertionText.value = existing.text;
showError(elements.insertionError, existing
? "文字内容不能留空。已恢复到本次编辑前的文字,没有发送保存请求。"
: "文字内容不能留空,没有发送保存请求。");
return;
}
const size = Number(elements.insertionSize.value);
if (!Number.isInteger(size) || size < 8 || size > 64) {
showError(elements.insertionError, "字号必须是 8 到 64 之间的整数。");
return;
}
const anchor = catalog.anchors[selectedAnchorId];
const insertion = {
id: selectedInsertionId || crypto.randomUUID(),
workspace_id: anchor.scope,
anchor_id: selectedAnchorId,
placement: elements.insertionPlacement.value,
text,
font_size_px: size,
};
const candidate = writableDocument(draft);
const index = candidate.insertions.findIndex((item) => item.id === insertion.id);
if (index >= 0) candidate.insertions[index] = insertion;
else candidate.insertions.push(insertion);
await commitCandidate(candidate, { afterSave: () => selectInsertion(insertion.id) });
});
elements.insertionDelete.addEventListener("click", () => {
elements.insertionDeleteConfirmation.hidden = false;
panel.querySelector("#ui-copy-insertion-delete-confirm").focus();
});
panel.querySelector("#ui-copy-insertion-delete-cancel").addEventListener("click", () => { elements.insertionDeleteConfirmation.hidden = true; });
panel.querySelector("#ui-copy-insertion-delete-confirm").addEventListener("click", async () => {
if (!selectedInsertionId) return;
const candidate = writableDocument(draft);
candidate.insertions = candidate.insertions.filter((item) => item.id !== selectedInsertionId);
await commitCandidate(candidate, {
afterSave: () => {
selectedInsertionId = null;
selectedAnchorId = null;
elements.insertionForm.hidden = true;
document.documentElement.dataset.uiCopyEditorSelection = "false";
},
});
});
elements.undo.addEventListener("click", async () => {
if (!history.length) return;
const target = clone(history[history.length - 1]);
target.revision = draft.revision;
target.updated_at = draft.updated_at;
target.base_catalog_digest = draft.base_catalog_digest;
const saved = await commitCandidate(target, {
recordHistory: false,
afterSave: () => {
history.pop();
if (selectedCopyId) resetCopyFields(selectedCopyId);
if (selectedInsertionId && !draft.insertions.some((item) => item.id === selectedInsertionId)) {
selectedInsertionId = null;
elements.insertionForm.hidden = true;
}
},
});
if (saved) elements.undo.disabled = history.length === 0;
});
panel.querySelectorAll("[data-mode]").forEach((button) => button.addEventListener("click", () => setMode(button.dataset.mode)));
elements.search.addEventListener("input", renderList);
document.addEventListener("click", (event) => {
if (panel.contains(event.target) || toggle.contains(event.target)) return;
if (mode === "select") {
const target = event.target.closest?.("[data-ui-copy-id]");
if (!target) return;
event.preventDefault();
event.stopImmediatePropagation();
selectCopy(target.dataset.uiCopyId);
} else if (mode === "insert") {
const anchor = event.target.closest?.("[data-ui-copy-anchor-id]");
if (!anchor) return;
event.preventDefault();
event.stopImmediatePropagation();
selectAnchor(anchor.dataset.uiCopyAnchorId);
}
}, true);
function setPanelOpen(open) {
panel.hidden = !open;
toggle.setAttribute("aria-expanded", String(open));
if (open) panel.querySelector("[data-mode].active")?.focus();
}
toggle.addEventListener("click", () => setPanelOpen(panel.hidden));
panel.querySelector("#ui-copy-editor-close").addEventListener("click", () => setPanelOpen(false));
bindStaticCatalog();
bindAnchors();
runtime.setUiCopyDraft(draft);
applyInsertions();
renderList();
setMode("browse");
@@ -0,0 +1,255 @@
from __future__ import annotations
from collections import Counter
from datetime import datetime, timezone
import hashlib
import json
from pathlib import Path
import re
from typing import Any
from uuid import UUID
from app.persistence import atomic_write_bytes
SCHEMA_VERSION = 1
FONT_SIZE_MIN = 8
FONT_SIZE_MAX = 64
TEXT_LENGTH_MAX = 2000
INSERTION_LIMIT = 500
DRAFT_FIELDS = frozenset({
"schema_version",
"base_catalog_digest",
"revision",
"updated_at",
"overrides",
"insertions",
})
OVERRIDE_FIELDS = frozenset({"text", "font_size_px", "hidden"})
INSERTION_FIELDS = frozenset({
"id",
"workspace_id",
"anchor_id",
"placement",
"text",
"font_size_px",
})
PLACEHOLDER_PATTERN = re.compile(r"\{([a-zA-Z][a-zA-Z0-9_]*)\}")
class UiCopyDraftError(RuntimeError):
pass
class UiCopyDraftConflictError(UiCopyDraftError):
pass
class UiCopyDraftValidationError(UiCopyDraftError):
pass
def _canonical_bytes(document: dict[str, Any]) -> bytes:
return (json.dumps(document, ensure_ascii=False, indent=2, sort_keys=True) + "\n").encode("utf-8")
def _now() -> str:
return datetime.now(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z")
class UiCopyDraftStore:
def __init__(self, data_dir: Path, catalog_path: Path) -> None:
self.directory = Path(data_dir) / "development" / "ui-copy-editor"
self.path = self.directory / "draft.json"
self.catalog_path = Path(catalog_path)
self.catalog_bytes = self.catalog_path.read_bytes()
self.catalog_digest = hashlib.sha256(self.catalog_bytes).hexdigest()
try:
self.catalog = json.loads(self.catalog_bytes)
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
raise UiCopyDraftValidationError("UI copy catalog is not valid UTF-8 JSON") from exc
self._validate_catalog()
self.document = self._load()
@property
def items(self) -> dict[str, dict[str, Any]]:
return self.catalog["items"]
@property
def anchors(self) -> dict[str, dict[str, Any]]:
return self.catalog["anchors"]
def _validate_catalog(self) -> None:
if not isinstance(self.catalog, dict) or self.catalog.get("schema_version") != 2:
raise UiCopyDraftValidationError("UI copy catalog schema is unsupported")
if set(self.catalog) != {"schema_version", "items", "anchors", "insertions"}:
raise UiCopyDraftValidationError("UI copy catalog contains unknown fields")
if not isinstance(self.catalog["items"], dict) or not isinstance(self.catalog["anchors"], dict):
raise UiCopyDraftValidationError("UI copy catalog collections are invalid")
for copy_id, item in self.catalog["items"].items():
if not isinstance(copy_id, str) or not isinstance(item, dict):
raise UiCopyDraftValidationError("UI copy catalog item is invalid")
render = item.get("render")
if not isinstance(render, dict) or render.get("type") not in {
"static_text",
"attribute",
"dynamic_template",
}:
raise UiCopyDraftValidationError(f"UI copy binding is invalid: {copy_id}")
if type(render.get("default_hidden", False)) is not bool:
raise UiCopyDraftValidationError(f"UI copy default hidden state is invalid: {copy_id}")
def empty_document(self) -> dict[str, Any]:
return {
"schema_version": SCHEMA_VERSION,
"base_catalog_digest": self.catalog_digest,
"revision": 0,
"updated_at": None,
"overrides": {},
"insertions": [],
}
def _load(self) -> dict[str, Any]:
if not self.path.exists():
return self.empty_document()
try:
raw = json.loads(self.path.read_text(encoding="utf-8"))
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
raise UiCopyDraftValidationError("UI copy draft is not valid UTF-8 JSON") from exc
return self.validate_document(raw, allow_catalog_mismatch=True)
def validate_document(
self,
raw: Any,
*,
allow_catalog_mismatch: bool = False,
) -> dict[str, Any]:
if not isinstance(raw, dict):
raise UiCopyDraftValidationError("UI copy draft must be a JSON object")
unknown = set(raw) - DRAFT_FIELDS
missing = DRAFT_FIELDS - set(raw)
if unknown or missing:
raise UiCopyDraftValidationError("UI copy draft fields are invalid")
version = raw.get("schema_version")
if type(version) is not int or version != SCHEMA_VERSION:
raise UiCopyDraftValidationError("UI copy draft schema_version is unsupported")
digest = raw.get("base_catalog_digest")
if not isinstance(digest, str) or not re.fullmatch(r"[0-9a-f]{64}", digest):
raise UiCopyDraftValidationError("UI copy draft catalog digest is invalid")
if not allow_catalog_mismatch and digest != self.catalog_digest:
raise UiCopyDraftConflictError("UI copy catalog changed; export the existing draft before editing")
revision = raw.get("revision")
if type(revision) is not int or revision < 0:
raise UiCopyDraftValidationError("UI copy draft revision is invalid")
updated_at = raw.get("updated_at")
if updated_at is not None and not isinstance(updated_at, str):
raise UiCopyDraftValidationError("UI copy draft updated_at is invalid")
overrides = self._validate_overrides(raw.get("overrides"))
insertions = self._validate_insertions(raw.get("insertions"))
return {
"schema_version": SCHEMA_VERSION,
"base_catalog_digest": digest,
"revision": revision,
"updated_at": updated_at,
"overrides": overrides,
"insertions": insertions,
}
def _validate_overrides(self, raw: Any) -> dict[str, dict[str, Any]]:
if not isinstance(raw, dict):
raise UiCopyDraftValidationError("UI copy overrides must be an object")
checked: dict[str, dict[str, Any]] = {}
for copy_id, value in raw.items():
if copy_id not in self.items:
raise UiCopyDraftValidationError(f"unknown UI copy id: {copy_id}")
if not isinstance(value, dict) or not value or set(value) - OVERRIDE_FIELDS:
raise UiCopyDraftValidationError(f"invalid UI copy override: {copy_id}")
item = self.items[copy_id]
normalized: dict[str, Any] = {}
if "text" in value:
text = value["text"]
if not isinstance(text, str) or not text or len(text) > TEXT_LENGTH_MAX:
raise UiCopyDraftValidationError(f"invalid UI copy text: {copy_id}")
expected = Counter(item.get("placeholders", []))
if Counter(PLACEHOLDER_PATTERN.findall(text)) != expected:
raise UiCopyDraftValidationError(f"UI copy placeholders changed: {copy_id}")
normalized["text"] = text
if "font_size_px" in value:
size = value["font_size_px"]
if item.get("kind") == "html_attribute":
raise UiCopyDraftValidationError(f"attribute copy cannot have a font size: {copy_id}")
if type(size) is not int or not FONT_SIZE_MIN <= size <= FONT_SIZE_MAX:
raise UiCopyDraftValidationError(f"invalid UI copy font size: {copy_id}")
normalized["font_size_px"] = size
if "hidden" in value:
hidden = value["hidden"]
if item.get("kind") == "html_attribute":
raise UiCopyDraftValidationError(f"attribute copy cannot be hidden: {copy_id}")
if type(hidden) is not bool:
raise UiCopyDraftValidationError(f"invalid UI copy hidden state: {copy_id}")
normalized["hidden"] = hidden
checked[copy_id] = normalized
return checked
def _validate_insertions(self, raw: Any) -> list[dict[str, Any]]:
if not isinstance(raw, list) or len(raw) > INSERTION_LIMIT:
raise UiCopyDraftValidationError("UI copy insertions are invalid")
checked: list[dict[str, Any]] = []
seen: set[str] = set()
for item in raw:
if not isinstance(item, dict) or set(item) != INSERTION_FIELDS:
raise UiCopyDraftValidationError("UI copy insertion fields are invalid")
identifier = item["id"]
try:
UUID(str(identifier))
except (ValueError, TypeError, AttributeError) as exc:
raise UiCopyDraftValidationError("UI copy insertion id is invalid") from exc
if identifier in seen:
raise UiCopyDraftValidationError("UI copy insertion id is duplicated")
seen.add(identifier)
anchor_id = item["anchor_id"]
anchor = self.anchors.get(anchor_id)
if anchor is None:
raise UiCopyDraftValidationError("UI copy insertion anchor is unknown")
workspace_id = item["workspace_id"]
if workspace_id != anchor.get("scope"):
raise UiCopyDraftValidationError("UI copy insertion workspace does not match its anchor")
placement = item["placement"]
if placement not in {"before", "after"}:
raise UiCopyDraftValidationError("UI copy insertion placement is invalid")
text = item["text"]
if not isinstance(text, str) or not text or len(text) > TEXT_LENGTH_MAX:
raise UiCopyDraftValidationError("UI copy insertion text is invalid")
size = item["font_size_px"]
if type(size) is not int or not FONT_SIZE_MIN <= size <= FONT_SIZE_MAX:
raise UiCopyDraftValidationError("UI copy insertion font size is invalid")
checked.append({
"id": identifier,
"workspace_id": workspace_id,
"anchor_id": anchor_id,
"placement": placement,
"text": text,
"font_size_px": size,
})
return checked
def status_document(self) -> dict[str, Any]:
return {
**self.document,
"catalog_mismatch": self.document["base_catalog_digest"] != self.catalog_digest,
"catalog_digest": self.catalog_digest,
}
def replace(self, raw: dict[str, Any], *, expected_revision: int) -> dict[str, Any]:
if self.document["base_catalog_digest"] != self.catalog_digest:
raise UiCopyDraftConflictError("UI copy catalog changed; export the existing draft before editing")
if expected_revision != self.document["revision"]:
raise UiCopyDraftConflictError("UI copy draft was modified by another page")
candidate = {**raw, "revision": expected_revision, "updated_at": self.document["updated_at"]}
checked = self.validate_document(candidate)
checked["revision"] = expected_revision + 1
checked["updated_at"] = _now()
self.directory.mkdir(parents=True, exist_ok=True)
atomic_write_bytes(self.path, _canonical_bytes(checked))
self.document = checked
return self.status_document()