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 '
2122import ContributionLegend from '../components/ContributionLegend' ;
2223import WikiQualityBadge from '../components/WikiQualityBadge' ;
2324
25+ /** 自动保存空闲等待时间(ms)——用户停止输入后等待此时间再保存 */
26+ const AUTOSAVE_IDLE_MS = 2000 ;
27+ /** 保存状态提示自动隐藏时间(ms) */
28+ const SAVE_STATUS_HIDE_MS = 3000 ;
29+
2430export 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 >
0 commit comments