1#![allow(unused_imports)]
8
9use dioxus::prelude::*;
12
13#[cfg(target_arch = "wasm32")]
15use crate::api::posts::{
16 create_post, get_post_by_id, update_post, CreatePostResponse, SinglePostResponse,
17};
18#[cfg(target_arch = "wasm32")]
19use crate::bridges::tiptap::{consume_upload_event, upload_image_file, EditorHandle};
20use crate::bridges::tiptap::{UploadErrorEntry, UploadsInFlight};
22use crate::components::assets::{AssetPickerModal, AssetSelection};
23use crate::components::forms::{FormInput, FormSelect, INPUT_INLINE_CLASS};
24use crate::components::skeletons::write_skeleton::WriteSkeleton;
25use crate::components::ui::{LoadingButton, BTN_CLOSE_ICON, BTN_PRIMARY_SM};
26use crate::models::post::Post;
27use crate::router::Route;
28use dioxus::router::components::Link;
29#[cfg(target_arch = "wasm32")]
30use wasm_bindgen::closure::Closure;
31#[cfg(target_arch = "wasm32")]
37use crate::utils::js::invoke_optional_global;
38#[cfg(target_arch = "wasm32")]
39use dioxus::html::HasFileData;
40#[cfg(target_arch = "wasm32")]
41use dioxus::web::{WebEventExt, WebFileExt};
42
43#[component]
47#[cfg_attr(not(target_arch = "wasm32"), allow(unused_mut, unused_variables))]
48pub fn Write() -> Element {
49 write_editor(None)
50}
51
52#[component]
56#[cfg_attr(not(target_arch = "wasm32"), allow(unused_mut, unused_variables))]
57pub fn WriteEdit(id: i32) -> Element {
58 write_editor(Some(id))
59}
60
61#[cfg_attr(not(target_arch = "wasm32"), allow(unused_mut, unused_variables))]
70fn write_editor(post_id: Option<i32>) -> Element {
71 let is_edit = post_id.is_some();
72 let mode_title = if is_edit {
73 if let Some(id) = post_id {
74 format!("编辑文章 #{}", id)
75 } else {
76 "编辑文章".to_string()
77 }
78 } else {
79 "新建文章".to_string()
80 };
81
82 let mut title = use_signal(|| "".to_string());
84 let mut summary = use_signal(|| "".to_string());
85 let mut slug = use_signal(|| "".to_string());
86 let mut tags = use_signal(|| "".to_string());
87 let mut cover_image = use_signal(|| "".to_string());
88 let cover_uploading = use_signal(|| false);
92 let body_picker_visible = use_signal(|| false);
95 let body_picker_uploading = use_signal(|| false);
96 let mut status = use_signal(|| "published".to_string());
97 let mut content = use_signal(|| "".to_string());
98 let mut sidebar_open = use_signal(|| true);
100 let mut loading = use_signal(|| true);
102 let mut saving = use_signal(|| false);
103 let mut error = use_signal(|| None::<String>);
104 let mut editor_content_set = use_signal(|| false);
105 let mut has_backfilled = use_signal(|| false);
106 let mut load_error = use_signal(|| None::<String>);
107 let mut edit_post = use_signal(|| None::<Post>);
109
110 #[cfg(target_arch = "wasm32")]
112 let mut editor: Signal<Option<EditorHandle>> = use_signal(|| None);
113 #[cfg(target_arch = "wasm32")]
115 let ready = use_signal(|| false);
116
117 let uploads_in_flight = use_signal(UploadsInFlight::default);
119 let upload_errors: Signal<Vec<UploadErrorEntry>> = use_signal(Vec::new);
120
121 let word_stats = use_memo(move || {
123 let md = content();
124 let chars = md.chars().filter(|c| !c.is_whitespace()).count();
125 let mins = (chars / 300).max(1);
126 (chars, mins)
127 });
128
129 let parsed_tags = use_memo(move || {
131 tags()
132 .split([',', ',', ';', ';'])
133 .map(|t| t.trim().to_string())
134 .filter(|t| !t.is_empty())
135 .collect::<Vec<String>>()
136 });
137 use_effect(move || {
139 if !is_edit || has_backfilled() {
140 return;
141 }
142 if let Some(ref post) = edit_post() {
143 has_backfilled.set(true);
144 title.set(post.title.clone());
145 summary.set(post.summary.clone().unwrap_or_default());
146 slug.set(post.slug.clone());
147 tags.set(post.tags.join(", "));
148 cover_image.set(post.cover_image.clone().unwrap_or_default());
149 status.set(post.status.as_str().to_string());
150 content.set(post.content_md.clone());
151 }
152 });
153
154 use_effect(move || {
156 if is_edit {
157 #[cfg(target_arch = "wasm32")]
158 if let Some(id) = post_id {
159 spawn(async move {
160 match get_post_by_id(id).await {
161 Ok(SinglePostResponse { post: Some(post) }) => {
162 edit_post.set(Some(post));
163 }
164 Ok(SinglePostResponse { post: None }) => {
165 load_error.set(Some("文章不存在".to_string()));
166 }
167 Err(e) => {
168 load_error.set(Some(format!("加载失败: {}", e)));
169 }
170 }
171 });
172 }
173 }
174 });
175
176 #[cfg(target_arch = "wasm32")]
178 use_drop(move || {
179 editor.set(None);
180 });
181
182 #[cfg(target_arch = "wasm32")]
186 use_effect(move || {
187 if is_edit && edit_post().is_none() {
193 return;
194 }
195 if editor.read().is_some() {
197 return;
198 }
199
200 let on_update = Closure::new({
203 let mut content = content;
204 move |md: String| content.set(md)
205 });
206 let on_ready = Closure::new({
207 let mut ready = ready;
208 move || ready.set(true)
209 });
210 let on_image_upload = crate::bridges::tiptap::make_upload_closure();
211 let on_upload_event = Closure::new({
212 let uploads_in_flight = uploads_in_flight;
213 let upload_errors = upload_errors;
214 move |ev: crate::bridges::tiptap::UploadEventJs| {
215 consume_upload_event(&ev, uploads_in_flight, upload_errors);
216 }
217 });
218 let on_run_code = crate::bridges::tiptap::make_run_code_closure();
220 let on_pick_from_library = Closure::new({
223 let mut body_picker_visible = body_picker_visible;
224 move || body_picker_visible.set(true)
225 });
226
227 let opts = crate::bridges::tiptap::EditorOptions::new();
229 opts.set_placeholder("在此输入内容...");
230 opts.set_on_update(&on_update);
231 opts.set_on_ready(&on_ready);
232 opts.set_on_image_upload(&on_image_upload);
233 opts.set_on_upload_event(&on_upload_event);
234 opts.set_on_run_code(&on_run_code);
235 opts.set_on_pick_from_library(&on_pick_from_library);
236
237 match crate::bridges::tiptap::get_module().create("tiptap-editor", &opts) {
239 Ok(Some(inst)) => {
240 if is_edit && !editor_content_set() {
245 if let Some(post) = edit_post() {
246 let md = &post.content_md;
247 if !md.is_empty() {
248 inst.set_markdown(md);
249 }
250 }
251 editor_content_set.set(true);
252 }
253 let handle = EditorHandle::new(
254 inst,
255 on_update,
256 on_image_upload,
257 on_ready,
258 on_upload_event,
259 on_run_code,
260 on_pick_from_library,
261 );
262 editor.set(Some(handle));
263 }
264 Ok(None) => {
265 load_error.set(Some("编辑器容器未就绪".to_string()));
266 loading.set(false);
267 }
268 Err(e) => {
269 load_error.set(Some(format!("编辑器初始化错误: {:?}", e)));
270 loading.set(false);
271 }
272 }
273 });
274
275 #[cfg(target_arch = "wasm32")]
278 use_effect(move || {
279 if ready() {
280 loading.set(false);
281 }
282 });
283
284 #[cfg(not(target_arch = "wasm32"))]
286 use_effect(move || {
287 loading.set(false);
288 });
289
290 let mut on_submit = move |_| {
292 let in_flight = uploads_in_flight.read();
294 if in_flight.uploading > 0 || in_flight.error > 0 {
295 let msg = if in_flight.uploading > 0 {
296 format!(
297 "有 {} 张图片正在上传,请等待完成后再保存",
298 in_flight.uploading
299 )
300 } else {
301 format!(
302 "有 {} 张图片上传失败,请移除或重试后再保存",
303 in_flight.error
304 )
305 };
306 error.set(Some(msg));
307 return;
308 }
309 drop(in_flight);
310
311 if cover_uploading() {
313 error.set(Some("封面图正在上传,请等待完成后再保存".to_string()));
314 return;
315 }
316
317 if body_picker_uploading() {
319 error.set(Some("图片正在上传,请等待完成后再保存".to_string()));
320 return;
321 }
322
323 if title().trim().is_empty() {
324 error.set(Some("标题不能为空".to_string()));
325 }
326
327 #[cfg(target_arch = "wasm32")]
329 if !title().trim().is_empty() {
330 let md = if let Some(handle) = &*editor.read() {
332 handle.instance().get_markdown()
333 } else {
334 content()
335 };
336
337 if md.contains("](blob:") || md.contains("data-upload-state") {
341 error.set(Some("检测到未完成上传的图片,请处理后保存".to_string()));
342 return;
343 }
344
345 if md.trim().is_empty() {
346 error.set(Some("内容不能为空".to_string()));
347 return;
348 }
349
350 let tags_list: Vec<String> = tags()
353 .split([',', ',', ';', ';'])
354 .map(|t| t.trim().to_string())
355 .filter(|t| !t.is_empty())
356 .collect();
357
358 let slug_opt = if slug().trim().is_empty() {
359 None
360 } else {
361 Some(slug().trim().to_string())
362 };
363
364 let summary_opt = if summary().trim().is_empty() {
365 None
366 } else {
367 Some(summary().trim().to_string())
368 };
369
370 let cover_image_opt = if cover_image().trim().is_empty() {
371 None
372 } else {
373 Some(cover_image().trim().to_string())
374 };
375
376 saving.set(true);
377 error.set(None);
378
379 if let Some(id) = post_id {
380 spawn(async move {
382 match update_post(
383 id,
384 title().trim().to_string(),
385 slug_opt,
386 summary_opt,
387 md,
388 status(),
389 tags_list,
390 cover_image_opt,
391 )
392 .await
393 {
394 Ok(CreatePostResponse { success: true, .. }) => {
395 saving.set(false);
396 let _ = dioxus::router::navigator().push(Route::Posts {});
397 }
398 Ok(CreatePostResponse {
399 success: false,
400 message,
401 ..
402 }) => {
403 saving.set(false);
404 error.set(Some(message));
405 }
406 Err(e) => {
407 saving.set(false);
408 error.set(Some(format!("更新失败: {}", e)));
409 }
410 }
411 });
412 } else {
413 spawn(async move {
415 match create_post(
416 title().trim().to_string(),
417 slug_opt,
418 summary_opt,
419 md,
420 status(),
421 tags_list,
422 cover_image_opt,
423 )
424 .await
425 {
426 Ok(CreatePostResponse { success: true, .. }) => {
427 saving.set(false);
428 let _ = dioxus::router::navigator().push(Route::Posts {});
429 }
430 Ok(CreatePostResponse {
431 success: false,
432 message,
433 ..
434 }) => {
435 saving.set(false);
436 error.set(Some(message));
437 }
438 Err(e) => {
439 saving.set(false);
440 error.set(Some(format!("保存失败: {}", e)));
441 }
442 }
443 });
444 }
445 }
446
447 #[cfg(not(target_arch = "wasm32"))]
449 if !title().trim().is_empty() {
450 error.set(Some("此页面需要 JavaScript 才能保存".to_string()));
451 }
452 };
453
454 rsx! {
455 div { class: "animate-page-enter relative flex flex-col w-full min-h-0 flex-1 bg-[var(--color-paper-theme)]",
457 if loading() {
458 div { class: "absolute inset-0 z-10 bg-[var(--color-paper-theme)] flex flex-col", WriteSkeleton {} }
459 }
460
461 div { class: "flex-shrink-0 px-6 py-3 border-b border-[var(--color-paper-border)]/60 bg-[var(--color-paper-theme)] flex items-center justify-between gap-4 z-10 select-none",
463 div { class: "flex items-center gap-3",
465 Link {
466 to: Route::Posts {},
467 class: "inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium text-[var(--color-paper-secondary)] hover:text-[var(--color-paper-primary)] hover:bg-[var(--color-paper-entry)] transition-all cursor-pointer",
468 svg {
469 class: "w-4 h-4",
470 xmlns: "http://www.w3.org/2000/svg",
471 view_box: "0 0 24 24",
472 fill: "none",
473 stroke: "currentColor",
474 stroke_width: "2",
475 stroke_linecap: "round",
476 stroke_linejoin: "round",
477 path { d: "M19 12H5M12 19l-7-7 7-7" }
478 }
479 "文章列表"
480 }
481 div { class: "w-px h-4 bg-[var(--color-paper-border)]/60" }
482 div { class: "flex items-center gap-2",
483 span { class: "text-sm font-semibold text-[var(--color-paper-primary)]",
484 "{mode_title}"
485 }
486 if status() == "published" {
488 span { class: "inline-flex items-center px-2 py-0.5 rounded-full text-[11px] font-medium bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border border-emerald-500/20",
489 "公开"
490 }
491 } else {
492 span { class: "inline-flex items-center px-2 py-0.5 rounded-full text-[11px] font-medium bg-amber-500/10 text-amber-600 dark:text-amber-400 border border-amber-500/20",
493 "草稿"
494 }
495 }
496 }
497 }
498
499 div { class: "hidden md:flex items-center gap-3 text-xs text-[var(--color-paper-tertiary)] font-medium",
501 div { class: "flex items-center gap-1.5",
502 span { class: "w-1.5 h-1.5 rounded-full bg-[var(--color-paper-accent)]" }
503 span { "{word_stats().0} 字" }
504 }
505 span { "·" }
506 span { "预计阅读 {word_stats().1} 分钟" }
507 }
508
509 div { class: "flex items-center gap-2",
511 button {
512 class: "inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium border transition-all cursor-pointer",
513 class: if sidebar_open() {
514 "border-[var(--color-paper-border)] text-[var(--color-paper-primary)] bg-[var(--color-paper-entry)] hover:bg-[var(--color-paper-border)]/50"
515 } else {
516 "border-[var(--color-paper-accent)]/50 text-[var(--color-paper-accent)] bg-[var(--color-paper-accent)]/10 hover:bg-[var(--color-paper-accent)]/20"
517 },
518 onclick: move |_| sidebar_open.toggle(),
519 title: if sidebar_open() { "进入专注写作模式(收起设置)" } else { "展开文章属性面板" },
520 svg {
521 class: "w-4 h-4",
522 xmlns: "http://www.w3.org/2000/svg",
523 view_box: "0 0 24 24",
524 fill: "none",
525 stroke: "currentColor",
526 stroke_width: "2",
527 stroke_linecap: "round",
528 stroke_linejoin: "round",
529 rect { x: "3", y: "3", width: "18", height: "18", rx: "2", ry: "2" }
530 line { x1: "15", y1: "3", x2: "15", y2: "21" }
531 }
532 if sidebar_open() {
533 "收起设置"
534 } else {
535 "文章设置"
536 }
537 }
538 }
539 }
540
541 div { class: "flex-1 min-h-0 flex",
543 div { class: "flex-1 min-w-0 min-h-0 overflow-y-auto px-6 sm:px-10 md:px-14 py-8 flex flex-col items-center",
545 div { class: "w-full max-w-4xl flex-1 flex flex-col",
546 input {
548 class: "w-full text-3xl sm:text-4xl md:text-5xl font-extrabold bg-transparent text-[var(--color-paper-primary)] placeholder-[var(--color-paper-tertiary)]/60 focus:outline-none tracking-tight leading-tight transition-colors py-2 mb-4",
549 placeholder: "输入文章标题...",
550 value: "{title}",
551 oninput: move |evt| title.set(evt.value()),
552 }
553
554 if let Some(err) = load_error() {
556 div { class: "flex-shrink-0 px-4 py-2 bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 rounded-2xl text-sm border border-red-100 dark:border-red-900/30 mb-3",
557 "{err}"
558 }
559 }
560
561 if let Some(err) = error() {
562 div { class: "flex-shrink-0 px-4 py-2 bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 rounded-2xl text-sm border border-red-100 dark:border-red-900/30 mb-3",
563 "{err}"
564 }
565 }
566
567 for err in upload_errors().clone() {
569 div {
570 key: "{err.id}",
571 class: "flex-shrink-0 flex items-center justify-between gap-3 px-4 py-2 bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 rounded-2xl text-sm border border-red-100 dark:border-red-900/30 mb-3",
572 span { "图片上传失败: {err.file_name} — {err.message}" }
573 button {
574 class: "{BTN_CLOSE_ICON}",
575 aria_label: "关闭提示",
576 onclick: {
577 let id = err.id.clone();
578 let mut upload_errors = upload_errors;
579 move |_| {
580 #[cfg(target_arch = "wasm32")]
581 if let Some(handle) = &*editor.read() {
582 handle.instance().remove_upload_by_upload_id(&id);
583 }
584 upload_errors.write().retain(|e| e.id != id);
585 }
586 },
587 "×"
588 }
589 }
590 }
591
592 div { class: "flex-1 min-h-[480px] flex flex-col mb-4",
594 div {
595 class: "relative group flex-1 min-h-0 w-full rounded-2xl overflow-hidden bg-[var(--color-paper-entry)]/40 border border-[var(--color-paper-border)]/70 focus-within:border-[var(--color-paper-accent)]/70 focus-within:ring-2 focus-within:ring-[var(--color-paper-accent)]/15 transition-all shadow-xs",
596 id: "tiptap-editor",
597 img {
598 src: "/images/xiantiaoxiaogou_input_bg.webp",
599 alt: "",
600 class: "absolute bottom-3 right-3 w-28 opacity-15 pointer-events-none z-0 select-none transition-opacity",
601 }
602 }
603 }
604 }
605 } if sidebar_open() {
609 div { class: "w-80 sm:w-88 flex-shrink-0 min-h-0 overflow-y-auto border-l border-[var(--color-paper-border)]/70 bg-[var(--color-paper-theme)] flex flex-col animate-section-enter",
610 div { class: "px-5 py-4 border-b border-[var(--color-paper-border)]/60 flex items-center justify-between select-none",
612 div { class: "flex items-center gap-2",
613 svg {
614 class: "w-4 h-4 text-[var(--color-paper-secondary)]",
615 xmlns: "http://www.w3.org/2000/svg",
616 view_box: "0 0 24 24",
617 fill: "none",
618 stroke: "currentColor",
619 stroke_width: "2",
620 stroke_linecap: "round",
621 stroke_linejoin: "round",
622 line { x1: "4", y1: "21", x2: "4", y2: "14" }
623 line { x1: "4", y1: "10", x2: "4", y2: "3" }
624 line { x1: "12", y1: "21", x2: "12", y2: "12" }
625 line { x1: "12", y1: "8", x2: "12", y2: "3" }
626 line { x1: "20", y1: "21", x2: "20", y2: "16" }
627 line { x1: "20", y1: "12", x2: "20", y2: "3" }
628 line { x1: "1", y1: "14", x2: "7", y2: "14" }
629 line { x1: "9", y1: "8", x2: "15", y2: "8" }
630 line { x1: "17", y1: "16", x2: "23", y2: "16" }
631 }
632 span { class: "text-xs font-bold uppercase tracking-wider text-[var(--color-paper-secondary)]",
633 "文章属性"
634 }
635 }
636 button {
637 class: "text-[var(--color-paper-tertiary)] hover:text-[var(--color-paper-primary)] p-1 rounded-lg hover:bg-[var(--color-paper-entry)] transition-colors cursor-pointer",
638 title: "收起侧栏",
639 onclick: move |_| sidebar_open.set(false),
640 svg {
641 class: "w-4 h-4",
642 xmlns: "http://www.w3.org/2000/svg",
643 view_box: "0 0 24 24",
644 fill: "none",
645 stroke: "currentColor",
646 stroke_width: "2",
647 stroke_linecap: "round",
648 stroke_linejoin: "round",
649 line { x1: "18", y1: "6", x2: "6", y2: "18" }
650 line { x1: "6", y1: "6", x2: "18", y2: "18" }
651 }
652 }
653 }
654
655 div { class: "p-5 border-b border-[var(--color-paper-border)]/60 flex flex-col gap-2.5",
657 div { class: "flex items-center justify-between",
658 label { class: "flex items-center gap-1.5 text-xs font-semibold text-[var(--color-paper-secondary)]",
659 svg {
660 class: "w-3.5 h-3.5 text-[var(--color-paper-tertiary)]",
661 xmlns: "http://www.w3.org/2000/svg",
662 view_box: "0 0 24 24",
663 fill: "none",
664 stroke: "currentColor",
665 stroke_width: "2",
666 stroke_linecap: "round",
667 stroke_linejoin: "round",
668 path { d: "M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" }
669 path { d: "M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" }
670 }
671 "永久链接 (Slug)"
672 }
673 }
674 div { class: "flex items-center gap-1 text-[11px] text-[var(--color-paper-tertiary)] font-mono px-1 truncate",
675 span { class: "text-[var(--color-paper-accent)] font-semibold", "/post/" }
676 span { class: "truncate",
677 if slug().trim().is_empty() {
678 "(自动生成)"
679 } else {
680 "{slug()}"
681 }
682 }
683 }
684 FormInput {
685 r#type: "text",
686 placeholder: "自定义 URL,留空自动生成...",
687 value: slug(),
688 oninput: move |v: String| slug.set(v),
689 }
690 }
691
692 div { class: "p-5 border-b border-[var(--color-paper-border)]/60 flex flex-col gap-2.5",
694 label { class: "flex items-center gap-1.5 text-xs font-semibold text-[var(--color-paper-secondary)]",
695 svg {
696 class: "w-3.5 h-3.5 text-[var(--color-paper-tertiary)]",
697 xmlns: "http://www.w3.org/2000/svg",
698 view_box: "0 0 24 24",
699 fill: "none",
700 stroke: "currentColor",
701 stroke_width: "2",
702 stroke_linecap: "round",
703 stroke_linejoin: "round",
704 path { d: "M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z" }
705 line { x1: "7", y1: "7", x2: "7.01", y2: "7" }
706 }
707 "文章标签"
708 }
709 FormInput {
710 r#type: "text",
711 placeholder: "输入标签,逗号或分号分隔...",
712 value: tags(),
713 oninput: move |v: String| tags.set(v),
714 }
715 if !parsed_tags().is_empty() {
717 div { class: "flex flex-wrap gap-1.5 mt-1",
718 for tag in parsed_tags() {
719 span {
720 key: "{tag}",
721 class: "inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-[var(--color-paper-entry)] text-[var(--color-paper-primary)] border border-[var(--color-paper-border)]/70 shadow-2xs",
722 "#{tag}"
723 }
724 }
725 }
726 }
727 }
728
729 div { class: "p-5 border-b border-[var(--color-paper-border)]/60 flex flex-col gap-2.5",
731 div { class: "flex items-center justify-between",
732 label { class: "flex items-center gap-1.5 text-xs font-semibold text-[var(--color-paper-secondary)]",
733 svg {
734 class: "w-3.5 h-3.5 text-[var(--color-paper-tertiary)]",
735 xmlns: "http://www.w3.org/2000/svg",
736 view_box: "0 0 24 24",
737 fill: "none",
738 stroke: "currentColor",
739 stroke_width: "2",
740 stroke_linecap: "round",
741 stroke_linejoin: "round",
742 path { d: "M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" }
743 polyline { points: "14 2 14 8 20 8" }
744 line { x1: "16", y1: "13", x2: "8", y2: "13" }
745 line { x1: "16", y1: "17", x2: "8", y2: "17" }
746 }
747 "文章摘要"
748 }
749 span { class: "text-[11px] text-[var(--color-paper-tertiary)]",
750 "{summary().chars().count()} 字"
751 }
752 }
753 textarea {
754 class: "w-full text-sm bg-[var(--color-paper-entry)] text-[var(--color-paper-primary)] placeholder-[var(--color-paper-tertiary)] focus:outline-none border border-[var(--color-paper-border)] focus:border-paper-accent focus:ring-1 focus:ring-paper-accent/30 rounded-2xl px-3.5 py-2.5 transition-all resize-none leading-relaxed",
755 placeholder: "选填,留空将自动从正文提取...",
756 rows: "3",
757 value: "{summary}",
758 oninput: move |evt| summary.set(evt.value()),
759 }
760 }
761
762 div { class: "p-5 flex flex-col gap-2.5",
764 div { class: "flex items-center justify-between",
765 label { class: "flex items-center gap-1.5 text-xs font-semibold text-[var(--color-paper-secondary)]",
766 svg {
767 class: "w-3.5 h-3.5 text-[var(--color-paper-tertiary)]",
768 xmlns: "http://www.w3.org/2000/svg",
769 view_box: "0 0 24 24",
770 fill: "none",
771 stroke: "currentColor",
772 stroke_width: "2",
773 stroke_linecap: "round",
774 stroke_linejoin: "round",
775 rect { x: "3", y: "3", width: "18", height: "18", rx: "2", ry: "2" }
776 circle { cx: "8.5", cy: "8.5", r: "1.5" }
777 polyline { points: "21 15 16 10 5 21" }
778 }
779 "文章封面"
780 }
781 span { class: "text-[11px] text-[var(--color-paper-tertiary)]",
782 "21:9 推荐"
783 }
784 }
785 CoverUploader { cover_image, cover_uploading }
786 }
787 }
788 } } div { class: "flex-shrink-0 px-6 py-3.5 flex items-center justify-between border-t border-[var(--color-paper-border)]/80 bg-[var(--color-paper-theme)] shadow-xs select-none",
793 Link {
795 to: Route::Posts {},
796 class: "inline-flex items-center gap-1.5 px-4 py-2 rounded-full text-sm font-medium text-[var(--color-paper-secondary)] hover:text-[var(--color-paper-primary)] hover:bg-[var(--color-paper-entry)] transition-colors cursor-pointer",
797 "返回列表"
798 }
799
800 div { class: "flex items-center gap-3",
802 FormSelect {
804 trigger_class: Some(
805 "inline-flex w-auto cursor-pointer select-none text-left text-sm font-medium pl-4 pr-8 py-2 rounded-full border border-[var(--color-paper-border)] bg-[var(--color-paper-entry)] text-[var(--color-paper-primary)] hover:bg-[var(--color-paper-theme)] focus:outline-none focus:border-paper-accent focus:ring-1 focus:ring-paper-accent/30 transition-colors duration-200",
806 ),
807 value: status(),
808 options: vec![
809 ("draft".to_string(), "存为草稿"),
810 ("published".to_string(), "直接发布"),
811 ],
812 onchange: move |v| status.set(v),
813 }
814 div { class: "w-px h-5 bg-[var(--color-paper-border)]/60" }
815 LoadingButton {
816 label: if is_edit { "更新文章".to_string() } else { "发布文章".to_string() },
817 loading: saving(),
818 onclick: move |_| on_submit(()),
819 }
820 }
821 } AssetPickerModal {
825 visible: body_picker_visible,
826 cover_uploading: body_picker_uploading,
827 title: "选择图片",
828 multi: true,
829 on_select: move |picks: Vec<AssetSelection>| {
830 #[cfg(target_arch = "wasm32")]
831 if let Some(handle) = &*editor.read() {
832 if let Ok(json) = serde_json::to_string(&picks) {
833 handle.instance().insert_images_from_library(&json);
834 }
835 }
836 #[cfg(not(target_arch = "wasm32"))]
837 let _ = picks;
838 },
839 }
840 }
841 }
842}
843
844#[component]
855#[cfg_attr(not(target_arch = "wasm32"), allow(unused_mut, unused_variables))]
856fn CoverUploader(cover_image: Signal<String>, cover_uploading: Signal<bool>) -> Element {
857 let mut cover_error = use_signal(|| None::<String>);
859 let mut cover_url_mode = use_signal(|| false);
860 let mut cover_drag_active = use_signal(|| false);
861 let mut cover_url_input = use_signal(|| "".to_string());
863 let mut picker_visible = use_signal(|| false);
865 let mut cover_img_loaded = use_signal(|| true);
869
870 #[cfg(target_arch = "wasm32")]
875 let mut spawn_cover_upload = move |file: web_sys::File| {
876 cover_uploading.set(true);
877 cover_error.set(None);
878 spawn(async move {
879 match upload_image_file(file).await {
880 Ok(url) => {
881 cover_image.set(url);
882 }
883 Err(msg) => {
884 cover_error.set(Some(msg));
885 }
886 }
887 cover_uploading.set(false);
888 });
889 };
890 #[cfg(target_arch = "wasm32")]
894 use_effect(move || {
895 let cv = cover_image();
896 if cv.is_empty() {
897 cover_img_loaded.set(true);
898 return;
899 }
900 cover_img_loaded.set(false);
901 let window = web_sys::window()
902 .expect("CoverUploader use_effect 仅在 WASM 浏览器上下文执行:无 window");
903 let selectors = js_sys::Array::of1(&".cover-preview".into());
904 let selectors_val = js_sys::Object::from(selectors).into();
905 let _ = js_sys::Reflect::set(&window, &"__lightboxSelectors".into(), &selectors_val);
906 invoke_optional_global(&window, "__initLightbox", &[selectors_val]);
907 });
908
909 rsx! {
910 div {
914 class: "relative w-full border border-dashed rounded-2xl overflow-hidden transition-all duration-200 group/cover",
915 class: "cover-preview",
916 class: if cover_image().is_empty() && !cover_uploading() { "h-14" } else { "aspect-[21/9]" },
918 class: if cover_drag_active() { "border-[var(--color-paper-primary)] bg-[var(--color-paper-entry)]" } else if cover_image().is_empty() { "border-[var(--color-paper-border)] bg-[var(--color-paper-entry)] hover:border-[var(--color-paper-primary)]" } else { "border-[var(--color-paper-border)] bg-[var(--color-paper-entry)]" },
919
920 ondragover: move |evt| {
922 evt.prevent_default();
923 if !cover_uploading() {
924 cover_drag_active.set(true);
925 }
926 },
927 ondragenter: move |evt| {
928 evt.prevent_default();
929 },
930 ondragleave: move |_| {
931 cover_drag_active.set(false);
932 },
933 ondrop: move |evt| {
934 evt.prevent_default();
935 if !cover_uploading() {
936 #[cfg(target_arch = "wasm32")]
937 {
938 if let Some(file) = evt.files().into_iter().next() {
939 if let Some(web_file) = file.get_web_file() {
940 spawn_cover_upload(web_file);
941 }
942 }
943 }
944 }
945 },
946 onpaste: move |evt| {
947 evt.prevent_default();
948 if !cover_uploading() {
949 #[cfg(target_arch = "wasm32")]
950 {
951 use wasm_bindgen::JsCast;
952 if let Some(raw) = evt.try_as_web_event() {
953 if let Some(ce) = raw.dyn_ref::<web_sys::ClipboardEvent>() {
954 if let Some(dt) = ce.clipboard_data() {
955 if let Some(file_list) = dt.files() {
956 if let Some(file) = file_list.item(0) {
957 spawn_cover_upload(file);
958 }
959 }
960 }
961 }
962 }
963 }
964 }
965 },
966
967 if cover_uploading() {
969 div { class: "absolute inset-0 flex flex-col items-center justify-center gap-3 bg-[var(--color-paper-tertiary)]/30 animate-pulse",
970 span { class: "text-sm font-medium text-[var(--color-paper-secondary)]",
971 "正在上传..."
972 }
973 }
974 }
975
976 if !cover_image().is_empty() && !cover_uploading() {
978 img {
982 class: "absolute inset-0 w-full h-full object-cover cursor-zoom-in lightbox-single",
983 "data-src": "{cover_image()}",
985 src: {
986 let cv = cover_image();
987 if cv.starts_with("/uploads/") {
988 let base = cv.split('?').next().unwrap_or(&cv);
989 if cv.contains('?') {
990 format!("{}&w=600", base)
991 } else {
992 format!("{}?w=600", base)
993 }
994 } else {
995 cv
996 }
997 },
998 alt: "封面预览",
999 onload: move |_| cover_img_loaded.set(true),
1001 onerror: move |_| {
1002 cover_img_loaded.set(true);
1003 cover_error.set(Some("封面图加载失败,请检查 URL".to_string()));
1004 },
1005 }
1006 if !cover_img_loaded() {
1008 div { class: "absolute inset-0 animate-pulse bg-[var(--color-paper-tertiary)]/40 pointer-events-none" }
1009 }
1010 if cover_drag_active() {
1012 div { class: "absolute inset-0 flex items-center justify-center bg-black/50 pointer-events-none",
1013 span { class: "text-sm font-medium text-white drop-shadow-md",
1014 "松开以更换封面"
1015 }
1016 }
1017 }
1018 if !cover_drag_active() {
1021 div { class: "absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/60 to-transparent opacity-0 group-hover/cover:opacity-100 transition-opacity flex items-center justify-center gap-1 py-2 pointer-events-none",
1022 label { class: "pointer-events-auto px-3 py-1.5 rounded-full text-xs font-medium text-white/90 hover:bg-white/15 cursor-pointer transition-colors",
1024 "更换"
1025 input {
1026 r#type: "file",
1027 accept: "image/jpeg,image/png,image/gif,image/webp",
1028 class: "hidden",
1029 onchange: move |evt| {
1030 #[cfg(target_arch = "wasm32")]
1031 {
1032 if let Some(file) = evt.files().into_iter().next() {
1033 if let Some(web_file) = file.get_web_file() {
1034 spawn_cover_upload(web_file);
1035 }
1036 }
1037 }
1038 },
1039 }
1040 }
1041 button {
1043 class: "pointer-events-auto px-3 py-1.5 rounded-full text-xs font-medium text-white/90 hover:bg-white/15 transition-colors cursor-pointer",
1044 onclick: move |_| picker_visible.set(true),
1045 "素材库"
1046 }
1047 button {
1049 class: "pointer-events-auto px-3 py-1.5 rounded-full text-xs font-medium text-white/90 hover:bg-white/15 transition-colors cursor-pointer",
1050 onclick: move |_| {
1051 cover_image.set(String::new());
1052 cover_error.set(None);
1053 cover_url_mode.set(false);
1054 cover_url_input.set(String::new());
1055 },
1056 "移除"
1057 }
1058 }
1059 }
1060 }
1061
1062 if cover_image().is_empty() && !cover_uploading() {
1064 label { class: "absolute inset-0 flex flex-row items-center gap-3 cursor-pointer px-4 text-left",
1066 svg {
1068 class: "w-5 h-5 shrink-0 text-[var(--color-paper-secondary)]",
1069 xmlns: "http://www.w3.org/2000/svg",
1070 view_box: "0 0 24 24",
1071 fill: "none",
1072 stroke: "currentColor",
1073 stroke_width: "1.8",
1074 stroke_linecap: "round",
1075 stroke_linejoin: "round",
1076 path { d: "M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" }
1077 polyline { points: "17 8 12 3 7 8" }
1078 line {
1079 x1: "12",
1080 y1: "3",
1081 x2: "12",
1082 y2: "15",
1083 }
1084 }
1085 span { class: "text-sm font-medium text-[var(--color-paper-secondary)] shrink-0",
1086 "拖拽 / 粘贴 / 点击上传"
1087 }
1088 span {
1090 class: "text-sm font-medium text-[var(--color-paper-tertiary)] hover:text-[var(--color-paper-primary)] transition-colors ml-auto shrink-0",
1091 onclick: move |evt| {
1092 evt.prevent_default();
1093 evt.stop_propagation();
1094 picker_visible.set(true);
1095 },
1096 "素材库"
1097 }
1098 span {
1100 class: "text-sm font-medium text-[var(--color-paper-tertiary)] hover:text-[var(--color-paper-primary)] transition-colors shrink-0",
1101 onclick: move |evt| {
1102 evt.prevent_default();
1103 evt.stop_propagation();
1104 cover_url_mode.set(true);
1105 cover_url_input.set(cover_image());
1106 },
1107 "输入链接"
1108 }
1109 input {
1111 r#type: "file",
1112 accept: "image/jpeg,image/png,image/gif,image/webp",
1113 class: "hidden",
1114 onchange: move |evt| {
1115 #[cfg(target_arch = "wasm32")]
1116 {
1117 if let Some(file) = evt.files().into_iter().next() {
1118 if let Some(web_file) = file.get_web_file() {
1119 spawn_cover_upload(web_file);
1120 }
1121 }
1122 }
1123 },
1126 }
1127 }
1128 }
1129 }
1130
1131 if cover_url_mode() && cover_image().is_empty() {
1133 div { class: "flex items-center gap-2 mt-2",
1134 FormInput {
1135 r#type: "url",
1136 placeholder: "粘贴图片链接...",
1137 value: cover_url_input(),
1138 class: INPUT_INLINE_CLASS,
1139 oninput: move |v: String| cover_url_input.set(v),
1140 onkeydown: move |evt: KeyboardEvent| {
1141 if evt.key() == Key::Enter {
1142 let v = cover_url_input().trim().to_string();
1143 if !v.is_empty() {
1144 cover_image.set(v);
1145 cover_error.set(None);
1146 cover_url_mode.set(false);
1147 }
1148 }
1149 },
1150 }
1151 button {
1152 class: "shrink-0 {BTN_PRIMARY_SM}",
1153 onclick: move |_| {
1154 let v = cover_url_input().trim().to_string();
1155 if !v.is_empty() {
1156 cover_image.set(v);
1157 cover_error.set(None);
1158 cover_url_mode.set(false);
1159 }
1160 },
1161 "确认"
1162 }
1163 button {
1164 class: "shrink-0 px-4 py-1.5 rounded-full text-sm font-medium text-[var(--color-paper-secondary)] hover:bg-[var(--color-paper-entry)] transition-colors cursor-pointer",
1165 onclick: move |_| {
1166 cover_url_mode.set(false);
1167 cover_url_input.set(String::new());
1168 },
1169 "取消"
1170 }
1171 }
1172 }
1173
1174 if let Some(err) = cover_error() {
1176 div { class: "flex items-center justify-between gap-3 px-4 py-2 bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 rounded-2xl text-sm border border-red-100 dark:border-red-900/30 mt-2",
1177 span { "封面图: {err}" }
1178 button {
1179 class: "{BTN_CLOSE_ICON}",
1180 aria_label: "关闭提示",
1181 onclick: move |_| cover_error.set(None),
1182 "×"
1183 }
1184 }
1185 }
1186
1187 AssetPickerModal {
1189 visible: picker_visible,
1190 cover_uploading,
1191 on_select: move |picks: Vec<AssetSelection>| {
1192 if let Some(first) = picks.into_iter().next() {
1194 cover_image.set(first.url);
1195 cover_error.set(None);
1196 }
1197 },
1198 }
1199 }
1200}