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
package/src/client/launcher.tsx
CHANGED
|
@@ -1,78 +1,78 @@
|
|
|
1
|
-
import React, {useEffect, useRef, useState} from 'react';
|
|
2
|
-
import type {SnapshotSelectorHook} from '@deepseek-ai/dsh-client-store';
|
|
3
|
-
import {styles} from './style.ts';
|
|
4
|
-
import avatar from './assets/avatar.png';
|
|
5
|
-
|
|
6
|
-
interface Workspace {workspaceId:string;title?:string;name?:string;cwd?:string;path?:string}
|
|
7
|
-
interface Props {
|
|
8
|
-
wide:boolean;
|
|
9
|
-
useNovelSession:SnapshotSelectorHook<string|undefined>;
|
|
10
|
-
useNovelWorkspaces:SnapshotSelectorHook<{items:Workspace[];phase:string}>;
|
|
11
|
-
useSessions:SnapshotSelectorHook<{byId:Record<string,{id:string;retainedBy:{mainView?:number}}>}>;
|
|
12
|
-
openNovel:(id:string)=>void;
|
|
13
|
-
revealConversation:()=>void;
|
|
14
|
-
openWorkspace:(id:string,beforeOpen:(id:string)=>void)=>Promise<void>;
|
|
15
|
-
}
|
|
16
|
-
const launcherStyle=`
|
|
17
|
-
.cg-launcher {position:relative;width:100%;min-width:0;margin:4px 0;}
|
|
18
|
-
.cg-launcher-button {box-sizing:border-box;display:flex;align-items:center;gap:9px;width:100%;min-height:38px;border:1px solid transparent;border-radius:9px;padding:8px 10px;background:transparent;color:var(--dsw-alias-label-primary,#252936);font:500 13px/20px var(--dsw-font-family,system-ui,sans-serif);cursor:pointer;-webkit-app-region:no-drag;transition:background 120ms;}
|
|
19
|
-
.cg-launcher-button:hover {background:var(--dsw-alias-interactive-bg-hover,#4d6bfe10);}
|
|
20
|
-
.cg-launcher-button:focus-visible {outline:2px solid var(--dsw-focus-ring-color,#4d6bfe);outline-offset:-2px;}
|
|
21
|
-
.cg-launcher-button svg {flex:none;color:var(--dsw-alias-label-secondary,#6c7769);}
|
|
22
|
-
.cg-launcher-button[data-compact=true] {width:36px;min-height:36px;justify-content:center;padding:8px;}
|
|
23
|
-
.cg-launcher-hint {position:absolute;bottom:100%;left:0;z-index:100;width:220px;box-sizing:border-box;padding:12px;border:1px solid var(--dsw-alias-border-l1,#dde0e6);border-radius:10px;background:var(--dsw-alias-bg-base,#ffffff);color:var(--dsw-alias-label-primary,#252936);box-shadow:0 8px 24px #0002;font:13px/1.6 system-ui;}
|
|
24
|
-
.cuigengji.cg-launcher-dialog {height:fit-content;min-height:0;padding:24px;width:min(400px,calc(100vw - 32px));max-height:80dvh;border:1px solid var(--cg-line);border-radius:16px;background:var(--cg-paper);box-shadow:0 16px 60px #0003;}
|
|
25
|
-
.cg-launcher-dialog::backdrop {background:#0005;}
|
|
26
|
-
.cg-launcher-dialog h2 {font-size:18px;margin:0 0 8px;}
|
|
27
|
-
.cg-launcher-dialog .launcher-intro {display:flex;align-items:center;gap:18px;margin-bottom:20px;}
|
|
28
|
-
.cg-launcher-dialog .launcher-intro img {display:block;width:76px;height:76px;border-radius:18px;background:#fff;flex:none;}
|
|
29
|
-
.cg-launcher-dialog .launcher-intro span {display:block;font-size:11px;color:var(--cg-accent);margin-bottom:6px;}
|
|
30
|
-
.cg-launcher-dialog .launcher-intro h2 {font-weight:600;line-height:1.5;margin:0;}
|
|
31
|
-
.cg-launcher-dialog>p {line-height:1.8;margin:0 0 20px;}
|
|
32
|
-
.cg-launcher-dialog .launcher-actions {display:flex;justify-content:flex-end;gap:8px;margin-top:20px;}
|
|
33
|
-
`;
|
|
34
|
-
|
|
35
|
-
export function BookIcon() {
|
|
36
|
-
return <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.65" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M12 5.5C9 3.5 5.5 3.5 3 4.5v14c2.5-1 6-1 9 1 3-2 6.5-2 9-1v-14c-2.5-1-6-1-9 1Z"/><path d="M12 5.5v14M6 8h3M15 8h3"/></svg>;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
/** Root sidebar entry: independent of message count and the hidden blank-session header. */
|
|
40
|
-
export function Launcher({wide,useNovelSession,useNovelWorkspaces,useSessions,openNovel,revealConversation,openWorkspace}:Props) {
|
|
41
|
-
const mounted=useNovelSession(value=>value), workspaces=useNovelWorkspaces(value=>value), sessions=useSessions(value=>value);
|
|
42
|
-
const [choosing,setChoosing]=useState(false), [workspace,setWorkspace]=useState('');
|
|
43
|
-
const [pending,setPending]=useState<string|null>(null), [busy,setBusy]=useState(false), [error,setError]=useState('');
|
|
44
|
-
const dialog=useRef<HTMLDialogElement>(null), trigger=useRef<HTMLButtonElement>(null);
|
|
45
|
-
useEffect(()=>{if(choosing)dialog.current?.showModal();},[choosing]);
|
|
46
|
-
useEffect(()=>{
|
|
47
|
-
if(!pending||!mounted)return;
|
|
48
|
-
setPending(null);
|
|
49
|
-
if(mounted!==pending)return;
|
|
50
|
-
try {openNovel(mounted);} catch(e) {setError(e instanceof Error?e.message:String(e));}
|
|
51
|
-
},[mounted,pending,openNovel]);
|
|
52
|
-
const close=()=>{dialog.current?.close();setChoosing(false);trigger.current?.focus();};
|
|
53
|
-
const open=()=>{
|
|
54
|
-
setError('');
|
|
55
|
-
try {
|
|
56
|
-
if(mounted){openNovel(mounted);return;}
|
|
57
|
-
const selected=Object.values(sessions.byId).find(s=>(s.retainedBy.mainView||0)>0)?.id;
|
|
58
|
-
if(selected){setPending(selected);revealConversation();return;}
|
|
59
|
-
setWorkspace(workspaces.items[0]?.workspaceId||'');setChoosing(true);
|
|
60
|
-
} catch(e) {setError(e instanceof Error?e.message:String(e));}
|
|
61
|
-
};
|
|
62
|
-
const connect=async()=>{
|
|
63
|
-
if(busy||!workspace)return;
|
|
64
|
-
setBusy(true);setError('');
|
|
65
|
-
try {await openWorkspace(workspace,id=>setPending(id));close();}
|
|
66
|
-
catch(e){setPending(null);setError(e instanceof Error?e.message:String(e));}
|
|
67
|
-
finally {setBusy(false);}
|
|
68
|
-
};
|
|
69
|
-
return <div className="cg-launcher"><style>{launcherStyle}</style>
|
|
70
|
-
<button ref={trigger} className="cg-launcher-button" data-compact={!wide} type="button" title="催更姬 · 写作工作台" aria-label="打开催更姬" onClick={open}><BookIcon/>{wide&&<span>催更姬</span>}</button>
|
|
71
|
-
{error&&!choosing&&<div className="cg-launcher-hint" role="alert">{error}</div>}
|
|
72
|
-
{choosing&&<dialog ref={dialog} className="cuigengji cg-launcher-dialog" aria-label="打开写作工作台" onCancel={e=>{e.preventDefault();if(!busy)close();}}><style>{styles}</style>
|
|
73
|
-
<div className="launcher-intro"><img src={avatar} width={76} height={76} alt="催更姬头像"/><div><span>催更姬 · 写作助手</span><h2>故事,从这里开始。</h2></div></div><p className="muted">选择一个 DSH 工作区即可开始,无需先发消息。</p>
|
|
74
|
-
{workspaces.items.length?<label>工作区<select aria-label="工作区" value={workspace} disabled={busy} onChange={e=>setWorkspace(e.target.value)}>{workspaces.items.map(w=><option key={w.workspaceId} value={w.workspaceId}>{w.title||w.name||w.cwd||w.path||w.workspaceId}</option>)}</select></label>:<p>{workspaces.phase==='ready'?'请先在 DSH 左侧添加一个工作区,然后从这里进入。':'正在读取工作区…'}</p>}
|
|
75
|
-
{error&&<p role="alert">{error}</p>}<div className="launcher-actions"><button onClick={close} disabled={busy}>取消</button><button className="primary" onClick={connect} disabled={busy||!workspace}>{busy?'正在打开…':'进入工作台'}</button></div>
|
|
76
|
-
</dialog>}
|
|
77
|
-
</div>;
|
|
78
|
-
}
|
|
1
|
+
import React, {useEffect, useRef, useState} from 'react';
|
|
2
|
+
import type {SnapshotSelectorHook} from '@deepseek-ai/dsh-client-store';
|
|
3
|
+
import {styles} from './style.ts';
|
|
4
|
+
import avatar from './assets/avatar.png';
|
|
5
|
+
|
|
6
|
+
interface Workspace {workspaceId:string;title?:string;name?:string;cwd?:string;path?:string}
|
|
7
|
+
interface Props {
|
|
8
|
+
wide:boolean;
|
|
9
|
+
useNovelSession:SnapshotSelectorHook<string|undefined>;
|
|
10
|
+
useNovelWorkspaces:SnapshotSelectorHook<{items:Workspace[];phase:string}>;
|
|
11
|
+
useSessions:SnapshotSelectorHook<{byId:Record<string,{id:string;retainedBy:{mainView?:number}}>}>;
|
|
12
|
+
openNovel:(id:string)=>void;
|
|
13
|
+
revealConversation:()=>void;
|
|
14
|
+
openWorkspace:(id:string,beforeOpen:(id:string)=>void)=>Promise<void>;
|
|
15
|
+
}
|
|
16
|
+
const launcherStyle=`
|
|
17
|
+
.cg-launcher {position:relative;width:100%;min-width:0;margin:4px 0;}
|
|
18
|
+
.cg-launcher-button {box-sizing:border-box;display:flex;align-items:center;gap:9px;width:100%;min-height:38px;border:1px solid transparent;border-radius:9px;padding:8px 10px;background:transparent;color:var(--dsw-alias-label-primary,#252936);font:500 13px/20px var(--dsw-font-family,system-ui,sans-serif);cursor:pointer;-webkit-app-region:no-drag;transition:background 120ms;}
|
|
19
|
+
.cg-launcher-button:hover {background:var(--dsw-alias-interactive-bg-hover,#4d6bfe10);}
|
|
20
|
+
.cg-launcher-button:focus-visible {outline:2px solid var(--dsw-focus-ring-color,#4d6bfe);outline-offset:-2px;}
|
|
21
|
+
.cg-launcher-button svg {flex:none;color:var(--dsw-alias-label-secondary,#6c7769);}
|
|
22
|
+
.cg-launcher-button[data-compact=true] {width:36px;min-height:36px;justify-content:center;padding:8px;}
|
|
23
|
+
.cg-launcher-hint {position:absolute;bottom:100%;left:0;z-index:100;width:220px;box-sizing:border-box;padding:12px;border:1px solid var(--dsw-alias-border-l1,#dde0e6);border-radius:10px;background:var(--dsw-alias-bg-base,#ffffff);color:var(--dsw-alias-label-primary,#252936);box-shadow:0 8px 24px #0002;font:13px/1.6 system-ui;}
|
|
24
|
+
.cuigengji.cg-launcher-dialog {height:fit-content;min-height:0;padding:24px;width:min(400px,calc(100vw - 32px));max-height:80dvh;border:1px solid var(--cg-line);border-radius:16px;background:var(--cg-paper);box-shadow:0 16px 60px #0003;}
|
|
25
|
+
.cg-launcher-dialog::backdrop {background:#0005;}
|
|
26
|
+
.cg-launcher-dialog h2 {font-size:18px;margin:0 0 8px;}
|
|
27
|
+
.cg-launcher-dialog .launcher-intro {display:flex;align-items:center;gap:18px;margin-bottom:20px;}
|
|
28
|
+
.cg-launcher-dialog .launcher-intro img {display:block;width:76px;height:76px;border-radius:18px;background:#fff;flex:none;}
|
|
29
|
+
.cg-launcher-dialog .launcher-intro span {display:block;font-size:11px;color:var(--cg-accent);margin-bottom:6px;}
|
|
30
|
+
.cg-launcher-dialog .launcher-intro h2 {font-weight:600;line-height:1.5;margin:0;}
|
|
31
|
+
.cg-launcher-dialog>p {line-height:1.8;margin:0 0 20px;}
|
|
32
|
+
.cg-launcher-dialog .launcher-actions {display:flex;justify-content:flex-end;gap:8px;margin-top:20px;}
|
|
33
|
+
`;
|
|
34
|
+
|
|
35
|
+
export function BookIcon() {
|
|
36
|
+
return <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.65" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M12 5.5C9 3.5 5.5 3.5 3 4.5v14c2.5-1 6-1 9 1 3-2 6.5-2 9-1v-14c-2.5-1-6-1-9 1Z"/><path d="M12 5.5v14M6 8h3M15 8h3"/></svg>;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Root sidebar entry: independent of message count and the hidden blank-session header. */
|
|
40
|
+
export function Launcher({wide,useNovelSession,useNovelWorkspaces,useSessions,openNovel,revealConversation,openWorkspace}:Props) {
|
|
41
|
+
const mounted=useNovelSession(value=>value), workspaces=useNovelWorkspaces(value=>value), sessions=useSessions(value=>value);
|
|
42
|
+
const [choosing,setChoosing]=useState(false), [workspace,setWorkspace]=useState('');
|
|
43
|
+
const [pending,setPending]=useState<string|null>(null), [busy,setBusy]=useState(false), [error,setError]=useState('');
|
|
44
|
+
const dialog=useRef<HTMLDialogElement>(null), trigger=useRef<HTMLButtonElement>(null);
|
|
45
|
+
useEffect(()=>{if(choosing)dialog.current?.showModal();},[choosing]);
|
|
46
|
+
useEffect(()=>{
|
|
47
|
+
if(!pending||!mounted)return;
|
|
48
|
+
setPending(null);
|
|
49
|
+
if(mounted!==pending)return;
|
|
50
|
+
try {openNovel(mounted);} catch(e) {setError(e instanceof Error?e.message:String(e));}
|
|
51
|
+
},[mounted,pending,openNovel]);
|
|
52
|
+
const close=()=>{dialog.current?.close();setChoosing(false);trigger.current?.focus();};
|
|
53
|
+
const open=()=>{
|
|
54
|
+
setError('');
|
|
55
|
+
try {
|
|
56
|
+
if(mounted){openNovel(mounted);return;}
|
|
57
|
+
const selected=Object.values(sessions.byId).find(s=>(s.retainedBy.mainView||0)>0)?.id;
|
|
58
|
+
if(selected){setPending(selected);revealConversation();return;}
|
|
59
|
+
setWorkspace(workspaces.items[0]?.workspaceId||'');setChoosing(true);
|
|
60
|
+
} catch(e) {setError(e instanceof Error?e.message:String(e));}
|
|
61
|
+
};
|
|
62
|
+
const connect=async()=>{
|
|
63
|
+
if(busy||!workspace)return;
|
|
64
|
+
setBusy(true);setError('');
|
|
65
|
+
try {await openWorkspace(workspace,id=>setPending(id));close();}
|
|
66
|
+
catch(e){setPending(null);setError(e instanceof Error?e.message:String(e));}
|
|
67
|
+
finally {setBusy(false);}
|
|
68
|
+
};
|
|
69
|
+
return <div className="cg-launcher"><style>{launcherStyle}</style>
|
|
70
|
+
<button ref={trigger} className="cg-launcher-button" data-compact={!wide} type="button" title="催更姬 · 写作工作台" aria-label="打开催更姬" onClick={open}><BookIcon/>{wide&&<span>催更姬</span>}</button>
|
|
71
|
+
{error&&!choosing&&<div className="cg-launcher-hint" role="alert">{error}</div>}
|
|
72
|
+
{choosing&&<dialog ref={dialog} className="cuigengji cg-launcher-dialog" aria-label="打开写作工作台" onCancel={e=>{e.preventDefault();if(!busy)close();}}><style>{styles}</style>
|
|
73
|
+
<div className="launcher-intro"><img src={avatar} width={76} height={76} alt="催更姬头像"/><div><span>催更姬 · 写作助手</span><h2>故事,从这里开始。</h2></div></div><p className="muted">选择一个 DSH 工作区即可开始,无需先发消息。</p>
|
|
74
|
+
{workspaces.items.length?<label>工作区<select aria-label="工作区" value={workspace} disabled={busy} onChange={e=>setWorkspace(e.target.value)}>{workspaces.items.map(w=><option key={w.workspaceId} value={w.workspaceId}>{w.title||w.name||w.cwd||w.path||w.workspaceId}</option>)}</select></label>:<p>{workspaces.phase==='ready'?'请先在 DSH 左侧添加一个工作区,然后从这里进入。':'正在读取工作区…'}</p>}
|
|
75
|
+
{error&&<p role="alert">{error}</p>}<div className="launcher-actions"><button onClick={close} disabled={busy}>取消</button><button className="primary" onClick={connect} disabled={busy||!workspace}>{busy?'正在打开…':'进入工作台'}</button></div>
|
|
76
|
+
</dialog>}
|
|
77
|
+
</div>;
|
|
78
|
+
}
|
|
@@ -1,34 +1,34 @@
|
|
|
1
|
-
import {DocumentTextarea} from './workspace.tsx';
|
|
2
|
-
import React, {useRef} from 'react';
|
|
3
|
-
import {useDraft} from './state.js';
|
|
4
|
-
import {useDialog} from '../dialog.tsx';
|
|
5
|
-
import {GroupSelect, type GroupItem} from './groups.tsx';
|
|
6
|
-
|
|
7
|
-
export interface DocumentDraft {title: string; summary: string; content: string; groupId: string | null}
|
|
8
|
-
interface Props {
|
|
9
|
-
draftKey: string; label: string; initial: DocumentDraft; groups: GroupItem[];
|
|
10
|
-
busy: boolean; context?: string; chapter?: boolean;
|
|
11
|
-
close: () => void; createGroup: (name: string) => Promise<GroupItem | null>;
|
|
12
|
-
save: (value: DocumentDraft, requestId: string) => Promise<boolean | undefined>;
|
|
13
|
-
}
|
|
14
|
-
/** Creation remains local until save. In particular, cancelling a continuation creates no node or edge. */
|
|
15
|
-
export function CreateDocument({draftKey,label,initial,groups,busy,context,chapter,close,createGroup,save}: Props) {
|
|
16
|
-
const {value,change,accept,dirty,cacheError} = useDraft(draftKey,initial);
|
|
17
|
-
const request = useRef({payload:'',id:''});
|
|
18
|
-
const submit = async () => {
|
|
19
|
-
const payload=JSON.stringify(value);
|
|
20
|
-
if(request.current.payload!==payload)request.current={payload,id:crypto.randomUUID()};
|
|
21
|
-
if(await save(value,request.current.id)){accept(initial);close();}
|
|
22
|
-
};
|
|
23
|
-
const {ask,confirm} = useDialog();
|
|
24
|
-
const set = (field: keyof DocumentDraft, next: string | null) => change((old: DocumentDraft)=>({...old,[field]:next}));
|
|
25
|
-
const cancel = async () => {if(!dirty || await confirm('取消新建并丢弃这份草稿?')) {accept(initial);close();}};
|
|
26
|
-
return <section className="creation"><header className="creation-bar"><button disabled={busy} onClick={cancel}>‹ 取消</button><span className="grow">{label}</span><button className="primary" disabled={busy||!value.title.trim()} onClick={submit}>创建</button></header>
|
|
27
|
-
<div className="creation-scroll"><div className="creation-document">{context&&<p className="creation-context">{context}</p>}<div className="creation-settings">
|
|
28
|
-
{chapter?<label>所属卷<select value={value.groupId||''} onChange={e=>set('groupId',e.target.value||null)}><option value="">未分卷</option>{groups.map(g=><option value={g.id} key={g.id}>{g.name}</option>)}</select></label>:<GroupSelect groups={groups} value={value.groupId} onChange={id=>set('groupId',id)}/>}
|
|
29
|
-
<button disabled={busy} onClick={async()=>{const name=await ask(chapter?'新卷名称':'新建分组名称');if(name?.trim()){const g=await createGroup(name);if(g)set('groupId',g.id);}}}>+ {chapter?'新建卷':'新建分组'}</button></div>
|
|
30
|
-
<fieldset className="editor-fields document-fields" disabled={busy}><label>标题<input autoFocus placeholder="写一个清晰的标题" value={value.title} onChange={e=>set('title',e.target.value)}/></label>
|
|
31
|
-
{!chapter&&<label><span>摘要 <small>可选</small></span><DocumentTextarea placeholder="用几句话概括,方便之后查找" value={value.summary} onChange={e=>set('summary',e.target.value)}/></label>}
|
|
32
|
-
<label><span>正文 <small>可选</small></span><DocumentTextarea className="prose" placeholder="从这里开始,自由记录内容…" value={value.content} onChange={e=>set('content',e.target.value)}/></label></fieldset>{cacheError&&<p role="alert">{cacheError}</p>}
|
|
33
|
-
</div></div></section>;
|
|
34
|
-
}
|
|
1
|
+
import {DocumentTextarea} from './workspace.tsx';
|
|
2
|
+
import React, {useRef} from 'react';
|
|
3
|
+
import {useDraft} from './state.js';
|
|
4
|
+
import {useDialog} from '../dialog.tsx';
|
|
5
|
+
import {GroupSelect, type GroupItem} from './groups.tsx';
|
|
6
|
+
|
|
7
|
+
export interface DocumentDraft {title: string; summary: string; content: string; groupId: string | null}
|
|
8
|
+
interface Props {
|
|
9
|
+
draftKey: string; label: string; initial: DocumentDraft; groups: GroupItem[];
|
|
10
|
+
busy: boolean; context?: string; chapter?: boolean;
|
|
11
|
+
close: () => void; createGroup: (name: string) => Promise<GroupItem | null>;
|
|
12
|
+
save: (value: DocumentDraft, requestId: string) => Promise<boolean | undefined>;
|
|
13
|
+
}
|
|
14
|
+
/** Creation remains local until save. In particular, cancelling a continuation creates no node or edge. */
|
|
15
|
+
export function CreateDocument({draftKey,label,initial,groups,busy,context,chapter,close,createGroup,save}: Props) {
|
|
16
|
+
const {value,change,accept,dirty,cacheError} = useDraft(draftKey,initial);
|
|
17
|
+
const request = useRef({payload:'',id:''});
|
|
18
|
+
const submit = async () => {
|
|
19
|
+
const payload=JSON.stringify(value);
|
|
20
|
+
if(request.current.payload!==payload)request.current={payload,id:crypto.randomUUID()};
|
|
21
|
+
if(await save(value,request.current.id)){accept(initial);close();}
|
|
22
|
+
};
|
|
23
|
+
const {ask,confirm} = useDialog();
|
|
24
|
+
const set = (field: keyof DocumentDraft, next: string | null) => change((old: DocumentDraft)=>({...old,[field]:next}));
|
|
25
|
+
const cancel = async () => {if(!dirty || await confirm('取消新建并丢弃这份草稿?')) {accept(initial);close();}};
|
|
26
|
+
return <section className="creation"><header className="creation-bar"><button disabled={busy} onClick={cancel}>‹ 取消</button><span className="grow">{label}</span><button className="primary" disabled={busy||!value.title.trim()} onClick={submit}>创建</button></header>
|
|
27
|
+
<div className="creation-scroll"><div className="creation-document">{context&&<p className="creation-context">{context}</p>}<div className="creation-settings">
|
|
28
|
+
{chapter?<label>所属卷<select value={value.groupId||''} onChange={e=>set('groupId',e.target.value||null)}><option value="">未分卷</option>{groups.map(g=><option value={g.id} key={g.id}>{g.name}</option>)}</select></label>:<GroupSelect groups={groups} value={value.groupId} onChange={id=>set('groupId',id)}/>}
|
|
29
|
+
<button disabled={busy} onClick={async()=>{const name=await ask(chapter?'新卷名称':'新建分组名称');if(name?.trim()){const g=await createGroup(name);if(g)set('groupId',g.id);}}}>+ {chapter?'新建卷':'新建分组'}</button></div>
|
|
30
|
+
<fieldset className="editor-fields document-fields" disabled={busy}><label>标题<input autoFocus placeholder="写一个清晰的标题" value={value.title} onChange={e=>set('title',e.target.value)}/></label>
|
|
31
|
+
{!chapter&&<label><span>摘要 <small>可选</small></span><DocumentTextarea placeholder="用几句话概括,方便之后查找" value={value.summary} onChange={e=>set('summary',e.target.value)}/></label>}
|
|
32
|
+
<label><span>正文 <small>可选</small></span><DocumentTextarea className="prose" placeholder="从这里开始,自由记录内容…" value={value.content} onChange={e=>set('content',e.target.value)}/></label></fieldset>{cacheError&&<p role="alert">{cacheError}</p>}
|
|
33
|
+
</div></div></section>;
|
|
34
|
+
}
|
|
@@ -1,61 +1,61 @@
|
|
|
1
|
-
import React, {createContext, useContext, useLayoutEffect, useRef, useState} from 'react';
|
|
2
|
-
import {createPortal} from 'react-dom';
|
|
3
|
-
|
|
4
|
-
/** Keep the overview mounted for canvas state, but remove it from layout and focus. */
|
|
5
|
-
export function OverviewPage({active, children}: {active:boolean; children:React.ReactNode}) {
|
|
6
|
-
const root = useRef<HTMLDivElement>(null);
|
|
7
|
-
const positions = useRef(new Map<HTMLElement, {top:number; left:number}>());
|
|
8
|
-
const focus = useRef<HTMLElement | null>(null);
|
|
9
|
-
const wasActive = useRef(active);
|
|
10
|
-
useLayoutEffect(() => {
|
|
11
|
-
if (active && !wasActive.current) {
|
|
12
|
-
positions.current.forEach((position, element) => {
|
|
13
|
-
if (root.current?.contains(element)) element.scrollTo(position.left, position.top);
|
|
14
|
-
else positions.current.delete(element);
|
|
15
|
-
});
|
|
16
|
-
if (focus.current && root.current?.contains(focus.current)) focus.current.focus({preventScroll:true});
|
|
17
|
-
}
|
|
18
|
-
wasActive.current = active;
|
|
19
|
-
}, [active]);
|
|
20
|
-
return <div ref={root} className="overview-page" hidden={!active}
|
|
21
|
-
onFocusCapture={event => {focus.current = event.target as HTMLElement;}}
|
|
22
|
-
onScrollCapture={event => {
|
|
23
|
-
if (!active) return;
|
|
24
|
-
const element = event.target as HTMLElement;
|
|
25
|
-
positions.current.set(element, {top:element.scrollTop, left:element.scrollLeft});
|
|
26
|
-
}}>{children}</div>;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
/** Related entries form a back trail; the last entry remains highlighted in overview. */
|
|
30
|
-
export function useDetailTrail(current:string | null) {
|
|
31
|
-
const [trail, setTrail] = useState<string[]>([]);
|
|
32
|
-
const [lastOpened, setLastOpened] = useState(current);
|
|
33
|
-
return {
|
|
34
|
-
previous: trail.at(-1), lastOpened,
|
|
35
|
-
visit(id:string) {
|
|
36
|
-
if (current && current !== id) setTrail(items => [...items, current]);
|
|
37
|
-
setLastOpened(id);
|
|
38
|
-
},
|
|
39
|
-
back(id:string) {setTrail(items => items.slice(0, -1)); setLastOpened(id);},
|
|
40
|
-
clear() {setTrail([]);},
|
|
41
|
-
};
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
const ActionSlot = createContext<HTMLDivElement | null>(null);
|
|
45
|
-
export function DetailActions({children}: {children:React.ReactNode}) {
|
|
46
|
-
const slot = useContext(ActionSlot);
|
|
47
|
-
return slot ? createPortal(children, slot) : null;
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
/** Navigation and edit controls stay outside the single scrolling document. */
|
|
51
|
-
export function DetailPage({className, backLabel, onBack, children}: {
|
|
52
|
-
className:string; backLabel:string; onBack:()=>void; children:React.ReactNode;
|
|
53
|
-
}) {
|
|
54
|
-
const [slot, setSlot] = useState<HTMLDivElement | null>(null);
|
|
55
|
-
const back = useRef<HTMLButtonElement>(null);
|
|
56
|
-
useLayoutEffect(() => {back.current?.focus({preventScroll:true});}, []);
|
|
57
|
-
return <section className={`detail-page ${className}`} aria-label="条目详情">
|
|
58
|
-
<header className="detail-bar"><button ref={back} className="detail-back" onClick={onBack}>{backLabel}</button><div className="detail-actions" ref={setSlot}/></header>
|
|
59
|
-
<div className="detail-scroll"><div className="detail-document"><ActionSlot.Provider value={slot}>{children}</ActionSlot.Provider></div></div>
|
|
60
|
-
</section>;
|
|
61
|
-
}
|
|
1
|
+
import React, {createContext, useContext, useLayoutEffect, useRef, useState} from 'react';
|
|
2
|
+
import {createPortal} from 'react-dom';
|
|
3
|
+
|
|
4
|
+
/** Keep the overview mounted for canvas state, but remove it from layout and focus. */
|
|
5
|
+
export function OverviewPage({active, children}: {active:boolean; children:React.ReactNode}) {
|
|
6
|
+
const root = useRef<HTMLDivElement>(null);
|
|
7
|
+
const positions = useRef(new Map<HTMLElement, {top:number; left:number}>());
|
|
8
|
+
const focus = useRef<HTMLElement | null>(null);
|
|
9
|
+
const wasActive = useRef(active);
|
|
10
|
+
useLayoutEffect(() => {
|
|
11
|
+
if (active && !wasActive.current) {
|
|
12
|
+
positions.current.forEach((position, element) => {
|
|
13
|
+
if (root.current?.contains(element)) element.scrollTo(position.left, position.top);
|
|
14
|
+
else positions.current.delete(element);
|
|
15
|
+
});
|
|
16
|
+
if (focus.current && root.current?.contains(focus.current)) focus.current.focus({preventScroll:true});
|
|
17
|
+
}
|
|
18
|
+
wasActive.current = active;
|
|
19
|
+
}, [active]);
|
|
20
|
+
return <div ref={root} className="overview-page" hidden={!active}
|
|
21
|
+
onFocusCapture={event => {focus.current = event.target as HTMLElement;}}
|
|
22
|
+
onScrollCapture={event => {
|
|
23
|
+
if (!active) return;
|
|
24
|
+
const element = event.target as HTMLElement;
|
|
25
|
+
positions.current.set(element, {top:element.scrollTop, left:element.scrollLeft});
|
|
26
|
+
}}>{children}</div>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Related entries form a back trail; the last entry remains highlighted in overview. */
|
|
30
|
+
export function useDetailTrail(current:string | null) {
|
|
31
|
+
const [trail, setTrail] = useState<string[]>([]);
|
|
32
|
+
const [lastOpened, setLastOpened] = useState(current);
|
|
33
|
+
return {
|
|
34
|
+
previous: trail.at(-1), lastOpened,
|
|
35
|
+
visit(id:string) {
|
|
36
|
+
if (current && current !== id) setTrail(items => [...items, current]);
|
|
37
|
+
setLastOpened(id);
|
|
38
|
+
},
|
|
39
|
+
back(id:string) {setTrail(items => items.slice(0, -1)); setLastOpened(id);},
|
|
40
|
+
clear() {setTrail([]);},
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const ActionSlot = createContext<HTMLDivElement | null>(null);
|
|
45
|
+
export function DetailActions({children}: {children:React.ReactNode}) {
|
|
46
|
+
const slot = useContext(ActionSlot);
|
|
47
|
+
return slot ? createPortal(children, slot) : null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Navigation and edit controls stay outside the single scrolling document. */
|
|
51
|
+
export function DetailPage({className, backLabel, onBack, children}: {
|
|
52
|
+
className:string; backLabel:string; onBack:()=>void; children:React.ReactNode;
|
|
53
|
+
}) {
|
|
54
|
+
const [slot, setSlot] = useState<HTMLDivElement | null>(null);
|
|
55
|
+
const back = useRef<HTMLButtonElement>(null);
|
|
56
|
+
useLayoutEffect(() => {back.current?.focus({preventScroll:true});}, []);
|
|
57
|
+
return <section className={`detail-page ${className}`} aria-label="条目详情">
|
|
58
|
+
<header className="detail-bar"><button ref={back} className="detail-back" onClick={onBack}>{backLabel}</button><div className="detail-actions" ref={setSlot}/></header>
|
|
59
|
+
<div className="detail-scroll"><div className="detail-document"><ActionSlot.Provider value={slot}>{children}</ActionSlot.Provider></div></div>
|
|
60
|
+
</section>;
|
|
61
|
+
}
|
|
@@ -1,17 +1,17 @@
|
|
|
1
|
-
export const draftKey = (scope, entity) => `cuigengji:draft:v2:${scope}:${entity}`;
|
|
2
|
-
export const changed = state => JSON.stringify(state.value) !== JSON.stringify(state.base);
|
|
3
|
-
export function restoreDraft(storage, key, initial) {
|
|
4
|
-
try {
|
|
5
|
-
const cached = JSON.parse(storage.getItem(key));
|
|
6
|
-
if (cached && cached.base && cached.value && typeof cached.base === 'object' && typeof cached.value === 'object'
|
|
7
|
-
&& Number.isSafeInteger(cached.base.revision ?? 0)) return cached;
|
|
8
|
-
} catch {}
|
|
9
|
-
return { base: initial, value: initial };
|
|
10
|
-
}
|
|
11
|
-
export function persistDraft(storage, key, state) {
|
|
12
|
-
if (changed(state)) storage.setItem(key, JSON.stringify(state));
|
|
13
|
-
else storage.removeItem(key);
|
|
14
|
-
}
|
|
15
|
-
export function rebaseDraft(state, server) {
|
|
16
|
-
return { base: server, value: { ...server, ...state.value, revision: server.revision, contentHash: server.contentHash } };
|
|
17
|
-
}
|
|
1
|
+
export const draftKey = (scope, entity) => `cuigengji:draft:v2:${scope}:${entity}`;
|
|
2
|
+
export const changed = state => JSON.stringify(state.value) !== JSON.stringify(state.base);
|
|
3
|
+
export function restoreDraft(storage, key, initial) {
|
|
4
|
+
try {
|
|
5
|
+
const cached = JSON.parse(storage.getItem(key));
|
|
6
|
+
if (cached && cached.base && cached.value && typeof cached.base === 'object' && typeof cached.value === 'object'
|
|
7
|
+
&& Number.isSafeInteger(cached.base.revision ?? 0)) return cached;
|
|
8
|
+
} catch {}
|
|
9
|
+
return { base: initial, value: initial };
|
|
10
|
+
}
|
|
11
|
+
export function persistDraft(storage, key, state) {
|
|
12
|
+
if (changed(state)) storage.setItem(key, JSON.stringify(state));
|
|
13
|
+
else storage.removeItem(key);
|
|
14
|
+
}
|
|
15
|
+
export function rebaseDraft(state, server) {
|
|
16
|
+
return { base: server, value: { ...server, ...state.value, revision: server.revision, contentHash: server.contentHash } };
|
|
17
|
+
}
|
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
import React,{useState} from 'react';
|
|
2
|
-
import {useResource} from './state.js';
|
|
3
|
-
import {ResourceState} from './ui.jsx';
|
|
4
|
-
export function ChapterEvidence({call,novelId,source,title,onChapter}) {
|
|
5
|
-
const [open,setOpen]=useState(false);
|
|
6
|
-
return <section className="source-row"><span>{title||'已删除或缺失章节'} · 引用版本{source.revision}</span><button onClick={()=>setOpen(!open)}>{open?'收起引用正文':'查看引用版本'}</button><button onClick={()=>onChapter?.(source.chapterId)}>打开当前正文</button>{open&&<Version {...{call,novelId,source}}/>}</section>;
|
|
7
|
-
}
|
|
8
|
-
function Version({call,novelId,source}) {
|
|
9
|
-
const resource=useResource(()=>call('chapter.history',{novelId,chapterId:source.chapterId,includeContent:true}).then(versions=>{const value=versions.find(v=>v.revision===source.revision);if(!value)throw new Error('引用版本不存在');return value;}),[call,novelId,source.chapterId,source.revision]);
|
|
10
|
-
return <ResourceState resource={resource}>{resource.value&&<pre aria-label="引用的历史正文">{resource.value.content}</pre>}</ResourceState>;
|
|
11
|
-
}
|
|
1
|
+
import React,{useState} from 'react';
|
|
2
|
+
import {useResource} from './state.js';
|
|
3
|
+
import {ResourceState} from './ui.jsx';
|
|
4
|
+
export function ChapterEvidence({call,novelId,source,title,onChapter}) {
|
|
5
|
+
const [open,setOpen]=useState(false);
|
|
6
|
+
return <section className="source-row"><span>{title||'已删除或缺失章节'} · 引用版本{source.revision}</span><button onClick={()=>setOpen(!open)}>{open?'收起引用正文':'查看引用版本'}</button><button onClick={()=>onChapter?.(source.chapterId)}>打开当前正文</button>{open&&<Version {...{call,novelId,source}}/>}</section>;
|
|
7
|
+
}
|
|
8
|
+
function Version({call,novelId,source}) {
|
|
9
|
+
const resource=useResource(()=>call('chapter.history',{novelId,chapterId:source.chapterId,includeContent:true}).then(versions=>{const value=versions.find(v=>v.revision===source.revision);if(!value)throw new Error('引用版本不存在');return value;}),[call,novelId,source.chapterId,source.revision]);
|
|
10
|
+
return <ResourceState resource={resource}>{resource.value&&<pre aria-label="引用的历史正文">{resource.value.content}</pre>}</ResourceState>;
|
|
11
|
+
}
|
|
@@ -1,34 +1,34 @@
|
|
|
1
|
-
import React from 'react';
|
|
2
|
-
import { useDialog } from '../dialog.tsx';
|
|
3
|
-
import type { Group } from '../../domain/memory/groups.ts';
|
|
4
|
-
import {FilterControl} from './workspace.tsx';
|
|
5
|
-
|
|
6
|
-
export type GroupItem = Pick<Group, 'id' | 'name' | 'summary' | 'revision'>;
|
|
7
|
-
interface Props {
|
|
8
|
-
groups: GroupItem[];
|
|
9
|
-
value: string;
|
|
10
|
-
onChange: (value: string) => void;
|
|
11
|
-
prefix: 'planning' | 'graph';
|
|
12
|
-
novelId: string;
|
|
13
|
-
busy: boolean;
|
|
14
|
-
call: (action: string, args: Record<string, unknown>) => Promise<unknown>;
|
|
15
|
-
run: (task: () => Promise<unknown>) => unknown;
|
|
16
|
-
}
|
|
17
|
-
export const filterGroup = (value: string): string | null | undefined => value === '__ungrouped' ? null : value || undefined;
|
|
18
|
-
export function GroupSelect({ groups, value, onChange }: { groups: GroupItem[]; value?: string | null; onChange: (id: string | null) => void }) {
|
|
19
|
-
return <label>分组<select value={value || ''} onChange={e => onChange(e.target.value || null)}><option value="">未分组</option>{groups.map(g => <option key={g.id} value={g.id}>{g.name}</option>)}</select></label>;
|
|
20
|
-
}
|
|
21
|
-
export function Groups({ groups, value, onChange, prefix, novelId, busy, call, run }: Props) {
|
|
22
|
-
const { ask, confirm } = useDialog();
|
|
23
|
-
const selected = groups.find(g => g.id === value);
|
|
24
|
-
const mutate = (action: string, args: Record<string, unknown>) => call(`${prefix}.group.${action}`, { novelId, requestId: crypto.randomUUID(), ...args });
|
|
25
|
-
return <div className="group-toolbar">
|
|
26
|
-
<FilterControl label={prefix === 'planning' ? '规划分组' : '设定分组'} value={value} change={onChange}><option value="">全部分组</option><option value="__ungrouped">未分组</option>{groups.map(g => <option key={g.id} value={g.id}>{g.name}</option>)}</FilterControl>
|
|
27
|
-
<button className="group-new" disabled={busy} onClick={async () => { const name = await ask('新建分组名称'); if (name?.trim()) run(async () => { const group = await mutate('create', { name }) as GroupItem; onChange(group.id); }); }}>+ 新建分组</button>
|
|
28
|
-
{selected && <details className="menu"><summary aria-label="管理分组">···</summary><div className="menu-panel">
|
|
29
|
-
{selected && <><button disabled={busy} onClick={async () => { const name = await ask('分组名称', selected.name); if (name?.trim()) run(() => mutate('update', { groupId: selected.id, expectedRevision: selected.revision, name })); }}>重命名</button>
|
|
30
|
-
<button disabled={busy} onClick={async () => { const summary = await ask('分组描述', selected.summary); if (summary !== null) run(() => mutate('update', { groupId: selected.id, expectedRevision: selected.revision, summary })); }}>编辑描述</button>
|
|
31
|
-
<button className="danger" disabled={busy} onClick={async () => { if (await confirm(`删除分组“${selected.name}”?内容将保留在未分组中。`)) run(async () => { await mutate('delete', { groupId: selected.id, expectedRevision: selected.revision, confirm: true }); onChange('__ungrouped'); }); }}>删除分组</button></>}
|
|
32
|
-
</div></details>}
|
|
33
|
-
</div>;
|
|
34
|
-
}
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { useDialog } from '../dialog.tsx';
|
|
3
|
+
import type { Group } from '../../domain/memory/groups.ts';
|
|
4
|
+
import {FilterControl} from './workspace.tsx';
|
|
5
|
+
|
|
6
|
+
export type GroupItem = Pick<Group, 'id' | 'name' | 'summary' | 'revision'>;
|
|
7
|
+
interface Props {
|
|
8
|
+
groups: GroupItem[];
|
|
9
|
+
value: string;
|
|
10
|
+
onChange: (value: string) => void;
|
|
11
|
+
prefix: 'planning' | 'graph';
|
|
12
|
+
novelId: string;
|
|
13
|
+
busy: boolean;
|
|
14
|
+
call: (action: string, args: Record<string, unknown>) => Promise<unknown>;
|
|
15
|
+
run: (task: () => Promise<unknown>) => unknown;
|
|
16
|
+
}
|
|
17
|
+
export const filterGroup = (value: string): string | null | undefined => value === '__ungrouped' ? null : value || undefined;
|
|
18
|
+
export function GroupSelect({ groups, value, onChange }: { groups: GroupItem[]; value?: string | null; onChange: (id: string | null) => void }) {
|
|
19
|
+
return <label>分组<select value={value || ''} onChange={e => onChange(e.target.value || null)}><option value="">未分组</option>{groups.map(g => <option key={g.id} value={g.id}>{g.name}</option>)}</select></label>;
|
|
20
|
+
}
|
|
21
|
+
export function Groups({ groups, value, onChange, prefix, novelId, busy, call, run }: Props) {
|
|
22
|
+
const { ask, confirm } = useDialog();
|
|
23
|
+
const selected = groups.find(g => g.id === value);
|
|
24
|
+
const mutate = (action: string, args: Record<string, unknown>) => call(`${prefix}.group.${action}`, { novelId, requestId: crypto.randomUUID(), ...args });
|
|
25
|
+
return <div className="group-toolbar">
|
|
26
|
+
<FilterControl label={prefix === 'planning' ? '规划分组' : '设定分组'} value={value} change={onChange}><option value="">全部分组</option><option value="__ungrouped">未分组</option>{groups.map(g => <option key={g.id} value={g.id}>{g.name}</option>)}</FilterControl>
|
|
27
|
+
<button className="group-new" disabled={busy} onClick={async () => { const name = await ask('新建分组名称'); if (name?.trim()) run(async () => { const group = await mutate('create', { name }) as GroupItem; onChange(group.id); }); }}>+ 新建分组</button>
|
|
28
|
+
{selected && <details className="menu"><summary aria-label="管理分组">···</summary><div className="menu-panel">
|
|
29
|
+
{selected && <><button disabled={busy} onClick={async () => { const name = await ask('分组名称', selected.name); if (name?.trim()) run(() => mutate('update', { groupId: selected.id, expectedRevision: selected.revision, name })); }}>重命名</button>
|
|
30
|
+
<button disabled={busy} onClick={async () => { const summary = await ask('分组描述', selected.summary); if (summary !== null) run(() => mutate('update', { groupId: selected.id, expectedRevision: selected.revision, summary })); }}>编辑描述</button>
|
|
31
|
+
<button className="danger" disabled={busy} onClick={async () => { if (await confirm(`删除分组“${selected.name}”?内容将保留在未分组中。`)) run(async () => { await mutate('delete', { groupId: selected.id, expectedRevision: selected.revision, confirm: true }); onChange('__ungrouped'); }); }}>删除分组</button></>}
|
|
32
|
+
</div></details>}
|
|
33
|
+
</div>;
|
|
34
|
+
}
|