Skip to main content

yggdrasil/pages/admin/
note_editor.rs

1//! 自动保存工作稿;公开与知识库快照只由明确操作更新。
2use super::note_drafts::use_draft_protection;
3use super::notes::client_request;
4use crate::api::notes::*;
5use crate::components::forms::FormSelect;
6use crate::components::ui::{Checkbox, BTN_PRIMARY_SM, BTN_SECONDARY};
7use crate::models::note::*;
8use crate::router::Route;
9use dioxus::prelude::*;
10
11#[derive(Clone, Copy)]
12struct EditorState {
13    draft: Signal<NoteDraft>,
14    saved: Signal<Option<Note>>,
15    busy: Signal<bool>,
16    uploading: Signal<bool>,
17    error: Signal<String>,
18    status: Signal<String>,
19}
20
21impl EditorState {
22    fn dirty(self) -> bool {
23        self.saved()
24            .map(|n| n.draft() != self.draft())
25            .unwrap_or_else(|| {
26                !self.draft().content_md.is_empty() || !self.draft().title.is_empty()
27            })
28    }
29    fn saved(self) -> Option<Note> {
30        (self.saved)()
31    }
32    fn draft(self) -> NoteDraft {
33        (self.draft)()
34    }
35}
36
37async fn persist(mut state: EditorState, action: Option<NoteAction>) {
38    if (state.busy)() {
39        return;
40    }
41    if (state.uploading)() {
42        state
43            .error
44            .set("请先等待图片上传完成,或移除失败的图片".into());
45        return;
46    }
47    state.busy.set(true);
48    state.error.set(String::new());
49    state.status.set("正在保存…".into());
50    let snapshot = state.draft();
51    let result = async {
52        let note = if state.dirty() || state.saved().is_none() {
53            save_note(snapshot.clone()).await?
54        } else {
55            state.saved().unwrap()
56        };
57        // 保存期间仍可输入,仅推进 ID / 版本,绝不覆盖新的文字。
58        if state.draft() == snapshot {
59            state.draft.set(note.draft());
60        } else {
61            state.draft.with_mut(|d| {
62                d.id = Some(note.id);
63                d.expected_version = Some(note.version);
64            });
65        }
66        state.saved.set(Some(note.clone()));
67        if let Some(action) = action {
68            act_on_note(note.id, note.version, action).await
69        } else {
70            Ok(note)
71        }
72    }
73    .await;
74    match result {
75        Ok(note) => {
76            state.saved.set(Some(note));
77            state.status.set(
78                match action {
79                    Some(NoteAction::Publish) => "公开版本已更新",
80                    Some(NoteAction::IncludeKnowledge) => "已收录当前版本",
81                    Some(NoteAction::Unpublish) => "已撤回公开版本",
82                    Some(NoteAction::ExcludeKnowledge) => "已移出知识库",
83                    Some(NoteAction::Trash) => "已移入回收站",
84                    _ => "草稿已保存",
85                }
86                .into(),
87            );
88        }
89        Err(e) => {
90            state.error.set(e.to_string());
91            state.status.set("保存未完成".into());
92        }
93    }
94    state.busy.set(false);
95}
96
97#[component]
98pub fn NewNote() -> Element {
99    rsx! { NoteEditor { initial: None } }
100}
101
102#[component]
103pub fn EditNote(id: i32) -> Element {
104    rsx! {for key in std::iter::once(id) {LoadNoteEditor {key:"{key}",id:key}}}
105}
106
107#[component]
108fn LoadNoteEditor(id: i32) -> Element {
109    let response = use_resource(move || client_request(get_owned_note(id, None)));
110    let data = response.read();
111    match data.as_ref() {
112        Some(Ok(note)) => rsx! {NoteEditor {initial:Some(note.clone())}},
113        Some(Err(e)) => rsx! {p {class:"notes-error",role:"alert","{e}"}},
114        None => rsx! {crate::pages::notes::NotesSkeleton {}},
115    }
116}
117
118#[component]
119fn NoteEditor(initial: Option<Note>) -> Element {
120    let mut state = EditorState {
121        draft: use_signal(|| initial.as_ref().map(Note::draft).unwrap_or_default()),
122        saved: use_signal(|| initial.clone()),
123        busy: use_signal(|| false),
124        uploading: use_signal(|| false),
125        error: use_signal(String::new),
126        status: use_signal(|| "自动保存工作稿".to_string()),
127    };
128    let mut editor_epoch = use_signal(|| 0_u32);
129    let mut tags = use_signal(|| state.draft().tags.join(", "));
130    let slot = state
131        .draft()
132        .id
133        .map(|id| id.to_string())
134        .unwrap_or_else(|| "new".into());
135    let (mut recovery, cache_failed) = use_draft_protection(slot, state.draft, move || {
136        state.dirty() || (state.busy)() || (state.uploading)()
137    });
138    let books = use_resource(|| client_request(owned_notebooks()));
139    let mut preview = use_signal(|| false);
140    let mut revisions = use_signal(Vec::<NoteRevision>::new);
141    let mut show_history = use_signal(|| false);
142    let mut confirm_trash = use_signal(|| false);
143    let navigator = navigator();
144
145    #[cfg(target_arch = "wasm32")]
146    use_effect(move || {
147        let snapshot = state.draft();
148        let busy = (state.busy)();
149        let uploading = (state.uploading)();
150        let failed = !(state.error)().is_empty();
151        if busy || uploading || failed || recovery().is_some() || !state.dirty() {
152            return;
153        }
154        spawn(async move {
155            crate::utils::time::sleep_ms(1200).await;
156            if state.draft() == snapshot
157                && !(state.busy)()
158                && !(state.uploading)()
159                && state.dirty()
160                && (state.error)().is_empty()
161                && recovery().is_none()
162            {
163                persist(state, None).await;
164            }
165        });
166    });
167
168    let deleted = state.saved().is_some_and(|n| n.deleted_at.is_some());
169    rsx! {
170        div {class:"notes-admin",
171            div {class:"notes-admin-heading",
172                div {
173                    Link {class:"note-read",to:Route::AdminNotes {},"← 笔记管理"}
174                    p {class:"notes-status",role:"status",aria_live:"polite","{state.status}" if state.dirty() {" · 有未保存修改"}}
175                }
176                div {class:"notes-admin-actions",
177                    button {class:BTN_SECONDARY,disabled:(state.busy)() || deleted || recovery().is_some(),onclick:move |_|async move {persist(state,None).await;},"保存草稿"}
178                    button {class:BTN_SECONDARY,disabled:(state.busy)() || state.saved().is_none() || recovery().is_some(),onclick:move |_| {spawn(async move {persist(state,None).await;if (state.error)().is_empty(){preview.set(!preview());}});},if preview(){"返回编辑"}else{"预览"}}
179                    button {class:BTN_PRIMARY_SM,disabled:(state.busy)() || deleted || recovery().is_some(),onclick:move |_|async move {persist(state,Some(NoteAction::Publish)).await;},
180                        if state.saved().is_some_and(|n|n.published_version.is_some()) {"更新公开版"} else {"发布到前台"}
181                    }
182                }
183            }
184            if !(state.error)().is_empty() {p {class:"notes-error",role:"alert","{state.error}"}}
185            if cache_failed(){p {class:"notes-error",role:"alert","浏览器无法保存本地恢复副本,请先保存到服务端再离开。"}}
186            if let Some(local) = recovery() {
187                div {class:"notes-error",role:"alert",
188                    p {"此标签页留有未保存内容。恢复只修改工作稿,不会发布;若服务端已有新版本,请先核对内容。"}
189                    button {class:BTN_SECONDARY,onclick:move |_| {
190                        let mut draft=local.clone();
191                        if let Some(saved)=state.saved(){draft.id=Some(saved.id);draft.expected_version=Some(saved.version);}
192                        tags.set(draft.tags.join(", "));state.draft.set(draft);editor_epoch+=1;recovery.set(None);
193                    },"恢复未保存内容"}
194                    button {class:"note-read",onclick:move |_|recovery.set(None),"使用服务端版本"}
195                }
196            }
197            if recovery().is_some() {
198                p {class:"notes-status","请选择恢复或使用服务端版本,再继续编辑。"}
199            } else if deleted {
200                p {class:"notes-error","这条笔记在回收站中。恢复后可继续编辑,公开状态与知识库收录需要重新确认。"}
201                button {class:BTN_PRIMARY_SM,onclick:move |_|async move {persist(state,Some(NoteAction::Restore)).await;},"恢复笔记"}
202            } else if preview() {
203                if let Some(note)=state.saved() {crate::pages::notes::NoteDocument {key:"{note.version}",note,preview:true}}
204            } else {
205                div {class:"notes-editor-grid",
206                    div {class:"notes-editor-main",
207                        input {aria_label:"笔记标题",placeholder:if state.draft().kind==NoteKind::Moment {"标题可选,先记下来…"} else {"给这篇笔记一个标题"},maxlength:200,
208                            value:state.draft().title,oninput:move |ev|state.draft.with_mut(|d|d.title=ev.value())}
209                        for epoch in std::iter::once(editor_epoch()) {
210                            NoteComposer {key:"{epoch}",initial:state.draft().content_md,on_change:move |md|state.draft.with_mut(|d|d.content_md=md),uploading:state.uploading}
211                        }
212                    }
213                    aside {class:"notes-editor-side",
214                        div {class:"notes-editor-field",label {r#for:"note-kind","记录方式"}
215                            FormSelect {
216                                id: Some("note-kind".to_string()),
217                                value: state.draft().kind,
218                                options: vec![(NoteKind::Moment, "随记"), (NoteKind::Topic, "主题笔记")],
219                                onchange: move |kind: NoteKind| state.draft.with_mut(|d| d.kind = kind),
220                            }
221                        }
222                        div {class:"notes-editor-field",label {r#for:"note-tags","标签"} input {id:"note-tags",r#type:"text",placeholder:"Rust, 排错, 读书",value:"{tags}",oninput:move |ev| {
223                            let value=ev.value();state.draft.with_mut(|d|d.tags=value.split([',',',']).map(str::trim).filter(|s|!s.is_empty()).map(str::to_string).collect());tags.set(value);
224                        }}}
225                        fieldset {class:"notes-editor-section notes-editor-notebooks",legend {"收录到笔记本"}
226                            if let Some(Ok(items))=books.read().as_ref() {
227                                for book in items {label {class:"notebook-check",key:"{book.id}",
228                                    Checkbox {checked:state.draft().notebook_ids.contains(&book.id),onchange:{let id=book.id;move |checked|state.draft.with_mut(|d| {d.notebook_ids.retain(|b|*b!=id);if checked{d.notebook_ids.push(id);d.notebook_ids.sort_unstable();}})}} "{book.title}" if book.archived_at.is_some(){"(已归档)"}
229                                }}
230                                if items.is_empty() {Link {to:Route::AdminNotebooks {},"创建第一个笔记本 →"}}
231                            }
232                        }
233                        div {class:"notes-editor-section notes-editor-knowledge",h2 {"AI 知识库"}
234                            p {"收录后,获得授权的 AI 可以读取这一版内容。无需公开发布。"}
235                            if let Some(version)=state.saved().and_then(|n|n.knowledge_version) {p {"当前收录:v{version}"}}
236                            button {class:BTN_SECONDARY,disabled:(state.busy)(),onclick:move |_|async move {persist(state,Some(NoteAction::IncludeKnowledge)).await;},"收录当前版本"}
237                            if state.saved().is_some_and(|n|n.knowledge_version.is_some()) {button {class:"note-read",disabled:(state.busy)(),onclick:move |_|async move {persist(state,Some(NoteAction::ExcludeKnowledge)).await;},"移出知识库"}}
238                        }
239                        if let Some(note)=state.saved() {
240                            div {class:"notes-editor-section notes-editor-public",h2 {"公开状态"}
241                                if let Some(version)=note.published_version {p {"已公开 v{version},草稿修改不会自动发布。"}
242                                    div {class:"notes-editor-inline-actions",
243                                        a {class:"note-read",href:format!("/notes/{}",note.slug),target:"_blank",rel:"noopener","查看公开页面 ↗"}
244                                        button {class:"note-read",disabled:(state.busy)(),onclick:move |_|async move {persist(state,Some(NoteAction::Unpublish)).await;},"撤回公开版"}
245                                    }
246                                } else {p {"仅自己可见。"}}
247                            }
248                            div {class:"notes-editor-section notes-editor-history-section",
249                                button {class:"note-read notes-editor-history-toggle",onclick:move |_| {show_history.set(!show_history());if let Some(n)=state.saved(){spawn(async move {match note_history(n.id).await {Ok(items)=>revisions.set(items),Err(e)=>state.error.set(e.to_string())}});}},"历史版本"}
250                                if show_history() {
251                                    p {"恢复会生成新草稿,保留现有历史。"}
252                                    div {class:"notes-editor-history",
253                                        for revision in revisions() {
254                                            button {disabled:(state.busy)(),onclick:move |_| {
255                                                if let Some(current)=state.saved() {spawn(async move {
256                                                    persist(state,None).await;
257                                                    if !(state.error)().is_empty(){return;}
258                                                    match get_owned_note(current.id,Some(revision.version)).await {
259                                                        Ok(old)=>{let mut draft=old.draft();draft.expected_version=state.saved().map(|n|n.version);tags.set(draft.tags.join(", "));state.draft.set(draft);editor_epoch+=1;persist(state,None).await;},
260                                                        Err(e)=>state.error.set(e.to_string()),
261                                                    }
262                                                });}
263                                            },{format!("v{} · {} · 恢复",revision.version,revision.created_at.format("%m-%d %H:%M"))}}
264                                        }
265                                    }
266                                }
267                            }
268                            div {class:"notes-editor-section notes-editor-danger",
269                                if confirm_trash() {
270                                    p {"移入回收站将撤回公开版并移出知识库,之后可以恢复。"}
271                                    button {class:BTN_SECONDARY,disabled:(state.busy)(),onclick:move |_|async move {persist(state,Some(NoteAction::Trash)).await;if (state.error)().is_empty(){navigator.push(Route::AdminNotes {});}},"确认移入回收站"}
272                                    button {class:"note-read",onclick:move |_|confirm_trash.set(false),"取消"}
273                                } else {button {class:"note-read",onclick:move |_|confirm_trash.set(true),"移入回收站"}}
274                            }
275                        }
276                    }
277                }
278            }
279        }
280    }
281}
282
283/// 富文本编辑与笔记专用的受保护图片上传。
284#[component]
285fn NoteComposer(
286    initial: String,
287    on_change: EventHandler<String>,
288    mut uploading: Signal<bool>,
289) -> Element {
290    #[cfg(target_arch = "wasm32")]
291    {
292        use crate::bridges::library::{library_ready, use_browser_library};
293        use crate::bridges::tiptap::{
294            self, EditorHandle, EditorOptions, UploadErrorEntry, UploadsInFlight,
295        };
296        use wasm_bindgen::prelude::*;
297        let library = use_browser_library("tiptap", || true);
298        let mut editor = use_signal(|| None::<EditorHandle>);
299        let mut ready = use_signal(|| false);
300        let mut error = use_signal(String::new);
301        let counts = use_signal(UploadsInFlight::default);
302        let errors = use_signal(Vec::<UploadErrorEntry>::new);
303        let mut pick = use_signal(|| false);
304        let picking_upload = use_signal(|| false);
305        use_drop(move || editor.set(None));
306        use_effect(move || {
307            uploading.set(counts().uploading > 0 || counts().error > 0 || picking_upload())
308        });
309        use_effect(move || {
310            if editor.peek().is_some() || !library_ready(library) {
311                return;
312            }
313            let update = Closure::new(move |md: String| on_change.call(md));
314            let on_ready = Closure::new(move || ready.set(true));
315            let upload = Closure::new(move |file: web_sys::File| {
316                wasm_bindgen_futures::future_to_promise(async move {
317                    tiptap::wasm::upload_note_image_file(file)
318                        .await
319                        .map(|s| JsValue::from_str(&s))
320                        .map_err(|e| JsValue::from_str(&e))
321                })
322            });
323            let upload_event = Closure::new(move |ev: tiptap::UploadEventJs| {
324                tiptap::consume_upload_event(&ev, counts, errors)
325            });
326            let run = tiptap::make_run_code_closure();
327            let picker = Closure::new(move || pick.set(true));
328            let opts = EditorOptions::new();
329            opts.set_placeholder("从一个想法开始。输入 / 插入代码、图片、公式或引用…");
330            opts.set_on_update(&update);
331            opts.set_on_ready(&on_ready);
332            opts.set_on_image_upload(&upload);
333            opts.set_on_upload_event(&upload_event);
334            opts.set_on_run_code(&run);
335            opts.set_on_pick_from_library(&picker);
336            match tiptap::get_module().create("note-tiptap", &opts) {
337                Ok(Some(inst)) => {
338                    inst.set_markdown(&initial);
339                    editor.set(Some(EditorHandle::new(
340                        inst,
341                        update,
342                        upload,
343                        on_ready,
344                        upload_event,
345                        run,
346                        picker,
347                    )));
348                }
349                _ => error.set("编辑器初始化失败,请刷新后重试".into()),
350            }
351        });
352        return rsx! {
353            div {class:"notes-editor-surface",
354                crate::bridges::library::LibraryLoadError {library}
355                if !ready() {p {role:"status","正在加载编辑器…"}}
356                if !error().is_empty() {p {class:"notes-error",role:"alert","{error}"}}
357                for item in errors() {p {class:"notes-error",key:"{item.id}","{item.message}"
358                    button {class:"note-read",onclick:move |_| {if let Some(handle)=editor.peek().as_ref(){handle.instance().remove_upload_by_upload_id(&item.id);}},"移除失败图片"}
359                }}
360                div {id:"note-tiptap"}
361            }
362            if pick() {
363                p {class:"notes-status","素材库中的图片原本就是公开素材。需要私密图片时,请在编辑器中上传。"}
364                crate::components::assets::AssetPickerModal {visible:pick,cover_uploading:picking_upload,title:"选择公开素材",multi:true,on_select:move |items:Vec<crate::components::assets::AssetSelection>| {
365                    if let Some(handle)=editor.peek().as_ref(){if let Ok(json)=serde_json::to_string(&items){handle.instance().insert_images_from_library(&json);}}
366                    pick.set(false);
367                }}
368            }
369        };
370    }
371    #[cfg(not(target_arch = "wasm32"))]
372    {
373        let _ = (initial, on_change, uploading);
374        rsx! {div {class:"notes-editor-surface",p {role:"status","正在加载编辑器…"}}}
375    }
376}