dsh-cuigengji-plugin 0.4.2 → 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 +15 -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 +35 -11
- 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 +45 -10
- 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 +19 -14
- package/package.json +19 -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,81 +1,81 @@
|
|
|
1
|
-
import {OverviewPage,DetailPage,DetailActions,useDetailTrail} from '../../shared/detail-page.tsx';
|
|
2
|
-
import {DocumentTextarea} from '../../shared/workspace.tsx';
|
|
3
|
-
import {CreateDocument} from '../../shared/creation.tsx';
|
|
4
|
-
import {RelationCanvas} from './RelationCanvas.tsx';
|
|
5
|
-
import {Modal,useWorkbenchPreferences} from '../../shared/preferences.tsx';
|
|
6
|
-
import {ViewSwitch,FilterControl,SearchControl} from '../../shared/workspace.tsx';
|
|
7
|
-
import {Groups, GroupSelect, filterGroup} from '../../shared/groups.tsx';
|
|
8
|
-
import {BatchManager,BatchList} from '../../shared/batch-management.tsx';
|
|
9
|
-
import React,{useEffect,useRef,useState} from 'react';
|
|
10
|
-
import {useDialog} from '../../dialog.tsx';
|
|
11
|
-
import {useDraft,useResource,usePreference} from '../../shared/state.js';
|
|
12
|
-
import {ResourceState,SaveBar,saveShortcut} from '../../shared/ui.jsx';
|
|
13
|
-
|
|
14
|
-
const kind=n=>n.type==='character_card'?'角色卡':'世界书';
|
|
1
|
+
import {OverviewPage,DetailPage,DetailActions,useDetailTrail} from '../../shared/detail-page.tsx';
|
|
2
|
+
import {DocumentTextarea} from '../../shared/workspace.tsx';
|
|
3
|
+
import {CreateDocument} from '../../shared/creation.tsx';
|
|
4
|
+
import {RelationCanvas} from './RelationCanvas.tsx';
|
|
5
|
+
import {Modal,useWorkbenchPreferences} from '../../shared/preferences.tsx';
|
|
6
|
+
import {ViewSwitch,FilterControl,SearchControl} from '../../shared/workspace.tsx';
|
|
7
|
+
import {Groups, GroupSelect, filterGroup} from '../../shared/groups.tsx';
|
|
8
|
+
import {BatchManager,BatchList} from '../../shared/batch-management.tsx';
|
|
9
|
+
import React,{useEffect,useRef,useState} from 'react';
|
|
10
|
+
import {useDialog} from '../../dialog.tsx';
|
|
11
|
+
import {useDraft,useResource,usePreference} from '../../shared/state.js';
|
|
12
|
+
import {ResourceState,SaveBar,saveShortcut} from '../../shared/ui.jsx';
|
|
13
|
+
|
|
14
|
+
const kind=n=>n.type==='character_card'?'角色卡':'世界书';
|
|
15
15
|
export function Memory({call,novelId,tick,run,busy,onTransfer}) {
|
|
16
16
|
const {preferences}=useWorkbenchPreferences();
|
|
17
17
|
const {confirm}=useDialog();
|
|
18
|
-
const [view,setView]=useState(preferences.memoryView),[batch,setBatch]=useState(false),[creating,setCreating]=useState(null);
|
|
19
|
-
const [selected,setSelected]=usePreference(`${novelId}:memory-selected`,null);
|
|
20
|
-
const [node,setNode]=useState(null),[edge,setEdge]=useState(null),[query,setQuery]=useState(''),[filter,setFilter]=useState(''),[group,setGroup]=useState(''),[checked,setChecked]=useState([]),[error,setError]=useState(''),[poll,setPoll]=useState(0);
|
|
21
|
-
const request=useRef(0),canvas=useRef(null),batchView=useRef(view);
|
|
22
|
-
const trail=useDetailTrail(node?.id||null);
|
|
23
|
-
const resource=useResource(()=>Promise.all([call('graph.list',{novelId,query,groupId:filterGroup(group)}),call('edge.list',{novelId}),call('graph.groups',{novelId})]),[call,novelId,tick,poll,query,group]);
|
|
24
|
-
const [nodes=[],edges=[],groups=[]]=resource.value||[];
|
|
25
|
-
useEffect(()=>{const timer=setInterval(()=>{if(!document.hidden)setPoll(v=>v+1);},5000);return()=>{clearInterval(timer);request.current++;};},[]);
|
|
26
|
-
const open=async (id,back=false)=>{const seq=++request.current;setError('');try{const data=await call('graph.get',{novelId,nodeId:id});if(seq===request.current){if(back)trail.back(id);else trail.visit(id);setNode(data);setSelected(id);setEdge(null);}}catch(e){if(seq===request.current)setError(e.message);}};
|
|
27
|
-
useEffect(()=>{if(selected)open(selected);},[]);
|
|
18
|
+
const [view,setView]=useState(preferences.memoryView),[batch,setBatch]=useState(false),[creating,setCreating]=useState(null);
|
|
19
|
+
const [selected,setSelected]=usePreference(`${novelId}:memory-selected`,null);
|
|
20
|
+
const [node,setNode]=useState(null),[edge,setEdge]=useState(null),[query,setQuery]=useState(''),[filter,setFilter]=useState(''),[group,setGroup]=useState(''),[checked,setChecked]=useState([]),[error,setError]=useState(''),[poll,setPoll]=useState(0);
|
|
21
|
+
const request=useRef(0),canvas=useRef(null),batchView=useRef(view);
|
|
22
|
+
const trail=useDetailTrail(node?.id||null);
|
|
23
|
+
const resource=useResource(()=>Promise.all([call('graph.list',{novelId,query,groupId:filterGroup(group)}),call('edge.list',{novelId}),call('graph.groups',{novelId})]),[call,novelId,tick,poll,query,group]);
|
|
24
|
+
const [nodes=[],edges=[],groups=[]]=resource.value||[];
|
|
25
|
+
useEffect(()=>{const timer=setInterval(()=>{if(!document.hidden)setPoll(v=>v+1);},5000);return()=>{clearInterval(timer);request.current++;};},[]);
|
|
26
|
+
const open=async (id,back=false)=>{const seq=++request.current;setError('');try{const data=await call('graph.get',{novelId,nodeId:id});if(seq===request.current){if(back)trail.back(id);else trail.visit(id);setNode(data);setSelected(id);setEdge(null);}}catch(e){if(seq===request.current)setError(e.message);}};
|
|
27
|
+
useEffect(()=>{if(selected)open(selected);},[]);
|
|
28
28
|
const create=(type,position,connection)=>setCreating({type,position:position||canvas.current?.(),connection:connection?{...connection,expectedRevision:allNodes.find(n=>n.id===connection.id)?.revision}:undefined});
|
|
29
|
-
const saved=n=>{if(n)trail.visit(n.id);else trail.clear();setNode(n);setSelected(n?.id||null);};
|
|
30
|
-
const all=useResource(()=>call('graph.list',{novelId}),[call,novelId,tick,poll]);
|
|
29
|
+
const saved=n=>{if(n)trail.visit(n.id);else trail.clear();setNode(n);setSelected(n?.id||null);};
|
|
30
|
+
const all=useResource(()=>call('graph.list',{novelId}),[call,novelId,tick,poll]);
|
|
31
31
|
const merged=new Map();for(const n of [...(all.value||[]),...nodes])if(!merged.has(n.id)||merged.get(n.id).revision<n.revision)merged.set(n.id,n);
|
|
32
32
|
const allNodes=[...merged.values()];
|
|
33
|
-
const related=edges.filter(e=>e.from===node?.id||e.to===node?.id);
|
|
34
|
-
const visible=nodes.filter(n=>!filter||n.type===filter);
|
|
35
|
-
const batchItems=visible.map(n=>({id:n.id,title:n.name,summary:n.summary,meta:[kind(n),groups.find(g=>g.id===n.groupId)?.name].filter(Boolean).join(' · ')}));
|
|
36
|
-
const beginBatch=()=>{batchView.current=view;setBatch(true);setChecked([]);setView('list');};
|
|
37
|
-
const endBatch=()=>{setBatch(false);setChecked([]);setView(batchView.current);};
|
|
38
|
-
const clear=()=>{setQuery('');setFilter('');setGroup('');};
|
|
39
|
-
const close=()=>{if(trail.previous){void open(trail.previous,true);return;}request.current++;trail.clear();setNode(null);setEdge(null);setSelected(null);};
|
|
40
|
-
const connect=(from,to)=>setEdge({from,to,name:'',content:''});
|
|
41
|
-
const connectDirect=(from,to)=>run(()=>call('edge.create',{novelId,from,to,name:'',content:'',requestId:crypto.randomUUID()}));
|
|
33
|
+
const related=edges.filter(e=>e.from===node?.id||e.to===node?.id);
|
|
34
|
+
const visible=nodes.filter(n=>!filter||n.type===filter);
|
|
35
|
+
const batchItems=visible.map(n=>({id:n.id,title:n.name,summary:n.summary,meta:[kind(n),groups.find(g=>g.id===n.groupId)?.name].filter(Boolean).join(' · ')}));
|
|
36
|
+
const beginBatch=()=>{batchView.current=view;setBatch(true);setChecked([]);setView('list');};
|
|
37
|
+
const endBatch=()=>{setBatch(false);setChecked([]);setView(batchView.current);};
|
|
38
|
+
const clear=()=>{setQuery('');setFilter('');setGroup('');};
|
|
39
|
+
const close=()=>{if(trail.previous){void open(trail.previous,true);return;}request.current++;trail.clear();setNode(null);setEdge(null);setSelected(null);};
|
|
40
|
+
const connect=(from,to)=>setEdge({from,to,name:'',content:''});
|
|
41
|
+
const connectDirect=(from,to)=>run(()=>call('edge.create',{novelId,from,to,name:'',content:'',requestId:crypto.randomUUID()}));
|
|
42
42
|
const saveLayout=(points,revisions={},requestId=crypto.randomUUID())=>run(()=>call('graph.layout',{novelId,requestId,positions:Object.entries(points).map(([nodeId,position])=>({nodeId,position,expectedRevision:revisions[nodeId]??allNodes.find(n=>n.id===nodeId)?.revision}))}));
|
|
43
|
-
const disconnect=items=>run(()=>call('edge.disconnect',{novelId,requestId:crypto.randomUUID(),confirm:true,edges:items.map(e=>({id:e.id,expectedRevision:e.revision}))}));
|
|
44
|
-
const removeNodes=async ids=>{if(ids.length&&!busy&&await confirm(`删除选中的 ${ids.length} 张设定卡片及其关系?`))await run(()=>call('graph.remove',{novelId,requestId:crypto.randomUUID(),confirm:true,members:ids.map(id=>({id,expectedRevision:allNodes.find(n=>n.id===id)?.revision}))}));};
|
|
43
|
+
const disconnect=items=>run(()=>call('edge.disconnect',{novelId,requestId:crypto.randomUUID(),confirm:true,edges:items.map(e=>({id:e.id,expectedRevision:e.revision}))}));
|
|
44
|
+
const removeNodes=async ids=>{if(ids.length&&!busy&&await confirm(`删除选中的 ${ids.length} 张设定卡片及其关系?`))await run(()=>call('graph.remove',{novelId,requestId:crypto.randomUUID(),confirm:true,members:ids.map(id=>({id,expectedRevision:allNodes.find(n=>n.id===id)?.revision}))}));};
|
|
45
45
|
const duplicateNodes=(ids,points={})=>run(()=>call('graph.duplicate',{novelId,requestId:crypto.randomUUID(),members:ids.map(id=>({id,expectedRevision:allNodes.find(n=>n.id===id)?.revision,...(points[id]?{position:points[id]}:{})}))}));
|
|
46
|
-
const moveNodes=(ids,groupId)=>run(()=>call('graph.move',{novelId,requestId:crypto.randomUUID(),groupId,members:ids.map(id=>({id,expectedRevision:allNodes.find(n=>n.id===id)?.revision}))}));
|
|
47
|
-
if(creating)return <CreateDocument draftKey={`${novelId}:memory-create:${creating.type}:${creating.connection?.id||'new'}`} label={creating.type==='character_card'?'新建角色卡':'新建世界书'} context={creating.connection?'创建后自动连接到原设定':undefined} initial={{title:'',summary:'',content:'',groupId:filterGroup(group)||allNodes.find(n=>n.id===creating.connection?.id)?.groupId||null}} {...{groups,busy}} close={()=>setCreating(null)} createGroup={async name=>{let result;await run(async()=>{result=await call('graph.group.create',{novelId,name});});return result;}} save={(value,requestId)=>run(async()=>{const fields={type:creating.type,name:value.title,summary:value.summary,content:value.content,groupId:value.groupId,...(creating.position?{position:creating.position}:{})};const result=creating.connection?await call('graph.continue',{novelId,requestId,nodeId:creating.connection.id,expectedRevision:creating.connection.expectedRevision,side:creating.connection.side,value:fields}):await call('graph.create',{novelId,requestId,...fields});saved(result.node||result);})}/>;
|
|
48
|
-
return <div className="memory-workspace"><OverviewPage active={!node}><header className="module-heading canvas-heading"><div className="module-toolbar">
|
|
49
|
-
<Groups {...{groups,busy,call,run,novelId}} prefix="graph" value={group} onChange={value=>{setGroup(value);setChecked([]);}}/>
|
|
50
|
-
<FilterControl label="类型" value={filter} change={setFilter}><option value="">全部类型</option><option value="character_card">角色卡</option><option value="world_entry">世界书</option></FilterControl>
|
|
51
|
-
<span className="toolbar-spacer"/><SearchControl label="搜索设定" value={query} change={setQuery}/>{!batch&&<><ViewSwitch value={view} change={setView} graph="画布"/>
|
|
52
|
-
<button className="module-select" aria-label="多选" disabled={busy} onClick={beginBatch}>多选</button><details className="menu"><summary className="primary">+ 设定</summary><div className="menu-panel"><button disabled={busy} onClick={()=>create('character_card')}>新建角色卡</button><button disabled={busy} onClick={()=>create('world_entry')}>新建世界书</button></div></details>
|
|
53
|
-
<details className="menu"><summary aria-label="设定更多操作">···</summary><div className="menu-panel"><button disabled={busy} onClick={()=>onTransfer({mode:'import',selection:{kind:'memory',novelId}})}>导入设定</button><button disabled={busy} onClick={()=>onTransfer({mode:'export',selection:{kind:'memory',novelId,nodeIds:visible.map(n=>n.id)}})}>导出当前设定范围</button></div></details></>}
|
|
54
|
-
</div>{(query||group||filter)&&<div className="active-filters"><button onClick={clear}>清除筛选 <span aria-hidden="true">×</span></button></div>}</header>
|
|
55
|
-
<div className="memory-layout"><div className="memory-overview">{batch&&<BatchManager items={batchItems} selected={checked} change={setChecked} groups={groups} busy={busy||resource.loading} done={endBatch} onMove={(ids,groupId)=>run(async()=>{await call('graph.move',{novelId,groupId,members:ids.map(id=>({id,expectedRevision:allNodes.find(n=>n.id===id)?.revision}))});setChecked([]);})}/>}<ResourceState resource={resource}>
|
|
56
|
-
{batch?<BatchList items={batchItems} selected={checked} change={setChecked} busy={busy||resource.loading}/>:view==='canvas'?<RelationCanvas key={novelId} scopeKey={`${novelId}:settings`} nodes={visible} allNodes={allNodes} edges={edges} groups={groups} busy={busy} onReady={center=>{canvas.current=center;}} onNew={(position,connection,type)=>create(type||allNodes.find(n=>n.id===connection?.id)?.type||'character_card',position,connection)} onSelect={open} onConnect={connectDirect} onLayout={saveLayout} onEdit={setEdge} onDelete={disconnect} onRemove={removeNodes} onDuplicate={duplicateNodes} onMove={moveNodes}/>:!visible.length?<div className="empty">{query||filter||group?'没有匹配的设定,试试调整筛选。':'添加第一张角色卡或世界书。'}</div>:<div className="library-list">{visible.map(n=><div className="selectable-entry" key={n.id}><button className={`library-entry ${trail.lastOpened===n.id?'selected':''}`} aria-current={trail.lastOpened===n.id?'true':undefined} onClick={()=>open(n.id)}><span className="entry-heading"><strong>{n.name}</strong><small>{kind(n)}</small></span>{n.summary&&<p>{n.summary}</p>}{n.groupId&&<small>{groups.find(g=>g.id===n.groupId)?.name}</small>}</button></div>)}</div>}</ResourceState></div></div></OverviewPage>
|
|
57
|
-
{error&&<p className="notice error" role="alert">{error}</p>}
|
|
58
|
-
{node&&<DetailPage key={node.id} className="memory-detail" backLabel={trail.previous?'‹ 返回上一条':'‹ 返回设定'} onBack={close}><NodeEditor key={node.id||node.type} {...{call,novelId,run,busy,saved,groups,onTransfer}} initial={node} latest={allNodes.find(n=>n.id===node.id)}/>
|
|
59
|
-
{node.id&&<section className="section-fold"><div className="row"><h3 className="grow">相关设定</h3><button disabled={busy||allNodes.length<2} onClick={()=>connect(node.id,allNodes.find(n=>n.id!==node.id)?.id)}>添加关系</button></div>{related.map(e=><div key={e.id} className="related-entry"><button onClick={()=>open(e.from===node.id?e.to:e.from)}>{allNodes.find(n=>n.id===(e.from===node.id?e.to:e.from))?.name||'设定'}</button>{e.name&&<small>{e.name}</small>}{e.content&&<p>{e.content}</p>}<button aria-label={`修改关系 ${e.name||'连线'}`} onClick={()=>setEdge(e)}>···</button></div>)}</section>}
|
|
60
|
-
</DetailPage>}{edge&&<Modal title={edge.id?'编辑关系':'添加关系'} close={()=>setEdge(null)}><EdgeEditor key={edge.id||`new:${edge.from}:${edge.to}`} {...{call,novelId,run,busy}} nodes={allNodes} initial={edge} saved={()=>setEdge(null)}/></Modal>}</div>;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
function NodeEditor({call,novelId,run,busy,initial,latest,saved,groups,onTransfer}) {
|
|
64
|
-
const {value,base,change,accept,dirty,cacheError}=useDraft(`${novelId}:node:${initial.id||initial.type+':new'}`,initial);
|
|
65
|
-
const fresh=useResource(()=>initial.id?call('graph.get',{novelId,nodeId:initial.id}):Promise.resolve(null),[call,novelId,initial.id,latest?.revision]);
|
|
66
|
-
useEffect(()=>{if(!busy&&!dirty&&fresh.value?.revision>base.revision){accept(fresh.value);saved(fresh.value);}},[fresh.value,busy,dirty,base.revision]);
|
|
67
|
-
const [editing,setEditing]=useState(!initial.id||dirty);const {confirm,ask}=useDialog();
|
|
68
|
-
const set=(key,v)=>change(old=>({...old,[key]:v}));
|
|
69
|
-
const save=()=>{if(!value.name.trim()||busy)return;run(async()=>{const result=await call(value.id?'graph.update':'graph.create',{novelId,nodeId:value.id,expectedRevision:base.revision,type:value.type==='character_card'?'character_card':'world_entry',groupId:value.groupId||null,name:value.name,summary:value.summary||'',content:value.content||''});accept(result);saved(result);setEditing(false);});};
|
|
70
|
-
return <div onKeyDown={e=>saveShortcut(e,save)}><h2>{value.name||`新${kind(value)}`}</h2><DetailActions><button onClick={()=>setEditing(!editing)}>{editing?'阅读':'编辑'}</button></DetailActions>{fresh.error&&<p className="notice error" role="alert">{fresh.error}</p>}{latest&&latest.revision!==base.revision&&<p className="notice">设定已有新版本,本地草稿仍保留。保存会检查冲突。</p>}
|
|
71
|
-
{editing?<fieldset disabled={busy} className="editor-fields document-fields"><label>类型<select value={value.type==='character_card'?'character_card':'world_entry'} onChange={e=>set('type',e.target.value)}><option value="character_card">角色卡</option><option value="world_entry">世界书</option></select></label><div className="creation-settings"><GroupSelect groups={groups} value={value.groupId} onChange={id=>set('groupId',id)}/><button type="button" disabled={busy} onClick={async()=>{const name=await ask('新建分组名称');if(name?.trim())run(async()=>{const g=await call('graph.group.create',{novelId,name});set('groupId',g.id);});}}>+ 新建分组</button></div><label>名称<input value={value.name} onChange={e=>set('name',e.target.value)}/></label><label>摘要<DocumentTextarea aria-label="摘要" value={value.summary||''} onChange={e=>set('summary',e.target.value)}/></label><label>全文<DocumentTextarea aria-label="全文" className="prose" value={value.content||''} onChange={e=>set('content',e.target.value)}/></label></fieldset>:<><p className="document-summary">{value.summary||'暂无摘要'}</p><article className="document-body">{value.content||'暂无内容'}</article></>}
|
|
72
|
-
{(editing||dirty)&&<SaveBar dirty={dirty||!value.id} {...{busy}} error={cacheError} invalid={!value.name.trim()?'请填写名称':null} onSave={save} label="保存设定"/>}
|
|
73
|
-
{value.id&&<details className="section-fold"><summary>更多操作</summary><button disabled={busy||dirty} title={dirty?'先保存设定后导出':undefined} onClick={()=>onTransfer({mode:'export',selection:{kind:'memory',novelId,nodeIds:[value.id]}})}>导出这条设定</button><button disabled={busy} onClick={async()=>{if(!dirty||await confirm('放弃本地草稿并读取最新设定?'))run(async()=>{const n=await call('graph.get',{novelId,nodeId:value.id});accept(n);saved(n);});}}>读取最新</button><button className="danger" disabled={busy} onClick={async()=>{if(await confirm(`删除“${value.name}”及其关系?`))run(async()=>{await call('graph.delete',{novelId,nodeId:value.id,expectedRevision:base.revision,confirm:true});accept(initial);saved(null);});}}>删除设定</button></details>}
|
|
74
|
-
</div>;
|
|
75
|
-
}
|
|
76
|
-
function EdgeEditor({call,novelId,nodes,run,busy,initial,saved}) {
|
|
77
|
-
const {value:draft,base,change,accept,dirty,cacheError}=useDraft(`${novelId}:edge:${initial.id||`new:${initial.from}:${initial.to}`}`,initial);
|
|
78
|
-
const {confirm,ask}=useDialog();
|
|
79
|
-
const save=()=>{if(draft.from&&draft.to&&!busy)run(async()=>{const e=await call(draft.id?'edge.update':'edge.create',{from:draft.from,to:draft.to,name:draft.name,content:draft.content,novelId,edgeId:draft.id,expectedRevision:base.revision});accept(e);saved(e);});};
|
|
80
|
-
return <div onKeyDown={e=>saveShortcut(e,save)}><fieldset disabled={busy} className="editor-fields">{[['from','从'],['to','到']].map(([key,label])=><label key={key}>{label}<select value={draft[key]} onChange={e=>change(d=>({...d,[key]:e.target.value}))}>{nodes.map(n=><option key={n.id} value={n.id}>{n.name}</option>)}</select></label>)}<label>关系名称(可选)<input placeholder="例如:居住于、朋友、敌对" value={draft.name||''} onChange={e=>change(d=>({...d,name:e.target.value}))}/></label><label>说明<DocumentTextarea value={draft.content||''} onChange={e=>change(d=>({...d,content:e.target.value}))}/></label></fieldset><SaveBar dirty={dirty||!draft.id} {...{busy}} error={cacheError} invalid={!draft.from||!draft.to?"请选择关系端点":null} onSave={save} label="保存关系"/>{draft.id&&<details><summary>更多操作</summary><button disabled={busy} onClick={async()=>{if(!dirty||await confirm("丢弃关系草稿并读取最新版本?"))run(async()=>{const latest=await call("edge.get",{novelId,edgeId:draft.id});accept(latest);saved(latest);});}}>读取最新关系</button><button disabled={busy} className="danger" onClick={async()=>{if(await confirm('删除这条关系?'))run(async()=>{await call('edge.delete',{novelId,edgeId:draft.id,expectedRevision:base.revision,confirm:true});accept(initial);saved(null);});}}>删除关系</button></details>}</div>;
|
|
81
|
-
}
|
|
46
|
+
const moveNodes=(ids,groupId)=>run(()=>call('graph.move',{novelId,requestId:crypto.randomUUID(),groupId,members:ids.map(id=>({id,expectedRevision:allNodes.find(n=>n.id===id)?.revision}))}));
|
|
47
|
+
if(creating)return <CreateDocument draftKey={`${novelId}:memory-create:${creating.type}:${creating.connection?.id||'new'}`} label={creating.type==='character_card'?'新建角色卡':'新建世界书'} context={creating.connection?'创建后自动连接到原设定':undefined} initial={{title:'',summary:'',content:'',groupId:filterGroup(group)||allNodes.find(n=>n.id===creating.connection?.id)?.groupId||null}} {...{groups,busy}} close={()=>setCreating(null)} createGroup={async name=>{let result;await run(async()=>{result=await call('graph.group.create',{novelId,name});});return result;}} save={(value,requestId)=>run(async()=>{const fields={type:creating.type,name:value.title,summary:value.summary,content:value.content,groupId:value.groupId,...(creating.position?{position:creating.position}:{})};const result=creating.connection?await call('graph.continue',{novelId,requestId,nodeId:creating.connection.id,expectedRevision:creating.connection.expectedRevision,side:creating.connection.side,value:fields}):await call('graph.create',{novelId,requestId,...fields});saved(result.node||result);})}/>;
|
|
48
|
+
return <div className="memory-workspace"><OverviewPage active={!node}><header className="module-heading canvas-heading"><div className="module-toolbar">
|
|
49
|
+
<Groups {...{groups,busy,call,run,novelId}} prefix="graph" value={group} onChange={value=>{setGroup(value);setChecked([]);}}/>
|
|
50
|
+
<FilterControl label="类型" value={filter} change={setFilter}><option value="">全部类型</option><option value="character_card">角色卡</option><option value="world_entry">世界书</option></FilterControl>
|
|
51
|
+
<span className="toolbar-spacer"/><SearchControl label="搜索设定" value={query} change={setQuery}/>{!batch&&<><ViewSwitch value={view} change={setView} graph="画布"/>
|
|
52
|
+
<button className="module-select" aria-label="多选" disabled={busy} onClick={beginBatch}>多选</button><details className="menu"><summary className="primary">+ 设定</summary><div className="menu-panel"><button disabled={busy} onClick={()=>create('character_card')}>新建角色卡</button><button disabled={busy} onClick={()=>create('world_entry')}>新建世界书</button></div></details>
|
|
53
|
+
<details className="menu"><summary aria-label="设定更多操作">···</summary><div className="menu-panel"><button disabled={busy} onClick={()=>onTransfer({mode:'import',selection:{kind:'memory',novelId}})}>导入设定</button><button disabled={busy} onClick={()=>onTransfer({mode:'export',selection:{kind:'memory',novelId,nodeIds:visible.map(n=>n.id)}})}>导出当前设定范围</button></div></details></>}
|
|
54
|
+
</div>{(query||group||filter)&&<div className="active-filters"><button onClick={clear}>清除筛选 <span aria-hidden="true">×</span></button></div>}</header>
|
|
55
|
+
<div className="memory-layout"><div className="memory-overview">{batch&&<BatchManager items={batchItems} selected={checked} change={setChecked} groups={groups} busy={busy||resource.loading} done={endBatch} onMove={(ids,groupId)=>run(async()=>{await call('graph.move',{novelId,groupId,members:ids.map(id=>({id,expectedRevision:allNodes.find(n=>n.id===id)?.revision}))});setChecked([]);})}/>}<ResourceState resource={resource}>
|
|
56
|
+
{batch?<BatchList items={batchItems} selected={checked} change={setChecked} busy={busy||resource.loading}/>:view==='canvas'?<RelationCanvas key={novelId} scopeKey={`${novelId}:settings`} nodes={visible} allNodes={allNodes} edges={edges} groups={groups} busy={busy} onReady={center=>{canvas.current=center;}} onNew={(position,connection,type)=>create(type||allNodes.find(n=>n.id===connection?.id)?.type||'character_card',position,connection)} onSelect={open} onConnect={connectDirect} onLayout={saveLayout} onEdit={setEdge} onDelete={disconnect} onRemove={removeNodes} onDuplicate={duplicateNodes} onMove={moveNodes}/>:!visible.length?<div className="empty">{query||filter||group?'没有匹配的设定,试试调整筛选。':'添加第一张角色卡或世界书。'}</div>:<div className="library-list">{visible.map(n=><div className="selectable-entry" key={n.id}><button className={`library-entry ${trail.lastOpened===n.id?'selected':''}`} aria-current={trail.lastOpened===n.id?'true':undefined} onClick={()=>open(n.id)}><span className="entry-heading"><strong>{n.name}</strong><small>{kind(n)}</small></span>{n.summary&&<p>{n.summary}</p>}{n.groupId&&<small>{groups.find(g=>g.id===n.groupId)?.name}</small>}</button></div>)}</div>}</ResourceState></div></div></OverviewPage>
|
|
57
|
+
{error&&<p className="notice error" role="alert">{error}</p>}
|
|
58
|
+
{node&&<DetailPage key={node.id} className="memory-detail" backLabel={trail.previous?'‹ 返回上一条':'‹ 返回设定'} onBack={close}><NodeEditor key={node.id||node.type} {...{call,novelId,run,busy,saved,groups,onTransfer}} initial={node} latest={allNodes.find(n=>n.id===node.id)}/>
|
|
59
|
+
{node.id&&<section className="section-fold"><div className="row"><h3 className="grow">相关设定</h3><button disabled={busy||allNodes.length<2} onClick={()=>connect(node.id,allNodes.find(n=>n.id!==node.id)?.id)}>添加关系</button></div>{related.map(e=><div key={e.id} className="related-entry"><button onClick={()=>open(e.from===node.id?e.to:e.from)}>{allNodes.find(n=>n.id===(e.from===node.id?e.to:e.from))?.name||'设定'}</button>{e.name&&<small>{e.name}</small>}{e.content&&<p>{e.content}</p>}<button aria-label={`修改关系 ${e.name||'连线'}`} onClick={()=>setEdge(e)}>···</button></div>)}</section>}
|
|
60
|
+
</DetailPage>}{edge&&<Modal title={edge.id?'编辑关系':'添加关系'} close={()=>setEdge(null)}><EdgeEditor key={edge.id||`new:${edge.from}:${edge.to}`} {...{call,novelId,run,busy}} nodes={allNodes} initial={edge} saved={()=>setEdge(null)}/></Modal>}</div>;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function NodeEditor({call,novelId,run,busy,initial,latest,saved,groups,onTransfer}) {
|
|
64
|
+
const {value,base,change,accept,dirty,cacheError}=useDraft(`${novelId}:node:${initial.id||initial.type+':new'}`,initial);
|
|
65
|
+
const fresh=useResource(()=>initial.id?call('graph.get',{novelId,nodeId:initial.id}):Promise.resolve(null),[call,novelId,initial.id,latest?.revision]);
|
|
66
|
+
useEffect(()=>{if(!busy&&!dirty&&fresh.value?.revision>base.revision){accept(fresh.value);saved(fresh.value);}},[fresh.value,busy,dirty,base.revision]);
|
|
67
|
+
const [editing,setEditing]=useState(!initial.id||dirty);const {confirm,ask}=useDialog();
|
|
68
|
+
const set=(key,v)=>change(old=>({...old,[key]:v}));
|
|
69
|
+
const save=()=>{if(!value.name.trim()||busy)return;run(async()=>{const result=await call(value.id?'graph.update':'graph.create',{novelId,nodeId:value.id,expectedRevision:base.revision,type:value.type==='character_card'?'character_card':'world_entry',groupId:value.groupId||null,name:value.name,summary:value.summary||'',content:value.content||''});accept(result);saved(result);setEditing(false);});};
|
|
70
|
+
return <div onKeyDown={e=>saveShortcut(e,save)}><h2>{value.name||`新${kind(value)}`}</h2><DetailActions><button onClick={()=>setEditing(!editing)}>{editing?'阅读':'编辑'}</button></DetailActions>{fresh.error&&<p className="notice error" role="alert">{fresh.error}</p>}{latest&&latest.revision!==base.revision&&<p className="notice">设定已有新版本,本地草稿仍保留。保存会检查冲突。</p>}
|
|
71
|
+
{editing?<fieldset disabled={busy} className="editor-fields document-fields"><label>类型<select value={value.type==='character_card'?'character_card':'world_entry'} onChange={e=>set('type',e.target.value)}><option value="character_card">角色卡</option><option value="world_entry">世界书</option></select></label><div className="creation-settings"><GroupSelect groups={groups} value={value.groupId} onChange={id=>set('groupId',id)}/><button type="button" disabled={busy} onClick={async()=>{const name=await ask('新建分组名称');if(name?.trim())run(async()=>{const g=await call('graph.group.create',{novelId,name});set('groupId',g.id);});}}>+ 新建分组</button></div><label>名称<input value={value.name} onChange={e=>set('name',e.target.value)}/></label><label>摘要<DocumentTextarea aria-label="摘要" value={value.summary||''} onChange={e=>set('summary',e.target.value)}/></label><label>全文<DocumentTextarea aria-label="全文" className="prose" value={value.content||''} onChange={e=>set('content',e.target.value)}/></label></fieldset>:<><p className="document-summary">{value.summary||'暂无摘要'}</p><article className="document-body">{value.content||'暂无内容'}</article></>}
|
|
72
|
+
{(editing||dirty)&&<SaveBar dirty={dirty||!value.id} {...{busy}} error={cacheError} invalid={!value.name.trim()?'请填写名称':null} onSave={save} label="保存设定"/>}
|
|
73
|
+
{value.id&&<details className="section-fold"><summary>更多操作</summary><button disabled={busy||dirty} title={dirty?'先保存设定后导出':undefined} onClick={()=>onTransfer({mode:'export',selection:{kind:'memory',novelId,nodeIds:[value.id]}})}>导出这条设定</button><button disabled={busy} onClick={async()=>{if(!dirty||await confirm('放弃本地草稿并读取最新设定?'))run(async()=>{const n=await call('graph.get',{novelId,nodeId:value.id});accept(n);saved(n);});}}>读取最新</button><button className="danger" disabled={busy} onClick={async()=>{if(await confirm(`删除“${value.name}”及其关系?`))run(async()=>{await call('graph.delete',{novelId,nodeId:value.id,expectedRevision:base.revision,confirm:true});accept(initial);saved(null);});}}>删除设定</button></details>}
|
|
74
|
+
</div>;
|
|
75
|
+
}
|
|
76
|
+
function EdgeEditor({call,novelId,nodes,run,busy,initial,saved}) {
|
|
77
|
+
const {value:draft,base,change,accept,dirty,cacheError}=useDraft(`${novelId}:edge:${initial.id||`new:${initial.from}:${initial.to}`}`,initial);
|
|
78
|
+
const {confirm,ask}=useDialog();
|
|
79
|
+
const save=()=>{if(draft.from&&draft.to&&!busy)run(async()=>{const e=await call(draft.id?'edge.update':'edge.create',{from:draft.from,to:draft.to,name:draft.name,content:draft.content,novelId,edgeId:draft.id,expectedRevision:base.revision});accept(e);saved(e);});};
|
|
80
|
+
return <div onKeyDown={e=>saveShortcut(e,save)}><fieldset disabled={busy} className="editor-fields">{[['from','从'],['to','到']].map(([key,label])=><label key={key}>{label}<select value={draft[key]} onChange={e=>change(d=>({...d,[key]:e.target.value}))}>{nodes.map(n=><option key={n.id} value={n.id}>{n.name}</option>)}</select></label>)}<label>关系名称(可选)<input placeholder="例如:居住于、朋友、敌对" value={draft.name||''} onChange={e=>change(d=>({...d,name:e.target.value}))}/></label><label>说明<DocumentTextarea value={draft.content||''} onChange={e=>change(d=>({...d,content:e.target.value}))}/></label></fieldset><SaveBar dirty={dirty||!draft.id} {...{busy}} error={cacheError} invalid={!draft.from||!draft.to?"请选择关系端点":null} onSave={save} label="保存关系"/>{draft.id&&<details><summary>更多操作</summary><button disabled={busy} onClick={async()=>{if(!dirty||await confirm("丢弃关系草稿并读取最新版本?"))run(async()=>{const latest=await call("edge.get",{novelId,edgeId:draft.id});accept(latest);saved(latest);});}}>读取最新关系</button><button disabled={busy} className="danger" onClick={async()=>{if(await confirm('删除这条关系?'))run(async()=>{await call('edge.delete',{novelId,edgeId:draft.id,expectedRevision:base.revision,confirm:true});accept(initial);saved(null);});}}>删除关系</button></details>}</div>;
|
|
81
|
+
}
|
|
@@ -32,4 +32,4 @@ export function DecorationCard({value,point,zoom,busy,selected,dragProps,edit,re
|
|
|
32
32
|
<div className="decoration-content" onDoubleClick={edit}>{value.content}</div>
|
|
33
33
|
<button className="decoration-resize" aria-label={`调整批注尺寸 ${value.title||'未命名批注'}`} title="拖动调整大小" disabled={busy} onClick={e=>e.stopPropagation()} onPointerDown={e=>{if(e.button!==0)return;e.stopPropagation();e.preventDefault();gesture.current={x:e.clientX,y:e.clientY,width:size.width,height:size.height,zoom,pointer:e.pointerId,value,size};root.current?.closest<HTMLElement>('.planning-canvas-scroll')?.setAttribute('data-resizing','true');e.currentTarget.setPointerCapture(e.pointerId);}} onPointerMove={e=>{const g=gesture.current;if(!g||g.pointer!==e.pointerId)return;e.stopPropagation();g.size={width:Math.max(160,Math.min(6000,g.width+(e.clientX-g.x)/g.zoom)),height:Math.max(80,Math.min(6000,g.height+(e.clientY-g.y)/g.zoom))};setSize(g.size);}} onPointerCancel={cancel} onPointerUp={async e=>{const g=gesture.current;if(!g||g.pointer!==e.pointerId)return;e.stopPropagation();gesture.current=null;unlock();if(g.size.width===g.width&&g.size.height===g.height)return;if(!await resize(g.value,g.size))setSize({width:value.width,height:value.height});}}>◢</button>
|
|
34
34
|
</section>;
|
|
35
|
-
}
|
|
35
|
+
}
|
|
@@ -1,13 +1,13 @@
|
|
|
1
|
-
import React,{useState} from 'react';
|
|
1
|
+
import React,{useState} from 'react';
|
|
2
2
|
import {Modal} from '../../shared/preferences.tsx';
|
|
3
3
|
import {FilterControl} from '../../shared/workspace.tsx';
|
|
4
|
-
import {Icon} from '../../shared/icons.tsx';
|
|
5
|
-
export interface PageIndex {id:string|null;name:string;summary:string;revision:number;nodeCount:number;decorationCount:number}
|
|
6
|
-
interface Props {pages:PageIndex[];value:string;busy:boolean;change:(id:string)=>void;create:(name:string,summary:string)=>Promise<boolean|undefined>;update:(page:PageIndex,name:string,summary:string)=>Promise<boolean|undefined>;remove:(page:PageIndex)=>Promise<boolean|undefined>}
|
|
7
|
-
export function PlanningPages({pages,value,busy,change,create,update,remove}:Props){
|
|
8
|
-
const [draft,setDraft]=useState<{page?:PageIndex;name:string;summary:string}|null>(null),[error,setError]=useState('');
|
|
9
|
-
const current=pages.find(p=>(p.id||'')===value);
|
|
10
|
-
return <><div className="planning-page-picker"><FilterControl label="规划页面" value={value} change={change}>{pages.map(p=><option key={p.id||'main'} value={p.id||''}>{p.name}</option>)}</FilterControl><button className="page-new" aria-label="新建规划页面" disabled={busy} onClick={()=>{setError('');setDraft({name:'',summary:''});}}>+ 页面</button>{current?.id&&<button className="page-settings" aria-label="规划页面设置" title="重命名或管理当前页面" disabled={busy} onClick={()=>{setError('');setDraft({page:current,name:current.name,summary:current.summary});}}><Icon name="edit"/></button>}</div>
|
|
11
|
-
{draft&&<Modal title={draft.page?'页面设置':'新建规划页面'} close={()=>setDraft(null)}><form className="editor-fields" onSubmit={async e=>{e.preventDefault();const ok=draft.page?await update(draft.page,draft.name,draft.summary):await create(draft.name,draft.summary);if(ok)setDraft(null);else setError('未保存,请核对提示后重试。');}}><label>页面名称<input autoFocus required value={draft.name} onChange={e=>setDraft({...draft,name:e.target.value})}/></label><label>用途摘要<textarea placeholder="这页讨论哪段情节,方便你和 AI 找到它" value={draft.summary} onChange={e=>setDraft({...draft,summary:e.target.value})}/></label>{error&&<p role="alert">{error}</p>}<div className="row"><button type="submit" className="primary" disabled={busy||!draft.name.trim()}>保存页面</button>{draft.page&&<button type="button" disabled={busy||draft.page.nodeCount>0||draft.page.decorationCount>0} title="只删除空页面;有内容时请先移动情节并删除批注" onClick={async()=>{if(await remove(draft.page!))setDraft(null);else setError('未删除,请核对页面是否仍有内容。');}}>删除空页面</button>}</div></form></Modal>}
|
|
12
|
-
</>;
|
|
13
|
-
}
|
|
4
|
+
import {Icon} from '../../shared/icons.tsx';
|
|
5
|
+
export interface PageIndex {id:string|null;name:string;summary:string;revision:number;nodeCount:number;decorationCount:number}
|
|
6
|
+
interface Props {pages:PageIndex[];value:string;busy:boolean;change:(id:string)=>void;create:(name:string,summary:string)=>Promise<boolean|undefined>;update:(page:PageIndex,name:string,summary:string)=>Promise<boolean|undefined>;remove:(page:PageIndex)=>Promise<boolean|undefined>}
|
|
7
|
+
export function PlanningPages({pages,value,busy,change,create,update,remove}:Props){
|
|
8
|
+
const [draft,setDraft]=useState<{page?:PageIndex;name:string;summary:string}|null>(null),[error,setError]=useState('');
|
|
9
|
+
const current=pages.find(p=>(p.id||'')===value);
|
|
10
|
+
return <><div className="planning-page-picker"><FilterControl label="规划页面" value={value} change={change}>{pages.map(p=><option key={p.id||'main'} value={p.id||''}>{p.name}</option>)}</FilterControl><button className="page-new" aria-label="新建规划页面" disabled={busy} onClick={()=>{setError('');setDraft({name:'',summary:''});}}>+ 页面</button>{current?.id&&<button className="page-settings" aria-label="规划页面设置" title="重命名或管理当前页面" disabled={busy} onClick={()=>{setError('');setDraft({page:current,name:current.name,summary:current.summary});}}><Icon name="edit"/></button>}</div>
|
|
11
|
+
{draft&&<Modal title={draft.page?'页面设置':'新建规划页面'} close={()=>setDraft(null)}><form className="editor-fields" onSubmit={async e=>{e.preventDefault();const ok=draft.page?await update(draft.page,draft.name,draft.summary):await create(draft.name,draft.summary);if(ok)setDraft(null);else setError('未保存,请核对提示后重试。');}}><label>页面名称<input autoFocus required value={draft.name} onChange={e=>setDraft({...draft,name:e.target.value})}/></label><label>用途摘要<textarea placeholder="这页讨论哪段情节,方便你和 AI 找到它" value={draft.summary} onChange={e=>setDraft({...draft,summary:e.target.value})}/></label>{error&&<p role="alert">{error}</p>}<div className="row"><button type="submit" className="primary" disabled={busy||!draft.name.trim()}>保存页面</button>{draft.page&&<button type="button" disabled={busy||draft.page.nodeCount>0||draft.page.decorationCount>0} title="只删除空页面;有内容时请先移动情节并删除批注" onClick={async()=>{if(await remove(draft.page!))setDraft(null);else setError('未删除,请核对页面是否仍有内容。');}}>删除空页面</button>}</div></form></Modal>}
|
|
12
|
+
</>;
|
|
13
|
+
}
|
|
@@ -1,16 +1,16 @@
|
|
|
1
|
-
// Keep a paginated overview on one board revision, without retrying forever during edits.
|
|
2
|
-
export async function readPlanningSnapshot(call,novelId,scope={}) {
|
|
3
|
-
let offset=0,items=[],result,sequence;
|
|
4
|
-
do {
|
|
5
|
-
result=await call('planning.list',{novelId,...scope,offset,limit:200});
|
|
6
|
-
if(sequence!==undefined&&result.sequence!==sequence)throw new Error('读取期间规划已更新,请重试。已有画布和草稿仍保留。');
|
|
7
|
-
sequence=result.sequence;items.push(...result.items);offset=result.nextOffset;
|
|
8
|
-
} while(offset!==null);
|
|
9
|
-
return {...result,items};
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
export async function readDecorations(call,novelId,pageId){
|
|
13
|
-
let offset=0,items=[],sequence;
|
|
14
|
-
do{const result=await call('planning.decorations',{novelId,pageId,offset,limit:200});if(sequence!==undefined&&sequence!==result.sequence)throw new Error('读取期间批注已更新,请重试');sequence=result.sequence;items.push(...result.items);offset=result.nextOffset;}while(offset!==null);
|
|
15
|
-
return items;
|
|
16
|
-
}
|
|
1
|
+
// Keep a paginated overview on one board revision, without retrying forever during edits.
|
|
2
|
+
export async function readPlanningSnapshot(call,novelId,scope={}) {
|
|
3
|
+
let offset=0,items=[],result,sequence;
|
|
4
|
+
do {
|
|
5
|
+
result=await call('planning.list',{novelId,...scope,offset,limit:200});
|
|
6
|
+
if(sequence!==undefined&&result.sequence!==sequence)throw new Error('读取期间规划已更新,请重试。已有画布和草稿仍保留。');
|
|
7
|
+
sequence=result.sequence;items.push(...result.items);offset=result.nextOffset;
|
|
8
|
+
} while(offset!==null);
|
|
9
|
+
return {...result,items};
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export async function readDecorations(call,novelId,pageId){
|
|
13
|
+
let offset=0,items=[],sequence;
|
|
14
|
+
do{const result=await call('planning.decorations',{novelId,pageId,offset,limit:200});if(sequence!==undefined&&sequence!==result.sequence)throw new Error('读取期间批注已更新,请重试');sequence=result.sequence;items.push(...result.items);offset=result.nextOffset;}while(offset!==null);
|
|
15
|
+
return items;
|
|
16
|
+
}
|
package/src/client/index.tsx
CHANGED
|
@@ -1,134 +1,134 @@
|
|
|
1
|
-
// The workbench is the typed migration boundary; legacy JSX modules are intentionally
|
|
2
|
-
// consumed without declaration generation while they are migrated incrementally.
|
|
3
|
-
// @ts-nocheck
|
|
4
|
-
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
|
5
|
-
import {PreferencesProvider, PreferencesDialog, AboutDialog, useWorkbenchPreferences} from './shared/preferences.tsx';
|
|
6
|
-
import { styles } from './style.ts';
|
|
7
|
-
import { DialogProvider, useDialog } from './dialog.tsx';
|
|
8
|
-
import { LocaleContext, registerLocale } from './locale.js';
|
|
9
|
-
import { Memory } from './features/memory/index.jsx';
|
|
1
|
+
// The workbench is the typed migration boundary; legacy JSX modules are intentionally
|
|
2
|
+
// consumed without declaration generation while they are migrated incrementally.
|
|
3
|
+
// @ts-nocheck
|
|
4
|
+
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
|
5
|
+
import {PreferencesProvider, PreferencesDialog, AboutDialog, useWorkbenchPreferences} from './shared/preferences.tsx';
|
|
6
|
+
import { styles } from './style.ts';
|
|
7
|
+
import { DialogProvider, useDialog } from './dialog.tsx';
|
|
8
|
+
import { LocaleContext, registerLocale } from './locale.js';
|
|
9
|
+
import { Memory } from './features/memory/index.jsx';
|
|
10
10
|
import { Manage } from './features/manage/index.tsx';
|
|
11
|
-
import { TransferPage } from './features/transfer/index.tsx';
|
|
12
|
-
import {Welcome} from './features/welcome/index.tsx';
|
|
13
|
-
import { Chapters } from './features/chapters/index.jsx';
|
|
14
|
-
import { Planning } from './features/planning/index.jsx';
|
|
11
|
+
import { TransferPage } from './features/transfer/index.tsx';
|
|
12
|
+
import {Welcome} from './features/welcome/index.tsx';
|
|
13
|
+
import { Chapters } from './features/chapters/index.jsx';
|
|
14
|
+
import { Planning } from './features/planning/index.jsx';
|
|
15
15
|
import { Preset } from './features/preset/index.tsx';
|
|
16
|
-
import { SessionScope, usePreference, useResource } from './shared/state.js';
|
|
17
|
-
import { ResourceState } from './shared/ui.jsx';
|
|
18
|
-
import { Launcher } from './launcher.tsx';
|
|
19
|
-
import {WorkbenchHeader} from './shared/WorkbenchHeader.tsx';
|
|
20
|
-
import {RefreshProvider, useWorkbenchRefresh} from './shared/refresh.tsx';
|
|
21
|
-
import {notifyLibraryChanged, subscribeLibraryChanges} from './shared/library-events.ts';
|
|
22
|
-
|
|
23
|
-
const ID = 'dsh-cuigengji-plugin';
|
|
24
|
-
const RPC_ENDPOINT = 'cuigengji/dispatch';
|
|
25
|
-
const message = error => error?.message || String(error);
|
|
26
|
-
export const inject = ['connection', 'slots', 'sidebarRight', 'sidebarRightTabs', 'locale', 'layout', 'uiWorkspace', 'workspaces'];
|
|
27
|
-
export function apply(ctx) {
|
|
28
|
-
const rpc = async (action, args, sessionId) => {
|
|
29
|
-
const result = await ctx.connection.rpc.call('/api', RPC_ENDPOINT, { action, args, sessionId });
|
|
30
|
-
if (!result.ok) throw Object.assign(new Error(result.error.message), { code: result.error.code });
|
|
31
|
-
return result.value;
|
|
32
|
-
};
|
|
33
|
-
return applyWithRPC(ctx, rpc);
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
export function applyWithRPC(ctx, rpc) {
|
|
37
|
-
const t = ctx.locale ? registerLocale(ctx) : key => ({ title: '催更姬', open: '打开小说目录、正文和设定' }[key]);
|
|
38
|
-
ctx.effect(() => ctx.sidebarRightTabs.register({
|
|
39
|
-
id: ID, kind: 'cuigengji', keepMounted: true,
|
|
40
|
-
patterns: ['dsh-resource://cuigengji/**'], title: () => t('title'),
|
|
41
|
-
}));
|
|
42
|
-
ctx.slots.inject('sidebar.footer.action', () => ctx.slots.register({
|
|
43
|
-
name: 'sidebar.footer.action', id: ID, order: 40,
|
|
44
|
-
inject: () => ({
|
|
45
|
-
hooks: {novelSession:ctx.sidebarRight.mounted,novelWorkspaces:ctx.workspaces.list},
|
|
46
|
-
openNovel: sessionId => ctx.sidebarRight.openResource(`dsh-resource://cuigengji/${encodeURIComponent(sessionId)}`),
|
|
47
|
-
revealConversation: () => ctx.layout.selectPanel(null),
|
|
48
|
-
openWorkspace: (id,beforeOpen) => ctx.uiWorkspace.openWorkspace(id,beforeOpen),
|
|
49
|
-
}),
|
|
50
|
-
}, Launcher));
|
|
51
|
-
ctx.slots.inject('sidebar.right.pane.tab', () => ctx.slots.register({
|
|
52
|
-
name: 'sidebar.right.pane.tab', key: ID,
|
|
53
|
-
}, props => {
|
|
54
|
-
const tab = props.useTabInfo();
|
|
55
|
-
const address = tab.tab.navigation.address;
|
|
56
|
-
const sessionId = decodeURIComponent(address.slice(address.lastIndexOf('/') + 1));
|
|
57
|
-
return <LocaleContext.Provider value={ctx.locale}><Workbench key={sessionId} sessionId={sessionId} rpc={rpc} /></LocaleContext.Provider>;
|
|
58
|
-
}));
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
export function Workbench(props) {
|
|
62
|
-
return <SessionScope.Provider value={props.sessionId}><RefreshProvider><PreferencesProvider><DialogProvider><WorkbenchContent {...props} /></DialogProvider></PreferencesProvider></RefreshProvider></SessionScope.Provider>;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
function WorkbenchContent({ sessionId, rpc }) {
|
|
66
|
-
const {preferences}=useWorkbenchPreferences();
|
|
67
|
-
const {refresh}=useWorkbenchRefresh();
|
|
68
|
-
const [utility,setUtility]=useState(null);
|
|
69
|
-
const [homeOpen,setHomeOpen]=useState(false);
|
|
16
|
+
import { SessionScope, usePreference, useResource } from './shared/state.js';
|
|
17
|
+
import { ResourceState } from './shared/ui.jsx';
|
|
18
|
+
import { Launcher } from './launcher.tsx';
|
|
19
|
+
import {WorkbenchHeader} from './shared/WorkbenchHeader.tsx';
|
|
20
|
+
import {RefreshProvider, useWorkbenchRefresh} from './shared/refresh.tsx';
|
|
21
|
+
import {notifyLibraryChanged, subscribeLibraryChanges} from './shared/library-events.ts';
|
|
22
|
+
|
|
23
|
+
const ID = 'dsh-cuigengji-plugin';
|
|
24
|
+
const RPC_ENDPOINT = 'cuigengji/dispatch';
|
|
25
|
+
const message = error => error?.message || String(error);
|
|
26
|
+
export const inject = ['connection', 'slots', 'sidebarRight', 'sidebarRightTabs', 'locale', 'layout', 'uiWorkspace', 'workspaces'];
|
|
27
|
+
export function apply(ctx) {
|
|
28
|
+
const rpc = async (action, args, sessionId) => {
|
|
29
|
+
const result = await ctx.connection.rpc.call('/api', RPC_ENDPOINT, { action, args, sessionId });
|
|
30
|
+
if (!result.ok) throw Object.assign(new Error(result.error.message), { code: result.error.code });
|
|
31
|
+
return result.value;
|
|
32
|
+
};
|
|
33
|
+
return applyWithRPC(ctx, rpc);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function applyWithRPC(ctx, rpc) {
|
|
37
|
+
const t = ctx.locale ? registerLocale(ctx) : key => ({ title: '催更姬', open: '打开小说目录、正文和设定' }[key]);
|
|
38
|
+
ctx.effect(() => ctx.sidebarRightTabs.register({
|
|
39
|
+
id: ID, kind: 'cuigengji', keepMounted: true,
|
|
40
|
+
patterns: ['dsh-resource://cuigengji/**'], title: () => t('title'),
|
|
41
|
+
}));
|
|
42
|
+
ctx.slots.inject('sidebar.footer.action', () => ctx.slots.register({
|
|
43
|
+
name: 'sidebar.footer.action', id: ID, order: 40,
|
|
44
|
+
inject: () => ({
|
|
45
|
+
hooks: {novelSession:ctx.sidebarRight.mounted,novelWorkspaces:ctx.workspaces.list},
|
|
46
|
+
openNovel: sessionId => ctx.sidebarRight.openResource(`dsh-resource://cuigengji/${encodeURIComponent(sessionId)}`),
|
|
47
|
+
revealConversation: () => ctx.layout.selectPanel(null),
|
|
48
|
+
openWorkspace: (id,beforeOpen) => ctx.uiWorkspace.openWorkspace(id,beforeOpen),
|
|
49
|
+
}),
|
|
50
|
+
}, Launcher));
|
|
51
|
+
ctx.slots.inject('sidebar.right.pane.tab', () => ctx.slots.register({
|
|
52
|
+
name: 'sidebar.right.pane.tab', key: ID,
|
|
53
|
+
}, props => {
|
|
54
|
+
const tab = props.useTabInfo();
|
|
55
|
+
const address = tab.tab.navigation.address;
|
|
56
|
+
const sessionId = decodeURIComponent(address.slice(address.lastIndexOf('/') + 1));
|
|
57
|
+
return <LocaleContext.Provider value={ctx.locale}><Workbench key={sessionId} sessionId={sessionId} rpc={rpc} /></LocaleContext.Provider>;
|
|
58
|
+
}));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function Workbench(props) {
|
|
62
|
+
return <SessionScope.Provider value={props.sessionId}><RefreshProvider><PreferencesProvider><DialogProvider><WorkbenchContent {...props} /></DialogProvider></PreferencesProvider></RefreshProvider></SessionScope.Provider>;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function WorkbenchContent({ sessionId, rpc }) {
|
|
66
|
+
const {preferences}=useWorkbenchPreferences();
|
|
67
|
+
const {refresh}=useWorkbenchRefresh();
|
|
68
|
+
const [utility,setUtility]=useState(null);
|
|
69
|
+
const [homeOpen,setHomeOpen]=useState(false);
|
|
70
70
|
const [tab, setTab] = usePreference('page', 'chapters');
|
|
71
71
|
const [managementReturn, setManagementReturn] = usePreference('management-return', 'chapters');
|
|
72
|
-
useEffect(()=>{if(tab==='context')setTab('chapters');},[tab]);
|
|
73
|
-
const [returnTarget,setReturnTarget]=useState(null);
|
|
74
|
-
const [transferInitial,setTransferInitial]=useState(null);
|
|
75
|
-
const [transferVisit,setTransferVisit]=useState(0);
|
|
76
|
-
const [manageInitial,setManageInitial]=useState('overview');
|
|
77
|
-
const [error, setError] = useState(''), [busy, setBusy] = useState(false), [tick, setTick] = useState(0);
|
|
78
|
-
const { ask, confirm } = useDialog();
|
|
79
|
-
const call = useCallback((action, args = {}) => rpc(action, args, sessionId), [rpc, sessionId]);
|
|
80
|
-
const resource = useResource(() => Promise.all([call('novel.list', {includeArchived:true}), call('binding.get')]), [call, tick]);
|
|
81
|
-
const [library = [], binding = null] = resource.value || [];
|
|
82
|
-
const books=library.filter(book=>!book.archived), recycledCount=library.length-books.length;
|
|
83
|
-
useEffect(()=>subscribeLibraryChanges(()=>setTick(value=>value+1)),[]);
|
|
84
|
-
const novelId = binding?.novelId, novel = books.find(n => n.id === novelId);
|
|
85
|
-
const showHome=homeOpen||(!novelId&&!['data','manage'].includes(tab));
|
|
86
|
-
useEffect(()=>{
|
|
87
|
-
const outside=event=>{document.querySelectorAll('.cuigengji details.menu[open]').forEach(menu=>{if(!menu.contains(event.target))menu.open=false;});};
|
|
88
|
-
const escape=event=>{if(event.key==='Escape'){document.querySelectorAll('.cuigengji details.menu[open]').forEach(menu=>{menu.open=false;menu.querySelector('summary')?.focus();});}};
|
|
89
|
-
const activate=event=>{const menu=event.target.closest?.('details.menu');if(menu&&event.target.closest('button'))menu.open=false;};
|
|
90
|
-
document.addEventListener('click',activate);document.addEventListener('pointerdown',outside);document.addEventListener('keydown',escape);
|
|
91
|
-
return()=>{document.removeEventListener('click',activate);document.removeEventListener('pointerdown',outside);document.removeEventListener('keydown',escape);};
|
|
92
|
-
},[]);
|
|
93
|
-
const running = useRef(false);
|
|
94
|
-
const run = async fn => {
|
|
95
|
-
if (running.current) return;
|
|
96
|
-
running.current = true;
|
|
97
|
-
setBusy(true); setError('');
|
|
98
|
-
try { await fn(); setTick(x => x + 1); return true; }
|
|
99
|
-
catch (e) { setError(message(e)); return false; }
|
|
100
|
-
finally { running.current = false; setBusy(false); }
|
|
101
|
-
};
|
|
102
|
-
const create = async () => { const title = await ask('作品名称'); if (title?.trim()) run(async () => { const n = await call('novel.create', { title }); await call('binding.set', { novelId: n.id }); setHomeOpen(false);setTab('chapters'); }); };
|
|
103
|
-
const openNovel=id=>run(async()=>{await call('binding.set',{novelId:id});setHomeOpen(false);setTab('chapters');setReturnTarget(null);});
|
|
104
|
-
const openManagement=(page,initial='overview')=>{if(homeOpen||!novelId)setManagementReturn('welcome');else if(['chapters','plan','memory'].includes(tab))setManagementReturn(tab);setHomeOpen(false);setReturnTarget(null);setManageInitial(initial);setTab(page);};
|
|
105
|
-
const recycleNovel=async book=>{
|
|
106
|
-
if(busy||!await confirm(`将《${book.title}》移入回收站?\n正文、章节历史、设定、规划和写作预设会完整保留,之后可以恢复。相关会话将解除作品绑定。`))return;
|
|
107
|
-
await run(async()=>{await call('novel.archive',{novelId:book.id,expectedRevision:book.revision,requestId:crypto.randomUUID(),confirm:true});notifyLibraryChanged();});
|
|
108
|
-
};
|
|
109
|
-
const openTransfer=initial=>{if(homeOpen||!novelId)setManagementReturn('welcome');else if(['chapters','plan','memory','preset','manage'].includes(tab))setManagementReturn(tab);setHomeOpen(false);setTransferInitial(initial);setTransferVisit(value=>value+1);setReturnTarget(null);setTab('data');};
|
|
110
|
-
const returnFromManagement=()=>{setReturnTarget(null);setHomeOpen(managementReturn==='welcome');setTab(['chapters','plan','memory','preset','manage'].includes(managementReturn)?managementReturn:'chapters');};
|
|
111
|
-
const managementBack={onBack:returnFromManagement,backLabel:managementReturn==='welcome'?'返回作品首页':novelId?'返回'+({plan:'规划',memory:'设定',chapters:'正文',preset:'写作预设',manage:'作品与备份'}[managementReturn]||'正文'):'返回作品选择'};
|
|
112
|
-
const welcomeProps={books,busy,recycledCount,onOpen:openNovel,onCreate:create,onImport:()=>openTransfer({mode:'import'}),onManage:()=>openManagement('manage'),onRecycle:recycleNovel,onTrash:()=>openManagement('manage','recycle')};
|
|
113
|
-
const manageProps={call,novelId,novel,run,busy,tick,initialView:manageInitial,onTransfer:openTransfer,...managementBack};
|
|
114
|
-
const jump=(page,key,id)=>{setReturnTarget(tab);try{localStorage.setItem(`cuigengji:ui:${sessionId}:${novelId}:${key}`,JSON.stringify(id));}catch{}setTab(page);};
|
|
115
|
-
return <section className="cuigengji" data-motion={preferences.motion} style={{'--prose-size':`${preferences.fontSize}px`,'--prose-leading':preferences.leading}} aria-label="催更姬小说工作台">
|
|
116
|
-
<style>{styles}</style>
|
|
117
|
-
<WorkbenchHeader home={showHome} novelTitle={novel?.title} tab={tab} busy={busy} refreshing={resource.loading} onHome={()=>{setHomeOpen(true);setReturnTarget(null);}} onTab={page=>{setReturnTarget(null);setTab(page);}} onRefresh={refresh} onSettings={()=>setUtility('settings')} onAbout={()=>setUtility('about')}/>
|
|
118
|
-
{error && <div className="notice error" role="alert">{error}<button onClick={()=>setError('')}>关闭提示</button></div>}
|
|
119
|
-
|
|
120
|
-
<ResourceState resource={resource}>
|
|
121
|
-
{homeOpen?<Welcome {...welcomeProps}/>:tab==='data'?<TransferPage key={transferVisit} {...{call,run,busy,novelId}} {...managementBack} initial={transferInitial} onOpen={openNovel}/>:<>
|
|
122
|
-
{!novelId ? (tab==='manage'?<Manage {...manageProps}/>:<Welcome {...welcomeProps}/>) : <div className="workbench-body" key={novelId}>{returnTarget&&<div className="return-strip"><button onClick={()=>{setTab(returnTarget);setReturnTarget(null);}}>‹ 返回{({plan:'规划',memory:'设定'})[returnTarget]||'上一页'}</button></div>}
|
|
123
|
-
{tab==='chapters' && <Chapters {...{call,novelId,tick,run,busy}} onTransfer={openTransfer}/>}
|
|
124
|
-
{tab==='memory' && <Memory {...{call,novelId,tick,run,busy}} onTransfer={openTransfer} onChapter={id=>jump('chapters','chapter',id)}/>}
|
|
125
|
-
{tab==='plan' && <Planning {...{call,novelId,tick,run,busy}} onTransfer={openTransfer} onMemory={id=>jump('memory','memory-selected',id)} onChapter={id=>jump('chapters','chapter',id)}/>}
|
|
126
|
-
{tab==='preset' && <Preset {...{call,novelId,run,busy}} {...managementBack} onTransfer={openTransfer}/>}
|
|
127
|
-
{tab==='manage' && <Manage {...manageProps}/>}
|
|
128
|
-
</div>}
|
|
129
|
-
</>}
|
|
130
|
-
</ResourceState>
|
|
131
|
-
{utility==='settings'&&<PreferencesDialog close={()=>setUtility(null)} novelTitle={novel?.title} onPreset={novelId?()=>{setUtility(null);openManagement('preset');}:undefined}/>}
|
|
132
|
-
{utility==='about'&&<AboutDialog close={()=>setUtility(null)}/>}
|
|
133
|
-
</section>;
|
|
134
|
-
}
|
|
72
|
+
useEffect(()=>{if(tab==='context')setTab('chapters');},[tab]);
|
|
73
|
+
const [returnTarget,setReturnTarget]=useState(null);
|
|
74
|
+
const [transferInitial,setTransferInitial]=useState(null);
|
|
75
|
+
const [transferVisit,setTransferVisit]=useState(0);
|
|
76
|
+
const [manageInitial,setManageInitial]=useState('overview');
|
|
77
|
+
const [error, setError] = useState(''), [busy, setBusy] = useState(false), [tick, setTick] = useState(0);
|
|
78
|
+
const { ask, confirm } = useDialog();
|
|
79
|
+
const call = useCallback((action, args = {}) => rpc(action, args, sessionId), [rpc, sessionId]);
|
|
80
|
+
const resource = useResource(() => Promise.all([call('novel.list', {includeArchived:true}), call('binding.get')]), [call, tick]);
|
|
81
|
+
const [library = [], binding = null] = resource.value || [];
|
|
82
|
+
const books=library.filter(book=>!book.archived), recycledCount=library.length-books.length;
|
|
83
|
+
useEffect(()=>subscribeLibraryChanges(()=>setTick(value=>value+1)),[]);
|
|
84
|
+
const novelId = binding?.novelId, novel = books.find(n => n.id === novelId);
|
|
85
|
+
const showHome=homeOpen||(!novelId&&!['data','manage'].includes(tab));
|
|
86
|
+
useEffect(()=>{
|
|
87
|
+
const outside=event=>{document.querySelectorAll('.cuigengji details.menu[open]').forEach(menu=>{if(!menu.contains(event.target))menu.open=false;});};
|
|
88
|
+
const escape=event=>{if(event.key==='Escape'){document.querySelectorAll('.cuigengji details.menu[open]').forEach(menu=>{menu.open=false;menu.querySelector('summary')?.focus();});}};
|
|
89
|
+
const activate=event=>{const menu=event.target.closest?.('details.menu');if(menu&&event.target.closest('button'))menu.open=false;};
|
|
90
|
+
document.addEventListener('click',activate);document.addEventListener('pointerdown',outside);document.addEventListener('keydown',escape);
|
|
91
|
+
return()=>{document.removeEventListener('click',activate);document.removeEventListener('pointerdown',outside);document.removeEventListener('keydown',escape);};
|
|
92
|
+
},[]);
|
|
93
|
+
const running = useRef(false);
|
|
94
|
+
const run = async fn => {
|
|
95
|
+
if (running.current) return;
|
|
96
|
+
running.current = true;
|
|
97
|
+
setBusy(true); setError('');
|
|
98
|
+
try { await fn(); setTick(x => x + 1); return true; }
|
|
99
|
+
catch (e) { setError(message(e)); return false; }
|
|
100
|
+
finally { running.current = false; setBusy(false); }
|
|
101
|
+
};
|
|
102
|
+
const create = async () => { const title = await ask('作品名称'); if (title?.trim()) run(async () => { const n = await call('novel.create', { title }); await call('binding.set', { novelId: n.id }); setHomeOpen(false);setTab('chapters'); }); };
|
|
103
|
+
const openNovel=id=>run(async()=>{await call('binding.set',{novelId:id});setHomeOpen(false);setTab('chapters');setReturnTarget(null);});
|
|
104
|
+
const openManagement=(page,initial='overview')=>{if(homeOpen||!novelId)setManagementReturn('welcome');else if(['chapters','plan','memory'].includes(tab))setManagementReturn(tab);setHomeOpen(false);setReturnTarget(null);setManageInitial(initial);setTab(page);};
|
|
105
|
+
const recycleNovel=async book=>{
|
|
106
|
+
if(busy||!await confirm(`将《${book.title}》移入回收站?\n正文、章节历史、设定、规划和写作预设会完整保留,之后可以恢复。相关会话将解除作品绑定。`))return;
|
|
107
|
+
await run(async()=>{await call('novel.archive',{novelId:book.id,expectedRevision:book.revision,requestId:crypto.randomUUID(),confirm:true});notifyLibraryChanged();});
|
|
108
|
+
};
|
|
109
|
+
const openTransfer=initial=>{if(homeOpen||!novelId)setManagementReturn('welcome');else if(['chapters','plan','memory','preset','manage'].includes(tab))setManagementReturn(tab);setHomeOpen(false);setTransferInitial(initial);setTransferVisit(value=>value+1);setReturnTarget(null);setTab('data');};
|
|
110
|
+
const returnFromManagement=()=>{setReturnTarget(null);setHomeOpen(managementReturn==='welcome');setTab(['chapters','plan','memory','preset','manage'].includes(managementReturn)?managementReturn:'chapters');};
|
|
111
|
+
const managementBack={onBack:returnFromManagement,backLabel:managementReturn==='welcome'?'返回作品首页':novelId?'返回'+({plan:'规划',memory:'设定',chapters:'正文',preset:'写作预设',manage:'作品与备份'}[managementReturn]||'正文'):'返回作品选择'};
|
|
112
|
+
const welcomeProps={books,busy,recycledCount,onOpen:openNovel,onCreate:create,onImport:()=>openTransfer({mode:'import'}),onManage:()=>openManagement('manage'),onRecycle:recycleNovel,onTrash:()=>openManagement('manage','recycle')};
|
|
113
|
+
const manageProps={call,novelId,novel,run,busy,tick,initialView:manageInitial,onTransfer:openTransfer,...managementBack};
|
|
114
|
+
const jump=(page,key,id)=>{setReturnTarget(tab);try{localStorage.setItem(`cuigengji:ui:${sessionId}:${novelId}:${key}`,JSON.stringify(id));}catch{}setTab(page);};
|
|
115
|
+
return <section className="cuigengji" data-motion={preferences.motion} style={{'--prose-size':`${preferences.fontSize}px`,'--prose-leading':preferences.leading}} aria-label="催更姬小说工作台">
|
|
116
|
+
<style>{styles}</style>
|
|
117
|
+
<WorkbenchHeader home={showHome} novelTitle={novel?.title} tab={tab} busy={busy} refreshing={resource.loading} onHome={()=>{setHomeOpen(true);setReturnTarget(null);}} onTab={page=>{setReturnTarget(null);setTab(page);}} onRefresh={refresh} onSettings={()=>setUtility('settings')} onAbout={()=>setUtility('about')}/>
|
|
118
|
+
{error && <div className="notice error" role="alert">{error}<button onClick={()=>setError('')}>关闭提示</button></div>}
|
|
119
|
+
|
|
120
|
+
<ResourceState resource={resource}>
|
|
121
|
+
{homeOpen?<Welcome {...welcomeProps}/>:tab==='data'?<TransferPage key={transferVisit} {...{call,run,busy,novelId}} {...managementBack} initial={transferInitial} onOpen={openNovel}/>:<>
|
|
122
|
+
{!novelId ? (tab==='manage'?<Manage {...manageProps}/>:<Welcome {...welcomeProps}/>) : <div className="workbench-body" key={novelId}>{returnTarget&&<div className="return-strip"><button onClick={()=>{setTab(returnTarget);setReturnTarget(null);}}>‹ 返回{({plan:'规划',memory:'设定'})[returnTarget]||'上一页'}</button></div>}
|
|
123
|
+
{tab==='chapters' && <Chapters {...{call,novelId,tick,run,busy}} onTransfer={openTransfer}/>}
|
|
124
|
+
{tab==='memory' && <Memory {...{call,novelId,tick,run,busy}} onTransfer={openTransfer} onChapter={id=>jump('chapters','chapter',id)}/>}
|
|
125
|
+
{tab==='plan' && <Planning {...{call,novelId,tick,run,busy}} onTransfer={openTransfer} onMemory={id=>jump('memory','memory-selected',id)} onChapter={id=>jump('chapters','chapter',id)}/>}
|
|
126
|
+
{tab==='preset' && <Preset {...{call,novelId,run,busy}} {...managementBack} onTransfer={openTransfer}/>}
|
|
127
|
+
{tab==='manage' && <Manage {...manageProps}/>}
|
|
128
|
+
</div>}
|
|
129
|
+
</>}
|
|
130
|
+
</ResourceState>
|
|
131
|
+
{utility==='settings'&&<PreferencesDialog close={()=>setUtility(null)} novelTitle={novel?.title} onPreset={novelId?()=>{setUtility(null);openManagement('preset');}:undefined}/>}
|
|
132
|
+
{utility==='about'&&<AboutDialog close={()=>setUtility(null)}/>}
|
|
133
|
+
</section>;
|
|
134
|
+
}
|