Skip to main content

yggdrasil/pages/admin/
notes.rs

1//! 笔记管理(`/admin/notes`)。
2//!
3//! 标题行(管理笔记本、写笔记)→ 快速记录 Composer(正文与标签,存为私密随记)→
4//! 类型选项卡、搜索、公开状态 / 知识库 / 笔记本筛选与回收站开关 → 笔记行与底栏分页。
5//! 当前页可多选,批量公开、设为私密、收录或移出知识库;筛选、翻页和刷新后清空选择。
6//! 回收站里的行改为「恢复为草稿」。筛选条件按历史记录保存。私人数据只在浏览器
7//! 鉴权后读取(Effect 内请求,SSR 不执行)。样式见 styles/pages/admin-notes.css。
8use 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
34/// 列表接口每页固定 20 条。
35const NOTES_PER_PAGE: i32 = 20;
36/// 输入停顿多久后自动搜索(毫秒)。
37const SEARCH_DEBOUNCE_MS: u32 = 300;
38
39/// `/admin/notes`。数据全部在浏览器鉴权后读取,没有 SSR 数据。
40pub 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
59/// 「标签:Rust, 排错」→ 去空白、去空项;中英文逗号都算分隔。
60pub(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
69/// 列表里的修改时间:当天只写时刻,今年写月日,更早写完整日期。
70fn 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/// 笔记管理页。
85#[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    // 列表与笔记本的镜像信号:刷新时保留上一份数据直到新结果到达。
99    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(&note.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(&note.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            // 成败都重读版本,不能拿过期选择再次提交。
263            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/// 快速记录:正文与标签存为私密随记。正文未变时进入编辑器,保存期间继续输入则留在本页。
670#[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                // 组件已卸载,丢弃迟到的结果。
699                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/// 笔记的一行。回收站里的行不进入编辑器,右侧给出「恢复为草稿」。
812#[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}