dsh-cuigengji-plugin 0.4.3 → 0.4.4
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/LICENSE +21 -21
- package/README.md +1 -1
- package/cordis.patch.yml +3 -3
- package/docs/CHANGELOG.md +7 -0
- package/docs/agent-installation.md +19 -12
- package/docs/architecture.md +74 -74
- package/docs/desktop-migration-20260930.md +26 -26
- package/docs/development.md +7 -3
- package/docs/getting-started.md +17 -7
- package/docs/layout-progress.md +31 -31
- package/docs/market-listing.md +5 -3
- package/docs/npm-publishing.md +28 -12
- package/docs/package-name-migration.md +3 -3
- package/docs/planning-board.md +44 -44
- package/docs/presets.md +24 -24
- package/docs/release-process.md +15 -11
- package/docs/tavern-compatibility-audit-20261001.md +109 -109
- package/docs/test-report.md +139 -129
- package/docs/upgrade.md +3 -1
- package/docs/usage-review.md +13 -13
- package/lib/client.js +18 -14
- package/package.json +18 -14
- package/skills/cuigengji-maintenance/SKILL.md +2 -2
- package/src/adapters/dsh/prompt.js +36 -36
- package/src/adapters/dsh/rpc.js +96 -96
- package/src/adapters/dsh/skills.js +17 -17
- package/src/adapters/mcp/actions.js +6 -6
- package/src/adapters/mcp/client.js +172 -172
- package/src/adapters/mcp/server.js +40 -40
- package/src/application/bindings/session.js +72 -72
- package/src/application/queries/context.js +15 -15
- package/src/application/store.js +412 -412
- package/src/client/features/chapters/clipboard.ts +27 -27
- package/src/client/features/chapters/history.ts +11 -11
- package/src/client/features/chapters/index.jsx +118 -118
- package/src/client/features/chapters/sync.ts +155 -155
- package/src/client/features/chapters/useChapterSync.ts +39 -39
- package/src/client/features/context/index.jsx +22 -22
- package/src/client/features/memory/index.jsx +73 -73
- package/src/client/features/planning/Decorations.tsx +1 -1
- package/src/client/features/planning/Pages.tsx +11 -11
- package/src/client/features/planning/data.js +16 -16
- package/src/client/index.tsx +130 -130
- package/src/client/launcher.tsx +78 -78
- package/src/client/shared/creation.tsx +34 -34
- package/src/client/shared/detail-page.tsx +61 -61
- package/src/client/shared/drafts.js +17 -17
- package/src/client/shared/evidence.jsx +11 -11
- package/src/client/shared/groups.tsx +34 -34
- package/src/client/shared/preferences.tsx +104 -104
- package/src/client/shared/select-style.ts +25 -25
- package/src/client/shared/state.js +95 -95
- package/src/client/shared/ui.jsx +19 -19
- package/src/client/shared/workspace.tsx +11 -11
- package/src/client/style.ts +274 -274
- package/src/client/visual.ts +190 -190
- package/src/contracts/actions.js +19 -19
- package/src/contracts/planning.ts +52 -52
- package/src/domain/manuscript.ts +4 -4
- package/src/domain/planning/index.ts +113 -113
- package/src/domain/planning/model.ts +59 -59
- package/src/domain/preset/index.js +89 -89
- package/src/index.js +91 -91
- package/src/infrastructure/migrations/legacy.js +96 -96
- package/src/infrastructure/migrations/tavern.js +78 -78
- package/src/infrastructure/persistence/atomic-store.ts +80 -80
- package/src/infrastructure/persistence/project-layout.ts +1 -1
|
@@ -1,27 +1,27 @@
|
|
|
1
|
-
import type { ClipboardEvent } from 'react';
|
|
2
|
-
|
|
3
|
-
const escapeHtml = (text: string): string => text.replace(/[&<>"']/g, character => ({
|
|
4
|
-
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
|
5
|
-
})[character]!);
|
|
6
|
-
|
|
7
|
-
/** Plain text remains exact; rich editors also receive explicit paragraph breaks. */
|
|
8
|
-
export function manuscriptClipboard(text: string): { plain: string; html: string } {
|
|
9
|
-
const lines = text.replace(/\r\n?/g, '\n').split('\n');
|
|
10
|
-
const html = lines.map(line => {
|
|
11
|
-
// Preserve indentation without turning ordinary word spaces into unbreakable runs.
|
|
12
|
-
const content = escapeHtml(line)
|
|
13
|
-
.replace(/^ +| +$/g, spaces => ' '.repeat(spaces.length))
|
|
14
|
-
.replace(/ {2,}/g, spaces => ' '.repeat(spaces.length - 1) + ' ');
|
|
15
|
-
return `<p style="margin:0; padding:0; white-space:pre-wrap; line-height:1.8;">${content || '<br>'}</p>`;
|
|
16
|
-
}).join('');
|
|
17
|
-
return { plain: text, html: `<div>${html}</div>` };
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
export function copyManuscriptSelection(event: ClipboardEvent<HTMLTextAreaElement>): void {
|
|
21
|
-
const { value, selectionStart, selectionEnd } = event.currentTarget;
|
|
22
|
-
if (selectionStart === selectionEnd || !event.clipboardData) return;
|
|
23
|
-
const { plain, html } = manuscriptClipboard(value.slice(selectionStart, selectionEnd));
|
|
24
|
-
event.clipboardData.setData('text/plain', plain);
|
|
25
|
-
event.clipboardData.setData('text/html', html);
|
|
26
|
-
event.preventDefault();
|
|
27
|
-
}
|
|
1
|
+
import type { ClipboardEvent } from 'react';
|
|
2
|
+
|
|
3
|
+
const escapeHtml = (text: string): string => text.replace(/[&<>"']/g, character => ({
|
|
4
|
+
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
|
5
|
+
})[character]!);
|
|
6
|
+
|
|
7
|
+
/** Plain text remains exact; rich editors also receive explicit paragraph breaks. */
|
|
8
|
+
export function manuscriptClipboard(text: string): { plain: string; html: string } {
|
|
9
|
+
const lines = text.replace(/\r\n?/g, '\n').split('\n');
|
|
10
|
+
const html = lines.map(line => {
|
|
11
|
+
// Preserve indentation without turning ordinary word spaces into unbreakable runs.
|
|
12
|
+
const content = escapeHtml(line)
|
|
13
|
+
.replace(/^ +| +$/g, spaces => ' '.repeat(spaces.length))
|
|
14
|
+
.replace(/ {2,}/g, spaces => ' '.repeat(spaces.length - 1) + ' ');
|
|
15
|
+
return `<p style="margin:0; padding:0; white-space:pre-wrap; line-height:1.8;">${content || '<br>'}</p>`;
|
|
16
|
+
}).join('');
|
|
17
|
+
return { plain: text, html: `<div>${html}</div>` };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function copyManuscriptSelection(event: ClipboardEvent<HTMLTextAreaElement>): void {
|
|
21
|
+
const { value, selectionStart, selectionEnd } = event.currentTarget;
|
|
22
|
+
if (selectionStart === selectionEnd || !event.clipboardData) return;
|
|
23
|
+
const { plain, html } = manuscriptClipboard(value.slice(selectionStart, selectionEnd));
|
|
24
|
+
event.clipboardData.setData('text/plain', plain);
|
|
25
|
+
event.clipboardData.setData('text/html', html);
|
|
26
|
+
event.preventDefault();
|
|
27
|
+
}
|
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
type Version={revision:number;timestamp?:string;reason?:string;actor?:{kind?:string;sessionId?:string|null}};
|
|
2
|
-
/** Group presentation only. Every source revision remains independently restorable. */
|
|
3
|
-
export function groupHistory<T extends Version>(versions:T[]):T[][]{
|
|
4
|
-
const groups:T[][]=[];
|
|
5
|
-
for(const version of [...versions].reverse()){
|
|
6
|
-
const group=groups.at(-1),previous=group?.at(-1);
|
|
7
|
-
if(previous&&version.actor?.kind==='human'&&previous.actor?.kind==='human'&&version.reason==='作者自动保存'&&previous.reason==='作者自动保存'&&version.actor?.sessionId===previous.actor?.sessionId&&Date.parse(previous.timestamp||'')-Date.parse(version.timestamp||'')<180000)group!.push(version);
|
|
8
|
-
else groups.push([version]);
|
|
9
|
-
}
|
|
10
|
-
return groups;
|
|
11
|
-
}
|
|
1
|
+
type Version={revision:number;timestamp?:string;reason?:string;actor?:{kind?:string;sessionId?:string|null}};
|
|
2
|
+
/** Group presentation only. Every source revision remains independently restorable. */
|
|
3
|
+
export function groupHistory<T extends Version>(versions:T[]):T[][]{
|
|
4
|
+
const groups:T[][]=[];
|
|
5
|
+
for(const version of [...versions].reverse()){
|
|
6
|
+
const group=groups.at(-1),previous=group?.at(-1);
|
|
7
|
+
if(previous&&version.actor?.kind==='human'&&previous.actor?.kind==='human'&&version.reason==='作者自动保存'&&previous.reason==='作者自动保存'&&version.actor?.sessionId===previous.actor?.sessionId&&Date.parse(previous.timestamp||'')-Date.parse(version.timestamp||'')<180000)group!.push(version);
|
|
8
|
+
else groups.push([version]);
|
|
9
|
+
}
|
|
10
|
+
return groups;
|
|
11
|
+
}
|
|
@@ -1,98 +1,98 @@
|
|
|
1
|
-
import {CreateDocument} from '../../shared/creation.tsx';
|
|
2
|
-
import {ProseDisplay, useWorkbenchPreferences} from '../../shared/preferences.tsx';
|
|
3
|
-
import React, { useEffect, useState, useRef, useLayoutEffect, useContext } from 'react';
|
|
4
|
-
import { useDialog } from '../../dialog.tsx';
|
|
5
|
-
import { SessionScope, usePreference, useResource, useScrollPosition } from '../../shared/state.js';
|
|
6
|
-
import { InlineTitle } from '../../shared/InlineTitle.tsx';
|
|
7
|
-
import { DirectoryRow } from './DirectoryRow.tsx';
|
|
8
|
-
import { ResourceState, saveShortcut } from '../../shared/ui.jsx';
|
|
9
|
-
import { formatProse } from './format.ts';
|
|
10
|
-
import { countManuscript } from '../../../domain/manuscript.ts';
|
|
11
|
-
import { useChapterSync, prepareChapterRemoval } from './useChapterSync.ts';
|
|
1
|
+
import {CreateDocument} from '../../shared/creation.tsx';
|
|
2
|
+
import {ProseDisplay, useWorkbenchPreferences} from '../../shared/preferences.tsx';
|
|
3
|
+
import React, { useEffect, useState, useRef, useLayoutEffect, useContext } from 'react';
|
|
4
|
+
import { useDialog } from '../../dialog.tsx';
|
|
5
|
+
import { SessionScope, usePreference, useResource, useScrollPosition } from '../../shared/state.js';
|
|
6
|
+
import { InlineTitle } from '../../shared/InlineTitle.tsx';
|
|
7
|
+
import { DirectoryRow } from './DirectoryRow.tsx';
|
|
8
|
+
import { ResourceState, saveShortcut } from '../../shared/ui.jsx';
|
|
9
|
+
import { formatProse } from './format.ts';
|
|
10
|
+
import { countManuscript } from '../../../domain/manuscript.ts';
|
|
11
|
+
import { useChapterSync, prepareChapterRemoval } from './useChapterSync.ts';
|
|
12
12
|
import { useManuscriptTextarea } from './useManuscriptTextarea.ts';
|
|
13
13
|
import { useChapterReorder } from './useChapterReorder.ts';
|
|
14
14
|
import { chapterChanges } from './sync.ts';
|
|
15
|
-
import { groupHistory } from './history.ts';
|
|
16
|
-
import { copyManuscriptSelection } from './clipboard.ts';
|
|
17
|
-
|
|
18
|
-
async function readChapter(call, novelId, chapterId) {
|
|
19
|
-
const first = await call('chapter.get', { novelId, chapterId, maxChars: 200000 });
|
|
20
|
-
let content = first.content, next = first.nextStart;
|
|
21
|
-
while (next !== null) {
|
|
22
|
-
const page = await call('chapter.get', { novelId, chapterId, start: next, maxChars: 200000 });
|
|
23
|
-
if (page.revision !== first.revision) throw new Error('读取期间正文发生变化,请重试。');
|
|
24
|
-
content += page.content; next = page.nextStart;
|
|
25
|
-
}
|
|
26
|
-
return { ...first, content };
|
|
27
|
-
}
|
|
28
|
-
export function Chapters({ call, novelId, tick, run, busy, onTransfer }) {
|
|
29
|
-
const scope=useContext(SessionScope);
|
|
30
|
-
const [undo,setUndo]=useState(null);
|
|
31
|
-
const {preferences}=useWorkbenchPreferences();
|
|
32
|
-
const [creating,setCreating]=useState(false);
|
|
33
|
-
const workspace=useRef(null),[narrow,setNarrow]=useState(false);
|
|
34
|
-
useEffect(()=>{const el=workspace.current;if(!el)return;const resize=new ResizeObserver(([entry])=>setNarrow(entry.contentRect.width<760));resize.observe(el);return()=>resize.disconnect();},[creating]);
|
|
35
|
-
const [id,setId] = usePreference(`${novelId}:chapter`, '');
|
|
36
|
-
const [directory,setDirectory] = useState(!id), [query,setQuery] = useState(''), [deleted,setDeleted] = useState(false);
|
|
37
|
-
const [directoryCollapsed,setDirectoryCollapsed] = useState(!preferences.directory);
|
|
38
|
-
const [collapsed,setCollapsed]=usePreference(`${novelId}:collapsed-volumes`,{});
|
|
39
|
-
const [poll,setPoll] = useState(0);
|
|
40
|
-
const { ask,confirm } = useDialog();
|
|
41
|
-
const resource = useResource(() => Promise.all([call('chapter.list',{novelId,includeDeleted:true}),call('volume.list',{novelId})]),[call,novelId,tick,poll]);
|
|
42
|
-
useEffect(()=>{const timer=setInterval(()=>setPoll(n=>n+1),5000);return ()=>clearInterval(timer);},[]);
|
|
15
|
+
import { groupHistory } from './history.ts';
|
|
16
|
+
import { copyManuscriptSelection } from './clipboard.ts';
|
|
17
|
+
|
|
18
|
+
async function readChapter(call, novelId, chapterId) {
|
|
19
|
+
const first = await call('chapter.get', { novelId, chapterId, maxChars: 200000 });
|
|
20
|
+
let content = first.content, next = first.nextStart;
|
|
21
|
+
while (next !== null) {
|
|
22
|
+
const page = await call('chapter.get', { novelId, chapterId, start: next, maxChars: 200000 });
|
|
23
|
+
if (page.revision !== first.revision) throw new Error('读取期间正文发生变化,请重试。');
|
|
24
|
+
content += page.content; next = page.nextStart;
|
|
25
|
+
}
|
|
26
|
+
return { ...first, content };
|
|
27
|
+
}
|
|
28
|
+
export function Chapters({ call, novelId, tick, run, busy, onTransfer }) {
|
|
29
|
+
const scope=useContext(SessionScope);
|
|
30
|
+
const [undo,setUndo]=useState(null);
|
|
31
|
+
const {preferences}=useWorkbenchPreferences();
|
|
32
|
+
const [creating,setCreating]=useState(false);
|
|
33
|
+
const workspace=useRef(null),[narrow,setNarrow]=useState(false);
|
|
34
|
+
useEffect(()=>{const el=workspace.current;if(!el)return;const resize=new ResizeObserver(([entry])=>setNarrow(entry.contentRect.width<760));resize.observe(el);return()=>resize.disconnect();},[creating]);
|
|
35
|
+
const [id,setId] = usePreference(`${novelId}:chapter`, '');
|
|
36
|
+
const [directory,setDirectory] = useState(!id), [query,setQuery] = useState(''), [deleted,setDeleted] = useState(false);
|
|
37
|
+
const [directoryCollapsed,setDirectoryCollapsed] = useState(!preferences.directory);
|
|
38
|
+
const [collapsed,setCollapsed]=usePreference(`${novelId}:collapsed-volumes`,{});
|
|
39
|
+
const [poll,setPoll] = useState(0);
|
|
40
|
+
const { ask,confirm } = useDialog();
|
|
41
|
+
const resource = useResource(() => Promise.all([call('chapter.list',{novelId,includeDeleted:true}),call('volume.list',{novelId})]),[call,novelId,tick,poll]);
|
|
42
|
+
useEffect(()=>{const timer=setInterval(()=>setPoll(n=>n+1),5000);return ()=>clearInterval(timer);},[]);
|
|
43
43
|
const [savedChapters=[],volumes=[]] = resource.value || [];
|
|
44
44
|
const reorder=useChapterReorder({chapters:savedChapters,volumes,call,novelId,busy,onMoved:volumeId=>{setPoll(n=>n+1);setCollapsed(old=>({...old,[volumeId||'none']:false}));}});
|
|
45
45
|
const chapters=reorder.chapters;
|
|
46
|
-
const live=chapters.filter(c=>!c.deleted),trash=chapters.filter(c=>c.deleted);
|
|
47
|
-
const refresh=()=>setPoll(n=>n+1);
|
|
48
|
-
const remove=c=>run(async()=>{
|
|
49
|
-
const prepared=await prepareChapterRemoval({scope,novelId,chapterId:c.id,read:()=>readChapter(call,novelId,c.id),
|
|
50
|
-
write:(value,base,requestId)=>call('chapter.update',{novelId,chapterId:c.id,requestId,...chapterChanges(value,base),expectedRevision:base.revision,expectedHash:base.contentHash,reason:'作者自动保存'}),onSaved:refresh});
|
|
51
|
-
const before=prepared.chapter;
|
|
52
|
-
if(before.deleted)throw new Error('章节已在回收站,请刷新目录。');
|
|
53
|
-
const removed=await call('chapter.delete',{novelId,chapterId:c.id,expectedRevision:before.revision,requestId:crypto.randomUUID(),confirm:true});
|
|
54
|
-
prepared.accept({...before,...removed});
|
|
55
|
-
setUndo({id:c.id,title:before.title,revision:removed.revision,targetRevision:removed.revision,wasCurrent:id===c.id});
|
|
56
|
-
if(id===c.id){const at=live.findIndex(ch=>ch.id===c.id);setId((live[at+1]||live[at-1])?.id||'');}
|
|
57
|
-
refresh();
|
|
58
|
-
});
|
|
59
|
-
const restore=(c,returnToChapter=false)=>run(async()=>{
|
|
60
|
-
await call('chapter.restore',{novelId,chapterId:c.id,expectedRevision:c.revision,targetRevision:c.targetRevision||c.revision,requestId:crypto.randomUUID()});
|
|
61
|
-
if(returnToChapter)setId(c.id);
|
|
62
|
-
setUndo(null);refresh();
|
|
63
|
-
});
|
|
64
|
-
const select = chapter => { setId(chapter.id); setDirectory(false); };
|
|
65
|
-
const create = () => setCreating(true);
|
|
66
|
-
if(creating)return <CreateDocument chapter draftKey={`${novelId}:chapter-create`} label="新建章节" initial={{title:'',summary:'',content:'',groupId:chapters.find(c=>c.id===id)?.volumeId||null}} groups={volumes.map(v=>({...v,name:v.title}))} busy={busy} close={()=>setCreating(false)} createGroup={async title=>{let result;await run(async()=>{const v=await call('volume.create',{novelId,title});result={...v,name:v.title};});return result;}} save={(value,requestId)=>run(async()=>{const c=await call('chapter.create',{novelId,requestId,title:value.title,content:value.content,volumeId:value.groupId});select(c);})}/>;
|
|
67
|
-
const groups = [...volumes, {id:null,title:'未分卷'}];
|
|
46
|
+
const live=chapters.filter(c=>!c.deleted),trash=chapters.filter(c=>c.deleted);
|
|
47
|
+
const refresh=()=>setPoll(n=>n+1);
|
|
48
|
+
const remove=c=>run(async()=>{
|
|
49
|
+
const prepared=await prepareChapterRemoval({scope,novelId,chapterId:c.id,read:()=>readChapter(call,novelId,c.id),
|
|
50
|
+
write:(value,base,requestId)=>call('chapter.update',{novelId,chapterId:c.id,requestId,...chapterChanges(value,base),expectedRevision:base.revision,expectedHash:base.contentHash,reason:'作者自动保存'}),onSaved:refresh});
|
|
51
|
+
const before=prepared.chapter;
|
|
52
|
+
if(before.deleted)throw new Error('章节已在回收站,请刷新目录。');
|
|
53
|
+
const removed=await call('chapter.delete',{novelId,chapterId:c.id,expectedRevision:before.revision,requestId:crypto.randomUUID(),confirm:true});
|
|
54
|
+
prepared.accept({...before,...removed});
|
|
55
|
+
setUndo({id:c.id,title:before.title,revision:removed.revision,targetRevision:removed.revision,wasCurrent:id===c.id});
|
|
56
|
+
if(id===c.id){const at=live.findIndex(ch=>ch.id===c.id);setId((live[at+1]||live[at-1])?.id||'');}
|
|
57
|
+
refresh();
|
|
58
|
+
});
|
|
59
|
+
const restore=(c,returnToChapter=false)=>run(async()=>{
|
|
60
|
+
await call('chapter.restore',{novelId,chapterId:c.id,expectedRevision:c.revision,targetRevision:c.targetRevision||c.revision,requestId:crypto.randomUUID()});
|
|
61
|
+
if(returnToChapter)setId(c.id);
|
|
62
|
+
setUndo(null);refresh();
|
|
63
|
+
});
|
|
64
|
+
const select = chapter => { setId(chapter.id); setDirectory(false); };
|
|
65
|
+
const create = () => setCreating(true);
|
|
66
|
+
if(creating)return <CreateDocument chapter draftKey={`${novelId}:chapter-create`} label="新建章节" initial={{title:'',summary:'',content:'',groupId:chapters.find(c=>c.id===id)?.volumeId||null}} groups={volumes.map(v=>({...v,name:v.title}))} busy={busy} close={()=>setCreating(false)} createGroup={async title=>{let result;await run(async()=>{const v=await call('volume.create',{novelId,title});result={...v,name:v.title};});return result;}} save={(value,requestId)=>run(async()=>{const c=await call('chapter.create',{novelId,requestId,title:value.title,content:value.content,volumeId:value.groupId});select(c);})}/>;
|
|
67
|
+
const groups = [...volumes, {id:null,title:'未分卷'}];
|
|
68
68
|
const directoryContent=<aside ref={reorder.directoryRef} className="chapter-directory" aria-label="章节目录" data-reordering={reorder.saving||!!reorder.drag} onClickCapture={reorder.onClickCapture} onPointerDownCapture={reorder.onPointerDownCapture}>
|
|
69
|
-
<div className="row directory-heading">{deleted&&<button className="icon-button" aria-label="返回章节目录" onClick={()=>setDeleted(false)}>‹</button>}<h2 className="grow">{deleted?'回收站':'目录'}</h2><button className="icon-button" aria-label={directoryCollapsed?'展开章节目录':'收起章节目录'} title={directoryCollapsed?'展开章节目录':'收起章节目录'} onClick={()=>setDirectoryCollapsed(v=>!v)}>{directoryCollapsed?'›':'‹'}</button>{!deleted&&<button className="primary" onClick={create} disabled={busy}>+ 章节</button>}</div>
|
|
70
|
-
<div className="directory-search"><input className="search" aria-label="搜索章节" placeholder="搜索章节名称" value={query} onChange={e=>setQuery(e.target.value)}/>{!deleted&&<button className="directory-new-volume" disabled={busy} aria-label="新建卷" onClick={async()=>{const title=await ask('新卷名称');if(title)run(()=>call('volume.create',{novelId,title}));}}>+ 卷</button>}</div>
|
|
71
|
-
<ResourceState resource={resource}>
|
|
72
|
-
{!(deleted?trash:live).length && <p className="empty">{deleted?'回收站是空的':'还没有章节。新建一章,开始你的故事。'}</p>}
|
|
73
|
-
{query && !(deleted?trash:live).some(c=>c.title.includes(query)) && <p className="empty">没有匹配的章节</p>}
|
|
74
|
-
{deleted?trash.filter(c=>c.title.includes(query)).map(c=><DirectoryRow key={c.id} chapter={c} selected={c.id===id} disabled={busy} open={()=>select(c)} remove={()=>{}} restore={()=>restore(c)}/>):groups.map(v=>{const items=live.filter(c=>(c.volumeId||null)===v.id && c.title.includes(query)),folded=collapsed[v.id||'none']&&!query,endDrop=reorder.drag?.target?.volumeId===v.id&&reorder.drag.target.beforeChapterId===null;if(!items.length&&!v.id&&!reorder.drag)return null;return <section className={`volume-group ${endDrop&&folded?'chapter-drop-group':''}`} data-chapter-volume={v.id||''} key={v.id||'none'}>
|
|
75
|
-
<div className="row compact"><button className="volume-toggle grow" aria-expanded={!collapsed[v.id||'none']||!!query} onClick={()=>setCollapsed(old=>({...old,[v.id||'none']:!old[v.id||'none']}))}>{collapsed[v.id||'none']&&!query?'▸':'▾'} {v.title} <small>{items.length}</small></button>{v.id&&<details className="menu"><summary aria-label={`${v.title}设置`}>···</summary><div className="menu-panel">
|
|
76
|
-
<button disabled={busy} onClick={async()=>{const title=await ask('卷名称',v.title);if(title)run(()=>call('volume.update',{novelId,volumeId:v.id,title,expectedRevision:v.revision}));}}>改名</button>
|
|
77
|
-
<button disabled={busy} onClick={()=>onTransfer({mode:'export',selection:{kind:'chapters',novelId,volumeIds:[v.id]}})}>导出这卷</button><button disabled={busy} onClick={async()=>{const value=await ask('卷排序(数字越小越靠前)',String(v.order));if(value!==null)run(()=>call('volume.update',{novelId,volumeId:v.id,order:Number(value),expectedRevision:v.revision}));}}>调整顺序</button>
|
|
78
|
-
<button className="danger" disabled={busy} onClick={async()=>{if(await confirm(`删除卷“${v.title}”?章节将保留在未分卷。`))run(()=>call('volume.delete',{novelId,volumeId:v.id,expectedRevision:v.revision,confirm:true,chapterPolicy:'detach'}));}}>删除卷</button>
|
|
79
|
-
</div></details>}</div>
|
|
80
|
-
{!folded&&items.map(c=><DirectoryRow key={c.id} chapter={c} selected={c.id===id} dragging={reorder.drag?.source.id===c.id} before={reorder.drag?.target?.beforeChapterId===c.id} disabled={busy||reorder.saving||!!reorder.drag} dragProps={reorder.rowProps(c)} open={()=>select(c)} remove={()=>remove(c)} restore={()=>{}}/>)}
|
|
69
|
+
<div className="row directory-heading">{deleted&&<button className="icon-button" aria-label="返回章节目录" onClick={()=>setDeleted(false)}>‹</button>}<h2 className="grow">{deleted?'回收站':'目录'}</h2><button className="icon-button" aria-label={directoryCollapsed?'展开章节目录':'收起章节目录'} title={directoryCollapsed?'展开章节目录':'收起章节目录'} onClick={()=>setDirectoryCollapsed(v=>!v)}>{directoryCollapsed?'›':'‹'}</button>{!deleted&&<button className="primary" onClick={create} disabled={busy}>+ 章节</button>}</div>
|
|
70
|
+
<div className="directory-search"><input className="search" aria-label="搜索章节" placeholder="搜索章节名称" value={query} onChange={e=>setQuery(e.target.value)}/>{!deleted&&<button className="directory-new-volume" disabled={busy} aria-label="新建卷" onClick={async()=>{const title=await ask('新卷名称');if(title)run(()=>call('volume.create',{novelId,title}));}}>+ 卷</button>}</div>
|
|
71
|
+
<ResourceState resource={resource}>
|
|
72
|
+
{!(deleted?trash:live).length && <p className="empty">{deleted?'回收站是空的':'还没有章节。新建一章,开始你的故事。'}</p>}
|
|
73
|
+
{query && !(deleted?trash:live).some(c=>c.title.includes(query)) && <p className="empty">没有匹配的章节</p>}
|
|
74
|
+
{deleted?trash.filter(c=>c.title.includes(query)).map(c=><DirectoryRow key={c.id} chapter={c} selected={c.id===id} disabled={busy} open={()=>select(c)} remove={()=>{}} restore={()=>restore(c)}/>):groups.map(v=>{const items=live.filter(c=>(c.volumeId||null)===v.id && c.title.includes(query)),folded=collapsed[v.id||'none']&&!query,endDrop=reorder.drag?.target?.volumeId===v.id&&reorder.drag.target.beforeChapterId===null;if(!items.length&&!v.id&&!reorder.drag)return null;return <section className={`volume-group ${endDrop&&folded?'chapter-drop-group':''}`} data-chapter-volume={v.id||''} key={v.id||'none'}>
|
|
75
|
+
<div className="row compact"><button className="volume-toggle grow" aria-expanded={!collapsed[v.id||'none']||!!query} onClick={()=>setCollapsed(old=>({...old,[v.id||'none']:!old[v.id||'none']}))}>{collapsed[v.id||'none']&&!query?'▸':'▾'} {v.title} <small>{items.length}</small></button>{v.id&&<details className="menu"><summary aria-label={`${v.title}设置`}>···</summary><div className="menu-panel">
|
|
76
|
+
<button disabled={busy} onClick={async()=>{const title=await ask('卷名称',v.title);if(title)run(()=>call('volume.update',{novelId,volumeId:v.id,title,expectedRevision:v.revision}));}}>改名</button>
|
|
77
|
+
<button disabled={busy} onClick={()=>onTransfer({mode:'export',selection:{kind:'chapters',novelId,volumeIds:[v.id]}})}>导出这卷</button><button disabled={busy} onClick={async()=>{const value=await ask('卷排序(数字越小越靠前)',String(v.order));if(value!==null)run(()=>call('volume.update',{novelId,volumeId:v.id,order:Number(value),expectedRevision:v.revision}));}}>调整顺序</button>
|
|
78
|
+
<button className="danger" disabled={busy} onClick={async()=>{if(await confirm(`删除卷“${v.title}”?章节将保留在未分卷。`))run(()=>call('volume.delete',{novelId,volumeId:v.id,expectedRevision:v.revision,confirm:true,chapterPolicy:'detach'}));}}>删除卷</button>
|
|
79
|
+
</div></details>}</div>
|
|
80
|
+
{!folded&&items.map(c=><DirectoryRow key={c.id} chapter={c} selected={c.id===id} dragging={reorder.drag?.source.id===c.id} before={reorder.drag?.target?.beforeChapterId===c.id} disabled={busy||reorder.saving||!!reorder.drag} dragProps={reorder.rowProps(c)} open={()=>select(c)} remove={()=>remove(c)} restore={()=>{}}/>)}
|
|
81
81
|
{endDrop&&!folded&&<div className="chapter-drop-end" aria-hidden="true"/>}
|
|
82
82
|
</section>;})}
|
|
83
|
-
</ResourceState><div className="directory-foot"><span>{live.length} 章 · {live.reduce((sum,c)=>sum+(c.textCount||0),0).toLocaleString('zh-CN')} 字{reorder.saving&&<span className="directory-sort-note">保存顺序…</span>}</span><button className="recycle-entry" aria-pressed={deleted} onClick={()=>setDeleted(v=>!v)}>回收站 <small>{trash.length}</small></button></div>
|
|
84
|
-
{undo&&<div className="directory-undo" role="status"><span>已移入回收站 · {undo.title}</span><button disabled={busy} onClick={()=>restore(undo,undo.wasCurrent)}>撤销</button></div>}
|
|
83
|
+
</ResourceState><div className="directory-foot"><span>{live.length} 章 · {live.reduce((sum,c)=>sum+(c.textCount||0),0).toLocaleString('zh-CN')} 字{reorder.saving&&<span className="directory-sort-note">保存顺序…</span>}</span><button className="recycle-entry" aria-pressed={deleted} onClick={()=>setDeleted(v=>!v)}>回收站 <small>{trash.length}</small></button></div>
|
|
84
|
+
{undo&&<div className="directory-undo" role="status"><span>已移入回收站 · {undo.title}</span><button disabled={busy} onClick={()=>restore(undo,undo.wasCurrent)}>撤销</button></div>}
|
|
85
85
|
{reorder.error&&<p role="alert" className="directory-sort-error">{reorder.error}</p>}
|
|
86
86
|
{reorder.drag&&<div className="chapter-drag-preview" aria-hidden="true" style={{left:Math.max(8,Math.min(reorder.drag.x+14,window.innerWidth-220)),top:Math.min(reorder.drag.y+14,window.innerHeight-60)}}><span>⠿</span>{reorder.drag.source.title}</div>}
|
|
87
87
|
<span className="chapter-sort-live" role="status">{reorder.saving?'正在保存章节顺序':reorder.drag?.target?'松开以放到此位置':''}</span>
|
|
88
|
-
</aside>;
|
|
89
|
-
return <div ref={workspace} className={`chapter-workspace ${directory?'show-directory':'show-editor'} ${directoryCollapsed?'directory-collapsed':''}`}>
|
|
90
|
-
{narrow ? directory&&<DirectoryDrawer close={()=>setDirectory(false)}>{directoryContent}</DirectoryDrawer> : directoryContent}
|
|
91
|
-
<main className="chapter-main">
|
|
92
|
-
{id ? <ChapterLoader key={id} {...{call,novelId,tick,run,busy,volumes,onTransfer}} onSaved={()=>setPoll(n=>n+1)} chapterId={id} latest={chapters.find(c=>c.id===id)} back={()=>setDirectory(true)}/> : <div className="empty"><h2>选择一章,继续写作</h2><p>从目录打开章节,或新建一章。</p><button onClick={()=>setDirectory(true)}>打开目录</button></div>}
|
|
93
|
-
</main>
|
|
94
|
-
</div>;
|
|
95
|
-
}
|
|
88
|
+
</aside>;
|
|
89
|
+
return <div ref={workspace} className={`chapter-workspace ${directory?'show-directory':'show-editor'} ${directoryCollapsed?'directory-collapsed':''}`}>
|
|
90
|
+
{narrow ? directory&&<DirectoryDrawer close={()=>setDirectory(false)}>{directoryContent}</DirectoryDrawer> : directoryContent}
|
|
91
|
+
<main className="chapter-main">
|
|
92
|
+
{id ? <ChapterLoader key={id} {...{call,novelId,tick,run,busy,volumes,onTransfer}} onSaved={()=>setPoll(n=>n+1)} chapterId={id} latest={chapters.find(c=>c.id===id)} back={()=>setDirectory(true)}/> : <div className="empty"><h2>选择一章,继续写作</h2><p>从目录打开章节,或新建一章。</p><button onClick={()=>setDirectory(true)}>打开目录</button></div>}
|
|
93
|
+
</main>
|
|
94
|
+
</div>;
|
|
95
|
+
}
|
|
96
96
|
function DirectoryDrawer({children,close}) {
|
|
97
97
|
const ref=useRef(null),backdropPress=useRef(false);
|
|
98
98
|
useEffect(()=>{const previous=document.activeElement,dialog=ref.current;dialog?.showModal();return()=>{dialog?.close();if(previous?.isConnected)previous.focus({preventScroll:true});};},[]);
|
|
@@ -105,41 +105,41 @@ function DirectoryDrawer({children,close}) {
|
|
|
105
105
|
<header className="drawer-header"><button type="button" className="drawer-close" aria-label="关闭章节目录" title="关闭目录" onClick={close}><span aria-hidden="true">×</span></button></header>{children}
|
|
106
106
|
</dialog>;
|
|
107
107
|
}
|
|
108
|
-
function ChapterLoader(props) {
|
|
109
|
-
const resource=useResource(()=>readChapter(props.call,props.novelId,props.chapterId),[props.call,props.novelId,props.chapterId]);
|
|
110
|
-
return <>{!resource.value&&<button className="directory-back" onClick={props.back}>‹ 返回目录</button>}<ResourceState resource={resource}>{resource.value&&<ChapterEditor {...props} initial={resource.value}/>}</ResourceState></>;
|
|
111
|
-
}
|
|
112
|
-
function ChapterEditor({call,novelId,chapterId,latest,tick,run,busy,volumes,back,initial,onSaved,onTransfer}) {
|
|
108
|
+
function ChapterLoader(props) {
|
|
109
|
+
const resource=useResource(()=>readChapter(props.call,props.novelId,props.chapterId),[props.call,props.novelId,props.chapterId]);
|
|
110
|
+
return <>{!resource.value&&<button className="directory-back" onClick={props.back}>‹ 返回目录</button>}<ResourceState resource={resource}>{resource.value&&<ChapterEditor {...props} initial={resource.value}/>}</ResourceState></>;
|
|
111
|
+
}
|
|
112
|
+
function ChapterEditor({call,novelId,chapterId,latest,tick,run,busy,volumes,back,initial,onSaved,onTransfer}) {
|
|
113
113
|
const {value:draft,base,change,accept,rebase,dirty,cacheError,phase,error:saveError,remote,notice:syncNotice,save,composition}=useChapterSync({novelId,chapterId,initial,latest,onSaved,read:()=>readChapter(call,novelId,chapterId),write:(value,base,requestId)=>call('chapter.update',{novelId,chapterId,requestId,...chapterChanges(value,base),expectedRevision:base.revision,expectedHash:base.contentHash,reason:'作者自动保存'})});
|
|
114
114
|
const manuscript=useManuscriptTextarea(draft.content),textareaRef=manuscript.ref;
|
|
115
|
-
const [comparison,setComparison]=useState(null),[historyOpen,setHistoryOpen]=useState(false);
|
|
116
|
-
const {preferences}=useWorkbenchPreferences();
|
|
117
|
-
const font=preferences.fontSize;
|
|
118
|
-
const scrollRef=useScrollPosition(`${novelId}:${chapterId}`);
|
|
119
|
-
// Let the document own scrolling; the textarea must not become a nested viewport.
|
|
120
|
-
const fitManuscript=()=>{const el=textareaRef.current;if(!el)return;const top=scrollRef.current?.scrollTop||0;el.style.height='0px';el.style.height=`${Math.max(240,el.scrollHeight)}px`;if(scrollRef.current)scrollRef.current.scrollTop=top;};
|
|
121
|
-
useLayoutEffect(fitManuscript,[draft.content,font,preferences.leading]);
|
|
122
|
-
useEffect(()=>{const viewport=scrollRef.current;if(!viewport)return;let width=-1;const observer=new ResizeObserver(entries=>{const next=entries[0].contentRect.width;if(next!==width){width=next;fitManuscript();}});observer.observe(viewport);return()=>observer.disconnect();},[]);
|
|
123
|
-
const {confirm}=useDialog();
|
|
124
|
-
const [formatUndo,setFormatUndo]=useState(null);
|
|
125
|
-
const format=()=>{const content=formatProse(draft.content);if(content!==draft.content){setFormatUndo({before:draft.content,after:content});change(d=>({...d,content}));}};
|
|
126
|
-
const history=useResource(()=>historyOpen?call('chapter.history',{novelId,chapterId,includeContent:true}):Promise.resolve([]),[call,novelId,chapterId,tick,base.revision,historyOpen]);
|
|
127
|
-
const conflict=phase==='conflict';
|
|
128
|
-
const reload=async()=>{if(!dirty||await confirm('丢弃本地草稿,读取最新正文?'))run(async()=>{accept(await readChapter(call,novelId,chapterId));setComparison(null);});};
|
|
129
|
-
const set=(key,value)=>change(d=>({...d,[key]:value}));
|
|
130
|
-
return <div className="editor-shell" onCompositionStart={()=>composition(true)} onCompositionEnd={()=>composition(false)} onKeyDown={e=>saveShortcut(e,save)}>
|
|
131
|
-
<header className="editor-heading"><div className="row compact chapter-title-row"><button className="directory-back" onClick={back}>‹ 目录</button><div className="grow chapter-title"><label className="chapter-volume"><select aria-label="章节所属卷" disabled={busy||base.deleted} value={draft.volumeId||''} onChange={e=>set('volumeId',e.target.value||null)}><option value="">未分卷</option>{volumes.map(v=><option key={v.id} value={v.id}>{v.title}</option>)}</select></label><InlineTitle value={draft.title} label="修改章节标题" disabled={busy||base.deleted} commit={title=>set('title',title)}/></div><details className="menu"><summary aria-label="章节设置" title="章节操作">···</summary><div className="menu-panel chapter-settings-panel"><button disabled={busy||base.deleted||dirty||phase==='saving'} title={dirty?'等待正文自动保存后导出':undefined} onClick={()=>onTransfer({mode:'export',selection:{kind:'chapters',novelId,chapterIds:[chapterId]}})}>导出本章</button>
|
|
132
|
-
<button type="button" onClick={e=>{setHistoryOpen(true);e.currentTarget.closest('details').open=false;}}>版本历史</button>
|
|
133
|
-
</div></details></div>
|
|
134
|
-
<div className="chapter-toolbar"><div className="prose-tools"><button type="button" className="format-button" disabled={busy||base.deleted||!draft.content.trim()} onClick={format}>自动排版</button>{formatUndo&&formatUndo.after===draft.content&&<button className="icon-button" aria-label="撤销排版" disabled={busy||base.deleted} onClick={()=>{set('content',formatUndo.before);setFormatUndo(null);}}>↶</button>}</div><ProseDisplay/></div>
|
|
135
|
-
</header>
|
|
136
|
-
<div className="editor-scroll is-editing" ref={scrollRef}>
|
|
137
|
-
{conflict&&<div className="notice">你与 AI 或其他窗口修改了同一处,双方内容均已保留。<button disabled={busy} onClick={()=>setComparison(remote)}>比较最新正文</button><button disabled={busy||phase==='saving'} onClick={reload}>放弃草稿并读取最新</button></div>}
|
|
138
|
-
{base.deleted&&<div className="notice">本章已移入回收站。可从目录的回收站恢复。</div>}
|
|
115
|
+
const [comparison,setComparison]=useState(null),[historyOpen,setHistoryOpen]=useState(false);
|
|
116
|
+
const {preferences}=useWorkbenchPreferences();
|
|
117
|
+
const font=preferences.fontSize;
|
|
118
|
+
const scrollRef=useScrollPosition(`${novelId}:${chapterId}`);
|
|
119
|
+
// Let the document own scrolling; the textarea must not become a nested viewport.
|
|
120
|
+
const fitManuscript=()=>{const el=textareaRef.current;if(!el)return;const top=scrollRef.current?.scrollTop||0;el.style.height='0px';el.style.height=`${Math.max(240,el.scrollHeight)}px`;if(scrollRef.current)scrollRef.current.scrollTop=top;};
|
|
121
|
+
useLayoutEffect(fitManuscript,[draft.content,font,preferences.leading]);
|
|
122
|
+
useEffect(()=>{const viewport=scrollRef.current;if(!viewport)return;let width=-1;const observer=new ResizeObserver(entries=>{const next=entries[0].contentRect.width;if(next!==width){width=next;fitManuscript();}});observer.observe(viewport);return()=>observer.disconnect();},[]);
|
|
123
|
+
const {confirm}=useDialog();
|
|
124
|
+
const [formatUndo,setFormatUndo]=useState(null);
|
|
125
|
+
const format=()=>{const content=formatProse(draft.content);if(content!==draft.content){setFormatUndo({before:draft.content,after:content});change(d=>({...d,content}));}};
|
|
126
|
+
const history=useResource(()=>historyOpen?call('chapter.history',{novelId,chapterId,includeContent:true}):Promise.resolve([]),[call,novelId,chapterId,tick,base.revision,historyOpen]);
|
|
127
|
+
const conflict=phase==='conflict';
|
|
128
|
+
const reload=async()=>{if(!dirty||await confirm('丢弃本地草稿,读取最新正文?'))run(async()=>{accept(await readChapter(call,novelId,chapterId));setComparison(null);});};
|
|
129
|
+
const set=(key,value)=>change(d=>({...d,[key]:value}));
|
|
130
|
+
return <div className="editor-shell" onCompositionStart={()=>composition(true)} onCompositionEnd={()=>composition(false)} onKeyDown={e=>saveShortcut(e,save)}>
|
|
131
|
+
<header className="editor-heading"><div className="row compact chapter-title-row"><button className="directory-back" onClick={back}>‹ 目录</button><div className="grow chapter-title"><label className="chapter-volume"><select aria-label="章节所属卷" disabled={busy||base.deleted} value={draft.volumeId||''} onChange={e=>set('volumeId',e.target.value||null)}><option value="">未分卷</option>{volumes.map(v=><option key={v.id} value={v.id}>{v.title}</option>)}</select></label><InlineTitle value={draft.title} label="修改章节标题" disabled={busy||base.deleted} commit={title=>set('title',title)}/></div><details className="menu"><summary aria-label="章节设置" title="章节操作">···</summary><div className="menu-panel chapter-settings-panel"><button disabled={busy||base.deleted||dirty||phase==='saving'} title={dirty?'等待正文自动保存后导出':undefined} onClick={()=>onTransfer({mode:'export',selection:{kind:'chapters',novelId,chapterIds:[chapterId]}})}>导出本章</button>
|
|
132
|
+
<button type="button" onClick={e=>{setHistoryOpen(true);e.currentTarget.closest('details').open=false;}}>版本历史</button>
|
|
133
|
+
</div></details></div>
|
|
134
|
+
<div className="chapter-toolbar"><div className="prose-tools"><button type="button" className="format-button" disabled={busy||base.deleted||!draft.content.trim()} onClick={format}>自动排版</button>{formatUndo&&formatUndo.after===draft.content&&<button className="icon-button" aria-label="撤销排版" disabled={busy||base.deleted} onClick={()=>{set('content',formatUndo.before);setFormatUndo(null);}}>↶</button>}</div><ProseDisplay/></div>
|
|
135
|
+
</header>
|
|
136
|
+
<div className="editor-scroll is-editing" ref={scrollRef}>
|
|
137
|
+
{conflict&&<div className="notice">你与 AI 或其他窗口修改了同一处,双方内容均已保留。<button disabled={busy} onClick={()=>setComparison(remote)}>比较最新正文</button><button disabled={busy||phase==='saving'} onClick={reload}>放弃草稿并读取最新</button></div>}
|
|
138
|
+
{base.deleted&&<div className="notice">本章已移入回收站。可从目录的回收站恢复。</div>}
|
|
139
139
|
<textarea {...manuscript} className="prose manuscript" aria-label="正文" style={{fontSize:font}} disabled={busy||base.deleted} onCopy={copyManuscriptSelection} onChange={e=>set('content',e.target.value)}/>
|
|
140
|
-
{historyOpen&&<section className="history-panel"><div className="row"><h3 className="grow">历史版本</h3><button onClick={()=>setHistoryOpen(false)}>关闭历史</button></div><ResourceState resource={history}>{groupHistory(history.value||[]).map(group=><details className="history-session" key={group[0].revision}><summary>{group.length>1?`作者编辑 · ${group.length} 次自动保存`:`版本 ${group[0].revision}`} · {group[0].timestamp?new Date(group[0].timestamp).toLocaleString():''}</summary>{group.map(h=><div className="history-item" key={h.revision}><div><strong>版本 {h.revision}</strong><p className="muted">{h.actor?.kind==='agent'?'Agent':'作者'} · {h.timestamp ? new Date(h.timestamp).toLocaleString() : ''}<br/>{h.reason||'正文修改'}</p></div><div className="row"><button onClick={()=>{setComparison(h);setHistoryOpen(false);}}>比较</button><button disabled={busy||phase==='saving'} onClick={async()=>{if(await confirm(`恢复版本${h.revision}?当前草稿会被替换,已保存的历史仍保留。`))run(async()=>{await call('chapter.restore',{novelId,chapterId,targetRevision:h.revision,expectedRevision:base.revision});accept(await readChapter(call,novelId,chapterId));});}}>恢复</button></div></div>)}</details>)}</ResourceState></section>}
|
|
141
|
-
{comparison&&<section className="history-panel comparison"><div className="row"><h3 className="grow">版本比较</h3><button onClick={()=>setComparison(null)}>关闭比较</button></div><div className="split"><div><h3>本地内容(可直接合并)</h3><textarea className="prose" aria-label="合并正文" disabled={busy||base.deleted} value={draft.content} onChange={e=>set('content',e.target.value)}/></div><div><h3>版本 {comparison.revision}</h3><pre>{comparison.content}</pre></div></div><p className="muted">可在这里合并需要的正文,再更新保存基准;合并后自动保存。后续写入仍检查版本。</p>{comparison.revision===latest?.revision&&!comparison.deleted&&<button onClick={async()=>{if(await confirm('确认已将需要的内容合并到本地正文?将以这版作为保存基准,后续更新仍会检查冲突。')){rebase(comparison);setComparison(null);}}}>已合并,更新保存基准</button>}<button disabled={busy||phase==='saving'} onClick={reload}>放弃草稿并读取最新</button></section>}
|
|
142
|
-
</div>
|
|
143
|
-
<footer className="savebar auto-savebar"><div role="status" className={cacheError||saveError||conflict?'error-text':'muted'}>{cacheError||saveError||(base.deleted?'已删除章节':!draft.title.trim()?'请填写章节名称':conflict?'待合并 · 草稿已保留':phase==='saving'?'保存中…':dirty?'等待自动保存…':syncNotice||'已保存')}<span title="含标点、不计空白">{countManuscript(draft.content).toLocaleString('zh-CN')} 字</span></div>{phase==='error'&&<button onClick={save}>重试保存</button>}</footer>
|
|
144
|
-
</div>;
|
|
145
|
-
}
|
|
140
|
+
{historyOpen&&<section className="history-panel"><div className="row"><h3 className="grow">历史版本</h3><button onClick={()=>setHistoryOpen(false)}>关闭历史</button></div><ResourceState resource={history}>{groupHistory(history.value||[]).map(group=><details className="history-session" key={group[0].revision}><summary>{group.length>1?`作者编辑 · ${group.length} 次自动保存`:`版本 ${group[0].revision}`} · {group[0].timestamp?new Date(group[0].timestamp).toLocaleString():''}</summary>{group.map(h=><div className="history-item" key={h.revision}><div><strong>版本 {h.revision}</strong><p className="muted">{h.actor?.kind==='agent'?'Agent':'作者'} · {h.timestamp ? new Date(h.timestamp).toLocaleString() : ''}<br/>{h.reason||'正文修改'}</p></div><div className="row"><button onClick={()=>{setComparison(h);setHistoryOpen(false);}}>比较</button><button disabled={busy||phase==='saving'} onClick={async()=>{if(await confirm(`恢复版本${h.revision}?当前草稿会被替换,已保存的历史仍保留。`))run(async()=>{await call('chapter.restore',{novelId,chapterId,targetRevision:h.revision,expectedRevision:base.revision});accept(await readChapter(call,novelId,chapterId));});}}>恢复</button></div></div>)}</details>)}</ResourceState></section>}
|
|
141
|
+
{comparison&&<section className="history-panel comparison"><div className="row"><h3 className="grow">版本比较</h3><button onClick={()=>setComparison(null)}>关闭比较</button></div><div className="split"><div><h3>本地内容(可直接合并)</h3><textarea className="prose" aria-label="合并正文" disabled={busy||base.deleted} value={draft.content} onChange={e=>set('content',e.target.value)}/></div><div><h3>版本 {comparison.revision}</h3><pre>{comparison.content}</pre></div></div><p className="muted">可在这里合并需要的正文,再更新保存基准;合并后自动保存。后续写入仍检查版本。</p>{comparison.revision===latest?.revision&&!comparison.deleted&&<button onClick={async()=>{if(await confirm('确认已将需要的内容合并到本地正文?将以这版作为保存基准,后续更新仍会检查冲突。')){rebase(comparison);setComparison(null);}}}>已合并,更新保存基准</button>}<button disabled={busy||phase==='saving'} onClick={reload}>放弃草稿并读取最新</button></section>}
|
|
142
|
+
</div>
|
|
143
|
+
<footer className="savebar auto-savebar"><div role="status" className={cacheError||saveError||conflict?'error-text':'muted'}>{cacheError||saveError||(base.deleted?'已删除章节':!draft.title.trim()?'请填写章节名称':conflict?'待合并 · 草稿已保留':phase==='saving'?'保存中…':dirty?'等待自动保存…':syncNotice||'已保存')}<span title="含标点、不计空白">{countManuscript(draft.content).toLocaleString('zh-CN')} 字</span></div>{phase==='error'&&<button onClick={save}>重试保存</button>}</footer>
|
|
144
|
+
</div>;
|
|
145
|
+
}
|