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