Skip to main content

yggdrasil/pages/admin/notes/
notebooks.rs

1//! 笔记本管理(`/admin/notebooks`)。
2//!
3//! 左栏:使用中 / 已归档选项卡与笔记本列表;右栏:选中笔记本的名称、简介、公开开关、
4//! 目录顺序(上移下移)与归档,或新建笔记本的表单。名称、简介、公开与顺序一起保存,
5//! 只提交改动过的部分。数据只在浏览器鉴权后读取。样式见 styles/pages/admin-notes.css。
6use crate::api::error::ApiError;
7use crate::api::notes::*;
8use crate::components::admin_layout::AdminPageHead;
9use crate::components::empty_state::{EmptyState, EmptyStateAction};
10use crate::components::forms::{FormInput, FormLabel, FormTextarea, ToggleSwitch};
11use crate::components::skeletons::notes_admin_skeleton::NotebooksSkeleton;
12use crate::components::ui::{
13    button_class, use_toast, ButtonSize, ButtonVariant, ConfirmDialog, Icon, IconButton, IconName,
14    LoadError,
15};
16use crate::models::note::*;
17use crate::utils::error::failure_message;
18use leptos::prelude::*;
19use leptos::task::spawn_local;
20use leptos_router::{lazy_route, LazyRoute};
21
22/// `/admin/notebooks`。数据全部在浏览器鉴权后读取,没有 SSR 数据。
23pub struct NotebooksRoute;
24
25#[lazy_route]
26impl LazyRoute for NotebooksRoute {
27    fn data() -> Self {
28        Self
29    }
30
31    fn view(_this: Self) -> AnyView {
32        view! { <AdminNotebooks/> }.into_any()
33    }
34}
35
36/// 右栏正在编辑的对象。
37#[derive(Clone, Copy, PartialEq)]
38enum Selection {
39    Book(i32),
40    New,
41}
42
43/// 整页所处的阶段;只在阶段变化时重建整块内容,列表刷新不会重建右栏编辑器。
44#[derive(Clone, Copy, PartialEq)]
45enum Phase {
46    Failed,
47    Loading,
48    Empty,
49    Grid,
50}
51
52/// 新建前已有的笔记本之外多出来的那本;列表还没刷新时为 `None`。
53fn created(items: &[Notebook], known: &[i32]) -> Option<i32> {
54    items.iter().map(|b| b.id).find(|id| !known.contains(id))
55}
56
57#[component]
58fn AdminNotebooks() -> impl IntoView {
59    // 列表镜像信号:刷新时保留上一份数据直到新结果到达。
60    let books = RwSignal::new(None::<Result<Vec<Notebook>, ()>>);
61    let reload = RwSignal::new(0_u32);
62    let generation = StoredValue::new(0_u64);
63    let archived = RwSignal::new(false);
64    let selection = RwSignal::new(None::<Selection>);
65    // 新建时记下已有的笔记本;列表刷新出新的那本后选中它。
66    let known = RwSignal::new(None::<Vec<i32>>);
67
68    Effect::new(move |_| {
69        reload.track();
70        let Some(id) = generation.try_update_value(|g| {
71            *g += 1;
72            *g
73        }) else {
74            return;
75        };
76        spawn_local(async move {
77            let result = owned_notebooks().await.map_err(|_| ());
78            if generation.try_get_value() == Some(id) {
79                let _ = books.try_set(Some(result));
80            }
81        });
82    });
83    let restart = Callback::new(move |()| reload.update(|n| *n = n.wrapping_add(1)));
84
85    // 首次加载、切换选项卡或选中项消失时,选中当前选项卡的第一本。
86    Effect::new(move |_| {
87        let Some(Ok(items)) = books.get() else {
88            return;
89        };
90        if let Some(before) = known.get() {
91            // 还是刷新前的列表时保持新建表单。
92            if let Some(id) = created(&items, &before) {
93                known.set(None);
94                archived.set(false);
95                selection.set(Some(Selection::Book(id)));
96            }
97            return;
98        }
99        let archived = archived.get();
100        let shown: Vec<i32> = items
101            .iter()
102            .filter(|b| b.archived_at.is_some() == archived)
103            .map(|b| b.id)
104            .collect();
105        let keep = match selection.get_untracked() {
106            Some(Selection::New) => true,
107            Some(Selection::Book(id)) => shown.contains(&id),
108            None => false,
109        };
110        if !keep {
111            selection.set(shown.first().copied().map(Selection::Book));
112        }
113    });
114
115    let current = Memo::new(move |_| selection.get());
116    let active_count = Memo::new(move |_| match books.get() {
117        Some(Ok(items)) => items.iter().filter(|b| b.archived_at.is_none()).count(),
118        _ => 0,
119    });
120    let archived_count = Memo::new(move |_| match books.get() {
121        Some(Ok(items)) => items.len() - active_count.get(),
122        _ => 0,
123    });
124    let phase = Memo::new(move |_| match books.get() {
125        None => Phase::Loading,
126        Some(Err(())) => Phase::Failed,
127        Some(Ok(items)) if items.is_empty() && current.get() != Some(Selection::New) => {
128            Phase::Empty
129        }
130        Some(Ok(_)) => Phase::Grid,
131    });
132    let shown_books = Memo::new(move |_| match books.get() {
133        Some(Ok(items)) => {
134            let archived = archived.get();
135            items
136                .into_iter()
137                .filter(|b| b.archived_at.is_some() == archived)
138                .collect::<Vec<_>>()
139        }
140        _ => Vec::new(),
141    });
142
143    view! {
144        <div class="admin-notebooks">
145            <AdminPageHead
146                title="笔记本"
147                actions=move || {
148                    view! {
149                        <button
150                            class=button_class(ButtonVariant::Primary, ButtonSize::Md)
151                            type="button"
152                            on:click=move |_| selection.set(Some(Selection::New))
153                        >
154                            <Icon name=IconName::Plus size=16/>
155                            "新建笔记本"
156                        </button>
157                    }
158                }
159            >
160                "把相关的记录放在一起,按你的思路排列。"
161            </AdminPageHead>
162            {move || match phase.get() {
163                Phase::Failed => {
164                    view! { <LoadError title="笔记本加载失败" on_retry=restart/> }.into_any()
165                }
166                Phase::Loading => view! { <NotebooksSkeleton/> }.into_any(),
167                Phase::Empty => {
168                    view! {
169                        <div class="admin-panel">
170                            <EmptyState
171                                icon=IconName::BookOpen
172                                title="还没有笔记本"
173                                description="把一组主题笔记放进笔记本,按你的顺序排列;公开后访客可以从第一篇读起。"
174                                action=EmptyStateAction::new(
175                                    "新建笔记本",
176                                    move |_| selection.set(Some(Selection::New)),
177                                )
178                            />
179                        </div>
180                    }
181                        .into_any()
182                }
183                Phase::Grid => {
184                    view! {
185                        <div class="admin-notebooks-grid">
186                            <section class="admin-panel admin-notebooks-list" aria-label="笔记本列表">
187                                <div class="admin-notebooks-tabs" role="tablist" aria-label="笔记本状态">
188                                    {[(false, "使用中"), (true, "已归档")]
189                                        .into_iter()
190                                        .map(|(value, label)| {
191                                            view! {
192                                                <button
193                                                    type="button"
194                                                    role="tab"
195                                                    aria-selected=move || (archived.get() == value).to_string()
196                                                    on:click=move |_| {
197                                                        archived.set(value);
198                                                        if selection.get_untracked() == Some(Selection::New) {
199                                                            selection.set(None);
200                                                        }
201                                                    }
202                                                >
203                                                    {label}
204                                                    <span>
205                                                        {move || {
206                                                            if value {
207                                                                archived_count.get()
208                                                            } else {
209                                                                active_count.get()
210                                                            }
211                                                        }}
212                                                    </span>
213                                                </button>
214                                            }
215                                        })
216                                        .collect_view()}
217                                </div>
218                                <ul>
219                                    <For
220                                        each=move || shown_books.get()
221                                        key=|book| (book.id, book.note_count, book.is_public, book.archived_at.is_some(), book.title.clone())
222                                        children=move |book| {
223                                            let id = book.id;
224                                            let status = if book.archived_at.is_some() {
225                                                "已归档"
226                                            } else if book.is_public {
227                                                "公开"
228                                            } else {
229                                                "私密"
230                                            };
231                                            let public_dot = book.is_public && book.archived_at.is_none();
232                                            view! {
233                                                <li>
234                                                    <button
235                                                        class="admin-notebook-item"
236                                                        type="button"
237                                                        aria-pressed=move || {
238                                                            (current.get() == Some(Selection::Book(id))).to_string()
239                                                        }
240                                                        on:click=move |_| selection.set(Some(Selection::Book(id)))
241                                                    >
242                                                        <span class="admin-notebook-item-text">
243                                                            <span class="admin-notebook-item-title">{book.title}</span>
244                                                            <span class="admin-notebook-item-meta">
245                                                                {format!("{} 篇 · ", book.note_count)} {status}
246                                                            </span>
247                                                        </span>
248                                                        {public_dot
249                                                            .then(|| {
250                                                                view! { <span class="admin-notebook-item-dot" title="公开"></span> }
251                                                            })}
252                                                    </button>
253                                                </li>
254                                            }
255                                        }
256                                    />
257                                </ul>
258                                {move || {
259                                    shown_books
260                                        .with(|shown| shown.is_empty())
261                                        .then(|| {
262                                            view! {
263                                                <p class="admin-notebooks-empty">
264                                                    {if archived.get() {
265                                                        "没有已归档的笔记本。"
266                                                    } else {
267                                                        "没有使用中的笔记本。"
268                                                    }}
269                                                </p>
270                                            }
271                                        })
272                                }}
273                            </section>
274                            {move || match current.get() {
275                                Some(Selection::New) => {
276                                    view! {
277                                        <NotebookEditor
278                                            book=Signal::stored(None)
279                                            on_saved=Callback::new(move |()| {
280                                                let ids = books
281                                                    .with_untracked(|b| match b {
282                                                        Some(Ok(items)) => items.iter().map(|b| b.id).collect(),
283                                                        _ => Vec::new(),
284                                                    });
285                                                known.set(Some(ids));
286                                                restart.run(());
287                                            })
288                                            on_cancel=Callback::new(move |()| selection.set(None))
289                                        />
290                                    }
291                                        .into_any()
292                                }
293                                Some(Selection::Book(id)) => {
294                                    let book = Memo::new(move |_| {
295                                        books
296                                            .with(|b| match b {
297                                                Some(Ok(items)) => items.iter().find(|b| b.id == id).cloned(),
298                                                _ => None,
299                                            })
300                                    });
301                                    let exists = Memo::new(move |_| book.with(Option::is_some));
302                                    view! {
303                                        {move || {
304                                            if exists.get() {
305                                                view! {
306                                                    <NotebookEditor
307                                                        book=Signal::from(book)
308                                                        on_saved=restart
309                                                        on_cancel=Callback::new(|()| {})
310                                                    />
311                                                }
312                                                    .into_any()
313                                            } else {
314                                                blank_editor().into_any()
315                                            }
316                                        }}
317                                    }
318                                        .into_any()
319                                }
320                                None => blank_editor().into_any(),
321                            }}
322                        </div>
323                    }
324                        .into_any()
325                }
326            }}
327        </div>
328    }
329}
330
331fn blank_editor() -> impl IntoView {
332    view! {
333        <div class="admin-panel admin-notebook-editor admin-notebook-blank">
334            <p>"选择左侧的笔记本进行编辑。"</p>
335        </div>
336    }
337}
338
339/// 右栏:名称、简介、公开、目录顺序与归档。`book` 为 `None` 时是新建表单;
340/// 保存后列表刷新,`book` 随之更新,「已保存」状态由它推出。
341#[component]
342fn NotebookEditor(
343    book: Signal<Option<Notebook>>,
344    on_saved: Callback<()>,
345    on_cancel: Callback<()>,
346) -> impl IntoView {
347    let toast = use_toast();
348    let id = book.with_untracked(|b| b.as_ref().map(|b| b.id));
349    let is_archived =
350        Memo::new(move |_| book.with(|b| b.as_ref().is_some_and(|b| b.archived_at.is_some())));
351    let base_title =
352        Memo::new(move |_| book.with(|b| b.as_ref().map(|b| b.title.clone()).unwrap_or_default()));
353    let base_description = Memo::new(move |_| {
354        book.with(|b| {
355            b.as_ref()
356                .map(|b| b.description.clone())
357                .unwrap_or_default()
358        })
359    });
360    let base_public = Memo::new(move |_| book.with(|b| b.as_ref().is_some_and(|b| b.is_public)));
361    let title = RwSignal::new(base_title.get_untracked());
362    let description = RwSignal::new(base_description.get_untracked());
363    let public = RwSignal::new(base_public.get_untracked());
364    let busy = RwSignal::new(false);
365    let confirm_archive = RwSignal::new(false);
366
367    // 目录:服务端顺序与当前编排。刷新时保留上一份数据直到新结果到达。
368    let order = RwSignal::new(None::<Result<(), ()>>);
369    let order_reload = RwSignal::new(0_u32);
370    let order_generation = StoredValue::new(0_u64);
371    let baseline = RwSignal::new(Vec::<NotebookEntry>::new());
372    let rows = RwSignal::new(Vec::<NotebookEntry>::new());
373    let moved = RwSignal::new(None::<i32>);
374    if let Some(book_id) = id {
375        Effect::new(move |_| {
376            order_reload.track();
377            let Some(token) = order_generation.try_update_value(|g| {
378                *g += 1;
379                *g
380            }) else {
381                return;
382            };
383            spawn_local(async move {
384                let result = notebook_order(book_id).await;
385                if order_generation.try_get_value() != Some(token) {
386                    return;
387                }
388                match result {
389                    Ok(items) => {
390                        let _ = baseline.try_set(items.clone());
391                        let _ = rows.try_set(items);
392                        let _ = order.try_set(Some(Ok(())));
393                    }
394                    Err(_) => {
395                        let _ = order.try_set(Some(Err(())));
396                    }
397                }
398            });
399        });
400    }
401    let retry_order = Callback::new(move |()| order_reload.update(|n| *n = n.wrapping_add(1)));
402
403    let meta_dirty = Memo::new(move |_| {
404        title.get() != base_title.get()
405            || description.get() != base_description.get()
406            || public.get() != base_public.get()
407    });
408    let order_dirty = Memo::new(move |_| {
409        rows.with(|r| baseline.with(|b| r.iter().map(|r| r.id).ne(b.iter().map(|r| r.id))))
410    });
411    let dirty = Memo::new(move |_| meta_dirty.get() || order_dirty.get());
412    let can_save = Memo::new(move |_| {
413        !busy.get() && !title.with(|t| t.trim().is_empty()) && (dirty.get() || id.is_none())
414    });
415
416    let save = move || {
417        if busy.get_untracked() {
418            return;
419        }
420        busy.set(true);
421        let ids: Vec<i32> = rows.with_untracked(|r| r.iter().map(|r| r.id).collect());
422        let meta_dirty = meta_dirty.get_untracked();
423        let order_dirty = order_dirty.get_untracked();
424        let input = NotebookInput {
425            id,
426            title: title.get_untracked(),
427            description: description.get_untracked(),
428            is_public: public.get_untracked(),
429        };
430        spawn_local(async move {
431            let mut result: Result<(), ApiError> = Ok(());
432            if meta_dirty || id.is_none() {
433                result = save_notebook(input).await;
434            }
435            if let (Ok(()), Some(book_id), true) = (&result, id, order_dirty) {
436                result = reorder_notebook(book_id, ids).await;
437                if result.is_ok() {
438                    if let Some(saved) = rows.try_get_untracked() {
439                        let _ = baseline.try_set(saved);
440                    }
441                    let _ = moved.try_set(None);
442                }
443            }
444            match result {
445                Ok(()) => {
446                    toast.success(if id.is_some() {
447                        "笔记本已保存"
448                    } else {
449                        "笔记本已创建"
450                    });
451                    on_saved.try_run(());
452                }
453                Err(e) => {
454                    toast.error(failure_message("保存失败", &e));
455                    let _ = order_reload.try_update(|n| *n = n.wrapping_add(1));
456                }
457            }
458            let _ = busy.try_set(false);
459        });
460    };
461    let set_archived = move |archive: bool| {
462        let Some(book_id) = id else {
463            return;
464        };
465        busy.set(true);
466        spawn_local(async move {
467            match archive_notebook(book_id, archive).await {
468                Ok(()) => {
469                    toast.success(if archive {
470                        "已归档,公开目录不再显示"
471                    } else {
472                        "已恢复,目录保持私密"
473                    });
474                    on_saved.try_run(());
475                }
476                Err(e) => toast.error(failure_message("操作失败", &e)),
477            }
478            let _ = busy.try_set(false);
479        });
480    };
481    let shift = move |index: usize, up: bool| {
482        let to = if up { index - 1 } else { index + 1 };
483        rows.update(|r| r.swap(index, to));
484        moved.set(rows.with_untracked(|r| r.get(to).map(|r| r.id)));
485    };
486
487    let heading = if id.is_some() {
488        "编辑笔记本"
489    } else {
490        "新建笔记本"
491    };
492    let count = Memo::new(move |_| rows.with(Vec::len));
493
494    view! {
495        <section class="admin-panel admin-notebook-editor" aria-labelledby="notebook-editor-h">
496            <div class="admin-notebook-fields">
497                <h2 id="notebook-editor-h">{heading}</h2>
498                <div>
499                    <FormLabel label="名称" html_for="notebook-title"/>
500                    <FormInput
501                        id="notebook-title"
502                        maxlength=100
503                        required=true
504                        value=title
505                        oninput=Callback::new(move |v: String| title.set(v))
506                    />
507                </div>
508                <div>
509                    <FormLabel label="简介" html_for="notebook-description"/>
510                    <FormTextarea
511                        id="notebook-description"
512                        rows=2
513                        maxlength=1000
514                        placeholder="一两句话说明这本笔记写什么"
515                        value=description
516                        oninput=Callback::new(move |v: String| description.set(v))
517                    />
518                </div>
519                <div class="admin-notebook-switch">
520                    <ToggleSwitch
521                        checked=public
522                        disabled=is_archived
523                        aria_label="公开展示这个笔记本"
524                        ontoggle=Callback::new(move |()| public.update(|p| *p = !*p))
525                    />
526                    <div>
527                        <p>"公开展示这个笔记本"</p>
528                        <p class="admin-notebook-hint">
529                            {move || {
530                                if is_archived.get() {
531                                    "已归档的笔记本须先恢复,才能公开。"
532                                } else {
533                                    "访客只能看到其中已发布的笔记。"
534                                }
535                            }}
536                        </p>
537                    </div>
538                </div>
539            </div>
540
541            {id
542                .is_some()
543                .then(|| {
544                    view! {
545                        <div class="admin-notebook-order">
546                            <div class="admin-notebook-order-head">
547                                <h3>"目录顺序"</h3>
548                                <span>"上移或下移调整阅读顺序,保存后生效"</span>
549                            </div>
550                            {move || match order.get() {
551                                None => {
552                                    view! {
553                                        <p class="admin-notebook-hint" role="status">
554                                            "正在读取目录…"
555                                        </p>
556                                    }
557                                        .into_any()
558                                }
559                                Some(Err(())) => {
560                                    view! { <LoadError title="目录加载失败" on_retry=retry_order/> }
561                                        .into_any()
562                                }
563                                Some(Ok(())) if count.get() == 0 => {
564                                    view! {
565                                        <p class="admin-notebook-hint">
566                                            "这个笔记本还没有笔记。在笔记编辑器的「设置」里把笔记收进来。"
567                                        </p>
568                                    }
569                                        .into_any()
570                                }
571                                Some(Ok(())) => {
572                                    view! {
573                                        <ol class="admin-notebook-rows">
574                                            <For
575                                                each=move || rows.get()
576                                                key=|entry| entry.id
577                                                children=move |entry| {
578                                                    let entry_id = entry.id;
579                                                    let index = Memo::new(move |_| {
580                                                        rows.with(|r| r.iter().position(|e| e.id == entry_id))
581                                                            .unwrap_or(0)
582                                                    });
583                                                    view! {
584                                                        <li data-moved=move || {
585                                                            (moved.get() == Some(entry_id)).then_some("true")
586                                                        }>
587                                                            <span class="admin-notebook-row-n">
588                                                                {move || format!("{:02}", index.get() + 1)}
589                                                            </span>
590                                                            <span class="admin-notebook-row-text">
591                                                                <span class="admin-notebook-row-title">
592                                                                    {entry.title.clone()}
593                                                                </span>
594                                                                <span class="admin-notebook-row-state">
595                                                                    {entry.state_label()}
596                                                                </span>
597                                                            </span>
598                                                            <IconButton
599                                                                icon=IconName::ChevronUp
600                                                                label=format!("上移「{}」", entry.title)
601                                                                size=ButtonSize::Sm
602                                                                disabled=Signal::derive(move || {
603                                                                    index.get() == 0 || busy.get()
604                                                                })
605                                                                onclick=move |_| shift(index.get_untracked(), true)
606                                                            />
607                                                            <IconButton
608                                                                icon=IconName::ChevronDown
609                                                                label=format!("下移「{}」", entry.title)
610                                                                size=ButtonSize::Sm
611                                                                disabled=Signal::derive(move || {
612                                                                    index.get() + 1 == count.get() || busy.get()
613                                                                })
614                                                                onclick=move |_| shift(index.get_untracked(), false)
615                                                            />
616                                                        </li>
617                                                    }
618                                                }
619                                            />
620                                        </ol>
621                                    }
622                                        .into_any()
623                                }
624                            }}
625                        </div>
626                    }
627                })}
628
629            <div class="admin-notebook-foot">
630                {if id.is_some() {
631                    view! {
632                        <div class="admin-notebook-archive">
633                            {move || {
634                                if is_archived.get() {
635                                    view! {
636                                        <button
637                                            class="admin-notebook-link"
638                                            type="button"
639                                            disabled=move || busy.get()
640                                            on:click=move |_| set_archived(false)
641                                        >
642                                            "恢复这个笔记本"
643                                        </button>
644                                        <p>"恢复后目录保持私密,需要时再打开公开。"</p>
645                                    }
646                                        .into_any()
647                                } else {
648                                    view! {
649                                        <button
650                                            class="admin-notebook-link"
651                                            type="button"
652                                            disabled=move || busy.get()
653                                            on:click=move |_| confirm_archive.set(true)
654                                        >
655                                            "归档这个笔记本"
656                                        </button>
657                                        <p>
658                                            "归档会隐藏公开目录,不删除笔记,也不撤回笔记自身的发布或 AI 授权。"
659                                        </p>
660                                    }
661                                        .into_any()
662                                }
663                            }}
664                        </div>
665                    }
666                        .into_any()
667                } else {
668                    view! {
669                        <button
670                            class=button_class(ButtonVariant::Ghost, ButtonSize::Md)
671                            type="button"
672                            on:click=move |_| on_cancel.run(())
673                        >
674                            "取消"
675                        </button>
676                    }
677                        .into_any()
678                }}
679                <button
680                    class=button_class(ButtonVariant::Primary, ButtonSize::Md)
681                    type="button"
682                    disabled=move || !can_save.get()
683                    on:click=move |_| save()
684                >
685                    {move || {
686                        if busy.get() {
687                            "保存中…"
688                        } else if id.is_none() {
689                            "创建笔记本"
690                        } else if dirty.get() {
691                            "保存"
692                        } else {
693                            "已保存"
694                        }
695                    }}
696                </button>
697            </div>
698        </section>
699        <ConfirmDialog
700            visible=confirm_archive
701            title="归档这个笔记本?"
702            description="公开目录会隐藏,笔记本身不受影响:已发布的笔记仍然公开,AI 授权也保留。之后可以在「已归档」里恢复。"
703            confirm_label="归档"
704            icon=IconName::Archive
705            on_confirm=Callback::new(move |()| set_archived(true))
706        />
707    }
708}
709
710#[cfg(test)]
711mod tests {
712    use super::*;
713
714    #[test]
715    fn created_finds_the_notebook_missing_before_save() {
716        let book = |id| Notebook {
717            id,
718            ..Default::default()
719        };
720        assert_eq!(created(&[book(3), book(9), book(4)], &[3, 4]), Some(9));
721        // 列表尚未刷新。
722        assert_eq!(created(&[book(3), book(4)], &[3, 4]), None);
723    }
724}