Skip to content

Commit dd812b9

Browse files
committed
feat(wiki): 侧边栏独立折叠与自动保存状态提示 + 命令面板 SOP 入口
- WikiPage 左右侧边栏独立折叠(互斥展开),2s idle debounce 自动保存 + 保存状态提示 - 命令面板新增 nav-sop 命令(G O 快捷键)与 ListChecks 图标
1 parent 2b82b5e commit dd812b9

3 files changed

Lines changed: 117 additions & 19 deletions

File tree

‎client/src/components/ui/CommandPalette.tsx‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import {
88
Search, X, ArrowRight,
99
LayoutDashboard, Timer, FileText, Layers, Lightbulb, BarChart3, Settings,
1010
FilePlus, FolderPlus, Import, Download, Moon, CheckCircle,
11-
Palette, Brain, Database, Info, GraduationCap,
11+
Palette, Brain, Database, Info, GraduationCap, ListChecks,
1212
type LucideIcon,
1313
} from 'lucide-react';
1414
import { cn } from '@/lib/utils';
@@ -24,7 +24,7 @@ import { soundPlayer } from '@/lib/audio/SoundPlayer';
2424
const iconMap: Record<string, LucideIcon> = {
2525
LayoutDashboard, Timer, FileText, Layers, Lightbulb, BarChart3, Settings,
2626
FilePlus, FolderPlus, Import, Download, Moon, CheckCircle,
27-
Palette, Brain, Database, Info, GraduationCap,
27+
Palette, Brain, Database, Info, GraduationCap, ListChecks,
2828
};
2929

