Spaces:
Running
Running
Upload 2 files
Browse files- static/app.js +260 -13
- static/style.css +29 -0
static/app.js
CHANGED
|
@@ -28,6 +28,10 @@ const STATE = {
|
|
| 28 |
facetHasMore: { source: false, author: false, tag: false },
|
| 29 |
leftOpen: true,
|
| 30 |
preview: "",
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
isDark: true,
|
| 32 |
isMobile: false,
|
| 33 |
filterSearch: { source: "", author: "", tag: "" },
|
|
@@ -46,6 +50,7 @@ const PERIOD_PRESETS = [
|
|
| 46 |
{ label: "2012-", from: "20121115", to: "" },
|
| 47 |
];
|
| 48 |
const TAG_TYPES = ["文稿大类", "文稿类型", "地点", "人物", "出版方/发行方", "主题/事件", "记录", "审核", "翻译", "翻印/传抄"];
|
|
|
|
| 49 |
|
| 50 |
const DOM = {};
|
| 51 |
|
|
@@ -145,6 +150,10 @@ function parseUrl() {
|
|
| 145 |
STATE.filterPage.author = Math.max(1, parseInt(params.get("author_page") || "1", 10) || 1);
|
| 146 |
STATE.filterPage.tag = Math.max(1, parseInt(params.get("tag_page") || "1", 10) || 1);
|
| 147 |
STATE.preview = params.get("preview") || "";
|
|
|
|
|
|
|
|
|
|
|
|
|
| 148 |
}
|
| 149 |
|
| 150 |
function syncUrl(replace = true) {
|
|
@@ -177,6 +186,10 @@ function syncUrl(replace = true) {
|
|
| 177 |
if (STATE.filterPage.author > 1) params.set("author_page", String(STATE.filterPage.author));
|
| 178 |
if (STATE.filterPage.tag > 1) params.set("tag_page", String(STATE.filterPage.tag));
|
| 179 |
if (STATE.preview) params.set("preview", STATE.preview);
|
|
|
|
|
|
|
|
|
|
|
|
|
| 180 |
const url = params.toString() ? `/?${params.toString()}` : "/";
|
| 181 |
if (STATE.preview) {
|
| 182 |
const closeParams = new URLSearchParams(params);
|
|
@@ -593,6 +606,10 @@ function closePreview(useHistory = false) {
|
|
| 593 |
return;
|
| 594 |
}
|
| 595 |
STATE.preview = "";
|
|
|
|
|
|
|
|
|
|
|
|
|
| 596 |
clearPreviewDom();
|
| 597 |
renderResults();
|
| 598 |
syncUrl();
|
|
@@ -1283,6 +1300,11 @@ async function restorePreview(scroll = true) {
|
|
| 1283 |
document.body.classList.add("preview-open");
|
| 1284 |
previewAbortController = null;
|
| 1285 |
DOM.emptyState.style.display = "none";
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1286 |
const previewBody = DOM.previewPanel.querySelector(".preview-body");
|
| 1287 |
if (scroll && previewBody) previewBody.scrollTo({ top: 0, behavior: "smooth" });
|
| 1288 |
} catch (error) {
|
|
@@ -1297,6 +1319,10 @@ function openPreview(docId, scroll = true) {
|
|
| 1297 |
const replacingPreview = history.state && history.state.previewEntry === true;
|
| 1298 |
if (!replacingPreview) previewResultsScrollTop = DOM.results.scrollTop;
|
| 1299 |
STATE.preview = docId;
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1300 |
applyStateToDom();
|
| 1301 |
syncUrl(replacingPreview);
|
| 1302 |
restorePreview(scroll);
|
|
@@ -1425,9 +1451,18 @@ function compareSelectHTML(item) {
|
|
| 1425 |
|
| 1426 |
function textDelta(original, edited) {
|
| 1427 |
if (original === edited) return "";
|
| 1428 |
-
|
| 1429 |
-
|
| 1430 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1431 |
}
|
| 1432 |
|
| 1433 |
function partTextWithPivots(article, partIndex) {
|
|
@@ -1455,21 +1490,42 @@ function proofreadHTML(item) {
|
|
| 1455 |
const article = item.article || {};
|
| 1456 |
const sourceFiles = item.source_files || [];
|
| 1457 |
const requestedPage = Math.max(1, Number(article.page_start) || 1);
|
| 1458 |
-
const defaultFile = sourceFiles.length > 1 ? Math.min(sourceFiles.length - 1, requestedPage - 1) : 0;
|
| 1459 |
const sourceOptions = sourceFiles.map((file) => `<option value="${file.index}" ${Number(file.index) === defaultFile ? "selected" : ""}>${escapeHTML(file.name)}</option>`).join("");
|
| 1460 |
const sourcePicker = sourceFiles.length > 1 ? `<select data-proofread-source-file aria-label="选择原文件">${sourceOptions}</select>` : "";
|
| 1461 |
-
const
|
| 1462 |
-
const
|
|
|
|
|
|
|
|
|
|
| 1463 |
const comments = (article.comments || []).map((comment, index) => `<label class="proofread-field"><span>注释 ${index + 1}</span><textarea data-proofread-comment="${index}">${escapeHTML(comment)}</textarea></label>`).join("");
|
| 1464 |
const tags = Array.isArray(article.tags) ? article.tags : [];
|
| 1465 |
const actions = `<div class="preview-tools"><button type="button" class="icon-btn-sm" data-close-preview aria-label="关闭预览">×</button></div>`;
|
| 1466 |
-
return `<div class="preview-header"><div class="preview-heading">${previewTitleHTML(item)}<div class="preview-compare-note">对比原文件并校对</div></div><div class="preview-actions">${actions}</div></div><div class="proofread-grid"><section class="proofread-source"><div class="proofread-pane-header"><span>原文件</span>${sourcePicker}</div><iframe data-proofread-source title="原文件" src="/api/download/source/${encodeURIComponent(item.doc_id)}?file=${defaultFile}&inline=true${pdfPage}"></iframe></section><form class="proofread-editor" data-proofread-form><div class="proofread-pane-header"><span>校订内容</span><button type="submit" class="result-action-btn primary">提交校订</button></div><fieldset><legend>文章元数据</legend><label class="proofread-field"><span>标题</span><input data-meta-title value="${escapeHTML(article.title || item.title || "")}"></label><label class="proofread-field"><span>作者</span>${listEditorHTML("authors", article.authors || item.authors || [], [{ key: "value", label: "作者" }])}</label><label class="proofread-field"><span>日期</span>${listEditorHTML("dates", article.dates || [], [{ key: "year", label: "年", type: "number" }, { key: "month", label: "月", type: "number" }, { key: "day", label: "日", type: "number" }])}</label><label class="proofread-field"><span>标签</span>${listEditorHTML("tags", tags, [{ key: "name", label: "名称" }, { key: "type", label: "类型", options: TAG_TYPES }])}</label></fieldset><fieldset><legend>来源元数据</legend><label class="proofread-field"><span>来源名称</span><input data-source-name value="${escapeHTML(item.publication_name || "")}"></label><label class="proofread-field"><span>来源作者</span><input data-source-author value="${escapeHTML(item.publication_author || "")}"></label><label class="proofread-field"><span>来源类型</span><input data-source-type value="${escapeHTML(item.publication_type || "")}"></label><label class="proofread-field"><span>来源文件</span>${listEditorHTML("files", sourceFiles.map((file) => file.url), [{ key: "value", label: "URL" }])}</label></fieldset><fieldset><legend>正文</legend><label class="proofread-field"><span>描述</span><textarea data-proofread-description>${escapeHTML(article.description || "")}</textarea></label>${parts}</fieldset><fieldset><legend>注释</legend>${comments || '<div class="proofread-empty">无注释</div>'}</fieldset><label class="proofread-field"><span>说明</span><textarea data-proofread-note maxlength="2000"></textarea></label></form></div>`;
|
| 1467 |
}
|
| 1468 |
|
| 1469 |
-
function openProofread(item, focusPart = null) {
|
| 1470 |
if (!item) return;
|
|
|
|
|
|
|
|
|
|
| 1471 |
DOM.previewPanel.classList.add("proofread-mode");
|
| 1472 |
DOM.previewPanel.innerHTML = proofreadHTML(item);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1473 |
if (focusPart != null) requestAnimationFrame(() => {
|
| 1474 |
const input = DOM.previewPanel.querySelector(`[data-proofread-part-input="${focusPart}"]`);
|
| 1475 |
if (input) input.focus();
|
|
@@ -1486,13 +1542,147 @@ function readList(form, kind) {
|
|
| 1486 |
}).filter((value) => typeof value === "string" ? Boolean(value) : Object.keys(value).length);
|
| 1487 |
}
|
| 1488 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1489 |
function proofreadPayload(item, form) {
|
| 1490 |
const article = item.article || {};
|
| 1491 |
const patch = { version: 2, parts: {}, comments: {}, description: "" };
|
| 1492 |
form.querySelectorAll("[data-proofread-part-input]").forEach((input) => {
|
| 1493 |
const index = Number(input.dataset.proofreadPartInput);
|
| 1494 |
const delta = textDelta(partTextWithPivots(article, index), input.value);
|
| 1495 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1496 |
});
|
| 1497 |
form.querySelectorAll("[data-proofread-comment]").forEach((input) => {
|
| 1498 |
const index = Number(input.dataset.proofreadComment);
|
|
@@ -1532,8 +1722,9 @@ async function submitProofreadForm(form) {
|
|
| 1532 |
const submit = form.querySelector('button[type="submit"]');
|
| 1533 |
submit.disabled = true;
|
| 1534 |
try {
|
| 1535 |
-
await apiWithTimeout("/api/proofread", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload) }, 30000);
|
| 1536 |
-
|
|
|
|
| 1537 |
} catch (error) {
|
| 1538 |
showToast("校订提交失败");
|
| 1539 |
} finally {
|
|
@@ -1541,9 +1732,12 @@ async function submitProofreadForm(form) {
|
|
| 1541 |
}
|
| 1542 |
}
|
| 1543 |
|
| 1544 |
-
async function comparePreview(docId) {
|
| 1545 |
if (!STATE.preview || !docId) return;
|
| 1546 |
const baseDocId = STATE.preview;
|
|
|
|
|
|
|
|
|
|
| 1547 |
const requestSeq = compareRequestSeq + 1;
|
| 1548 |
compareRequestSeq = requestSeq;
|
| 1549 |
try {
|
|
@@ -1833,6 +2027,36 @@ function attachEvents() {
|
|
| 1833 |
}
|
| 1834 |
});
|
| 1835 |
DOM.previewPanel.addEventListener("click", (event) => {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1836 |
const addList = event.target.closest("[data-add-list]");
|
| 1837 |
if (addList) {
|
| 1838 |
const kind = addList.dataset.addList;
|
|
@@ -1875,8 +2099,31 @@ function attachEvents() {
|
|
| 1875 |
const sourceFile = event.target.closest("[data-proofread-source-file]");
|
| 1876 |
if (sourceFile) {
|
| 1877 |
const frame = DOM.previewPanel.querySelector("[data-proofread-source]");
|
| 1878 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1879 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1880 |
});
|
| 1881 |
DOM.previewPanel.addEventListener("submit", (event) => {
|
| 1882 |
const form = event.target.closest("[data-proofread-form]");
|
|
|
|
| 28 |
facetHasMore: { source: false, author: false, tag: false },
|
| 29 |
leftOpen: true,
|
| 30 |
preview: "",
|
| 31 |
+
compare: "",
|
| 32 |
+
proofread: false,
|
| 33 |
+
proofreadFile: "",
|
| 34 |
+
proofreadPage: "",
|
| 35 |
isDark: true,
|
| 36 |
isMobile: false,
|
| 37 |
filterSearch: { source: "", author: "", tag: "" },
|
|
|
|
| 50 |
{ label: "2012-", from: "20121115", to: "" },
|
| 51 |
];
|
| 52 |
const TAG_TYPES = ["文稿大类", "文稿类型", "地点", "人物", "出版方/发行方", "主题/事件", "记录", "审核", "翻译", "翻印/传抄"];
|
| 53 |
+
const PART_TYPES = ["appellation", "title", "authors", "place", "subtitle", "subtitle2", "subtitle3", "subtitle4", "subtitle5", "subdate", "paragraph", "quotation", "signature", "image", "image_description"];
|
| 54 |
|
| 55 |
const DOM = {};
|
| 56 |
|
|
|
|
| 150 |
STATE.filterPage.author = Math.max(1, parseInt(params.get("author_page") || "1", 10) || 1);
|
| 151 |
STATE.filterPage.tag = Math.max(1, parseInt(params.get("tag_page") || "1", 10) || 1);
|
| 152 |
STATE.preview = params.get("preview") || "";
|
| 153 |
+
STATE.compare = params.get("compare") || "";
|
| 154 |
+
STATE.proofread = params.get("proofread") === "1";
|
| 155 |
+
STATE.proofreadFile = params.get("source_file") || "";
|
| 156 |
+
STATE.proofreadPage = params.get("source_page") || "";
|
| 157 |
}
|
| 158 |
|
| 159 |
function syncUrl(replace = true) {
|
|
|
|
| 186 |
if (STATE.filterPage.author > 1) params.set("author_page", String(STATE.filterPage.author));
|
| 187 |
if (STATE.filterPage.tag > 1) params.set("tag_page", String(STATE.filterPage.tag));
|
| 188 |
if (STATE.preview) params.set("preview", STATE.preview);
|
| 189 |
+
if (STATE.compare) params.set("compare", STATE.compare);
|
| 190 |
+
if (STATE.proofread) params.set("proofread", "1");
|
| 191 |
+
if (STATE.proofreadFile) params.set("source_file", STATE.proofreadFile);
|
| 192 |
+
if (STATE.proofreadPage) params.set("source_page", STATE.proofreadPage);
|
| 193 |
const url = params.toString() ? `/?${params.toString()}` : "/";
|
| 194 |
if (STATE.preview) {
|
| 195 |
const closeParams = new URLSearchParams(params);
|
|
|
|
| 606 |
return;
|
| 607 |
}
|
| 608 |
STATE.preview = "";
|
| 609 |
+
STATE.compare = "";
|
| 610 |
+
STATE.proofread = false;
|
| 611 |
+
STATE.proofreadFile = "";
|
| 612 |
+
STATE.proofreadPage = "";
|
| 613 |
clearPreviewDom();
|
| 614 |
renderResults();
|
| 615 |
syncUrl();
|
|
|
|
| 1300 |
document.body.classList.add("preview-open");
|
| 1301 |
previewAbortController = null;
|
| 1302 |
DOM.emptyState.style.display = "none";
|
| 1303 |
+
if (STATE.proofread) {
|
| 1304 |
+
openProofread(item, null, true);
|
| 1305 |
+
} else if (STATE.compare) {
|
| 1306 |
+
comparePreview(STATE.compare, true);
|
| 1307 |
+
}
|
| 1308 |
const previewBody = DOM.previewPanel.querySelector(".preview-body");
|
| 1309 |
if (scroll && previewBody) previewBody.scrollTo({ top: 0, behavior: "smooth" });
|
| 1310 |
} catch (error) {
|
|
|
|
| 1319 |
const replacingPreview = history.state && history.state.previewEntry === true;
|
| 1320 |
if (!replacingPreview) previewResultsScrollTop = DOM.results.scrollTop;
|
| 1321 |
STATE.preview = docId;
|
| 1322 |
+
STATE.compare = "";
|
| 1323 |
+
STATE.proofread = false;
|
| 1324 |
+
STATE.proofreadFile = "";
|
| 1325 |
+
STATE.proofreadPage = "";
|
| 1326 |
applyStateToDom();
|
| 1327 |
syncUrl(replacingPreview);
|
| 1328 |
restorePreview(scroll);
|
|
|
|
| 1451 |
|
| 1452 |
function textDelta(original, edited) {
|
| 1453 |
if (original === edited) return "";
|
| 1454 |
+
let prefix = 0;
|
| 1455 |
+
while (prefix < original.length && prefix < edited.length && original[prefix] === edited[prefix]) prefix += 1;
|
| 1456 |
+
let suffix = 0;
|
| 1457 |
+
while (suffix < original.length - prefix && suffix < edited.length - prefix && original[original.length - 1 - suffix] === edited[edited.length - 1 - suffix]) suffix += 1;
|
| 1458 |
+
const removedLength = original.length - prefix - suffix;
|
| 1459 |
+
const inserted = edited.slice(prefix, edited.length - suffix);
|
| 1460 |
+
return [
|
| 1461 |
+
prefix ? `=${prefix}` : "",
|
| 1462 |
+
removedLength ? `-${removedLength}` : "",
|
| 1463 |
+
inserted ? `+${encodeURI(inserted).replace(/%20/g, " ")}` : "",
|
| 1464 |
+
suffix ? `=${suffix}` : "",
|
| 1465 |
+
].filter(Boolean).join("\t");
|
| 1466 |
}
|
| 1467 |
|
| 1468 |
function partTextWithPivots(article, partIndex) {
|
|
|
|
| 1490 |
const article = item.article || {};
|
| 1491 |
const sourceFiles = item.source_files || [];
|
| 1492 |
const requestedPage = Math.max(1, Number(article.page_start) || 1);
|
| 1493 |
+
const defaultFile = STATE.proofreadFile !== "" ? Math.max(0, Math.min(sourceFiles.length - 1, Number(STATE.proofreadFile) || 0)) : (sourceFiles.length > 1 ? Math.min(sourceFiles.length - 1, requestedPage - 1) : 0);
|
| 1494 |
const sourceOptions = sourceFiles.map((file) => `<option value="${file.index}" ${Number(file.index) === defaultFile ? "selected" : ""}>${escapeHTML(file.name)}</option>`).join("");
|
| 1495 |
const sourcePicker = sourceFiles.length > 1 ? `<select data-proofread-source-file aria-label="选择原文件">${sourceOptions}</select>` : "";
|
| 1496 |
+
const selectedUrl = sourceFiles[defaultFile] && sourceFiles[defaultFile].url || "";
|
| 1497 |
+
const page = Math.max(1, Number(STATE.proofreadPage) || requestedPage);
|
| 1498 |
+
const pdfPage = /\.pdf(?:$|\?)/i.test(selectedUrl) ? `#page=${page}` : "";
|
| 1499 |
+
const partTypeOptions = (current) => PART_TYPES.map((type) => `<option value="${type}" ${type === current ? "selected" : ""}>${type}</option>`).join("");
|
| 1500 |
+
const parts = (article.parts || []).map((part, index) => `<div class="proofread-part-group" data-proofread-part-group="${index}"><div data-part-insert-before></div><label class="proofread-field proofread-part" data-proofread-part="${index}"><span>段落 ${index + 1} / <select data-proofread-part-type="${index}">${partTypeOptions(part.type || "paragraph")}</select></span><span class="proofread-part-actions"><button type="button" class="text-btn-sm" data-add-part-before="${index}">前插入</button><button type="button" class="text-btn-sm" data-add-part-after="${index}">后插入</button><button type="button" class="text-btn-sm" data-delete-part="${index}">删除</button></span><textarea data-proofread-part-input="${index}">${escapeHTML(partTextWithPivots(article, index))}</textarea></label><div data-part-insert-after></div></div>`).join("");
|
| 1501 |
const comments = (article.comments || []).map((comment, index) => `<label class="proofread-field"><span>注释 ${index + 1}</span><textarea data-proofread-comment="${index}">${escapeHTML(comment)}</textarea></label>`).join("");
|
| 1502 |
const tags = Array.isArray(article.tags) ? article.tags : [];
|
| 1503 |
const actions = `<div class="preview-tools"><button type="button" class="icon-btn-sm" data-close-preview aria-label="关闭预览">×</button></div>`;
|
| 1504 |
+
return `<div class="preview-header"><div class="preview-heading">${previewTitleHTML(item)}<div class="preview-compare-note">对比原文件并校对</div></div><div class="preview-actions">${actions}</div></div><div class="proofread-grid"><section class="proofread-source"><div class="proofread-pane-header"><span>原文件</span>${sourcePicker}</div><iframe sandbox data-proofread-source title="原文件" src="/api/download/source/${encodeURIComponent(item.doc_id)}?file=${defaultFile}&inline=true${pdfPage}"></iframe></section><form class="proofread-editor" data-proofread-form><div class="proofread-pane-header"><span>校订内容</span><button type="submit" class="result-action-btn primary">提交校订</button></div><fieldset><legend>文章元数据</legend><label class="proofread-field"><span>标题</span><input data-meta-title value="${escapeHTML(article.title || item.title || "")}"></label><label class="proofread-field"><span>作者</span>${listEditorHTML("authors", article.authors || item.authors || [], [{ key: "value", label: "作者" }])}</label><label class="proofread-field"><span>日期</span>${listEditorHTML("dates", article.dates || [], [{ key: "year", label: "年", type: "number" }, { key: "month", label: "月", type: "number" }, { key: "day", label: "日", type: "number" }])}</label><label class="proofread-field"><span>标签</span>${listEditorHTML("tags", tags, [{ key: "name", label: "名称" }, { key: "type", label: "类型", options: TAG_TYPES }])}</label></fieldset><fieldset><legend>来源元数据</legend><label class="proofread-field"><span>来源名称</span><input data-source-name value="${escapeHTML(item.publication_name || "")}"></label><label class="proofread-field"><span>来源作者</span><input data-source-author value="${escapeHTML(item.publication_author || "")}"></label><label class="proofread-field"><span>来源类型</span><input data-source-type value="${escapeHTML(item.publication_type || "")}"></label><label class="proofread-field"><span>来源文件</span>${listEditorHTML("files", sourceFiles.map((file) => file.url), [{ key: "value", label: "URL" }])}</label></fieldset><fieldset><legend>正文</legend><label class="proofread-field"><span>描述</span><textarea data-proofread-description>${escapeHTML(article.description || "")}</textarea></label>${parts}</fieldset><fieldset><legend>注释</legend>${comments || '<div class="proofread-empty">无注释</div>'}</fieldset><label class="proofread-field"><span>说明</span><textarea data-proofread-note maxlength="2000"></textarea></label></form></div>`;
|
| 1505 |
}
|
| 1506 |
|
| 1507 |
+
function openProofread(item, focusPart = null, restoring = false) {
|
| 1508 |
if (!item) return;
|
| 1509 |
+
STATE.proofread = true;
|
| 1510 |
+
STATE.compare = "";
|
| 1511 |
+
if (!restoring) syncUrl(true);
|
| 1512 |
DOM.previewPanel.classList.add("proofread-mode");
|
| 1513 |
DOM.previewPanel.innerHTML = proofreadHTML(item);
|
| 1514 |
+
const form = DOM.previewPanel.querySelector("[data-proofread-form]");
|
| 1515 |
+
const sourceHeader = DOM.previewPanel.querySelector(".proofread-source .proofread-pane-header");
|
| 1516 |
+
if (sourceHeader && !sourceHeader.querySelector("[data-proofread-page]")) {
|
| 1517 |
+
sourceHeader.insertAdjacentHTML("beforeend", `<label class="proofread-page-control">页码 <input data-proofread-page type="number" min="1" value="${Math.max(1, Number((item.article || {}).page_start) || 1)}"></label>`);
|
| 1518 |
+
}
|
| 1519 |
+
if (form && !form.querySelector("[data-fulltext-box]")) {
|
| 1520 |
+
const text = (item.article && item.article.parts || []).map((_part, index) => partTextWithPivots(item.article, index)).join("\n");
|
| 1521 |
+
form.insertAdjacentHTML("afterbegin", `<div data-fulltext-box hidden><textarea data-fulltext-input>${escapeHTML(text)}</textarea><button type="button" class="text-btn-sm" data-fulltext-apply>应用到段落</button></div>`);
|
| 1522 |
+
const header = form.querySelector(".proofread-pane-header");
|
| 1523 |
+
if (header) header.insertAdjacentHTML("afterbegin", `<button type="button" class="text-btn-sm" data-fulltext-toggle>全文编辑</button>`);
|
| 1524 |
+
}
|
| 1525 |
+
restoreProofreadDraft(item, form);
|
| 1526 |
+
if (!STATE.proofreadFile) { const sourceInput = DOM.previewPanel.querySelector("[data-proofread-source-file]"); STATE.proofreadFile = sourceInput ? sourceInput.value : "0"; }
|
| 1527 |
+
if (!STATE.proofreadPage) { const pageInput = form && form.querySelector("[data-proofread-page]"); STATE.proofreadPage = pageInput ? pageInput.value : String(Math.max(1, Number(item.article && item.article.page_start) || 1)); }
|
| 1528 |
+
if (!restoring) syncUrl(true);
|
| 1529 |
if (focusPart != null) requestAnimationFrame(() => {
|
| 1530 |
const input = DOM.previewPanel.querySelector(`[data-proofread-part-input="${focusPart}"]`);
|
| 1531 |
if (input) input.focus();
|
|
|
|
| 1542 |
}).filter((value) => typeof value === "string" ? Boolean(value) : Object.keys(value).length);
|
| 1543 |
}
|
| 1544 |
|
| 1545 |
+
function proofreadDraftKey(docId) {
|
| 1546 |
+
return `bha-proofread-draft:${docId}`;
|
| 1547 |
+
}
|
| 1548 |
+
|
| 1549 |
+
function safeSessionGet(key) {
|
| 1550 |
+
try { return sessionStorage.getItem(key); } catch (_error) { return null; }
|
| 1551 |
+
}
|
| 1552 |
+
|
| 1553 |
+
function safeSessionSet(key, value) {
|
| 1554 |
+
try { sessionStorage.setItem(key, value); } catch (_error) { /* private browsing may disable storage */ }
|
| 1555 |
+
}
|
| 1556 |
+
|
| 1557 |
+
function collectProofreadDraft(form) {
|
| 1558 |
+
if (!form) return null;
|
| 1559 |
+
const lists = {};
|
| 1560 |
+
for (const kind of ["authors", "dates", "tags", "files"]) lists[kind] = readList(form, kind);
|
| 1561 |
+
return {
|
| 1562 |
+
parts: Object.fromEntries([...form.querySelectorAll("[data-proofread-part-input]")].map((input) => [input.dataset.proofreadPartInput, input.value])),
|
| 1563 |
+
comments: Object.fromEntries([...form.querySelectorAll("[data-proofread-comment]")].map((input) => [input.dataset.proofreadComment, input.value])),
|
| 1564 |
+
title: (form.querySelector("[data-meta-title]") || {}).value || "",
|
| 1565 |
+
source: Object.fromEntries(["name", "author", "type"].map((key) => { const input = form.querySelector(`[data-source-${key}]`); return [key, input ? input.value : ""]; })),
|
| 1566 |
+
lists,
|
| 1567 |
+
note: (form.querySelector("[data-proofread-note]") || {}).value || "",
|
| 1568 |
+
description: (form.querySelector("[data-proofread-description]") || {}).value || "",
|
| 1569 |
+
page: (form.querySelector("[data-proofread-page]") || {}).value || "",
|
| 1570 |
+
newParts: [...form.querySelectorAll("[data-proofread-new-part]")].map((row) => ({
|
| 1571 |
+
text: (row.querySelector("textarea") || {}).value || "",
|
| 1572 |
+
type: (row.querySelector("[data-new-part-type]") || {}).value || "paragraph",
|
| 1573 |
+
group: ((row.closest("[data-proofread-part-group]") || {}).dataset || {}).proofreadPartGroup || "",
|
| 1574 |
+
before: Boolean(row.parentElement && row.parentElement.hasAttribute("data-part-insert-before")),
|
| 1575 |
+
})),
|
| 1576 |
+
deleted: Object.fromEntries([...form.querySelectorAll("[data-delete-part]")].filter((button) => button.dataset.proofreadDeleted === "true").map((button) => [button.dataset.deletePart, true])),
|
| 1577 |
+
types: Object.fromEntries([...form.querySelectorAll("[data-proofread-part-type]")].map((input) => [input.dataset.proofreadPartType, input.value])),
|
| 1578 |
+
};
|
| 1579 |
+
}
|
| 1580 |
+
|
| 1581 |
+
function saveProofreadDraft() {
|
| 1582 |
+
if (!STATE.preview) return;
|
| 1583 |
+
const draft = collectProofreadDraft(DOM.previewPanel.querySelector("[data-proofread-form]"));
|
| 1584 |
+
if (draft) safeSessionSet(proofreadDraftKey(STATE.preview), JSON.stringify(draft));
|
| 1585 |
+
}
|
| 1586 |
+
|
| 1587 |
+
function restoreProofreadDraft(item, form) {
|
| 1588 |
+
if (!form) return;
|
| 1589 |
+
let draft;
|
| 1590 |
+
try { draft = JSON.parse(safeSessionGet(proofreadDraftKey(item.doc_id)) || "null"); } catch (_error) { draft = null; }
|
| 1591 |
+
if (!draft) return;
|
| 1592 |
+
for (const [index, value] of Object.entries(draft.parts || {})) {
|
| 1593 |
+
const input = form.querySelector(`[data-proofread-part-input="${CSS.escape(index)}"]`);
|
| 1594 |
+
if (input) input.value = value;
|
| 1595 |
+
}
|
| 1596 |
+
for (const [index, value] of Object.entries(draft.comments || {})) {
|
| 1597 |
+
const input = form.querySelector(`[data-proofread-comment="${CSS.escape(index)}"]`);
|
| 1598 |
+
if (input) input.value = value;
|
| 1599 |
+
}
|
| 1600 |
+
if (form.querySelector("[data-meta-title]")) form.querySelector("[data-meta-title]").value = draft.title || "";
|
| 1601 |
+
for (const key of ["name", "author", "type"]) {
|
| 1602 |
+
const input = form.querySelector(`[data-source-${key}]`);
|
| 1603 |
+
if (input && draft.source) input.value = draft.source[key] || "";
|
| 1604 |
+
}
|
| 1605 |
+
for (const kind of ["authors", "dates", "tags", "files"]) {
|
| 1606 |
+
const values = draft.lists && draft.lists[kind];
|
| 1607 |
+
const holder = form.querySelector(`[data-list="${kind}"]`);
|
| 1608 |
+
if (!holder || !Array.isArray(values)) continue;
|
| 1609 |
+
while (holder.querySelectorAll("[data-list-row]").length < values.length) {
|
| 1610 |
+
const add = holder.querySelector(`[data-add-list="${kind}"]`);
|
| 1611 |
+
if (add) add.click(); else break;
|
| 1612 |
+
}
|
| 1613 |
+
while (holder.querySelectorAll("[data-list-row]").length > values.length) {
|
| 1614 |
+
const rows = holder.querySelectorAll("[data-list-row]");
|
| 1615 |
+
const remove = rows[rows.length - 1].querySelector("[data-remove-list]");
|
| 1616 |
+
if (remove) remove.click(); else break;
|
| 1617 |
+
}
|
| 1618 |
+
[...holder.querySelectorAll("[data-list-row]")].forEach((row, index) => {
|
| 1619 |
+
const value = values[index];
|
| 1620 |
+
row.querySelectorAll("[data-list-field]").forEach((input) => {
|
| 1621 |
+
const next = typeof value === "string" ? value : value && value[input.dataset.listField];
|
| 1622 |
+
input.value = next == null ? "" : String(next);
|
| 1623 |
+
});
|
| 1624 |
+
});
|
| 1625 |
+
}
|
| 1626 |
+
if (form.querySelector("[data-proofread-note]")) form.querySelector("[data-proofread-note]").value = draft.note || "";
|
| 1627 |
+
if (form.querySelector("[data-proofread-description]")) form.querySelector("[data-proofread-description]").value = draft.description || "";
|
| 1628 |
+
if (draft.page && form.querySelector("[data-proofread-page]")) form.querySelector("[data-proofread-page]").value = draft.page;
|
| 1629 |
+
for (const [index, type] of Object.entries(draft.types || {})) {
|
| 1630 |
+
const input = form.querySelector(`[data-proofread-part-type="${CSS.escape(index)}"]`);
|
| 1631 |
+
if (input) input.value = type;
|
| 1632 |
+
}
|
| 1633 |
+
for (const index of Object.keys(draft.deleted || {})) {
|
| 1634 |
+
const button = form.querySelector(`[data-delete-part="${CSS.escape(index)}"]`);
|
| 1635 |
+
if (button) { button.dataset.proofreadDeleted = "true"; button.textContent = "恢复"; const group = button.closest("[data-proofread-part-group]"); if (group) group.classList.add("proofread-deleted"); }
|
| 1636 |
+
}
|
| 1637 |
+
for (const part of draft.newParts || []) {
|
| 1638 |
+
const target = form.querySelector(`[data-proofread-part-group="${CSS.escape(part.group)}"] [${part.before ? "data-part-insert-before" : "data-part-insert-after"}]`);
|
| 1639 |
+
if (target) {
|
| 1640 |
+
target.insertAdjacentHTML("beforeend", newPartHTML());
|
| 1641 |
+
const row = target.lastElementChild;
|
| 1642 |
+
row.querySelector("textarea").value = part.text || "";
|
| 1643 |
+
row.querySelector("[data-new-part-type]").value = part.type || "paragraph";
|
| 1644 |
+
}
|
| 1645 |
+
}
|
| 1646 |
+
}
|
| 1647 |
+
|
| 1648 |
+
function newPartHTML() {
|
| 1649 |
+
return `<label class="proofread-field proofread-new-part" data-proofread-new-part><span>新增段落</span><select data-new-part-type>${PART_TYPES.map((type) => `<option value="${type}">${type}</option>`).join("")}</select><textarea></textarea><button type="button" class="text-btn-sm" data-remove-new-part>删除</button></label>`;
|
| 1650 |
+
}
|
| 1651 |
+
|
| 1652 |
+
function applyFullText(form) {
|
| 1653 |
+
const input = form.querySelector("[data-fulltext-input]");
|
| 1654 |
+
const rows = [...form.querySelectorAll("[data-proofread-part-input]")];
|
| 1655 |
+
if (!input || !rows.length) return;
|
| 1656 |
+
const lines = input.value.split("\n");
|
| 1657 |
+
rows.forEach((row, index) => { row.value = lines[index] == null ? "" : lines[index]; });
|
| 1658 |
+
rows.slice(lines.length).forEach((row) => { const group = row.closest("[data-proofread-part-group]"); const button = group && group.querySelector("[data-delete-part]"); if (button) button.click(); });
|
| 1659 |
+
if (lines.length > rows.length) {
|
| 1660 |
+
const target = rows[rows.length - 1].closest("[data-proofread-part-group]").querySelector("[data-part-insert-after]");
|
| 1661 |
+
for (const line of lines.slice(rows.length)) target.insertAdjacentHTML("beforeend", newPartHTML().replace("<textarea></textarea>", `<textarea>${escapeHTML(line)}</textarea>`));
|
| 1662 |
+
}
|
| 1663 |
+
saveProofreadDraft();
|
| 1664 |
+
}
|
| 1665 |
+
|
| 1666 |
function proofreadPayload(item, form) {
|
| 1667 |
const article = item.article || {};
|
| 1668 |
const patch = { version: 2, parts: {}, comments: {}, description: "" };
|
| 1669 |
form.querySelectorAll("[data-proofread-part-input]").forEach((input) => {
|
| 1670 |
const index = Number(input.dataset.proofreadPartInput);
|
| 1671 |
const delta = textDelta(partTextWithPivots(article, index), input.value);
|
| 1672 |
+
const part = article.parts[index] || {};
|
| 1673 |
+
const change = {};
|
| 1674 |
+
if (delta) change.diff = delta;
|
| 1675 |
+
const typeInput = form.querySelector(`[data-proofread-part-type="${index}"]`);
|
| 1676 |
+
const type = typeInput ? typeInput.value : "";
|
| 1677 |
+
if (type && type !== (part.type || "paragraph")) change.type = type;
|
| 1678 |
+
const group = input.closest("[data-proofread-part-group]");
|
| 1679 |
+
const deleted = group && group.querySelector("[data-proofread-deleted]");
|
| 1680 |
+
if (deleted && deleted.dataset.proofreadDeleted === "true") change.delete = true;
|
| 1681 |
+
const before = group ? [...group.querySelector("[data-part-insert-before]").querySelectorAll("[data-proofread-new-part]")].map((row) => ({ text: (row.querySelector("textarea") || {}).value || "", type: (row.querySelector("[data-new-part-type]") || {}).value || "paragraph" })) : [];
|
| 1682 |
+
const after = group ? [...group.querySelector("[data-part-insert-after]").querySelectorAll("[data-proofread-new-part]")].map((row) => ({ text: (row.querySelector("textarea") || {}).value || "", type: (row.querySelector("[data-new-part-type]") || {}).value || "paragraph" })) : [];
|
| 1683 |
+
if (before.length) change.insertBefore = before;
|
| 1684 |
+
if (after.length) change.insertAfter = after;
|
| 1685 |
+
if (Object.keys(change).length) patch.parts[index] = change;
|
| 1686 |
});
|
| 1687 |
form.querySelectorAll("[data-proofread-comment]").forEach((input) => {
|
| 1688 |
const index = Number(input.dataset.proofreadComment);
|
|
|
|
| 1722 |
const submit = form.querySelector('button[type="submit"]');
|
| 1723 |
submit.disabled = true;
|
| 1724 |
try {
|
| 1725 |
+
const result = await apiWithTimeout("/api/proofread", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload) }, 30000);
|
| 1726 |
+
try { sessionStorage.removeItem(proofreadDraftKey(item.doc_id)); } catch (_error) { /* storage is optional */ }
|
| 1727 |
+
showToast(result.auto_merge ? "校订已提交,通过检查后自动合并" : "校订已提交,等待人工审核 PR");
|
| 1728 |
} catch (error) {
|
| 1729 |
showToast("校订提交失败");
|
| 1730 |
} finally {
|
|
|
|
| 1732 |
}
|
| 1733 |
}
|
| 1734 |
|
| 1735 |
+
async function comparePreview(docId, restoring = false) {
|
| 1736 |
if (!STATE.preview || !docId) return;
|
| 1737 |
const baseDocId = STATE.preview;
|
| 1738 |
+
STATE.compare = docId;
|
| 1739 |
+
STATE.proofread = false;
|
| 1740 |
+
if (!restoring) syncUrl(true);
|
| 1741 |
const requestSeq = compareRequestSeq + 1;
|
| 1742 |
compareRequestSeq = requestSeq;
|
| 1743 |
try {
|
|
|
|
| 2027 |
}
|
| 2028 |
});
|
| 2029 |
DOM.previewPanel.addEventListener("click", (event) => {
|
| 2030 |
+
const partInsert = event.target.closest("[data-add-part-before], [data-add-part-after]");
|
| 2031 |
+
if (partInsert) {
|
| 2032 |
+
const group = partInsert.closest("[data-proofread-part-group]");
|
| 2033 |
+
const target = group && group.querySelector(partInsert.dataset.addPartBefore != null ? "[data-part-insert-before]" : "[data-part-insert-after]");
|
| 2034 |
+
if (target) target.insertAdjacentHTML("beforeend", newPartHTML());
|
| 2035 |
+
saveProofreadDraft();
|
| 2036 |
+
return;
|
| 2037 |
+
}
|
| 2038 |
+
const deletePart = event.target.closest("[data-delete-part]");
|
| 2039 |
+
if (deletePart) {
|
| 2040 |
+
deletePart.dataset.proofreadDeleted = deletePart.dataset.proofreadDeleted === "true" ? "false" : "true";
|
| 2041 |
+
deletePart.textContent = deletePart.dataset.proofreadDeleted === "true" ? "恢复" : "删除";
|
| 2042 |
+
const group = deletePart.closest("[data-proofread-part-group]"); if (group) group.classList.toggle("proofread-deleted", deletePart.dataset.proofreadDeleted === "true");
|
| 2043 |
+
saveProofreadDraft();
|
| 2044 |
+
return;
|
| 2045 |
+
}
|
| 2046 |
+
if (event.target.closest("[data-remove-new-part]")) {
|
| 2047 |
+
const newPart = event.target.closest("[data-proofread-new-part]"); if (newPart) newPart.remove();
|
| 2048 |
+
saveProofreadDraft();
|
| 2049 |
+
return;
|
| 2050 |
+
}
|
| 2051 |
+
if (event.target.closest("[data-fulltext-toggle]")) {
|
| 2052 |
+
const box = DOM.previewPanel.querySelector("[data-fulltext-box]");
|
| 2053 |
+
if (box) box.hidden = !box.hidden;
|
| 2054 |
+
return;
|
| 2055 |
+
}
|
| 2056 |
+
if (event.target.closest("[data-fulltext-apply]")) {
|
| 2057 |
+
applyFullText(event.target.closest("[data-proofread-form]"));
|
| 2058 |
+
return;
|
| 2059 |
+
}
|
| 2060 |
const addList = event.target.closest("[data-add-list]");
|
| 2061 |
if (addList) {
|
| 2062 |
const kind = addList.dataset.addList;
|
|
|
|
| 2099 |
const sourceFile = event.target.closest("[data-proofread-source-file]");
|
| 2100 |
if (sourceFile) {
|
| 2101 |
const frame = DOM.previewPanel.querySelector("[data-proofread-source]");
|
| 2102 |
+
STATE.proofreadFile = sourceFile.value;
|
| 2103 |
+
const item = getCachedPreview(STATE.preview);
|
| 2104 |
+
const pageInput = DOM.previewPanel.querySelector("[data-proofread-page]");
|
| 2105 |
+
const page = Math.max(1, Number(pageInput ? pageInput.value : 0) || Number(item && item.article && item.article.page_start) || 1);
|
| 2106 |
+
STATE.proofreadPage = String(page);
|
| 2107 |
+
const selected = item && item.source_files && item.source_files.find((file) => String(file.index) === String(sourceFile.value));
|
| 2108 |
+
const pdfPage = selected && /\.pdf(?:$|\?)/i.test(selected.url) ? `#page=${page}` : "";
|
| 2109 |
+
if (frame) frame.src = `/api/download/source/${encodeURIComponent(STATE.preview)}?file=${encodeURIComponent(sourceFile.value)}&inline=true${pdfPage}`;
|
| 2110 |
+
syncUrl(true);
|
| 2111 |
+
saveProofreadDraft();
|
| 2112 |
+
return;
|
| 2113 |
}
|
| 2114 |
+
const pageInput = event.target.closest("[data-proofread-page]");
|
| 2115 |
+
if (pageInput) {
|
| 2116 |
+
STATE.proofreadPage = String(Math.max(1, Number(pageInput.value) || 1));
|
| 2117 |
+
const frame = DOM.previewPanel.querySelector("[data-proofread-source]");
|
| 2118 |
+
const item = getCachedPreview(STATE.preview);
|
| 2119 |
+
const selected = item && item.source_files && item.source_files.find((file) => String(file.index) === String(STATE.proofreadFile || "0"));
|
| 2120 |
+
if (frame && selected && /\.pdf(?:$|\?)/i.test(selected.url)) frame.src = `${frame.src.split("#", 1)[0]}#page=${STATE.proofreadPage}`;
|
| 2121 |
+
syncUrl(true);
|
| 2122 |
+
saveProofreadDraft();
|
| 2123 |
+
}
|
| 2124 |
+
});
|
| 2125 |
+
DOM.previewPanel.addEventListener("input", (event) => {
|
| 2126 |
+
if (event.target.closest("[data-proofread-form]")) saveProofreadDraft();
|
| 2127 |
});
|
| 2128 |
DOM.previewPanel.addEventListener("submit", (event) => {
|
| 2129 |
const form = event.target.closest("[data-proofread-form]");
|
static/style.css
CHANGED
|
@@ -651,6 +651,35 @@ ul, li {
|
|
| 651 |
resize: vertical;
|
| 652 |
line-height: 1.6;
|
| 653 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 654 |
|
| 655 |
.proofread-part textarea {
|
| 656 |
min-height: 120px;
|
|
|
|
| 651 |
resize: vertical;
|
| 652 |
line-height: 1.6;
|
| 653 |
}
|
| 654 |
+
.proofread-part-group.proofread-deleted {
|
| 655 |
+
opacity: .55;
|
| 656 |
+
}
|
| 657 |
+
.proofread-part-actions {
|
| 658 |
+
display: flex;
|
| 659 |
+
gap: .35rem;
|
| 660 |
+
flex-wrap: wrap;
|
| 661 |
+
}
|
| 662 |
+
.proofread-page-control {
|
| 663 |
+
display: inline-flex;
|
| 664 |
+
align-items: center;
|
| 665 |
+
gap: .35rem;
|
| 666 |
+
margin-left: auto;
|
| 667 |
+
font-size: .8rem;
|
| 668 |
+
}
|
| 669 |
+
.proofread-page-control input {
|
| 670 |
+
width: 5rem;
|
| 671 |
+
}
|
| 672 |
+
[data-fulltext-box] {
|
| 673 |
+
display: grid;
|
| 674 |
+
gap: .5rem;
|
| 675 |
+
margin-bottom: .75rem;
|
| 676 |
+
}
|
| 677 |
+
[data-fulltext-box][hidden] {
|
| 678 |
+
display: none;
|
| 679 |
+
}
|
| 680 |
+
[data-fulltext-input] {
|
| 681 |
+
min-height: 16rem;
|
| 682 |
+
}
|
| 683 |
|
| 684 |
.proofread-part textarea {
|
| 685 |
min-height: 120px;
|