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