235 lines
8.4 KiB
JavaScript
235 lines
8.4 KiB
JavaScript
(() => {
|
||
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();
|
||
})();
|