1use std::collections::BTreeSet;
9
10use chrono::{DateTime, Datelike, FixedOffset};
11use leptos::ev::{KeyboardEvent, SubmitEvent};
12use leptos::prelude::*;
13use leptos::task::spawn_local;
14use leptos_router::{lazy_route, LazyRoute};
15
16use super::note_drafts::{clear_submitted, use_draft_protection};
17use crate::api::notes::*;
18use crate::components::admin_layout::AdminPageHead;
19use crate::components::empty_state::{EmptyState, EmptyStateAction};
20use crate::components::forms::{FormSelect, FORM_SELECT_COMPACT_CLASS};
21use crate::components::skeletons::notes_admin_skeleton::NoteRowsSkeleton;
22use crate::components::ui::{
23 button_class, use_toast, ButtonSize, ButtonVariant, Checkbox, Composer, ComposerInput,
24 ConfirmDialog, Icon, IconName, LoadError, SegmentedControl, SegmentedItem, StatusDot,
25 TablePager,
26};
27use crate::models::note::*;
28use crate::routes::Route;
29use crate::utils::error::failure_message;
30use crate::utils::time::{hm, md, to_local, ymd};
31
32pub mod notebooks;
33
34const NOTES_PER_PAGE: i32 = 20;
36const SEARCH_DEBOUNCE_MS: u32 = 300;
38
39pub struct AdminNotesRoute;
41
42#[lazy_route]
43impl LazyRoute for AdminNotesRoute {
44 fn data() -> Self {
45 Self
46 }
47
48 fn view(_this: Self) -> AnyView {
49 view! { <AdminNotes/> }.into_any()
50 }
51}
52
53#[derive(Clone, Default, serde::Serialize, serde::Deserialize)]
54struct NotesHistoryState {
55 filter: NoteFilter,
56 search_input: String,
57}
58
59pub(super) fn parse_tags(input: &str) -> Vec<String> {
61 input
62 .split([',', ','])
63 .map(str::trim)
64 .filter(|s| !s.is_empty())
65 .map(str::to_string)
66 .collect()
67}
68
69fn short_when(at: DateTime<FixedOffset>, now: DateTime<FixedOffset>) -> String {
71 if at.date_naive() == now.date_naive() {
72 hm(&at)
73 } else if at.year() == now.year() {
74 md(&at, '-')
75 } else {
76 ymd(&at, '-')
77 }
78}
79
80fn kind_value(kind: Option<NoteKind>) -> String {
81 kind.map_or("all", NoteKind::as_str).to_string()
82}
83
84#[component]
86fn AdminNotes() -> impl IntoView {
87 let entry_id = crate::bridges::navigation::entry_id();
88 let saved = crate::bridges::navigation::read_state::<NotesHistoryState>("admin-notes")
89 .unwrap_or_default();
90 let toast = use_toast();
91 let filter = RwSignal::new(NoteFilter {
92 page: saved.filter.page.max(1),
93 ..saved.filter.clone()
94 });
95 let search_input = RwSignal::new(saved.search_input.clone());
96 let search_seq = RwSignal::new(0_u64);
97 let restoring = RwSignal::new(None::<i32>);
98 let response = RwSignal::new(None::<Result<NotePage, ()>>);
100 let books = RwSignal::new(None::<Result<Vec<Notebook>, ()>>);
101 let reload = RwSignal::new(0_u32);
102 let generation = StoredValue::new(0_u64);
103 let selected = RwSignal::new(BTreeSet::<i32>::new());
104 let loading = RwSignal::new(true);
105 let batch_busy = RwSignal::new(false);
106 let confirm_publish = RwSignal::new(false);
107 let confirm_knowledge = RwSignal::new(false);
108
109 Effect::new(move |_| {
110 reload.track();
111 let current = filter.get();
112 selected.update(BTreeSet::clear);
113 confirm_publish.set(false);
114 confirm_knowledge.set(false);
115 loading.set(true);
116 let Some(id) = generation.try_update_value(|g| {
117 *g += 1;
118 *g
119 }) else {
120 return;
121 };
122 spawn_local(async move {
123 let requested_page = current.page;
124 let result = list_owned_notes(current).await.map_err(|_| ());
125 if generation.try_get_value() == Some(id) {
126 if let Ok(page) = &result {
127 if page.notes.is_empty() && requested_page > 1 {
128 let last_page =
129 ((page.total.max(1) - 1) / i64::from(NOTES_PER_PAGE) + 1) as i32;
130 let _ = filter.try_update(|f| f.page = last_page);
131 return;
132 }
133 }
134 let _ = response.try_set(Some(result));
135 let _ = loading.try_set(false);
136 }
137 });
138 });
139 Effect::new(move |_| {
140 spawn_local(async move {
141 let result = owned_notebooks().await.map_err(|_| ());
142 let _ = books.try_set(Some(result));
143 });
144 });
145
146 Effect::new(move |_| {
147 crate::bridges::navigation::write_state(
148 &entry_id,
149 "admin-notes",
150 &NotesHistoryState {
151 filter: filter.get(),
152 search_input: search_input.get(),
153 },
154 );
155 });
156
157 let submit_search = move || {
158 let q = search_input.get_untracked().trim().to_string();
159 if q != filter.with_untracked(|f| f.query.clone()) {
160 filter.update(|f| {
161 f.query = q;
162 f.page = 1;
163 });
164 }
165 };
166 let schedule_search = move || {
167 search_seq.update(|s| *s += 1);
168 let id = search_seq.get_untracked();
169 spawn_local(async move {
170 crate::utils::time::sleep_ms(SEARCH_DEBOUNCE_MS).await;
171 if search_seq.try_get_untracked() == Some(id) {
172 submit_search();
173 }
174 });
175 };
176 let restore = Callback::new(move |note: Note| {
177 restoring.set(Some(note.id));
178 spawn_local(async move {
179 match act_on_note(note.id, note.version, NoteAction::Restore).await {
180 Ok(_) => {
181 toast.success(format!("已把「{}」恢复为草稿", note.display_title()));
182 let _ = reload.try_update(|n| *n = n.wrapping_add(1));
183 }
184 Err(e) => toast.error(failure_message("恢复失败", &e)),
185 }
186 let _ = restoring.try_set(None);
187 });
188 });
189
190 let select_note = Callback::new(move |(id, checked): (i32, bool)| {
191 if loading.get_untracked() || batch_busy.get_untracked() {
192 return;
193 }
194 selected.update(|ids| {
195 if checked {
196 ids.insert(id);
197 } else {
198 ids.remove(&id);
199 }
200 });
201 });
202 let all_selected = Memo::new(move |_| {
203 response.with(|result| match result {
204 Some(Ok(page)) => {
205 !page.notes.is_empty()
206 && selected.with(|ids| page.notes.iter().all(|note| ids.contains(¬e.id)))
207 }
208 _ => false,
209 })
210 });
211 let select_page = Callback::new(move |checked: bool| {
212 if loading.get_untracked() || batch_busy.get_untracked() {
213 return;
214 }
215 selected.set(if checked {
216 response.with_untracked(|result| match result {
217 Some(Ok(page)) => page.notes.iter().map(|note| note.id).collect(),
218 _ => BTreeSet::new(),
219 })
220 } else {
221 BTreeSet::new()
222 });
223 });
224 let run_batch = Callback::new(move |action: NoteAction| {
225 if loading.get_untracked() || batch_busy.get_untracked() {
226 return;
227 }
228 let targets = response.with_untracked(|result| match result {
229 Some(Ok(page)) => selected.with_untracked(|ids| {
230 page.notes
231 .iter()
232 .filter(|note| ids.contains(¬e.id))
233 .map(|note| NoteBatchTarget {
234 id: note.id,
235 expected_version: note.version,
236 })
237 .collect::<Vec<_>>()
238 }),
239 _ => Vec::new(),
240 });
241 if targets.is_empty() {
242 return;
243 }
244 let count = targets.len();
245 batch_busy.set(true);
246 spawn_local(async move {
247 match batch_act_on_notes(targets, action).await {
248 Ok(()) => {
249 let verb = match action {
250 NoteAction::Publish => "设为公开",
251 NoteAction::Unpublish => "设为私密",
252 NoteAction::IncludeKnowledge => "收录到 AI 知识库",
253 NoteAction::ExcludeKnowledge => "移出 AI 知识库",
254 _ => unreachable!(),
255 };
256 toast.success(format!("已将 {count} 条笔记{verb}"));
257 }
258 Err(error) => {
259 toast.error(failure_message("批量操作失败,请核对刷新后的状态", &error))
260 }
261 }
262 let _ = selected.try_update(BTreeSet::clear);
264 let _ = reload.try_update(|n| *n = n.wrapping_add(1));
265 let _ = batch_busy.try_set(false);
266 });
267 });
268
269 let trash = Memo::new(move |_| filter.with(|f| f.trash));
270 let filtering = Memo::new(move |_| {
271 filter.with(|f| {
272 !f.query.is_empty()
273 || f.published.is_some()
274 || f.knowledge.is_some()
275 || f.notebook_id.is_some()
276 })
277 });
278 let book_list = Memo::new(move |_| match books.get() {
279 Some(Ok(items)) => items,
280 _ => Vec::new(),
281 });
282 let book_options = Memo::new(move |_| {
283 let mut options: Vec<(Option<i32>, String)> = vec![(None, "笔记本".to_string())];
284 options.extend(
285 book_list
286 .get()
287 .iter()
288 .map(|b| (Some(b.id), b.title.clone())),
289 );
290 options
291 });
292 let retry = Callback::new(move |()| {
293 reload.update(|n| *n = n.wrapping_add(1));
294 });
295
296 view! {
297 <div class="admin-notes">
298 <AdminPageHead
299 title="笔记"
300 actions=|| {
301 view! {
302 <a
303 class=button_class(ButtonVariant::Ghost, ButtonSize::Md)
304 href=Route::AdminNotebooks.to_string()
305 >
306 "管理笔记本"
307 </a>
308 <a
309 class=format!(
310 "{} admin-hide-mobile",
311 button_class(ButtonVariant::Primary, ButtonSize::Md),
312 )
313 href=Route::NewNote.to_string()
314 >
315 <Icon name=IconName::Plus size=16/>
316 "写笔记"
317 </a>
318 }
319 }
320 >
321 "捕捉此刻的想法,也为长久的知识留一个位置。"
322 </AdminPageHead>
323
324 <QuickNote on_saved=retry/>
325
326 <div class="admin-toolbar admin-notes-toolbar">
327 {move || {
328 if trash.get() {
329 view! {
330 <p class="admin-notes-trash-title">
331 <Icon name=IconName::Trash size=15/>
332 "回收站"
333 <span>"恢复后为私密草稿,公开与收录需要重新确认。"</span>
334 </p>
335 }
336 .into_any()
337 } else {
338 view! {
339 <SegmentedControl
340 aria_label="笔记类型"
341 items=vec![
342 SegmentedItem::new("all".to_string(), "全部"),
343 SegmentedItem::new("moment".to_string(), "随记"),
344 SegmentedItem::new("topic".to_string(), "主题笔记"),
345 ]
346 value=Signal::derive(move || kind_value(filter.with(|f| f.kind)))
347 on_change=Callback::new(move |value: String| {
348 filter
349 .update(|f| {
350 f.kind = match value.as_str() {
351 "moment" => Some(NoteKind::Moment),
352 "topic" => Some(NoteKind::Topic),
353 _ => None,
354 };
355 f.page = 1;
356 })
357 })
358 />
359 }
360 .into_any()
361 }
362 }}
363 <div class="admin-toolbar-filters">
364 <label class="field field-sm admin-filter-search">
365 <Icon name=IconName::Search size=14/>
366 <span class="sr-only">"搜索标题、正文或标签"</span>
367 <input
368 type="search"
369 placeholder="搜索标题、正文或标签"
370 value=search_input.get_untracked()
371 prop:value=move || search_input.get()
372 on:input=move |e| {
373 search_input.set(event_target_value(&e));
374 schedule_search();
375 }
376 on:keydown=move |e: KeyboardEvent| {
377 if e.key() == "Enter" && !e.is_composing() {
378 search_seq.update(|s| *s += 1);
379 submit_search();
380 } else if e.key() == "Escape" && !search_input.get_untracked().is_empty() {
381 e.prevent_default();
382 search_input.set(String::new());
383 search_seq.update(|s| *s += 1);
384 submit_search();
385 }
386 }
387 />
388 </label>
389 {move || {
390 (!trash.get())
391 .then(|| {
392 view! {
393 <FormSelect
394 id="admin-notes-published"
395 value=Signal::derive(move || filter.with(|f| f.published))
396 options=vec![
397 (None, "公开状态"),
398 (Some(true), "已公开"),
399 (Some(false), "私密"),
400 ]
401 trigger_class=FORM_SELECT_COMPACT_CLASS
402 aria_label="按公开状态筛选"
403 onchange=Callback::new(move |value: Option<bool>| {
404 filter
405 .update(|f| {
406 f.published = value;
407 f.page = 1;
408 })
409 })
410 />
411 <FormSelect
412 id="admin-notes-knowledge"
413 value=Signal::derive(move || filter.with(|f| f.knowledge))
414 options=vec![
415 (None, "AI 知识库"),
416 (Some(true), "已收录"),
417 (Some(false), "未收录"),
418 ]
419 trigger_class=FORM_SELECT_COMPACT_CLASS
420 aria_label="按知识库收录筛选"
421 onchange=Callback::new(move |value: Option<bool>| {
422 filter
423 .update(|f| {
424 f.knowledge = value;
425 f.page = 1;
426 })
427 })
428 />
429 {move || {
430 (book_options.with(|options| options.len()) > 1)
431 .then(|| {
432 view! {
433 <FormSelect
434 id="admin-notes-notebook"
435 value=Signal::derive(move || {
436 filter.with(|f| f.notebook_id)
437 })
438 options=book_options
439 trigger_class=FORM_SELECT_COMPACT_CLASS
440 aria_label="按笔记本筛选"
441 onchange=Callback::new(move |value: Option<i32>| {
442 filter
443 .update(|f| {
444 f.notebook_id = value;
445 f.page = 1;
446 })
447 })
448 />
449 }
450 })
451 }}
452 }
453 })
454 }}
455 <button
456 class=button_class(ButtonVariant::Ghost, ButtonSize::Sm)
457 type="button"
458 aria-pressed=move || trash.get().to_string()
459 on:click=move |_| {
460 filter
461 .update(|f| {
462 f.trash = !f.trash;
463 f.page = 1;
464 })
465 }
466 >
467 {move || {
468 if trash.get() {
469 view! {
470 <Icon name=IconName::ArrowLeft size=14/>
471 "返回笔记"
472 }
473 .into_any()
474 } else {
475 "回收站".into_any()
476 }
477 }}
478 </button>
479 </div>
480 </div>
481
482 {move || match response.get() {
483 None => view! { <NoteRowsSkeleton/> }.into_any(),
484 Some(Err(())) => {
485 view! { <LoadError title="笔记列表加载失败" on_retry=retry/> }.into_any()
486 }
487 Some(Ok(page)) if page.notes.is_empty() => {
488 view! {
489 <div class="admin-panel">
490 {move || {
491 if filtering.get() {
492 view! {
493 <EmptyState
494 icon=IconName::SearchX
495 title="没有符合条件的笔记"
496 description="换个关键词,或清除筛选再试。"
497 action=EmptyStateAction::new(
498 "清除筛选",
499 move |_| {
500 search_input.set(String::new());
501 filter
502 .update(|f| {
503 *f = NoteFilter {
504 kind: f.kind,
505 trash: f.trash,
506 ..Default::default()
507 };
508 });
509 },
510 )
511 />
512 }
513 .into_any()
514 } else if trash.get() {
515 view! {
516 <EmptyState
517 icon=IconName::Trash
518 title="回收站是空的"
519 description="移入回收站的笔记会出现在这里,可以随时恢复。"
520 />
521 }
522 .into_any()
523 } else {
524 view! {
525 <EmptyState
526 icon=IconName::NotebookPen
527 title="还没有笔记"
528 description="从上面的快速记录开始,或者写一篇主题笔记。"
529 action=EmptyStateAction::new(
530 "写笔记",
531 move |_| {
532 crate::navigation::push(&Route::NewNote.to_string());
533 },
534 )
535 />
536 }
537 .into_any()
538 }
539 }}
540 </div>
541 }
542 .into_any()
543 }
544 Some(Ok(page)) => {
545 let now = crate::utils::time::local_now();
546 let book_list = book_list.get();
547 let total = page.total;
548 let rows = page
549 .notes
550 .into_iter()
551 .map(|note| {
552 let id = note.id;
553 let when = short_when(to_local(note.updated_at), now);
554 let names = note
555 .notebook_ids
556 .iter()
557 .filter_map(|id| book_list.iter().find(|b| b.id == *id))
558 .map(|b| b.title.clone())
559 .collect::<Vec<_>>();
560 view! {
561 <NoteRow
562 note=note
563 when=when
564 books=names
565 restoring=Signal::derive(move || restoring.get() == Some(id))
566 on_restore=restore
567 selected=Signal::derive(move || selected.with(|ids| ids.contains(&id)))
568 selection_disabled=Signal::derive(move || loading.get() || batch_busy.get())
569 on_select=select_note
570 />
571 }
572 })
573 .collect_view();
574 view! {
575 <div class="admin-panel admin-notes-list">
576 <Show when=move || !trash.get()>
577 <fieldset class="admin-notes-select-page" disabled=move || loading.get() || batch_busy.get()>
578 <label>
579 <Checkbox checked=all_selected label="全选本页笔记" onchange=select_page/>
580 "全选本页"
581 </label>
582 <span>"仅选择当前页,切换筛选或翻页后清空"</span>
583 </fieldset>
584 </Show>
585 <ul>{rows}</ul>
586 <TablePager
587 page=Signal::derive(move || filter.with(|f| f.page.max(1)))
588 total=total
589 per_page=NOTES_PER_PAGE
590 unit="条"
591 on_change=Callback::new(move |page: i32| {
592 filter.update(|f| f.page = page)
593 })
594 />
595 </div>
596 }
597 .into_any()
598 }
599 }}
600
601 <Show when=move || selected.with(|ids| !ids.is_empty())>
602 <div class="batch-bar admin-notes-batch" role="toolbar" aria-label="笔记批量操作" aria-busy=move || batch_busy.get().to_string()>
603 <span class="batch-bar-count" role="status">
604 {move || if batch_busy.get() { "处理中…".to_string() } else { format!("已选 {} 条", selected.with(BTreeSet::len)) }}
605 </span>
606 <button
607 class=button_class(ButtonVariant::Ghost, ButtonSize::Sm)
608 type="button"
609 disabled=move || batch_busy.get()
610 on:click=move |_| confirm_publish.set(true)
611 >"设为公开"</button>
612 <button
613 class=button_class(ButtonVariant::Ghost, ButtonSize::Sm)
614 type="button"
615 disabled=move || batch_busy.get()
616 on:click=move |_| run_batch.run(NoteAction::Unpublish)
617 >"设为私密"</button>
618 <button
619 class=button_class(ButtonVariant::Ghost, ButtonSize::Sm)
620 type="button"
621 disabled=move || batch_busy.get()
622 on:click=move |_| confirm_knowledge.set(true)
623 >"收录到 AI 知识库"</button>
624 <button
625 class=button_class(ButtonVariant::Ghost, ButtonSize::Sm)
626 type="button"
627 disabled=move || batch_busy.get()
628 on:click=move |_| run_batch.run(NoteAction::ExcludeKnowledge)
629 >"移出 AI 知识库"</button>
630 <button
631 class=button_class(ButtonVariant::Ghost, ButtonSize::Sm)
632 type="button"
633 disabled=move || batch_busy.get()
634 on:click=move |_| selected.update(BTreeSet::clear)
635 >"取消选择"</button>
636 </div>
637 </Show>
638 <ConfirmDialog
639 visible=confirm_publish
640 title="批量公开笔记?"
641 description=Signal::derive(move || format!(
642 "将公开所选 {} 条笔记的当前工作稿,包括尚未发布的修改。AI 知识库收录状态不变。",
643 selected.with(BTreeSet::len),
644 ))
645 confirm_label="确认公开"
646 icon=IconName::Eye
647 on_confirm=move |()| run_batch.run(NoteAction::Publish)
648 />
649 <ConfirmDialog
650 visible=confirm_knowledge
651 title="批量收录到 AI 知识库?"
652 description=Signal::derive(move || format!(
653 "将所选 {} 条笔记的当前工作稿收录到 AI 知识库,包括尚未发布的修改。公开状态不变;私密笔记仍需知识库授权才能读取。",
654 selected.with(BTreeSet::len),
655 ))
656 confirm_label="确认收录"
657 icon=IconName::BookOpen
658 on_confirm=move |()| run_batch.run(NoteAction::IncludeKnowledge)
659 />
660
661 <a class="admin-fab" href=Route::NewNote.to_string() aria-label="写笔记">
662 <Icon name=IconName::Plus size=18/>
663 "写笔记"
664 </a>
665 </div>
666 }
667}
668
669#[component]
671fn QuickNote(on_saved: Callback<()>) -> impl IntoView {
672 let toast = use_toast();
673 let quick = RwSignal::new(String::new());
674 let tags = RwSignal::new(String::new());
675 let busy = RwSignal::new(false);
676 let last_saved = RwSignal::new(None::<i32>);
677 let (recovery, cache_failed) = use_draft_protection("quick".into(), quick, move || {
678 !quick.get().is_empty() || busy.get()
679 });
680 let recovering = Memo::new(move |_| recovery.with(Option::is_some));
681 let empty = Memo::new(move |_| quick.with(|q| q.trim().is_empty()));
682
683 let save = move || {
684 if busy.get_untracked() || recovering.get_untracked() || empty.get_untracked() {
685 return;
686 }
687 busy.set(true);
688 let content_md = quick.get_untracked();
689 let tag_list = parse_tags(&tags.get_untracked());
690 spawn_local(async move {
691 let result = save_note(NoteDraft {
692 content_md: content_md.clone(),
693 tags: tag_list,
694 ..Default::default()
695 })
696 .await;
697 if busy.try_set(false).is_some() {
698 return;
700 }
701 match result {
702 Ok(note) => {
703 let _ = tags.try_set(String::new());
704 let Some(unchanged) =
705 quick.try_update(|current| clear_submitted(current, &content_md))
706 else {
707 return;
708 };
709 if unchanged {
710 crate::navigation::push(&Route::EditNote { id: note.id }.to_string());
711 } else {
712 let _ = last_saved.try_set(Some(note.id));
713 on_saved.run(());
714 }
715 }
716 Err(e) => toast.error(failure_message("保存失败", &e)),
717 }
718 });
719 };
720
721 view! {
722 {move || {
723 recovery
724 .get()
725 .map(|local| {
726 view! {
727 <div class="admin-notes-banner" role="alert">
728 <Icon name=IconName::RotateCcwClock size=16/>
729 <p>"这个标签页有尚未保存的快速记录。"</p>
730 <button
731 class=button_class(ButtonVariant::Secondary, ButtonSize::Sm)
732 type="button"
733 on:click=move |_| {
734 quick.set(local.clone());
735 recovery.set(None);
736 }
737 >
738 "恢复记录"
739 </button>
740 <button
741 class=button_class(ButtonVariant::Ghost, ButtonSize::Sm)
742 type="button"
743 on:click=move |_| recovery.set(None)
744 >
745 "放弃"
746 </button>
747 </div>
748 }
749 })
750 }}
751 <form on:submit=move |ev: SubmitEvent| {
752 ev.prevent_default();
753 save();
754 }>
755 <Composer stacked=true class="admin-notes-quick">
756 <ComposerInput
757 multiline=true
758 aria_label="快速记录"
759 disabled=recovering
760 placeholder="有什么想记下的?一句话、一段代码,或一个刚发现的链接。"
761 value=quick
762 oninput=Callback::new(move |value: String| quick.set(value))
763 on_submit=Callback::new(move |()| save())
764 />
765 <div class="admin-notes-quick-foot">
766 <input
767 class="admin-notes-quick-tags"
768 aria-label="随记标签"
769 placeholder="标签:Rust, 排错, 读书"
770 disabled=move || recovering.get()
771 prop:value=move || tags.get()
772 on:input=move |ev| tags.set(event_target_value(&ev))
773 />
774 <span class="admin-notes-quick-hint">"先存为私密草稿,稍后整理。"</span>
775 <button
776 class=button_class(ButtonVariant::Primary, ButtonSize::Sm)
777 type="submit"
778 disabled=move || busy.get() || recovering.get() || empty.get()
779 >
780 {move || if busy.get() { "保存中…" } else { "记下" }}
781 </button>
782 </div>
783 </Composer>
784 </form>
785 {move || {
786 cache_failed
787 .get()
788 .then(|| {
789 view! {
790 <p class="admin-notes-note" role="alert">
791 "浏览器无法保存本地恢复副本,请先保存到服务端再离开。"
792 </p>
793 }
794 })
795 }}
796 {move || {
797 last_saved
798 .get()
799 .map(|id| {
800 view! {
801 <p class="admin-notes-note" role="status">
802 "上一条已保存,继续输入的内容已保留。"
803 <a href=Route::EditNote { id }.to_string()>"查看已保存记录"</a>
804 </p>
805 }
806 })
807 }}
808 }
809}
810
811#[component]
813fn NoteRow(
814 note: Note,
815 when: String,
816 books: Vec<String>,
817 restoring: Signal<bool>,
818 on_restore: Callback<Note>,
819 selected: Signal<bool>,
820 selection_disabled: Signal<bool>,
821 on_select: Callback<(i32, bool)>,
822) -> impl IntoView {
823 let glyph = match note.kind {
824 NoteKind::Topic => "题",
825 NoteKind::Moment => "记",
826 };
827 let title = note.display_title();
828 let checkbox_label = format!("选择笔记「{title}」");
829 let id = note.id;
830 let ahead = note.published_version.is_some_and(|v| note.version > v);
831 let tags = note
832 .tags
833 .iter()
834 .map(|t| format!("#{t}"))
835 .collect::<Vec<_>>()
836 .join(" ");
837 let published = match note.published_version {
838 Some(v) => view! { <StatusDot label=format!("公开 v{v}") tone="clay"/> }.into_any(),
839 None => view! { <StatusDot label="私密" class="admin-notes-private"/> }.into_any(),
840 };
841 let knowledge = note
842 .knowledge_version
843 .map(|v| view! { <span class="admin-notes-pill">{format!("知识库 v{v}")}</span> });
844 let books = books
845 .into_iter()
846 .map(|book| view! { <span>{format!("《{book}》")}</span> })
847 .collect_view();
848 let meta = view! {
849 <span class="admin-notes-meta">
850 {published} {ahead.then(|| view! { <span class="admin-notes-ahead">"有未发布修改"</span> })}
851 {knowledge} {books} {(!tags.is_empty()).then(|| view! { <span>{tags}</span> })}
852 </span>
853 };
854 if let Some(deleted_at) = note.deleted_at {
855 let deleted = ymd(&to_local(deleted_at), '-');
856 return view! {
857 <li class="admin-notes-row" aria-busy=move || restoring.get().then_some("true")>
858 <span class="admin-notes-glyph" aria-hidden="true">
859 {glyph}
860 </span>
861 <span class="admin-notes-main">
862 <span class="admin-notes-title">{title}</span>
863 {meta}
864 </span>
865 <span class="admin-notes-side">
866 <time>{format!("删除于 {deleted}")}</time>
867 <button
868 class=button_class(ButtonVariant::Secondary, ButtonSize::Sm)
869 type="button"
870 disabled=move || restoring.get()
871 on:click=move |_| on_restore.run(note.clone())
872 >
873 "恢复为草稿"
874 </button>
875 </span>
876 </li>
877 }
878 .into_any();
879 }
880 view! {
881 <li class="admin-notes-selectable" class:is-selected=move || selected.get()>
882 <fieldset class="admin-notes-select" disabled=move || selection_disabled.get()>
883 <Checkbox
884 checked=selected
885 label=checkbox_label
886 onchange=move |checked| on_select.run((id, checked))
887 />
888 </fieldset>
889 <a class="admin-notes-row" href=Route::EditNote { id }.to_string()>
890 <span class="admin-notes-glyph" aria-hidden="true">
891 {glyph}
892 </span>
893 <span class="admin-notes-main">
894 <span class="admin-notes-title">{title}</span>
895 {meta}
896 </span>
897 <span class="admin-notes-side">
898 <time>{when}</time>
899 <span class="admin-notes-edit" aria-hidden="true">
900 "编辑"
901 <Icon name=IconName::ArrowRight size=13/>
902 </span>
903 </span>
904 </a>
905 </li>
906 }
907 .into_any()
908}
909
910#[cfg(test)]
911mod tests {
912 use super::*;
913 use chrono::TimeZone;
914
915 fn at(y: i32, m: u32, d: u32, h: u32, min: u32) -> DateTime<FixedOffset> {
916 FixedOffset::east_opt(8 * 3600)
917 .unwrap()
918 .with_ymd_and_hms(y, m, d, h, min, 0)
919 .unwrap()
920 }
921
922 #[test]
923 fn short_when_shortens_recent_dates() {
924 let now = at(2026, 10, 21, 16, 0);
925 assert_eq!(short_when(at(2026, 10, 21, 9, 5), now), "09:05");
926 assert_eq!(short_when(at(2026, 10, 12, 9, 5), now), "10-12");
927 assert_eq!(short_when(at(2025, 12, 31, 9, 5), now), "2025-12-31");
928 }
929
930 #[test]
931 fn parse_tags_accepts_both_commas_and_drops_blanks() {
932 assert_eq!(parse_tags(" Rust,排错, ,读书 "), ["Rust", "排错", "读书"]);
933 assert!(parse_tags(" ").is_empty());
934 }
935}