dsh-doc-assistant 0.1.14 → 0.1.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js CHANGED
@@ -12,7 +12,7 @@
12
12
  * ./shelf <- src/client/70-shelf.js
13
13
  * ./boot <- src/client/90-boot.js
14
14
  *
15
- * Source digest: 6fb0aa631aa6c2ca
15
+ * Source digest: d5cb4638b3c119ba
16
16
  *
17
17
  * This is the hand-written lazy-CJS bundle the DSH client module system
18
18
  * expects: it calls `window.__ModuleLoader__.load({ id, factory })` with the
@@ -656,6 +656,7 @@ window.__ModuleLoader__.load({
656
656
 
657
657
  /** GET /doc/ir?projectId=&path= -> { doc } */
658
658
  docIR: (first, second) => call('/doc/ir', { query: pairObject(first, second) }),
659
+ convertWord: (first, second) => call('/doc/convert-word', { body: pairObject(first, second) }),
659
660
 
660
661
  /** GET /covers?projectId= -> { covers } —— 批量,书架一次拿全(CONTRACT §15.3)。 */
661
662
  getCovers: (projectId) => call('/covers', { query: asObject(projectId, 'projectId') }),
@@ -698,6 +699,7 @@ window.__ModuleLoader__.load({
698
699
  /** POST /chat/clear { projectId, path } -> { messages: [] }(§13.3) */
699
700
  chatClear: (first, second) => call('/chat/clear', { body: pairObject(first, second) }),
700
701
  chatModels: () => call('/chat/models'),
702
+ contextStatus: (first, second) => call('/doc/context-status', { query: pairObject(first, second) }),
701
703
  docVersion: (first, second) => call('/doc/version', { query: pairObject(first, second) }),
702
704
  openOffice: (first, second) => call('/doc/open-office', { body: pairObject(first, second) }),
703
705
 
@@ -1290,7 +1292,9 @@ window.__ModuleLoader__.load({
1290
1292
  throw new Error(`宿主返回的不是 JSON:${path}`);
1291
1293
  }
1292
1294
  if (!data || data.ok !== true) {
1293
- throw new Error((data && data.error) || `请求失败:${path}`);
1295
+ const error = new Error((data && data.error) || `请求失败:${path}`);
1296
+ error.body = data;
1297
+ throw error;
1294
1298
  }
1295
1299
  return data;
1296
1300
  }
@@ -1376,6 +1380,16 @@ window.__ModuleLoader__.load({
1376
1380
  async chatModels() {
1377
1381
  return typeof api.chatModels === 'function' ? invokeApi(api.chatModels, [[]]) : getJSON('/chat/models');
1378
1382
  },
1383
+ async contextStatus(projectId, path) {
1384
+ const data = typeof api.contextStatus === 'function' ? await invokeApi(api.contextStatus, [[{ projectId, path }]])
1385
+ : await getJSON('/doc/context-status', { projectId, path });
1386
+ return data.progress || { stage: 'idle' };
1387
+ },
1388
+ async convertWord(projectId, path) {
1389
+ const data = typeof api.convertWord === 'function' ? await invokeApi(api.convertWord, [[{ projectId, path }]])
1390
+ : await postJSON('/doc/convert-word', { projectId, path });
1391
+ return bodyOf(data, ['entry']);
1392
+ },
1379
1393
  async openOffice(projectId, path) {
1380
1394
  return typeof api.openOffice === 'function' ? invokeApi(api.openOffice, [[{ projectId, path }]]) : postJSON('/doc/open-office', { projectId, path });
1381
1395
  },
@@ -1408,12 +1422,13 @@ window.__ModuleLoader__.load({
1408
1422
  return bodyOf(await postJSON('/revise/iterate', input), ['revision']);
1409
1423
  },
1410
1424
 
1411
- async acceptRevision(revisionId) {
1425
+ async acceptRevision(revisionId, options = {}) {
1426
+ const input = { revisionId, ...(options.layoutRiskToken ? { layoutRiskToken: options.layoutRiskToken } : {}) };
1412
1427
  if (typeof api.acceptRevision === 'function') {
1413
- const out = await invokeApi(api.acceptRevision, [[revisionId], [{ revisionId }]]);
1428
+ const out = await invokeApi(api.acceptRevision, options.layoutRiskToken ? [[input]] : [[revisionId], [input]]);
1414
1429
  return { revision: bodyOf(out, ['revision']), written: out && out.written ? out.written : null };
1415
1430
  }
1416
- const data = await postJSON('/revision/accept', { revisionId });
1431
+ const data = await postJSON('/revision/accept', input);
1417
1432
  return { revision: data.revision || null, written: data.written || null };
1418
1433
  },
1419
1434
 
@@ -1559,6 +1574,8 @@ window.__ModuleLoader__.load({
1559
1574
  this.listeners = [];
1560
1575
  this.lastWrite = null;
1561
1576
  this.seq = 0;
1577
+ this.changeSeq = 0;
1578
+ this.itemChanges = new Map();
1562
1579
  }
1563
1580
 
1564
1581
  subscribe(listener) {
@@ -1618,6 +1635,7 @@ window.__ModuleLoader__.load({
1618
1635
  if (!changed) return this.all();
1619
1636
  this.seq += 1;
1620
1637
  this.items = [];
1638
+ this.itemChanges = new Map();
1621
1639
  this.error = '';
1622
1640
  this.loading = false;
1623
1641
  this.emit();
@@ -1634,11 +1652,13 @@ window.__ModuleLoader__.load({
1634
1652
  this.loading = true;
1635
1653
  this.error = '';
1636
1654
  this.emit();
1637
- const seq = ++this.seq;
1655
+ const seq = ++this.seq, changeSeq = this.changeSeq;
1638
1656
  try {
1639
1657
  const list = await this.transport.listRevisions(this.projectId, this.docPath);
1640
1658
  if (seq !== this.seq) return this.all();
1641
- this.items = Array.isArray(list) ? list.slice() : [];
1659
+ const recent = this.items.filter(item => item?.id && (this.itemChanges.get(item.id) || 0) > changeSeq);
1660
+ const recentIds = new Set(recent.map(item => item.id));
1661
+ this.items = recent.concat((Array.isArray(list) ? list : []).filter(item => !recentIds.has(item?.id)));
1642
1662
  this.loading = false;
1643
1663
  this.emit();
1644
1664
  return this.all();
@@ -1653,11 +1673,14 @@ window.__ModuleLoader__.load({
1653
1673
 
1654
1674
  _upsert(revision) {
1655
1675
  if (!revision || typeof revision !== 'object') return null;
1676
+ if ((revision.projectId && revision.projectId !== this.projectId)
1677
+ || (revision.docPath && revision.docPath !== this.docPath)) return null;
1656
1678
  if (!revision.id) {
1657
1679
  this.items = [revision].concat(this.items);
1658
1680
  this.emit();
1659
1681
  return revision;
1660
1682
  }
1683
+ this.itemChanges.set(revision.id, ++this.changeSeq);
1661
1684
  const index = this.items.findIndex((item) => item && item.id === revision.id);
1662
1685
  if (index >= 0) {
1663
1686
  this.items[index] = Object.assign({}, this.items[index], revision);
@@ -1691,10 +1714,12 @@ window.__ModuleLoader__.load({
1691
1714
  return revision;
1692
1715
  }
1693
1716
 
1694
- async accept(revisionId) {
1717
+ async accept(revisionId, options) {
1718
+ const projectId = this.projectId, docPath = this.docPath;
1695
1719
  this.setBusy(`rev:${revisionId}`, true);
1696
1720
  try {
1697
- const out = await this.transport.acceptRevision(revisionId);
1721
+ const out = await this.transport.acceptRevision(revisionId, options);
1722
+ if (this.projectId !== projectId || this.docPath !== docPath) return out;
1698
1723
  if (out && out.revision && typeof out.revision === 'object') this._upsert(out.revision);
1699
1724
  else {
1700
1725
  const target = this.items.find((item) => item && item.id === revisionId);
@@ -1703,15 +1728,20 @@ window.__ModuleLoader__.load({
1703
1728
  }
1704
1729
  if (out && out.written) this.lastWrite = out.written;
1705
1730
  return out;
1731
+ } catch (error) {
1732
+ if (this.projectId === projectId && this.docPath === docPath && error?.body?.revision) this._upsert(error.body.revision);
1733
+ throw error;
1706
1734
  } finally {
1707
1735
  this.setBusy(`rev:${revisionId}`, false);
1708
1736
  }
1709
1737
  }
1710
1738
 
1711
1739
  async reject(revisionId) {
1740
+ const projectId = this.projectId, docPath = this.docPath;
1712
1741
  this.setBusy(`rev:${revisionId}`, true);
1713
1742
  try {
1714
1743
  const out = await this.transport.rejectRevision(revisionId);
1744
+ if (this.projectId !== projectId || this.docPath !== docPath) return out;
1715
1745
  if (out && out.revision && typeof out.revision === 'object') this._upsert(out.revision);
1716
1746
  else {
1717
1747
  const target = this.items.find((item) => item && item.id === revisionId);
@@ -1725,9 +1755,11 @@ window.__ModuleLoader__.load({
1725
1755
  }
1726
1756
 
1727
1757
  async restore(revisionId) {
1758
+ const projectId = this.projectId, docPath = this.docPath;
1728
1759
  this.setBusy(`rev:${revisionId}`, true);
1729
1760
  try {
1730
1761
  const out = await this.transport.restoreRevision(revisionId);
1762
+ if (this.projectId !== projectId || this.docPath !== docPath) return out;
1731
1763
  if (out && out.revision && typeof out.revision === 'object') this._upsert(out.revision);
1732
1764
  return out;
1733
1765
  } finally {
@@ -1760,15 +1792,17 @@ window.__ModuleLoader__.load({
1760
1792
  * (单条撤回会破坏共用快照的语义)。accept / reject 仍可逐条兜底。
1761
1793
  */
1762
1794
  async bulk(ids, action) {
1795
+ const projectId = this.projectId, docPath = this.docPath;
1796
+ const isCurrent = () => this.projectId === projectId && this.docPath === docPath;
1763
1797
  const list = (Array.isArray(ids) ? ids : []).filter(Boolean);
1764
1798
  if (list.length === 0) return [];
1765
1799
  try {
1766
1800
  const results = await this.transport.bulkRevisions(list, action);
1767
- await this.load();
1801
+ if (isCurrent()) await this.load();
1768
1802
  return results;
1769
1803
  } catch (error) {
1770
1804
  if (action === 'restore') {
1771
- await this.load().catch(() => {});
1805
+ if (isCurrent()) await this.load().catch(() => {});
1772
1806
  throw error;
1773
1807
  }
1774
1808
  const results = [];
@@ -1782,7 +1816,7 @@ window.__ModuleLoader__.load({
1782
1816
  results.push({ revisionId: id, ok: false, error: normalizeError(inner) });
1783
1817
  }
1784
1818
  }
1785
- await this.load();
1819
+ if (isCurrent()) await this.load();
1786
1820
  return results;
1787
1821
  }
1788
1822
  }
@@ -1875,8 +1909,13 @@ window.__ModuleLoader__.load({
1875
1909
  const ops = opsFor(revision);
1876
1910
  const busy = p.busy === true || (p.busyIds || []).indexOf(revision.id) >= 0;
1877
1911
  const canDecide = status === 'pending';
1912
+ const canAcceptLayoutRisk = canDecide && typeof revision.layoutWarning?.token === 'string';
1913
+ const savedError = p.acceptError || revision.error || '';
1914
+ // Older saved cards may still recommend the removed copy feature.
1915
+ const acceptError = /流式(?:编辑)?副本/.test(savedError)
1916
+ ? '此修订可能造成文字重叠或版式异常。修订尚未写入。请点击文档标题右侧的 Office 图标,手动打开文档调整排版。' : savedError;
1917
+ const layoutWarning = canDecide && /手动打开文档调整排版/.test(acceptError);
1878
1918
  const unsafe = /\.docx$/i.test(revision.docPath || '') && revision.selectionVersion !== 'word-selection-v2' && status !== 'accepted';
1879
- const outdated = status === 'pending' && p.currentHash && revision.docHash && p.currentHash !== revision.docHash;
1880
1919
  const group = typeof p.writeGroup === 'function' ? p.writeGroup(revision) : writeGroupOf([revision], revision);
1881
1920
  const groupSize = Array.isArray(group) ? group.length : 1;
1882
1921
 
@@ -1906,18 +1945,22 @@ window.__ModuleLoader__.load({
1906
1945
  collapsed ? h('span', { key: 'status', className: P + 'RevCollapseStatus' }, STATUS_TEXT[status] || status) : null,
1907
1946
  ]),
1908
1947
  unsafe ? h('div', { key: 'unsafe', className: P + 'Err' }, '旧版选区可能漏字,请重新选择文字后生成修订。') : null,
1909
- revision.error
1910
- ? h('div', { key: 'err', className: P + 'Err' }, `错误:${revision.error}`)
1948
+ acceptError
1949
+ ? h('div', { key: 'err', className: P + 'Err', 'data-revision-layout-warning': layoutWarning ? '1' : undefined }, `${layoutWarning ? '提示' : '错误'}:${acceptError}`)
1911
1950
  : null,
1951
+ status === 'accepted' && revision.written?.layoutRiskAccepted
1952
+ ? h('div', { key: 'layout-reminder', className: P + 'Hint', role: 'status', 'data-revision-layout-warning': '1' },
1953
+ '已写入修订,可能存在文字重叠或版式异常。请点击文档标题右侧的 Office 图标,手动打开文档调整排版。')
1954
+ : null,
1955
+ p.progress ? h('div', { key: 'progress', 'data-revision-write-progress': '1' }, p.progress) : null,
1912
1956
  !collapsed ? h('div', { key: 'diff', className: P + 'RevDiffBox', 'aria-label': '词级差异' }, h(InlineDiff, { ops })) : null,
1913
1957
  !collapsed ? h('div', { key: 'actions', className: P + 'RevActions' }, [
1914
- canDecide ? button('accept', '接受', p.onAccept, P + 'BtnPrimary', unsafe || outdated,
1915
- outdated ? '文档已更新,请重新选择文字后生成修订' : undefined) : null,
1958
+ canDecide ? button('accept', canAcceptLayoutRisk ? '仍然接受并写入' : '接受', p.onAccept, P + 'BtnPrimary', unsafe || p.writeBusy) : null,
1916
1959
  status === 'rejected'
1917
- ? button('restore', '恢复为待处理', p.onRestore, null, false)
1960
+ ? button('restore', '恢复为待处理', p.onRestore, null, p.writeBusy)
1918
1961
  : null,
1919
1962
  status === 'accepted' && groupSize <= 1
1920
- ? button('restore', '撤回', p.onRestore, null, false, '撤回这次写入并还原文件')
1963
+ ? button('restore', '撤回', p.onRestore, null, p.writeBusy, '撤回这次写入并还原文件')
1921
1964
  : null,
1922
1965
  status === 'accepted' && groupSize > 1
1923
1966
  ? h('button', {
@@ -1925,11 +1968,11 @@ window.__ModuleLoader__.load({
1925
1968
  type: 'button',
1926
1969
  className: P + 'Btn',
1927
1970
  title: '这几条修订共用一次写回快照,必须整组还原',
1928
- disabled: busy || typeof p.onRestoreGroup !== 'function',
1971
+ disabled: busy || p.writeBusy || typeof p.onRestoreGroup !== 'function',
1929
1972
  onClick: typeof p.onRestoreGroup === 'function' ? () => p.onRestoreGroup(group) : undefined,
1930
1973
  }, `撤回本次批量写入(${groupSize} 条)`)
1931
1974
  : null,
1932
- status !== 'accepted' ? button('regen', '重新生成', p.onRegenerate, null, unsafe || outdated) : null,
1975
+ status !== 'accepted' ? button('regen', '重新生成', p.onRegenerate, null, unsafe) : null,
1933
1976
  button('delete', '删除', p.onDelete, null, false, '删除这张修订卡片,不撤销已写入文档的修改'),
1934
1977
  ]) : null,
1935
1978
  ]);
@@ -2081,10 +2124,12 @@ window.__ModuleLoader__.load({
2081
2124
  border-radius:var(--dsw-radius-sm,5px);cursor:pointer}
2082
2125
  .${P}Btn:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.12))}
2083
2126
  .${P}Btn:disabled{opacity:.5;cursor:default}
2084
- .${P}BtnPrimary{background:var(--dsw-alias-brand-primary);border-color:var(--dsw-alias-brand-primary);
2085
- color:#fff}
2086
- .${P}BtnPrimary:hover:not(:disabled){background:var(--dsw-alias-brand-primary);color:#fff;
2087
- filter:brightness(.92)}
2127
+ .${P}BtnPrimary{background:var(--dsw-alias-button-primary-fill,var(--dsw-alias-brand-primary));
2128
+ border-color:var(--dsw-alias-button-primary-fill,var(--dsw-alias-brand-primary));
2129
+ color:var(--dsw-alias-label-primary-foreground,#fff)}
2130
+ .${P}BtnPrimary:hover:not(:disabled){background:var(--dsw-alias-button-primary-hover,var(--dsw-alias-button-primary-fill,var(--dsw-alias-brand-primary)));
2131
+ border-color:var(--dsw-alias-button-primary-hover,var(--dsw-alias-button-primary-fill,var(--dsw-alias-brand-primary)));
2132
+ color:var(--dsw-alias-label-primary-foreground,#fff)}
2088
2133
  .${P}BtnGhost{border-color:transparent;background:transparent;color:var(--dsw-alias-label-secondary)}
2089
2134
  .${P}Err{color:var(--dsw-alias-state-error-primary);font-size:12px;margin-top:4px}
2090
2135
  `;
@@ -2142,7 +2187,7 @@ window.__ModuleLoader__.load({
2142
2187
  const DISPLAY_NAME = '企业文档助手';
2143
2188
 
2144
2189
  /** 客户端版本号,与 package.json 的 version 同步维护(`/state` 不回传版本时用它)。 */
2145
- const CLIENT_VERSION = '0.1.14';
2190
+ const CLIENT_VERSION = '0.1.18';
2146
2191
 
2147
2192
  /** 设置窗口的分区。 */
2148
2193
  const SECTIONS = [
@@ -3626,7 +3671,8 @@ window.__ModuleLoader__.load({
3626
3671
  for (const item of items) {
3627
3672
  const expand = item.action === 'expand' || /^扩写/.test(item.instruction || '');
3628
3673
  const key = expand ? JSON.stringify([item.docHash, item.original, item.anchor?.kind,
3629
- item.anchor?.blockId, item.anchor?.srcStart, item.anchor?.srcEnd, item.anchor?.sheet, item.anchor?.cell]) : item.id;
3674
+ item.anchor?.blockId, item.anchor?.srcStart, item.anchor?.srcEnd, item.anchor?.sheet, item.anchor?.cell,
3675
+ item.anchor?.segments?.map(segment => [segment.blockId, segment.srcStart, segment.srcEnd])]) : item.id;
3630
3676
  let group = byKey.get(key);
3631
3677
  if (!group) { group = { key, items: [] }; byKey.set(key, group); groups.push(group); }
3632
3678
  group.items.push(item);
@@ -3637,14 +3683,19 @@ window.__ModuleLoader__.load({
3637
3683
 
3638
3684
  function comparisonChoices(group, currentHash) {
3639
3685
  if (group.items.some(item => item.status === 'accepted')) return [];
3640
- return group.items.filter(item => item.status === 'pending' && !item.deletedAt
3641
- && (!currentHash || !item.docHash || item.docHash === currentHash));
3686
+ return group.items.filter(item => item.status === 'pending' && !item.deletedAt);
3642
3687
  }
3643
3688
 
3644
3689
  function captureRequest(value) {
3645
3690
  const request = JSON.parse(JSON.stringify(value));
3646
- if (request.anchor) Object.freeze(request.anchor);
3647
- return Object.freeze(request);
3691
+ const freeze = item => {
3692
+ if (item && typeof item === 'object') {
3693
+ Object.values(item).forEach(freeze);
3694
+ Object.freeze(item);
3695
+ }
3696
+ return item;
3697
+ };
3698
+ return freeze(request);
3648
3699
  }
3649
3700
 
3650
3701
  // =========================================================================
@@ -4224,7 +4275,7 @@ window.__ModuleLoader__.load({
4224
4275
  }
4225
4276
 
4226
4277
  const LAYOUT_FORMATS = { docx: true, pptx: true, xlsx: true, pdf: true };
4227
- const LAYOUT_ONLY_FORMATS = { docx: true, pptx: true, pdf: true };
4278
+ const LAYOUT_ONLY_FORMATS = { doc: true, docx: true, pptx: true, pdf: true };
4228
4279
 
4229
4280
  function mediaUrl(route, projectId, path, extra) {
4230
4281
  const q = new URLSearchParams();
@@ -4343,13 +4394,39 @@ window.__ModuleLoader__.load({
4343
4394
  return { picked, matched, srcStart: matched[0], srcEnd: matched[matched.length - 1] + 1 };
4344
4395
  }
4345
4396
 
4397
+ function wordLineGeometry(line) {
4398
+ const box = line && line.layoutBox;
4399
+ return box && box.w > 0 ? { x: (line.x - box.x) / box.w, w: line.w / box.w }
4400
+ : { x: Number(line && line.x), w: Number(line && line.w) };
4401
+ }
4402
+
4403
+ /** Column membership supplies layout context only; source matching stays exact. */
4404
+ function sameWordColumn(a, b) {
4405
+ const first = wordLineGeometry(a), next = wordLineGeometry(b);
4406
+ if (![first.x, first.w, next.x, next.w].every(Number.isFinite) || first.w <= 0 || next.w <= 0) return false;
4407
+ const overlap = Math.min(first.x + first.w, next.x + next.w) - Math.max(first.x, next.x);
4408
+ return Math.abs(first.x - next.x) <= .065 && overlap > Math.min(first.w, next.w) * .45;
4409
+ }
4410
+
4411
+ function exactWordGlyphs(line) {
4412
+ if (!line || line.ocr || line.precise !== true || !Array.isArray(line.glyphs) || !line.glyphs.length) return false;
4413
+ let cursor = 0;
4414
+ for (const glyph of line.glyphs) {
4415
+ if (glyph.precise === false || !Number.isInteger(glyph.from) || !Number.isInteger(glyph.to)
4416
+ || glyph.from !== cursor || glyph.to <= cursor || !Number.isFinite(glyph.x) || !Number.isFinite(glyph.w) || glyph.w < 0) return false;
4417
+ cursor = glyph.to;
4418
+ }
4419
+ return cursor === String(line.text || '').length;
4420
+ }
4421
+
4346
4422
  function bindWordLayoutLines(blocks, lines) {
4347
4423
  const parts = sourceParts({ blocks });
4348
4424
  const source = parts.map((part) => part.text).join('\n');
4349
4425
  const index = wordTextIndex(source);
4350
4426
  const beginnings = parts.map(part => wordTextIndex(part.text).compact);
4351
4427
  const list = Array.isArray(lines) ? lines : [];
4352
- const entries = list.map((line, order) => {
4428
+ const entries = list.map((rawLine, order) => {
4429
+ const { srcStart, srcEnd, srcMap, blockId, wordCell, wordParagraphs, wordPrefix, wordLayout, ...line } = rawLine || {};
4353
4430
  const text = String(line && line.text || '');
4354
4431
  let prefix = 0;
4355
4432
  let picked = wordTextIndex(text);
@@ -4394,16 +4471,56 @@ window.__ModuleLoader__.load({
4394
4471
  window.forEach((entry, i) => { entry.at = placements[i]; });
4395
4472
  }
4396
4473
  }
4474
+ // Multi-column reports interleave author details with body lines in PDF
4475
+ // reading order. Match a unique, exact continuation in its own column,
4476
+ // and require any already mapped neighbour to agree with the placement.
4477
+ for (const entry of ordered) {
4478
+ if (entry.at >= 0 || !exactWordGlyphs(entry.line)) continue;
4479
+ const column = ordered.filter(other => exactWordGlyphs(other.line)
4480
+ && Number(other.line.page || 1) === Number(entry.line.page || 1) && sameWordColumn(entry.line, other.line));
4481
+ const center = column.indexOf(entry), placements = new Set();
4482
+ for (let size = 2; size <= Math.min(8, column.length); size += 1) {
4483
+ for (let from = Math.max(0, center - size + 1); from <= center && from + size <= column.length; from += 1) {
4484
+ const window = column.slice(from, from + size);
4485
+ if (!window.some(other => other !== entry && other.at >= 0)) continue;
4486
+ const needle = window.map(other => other.picked.compact).join('');
4487
+ if (needle.length > 2048) continue;
4488
+ let offset = 0;
4489
+ const anchored = new Set();
4490
+ for (const other of window) {
4491
+ if (other.at >= 0) anchored.add(other.at - offset);
4492
+ offset += other.picked.compact.length;
4493
+ }
4494
+ if (anchored.size !== 1) continue;
4495
+ const at = anchored.values().next().value;
4496
+ if (at < 0 || index.compact.slice(at, at + needle.length) !== needle) continue;
4497
+ let cursor = at, wanted = -1, consistent = true;
4498
+ for (const other of window) {
4499
+ if (other.at >= 0 && other.at !== cursor) consistent = false;
4500
+ if (other === entry) wanted = cursor;
4501
+ cursor += other.picked.compact.length;
4502
+ }
4503
+ if (consistent && !ordered.some(other => other !== entry && other.at >= 0
4504
+ && other.at < wanted + entry.picked.compact.length && other.at + other.picked.compact.length > wanted)) placements.add(wanted);
4505
+ }
4506
+ }
4507
+ if (placements.size === 1) entry.at = placements.values().next().value;
4508
+ }
4397
4509
  }
4398
4510
  return entries.map(entry => {
4399
4511
  const { line, text, picked, prefix, at } = entry;
4400
- if (at < 0) return { ...line, text };
4512
+ if (at < 0) return { ...line, text, wordLayout: true };
4401
4513
  const matched = index.positions.slice(at, at + picked.compact.length);
4402
4514
  const srcStart = matched[0];
4403
4515
  const srcEnd = matched[matched.length - 1] + 1;
4404
4516
  const part = parts.find(item => srcStart >= item.start && srcStart < item.start + item.text.length);
4405
- if (!part || srcEnd > part.start + part.text.length) return { ...line, text };
4406
- const bound = { ...line, text, blockId: part.block.id, srcStart, srcEnd };
4517
+ // PDF-to-Word documents can put a bullet and its text in two separate
4518
+ // paragraphs while painting them as one PDF line. Native glyphs prove
4519
+ // that exact combined span; OCR and table-cell crossings stay refused.
4520
+ const crossed = parts.filter(item => item.start < srcEnd && item.start + item.text.length > srcStart);
4521
+ if (!part || (srcEnd > part.start + part.text.length
4522
+ && (!exactWordGlyphs(line) || crossed.some(item => item.block.type === 'table')))) return { ...line, text, wordLayout: true };
4523
+ const bound = { ...line, text, wordLayout: true, blockId: part.block.id, srcStart, srcEnd };
4407
4524
  if (part.block.type === 'table') {
4408
4525
  for (let row = 0; row < (part.block.rows || []).length; row++) {
4409
4526
  const cells = part.block.rows[row].cells || [];
@@ -4502,6 +4619,45 @@ window.__ModuleLoader__.load({
4502
4619
  return ranges;
4503
4620
  }
4504
4621
 
4622
+ function wordListMarker(text) {
4623
+ return /^[\s⚫•●▪◦‣⁃∙○◉◎■□◆◇◼◾⬤*\-]+$/u.test(String(text || ''))
4624
+ && /[⚫•●▪◦‣⁃∙○◉◎■□◆◇◼◾⬤*\-]/u.test(String(text || ''));
4625
+ }
4626
+
4627
+ /** Separate Word paragraphs remain exact source segments of one selection. */
4628
+ function wordCompoundAnchor(doc, parts) {
4629
+ const source = sourceParts(doc).map(part => part.text).join('\n');
4630
+ if (!Array.isArray(parts) || !parts.length || parts.some(part => !part?.anchor
4631
+ || !Number.isInteger(part.anchor.srcStart) || !Number.isInteger(part.anchor.srcEnd)
4632
+ || part.anchor.srcStart < 0 || part.anchor.srcEnd <= part.anchor.srcStart || part.anchor.srcEnd > source.length
4633
+ || source.slice(part.anchor.srcStart, part.anchor.srcEnd) !== part.text)) return null;
4634
+ if (parts.length === 1) return { ...parts[0].anchor };
4635
+ const segments = parts.map(part => ({ blockId: part.anchor.blockId, srcStart: part.anchor.srcStart,
4636
+ srcEnd: part.anchor.srcEnd, selectionProof: part.anchor.selectionProof || 'pdf-glyphs-v1' }));
4637
+ if (segments.some((segment, i) => i > 0 && segment.srcStart < segments[i - 1].srcEnd)) return null;
4638
+ const anchor = { kind: 'range', blockId: segments[0].blockId, segments };
4639
+ const from = segments[0].srcStart, to = segments.at(-1).srcEnd;
4640
+ if (segments.every((segment, i) => !i || /^\s*$/.test(source.slice(segments[i - 1].srcEnd, segment.srcStart)))) {
4641
+ anchor.srcStart = from; anchor.srcEnd = to;
4642
+ }
4643
+ return anchor;
4644
+ }
4645
+
4646
+ function wordAnchorText(doc, anchor) {
4647
+ const source = sourceParts(doc).map(part => part.text).join('\n');
4648
+ const segments = Array.isArray(anchor?.segments) ? anchor.segments : [anchor];
4649
+ if (!segments.length || segments.some(segment => !Number.isInteger(segment?.srcStart) || !Number.isInteger(segment?.srcEnd)
4650
+ || segment.srcStart < 0 || segment.srcEnd <= segment.srcStart || segment.srcEnd > source.length)) return undefined;
4651
+ return segments.map(segment => source.slice(segment.srcStart, segment.srcEnd)).join('\n');
4652
+ }
4653
+
4654
+ function continuousWordAnchor(doc, anchor) {
4655
+ const source = sourceParts(doc).map(part => part.text).join('\n');
4656
+ const segments = Array.isArray(anchor?.segments) ? anchor.segments : [anchor];
4657
+ return wordAnchorText(doc, anchor) !== undefined && segments.every((segment, i) => !i
4658
+ || (segment.srcStart >= segments[i - 1].srcEnd && /^\s*$/.test(source.slice(segments[i - 1].srcEnd, segment.srcStart))));
4659
+ }
4660
+
4505
4661
  /** The physical highlight, quote and rewrite all use this same exact source. */
4506
4662
  function wordNativeSelection(doc, lines) {
4507
4663
  const selected = sortReadingOrder(lines);
@@ -4515,8 +4671,9 @@ window.__ModuleLoader__.load({
4515
4671
  if (!original.trim() || wordTextIndex(original).compact !== wordTextIndex(picked).compact) return fallback;
4516
4672
  const parts = wordRanges(doc, from, to);
4517
4673
  if (!parts.length) return fallback;
4518
- return { anchor: parts.length === 1 ? parts[0].anchor : { kind: 'range', blockId: parts[0].blockId },
4519
- text: original, blockId: parts[0].blockId, parts, verified: true };
4674
+ const anchor = wordCompoundAnchor(doc, parts);
4675
+ if (!anchor) return fallback;
4676
+ return { anchor, text: wordAnchorText(doc, anchor), blockId: parts[0].blockId, parts, verified: true };
4520
4677
  }
4521
4678
 
4522
4679
  /** A rectangle can select separate source slices; verify each before merging only whitespace gaps. */
@@ -4537,8 +4694,9 @@ window.__ModuleLoader__.load({
4537
4694
  }
4538
4695
  if (!groups.length) return null;
4539
4696
  const parts = groups.flatMap(group => wordRanges(doc, group.from, group.to));
4540
- return { anchor: parts.length === 1 ? parts[0].anchor : { kind: 'range', blockId: parts[0].blockId },
4541
- text: groups.map(group => source.slice(group.from, group.to)).join('\n'), parts, blockId: parts[0].blockId, verified: true };
4697
+ const anchor = wordCompoundAnchor(doc, parts);
4698
+ if (!anchor) return { anchor: { kind: 'range' }, text: joinOcrText(lines), wordUnmapped: true };
4699
+ return { anchor, text: wordAnchorText(doc, anchor), parts, blockId: parts[0].blockId, verified: true };
4542
4700
  }
4543
4701
 
4544
4702
  function uniqueAt(haystack, needle) {
@@ -5008,12 +5166,26 @@ window.__ModuleLoader__.load({
5008
5166
  * @param {{ x: number, y: number }} b
5009
5167
  */
5010
5168
  function selectionSpans(lines, a, b) {
5011
- const list = sortReadingOrder((Array.isArray(lines) ? lines : []).filter((line) => (
5169
+ let list = sortReadingOrder((Array.isArray(lines) ? lines : []).filter((line) => (
5012
5170
  line && (line.ocr === true || line.nativeSelection) && String(line.text || '').trim()
5013
5171
  )));
5014
5172
  if (!a || !b || list.length === 0) return [];
5015
- const ia = indexAtPoint(list, Number(a.x) || 0, Number(a.y) || 0);
5016
- const ib = indexAtPoint(list, Number(b.x) || 0, Number(b.y) || 0);
5173
+ let ia = indexAtPoint(list, Number(a.x) || 0, Number(a.y) || 0);
5174
+ let ib = indexAtPoint(list, Number(b.x) || 0, Number(b.y) || 0);
5175
+ const first = list[ia >= 0 ? ia : ib], last = list[ib >= 0 ? ib : ia];
5176
+ if (first && first.wordLayout && !first.wordCell && (!last || sameWordColumn(first, last))) {
5177
+ const edge = wordLineGeometry(first);
5178
+ // Restrict an ordinary drag inside a clearly separate Word column. A
5179
+ // rectangle remains available for an intentional multi-column selection.
5180
+ const hasOtherColumn = list.some(line => line.wordLayout && Number(line.page || 1) === Number(first.page || 1)
5181
+ && Math.abs(wordLineGeometry(line).x - edge.x) > .18
5182
+ && (wordLineGeometry(line).x >= edge.x + edge.w || wordLineGeometry(line).x + wordLineGeometry(line).w <= edge.x));
5183
+ if (hasOtherColumn) {
5184
+ list = list.filter(line => sameWordColumn(first, line));
5185
+ ia = indexAtPoint(list, Number(a.x) || 0, Number(a.y) || 0);
5186
+ ib = indexAtPoint(list, Number(b.x) || 0, Number(b.y) || 0);
5187
+ }
5188
+ }
5017
5189
  if (ia < 0 && ib < 0) {
5018
5190
  // 指针停在行外时,仍按拖过的矩形把跨到的行切开,避免一松手选区被清掉。
5019
5191
  const hits = hitsInRect(list, { x0: a.x, y0: a.y, x1: b.x, y1: b.y });
@@ -5512,7 +5684,7 @@ window.__ModuleLoader__.load({
5512
5684
  function revisionAllowed(doc) {
5513
5685
  if (!doc || typeof doc !== 'object') return true;
5514
5686
  const format = typeof doc.format === 'string' ? doc.format : '';
5515
- if (format === 'pdf' || format === 'image') return false;
5687
+ if (format === 'pdf' || format === 'image' || format === 'doc') return false;
5516
5688
  return !(doc.meta && doc.meta.writable === false);
5517
5689
  }
5518
5690
 
@@ -5574,6 +5746,18 @@ window.__ModuleLoader__.load({
5574
5746
  return `dshDoc:scroll:${projectId}:${path}`;
5575
5747
  }
5576
5748
 
5749
+ function slideStorageKey(projectId, path) {
5750
+ return `dshDoc:slide:${projectId}:${path}`;
5751
+ }
5752
+
5753
+ function storedSlide(projectId, path) {
5754
+ try {
5755
+ const value = Number(currentWindow()?.localStorage?.getItem(slideStorageKey(projectId, path)));
5756
+ if (Number.isSafeInteger(value) && value >= 1) return value;
5757
+ } catch { /* A private browser still opens the first page. */ }
5758
+ return 1;
5759
+ }
5760
+
5577
5761
  function isTypingTarget(target) {
5578
5762
  if (!target || typeof target.tagName !== 'string') return false;
5579
5763
  const tag = target.tagName;
@@ -5590,7 +5774,9 @@ window.__ModuleLoader__.load({
5590
5774
  const [findOpen, setFindOpen] = React.useState(false);
5591
5775
  const [findQ, setFindQ] = React.useState('');
5592
5776
  const [findAt, setFindAt] = React.useState(0);
5593
- const [slide, setSlide] = React.useState(1);
5777
+ const [slide, setSlide] = React.useState(() => storedSlide(p.projectId, p.path));
5778
+ const [slideInput, setSlideInput] = React.useState(() => String(storedSlide(p.projectId, p.path)));
5779
+ const [slideJumpError, setSlideJumpError] = React.useState('');
5594
5780
  const [slideCount, setSlideCount] = React.useState(0);
5595
5781
  const [slideReady, setSlideReady] = React.useState(false);
5596
5782
  const [slideError, setSlideError] = React.useState('');
@@ -5599,6 +5785,7 @@ window.__ModuleLoader__.load({
5599
5785
  const [pageFailed, setPageFailed] = React.useState(false);
5600
5786
  const [readyPages, setReadyPages] = React.useState({});
5601
5787
  const [ocrByPage, setOcrByPage] = React.useState({});
5788
+ const [pageTextNotice, setPageTextNotice] = React.useState(null);
5602
5789
  const [rasterPages, setRasterPages] = React.useState(0);
5603
5790
  const [rasterAspect, setRasterAspect] = React.useState(0);
5604
5791
  const [ocrOn, setOcrOn] = React.useState(null);
@@ -5610,6 +5797,7 @@ window.__ModuleLoader__.load({
5610
5797
  const bodyRef = React.useRef(null);
5611
5798
  const wrapRef = React.useRef(null);
5612
5799
  const slideCountRef = React.useRef(0);
5800
+ const slideStorageRef = React.useRef({ target: '', ready: false });
5613
5801
  const missTick = React.useRef(0);
5614
5802
  const gestureRef = React.useRef(null);
5615
5803
  const selectionScrollRef = React.useRef(null);
@@ -5628,7 +5816,9 @@ window.__ModuleLoader__.load({
5628
5816
  const actions = React.useMemo(() => (
5629
5817
  canRevise ? baseActions : baseActions.filter((action) => !action || action.kind !== 'revise')
5630
5818
  ), [baseActions, canRevise]);
5631
- const format = doc && typeof doc.format === 'string' ? doc.format : '';
5819
+ const sourceFormat = doc && typeof doc.format === 'string' ? doc.format : '';
5820
+ const format = sourceFormat === 'doc' ? 'docx' : sourceFormat;
5821
+ const visionModelKey = JSON.stringify(p.visionRoute || {});
5632
5822
  const canLayout = Boolean(p.projectId && p.path && LAYOUT_FORMATS[format]);
5633
5823
  // Word / PPT / PDF 只显示版式,旧的文本偏好也不能把它们带回抽取正文。
5634
5824
  const view = (LAYOUT_ONLY_FORMATS[format] && canLayout) || (p.view === 'layout' && canLayout) ? 'layout' : 'text';
@@ -5641,9 +5831,8 @@ window.__ModuleLoader__.load({
5641
5831
  kind: 'pick',
5642
5832
  title: '列出这一页的文本框,选一段润色、扩写,或自己改字',
5643
5833
  }])
5644
- : actions).map(action => action.kind === 'revise' && (p.busyAction === 'polish' || p.busyAction === 'expand')
5645
- ? { ...action, disabled: true } : action)
5646
- ), [actions, format, canRevise, showLayout, p.busyAction]);
5834
+ : actions)
5835
+ ), [actions, format, canRevise, showLayout]);
5647
5836
  const showImage = format === 'image' && !showLayout;
5648
5837
  const reason = doc && doc.meta && typeof doc.meta.reason === 'string' ? doc.meta.reason : '';
5649
5838
  const previewRev = Number(p.previewEpoch) || 0;
@@ -5666,6 +5855,9 @@ window.__ModuleLoader__.load({
5666
5855
  }
5667
5856
  function docMedia(route, extra) {
5668
5857
  const query = extra ? { ...extra } : {};
5858
+ if (route === 'page-text' && query.page && p.visionRoute?.provider && p.visionRoute?.model) {
5859
+ query.provider = p.visionRoute.provider; query.model = p.visionRoute.model;
5860
+ }
5669
5861
  // 旧地址没有 v,浏览器会把改写前的页图缓存五分钟。每次接受都换一个 v。
5670
5862
  query.v = previewRev;
5671
5863
  return mediaUrl(route, p.projectId, p.path, query);
@@ -5678,10 +5870,15 @@ window.__ModuleLoader__.load({
5678
5870
  setFindOpen(false);
5679
5871
  setFindQ('');
5680
5872
  setFindAt(0);
5681
- setSlide(1);
5873
+ const restoredSlide = storedSlide(p.projectId, p.path);
5874
+ slideStorageRef.current = { target: `${p.projectId}\u0000${p.path}`, ready: false };
5875
+ setSlide(restoredSlide);
5876
+ setSlideInput(String(restoredSlide));
5877
+ setSlideJumpError('');
5682
5878
  setSlideCount(0);
5683
5879
  setSlideReady(false);
5684
5880
  setSlideError('');
5881
+ setPageTextNotice(null);
5685
5882
  setSlideRatio(16 / 9);
5686
5883
  setPageModel(null);
5687
5884
  setPageFailed(false);
@@ -5733,23 +5930,30 @@ window.__ModuleLoader__.load({
5733
5930
  fetch(docMedia('pages'), { signal: ac.signal })
5734
5931
  .then((res) => res.json())
5735
5932
  .then((body) => {
5933
+ if (ac.signal.aborted) return;
5736
5934
  if (!body || body.ok === false) {
5737
5935
  setSlideError((body && body.error) || '无法读取幻灯片页数');
5738
5936
  return;
5739
5937
  }
5740
5938
  const pages = Number(body.pages);
5939
+ const count = Number.isInteger(pages) && pages > 0 ? pages : 1;
5741
5940
  setSlideError('');
5742
- setSlideCount(Number.isInteger(pages) && pages > 0 ? pages : 1);
5941
+ slideStorageRef.current = { target: `${p.projectId}\u0000${p.path}`, ready: true };
5942
+ setSlideCount(count);
5943
+ setSlide(value => Math.min(count, Math.max(1, value)));
5743
5944
  })
5744
5945
  .catch((error) => {
5745
- if (error && error.name === 'AbortError') return;
5946
+ if (ac.signal.aborted || error && error.name === 'AbortError') return;
5746
5947
  setSlideError('无法读取幻灯片页数');
5747
5948
  });
5748
5949
  return () => ac.abort();
5749
- }, [format, showLayout, p.projectId, p.path]);
5950
+ }, [format, showLayout, p.projectId, p.path, previewRev]);
5750
5951
 
5751
5952
  React.useEffect(() => {
5953
+ setSlideInput(String(slide));
5954
+ setSlideJumpError('');
5752
5955
  setSlideReady(false);
5956
+ setSlideError('');
5753
5957
  setSelection(null);
5754
5958
  setOcrOn(null);
5755
5959
  setBoxPick(null);
@@ -5758,8 +5962,16 @@ window.__ModuleLoader__.load({
5758
5962
  }, [slide, p.projectId, p.path]);
5759
5963
 
5760
5964
  React.useEffect(() => {
5761
- // 版式预览(含幻灯片)一直靠这段文字。PDF 切到文本页时也要,正文块是空的。
5762
- const want = Boolean(p.projectId && p.path && (showLayout || format === 'pdf'));
5965
+ if (format !== 'pptx' || !slideCount || slide < 1 || slide > slideCount
5966
+ || !slideStorageRef.current.ready || slideStorageRef.current.target !== `${p.projectId}\u0000${p.path}`) return;
5967
+ try { currentWindow()?.localStorage?.setItem(slideStorageKey(p.projectId, p.path), String(slide)); }
5968
+ catch { /* Navigation does not depend on local persistence. */ }
5969
+ if (typeof p.onLayoutPage === 'function') p.onLayoutPage(slide);
5970
+ }, [format, p.projectId, p.path, slide, slideCount]);
5971
+
5972
+ React.useEffect(() => {
5973
+ // 幻灯片逐页读取;打开图片型 PPTX 不预先转换整册。
5974
+ const want = Boolean(format !== 'pptx' && p.projectId && p.path && (showLayout || format === 'pdf'));
5763
5975
  if (!want) return undefined;
5764
5976
  const ac = new AbortController();
5765
5977
  fetch(docMedia('page-text'), { signal: ac.signal })
@@ -5808,7 +6020,7 @@ window.__ModuleLoader__.load({
5808
6020
  if (!nativePages.has(i)) queue.push(i);
5809
6021
  }
5810
6022
  }
5811
- const tokens = queue.map((page) => `${p.projectId}:${p.path}:${page}:${previewRev}`);
6023
+ const tokens = queue.map((page) => `${p.projectId}:${p.path}:${page}:${previewRev}:${visionModelKey}`);
5812
6024
  (async () => {
5813
6025
  for (let i = 0; i < queue.length; i += 1) {
5814
6026
  if (cancelled) return;
@@ -5816,23 +6028,32 @@ window.__ModuleLoader__.load({
5816
6028
  const token = tokens[i];
5817
6029
  if (ocrTried.current[token]) continue;
5818
6030
  ocrTried.current[token] = 'pending';
6031
+ if (format === 'pptx') setPageTextNotice({ page, pending: true, text: '正在识别本页文字,完成后即可选中…' });
5819
6032
  try {
5820
6033
  const res = await fetch(docMedia('page-text', { page }), { signal: ac.signal });
5821
6034
  const body = await res.json();
5822
6035
  if (cancelled) return;
5823
6036
  ocrTried.current[token] = 'done';
5824
- if (!body || body.ok === false) continue;
6037
+ if (!body || body.ok === false) {
6038
+ if (format === 'pptx') setPageTextNotice({ page, text: body?.error || '本页文字读取失败,请刷新后重试。' });
6039
+ continue;
6040
+ }
6041
+ if (format === 'pptx') setPageTextNotice(body.lines?.length
6042
+ ? (body.ocr?.warning ? { page, text: body.ocr.warning + (body.ocr.visionError ? ` ${body.ocr.visionError}` : '') } : null)
6043
+ : { page, text: body.ocr?.visionError || body.ocr?.error || '本页未识别到文字;可选用支持图片的模型进行文档提问。' });
5825
6044
  const pages = Number(body.pages);
5826
6045
  const aspect = Number(body.aspect);
5827
6046
  // 第一页可能全是图片;页数仍用于继续读取后面的文字页。
5828
6047
  if (pages > 0) setRasterPages((prev) => (pages > prev ? pages : prev));
5829
6048
  if (aspect > 0) setRasterAspect((prev) => (prev > 0 ? prev : aspect));
5830
6049
  if (!Array.isArray(body.lines) || body.lines.length === 0) continue;
5831
- if (format === 'docx' && body.source === 'pdf') {
6050
+ if (body.source === 'pdf' || body.source === 'mixed') {
5832
6051
  setPageModel((prev) => ({
5833
6052
  pages: Math.max(pages || 1, prev && prev.pages || 1),
5834
6053
  aspect: aspect > 0 ? aspect : (prev && prev.aspect || 0.772),
5835
- lines: (prev ? prev.lines.filter((line) => Number(line.page) !== page) : []).concat(body.lines),
6054
+ lines: (prev ? prev.lines.filter((line) => Number(line.page) !== page) : []).concat(body.lines.map(line => ({ ...line, page,
6055
+ ...(line.ocr !== true && line.precise && Array.isArray(line.glyphs) ? { nativeSelection: true } : {}),
6056
+ }))),
5836
6057
  }));
5837
6058
  continue;
5838
6059
  }
@@ -5843,7 +6064,10 @@ window.__ModuleLoader__.load({
5843
6064
  }));
5844
6065
  } catch (error) {
5845
6066
  if (error && error.name === 'AbortError') return;
5846
- if (!cancelled) ocrTried.current[token] = 'done';
6067
+ if (!cancelled) {
6068
+ ocrTried.current[token] = 'done';
6069
+ if (format === 'pptx') setPageTextNotice({ page, text: normalizeError(error) });
6070
+ }
5847
6071
  }
5848
6072
  }
5849
6073
  })();
@@ -5854,7 +6078,7 @@ window.__ModuleLoader__.load({
5854
6078
  if (ocrTried.current[tokens[i]] === 'pending') delete ocrTried.current[tokens[i]];
5855
6079
  }
5856
6080
  };
5857
- }, [format, showLayout, slide, pageModel, pageFailed, rasterPages, p.projectId, p.path, previewRev]);
6081
+ }, [format, showLayout, slide, pageModel, pageFailed, rasterPages, p.projectId, p.path, previewRev, visionModelKey]);
5858
6082
 
5859
6083
  const boundLines = React.useMemo(
5860
6084
  () => (format === 'docx' ? bindWordLayoutLines : bindLayoutLines)(blocks, pageModel && pageModel.lines),
@@ -6298,14 +6522,15 @@ window.__ModuleLoader__.load({
6298
6522
  if (target && typeof target.closest === 'function' && target.closest('button')) return;
6299
6523
  if (startPageGesture(event, event.currentTarget.closest?.('[data-reader-pages]'))) return;
6300
6524
  const native = format === 'docx' && (lines || []).some(line => line.precise && Array.isArray(line.glyphs));
6301
- if (!native && target && typeof target.closest === 'function') {
6525
+ const mixedSlide = format === 'pptx' && (lines || []).some(line => line.nativeSelection);
6526
+ if (!native && !mixedSlide && target && typeof target.closest === 'function') {
6302
6527
  const layer = target.closest('[data-text-layer]');
6303
6528
  if (layer && layer.getAttribute('data-ocr') !== '1') {
6304
6529
  ocrHold.current = false;
6305
6530
  return;
6306
6531
  }
6307
6532
  }
6308
- let ocrLines = (lines || []).filter(line => line && (native ? line.precise && Array.isArray(line.glyphs) : line.ocr === true))
6533
+ let ocrLines = (lines || []).filter(line => line && (native ? line.precise && Array.isArray(line.glyphs) : (line.ocr === true || line.nativeSelection)))
6309
6534
  .map(line => native ? { ...line, nativeSelection: true } : line);
6310
6535
  if (ocrLines.length === 0) return;
6311
6536
  const plane = event.currentTarget;
@@ -6550,6 +6775,18 @@ window.__ModuleLoader__.load({
6550
6775
  });
6551
6776
  };
6552
6777
 
6778
+ const jumpSlide = () => {
6779
+ const value = slideInput.trim(), number = Number(value), max = slideCountRef.current;
6780
+ if (!max) { setSlideJumpError('页数尚未读取完成,请稍后重试。'); return; }
6781
+ if (!/^\d+$/.test(value) || !Number.isSafeInteger(number) || number < 1 || number > max) {
6782
+ setSlideJumpError(`请输入 1–${max} 之间的整数页码。`);
6783
+ return;
6784
+ }
6785
+ setSlideJumpError('');
6786
+ setSlideInput(String(number));
6787
+ setSlide(number);
6788
+ };
6789
+
6553
6790
  const slideMode = showLayout && format === 'pptx';
6554
6791
  const pdfLineCount = pageModel && Array.isArray(pageModel.lines) ? pageModel.lines.length : 0;
6555
6792
  const ocrLineCount = Object.keys(ocrBound).reduce((sum, key) => sum + (Array.isArray(ocrBound[key]) ? ocrBound[key].length : 0), 0);
@@ -6588,7 +6825,7 @@ window.__ModuleLoader__.load({
6588
6825
  return;
6589
6826
  }
6590
6827
  const target = event.target;
6591
- if (target && typeof target.closest === 'function' && (target.closest('button') || target.closest('[data-text-layer]'))) return;
6828
+ if (target && typeof target.closest === 'function' && (target.closest('button,input,select,textarea') || target.closest('[data-text-layer]'))) return;
6592
6829
  const selected = currentWindow();
6593
6830
  const live = selected && typeof selected.getSelection === 'function' ? selected.getSelection() : null;
6594
6831
  if (live && !live.isCollapsed) return;
@@ -6673,7 +6910,25 @@ window.__ModuleLoader__.load({
6673
6910
  disabled: slideCount === 0 || slide >= slideCount,
6674
6911
  onClick: () => turnSlide(1),
6675
6912
  }, '›'),
6676
- h('div', { key: 'count', className: P + 'SlideCount' }, slideCount ? `${slide} / ${slideCount}` : '…'),
6913
+ h('div', { key: 'count', className: P + 'SlideCount', 'data-slide-navigation': '1' }, [
6914
+ h('div', { key: 'controls', className: P + 'SlideCountControls' }, [
6915
+ h('input', {
6916
+ key: 'page', type: 'number', inputMode: 'numeric', min: 1, max: slideCount || undefined, step: 1,
6917
+ value: slideInput, disabled: !slideCount, className: P + 'SlidePageInput',
6918
+ 'aria-label': '跳转页码', 'aria-invalid': slideJumpError ? 'true' : undefined, 'data-slide-page-input': '1',
6919
+ onChange: event => { setSlideInput(event.target.value); setSlideJumpError(''); },
6920
+ onKeyDown: event => {
6921
+ if (event.key === 'Enter') { event.preventDefault(); event.stopPropagation(); jumpSlide(); }
6922
+ else if (event.key === 'Escape') { setSlideInput(String(slide)); setSlideJumpError(''); }
6923
+ },
6924
+ }),
6925
+ h('span', { key: 'total', 'data-slide-total': '1' }, slideCount ? `/ ${slideCount}` : '/ …'),
6926
+ h('button', { key: 'jump', type: 'button', className: P + 'SlideJumpButton', disabled: !slideCount,
6927
+ 'aria-label': '跳转到指定页', 'data-slide-jump': '1', onClick: jumpSlide }, '跳转'),
6928
+ ]),
6929
+ slideJumpError ? h('div', { key: 'error', className: P + 'SlideJumpError', role: 'alert',
6930
+ 'data-slide-jump-error': '1' }, slideJumpError) : null,
6931
+ ]),
6677
6932
  ]) : null;
6678
6933
 
6679
6934
  const shownPages = usePages ? Math.min(pageCount, 80) : 0;
@@ -6731,6 +6986,12 @@ window.__ModuleLoader__.load({
6731
6986
 
6732
6987
  return h('div', { className: P + 'Wrap', ref: wrapRef, 'data-reader-wrap': '1', 'data-reader-view': view }, [
6733
6988
  viewBar,
6989
+ sourceFormat === 'doc' ? h('div', { key: 'legacy-word', className: P + 'SelectionNotice', role: 'status', 'data-legacy-word-notice': '1' }, [
6990
+ p.convertingWord ? h(Processing, { key: 'progress', text: '正在转换为可修订的 Word 文档…' })
6991
+ : h('span', { key: 'text' }, '旧版 DOC 可以阅读和提问;转换为 DOCX 副本后即可润色、扩写和接受修订。'),
6992
+ h('button', { key: 'convert', type: 'button', className: P + 'Btn', 'data-convert-word': '1',
6993
+ disabled: p.convertingWord || p.conversionDisabled, onClick: p.onConvertWord }, '转换并编辑'),
6994
+ ]) : null,
6734
6995
  p.officeNotice ? h('div', { key: 'office', className: P + 'SelectionNotice', role: 'alert', 'data-office-notice': '1' }, [
6735
6996
  h('span', { key: 'message' }, p.officeNotice.message),
6736
6997
  p.officeNotice.helpUrl ? h('a', { key: 'help', href: '/doc-assistant/office/help', target: '_blank', rel: 'noopener noreferrer' }, ' 查看安装指引') : null,
@@ -6741,6 +7002,9 @@ window.__ModuleLoader__.load({
6741
7002
  showLayout && p.selectionNotice
6742
7003
  ? h('div', { key: 'selection-notice', className: P + 'SelectionNotice', role: 'status', 'data-layout-selection-notice': '1' }, p.selectionNotice)
6743
7004
  : null,
7005
+ showLayout && format === 'pptx' && pageTextNotice?.page === slide
7006
+ ? h('div', { key: 'page-text-notice', className: P + 'SelectionNotice', role: 'status', 'data-page-text-status': '1' },
7007
+ pageTextNotice.pending ? h(Processing, { text: pageTextNotice.text }) : pageTextNotice.text) : null,
6744
7008
  findBar,
6745
7009
  h('main', {
6746
7010
  key: 'main',
@@ -7017,6 +7281,7 @@ window.__ModuleLoader__.load({
7017
7281
  const [rightOpen, setRightOpen] = React.useState(true);
7018
7282
  const [status, setStatus] = React.useState('');
7019
7283
  const [busyAction, setBusyAction] = React.useState('');
7284
+ const [acceptingRevisionId, setAcceptingRevisionId] = React.useState('');
7020
7285
  const [revisionTick, setRevisionTick] = React.useState(0);
7021
7286
  // 接受写回后幻灯片图的地址要变,否则浏览器会继续显示缓存的旧页。
7022
7287
  const [previewEpoch, setPreviewEpoch] = React.useState(0);
@@ -7029,6 +7294,8 @@ window.__ModuleLoader__.load({
7029
7294
  const readerStateRef = React.useRef(null);
7030
7295
  const reloadDocumentRef = React.useRef(null);
7031
7296
  const [officeBusy, setOfficeBusy] = React.useState(false);
7297
+ const [legacyConverting, setLegacyConverting] = React.useState(false);
7298
+ const legacyConvertRef = React.useRef(null);
7032
7299
  const officeLaunchRef = React.useRef('');
7033
7300
  const [officeNotice, setOfficeNotice] = React.useState(null);
7034
7301
  const [booted, setBooted] = React.useState(false);
@@ -7050,16 +7317,26 @@ window.__ModuleLoader__.load({
7050
7317
  const [readerView, setReaderView] = React.useState('text');
7051
7318
  const [selectionNotice, setSelectionNotice] = React.useState('');
7052
7319
  const [noChangeNotice, setNoChangeNotice] = React.useState('');
7053
- const [generationError, setGenerationError] = React.useState(null);
7054
- const generationRef = React.useRef(null);
7320
+ // Every request owns its immutable selection/model snapshot. Updating a job
7321
+ // replaces the Map so independent completions cannot overwrite each other.
7322
+ const [generationJobs, setGenerationJobs] = React.useState(() => new Map());
7323
+ const generationJobsRef = React.useRef(new Map());
7324
+ const generationSequenceRef = React.useRef(0);
7325
+ const generationAliveRef = React.useRef(true);
7326
+ const writeBusyRef = React.useRef(false);
7327
+ const [revisionErrors, setRevisionErrors] = React.useState(() => new Map());
7055
7328
  const generationTargetRef = React.useRef('');
7056
7329
  generationTargetRef.current = `${projectId}\u0000${docPath}`;
7057
- React.useEffect(() => () => { generationRef.current = null; }, []);
7330
+ const updateGenerationJobs = React.useCallback(update => {
7331
+ const next = update(generationJobsRef.current);
7332
+ generationJobsRef.current = next;
7333
+ if (generationAliveRef.current) setGenerationJobs(next);
7334
+ }, []);
7058
7335
  React.useEffect(() => {
7059
- setGenerationError(null);
7060
- setNoChangeNotice('');
7061
- if (generationRef.current && generationRef.current.target !== generationTargetRef.current) setBusyAction('');
7062
- }, [projectId, docPath]);
7336
+ generationAliveRef.current = true;
7337
+ return () => { generationAliveRef.current = false; };
7338
+ }, []);
7339
+ React.useEffect(() => { setNoChangeNotice(''); }, [projectId, docPath]);
7063
7340
  const [layoutPage, setLayoutPage] = React.useState(0);
7064
7341
  const [layoutFocus, setLayoutFocus] = React.useState(null);
7065
7342
  const [readPct, setReadPct] = React.useState(0);
@@ -7080,6 +7357,8 @@ window.__ModuleLoader__.load({
7080
7357
  const [messages, setMessages] = React.useState([]);
7081
7358
  const [chatBusy, setChatBusy] = React.useState(false);
7082
7359
  const [chatKind, setChatKind] = React.useState('chat');
7360
+ const [chatProgress, setChatProgress] = React.useState('');
7361
+ const chatStartedRef = React.useRef(0);
7083
7362
  const [chatError, setChatError] = React.useState('');
7084
7363
  const [chatModels, setChatModels] = React.useState([]);
7085
7364
  const [chatDefaultModel, setChatDefaultModel] = React.useState(null);
@@ -7122,7 +7401,7 @@ window.__ModuleLoader__.load({
7122
7401
  // Word / PPT / PDF 打开即版式;Excel 打开即表格文本,版式按需再转。
7123
7402
  React.useEffect(() => {
7124
7403
  const fmt = doc && doc.format;
7125
- if (fmt === 'docx' || fmt === 'pptx' || fmt === 'pdf') setReaderView('layout');
7404
+ if (fmt === 'doc' || fmt === 'docx' || fmt === 'pptx' || fmt === 'pdf') setReaderView('layout');
7126
7405
  else setReaderView('text');
7127
7406
  setLayoutPage(0);
7128
7407
  setLayoutFocus(null);
@@ -7255,7 +7534,7 @@ window.__ModuleLoader__.load({
7255
7534
  uploadBusyRef.current = true;
7256
7535
  setUploadOutcome(null);
7257
7536
  setStatus(`正在导入 ${list.length} 个文件…`);
7258
- let done = 0;
7537
+ let done = 0, converted = 0;
7259
7538
  let currentFile = '';
7260
7539
  try {
7261
7540
  for (const file of list) {
@@ -7264,11 +7543,12 @@ window.__ModuleLoader__.load({
7264
7543
  setUploadProgress({ ...progress, stage: '正在读取文件…' });
7265
7544
  const dataBase64 = await shelfModule.readFileBase64(file);
7266
7545
  // The response also waits for best-effort cover preparation.
7267
- setUploadProgress({ ...progress, stage: '正在保存文档并准备封面,请稍候…' });
7268
- await transport.uploadDocs(targetProject, '', [{ name: file.name, dataBase64 }]);
7546
+ setUploadProgress({ ...progress, stage: /\.doc$/i.test(file.name) ? '正在转换为可修订的 Word 文档并准备封面…' : '正在保存文档并准备封面,请稍候…' });
7547
+ const imported = await transport.uploadDocs(targetProject, '', [{ name: file.name, dataBase64 }]);
7548
+ converted += (Array.isArray(imported) ? imported : imported?.entries || []).filter(entry => entry.convertedFrom === 'doc').length;
7269
7549
  done += 1;
7270
7550
  }
7271
- const message = `已导入 ${done} 个文件`;
7551
+ const message = `已导入 ${done} 个文件${converted ? `,其中 ${converted} 个 DOC 已转换为可修订的 DOCX` : ''}`;
7272
7552
  setStatus(message);
7273
7553
  setUploadOutcome({ message, error: false });
7274
7554
  } catch (error) {
@@ -7364,6 +7644,21 @@ window.__ModuleLoader__.load({
7364
7644
  if (draftReference && rightOpen && rightTab === 'chat' && !chatBusy && chatInputRef.current) chatInputRef.current.focus();
7365
7645
  }, [draftReference, rightOpen, rightTab, chatBusy]);
7366
7646
 
7647
+ React.useEffect(() => {
7648
+ if (!chatBusy || !projectId || !docPath || typeof transport.contextStatus !== 'function' || !chatStartedRef.current) return undefined;
7649
+ let alive = true, timer;
7650
+ const started = chatStartedRef.current;
7651
+ const poll = async () => {
7652
+ try {
7653
+ const state = await transport.contextStatus(projectId, docPath);
7654
+ if (alive && Number(state?.at) >= started && ['reading', 'vision', 'ocr', 'ready'].includes(state.stage)) setChatProgress(state.message || '');
7655
+ } catch { /* The original chat request reports any real failure. */ }
7656
+ if (alive) timer = setTimeout(poll, 1000);
7657
+ };
7658
+ poll();
7659
+ return () => { alive = false; clearTimeout(timer); };
7660
+ }, [chatBusy, projectId, docPath, transport]);
7661
+
7367
7662
  // ---- 新消息滚到底
7368
7663
  React.useEffect(() => {
7369
7664
  const el = chatListRef.current;
@@ -7397,6 +7692,8 @@ window.__ModuleLoader__.load({
7397
7692
  setDraft('');
7398
7693
  setDraftReference(null);
7399
7694
  }
7695
+ chatStartedRef.current = Date.now();
7696
+ setChatProgress('');
7400
7697
  setChatBusy(true);
7401
7698
  setChatKind(kind);
7402
7699
  setChatError('');
@@ -7435,6 +7732,7 @@ window.__ModuleLoader__.load({
7435
7732
  if (chatInFlightRef.current && chatInFlightRef.current.target === target) return;
7436
7733
  const request = ++chatRequestRef.current;
7437
7734
  chatInFlightRef.current = { target, request };
7735
+ chatStartedRef.current = 0; setChatProgress('');
7438
7736
  setChatBusy(true);
7439
7737
  setChatError('');
7440
7738
  try {
@@ -7499,57 +7797,66 @@ window.__ModuleLoader__.load({
7499
7797
  const payload = extra?.request ? { ...extra.request } : { projectId, path: docPath, anchor, instruction, action: actionId };
7500
7798
  if (!extra?.request && typeof extra?.revised !== 'string') Object.assign(payload, chosenModel(chatModel));
7501
7799
  if (!extra?.request && doc && doc.format === 'docx') {
7502
- const source = sourceParts(doc).map(part => part.text).join('\n');
7503
- payload.expectedOriginal = source.slice(anchor.srcStart, anchor.srcEnd);
7800
+ payload.expectedOriginal = wordAnchorText(doc, anchor);
7504
7801
  if (doc.meta?.documentHash) payload.expectedDocumentHash = doc.meta.documentHash;
7505
7802
  }
7506
7803
  if (extra && typeof extra.revised === 'string') payload.revised = extra.revised;
7507
7804
  const snapshot = captureRequest(payload);
7508
7805
  const target = `${snapshot.projectId}\u0000${snapshot.path}`;
7509
- if (generationRef.current?.target === target) return;
7510
- const operation = { target, snapshot };
7511
- generationRef.current = operation;
7512
- const isCurrent = () => generationRef.current === operation && generationTargetRef.current === target;
7806
+ const range = snapshot.anchor || {};
7807
+ const ranges = Array.isArray(range.segments) ? range.segments : [range];
7808
+ const key = JSON.stringify([target, snapshot.action || actionId,
7809
+ snapshot.expectedDocumentHash || doc?.meta?.documentHash, snapshot.expectedOriginal ?? wordAnchorText(doc, snapshot.anchor), ranges.map(part => [part.kind,
7810
+ part.srcStart, part.srcEnd, part.blockId, part.sheet, part.cell, part.row, part.col])]);
7811
+ if (Array.from(generationJobsRef.current.values()).some(job => job.key === key && job.state === 'running')) return;
7812
+ const id = extra?.jobId || `generation-${++generationSequenceRef.current}`;
7813
+ const operation = Object.freeze({ id, key, target, snapshot, state: 'running', replaceId: extra?.replaceId,
7814
+ original: snapshot.expectedOriginal ?? wordAnchorText(doc, snapshot.anchor),
7815
+ documentHash: snapshot.expectedDocumentHash || doc?.meta?.documentHash,
7816
+ label: actionId === 'expand' ? '扩写' : actionId === 'manual' ? '修改' : '润色' });
7817
+ updateGenerationJobs(previous => { const next = new Map(previous); next.set(id, operation); return next; });
7818
+ const isCurrent = () => generationAliveRef.current && generationTargetRef.current === target
7819
+ && store.projectId === snapshot.projectId && store.docPath === snapshot.path;
7820
+ const finishJob = changes => updateGenerationJobs(previous => {
7821
+ if (previous.get(id) !== operation) return previous;
7822
+ const next = new Map(previous);
7823
+ if (changes) next.set(id, Object.freeze({ ...operation, ...changes }));
7824
+ else next.delete(id);
7825
+ return next;
7826
+ });
7513
7827
  let replaced = false;
7514
- setBusyAction(actionId || 'generate');
7515
- setStatus(extra?.replaceId ? '正在重新生成…' : '正在生成修订…');
7516
- setSelectionNotice('');
7517
- setNoChangeNotice('');
7518
- setGenerationError(null);
7519
- // 生成后让用户直接看到结果:展开右栏并切到「修订」标签。
7520
- setRightOpen(true);
7521
- setRightTab('revise');
7828
+ if (isCurrent()) {
7829
+ setStatus(extra?.replaceId ? '正在重新生成…' : '正在生成修订…');
7830
+ setSelectionNotice(''); setNoChangeNotice('');
7831
+ setRightOpen(true); setRightTab('revise');
7832
+ }
7522
7833
  try {
7523
7834
  if (extra?.replaceId) {
7524
7835
  await store.delete(extra.replaceId, { optimistic: true });
7525
7836
  replaced = true;
7526
7837
  }
7527
7838
  const revision = await store.generate(snapshot);
7528
- if (!isCurrent()) return revision;
7529
7839
  if (revision?.unchanged) {
7530
- const message = `${actionId === 'expand' ? '扩写' : '润色'}结果与原文一致,内容无变化。`;
7531
- if (!extra?.batch) {
7532
- setNoChangeNotice(message);
7533
- setStatus(message);
7534
- }
7840
+ const message = `${operation.label}结果与原文一致,内容无变化。`;
7841
+ finishJob({ state: 'unchanged', message, replaceId: undefined });
7842
+ if (isCurrent() && !extra?.batch) setStatus(message);
7535
7843
  return revision;
7536
7844
  }
7537
- const note = extra && extra.note ? `${extra.note} ` : '';
7538
- setStatus(`${note}${revision && revision.id ? `已生成修订 ${revision.id}` : '已生成修订'}`);
7845
+ finishJob(null);
7846
+ if (isCurrent()) {
7847
+ const note = extra && extra.note ? `${extra.note} ` : '';
7848
+ setStatus(`${note}${revision && revision.id ? `已生成修订 ${revision.id}` : '已生成修订'}`);
7849
+ }
7539
7850
  return revision;
7540
7851
  } catch (error) {
7541
- if (!isCurrent()) return;
7542
- const message = `${extra?.replaceId ? '重新生成' : actionId === 'expand' ? '扩写' : '润色'}失败:${normalizeError(error)}`;
7543
- setStatus(message);
7544
- setGenerationError({ message, request: snapshot, replaceId: !replaced ? extra?.replaceId : undefined });
7545
- } finally {
7546
- if (isCurrent()) setBusyAction('');
7547
- if (generationRef.current === operation) generationRef.current = null;
7852
+ const message = `${extra?.replaceId ? '重新生成' : operation.label}失败:${normalizeError(error)}`;
7853
+ finishJob({ state: 'failed', message, replaceId: !replaced ? extra?.replaceId : undefined });
7854
+ if (isCurrent()) setStatus(message);
7548
7855
  }
7549
- }, [store, projectId, docPath, doc, chatModel]);
7856
+ }, [store, projectId, docPath, doc, chatModel, updateGenerationJobs]);
7550
7857
 
7551
7858
  const refreshDoc = React.useCallback(() => {
7552
- if (docPath) openDoc(docPath, { keepStatus: true });
7859
+ if (docPath) return openDoc(docPath, { keepStatus: true });
7553
7860
  }, [docPath, openDoc]);
7554
7861
 
7555
7862
  const reloadDocument = async (automatic = false) => {
@@ -7598,83 +7905,86 @@ window.__ModuleLoader__.load({
7598
7905
  };
7599
7906
 
7600
7907
  const acceptRevision = React.useCallback(async (revision) => {
7601
- if (!revision) return;
7602
- setBusyAction(`rev:${revision.id}`);
7908
+ if (!revision || writeBusyRef.current) return;
7909
+ const target = `${store.projectId}\u0000${store.docPath}`;
7910
+ const isCurrent = () => generationTargetRef.current === target && `${store.projectId}\u0000${store.docPath}` === target;
7911
+ writeBusyRef.current = true; setBusyAction(`rev:${revision.id}`); setAcceptingRevisionId(revision.id);
7912
+ setStatus(/\.docx$/i.test(revision.docPath || '') ? '正在核对版式并写回…' : '正在写回修订…');
7913
+ setRevisionErrors(previous => { const next = new Map(previous); next.delete(revision.id); return next; });
7603
7914
  try {
7604
- const out = await store.accept(revision.id);
7915
+ const out = await store.accept(revision.id, revision.layoutWarning?.token
7916
+ ? { layoutRiskToken: revision.layoutWarning.token } : undefined);
7917
+ if (!isCurrent()) return;
7605
7918
  const written = out && out.written;
7606
- setStatus(written && written.backup
7607
- ? `已接受并写回(${written.bytes || 0} 字节,备份:${written.backup})`
7608
- : '已接受修订');
7609
- setPreviewEpoch((value) => value + 1);
7610
- refreshDoc();
7919
+ setStatus(written?.layoutRiskAccepted
7920
+ ? '已接受并写入修订,请点击文档标题右侧的 Office 图标,手动打开文档调整排版。'
7921
+ : written && written.backup ? `已接受并写回(${written.bytes || 0} 字节,备份:${written.backup})` : '已接受修订');
7922
+ setPreviewEpoch(value => value + 1);
7923
+ await Promise.all([refreshDoc(), store.load()]);
7611
7924
  } catch (error) {
7612
- setStatus(`接受失败:${normalizeError(error)}`);
7925
+ if (!isCurrent()) return;
7926
+ const message = normalizeError(error);
7927
+ setStatus(`${error?.body?.revision?.layoutWarning ? '排版提示' : '接受失败'}:${message}`);
7928
+ setRevisionErrors(previous => { const next = new Map(previous); next.set(revision.id, message); return next; });
7613
7929
  } finally {
7614
- setBusyAction('');
7930
+ writeBusyRef.current = false; setBusyAction(''); setAcceptingRevisionId('');
7615
7931
  }
7616
7932
  }, [store, refreshDoc]);
7617
7933
 
7618
7934
  const rejectRevision = React.useCallback(async (revision) => {
7619
7935
  if (!revision) return;
7620
- setBusyAction(`rev:${revision.id}`);
7936
+ const target = `${store.projectId}\u0000${store.docPath}`;
7621
7937
  try {
7622
7938
  await store.reject(revision.id);
7623
- setStatus('已撤回修订');
7939
+ if (generationTargetRef.current === target) setStatus('已撤回修订');
7624
7940
  } catch (error) {
7625
- setStatus(`撤回失败:${normalizeError(error)}`);
7626
- } finally {
7627
- setBusyAction('');
7941
+ if (generationTargetRef.current === target) setStatus(`撤回失败:${normalizeError(error)}`);
7628
7942
  }
7629
7943
  }, [store]);
7630
7944
 
7631
7945
  const deleteRevision = React.useCallback(async (revision) => {
7632
7946
  if (!revision) return;
7633
- setBusyAction(`rev:${revision.id}`);
7947
+ const target = `${store.projectId}\u0000${store.docPath}`;
7634
7948
  try {
7635
- await store.delete(revision.id);
7636
- setStatus(revision.status === 'accepted' ? '已删除修订卡片,文档中已写入的修改保留。' : '已删除修订卡片');
7949
+ await store.delete(revision.id, { optimistic: true });
7950
+ if (generationTargetRef.current === target) setStatus(revision.status === 'accepted'
7951
+ ? '已删除修订卡片,文档中已写入的修改保留。' : '已删除修订卡片');
7637
7952
  } catch (error) {
7638
- setStatus(`删除修订失败:${normalizeError(error)}`);
7639
- } finally { setBusyAction(''); }
7953
+ if (generationTargetRef.current === target) setStatus(`删除修订失败:${normalizeError(error)}`);
7954
+ }
7640
7955
  }, [store]);
7641
7956
 
7642
7957
  const restoreRevision = React.useCallback(async (revision) => {
7643
- if (!revision) return;
7644
- setBusyAction(`rev:${revision.id}`);
7958
+ if (!revision || writeBusyRef.current) return;
7959
+ const target = `${store.projectId}\u0000${store.docPath}`;
7960
+ writeBusyRef.current = true; setBusyAction(`rev:${revision.id}`);
7645
7961
  try {
7646
7962
  await store.restore(revision.id);
7963
+ if (generationTargetRef.current !== target) return;
7647
7964
  setStatus(revision.status === 'accepted' ? '已撤销写入并还原文件' : '已恢复为待处理');
7648
- setPreviewEpoch((value) => value + 1);
7649
- refreshDoc();
7650
- await store.load();
7965
+ setPreviewEpoch(value => value + 1);
7966
+ await Promise.all([refreshDoc(), store.load()]);
7651
7967
  } catch (error) {
7652
- setStatus(`恢复失败:${normalizeError(error)}`);
7653
- } finally {
7654
- setBusyAction('');
7655
- }
7968
+ if (generationTargetRef.current === target) setStatus(`恢复失败:${normalizeError(error)}`);
7969
+ } finally { writeBusyRef.current = false; setBusyAction(''); }
7656
7970
  }, [store, refreshDoc]);
7657
7971
 
7658
7972
  const restoreWriteGroup = React.useCallback(async (group) => {
7659
- const list = Array.isArray(group) ? group.filter((item) => item && item.id) : [];
7660
- if (list.length === 0) return;
7661
- setBusyAction(`rev:${list[0].id}`);
7973
+ const list = Array.isArray(group) ? group.filter(item => item && item.id) : [];
7974
+ if (!list.length || writeBusyRef.current) return;
7975
+ const target = `${store.projectId}\u0000${store.docPath}`;
7976
+ writeBusyRef.current = true; setBusyAction(`rev:${list[0].id}`);
7662
7977
  try {
7663
- const results = await store.bulk(list.map((item) => item.id), 'restore');
7664
- const failed = (results || []).filter((item) => item && item.ok === false);
7665
- if (failed.length > 0) {
7666
- setStatus(`整组撤回失败:${failed[0].error || ''}`);
7667
- return;
7668
- }
7978
+ const results = await store.bulk(list.map(item => item.id), 'restore');
7979
+ if (generationTargetRef.current !== target) return;
7980
+ const failed = (results || []).filter(item => item && item.ok === false);
7981
+ if (failed.length) { setStatus(`整组撤回失败:${failed[0].error || ''}`); return; }
7669
7982
  setStatus(`已撤回本次批量写入(${list.length} 条)`);
7670
- setPreviewEpoch((value) => value + 1);
7671
- refreshDoc();
7672
- await store.load();
7983
+ setPreviewEpoch(value => value + 1);
7984
+ await Promise.all([refreshDoc(), store.load()]);
7673
7985
  } catch (error) {
7674
- setStatus(`整组撤回失败:${normalizeError(error)}`);
7675
- } finally {
7676
- setBusyAction('');
7677
- }
7986
+ if (generationTargetRef.current === target) setStatus(`整组撤回失败:${normalizeError(error)}`);
7987
+ } finally { writeBusyRef.current = false; setBusyAction(''); }
7678
7988
  }, [store, refreshDoc]);
7679
7989
 
7680
7990
  const regenerate = React.useCallback(async (revision) => {
@@ -7693,15 +8003,21 @@ window.__ModuleLoader__.load({
7693
8003
  }, [generate, projectId, docPath, chatModel]);
7694
8004
 
7695
8005
  const acceptAll = React.useCallback(async () => {
8006
+ if (writeBusyRef.current) return;
7696
8007
  if (groupRevisionVariants(store.all()).some(group => group.items.filter(item => item.status === 'pending').length > 1)) {
7697
8008
  setStatus('同一选区有多份扩写,请选择其中一份接受。');
7698
8009
  return;
7699
8010
  }
8011
+ const target = `${store.projectId}\u0000${store.docPath}`;
8012
+ writeBusyRef.current = true;
7700
8013
  setBusyAction('accept-all');
7701
8014
  setStatus('正在批量接受…');
7702
8015
  try {
7703
8016
  const results = await store.acceptAll();
8017
+ if (generationTargetRef.current !== target) return;
7704
8018
  const failed = (results || []).filter((item) => item && item.ok === false);
8019
+ if (failed.length) setRevisionErrors(previous => { const next = new Map(previous);
8020
+ failed.forEach(item => next.set(item.revisionId, item.error || '接受失败')); return next; });
7705
8021
  const wrote = (results || []).some((item) => item && item.ok !== false);
7706
8022
  setStatus(failed.length > 0
7707
8023
  ? `接受完成,${failed.length} 条失败:${failed[0].error || ''}`
@@ -7709,9 +8025,9 @@ window.__ModuleLoader__.load({
7709
8025
  if (wrote) setPreviewEpoch((value) => value + 1);
7710
8026
  refreshDoc();
7711
8027
  } catch (error) {
7712
- setStatus(`批量接受失败:${normalizeError(error)}`);
8028
+ if (generationTargetRef.current === target) setStatus(`批量接受失败:${normalizeError(error)}`);
7713
8029
  } finally {
7714
- setBusyAction('');
8030
+ writeBusyRef.current = false; setBusyAction('');
7715
8031
  }
7716
8032
  }, [store, refreshDoc]);
7717
8033
 
@@ -7779,18 +8095,48 @@ window.__ModuleLoader__.load({
7779
8095
  if (!instruction) return;
7780
8096
  // 只读格式即使工具条漏了按钮,也不再打出「改写失败」。
7781
8097
  if (!revisionAllowed(doc)) return;
8098
+ const isWordEdit = doc?.format === 'docx' && (actionId === 'polish' || actionId === 'expand');
8099
+ if (isWordEdit && wordListMarker(hit.text)) {
8100
+ setNoChangeNotice('选区只有列表标记,没有需要修改的正文。');
8101
+ setStatus('已保留原列表标记,内容无变化。');
8102
+ return;
8103
+ }
7782
8104
  const anchor = hit.anchor || {};
7783
8105
  const hasRange = Number.isInteger(anchor.srcStart) && Number.isInteger(anchor.srcEnd);
7784
8106
  const parts = Array.isArray(hit.parts) ? hit.parts : [];
7785
8107
  const shapeNote = hit.matched === 'shape' ? '识别和原文不一致,已按整个文本框改写。' : '';
8108
+ if (isWordEdit && (parts.length > 1 || Array.isArray(anchor.segments))) {
8109
+ const allParts = parts.length ? parts : (anchor.segments || []).map(segment => ({ anchor: segment,
8110
+ text: wordAnchorText(doc, segment) }));
8111
+ if (!wordCompoundAnchor(doc, allParts)) {
8112
+ cannotLocate(); return;
8113
+ }
8114
+ let firstBody = 0;
8115
+ while (firstBody < allParts.length && wordListMarker(allParts[firstBody].text)) firstBody += 1;
8116
+ const body = allParts.slice(firstBody), combined = wordCompoundAnchor(doc, body);
8117
+ if (!combined || body.some(part => wordListMarker(part.text)) || !continuousWordAnchor(doc, combined)) {
8118
+ const message = '选区中间有未选中的文字或列表标记,请连续选择一段正文后再修改。';
8119
+ setSelectionNotice(message); setStatus(message); return;
8120
+ }
8121
+ // One model request sees the complete selected paragraph. Per-line
8122
+ // rewriting could skip an unchanged first line and fragment the result.
8123
+ generate(combined, instruction, actionId);
8124
+ return;
8125
+ }
7786
8126
  if (!hasRange && anchor.kind !== 'cell' && parts.length > 1) {
7787
- const ready = parts.filter((part) => (
8127
+ const located = parts.filter((part) => (
7788
8128
  part && part.anchor && Number.isInteger(part.anchor.srcStart) && Number.isInteger(part.anchor.srcEnd)
7789
8129
  ));
7790
- if (ready.length === 0 || (doc && doc.format === 'docx' && ready.length < parts.length)) {
8130
+ if (located.length === 0 || (doc && doc.format === 'docx' && located.length < parts.length)) {
7791
8131
  cannotLocate();
7792
8132
  return;
7793
8133
  }
8134
+ const ready = located;
8135
+ if (!ready.length) {
8136
+ setNoChangeNotice('选区只有列表标记,没有需要修改的正文。');
8137
+ setStatus('已保留原列表标记,内容无变化。');
8138
+ return;
8139
+ }
7794
8140
  (async () => {
7795
8141
  let unchanged = 0, changed = 0, failed = 0;
7796
8142
  for (let i = 0; i < ready.length; i += 1) {
@@ -7804,7 +8150,7 @@ window.__ModuleLoader__.load({
7804
8150
  if (unchanged) setNoChangeNotice(unchanged === ready.length
7805
8151
  ? '所选内容与原文一致,内容无变化。' : `${unchanged} 段内容无变化,已跳过。`);
7806
8152
  setStatus([changed ? `已生成 ${changed} 段修订` : '', unchanged ? `${unchanged} 段内容无变化` : '',
7807
- failed ? `${failed} 段处理失败` : '', ready.length < parts.length ? `${parts.length - ready.length} 段对不上原文` : ''].filter(Boolean).join(';'));
8153
+ failed ? `${failed} 段处理失败` : '', located.length < parts.length ? `${parts.length - located.length} 段对不上原文` : ''].filter(Boolean).join(';'));
7808
8154
  })();
7809
8155
  return;
7810
8156
  }
@@ -7940,6 +8286,28 @@ window.__ModuleLoader__.load({
7940
8286
  : treeNodes('', 0, treeCtx)),
7941
8287
  ]);
7942
8288
 
8289
+ const convertLegacyWord = async () => {
8290
+ if (legacyConvertRef.current || docBusy || busyAction || !projectId || !docPath) return;
8291
+ const target = `${projectId}\u0000${docPath}`, operation = { target };
8292
+ legacyConvertRef.current = operation; setLegacyConverting(true); setOfficeNotice(null);
8293
+ setStatus('正在转换为可修订的 Word 文档…');
8294
+ try {
8295
+ const entry = await transport.convertWord(projectId, docPath);
8296
+ if (!entry?.relPath) throw new Error('转换未返回可编辑的 Word 文件');
8297
+ setShelfReloadKey(value => value + 1);
8298
+ if (chatTargetRef.current === target) {
8299
+ await openDoc(entry.relPath, { projectId });
8300
+ setStatus(`已打开可修订副本 ${entry.name || entry.relPath}`);
8301
+ }
8302
+ } catch (error) {
8303
+ if (chatTargetRef.current === target) {
8304
+ setOfficeNotice({ message: normalizeError(error) }); setStatus(`转换失败:${normalizeError(error)}`);
8305
+ }
8306
+ } finally {
8307
+ if (legacyConvertRef.current === operation) { legacyConvertRef.current = null; setLegacyConverting(false); }
8308
+ }
8309
+ };
8310
+
7943
8311
  // ---- 渲染:中栏正文
7944
8312
  const center = h(DocReader, {
7945
8313
  key: 'center',
@@ -7947,6 +8315,7 @@ window.__ModuleLoader__.load({
7947
8315
  docBusy,
7948
8316
  docError,
7949
8317
  pendingByBlock,
8318
+ visionRoute: chosenModel(chatModel),
7950
8319
  busyAction: busyAction || (chatBusy && chatKind === 'translate' ? 'translate' : ''),
7951
8320
  actions: SELECTION_ACTIONS,
7952
8321
  projectId,
@@ -7966,6 +8335,9 @@ window.__ModuleLoader__.load({
7966
8335
  onAction: runAction,
7967
8336
  previewEpoch,
7968
8337
  officeNotice,
8338
+ onConvertWord: convertLegacyWord,
8339
+ convertingWord: legacyConverting,
8340
+ conversionDisabled: docBusy || Boolean(busyAction),
7969
8341
  onRefresh: reloadDocument,
7970
8342
  refreshDisabled: docBusy || Boolean(busyAction),
7971
8343
  onAccept: acceptRevision,
@@ -8045,7 +8417,7 @@ window.__ModuleLoader__.load({
8045
8417
  messageFooter(message),
8046
8418
  ])),
8047
8419
  chatBusy ? h('div', { key: 'thinking', className: P + 'Thinking', 'data-chat-thinking': '1' },
8048
- h(Processing, { kind: chatKind, text: chatKind === 'translate' ? '正在翻译…' : '思考中…' })) : null,
8420
+ h(Processing, { kind: chatKind, text: chatProgress || (chatKind === 'translate' ? '正在翻译…' : '思考中…') })) : null,
8049
8421
  ),
8050
8422
  chatError ? h('div', { key: 'chatErr', className: P + 'Err', 'data-chat-error': '1' }, chatError) : null,
8051
8423
  h('div', { key: 'composer', className: P + 'Composer' }, [
@@ -8107,7 +8479,7 @@ window.__ModuleLoader__.load({
8107
8479
  h('option', { key: 'default', value: '' }, chatModelsBusy ? '加载模型…' : `跟随 DSH${chatDefaultModel ? ` · ${chatDefaultModel.model}` : ''}`),
8108
8480
  chatModel && !chatModels.some(item => JSON.stringify([item.provider, item.model]) === chatModel)
8109
8481
  ? h('option', { key: 'unavailable', value: chatModel }, '所选模型暂不可用') : null,
8110
- ...chatModels.map(item => h('option', { key: JSON.stringify([item.provider, item.model]), value: JSON.stringify([item.provider, item.model]) }, `${item.label} · ${item.providerLabel}`)),
8482
+ ...chatModels.map(item => h('option', { key: JSON.stringify([item.provider, item.model]), value: JSON.stringify([item.provider, item.model]) }, `${item.label} · ${item.providerLabel}${item.inputModalities?.includes('image') ? ' · 图片' : ''}`)),
8111
8483
  ]),
8112
8484
  ]),
8113
8485
  h('div', { key: 'hint', className: P + 'Hint' },
@@ -8123,7 +8495,11 @@ window.__ModuleLoader__.load({
8123
8495
  collapsed: collapsedRevisions.includes(item.id),
8124
8496
  onToggleCollapsed: id => setCollapsedRevisions(previous => previous.includes(id)
8125
8497
  ? previous.filter(value => value !== id) : previous.concat(id).slice(-500)),
8126
- busy: busyAction === 'polish' || busyAction === 'expand' || busyAction === `rev:${item.id}`,
8498
+ busy: store.isBusy(`rev:${item.id}`),
8499
+ writeBusy: Boolean(busyAction),
8500
+ acceptError: revisionErrors.get(item.id),
8501
+ progress: acceptingRevisionId === item.id ? h(Processing, { kind: 'accept',
8502
+ text: /\.docx$/i.test(item.docPath || '') ? '正在核对版式并写回…' : '正在写回修订…' }) : null,
8127
8503
  writeGroup: (revision) => store.writeGroup(revision),
8128
8504
  onAccept: acceptRevision,
8129
8505
  onReject: rejectRevision,
@@ -8141,16 +8517,26 @@ window.__ModuleLoader__.load({
8141
8517
  store.loading ? h('div', { className: P + 'Hint' }, '加载修订中…') : null,
8142
8518
  store.error ? h('div', { className: P + 'Err' }, store.error) : null,
8143
8519
  noChangeNotice ? h('div', { className: P + 'NoChange', role: 'status', 'data-revision-unchanged': '1' }, noChangeNotice) : null,
8144
- generationError ? h('div', { className: P + 'GenerationError', role: 'alert', 'data-generation-error': '1' }, [
8145
- h('div', { key: 'message', className: P + 'Err' }, generationError.message),
8146
- h('button', { key: 'retry', type: 'button', className: P + 'Btn',
8147
- onClick: () => generate(generationError.request.anchor, generationError.request.instruction, generationError.request.action, {
8148
- request: generationError.request, replaceId: generationError.replaceId,
8149
- }) }, '重试'),
8150
- ]) : null,
8151
- busyAction === 'polish' || busyAction === 'expand'
8152
- ? h(Processing, { kind: busyAction, text: busyAction === 'expand' ? '正在扩写…' : '正在润色…' }) : null,
8153
- items.length === 0 && !store.loading && !noChangeNotice && !generationError && busyAction !== 'polish' && busyAction !== 'expand'
8520
+ Array.from(generationJobs.values()).filter(job => job.target === generationTargetRef.current).reverse().map(job =>
8521
+ h('div', { key: job.id, className: P + 'RevCard', 'data-generation-id': job.id,
8522
+ 'data-generation-state': job.state, 'data-generation-action': job.snapshot.action,
8523
+ 'data-generation-error': job.state === 'failed' ? '1' : undefined,
8524
+ role: job.state === 'failed' ? 'alert' : 'status' }, [
8525
+ h('div', { key: 'label', className: P + 'RevCollapse' }, `${job.label} · ${anchorLabel(job.snapshot.anchor)}`),
8526
+ typeof job.original === 'string' ? h('div', { key: 'original', className: P + 'GenerationOriginal',
8527
+ title: job.original }, job.original) : null,
8528
+ job.state === 'running' ? h(Processing, { key: 'progress', kind: job.snapshot.action,
8529
+ text: `正在${job.label}…` }) : null,
8530
+ job.message ? h('div', { key: 'message', className: job.state === 'failed' ? P + 'Err' : P + 'NoChange',
8531
+ 'data-revision-unchanged': job.state === 'unchanged' ? '1' : undefined }, job.message) : null,
8532
+ job.state === 'failed' ? h('button', { key: 'retry', type: 'button', className: P + 'Btn',
8533
+ onClick: () => generate(job.snapshot.anchor, job.snapshot.instruction, job.snapshot.action,
8534
+ { request: job.snapshot, replaceId: job.replaceId, jobId: job.id }) }, '重试') : null,
8535
+ job.state !== 'running' ? h('button', { key: 'dismiss', type: 'button', className: P + 'Btn',
8536
+ onClick: () => updateGenerationJobs(previous => { const next = new Map(previous); next.delete(job.id); return next; }) }, '删除') : null,
8537
+ ])),
8538
+ items.length === 0 && !store.loading && !noChangeNotice
8539
+ && !Array.from(generationJobs.values()).some(job => job.target === generationTargetRef.current)
8154
8540
  ? h('div', { className: P + 'Hint' }, revisionAllowed(doc)
8155
8541
  ? '选中正文后点「润色 / 扩写」生成修订;接受才会写回文件。'
8156
8542
  : '这份文档只能阅读,不能生成写回修订。')
@@ -8482,6 +8868,8 @@ window.__ModuleLoader__.load({
8482
8868
  .${P}NoChange{padding:12px;border:1px solid var(--dsw-alias-border-l1);border-radius:var(--dsw-radius-md,8px);
8483
8869
  background:var(--dsw-alias-bg-base);color:var(--dsw-alias-label-primary);font-size:13px;line-height:20px;margin-bottom:10px}
8484
8870
  .${P}GenerationError{padding:8px 10px;margin-bottom:10px}
8871
+ .${P}GenerationOriginal{max-height:90px;overflow:auto;white-space:pre-wrap;word-break:break-word;font-size:12px;line-height:1.6;
8872
+ color:var(--dsw-alias-label-secondary);padding:6px 10px}
8485
8873
  .${P}Processing{display:flex;align-items:center;gap:8px;padding:8px 10px;color:var(--dsw-alias-label-secondary);font-size:12px;flex:none}
8486
8874
  .${P}WhaleTail{width:20px;height:20px;contain:strict;flex:none;display:inline-flex;position:relative;overflow:hidden}
8487
8875
  .${P}WhaleAnimated{display:none;position:absolute;inset:0}
@@ -8691,8 +9079,18 @@ window.__ModuleLoader__.load({
8691
9079
  .${P}SlidePrev{left:12px}
8692
9080
  .${P}SlideNext{right:12px}
8693
9081
  .${P}SlideCount{position:absolute;left:50%;bottom:14px;transform:translateX(-50%);z-index:2;
8694
- color:#fff;background:rgba(0,0,0,.5);border-radius:999px;padding:2px 12px;font-size:12px;
9082
+ color:#fff;background:rgba(0,0,0,.65);border-radius:12px;padding:5px 8px;font-size:12px;max-width:calc(100% - 24px);
8695
9083
  font-variant-numeric:tabular-nums}
9084
+ .${P}SlideCountControls{display:flex;align-items:center;justify-content:center;gap:6px;white-space:nowrap}
9085
+ .${P}SlidePageInput{box-sizing:border-box;width:54px;min-width:0;padding:3px 4px;border:1px solid rgba(255,255,255,.4);
9086
+ border-radius:5px;background:rgba(255,255,255,.12);color:#fff;font:inherit;line-height:18px;text-align:center}
9087
+ .${P}SlidePageInput:focus-visible{outline:2px solid #fff;outline-offset:1px}
9088
+ .${P}SlidePageInput[aria-invalid="true"]{border-color:#ffaeae}
9089
+ .${P}SlideJumpButton{padding:3px 7px;border:0;border-radius:5px;background:rgba(255,255,255,.18);color:#fff;
9090
+ font:inherit;line-height:20px;cursor:pointer}
9091
+ .${P}SlideJumpButton:hover:not(:disabled){background:rgba(255,255,255,.3)}
9092
+ .${P}SlidePageInput:disabled,.${P}SlideJumpButton:disabled{opacity:.5;cursor:default}
9093
+ .${P}SlideJumpError{margin-top:4px;max-width:240px;white-space:normal;line-height:18px;color:#ffcdcd;text-align:center}
8696
9094
  .${P}ViewBar .${P}Hint{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;padding:0}
8697
9095
  .${P}SelectionNotice{flex:none;padding:10px 12px;font-size:13px;line-height:1.6;
8698
9096
  color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-layer-2);border-bottom:1px solid var(--dsw-alias-border-l1)}
@@ -8762,6 +9160,12 @@ window.__ModuleLoader__.load({
8762
9160
  exports.bindWordLayoutLines = bindWordLayoutLines;
8763
9161
  exports.wordNativeSelection = wordNativeSelection;
8764
9162
  exports.wordBoxSelection = wordBoxSelection;
9163
+ exports.wordCompoundAnchor = wordCompoundAnchor;
9164
+ exports.wordAnchorText = wordAnchorText;
9165
+ exports.continuousWordAnchor = continuousWordAnchor;
9166
+ exports.wordListMarker = wordListMarker;
9167
+ exports.captureRequest = captureRequest;
9168
+ exports.groupRevisionVariants = groupRevisionVariants;
8765
9169
  exports.rectangleSpans = rectangleSpans;
8766
9170
  exports.placeByShapes = placeByShapes;
8767
9171
  exports.mapOffsetToSrc = mapOffsetToSrc;
@@ -9155,7 +9559,13 @@ window.__ModuleLoader__.load({
9155
9559
  const p = props || {};
9156
9560
  const cover = normalizeCover(p.cover, p.relPath, p.format);
9157
9561
  const [failed, setFailed] = React.useState(false);
9562
+ const [loaded, setLoaded] = React.useState(false);
9158
9563
  const [naturalShape, setNaturalShape] = React.useState(null);
9564
+ React.useEffect(() => {
9565
+ setFailed(false);
9566
+ setLoaded(false);
9567
+ setNaturalShape(null);
9568
+ }, [cover.imageUrl, cover.relPath]);
9159
9569
  // 形状优先级:父级/显式给的 > 图片实际尺寸测出来的 > 按 cover.width/height 推断 > 竖版。
9160
9570
  // 即使图片失败回落生成封面也保持同一卡片形状,避免书架在 <img> 出错时跳版(§17.3.1)。
9161
9571
  const controlled = p.shape && p.shape.key ? p.shape : null;
@@ -9173,6 +9583,7 @@ window.__ModuleLoader__.load({
9173
9583
  setFailed(true);
9174
9584
  return;
9175
9585
  }
9586
+ setLoaded(true);
9176
9587
  const next = shapeFor({ width: w, height: hgt });
9177
9588
  if (next.key === shape.key) return;
9178
9589
  if (controlled) {
@@ -9190,10 +9601,13 @@ window.__ModuleLoader__.load({
9190
9601
  'data-cover-kind': 'page',
9191
9602
  'data-cover-shape': shape.key,
9192
9603
  'data-cover-src': cover.imageUrl,
9604
+ 'aria-busy': !loaded,
9193
9605
  }, [
9606
+ !loaded ? h(CoverArt, { key: 'placeholder', cover, shape, size: width,
9607
+ className: P + 'CoverPlaceholder', relPath: cover.relPath, format: cover.format }) : null,
9194
9608
  h('img', {
9195
9609
  key: 'img',
9196
- className: P + 'CoverImg',
9610
+ className: cx(P + 'CoverImg', !loaded && P + 'CoverImgLoading'),
9197
9611
  src: cover.imageUrl,
9198
9612
  alt: cover.title,
9199
9613
  loading: 'lazy',
@@ -9856,8 +10270,10 @@ window.__ModuleLoader__.load({
9856
10270
  .${P}Book_landscape .${P}Cover{box-shadow:0 6px 18px rgba(0,0,0,.28)}
9857
10271
  .${P}Book_square .${P}Cover{border-radius:8px}
9858
10272
  .${P}CoverPage{background:var(--dsw-alias-bg-layer-2)}
9859
- .${P}CoverImg{display:block;width:100%;height:100%;object-fit:cover;object-position:top center;
10273
+ .${P}CoverPlaceholder{position:absolute;inset:0;box-shadow:none}
10274
+ .${P}CoverImg{position:absolute;inset:0;display:block;width:100%;height:100%;object-fit:cover;object-position:top center;
9860
10275
  background:var(--dsw-alias-bg-layer-2)}
10276
+ .${P}CoverImgLoading{opacity:0}
9861
10277
  .${P}PageFade{position:absolute;left:0;right:0;bottom:0;height:26%;pointer-events:none;
9862
10278
  background:linear-gradient(to top,rgba(0,0,0,.28),rgba(0,0,0,0))}
9863
10279
  .${P}PageBadge{position:absolute;left:8px;bottom:8px;background:rgba(0,0,0,.42);color:#fff;