Add versioned annotation submissions
This commit is contained in:
@@ -0,0 +1,234 @@
|
||||
(() => {
|
||||
const bridge = window.__annotationSubmissionBridge;
|
||||
if (!bridge?.pageName || typeof bridge.read !== "function") return;
|
||||
|
||||
const endpoint = `/api/annotations/${encodeURIComponent(bridge.pageName)}/submissions`;
|
||||
const toolbar = document.querySelector(".toolbar");
|
||||
if (!toolbar) return;
|
||||
|
||||
const style = document.createElement("style");
|
||||
style.textContent = `
|
||||
.annotation-submit-status { color: #687385; font-size: 12px; white-space: nowrap; }
|
||||
.annotation-history-dialog { width: min(680px, calc(100vw - 32px)); max-height: min(720px, calc(100vh - 32px)); padding: 0; border: 1px solid #d9dee7; border-radius: 8px; box-shadow: 0 24px 80px rgba(15, 23, 42, .2); }
|
||||
.annotation-history-dialog::backdrop { background: rgba(15, 23, 42, .32); }
|
||||
.annotation-history-head { position: sticky; top: 0; z-index: 1; display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 16px 18px; border-bottom: 1px solid #e5e9ef; background: #fff; }
|
||||
.annotation-history-head h2 { margin: 0; font-size: 17px; }
|
||||
.annotation-history-close { width: 32px; height: 32px; border: 0; border-radius: 5px; background: #f1f4f8; font-size: 20px; color: #556274; }
|
||||
.annotation-history-list { margin: 0; padding: 0; list-style: none; overflow: auto; }
|
||||
.annotation-history-item { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 14px; align-items: center; padding: 14px 18px; border-bottom: 1px solid #edf0f4; }
|
||||
.annotation-history-item:last-child { border-bottom: 0; }
|
||||
.annotation-history-title { font-weight: 650; font-size: 14px; }
|
||||
.annotation-history-meta { margin-top: 5px; color: #687385; font-size: 12px; }
|
||||
.annotation-history-summary { margin-top: 7px; display: flex; flex-wrap: wrap; gap: 10px; color: #4b5666; font-size: 12px; }
|
||||
.annotation-history-actions { display: flex; gap: 7px; }
|
||||
.annotation-history-empty { padding: 40px 18px; text-align: center; color: #687385; }
|
||||
@media (max-width: 820px) { .annotation-submit-status { display: none; } }
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
|
||||
const historyButton = button("提交记录", "secondary");
|
||||
const submitButton = button("提交", "primary");
|
||||
const status = document.createElement("span");
|
||||
status.className = "annotation-submit-status";
|
||||
toolbar.append(status, historyButton, submitButton);
|
||||
|
||||
const dialog = document.createElement("dialog");
|
||||
dialog.className = "annotation-history-dialog";
|
||||
dialog.innerHTML = `
|
||||
<div class="annotation-history-head">
|
||||
<h2>提交记录</h2>
|
||||
<button class="annotation-history-close" aria-label="关闭">×</button>
|
||||
</div>
|
||||
<ol class="annotation-history-list"></ol>
|
||||
`;
|
||||
document.body.appendChild(dialog);
|
||||
dialog.querySelector(".annotation-history-close").onclick = () =>
|
||||
dialog.close();
|
||||
dialog.addEventListener("click", (event) => {
|
||||
if (event.target === dialog) dialog.close();
|
||||
});
|
||||
|
||||
let history = [];
|
||||
historyButton.onclick = async () => {
|
||||
dialog.showModal();
|
||||
await refreshHistory();
|
||||
};
|
||||
submitButton.onclick = async () => {
|
||||
submitButton.disabled = true;
|
||||
submitButton.textContent = "提交中";
|
||||
try {
|
||||
const response = await fetch(endpoint, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ annotations: bridge.read() }),
|
||||
});
|
||||
const payload = await response.json();
|
||||
if (!response.ok) throw new Error(payload.error || "提交失败");
|
||||
history = [
|
||||
summaryOf(payload.submission),
|
||||
...history.filter((item) => item.id !== payload.submission.id),
|
||||
];
|
||||
setStatus(payload.submission);
|
||||
setRevisionUrl(payload.submission.id);
|
||||
bridge.notify?.(`已提交第 ${payload.submission.version} 版`);
|
||||
} catch (error) {
|
||||
bridge.notify?.(error instanceof Error ? error.message : "提交失败");
|
||||
} finally {
|
||||
submitButton.disabled = false;
|
||||
submitButton.textContent = "提交";
|
||||
}
|
||||
};
|
||||
|
||||
async function refreshHistory() {
|
||||
const list = dialog.querySelector(".annotation-history-list");
|
||||
list.innerHTML = '<li class="annotation-history-empty">正在读取</li>';
|
||||
try {
|
||||
const payload = await fetchJson(endpoint);
|
||||
history = payload.history || [];
|
||||
renderHistory();
|
||||
} catch (error) {
|
||||
list.innerHTML = `<li class="annotation-history-empty">${escapeHtml(error instanceof Error ? error.message : "读取失败")}</li>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderHistory() {
|
||||
const list = dialog.querySelector(".annotation-history-list");
|
||||
if (!history.length) {
|
||||
list.innerHTML =
|
||||
'<li class="annotation-history-empty">还没有提交记录</li>';
|
||||
return;
|
||||
}
|
||||
list.innerHTML = "";
|
||||
for (const item of history) {
|
||||
const li = document.createElement("li");
|
||||
li.className = "annotation-history-item";
|
||||
li.innerHTML = `
|
||||
<div>
|
||||
<div class="annotation-history-title">第 ${item.version} 版 · ${escapeHtml(item.submittedBy?.username || "未知用户")}</div>
|
||||
<div class="annotation-history-meta">${formatTime(item.submittedAt)}</div>
|
||||
<div class="annotation-history-summary">
|
||||
<span>已标 ${item.summary?.completed || 0}/${item.summary?.total || 0}</span>
|
||||
<span>G ${item.summary?.G || 0}</span>
|
||||
<span>S ${item.summary?.S || 0}</span>
|
||||
<span>B ${item.summary?.B || 0}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="annotation-history-actions">
|
||||
<button class="btn secondary" data-action="copy" type="button">复制链接</button>
|
||||
<button class="btn secondary" data-action="view" type="button">查看</button>
|
||||
</div>
|
||||
`;
|
||||
li.querySelector('[data-action="copy"]').onclick = async () => {
|
||||
const url = revisionUrl(item.id);
|
||||
try {
|
||||
await navigator.clipboard.writeText(url);
|
||||
bridge.notify?.("版本链接已复制");
|
||||
} catch {
|
||||
window.prompt("复制版本链接", url);
|
||||
}
|
||||
};
|
||||
li.querySelector('[data-action="view"]').onclick = async () => {
|
||||
try {
|
||||
const payload = await fetchJson(
|
||||
`${endpoint}?revision=${encodeURIComponent(item.id)}`,
|
||||
);
|
||||
bridge.apply(payload.submission.annotations || []);
|
||||
dialog.close();
|
||||
setStatus(payload.submission, "正在查看");
|
||||
setRevisionUrl(payload.submission.id);
|
||||
bridge.notify?.(`已打开第 ${payload.submission.version} 版`);
|
||||
} catch (error) {
|
||||
bridge.notify?.(error instanceof Error ? error.message : "读取失败");
|
||||
}
|
||||
};
|
||||
list.appendChild(li);
|
||||
}
|
||||
}
|
||||
|
||||
async function initialize() {
|
||||
try {
|
||||
const requestedRevision = new URLSearchParams(window.location.search).get(
|
||||
"revision",
|
||||
);
|
||||
if (requestedRevision) {
|
||||
const payload = await fetchJson(
|
||||
`${endpoint}?revision=${encodeURIComponent(requestedRevision)}`,
|
||||
);
|
||||
bridge.apply(payload.submission.annotations || []);
|
||||
setStatus(payload.submission, "正在查看");
|
||||
return;
|
||||
}
|
||||
const payload = await fetchJson(endpoint);
|
||||
history = payload.history || [];
|
||||
if (payload.latest) {
|
||||
bridge.applyMissing(payload.latest.annotations || []);
|
||||
setStatus(payload.latest);
|
||||
} else {
|
||||
status.textContent = "尚未提交";
|
||||
}
|
||||
} catch {
|
||||
status.textContent = "提交记录暂不可用";
|
||||
}
|
||||
}
|
||||
|
||||
function setStatus(submission, prefix = "最新") {
|
||||
status.textContent = `${prefix}:第 ${submission.version} 版 · ${submission.submittedBy?.username || "未知用户"}`;
|
||||
}
|
||||
|
||||
function revisionUrl(revisionId) {
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set("revision", revisionId);
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function setRevisionUrl(revisionId) {
|
||||
window.history.replaceState(null, "", revisionUrl(revisionId));
|
||||
}
|
||||
|
||||
function button(label, variant) {
|
||||
const element = document.createElement("button");
|
||||
element.type = "button";
|
||||
element.className = `btn ${variant}`;
|
||||
element.textContent = label;
|
||||
return element;
|
||||
}
|
||||
|
||||
async function fetchJson(url) {
|
||||
const response = await fetch(url, { cache: "no-store" });
|
||||
const payload = await response.json();
|
||||
if (!response.ok) throw new Error(payload.error || "请求失败");
|
||||
return payload;
|
||||
}
|
||||
|
||||
function summaryOf(submission) {
|
||||
const { annotations: _annotations, ...summary } = submission;
|
||||
return summary;
|
||||
}
|
||||
|
||||
function formatTime(value) {
|
||||
return new Intl.DateTimeFormat("zh-CN", {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
}).format(new Date(value));
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value ?? "").replace(
|
||||
/[&<>"']/g,
|
||||
(character) =>
|
||||
({
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
'"': """,
|
||||
"'": "'",
|
||||
})[character],
|
||||
);
|
||||
}
|
||||
|
||||
void initialize();
|
||||
})();
|
||||
Reference in New Issue
Block a user