1use dioxus::prelude::*;
10
11use crate::api::comments::create_comment;
12use crate::bridges::library::{library_ready, use_browser_library, LibraryLoadError};
13use crate::bridges::tiptap::{UploadErrorEntry, UploadsInFlight};
14use crate::components::comments::section::{CommentContext, CommentSource};
15use crate::components::forms::{AlertBox, FormInput};
16use crate::components::ui::{UserAvatar, BTN_PRIMARY_SM, SPINNER_SVG};
17use crate::models::comment::PublicComment;
18use crate::utils::comment_storage::{self, AuthorInfo, PendingComment};
19#[cfg(target_arch = "wasm32")]
20use wasm_bindgen::closure::Closure;
21
22fn local_pending_comment(
24 approved: &[PublicComment],
25 existing: &[PendingComment],
26 parent_id: Option<i64>,
27 author_name: String,
28 author_url: String,
29 content_md: String,
30) -> PendingComment {
31 let parent_depth = parent_id.and_then(|id| {
32 approved
33 .iter()
34 .find(|comment| comment.id == id)
35 .map(|comment| comment.depth)
36 .or_else(|| {
37 existing
38 .iter()
39 .find(|comment| comment.id == id)
40 .map(|comment| comment.depth)
41 })
42 });
43 let next_id = existing
44 .iter()
45 .map(|comment| comment.id)
46 .min()
47 .unwrap_or(0)
48 .min(0)
49 - 1;
50 let now = chrono::Utc::now().to_rfc3339();
51 PendingComment {
52 id: next_id,
53 parent_id,
54 depth: parent_depth.map_or(0, |depth| depth + 1),
55 author_name,
56 author_url: (!author_url.trim().is_empty()).then_some(author_url),
57 avatar_url: String::new(),
58 content_md,
59 created_at: now.clone(),
60 stored_at: now,
61 }
62}
63
64#[component]
76#[cfg_attr(not(target_arch = "wasm32"), allow(unused_mut, unused_variables))]
79pub fn CommentForm(post_id: i32, parent_id: Option<i64>, parent_indent: Option<i32>) -> Element {
80 let ctx: CommentContext = use_context();
81 let mut active_reply = ctx.active_reply;
82 let mut refresh_trigger = ctx.refresh_trigger;
83 let mut pending_comments = ctx.pending_comments;
84 let viewer = ctx.current_user;
85
86 let mut author_name = use_signal(String::new);
87 let mut author_email = use_signal(String::new);
88 let mut author_url = use_signal(String::new);
89 let mut content_md = use_signal(|| match ctx.source {
90 CommentSource::Production => String::new(),
91 CommentSource::Local { .. } => "这是一条只在当前预览中显示的评论。".to_string(),
92 });
93 let mut honeypot = use_signal(String::new);
94 let mut submitting = use_signal(|| false);
95 let mut message = use_signal(|| Option::<(String, &'static str)>::None);
96 let mut loaded = use_signal(|| false);
97 let editor_library = use_browser_library("tiptap", move || {
98 parent_id.is_none_or(|pid| active_reply() == Some(pid))
99 });
100 let uploads_in_flight = use_signal(UploadsInFlight::default);
103 let upload_errors: Signal<Vec<UploadErrorEntry>> = use_signal(Vec::new);
104
105 use_effect(move || {
107 if loaded() {
108 return;
109 }
110 loaded.set(true);
111 if matches!(ctx.source, CommentSource::Production) {
112 if let Some(info) = comment_storage::load_author() {
113 author_name.set(info.name);
114 author_email.set(info.email);
115 author_url.set(info.url);
116 }
117 }
118 });
119
120 if let Some(pid) = parent_id {
122 if active_reply() != Some(pid) {
123 return rsx! {};
124 }
125 }
126
127 let is_reply = parent_id.is_some();
128
129 let id_suffix = parent_id.map_or_else(|| "root".to_string(), |pid| pid.to_string());
131 let id_suffix = match ctx.source {
132 CommentSource::Production => id_suffix,
133 CommentSource::Local { .. } => format!("{}-{id_suffix}", ctx.id_scope),
134 };
135 let image_input_dom_id = format!("comment-image-{id_suffix}");
137
138 let negative_margin = match (is_reply, parent_indent) {
140 (true, Some(px)) if px > 0 => format!("margin-left: -{px}px;"),
141 _ => String::new(),
142 };
143
144 let editor_dom_id = format!("comment-editor-{id_suffix}");
146
147 #[cfg(target_arch = "wasm32")]
149 let mut editor_handle: Signal<Option<crate::bridges::tiptap::EditorHandle>> =
150 use_signal(|| None);
151
152 #[cfg(target_arch = "wasm32")]
156 let editor_dom_id_for_mount = editor_dom_id.clone();
157 #[cfg(target_arch = "wasm32")]
158 use_effect(move || {
159 let editor_dom_id = editor_dom_id_for_mount.clone();
160 let active = match parent_id {
161 Some(pid) => active_reply() == Some(pid),
162 None => true,
163 };
164 if !active {
165 if editor_handle.peek().is_some() {
167 editor_handle.set(None);
168 }
169 return;
170 }
171 if editor_handle.peek().is_some() {
173 return;
174 }
175 if !library_ready(editor_library) {
176 return;
177 }
178
179 let on_update = Closure::new({
181 let mut content_md = content_md;
182 move |md: String| content_md.set(md)
183 });
184 let on_ready = Closure::new(|| {});
185 let on_image_upload = match ctx.source {
186 CommentSource::Production => crate::bridges::tiptap::make_comment_upload_closure(),
187 CommentSource::Local { .. } => Closure::new(|_file: web_sys::File| {
188 js_sys::Promise::reject(&"本地演示不上传图片".into())
189 }),
190 };
191 let on_upload_event = Closure::new({
192 let mut message = message;
193 move |ev: crate::bridges::tiptap::UploadEventJs| {
194 match ev.kind().as_str() {
197 "error" => {
198 let msg = ev.error_msg().unwrap_or_else(|| "上传失败".to_string());
199 message.set(Some((format!("图片上传失败:{msg}"), "error")));
200 }
201 "success" | "removed"
202 if message
203 .peek()
204 .as_ref()
205 .is_some_and(|(m, _)| m.starts_with("图片上传失败")) =>
206 {
207 message.set(None);
208 }
209 "success" | "removed" => {}
210 _ => {}
211 }
212 crate::bridges::tiptap::consume_upload_event(&ev, uploads_in_flight, upload_errors);
213 }
214 });
215
216 let opts = crate::bridges::tiptap::EditorOptions::new();
217 opts.set_variant("comment");
218 opts.set_placeholder(if is_reply {
219 "写下你的回复..."
220 } else {
221 "写下你的想法..."
222 });
223 opts.set_on_update(&on_update);
224 opts.set_on_ready(&on_ready);
225 if matches!(ctx.source, CommentSource::Production) {
226 opts.set_on_image_upload(&on_image_upload);
227 }
228 opts.set_on_upload_event(&on_upload_event);
229
230 match crate::bridges::tiptap::get_module().create(&editor_dom_id, &opts) {
232 Ok(Some(inst)) => {
233 let draft = content_md.peek().clone();
236 if !draft.is_empty() {
237 inst.set_markdown(&draft);
238 }
239 let handle = crate::bridges::tiptap::EditorHandle::new_comment(
240 inst,
241 on_update,
242 on_image_upload,
243 on_ready,
244 on_upload_event,
245 );
246 editor_handle.set(Some(handle));
247 }
248 Ok(None) => {
249 web_sys::console::warn_1(&format!("评论编辑器容器未找到: #{editor_dom_id}").into());
250 }
251 Err(e) => {
252 message.set(Some((format!("编辑器初始化失败: {e:?}"), "error")));
253 }
254 }
255 });
256
257 let mut do_submit = move || {
258 if submitting() || !library_ready(editor_library) {
259 return;
260 }
261
262 let post_id = post_id;
263 let parent_id = parent_id;
264 let is_anon = viewer().is_none();
265 let (name, email, url_val) = if is_anon {
267 (author_name(), author_email(), author_url())
268 } else {
269 (String::new(), String::new(), String::new())
270 };
271 let content = content_md();
272 let hp = honeypot();
273
274 let in_flight = uploads_in_flight();
277 if in_flight.uploading > 0 || in_flight.error > 0 {
278 let msg = if in_flight.uploading > 0 {
279 format!(
280 "有 {} 张图片正在上传,请等待完成后再发表",
281 in_flight.uploading
282 )
283 } else {
284 format!(
285 "有 {} 张图片上传失败,请重试或移除后再发表",
286 in_flight.error
287 )
288 };
289 message.set(Some((msg, "error")));
290 return;
291 }
292 if content.contains("](blob:") {
295 message.set(Some((
296 "检测到未完成上传的图片,请处理后再发表".to_string(),
297 "error",
298 )));
299 return;
300 }
301
302 if !hp.is_empty() {
304 return;
305 }
306
307 if content.trim().is_empty() {
308 message.set(Some(("请填写评论内容".to_string(), "error")));
309 return;
310 }
311 if is_anon && (name.trim().is_empty() || email.trim().is_empty()) {
312 message.set(Some(("请填写昵称和邮箱".to_string(), "error")));
313 return;
314 }
315 submitting.set(true);
316 message.set(None);
317 if let CommentSource::Local { approved } = ctx.source {
318 let pending = local_pending_comment(
319 &approved.read(),
320 &pending_comments.read(),
321 parent_id,
322 name,
323 url_val,
324 content,
325 );
326 pending_comments.write().push(pending);
327 content_md.set(String::new());
328 #[cfg(target_arch = "wasm32")]
329 if let Some(handle) = &*editor_handle.peek() {
330 handle.instance().set_markdown("");
331 }
332 submitting.set(false);
333 message.set(Some((
334 "已加入本地演示列表,内容不会保存".to_string(),
335 "success",
336 )));
337 if parent_id.is_some() {
338 active_reply.set(None);
339 }
340 return;
341 }
342 spawn(async move {
343 let result = create_comment(
344 post_id,
345 parent_id,
346 name.clone(),
347 email.clone(),
348 if url_val.trim().is_empty() {
349 None
350 } else {
351 Some(url_val.clone())
352 },
353 content.clone(),
354 hp.clone(),
355 )
356 .await;
357 submitting.set(false);
358 match result {
359 Ok(resp) => {
360 if resp.success {
361 if is_anon {
364 comment_storage::save_author(&AuthorInfo {
365 name: name.clone(),
366 email: email.clone(),
367 url: url_val.clone(),
368 });
369 if let Some(comment_id) = resp.comment_id {
370 let avatar_url = resp.avatar_url.unwrap_or_default();
371 let depth = resp.depth.unwrap_or(0);
372 let now = chrono::Utc::now().to_rfc3339();
373 let pending = PendingComment {
374 id: comment_id,
375 parent_id,
376 depth,
377 author_name: name.clone(),
378 author_url: if url_val.trim().is_empty() {
379 None
380 } else {
381 Some(url_val)
382 },
383 avatar_url,
384 content_md: content,
385 created_at: now.clone(),
386 stored_at: now,
387 };
388 comment_storage::save_pending_comment(post_id, pending.clone());
389 pending_comments.write().push(pending);
390 }
391 }
392 content_md.set(String::new());
393 #[cfg(target_arch = "wasm32")]
395 if let Some(handle) = &*editor_handle.peek() {
396 handle.instance().set_markdown("");
397 }
398 message.set(Some((resp.message, "success")));
399 if parent_id.is_some() {
400 active_reply.set(None);
401 }
402 refresh_trigger.set(!refresh_trigger());
403 } else {
404 message.set(Some((resp.message, "error")));
405 }
406 }
407 Err(_) => {
408 message.set(Some(("提交失败,请稍后重试".to_string(), "error")));
409 }
410 }
411 });
412 };
413
414 rsx! {
415 div {
416 class: if is_reply { "mt-3" } else { "" },
417 style: "{negative_margin}",
418 role: "form",
419 aria_label: if is_reply { "回复评论" } else { "发表评论" },
420 onkeydown: move |e: KeyboardEvent| {
423 if (e.modifiers().ctrl() || e.modifiers().meta()) && e.key() == Key::Enter {
424 do_submit();
425 }
426 },
427
428 if let Some((msg, variant)) = message() {
429 div { class: "mb-3", aria_live: "polite",
430 AlertBox { message: msg, variant }
431 }
432 }
433 LibraryLoadError { library: editor_library }
434
435 div { class: "rounded-2xl bg-[var(--color-paper-entry)] border border-[var(--color-paper-border)]/60 shadow-xs focus-within:border-[var(--color-paper-accent)]/60 focus-within:ring-2 focus-within:ring-[var(--color-paper-accent)]/20 transition-all duration-200 overflow-hidden",
437 if let Some(user) = viewer() {
439 {
440 let label = user.display_label().to_string();
441 let action = if is_reply { "回复" } else { "发表评论" };
442 rsx! {
443 div { class: "flex items-center justify-between px-4 py-2.5 bg-[var(--color-paper-theme)]/40 border-b border-[var(--color-paper-border)]/40 text-xs text-paper-secondary",
444 div { class: "flex items-center gap-2.5",
445 UserAvatar {
446 name: label.clone(),
447 avatar_url: user.avatar_url.clone(),
448 class: "w-6 h-6 rounded-full text-xs ring-1 ring-[var(--color-paper-border)]/60 shrink-0",
449 }
450 span { class: "text-paper-secondary",
451 "以 "
452 span { class: "font-semibold text-paper-primary", "{label}" }
453 " 的身份{action}"
454 }
455 span { class: "hidden sm:inline-flex items-center px-1.5 py-0.5 rounded-full text-[10px] font-medium bg-[var(--color-paper-accent)]/15 text-[var(--color-paper-accent)]",
456 "已登录"
457 }
458 }
459 div { class: "flex items-center gap-1 text-paper-tertiary",
460 span { class: "hidden sm:inline text-[11px]", "Markdown 语法就绪" }
461 }
462 }
463 }
464 }
465 } else {
466 div { class: "grid grid-cols-1 sm:grid-cols-3 divide-y sm:divide-y-0 sm:divide-x divide-[var(--color-paper-border)]/40 bg-[var(--color-paper-theme)]/30 border-b border-[var(--color-paper-border)]/40",
467 div { class: "relative",
468 FormInput {
469 id: Some(format!("comment-name-{id_suffix}")),
470 r#type: "text",
471 placeholder: "昵称 *",
472 title: Some("昵称"),
475 value: author_name(),
476 disabled: submitting(),
477 class: Some("w-full px-3.5 py-2 bg-transparent text-sm text-paper-primary placeholder:text-paper-tertiary focus:outline-none focus:bg-[var(--color-paper-entry)]/60 transition-colors"),
478 oninput: move |v| author_name.set(v),
479 }
480 }
481 div { class: "relative",
482 FormInput {
483 id: Some(format!("comment-email-{id_suffix}")),
484 r#type: "email",
485 placeholder: "邮箱 * (保密)",
486 title: Some("邮箱"),
487 value: author_email(),
488 disabled: submitting(),
489 class: Some("w-full px-3.5 py-2 bg-transparent text-sm text-paper-primary placeholder:text-paper-tertiary focus:outline-none focus:bg-[var(--color-paper-entry)]/60 transition-colors"),
490 oninput: move |v| author_email.set(v),
491 }
492 }
493 div { class: "relative",
494 FormInput {
495 id: Some(format!("comment-url-{id_suffix}")),
496 r#type: "url",
497 placeholder: "网站 (https://,可选)",
498 title: Some("网站"),
499 value: author_url(),
500 disabled: submitting(),
501 class: Some("w-full px-3.5 py-2 bg-transparent text-sm text-paper-primary placeholder:text-paper-tertiary focus:outline-none focus:bg-[var(--color-paper-entry)]/60 transition-colors"),
502 oninput: move |v| author_url.set(v),
503 }
504 }
505 }
506 }
507
508 div { class: "relative bg-transparent",
512 div {
513 id: "{editor_dom_id}",
514 class: "comment-editor-mount min-h-[96px]",
515 }
516 if !library_ready(editor_library) {
517 span { class: "absolute top-4 left-4 text-sm text-[var(--color-paper-tertiary)]", "正在加载编辑器…" }
518 }
519 img {
520 src: "/images/xiantiaoxiaogou_input_bg.webp",
521 alt: "",
522 class: "absolute bottom-2 right-2 w-20 sm:w-24 opacity-15 dark:opacity-20 pointer-events-none select-none z-0",
523 }
524 }
525
526 if viewer().is_none() {
529 textarea {
530 class: "hidden",
531 aria_hidden: "true",
532 tabindex: "-1",
533 value: "{honeypot}",
534 oninput: move |e| honeypot.set(e.value()),
535 }
536 }
537
538 div { class: "flex items-center justify-between px-3.5 py-2.5 bg-[var(--color-paper-theme)]/40 border-t border-[var(--color-paper-border)]/40 text-xs",
540 div { class: "flex items-center gap-1.5 text-paper-tertiary",
542 if matches!(ctx.source, CommentSource::Production) {
544 label {
545 r#for: "{image_input_dom_id}",
546 class: "p-1.5 rounded-md hover:text-paper-primary hover:bg-[var(--color-paper-entry)] transition-colors cursor-pointer",
547 title: "上传图片(也可直接粘贴到输入框)",
548 aria_label: "上传图片",
549 svg {
550 class: "w-3.5 h-3.5",
551 fill: "currentColor",
552 view_box: "0 -960 960 960",
553 path { d: "M180-120q-24 0-42-18t-18-42v-600q0-24 18-42t42-18h600q24 0 42 18t18 42v600q0 24-18 42t-42 18H180Zm0-60h600v-600H180v600Zm56-97h489L578-473 446-302l-93-127-117 152Zm-56 97v-600 600Z" }
554 }
555 }
556 input {
557 id: "{image_input_dom_id}",
558 class: "hidden",
559 r#type: "file",
560 accept: "image/jpeg,image/png,image/gif,image/webp",
561 multiple: true,
562 onchange: {
563 #[cfg(target_arch = "wasm32")]
565 let input_dom_id = image_input_dom_id.clone();
566 move |e| {
567 #[cfg(target_arch = "wasm32")]
568 {
569 use dioxus::web::WebFileExt;
570 for f in e.files() {
572 if let Some(web_file) = f.get_web_file() {
573 if let Some(handle) = &*editor_handle.peek() {
574 handle.instance().insert_uploading(web_file);
575 }
576 }
577 }
578 if let Some(el) = web_sys::window()
580 .and_then(|w| w.document())
581 .and_then(|d| d.get_element_by_id(&input_dom_id))
582 {
583 use wasm_bindgen::JsCast;
584 if let Some(input) = el.dyn_ref::<web_sys::HtmlInputElement>() {
585 input.set_value("");
586 }
587 }
588 }
589 }
590 },
591 }
592 }
593 if uploads_in_flight().uploading > 0 {
595 span { class: "inline-flex items-center gap-1.5 text-[11px] text-paper-secondary ml-1",
596 span { class: "inline-block", dangerous_inner_html: "{SPINNER_SVG}" }
597 "图片上传中…"
598 }
599 }
600 }
601
602 div { class: "flex items-center gap-2",
604 span { class: "hidden sm:inline-block text-[11px] text-paper-tertiary font-mono mr-1", "Ctrl + ↵" }
605 if is_reply {
606 button {
607 r#type: "button",
608 class: "px-3 py-1.5 text-xs text-paper-secondary hover:text-paper-primary hover:bg-[var(--color-paper-entry)] rounded-full transition-colors cursor-pointer",
609 onclick: move |_| active_reply.set(None),
610 "取消"
611 }
612 }
613 button {
614 r#type: "button",
615 class: "{BTN_PRIMARY_SM}",
616 class: if submitting() || uploads_in_flight().uploading > 0 || uploads_in_flight().error > 0 { "opacity-60 cursor-not-allowed pointer-events-none" } else { "" },
619 disabled: !library_ready(editor_library) || submitting() || uploads_in_flight().uploading > 0 || uploads_in_flight().error > 0,
620 onclick: move |_| {
621 do_submit();
622 },
623 if submitting() {
624 span { class: "inline-flex items-center gap-1.5",
625 span { class: "inline-block", dangerous_inner_html: "{SPINNER_SVG}" }
626 "提交中…"
627 }
628 } else if is_reply {
629 "回复"
630 } else {
631 "发表评论"
632 }
633 }
634 }
635 }
636 }
637 }
638 }
639}
640
641#[cfg(test)]
642mod tests {
643 use super::*;
644
645 #[test]
646 fn local_reply_uses_parent_depth_and_unique_negative_id() {
647 let parent = PendingComment {
648 id: -10,
649 parent_id: None,
650 depth: 1,
651 author_name: "样例".to_string(),
652 author_url: None,
653 avatar_url: String::new(),
654 content_md: "原评论".to_string(),
655 created_at: "2026-01-01T00:00:00Z".to_string(),
656 stored_at: "2026-01-01T00:00:00Z".to_string(),
657 };
658 let reply = local_pending_comment(
659 &[],
660 &[parent],
661 Some(-10),
662 "访客".to_string(),
663 String::new(),
664 "回复内容".to_string(),
665 );
666 assert_eq!(
667 (reply.id, reply.parent_id, reply.depth),
668 (-11, Some(-10), 2)
669 );
670 assert_eq!(reply.author_url, None);
671 assert_eq!(reply.content_md, "回复内容");
672
673 let approved = PublicComment {
674 id: 101,
675 parent_id: None,
676 depth: 0,
677 author_name: "作者".to_string(),
678 author_url: None,
679 avatar_url: String::new(),
680 is_author: true,
681 content_html: Some("<p>原评论</p>".to_string()),
682 created_at: "昨天".to_string(),
683 created_at_iso: "2026-01-01T00:00:00Z".to_string(),
684 };
685 let reply = local_pending_comment(
686 &[approved],
687 &[],
688 Some(101),
689 "访客".to_string(),
690 String::new(),
691 "回复".to_string(),
692 );
693 assert_eq!((reply.id, reply.parent_id, reply.depth), (-1, Some(101), 1));
694 }
695}