vomebook commited on
Commit
f8bc60d
·
verified ·
1 Parent(s): 0857d06

Upload 2 files

Browse files
Files changed (2) hide show
  1. static/app.js +260 -13
  2. 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
- const removed = original ? `-${original.length}` : "";
1429
- const added = edited ? `+${encodeURI(edited).replace(/%20/g, " ")}` : "";
1430
- return [removed, added].filter(Boolean).join("\t");
 
 
 
 
 
 
 
 
 
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 pdfPage = sourceFiles.length === 1 ? `#page=${requestedPage}` : "";
1462
- const parts = (article.parts || []).map((part, index) => `<label class="proofread-field proofread-part" data-proofread-part="${index}"><span>段落 ${index + 1} / ${escapeHTML(part.type || "正文")}</span><textarea data-proofread-part-input="${index}">${escapeHTML(partTextWithPivots(article, index))}</textarea></label>`).join("");
 
 
 
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
- if (delta) patch.parts[index] = { diff: delta };
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- showToast("校订已提交,等待生成 PR");
 
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
- if (frame) frame.src = `/api/download/source/${encodeURIComponent(STATE.preview)}?file=${encodeURIComponent(sourceFile.value)}&inline=true`;
 
 
 
 
 
 
 
 
 
 
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;