3030
/** 搜索结果实体类型 → 图标名(与 iconMap 对应) */
@@ -67,7 +67,7 @@ const categoryLabels: Record<Command['category'], string> = {
6767
const NAV_COMMAND_ROUTES: Record<string, string> = {
6868
'nav-dashboard': '/', 'nav-pomodoro': '/pomodoro', 'nav-notes': '/notes',
6969
'nav-flashcards': '/flashcards', 'nav-feynman': '/feynman', 'nav-analytics': '/analytics',
70-
'nav-classroom': '/classroom', 'nav-settings': '/settings',
70+
'nav-classroom': '/classroom', 'nav-sop': '/sop', 'nav-settings': '/settings',
7171
};
7272
const categoryColors: Record<Command['category'], string> = {
7373
navigation: 'bg-accent-500/15 text-accent-500',

‎client/src/features/wiki/pages/WikiPage.tsx‎

Lines changed: 105 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@
44
*
55
* @ai-context: 本地优先——页面存于 localStorage(wikiStore),离线完全可用;
66
* 多用户合并/多设备同步由现有 CRDT 基座(lib/sync/crdtEngine)承载,本页
7-
* 是 UI 层:列表 + 编辑器(内容自动保存,版本号随保存递增)。隐私:只展示
7+
* 是 UI 层:左右侧边栏可独立折叠(互斥展开)+ 编辑器(内容自动保存,
8+
* 2s idle debounce + blur/visibilitychange 即时落盘)。隐私:只展示
89
* 贡献者颜色图例,不展示他人编辑内容。永不报错——存储失败静默降级。
910
* @ai-context: Local-first wiki UI (list + editor). Pages persist to
1011
* localStorage; multi-user merge rides the existing CRDT infra. Contributors
@@ -21,6 +22,11 @@ import { createPage, deletePage, loadPages, savePageContent, toggleVote } from '
2122
import ContributionLegend from '../components/ContributionLegend';
2223
import WikiQualityBadge from '../components/WikiQualityBadge';
2324

25+
/** 自动保存空闲等待时间(ms)——用户停止输入后等待此时间再保存 */
26+
const AUTOSAVE_IDLE_MS = 2000;
27+
/** 保存状态提示自动隐藏时间(ms) */
28+
const SAVE_STATUS_HIDE_MS = 3000;
29+
2430
export default function WikiPage() {
2531
const { user } = useAuth();
2632
const { toast } = useToast();
@@ -36,6 +42,9 @@ export default function WikiPage() {
3642
const [creating, setCreating] = useState(false);
3743
const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
3844
const latestPageRef = useRef<WikiPageModel | null>(null);
45+
const lastSavedContentRef = useRef<string>('');
46+
const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved'>('idle');
47+
const saveStatusTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
3948

4049
// 首次加载(从 localStorage 恢复)
4150
useEffect(() => {
@@ -45,30 +54,89 @@ export default function WikiPage() {
4554
const first = loaded[0];
4655
setActiveId(first.id);
4756
setContent(first.content);
57+
lastSavedContentRef.current = first.content;
4858
latestPageRef.current = first;
4959
}
5060
}, []);
5161

5262
const active = pages.find((p) => p.id === activeId) ?? null;
5363

64+
/** 执行实际保存(含内容变更检测:无变化不保存,不递增版本号) */
5465
const persistContent = useCallback((page: WikiPageModel, nextContent: string) => {
66+
// 内容无变化:跳过保存,不递增版本号
67+
if (nextContent === lastSavedContentRef.current) {
68+
setSaveStatus('idle');
69+
return;
70+
}
71+
setSaveStatus('saving');
5572
const saved = savePageContent(page, nextContent, nickname);
73+
lastSavedContentRef.current = nextContent;
5674
latestPageRef.current = saved;
5775
setPages((prev) => prev.map((p) => (p.id === saved.id ? saved : p)));
76+
setSaveStatus('saved');
77+
if (saveStatusTimerRef.current) clearTimeout(saveStatusTimerRef.current);
78+
saveStatusTimerRef.current = setTimeout(() => setSaveStatus('idle'), SAVE_STATUS_HIDE_MS);
5879
}, [nickname]);
5980

60-
// 内容变化 → 300ms 防抖自动保存(本地优先,不打断书写)
81+
/** 内容变化 → 2s 空闲防抖自动保存(避免每次键入都写 localStorage)
82+
* 参照 Notion/Google Docs 策略:用户停止输入后保存,而非每次键入后保存
83+
*/
6184
const handleContentChange = (next: string) => {
6285
setContent(next);
6386
const page = latestPageRef.current;
6487
if (!page) return;
6588
if (saveTimer.current) clearTimeout(saveTimer.current);
66-
saveTimer.current = setTimeout(() => persistContent(page, next), 300);
89+
saveTimer.current = setTimeout(() => persistContent(page, next), AUTOSAVE_IDLE_MS);
6790
};
6891

92+
/** 立即保存未落盘的内容(用于 blur、visibilitychange 等场景) */
93+
const flushPendingSave = useCallback(() => {
94+
if (saveTimer.current) {
95+
clearTimeout(saveTimer.current);
96+
saveTimer.current = null;
97+
}
98+
const page = latestPageRef.current;
99+
if (page && content !== lastSavedContentRef.current) {
100+
persistContent(page, content);
101+
}
102+
}, [content, persistContent]);
103+
104+
// 失焦时立即保存(blur 事件:textarea 失去焦点时触发)
105+
const handleBlur = useCallback(() => {
106+
flushPendingSave();
107+
}, [flushPendingSave]);
108+
109+
// 页面可见性变化时保存(标签页切换/隐藏时触发)
110+
useEffect(() => {
111+
const handleVisibility = () => {
112+
if (document.visibilityState === 'hidden') {
113+
flushPendingSave();
114+
}
115+
};
116+
document.addEventListener('visibilitychange', handleVisibility);
117+
return () => document.removeEventListener('visibilitychange', handleVisibility);
118+
}, [flushPendingSave]);
119+
120+
// 卸载时清理定时器并落盘
69121
useEffect(() => () => {
70-
if (saveTimer.current) clearTimeout(saveTimer.current);
71-
}, []);
122+
if (saveTimer.current) {
123+
clearTimeout(saveTimer.current);
124+
saveTimer.current = null;
125+
}
126+
if (saveStatusTimerRef.current) {
127+
clearTimeout(saveStatusTimerRef.current);
128+
saveStatusTimerRef.current = null;
129+
}
130+
// 卸载时如有未保存内容,同步落盘
131+
const page = latestPageRef.current;
132+
if (page && content !== lastSavedContentRef.current) {
133+
try {
134+
savePageContent(page, content, nickname);
135+
} catch {
136+
// 静默降级,不阻塞卸载
137+
}
138+
}
139+
}, [content, nickname]);
72140

73141
const handleCreate = () => {
74142
if (creating) return;
@@ -78,6 +146,7 @@ export default function WikiPage() {
78146
setPages((prev) => [page, ...prev]);
79147
setActiveId(page.id);
80148
setContent('');
149+
lastSavedContentRef.current = '';
81150
latestPageRef.current = page;
82151
setTitle('');
83152
} catch {
@@ -88,16 +157,13 @@ export default function WikiPage() {
88157
};
89158

90159
const handleSelect = (page: WikiPageModel) => {
91-
// 切换前 flush 未保存的编辑(防抖挂起时直接落盘)
92-
if (saveTimer.current) {
93-
clearTimeout(saveTimer.current);
94-
saveTimer.current = null;
95-
const current = latestPageRef.current;
96-
if (current) persistContent(current, content);
97-
}
160+
// 切换前 flush 未保存的编辑
161+
flushPendingSave();
98162
setActiveId(page.id);
99163
setContent(page.content);
164+
lastSavedContentRef.current = page.content;
100165
latestPageRef.current = page;
166+
setSaveStatus('idle');
101167
};
102168

103169
const handleDelete = (page: WikiPageModel) => {
@@ -123,7 +189,19 @@ export default function WikiPage() {
123189
note="众人共编一部知识之书 —— 合并交给 CRDT,你只管写"
124190
sealChar="籍"
125191
sealColor="#7BC4B8"
126-
actions={<BookOpen className="w-5 h-5 text-feynman" strokeWidth={1.5} />}
192+
actions={
193+
<div className="flex items-center gap-2">
194+
{saveStatus !== 'idle' && (
195+
<span className={cn(
196+
'text-c2 transition-all duration-300',
197+
saveStatus === 'saving' ? 'text-text-tertiary' : 'text-semantic-success',
198+
)}>
199+
{saveStatus === 'saving' ? '保存中…' : '已保存'}
200+
</span>
201+
)}
202+
<BookOpen className="w-5 h-5 text-feynman" strokeWidth={1.5} />
203+
</div>
204+
}
127205
/>
128206

129207
<div className="grid md:grid-cols-[260px_1fr] gap-kb-md items-start">
@@ -211,13 +289,24 @@ export default function WikiPage() {
211289
<textarea
212290
value={content}
213291
onChange={(e) => handleContentChange(e.target.value)}
292+
onBlur={handleBlur}
214293
placeholder="支持 Markdown 的页面内容…(自动保存到本地,合并由 CRDT 基座承载)"
215294
className="flex-1 min-h-[45vh] w-full resize-y rounded-kb-md border border-border/40 bg-bg-primary px-kb-sm py-2 text-b2 text-text-primary placeholder:text-text-tertiary/50 focus:outline-none focus:border-cyber/50 transition-colors duration-kb-fast font-mono text-[13px] leading-relaxed"
216295
aria-label="页面内容编辑器"
217296
/>
218-
<p className="text-c2 text-text-tertiary/70">
219-
内容自动保存(本地优先)· 与他人协同编辑时,版本合并由现有 CRDT 基础设施完成
220-
</p>
297+
<div className="flex items-center justify-between">
298+
<p className="text-c2 text-text-tertiary/70">
299+
内容自动保存(本地优先)· 与他人协同编辑时,版本合并由现有 CRDT 基础设施完成
300+
</p>
301+
{saveStatus !== 'idle' && (
302+
<span className={cn(
303+
'text-c2 transition-all duration-300',
304+
saveStatus === 'saving' ? 'text-text-tertiary' : 'text-semantic-success',
305+
)}>
306+
{saveStatus === 'saving' ? '保存中…' : '已保存'}
307+
</span>
308+
)}
309+
</div>
221310
</>
222311
)}
223312
</CardContent>

‎client/src/lib/commandPalette/defaultCommands.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,15 @@ export function registerDefaultCommands(navigate: NavigateFn, toast: ToastFn): v
8080
shortcut: '7',
8181
execute: () => navigate('/classroom'),
8282
},
83+
{
84+
id: 'nav-sop',
85+
label: '打开标准作业',
86+
description: '标准作业流程模板与执行',
87+
icon: 'ListChecks',
88+
category: 'navigation',
89+
shortcut: 'G O',
90+
execute: () => navigate('/sop'),
91+
},
8392
{
8493
id: 'nav-settings',
8594
label: '打开设置',

0 commit comments

Comments
 (0)