Skip to main content

yggdrasil/pages/admin/
notes.rs

1//! 笔记管理与笔记本编辑。
2use super::note_drafts::{clear_submitted, use_draft_protection};
3use crate::api::notes::*;
4use crate::components::forms::{FormInput, FormSelect, INPUT_CLASS, INPUT_INLINE_CLASS};
5use crate::components::ui::{FilterTabs, BTN_PRIMARY_SM, BTN_SECONDARY as BTN_SECONDARY_SM};
6use crate::models::note::*;
7use crate::router::Route;
8
9/// 私人数据仅在浏览器鉴权后读取,避免进入按 URL 共享的 SSR 缓存。
10pub(super) async fn client_request<T>(request: impl std::future::Future<Output = T>) -> T {
11    #[cfg(target_arch = "wasm32")]
12    {
13        request.await
14    }
15    #[cfg(not(target_arch = "wasm32"))]
16    {
17        drop(request);
18        std::future::pending().await
19    }
20}
21use dioxus::prelude::*;
22
23#[component]
24pub fn AdminNotes() -> Element {
25    let mut filter = use_signal(NoteFilter::default);
26    let mut query = use_signal(String::new);
27    let mut quick = use_signal(String::new);
28    let mut busy = use_signal(|| false);
29    let mut error = use_signal(String::new);
30    let mut last_saved = use_signal(|| None::<i32>);
31    let (mut recovery, cache_failed) =
32        use_draft_protection("quick".into(), quick, move || !quick().is_empty() || busy());
33    let mut response = use_resource(move || client_request(list_owned_notes(filter())));
34    let navigator = navigator();
35    rsx! {
36        div { class: "notes-admin",
37            div { class: "notes-admin-heading",
38                div { h1 { "笔记" } p { "捕捉此刻的想法,也为长久的知识留一个位置。" } }
39                div { class: "notes-admin-actions",
40                    Link { class: BTN_SECONDARY_SM, to: Route::AdminNotebooks {}, "管理笔记本" }
41                    Link { class: BTN_PRIMARY_SM, to: Route::NewNote {}, "+ 写笔记" }
42                }
43            }
44            if !error().is_empty() { p { class: "notes-error", role: "alert", "{error}" } }
45            if cache_failed() {p {class:"notes-error",role:"alert","浏览器无法保存本地恢复副本,请先保存到服务端再离开。"}}
46            if let Some(local)=recovery() {
47                div {class:"notes-error",role:"alert",p {"此标签页有尚未保存的快速记录。"}
48                    button {class:BTN_SECONDARY_SM,onclick:move |_| {quick.set(local.clone());recovery.set(None);},"恢复记录"}
49                    button {class:"note-read",onclick:move |_|recovery.set(None),"放弃恢复"}
50                }
51            }
52            if let Some(id)=last_saved() {p {class:"notes-status",role:"status","上一条已保存,继续输入的内容已保留。 " Link {class:"note-read",to:Route::EditNote {id},"查看已保存记录 →"}}}
53            form { class: "notes-quick", onsubmit: move |ev| {
54                ev.prevent_default();
55                if busy() || recovery().is_some() || quick().trim().is_empty() {return;}
56                busy.set(true); error.set(String::new());
57                let content_md=quick();
58                spawn(async move {
59                    let result=save_note(NoteDraft {content_md:content_md.clone(),..Default::default()}).await;
60                    busy.set(false);
61                    match result {
62                        Ok(note) => {
63                            let unchanged=quick.with_mut(|current|clear_submitted(current,&content_md));
64                            if unchanged {navigator.push(Route::EditNote {id:note.id});}
65                            else {last_saved.set(Some(note.id));response.restart();}
66                        },
67                        Err(e) => error.set(e.to_string()),
68                    }
69                });
70            },
71                textarea { aria_label: "快速记录", disabled:recovery().is_some(),placeholder: "有什么想记下的?一句话、一段代码,或一个刚发现的链接。", value: "{quick}", oninput: move |ev| quick.set(ev.value()) }
72                footer { span { "先存为私密草稿,稍后整理。" } button { class: BTN_PRIMARY_SM, r#type: "submit", disabled: busy() || recovery().is_some() || quick().trim().is_empty(), if busy() { "保存中…" } else { "记下来 →" } } }
73            }
74            FilterTabs { items: vec![("all","全部"),("moment","随记"),("topic","主题笔记"),("trash","回收站")],
75                active_value: if filter().trash {"trash".to_string()} else {filter().kind.map(|k|k.as_str()).unwrap_or("all").to_string()},
76                on_change: move |value: String| filter.with_mut(|f| {f.trash=value=="trash";f.kind=match value.as_str(){"moment"=>Some(NoteKind::Moment),"topic"=>Some(NoteKind::Topic),_=>None};f.page=1;})
77            }
78            form {
79                class: "flex items-center gap-2 mt-4",
80                onsubmit: move |ev| {
81                    ev.prevent_default();
82                    filter.with_mut(|f| {
83                        f.query = query();
84                        f.page = 1;
85                    });
86                },
87                FormInput {
88                    r#type: "search",
89                    aria_label: "筛选笔记",
90                    placeholder: "搜索标题、正文或标签",
91                    value: query(),
92                    class: INPUT_INLINE_CLASS,
93                    oninput: move |v: String| {
94                        query.set(v.clone());
95                        if v.is_empty() && !filter().query.is_empty() {
96                            filter.with_mut(|f| {
97                                f.query = String::new();
98                                f.page = 1;
99                            });
100                        }
101                    },
102                }
103                button {
104                    class: "{BTN_PRIMARY_SM} shrink-0",
105                    r#type: "submit",
106                    "搜索"
107                }
108                if !filter().query.is_empty() {
109                    button {
110                        class: "{BTN_SECONDARY_SM} shrink-0",
111                        r#type: "button",
112                        onclick: move |_| {
113                            query.set(String::new());
114                            filter.with_mut(|f| {
115                                f.query = String::new();
116                                f.page = 1;
117                            });
118                        },
119                        "清除"
120                    }
121                }
122            }
123            div { class: "notes-admin-filters mt-4",
124                div { class: "notes-admin-filter",
125                    label { r#for: "notes-published-filter", "公开状态" }
126                    FormSelect {
127                        id: Some("notes-published-filter".to_string()),
128                        aria_label: "按公开状态筛选",
129                        value: filter().published,
130                        options: vec![(None, "全部"), (Some(true), "已公开"), (Some(false), "未公开(私密)")],
131                        onchange: move |value: Option<bool>| filter.with_mut(|f| {
132                            f.published = value;
133                            f.page = 1;
134                        }),
135                    }
136                }
137                div { class: "notes-admin-filter",
138                    label { r#for: "notes-knowledge-filter", "知识库状态" }
139                    FormSelect {
140                        id: Some("notes-knowledge-filter".to_string()),
141                        aria_label: "按知识库状态筛选",
142                        value: filter().knowledge,
143                        options: vec![(None, "全部"), (Some(true), "已收录"), (Some(false), "未收录")],
144                        onchange: move |value: Option<bool>| filter.with_mut(|f| {
145                            f.knowledge = value;
146                            f.page = 1;
147                        }),
148                    }
149                }
150            }
151            div { class: "notes-admin-list mt-5",
152                match response.read().as_ref() {
153                    Some(Ok(page)) => rsx! {
154                        if page.notes.is_empty() { crate::components::empty_state::EmptyState {title:"还没有笔记",description:"从上面的快速记录开始,或者写一篇主题笔记。"} }
155                        for note in &page.notes {
156                            div { class: "notes-admin-row", key: "{note.id}",
157                                div { h3 { Link {to:Route::EditNote {id:note.id}, "{note.display_title()}"} }
158                                    p { "{note.kind.label()} · "
159                                        if note.deleted_at.is_some() { "回收站" } else if note.published_version.is_some() { "已公开" } else { "私密" }
160                                        if note.knowledge_version.is_some() { " · 已收录知识库" }
161                                        " · v{note.version}"
162                                    }
163                                }
164                                div { class: "notes-admin-actions",
165                                    if note.deleted_at.is_some() {
166                                        button { class:BTN_SECONDARY_SM, onclick:{let n=note.clone(); move |_| {let n=n.clone();spawn(async move {match act_on_note(n.id,n.version,NoteAction::Restore).await {Ok(_)=>response.restart(),Err(e)=>error.set(e.to_string())}});}}, "恢复为草稿" }
167                                    } else {
168                                        Link { class:BTN_SECONDARY_SM, to:Route::EditNote {id:note.id}, "编辑 →" }
169                                    }
170                                }
171                            }
172                        }
173                        div { class:"notes-pagination",
174                            button { disabled:filter().page<=1, onclick:move |_|filter.with_mut(|f|f.page=(f.page-1).max(1)), "← 上一页" }
175                            span {"共 {page.total} 条"}
176                            button { disabled:i64::from(filter().page.max(1))*20>=page.total,onclick:move |_|filter.with_mut(|f|f.page=f.page.max(1)+1),"下一页 →"}
177                        }
178                    },
179                    Some(Err(e))=>rsx! {p {class:"notes-error",role:"alert","{e}"}},
180                    None=>rsx! {crate::pages::notes::NotesSkeleton {}},
181                }
182            }
183        }
184    }
185}
186
187#[component]
188pub fn AdminNotebooks() -> Element {
189    let mut books = use_resource(|| client_request(owned_notebooks()));
190    let mut editing = use_signal(|| None::<Notebook>);
191    let mut form_open = use_signal(|| false);
192    let mut order_id = use_signal(|| None::<i32>);
193    let mut order_open = use_signal(|| false);
194    let mut archived = use_signal(|| false);
195    let mut confirm_archive = use_signal(|| None::<i32>);
196    let mut busy = use_signal(|| false);
197    let mut error = use_signal(String::new);
198
199    let mut close_order = move |()| {
200        order_open.set(false);
201        #[cfg(target_arch = "wasm32")]
202        spawn(async move {
203            crate::utils::time::sleep_ms(320).await;
204            if !*order_open.peek() {
205                order_id.set(None);
206            }
207        });
208        #[cfg(not(target_arch = "wasm32"))]
209        order_id.set(None);
210    };
211
212    let mut toggle_order = move |id: i32| {
213        if order_open() && order_id() == Some(id) {
214            close_order(());
215        } else {
216            form_open.set(false);
217            order_id.set(Some(id));
218            order_open.set(true);
219        }
220    };
221
222    rsx! {
223        div { class:"notes-admin",
224            div {class:"notes-admin-heading",
225                div {h1 {"笔记本"} p {"把相关的记录放在一起,按你的思路排列。"}}
226                div {class:"notes-admin-actions",
227                    Link {class:BTN_SECONDARY_SM,to:Route::AdminNotes {},"返回笔记"}
228                    button {
229                        class: BTN_PRIMARY_SM,
230                        onclick: move |_| {
231                            editing.set(None);
232                            form_open.set(true);
233                            if order_open() {
234                                close_order(());
235                            }
236                        },
237                        "+ 新建笔记本"
238                    }
239                }
240            }
241            if form_open() {
242                for key in std::iter::once(editing().map(|b|b.id).unwrap_or(0)) {
243                    NotebookForm {key:"{key}",book:editing(),on_done:move |_| {form_open.set(false);books.restart();}}
244                }
245            }
246            div {
247                class: "notebook-order-panel",
248                "data-open": "{order_open()}",
249                inert: if order_open() { None } else { Some("") },
250                div {
251                    class: "overflow-hidden min-h-0",
252                    if let Some(id) = order_id() {
253                        NotebookOrder {
254                            key: "{id}",
255                            id,
256                            on_close: move |_| close_order(()),
257                        }
258                    }
259                }
260            }
261            FilterTabs {
262                items: vec![("active","使用中"),("archived","已归档")],
263                active_value: if archived(){"archived".to_string()}else{"active".to_string()},
264                on_change: move |value: String| {
265                    archived.set(value == "archived");
266                    confirm_archive.set(None);
267                    if order_open() {
268                        close_order(());
269                    }
270                }
271            }
272            p {class:"notes-status my-3","归档隐藏公开目录,不删除笔记,也不撤回笔记自身的发布或 AI 授权。恢复后目录保持私密。"}
273            if !error().is_empty(){p {class:"notes-error",role:"alert","{error}"}}
274            div {class:"notes-admin-list",
275                match books.read().as_ref() {
276                    Some(Ok(items))=>rsx! {
277                        if !items.iter().any(|b|b.archived_at.is_some()==archived()) {crate::components::empty_state::EmptyState {title:"这里还没有笔记本",description:"新建一个主题,或切换使用中和已归档列表。"}}
278                        for book in items.iter().filter(|b|b.archived_at.is_some()==archived()) {
279                            div {class:"notes-admin-row",key:"{book.id}",
280                                div {class:"notes-admin-row-main",
281                                    h3 {"{book.title}"}
282                                    p {class:"notes-admin-row-meta","{book.note_count} 篇 · "
283                                        span {class:if book.is_public {"notes-admin-row-public"} else {"notes-admin-row-private"},if book.is_public {"公开笔记本"} else {"私密笔记本"}}
284                                    }
285                                    if !book.description.trim().is_empty() {p {class:"notes-admin-row-description","{book.description}"}}
286                                }
287                                div {class:"notes-admin-actions notes-admin-row-actions",
288                                    button {
289                                        class: BTN_SECONDARY_SM,
290                                        disabled: busy(),
291                                        onclick: {
292                                            let book = book.clone();
293                                            move |_| {
294                                                editing.set(Some(book.clone()));
295                                                form_open.set(true);
296                                                if order_open() {
297                                                    close_order(());
298                                                }
299                                            }
300                                        },
301                                        "编辑"
302                                    }
303                                    button {
304                                        class: if order_open() && order_id() == Some(book.id) {
305                                            format!("{BTN_SECONDARY_SM} ring-2 ring-[var(--color-paper-accent)] text-[var(--color-paper-primary)]")
306                                        } else {
307                                            BTN_SECONDARY_SM.to_string()
308                                        },
309                                        disabled: busy(),
310                                        onclick: {
311                                            let id = book.id;
312                                            move |_| toggle_order(id)
313                                        },
314                                        "编排目录"
315                                    }
316                                    if archived() || confirm_archive()==Some(book.id) {
317                                        button {class:BTN_SECONDARY_SM,disabled:busy(),onclick:{let id=book.id;move |_|async move {
318                                            busy.set(true);error.set(String::new());
319                                            match archive_notebook(id,!archived()).await {
320                                                Ok(())=>{
321                                                    confirm_archive.set(None);
322                                                    form_open.set(false);
323                                                    if order_id() == Some(id) {
324                                                        close_order(());
325                                                    }
326                                                    books.restart();
327                                                },
328                                                Err(e)=>error.set(e.to_string())
329                                            };
330                                            busy.set(false);
331                                        }},if archived(){"恢复笔记本"}else{"确认归档"}}
332                                        if !archived(){button {class:"note-read",onclick:move |_|confirm_archive.set(None),"取消"}}
333                                    } else {button {class:"note-read",disabled:busy(),onclick:{let id=book.id;move |_|confirm_archive.set(Some(id))},"归档"}}
334                                }
335                            }
336                        }
337                    },
338                    Some(Err(e))=>rsx! {p {class:"notes-error",role:"alert","{e}"}},
339                    None=>rsx! {crate::pages::notes::NotesSkeleton {}},
340                }
341            }
342        }
343    }
344}
345
346#[component]
347fn NotebookForm(book: Option<Notebook>, on_done: EventHandler<()>) -> Element {
348    let id = book.as_ref().map(|b| b.id);
349    let archived = book.as_ref().is_some_and(|b| b.archived_at.is_some());
350    let mut title = use_signal(|| book.as_ref().map(|b| b.title.clone()).unwrap_or_default());
351    let mut description = use_signal(|| {
352        book.as_ref()
353            .map(|b| b.description.clone())
354            .unwrap_or_default()
355    });
356    let mut public = use_signal(|| book.as_ref().is_some_and(|b| b.is_public));
357    let mut error = use_signal(String::new);
358    let mut busy = use_signal(|| false);
359    rsx! {
360        form {class:"notes-quick space-y-4",onsubmit:move |ev| {ev.prevent_default();if busy(){return;}busy.set(true);spawn(async move {
361            match save_notebook(NotebookInput {id,title:title(),description:description(),is_public:public()}).await {Ok(())=>on_done.call(()),Err(e)=>error.set(e.to_string())};busy.set(false);
362        });},
363            label {"名称" input {class:INPUT_CLASS,required:true,maxlength:100,value:"{title}",oninput:move |ev|title.set(ev.value())}}
364            label {"介绍" textarea {class:INPUT_CLASS,maxlength:1000,value:"{description}",oninput:move |ev|description.set(ev.value())}}
365            fieldset {disabled:archived,
366                label {class:"flex items-center gap-2",crate::components::ui::Checkbox {checked:public(),onchange:move |v|public.set(v)} "公开展示这个笔记本(已归档时须先恢复;其中仅已发布的笔记对访客可见)"}
367            }
368            if !error().is_empty() {p {class:"notes-error",role:"alert","{error}"}}
369            div {class:"notes-admin-actions",button {class:BTN_PRIMARY_SM,r#type:"submit",disabled:busy(),"保存笔记本"} button {class:BTN_SECONDARY_SM,r#type:"button",onclick:move |_|on_done.call(()),"取消"}}
370        }
371    }
372}
373
374#[component]
375fn NotebookOrder(id: i32, on_close: EventHandler<()>) -> Element {
376    let response = use_resource(move || client_request(notebook_order(id)));
377    let mut rows = use_signal(Vec::<(i32, String)>::new);
378    let mut initialized = use_signal(|| false);
379    let mut error = use_signal(String::new);
380    let mut busy = use_signal(|| false);
381    use_effect(move || {
382        if !initialized() {
383            if let Some(Ok(items)) = response.read().as_ref() {
384                rows.set(items.clone());
385                initialized.set(true);
386            }
387        }
388    });
389    rsx! {
390        section {
391            class: "notes-quick animate-notebook-order-enter",
392            aria_label: "编排笔记本目录",
393            h2 { class: "font-semibold mb-3", "编排目录" }
394            p { class: "notes-status mb-3", "上移或下移调整阅读顺序,保存后生效。" }
395            if let Some(Err(e)) = response.read().as_ref() {
396                p { class: "notes-error", role: "alert", "{e}" }
397            }
398            if response.read().is_none() {
399                p { class: "notes-status animate-pulse", role: "status", "正在加载目录…" }
400            }
401            for (index, (note_id, title)) in rows().iter().enumerate() {
402                div {
403                    class: "flex justify-between items-center gap-3 py-2 transition-all duration-150",
404                    key: "{note_id}",
405                    span { "{title}" }
406                    div {
407                        class: "notes-admin-actions",
408                        button {
409                            r#type: "button",
410                            disabled: index == 0,
411                            aria_label: "上移",
412                            onclick: move |_| rows.with_mut(|r| r.swap(index, index - 1)),
413                            "↑"
414                        }
415                        button {
416                            r#type: "button",
417                            disabled: index + 1 == rows().len(),
418                            aria_label: "下移",
419                            onclick: move |_| rows.with_mut(|r| r.swap(index, index + 1)),
420                            "↓"
421                        }
422                    }
423                }
424            }
425            if !error().is_empty() {
426                p { class: "notes-error", role: "alert", "{error}" }
427            }
428            div {
429                class: "notes-admin-actions mt-4",
430                button {
431                    class: BTN_PRIMARY_SM,
432                    disabled: busy() || !initialized(),
433                    onclick: move |_| {
434                        busy.set(true);
435                        let ids = rows().iter().map(|r| r.0).collect();
436                        spawn(async move {
437                            match reorder_notebook(id, ids).await {
438                                Ok(()) => on_close.call(()),
439                                Err(e) => error.set(e.to_string()),
440                            };
441                            busy.set(false);
442                        });
443                    },
444                    "保存顺序"
445                }
446                button {
447                    class: BTN_SECONDARY_SM,
448                    onclick: move |_| on_close.call(()),
449                    "关闭"
450                }
451            }
452        }
453    }
454}