yggdrasil/pages/admin/notes/
notebooks.rs1use 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
22pub 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#[derive(Clone, Copy, PartialEq)]
38enum Selection {
39 Book(i32),
40 New,
41}
42
43#[derive(Clone, Copy, PartialEq)]
45enum Phase {
46 Failed,
47 Loading,
48 Empty,
49 Grid,
50}
51
52fn 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 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 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 Effect::new(move |_| {
87 let Some(Ok(items)) = books.get() else {
88 return;
89 };
90 if let Some(before) = known.get() {
91 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#[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 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 assert_eq!(created(&[book(3), book(4)], &[3, 4]), None);
723 }
724}