From ef6a3f0da23c04826b83572d685df0786adb15f6 Mon Sep 17 00:00:00 2001 From: xfy Date: Tue, 23 Jun 2026 14:14:28 +0800 Subject: [PATCH] fix(home,editor): repair post card nested anchors and editor upload state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 首页审查修复(home/post_card/image_viewer/search/home_skeleton): - PostCard 消除嵌套 :外层 Link 包裹改为绝对定位覆盖层链接, 标题/摘要/封面降级为普通元素,标签用 z-10 叠在覆盖层之上并 stop_propagation,整卡点击跳转文章详情。 - ImageViewer 新增 lightbox prop(默认 true),卡片封面传 false 禁用灯箱以归一为单一跳转交互;文章详情页零改动。 - home.rs 越界页码守卫:分页仅在 total>0 渲染,避免 /page/9999 出现孤立空分页;错误文案脱敏为"加载失败"。 - search.rs 错误文案脱敏为"搜索失败",与首页/标签页一致。 - HomeSkeleton 卡片 5→10,对齐 POSTS_PER_PAGE。 编辑器上传重构(write/tiptap_bridge): - consume_upload_event 改用 upload_errors Vec 自身判重,移除 seen_error_ids 副本状态;Signal 参数加 mut 修复 E0596 编译错误。 - make_upload_closure 构造阶段错误以 rejected Promise 返回而非 panic,单张坏文件不再拖垮整个编辑器。 - write.rs 移除未用的 success signal,编辑保存后统一跳转 Posts, 标签分隔符支持半角/全角逗号与分号,提取 META_*_CLASS 常量去重。 - Makefile dev 目标清理 static/ 目录。 --- Makefile | 2 + src/components/image_viewer.rs | 15 +++-- src/components/post_card.rs | 81 ++++++++++++----------- src/components/skeletons/home_skeleton.rs | 7 +- src/pages/admin/write.rs | 60 ++++++++--------- src/pages/home.rs | 39 ++++++----- src/pages/search.rs | 8 ++- src/tiptap_bridge.rs | 75 ++++++++++----------- 8 files changed, 152 insertions(+), 135 deletions(-) diff --git a/Makefile b/Makefile index 2536bc4..6e814df 100644 --- a/Makefile +++ b/Makefile @@ -27,6 +27,8 @@ build-editor-incremental: @cd libs/tiptap-editor && npm run build dev: build-editor-incremental + @echo "Cleaning static/..." + @rm -rf static/ @echo "Building Tiptap editor (incremental)..." @echo "Starting tailwindcss watch and dx serve..." @tailwindcss -i input.css -o public/style.css --watch & \ diff --git a/src/components/image_viewer.rs b/src/components/image_viewer.rs index 6da7981..6b5ef7d 100644 --- a/src/components/image_viewer.rs +++ b/src/components/image_viewer.rs @@ -13,8 +13,10 @@ use dioxus::prelude::*; /// - `thumb_params`:缩略图 URL 参数,默认 `"?w=800"` /// - `alt`:图片替代文本,默认 `"图片"` /// - `lazy_load`:是否启用懒加载,默认 `false` +/// - `lightbox`:是否启用点击放大灯箱,默认 `true`。卡片等场景设为 `false` +/// 以禁用灯箱交互,使整张卡片归一为单一跳转行为(避免嵌套交互)。 /// -/// 关键事件: +/// 关键事件(仅在 `lightbox == true` 时生效): /// - 点击缩略图:打开全屏灯箱 /// - 点击遮罩或关闭按钮:关闭灯箱 /// - 点击灯箱内容区:阻止事件冒泡,避免误关闭 @@ -26,16 +28,18 @@ pub fn ImageViewer( #[props(default = "?w=20".to_string())] placeholder_params: String, #[props(default = "图片".to_string())] alt: String, #[props(default = false)] lazy_load: bool, + #[props(default = true)] lightbox: bool, ) -> Element { let mut is_open = use_signal(|| false); // 打开灯箱时聚焦关闭按钮,并监听 Escape 键关闭。 + // 灯箱禁用时跳过键盘监听与焦点管理,避免无谓的 DOM 操作。 #[cfg(target_arch = "wasm32")] { use wasm_bindgen::{closure::Closure, JsCast}; use_effect(move || { - if !is_open() { + if !lightbox || !is_open() { return; } @@ -107,10 +111,11 @@ pub fn ImageViewer( rsx! { // blur-up 双层:底层占位图 + 上层高清图(data-src 由前端 JS 懒加载) + // 灯箱禁用时不绑定 onclick,使缩略图成为纯展示元素(卡片由外层覆盖链接接管跳转)。 span { class: "blur-img", style: "{ar_style}", - onclick: move |_| is_open.set(true), + onclick: move |_| if lightbox { is_open.set(true) }, img { class: "blur-img-placeholder", src: "{placeholder_src}", @@ -124,8 +129,8 @@ pub fn ImageViewer( } } - // 全屏灯箱 - if is_open() { + // 全屏灯箱(仅 lightbox 启用时可能打开) + if lightbox && is_open() { div { class: "image-viewer-overlay", role: "dialog", diff --git a/src/components/post_card.rs b/src/components/post_card.rs index 91de51b..69cbcd5 100644 --- a/src/components/post_card.rs +++ b/src/components/post_card.rs @@ -15,13 +15,16 @@ use crate::router::Route; /// - `post`:文章数据模型 /// /// 展示内容包括: -/// - 封面图(如有,使用 400x300 缩略图) +/// - 封面图(如有,使用 400x300 缩略图,不启用灯箱) /// - 文章标题 /// - 摘要(最多两行) /// - 发布日期与标签 /// -/// 关键事件: -/// - 点击标签时阻止事件冒泡,避免触发整卡跳转 +/// 交互模型(采用覆盖层链接,避免 `` 嵌套 `` 的非法 HTML): +/// - 整张卡片可点击跳转到文章详情:通过末尾一个绝对定位、铺满卡片的覆盖层 `Link` 实现。 +/// - 标签是独立的 `Link`,通过 `relative z-10` 叠在覆盖层之上,并 `stop_propagation`, +/// 点击标签进入标签详情页而不触发卡片跳转。 +/// - 封面 `ImageViewer` 关闭灯箱(`lightbox: false`),点击封面走卡片跳转,避免交互歧义。 #[component] pub fn PostCard(post: PostListItem) -> Element { let post_slug = post.slug.clone(); @@ -30,47 +33,51 @@ pub fn PostCard(post: PostListItem) -> Element { rsx! { article { - class: "relative mb-6 p-6 bg-paper-entry rounded-lg border border-paper-border hover:-translate-y-0.5 hover:border-paper-accent/50 hover:shadow-sm transition-all duration-200", - Link { - class: "block group", - to: Route::PostDetail { slug: post_slug }, - if has_cover { - div { - class: "mb-4 -mx-6 -mt-6 overflow-hidden rounded-t-lg", - ImageViewer { - src: post.cover_image.clone().unwrap_or_default(), - thumb_params: "?thumb=400x300", - alt: post.title.clone(), - lazy_load: true, - } + class: "group relative mb-6 p-6 bg-paper-entry rounded-lg border border-paper-border hover:-translate-y-0.5 hover:border-paper-accent/50 hover:shadow-sm transition-all duration-200", + if has_cover { + div { + class: "mb-4 -mx-6 -mt-6 overflow-hidden rounded-t-lg", + ImageViewer { + src: post.cover_image.clone().unwrap_or_default(), + thumb_params: "?thumb=400x300", + alt: post.title.clone(), + lazy_load: true, + lightbox: false, } } - h2 { - class: "text-2xl font-bold leading-tight text-paper-primary group-hover:text-paper-accent transition-colors duration-200", - "{post.title}" - } - div { - class: "mt-2 text-sm text-paper-secondary leading-relaxed line-clamp-2", - "{post.summary.as_deref().unwrap_or_default()}" - } - div { - class: "mt-3 flex items-center gap-3 text-[13px] text-paper-secondary", - span { "{date_str}" } - if !post.tags.is_empty() { - span { "·" } - for tag in post.tags.clone().into_iter() { - span { - Link { - class: "hover:text-paper-accent transition-colors duration-200", - to: Route::TagDetail { tag: tag.clone() }, - onclick: move |evt: dioxus::events::MouseEvent| evt.stop_propagation(), - "{tag}" - } + } + h2 { + class: "text-2xl font-bold leading-tight text-paper-primary group-hover:text-paper-accent transition-colors duration-200", + "{post.title}" + } + div { + class: "mt-2 text-sm text-paper-secondary leading-relaxed line-clamp-2", + "{post.summary.as_deref().unwrap_or_default()}" + } + div { + class: "mt-3 flex items-center gap-3 text-[13px] text-paper-secondary", + span { "{date_str}" } + if !post.tags.is_empty() { + span { "·" } + for tag in post.tags.clone().into_iter() { + span { + // 标签叠在覆盖链接之上,点击进入标签详情页而非文章详情。 + Link { + class: "relative z-10 hover:text-paper-accent transition-colors duration-200", + to: Route::TagDetail { tag: tag.clone() }, + onclick: move |evt: dioxus::events::MouseEvent| evt.stop_propagation(), + "{tag}" } } } } } + // 覆盖层链接:铺满卡片,承担整卡跳转。z-0 位于标签 (z-10) 之下。 + Link { + class: "absolute inset-0 z-0", + aria_label: "post link to {post.title}", + to: Route::PostDetail { slug: post_slug }, + } } } } diff --git a/src/components/skeletons/home_skeleton.rs b/src/components/skeletons/home_skeleton.rs index b21e3f5..d71e847 100644 --- a/src/components/skeletons/home_skeleton.rs +++ b/src/components/skeletons/home_skeleton.rs @@ -9,13 +9,14 @@ use crate::components::skeletons::post_card_skeleton::PostCardSkeleton; /// 首页骨架屏组件。 /// -/// 显示 5 个文章卡片骨架与分页按钮占位。 +/// 显示与 `POSTS_PER_PAGE` 等量的文章卡片骨架与分页按钮占位, +/// 使骨架屏与加载完成后的实际列表长度一致,避免内容跳变。 #[component] pub fn HomeSkeleton() -> Element { rsx! { div { - // 5 个文章卡片骨架 - for _ in 0..5 { + // 10 个文章卡片骨架,对齐首页 POSTS_PER_PAGE。 + for _ in 0..10 { PostCardSkeleton {} } // 分页按钮占位 diff --git a/src/pages/admin/write.rs b/src/pages/admin/write.rs index 2837fb4..4d73255 100644 --- a/src/pages/admin/write.rs +++ b/src/pages/admin/write.rs @@ -28,7 +28,7 @@ use wasm_bindgen::closure::Closure; /// /// 内部委托给 `write_editor`,以 `None` 表示新建模式。 #[component] -#[allow(unused_mut, unused_variables)] +#[cfg_attr(not(target_arch = "wasm32"), allow(unused_mut, unused_variables))] pub fn Write() -> Element { write_editor(None) } @@ -37,7 +37,7 @@ pub fn Write() -> Element { /// /// `id` 为要编辑的文章 ID,内部委托给 `write_editor` 加载现有数据。 #[component] -#[allow(unused_mut, unused_variables)] +#[cfg_attr(not(target_arch = "wasm32"), allow(unused_mut, unused_variables))] pub fn WriteEdit(id: i32) -> Element { write_editor(Some(id)) } @@ -50,7 +50,7 @@ pub fn WriteEdit(id: i32) -> Element { /// - 编辑模式下将 Markdown 内容回填到编辑器; /// - 提交时读取编辑器 Markdown、校验并调用 create_post / update_post; /// - 组件卸载时销毁 Tiptap 实例(EditorHandle::drop 自动 destroy + 释放 closure)。 -#[allow(unused_mut, unused_variables)] +#[cfg_attr(not(target_arch = "wasm32"), allow(unused_mut, unused_variables))] fn write_editor(post_id: Option) -> Element { let is_edit = post_id.is_some(); @@ -66,7 +66,6 @@ fn write_editor(post_id: Option) -> Element { let mut loading = use_signal(|| true); let mut saving = use_signal(|| false); let mut error = use_signal(|| None::); - let mut success = use_signal(|| false); let mut editor_content_set = use_signal(|| false); let mut has_backfilled = use_signal(|| false); let mut load_error = use_signal(|| None::); @@ -84,11 +83,6 @@ fn write_editor(post_id: Option) -> Element { // 上传状态:当前进行中计数(保存拦截)+ 顶部失败提示堆叠(用户手动关闭) let mut uploads_in_flight = use_signal(UploadsInFlight::default); let mut upload_errors: Signal> = use_signal(Vec::new); - // 已展示过错误的上传 id(去重 + 重试后再失败时原地更新)。 - // 用 signal 而非局部变量:closure 的 FnMut 闭包要求可重复调用, - // signal 是 Copy 可被多次 move。 - let mut seen_error_ids: Signal> = - use_signal(std::collections::HashSet::new); // 编辑模式:文章数据加载完成后,将字段回填到表单信号。 use_effect(move || { @@ -161,11 +155,10 @@ fn write_editor(post_id: Option) -> Element { }); let on_image_upload = crate::tiptap_bridge::make_upload_closure(); let on_upload_event = Closure::new({ - let mut uploads_in_flight = uploads_in_flight; - let mut upload_errors = upload_errors; - let mut seen_error_ids = seen_error_ids; + let uploads_in_flight = uploads_in_flight; + let upload_errors = upload_errors; move |ev: crate::tiptap_bridge::UploadEventJs| { - consume_upload_event(&ev, &mut uploads_in_flight, &mut upload_errors, &mut seen_error_ids); + consume_upload_event(&ev, uploads_in_flight, upload_errors); } }); @@ -240,13 +233,11 @@ fn write_editor(post_id: Option) -> Element { if title().trim().is_empty() { error.set(Some("标题不能为空".to_string())); - #[allow(clippy::needless_return)] - return; } // 仅在 WASM 前端读取编辑器内容并发起保存请求。 #[cfg(target_arch = "wasm32")] - { + if !title().trim().is_empty() { // 通过 EditorHandle 实例读取 Markdown;句柄未就绪时退回 content signal。 let md = if let Some(handle) = &*editor.read() { handle.instance().get_markdown() @@ -268,8 +259,9 @@ fn write_editor(post_id: Option) -> Element { } // 将逗号分隔的标签字符串转换为列表。 + // 同时支持半角/全角逗号与分号,避免中文输入法下的全角标点被误并入单个标签。 let tags_list: Vec = tags() - .split(',') + .split(|c| matches!(c, ',' | ',' | ';' | ';')) .map(|t| t.trim().to_string()) .filter(|t| !t.is_empty()) .collect(); @@ -312,7 +304,6 @@ fn write_editor(post_id: Option) -> Element { { Ok(CreatePostResponse { success: true, .. }) => { saving.set(false); - success.set(true); let _ = dioxus::router::navigator().push(Route::Posts {}); } Ok(CreatePostResponse { @@ -345,8 +336,7 @@ fn write_editor(post_id: Option) -> Element { { Ok(CreatePostResponse { success: true, .. }) => { saving.set(false); - success.set(true); - let _ = dioxus::router::navigator().push(Route::Admin {}); + let _ = dioxus::router::navigator().push(Route::Posts {}); } Ok(CreatePostResponse { success: false, @@ -364,6 +354,12 @@ fn write_editor(post_id: Option) -> Element { }); } } + + // 非 WASM(SSR hydration 前等场景):明确提示无法保存,而非静默无反应。 + #[cfg(not(target_arch = "wasm32"))] + if !title().trim().is_empty() { + error.set(Some("此页面需要 JavaScript 才能保存".to_string())); + } }; let save_button_text = if saving() { @@ -374,6 +370,12 @@ fn write_editor(post_id: Option) -> Element { "保存" }; + // 元信息表单复用的样式:label 与 input 各一份,避免三处重复粘贴。 + const META_LABEL_CLASS: &str = + "block text-[11px] font-medium text-[var(--color-paper-secondary)] tracking-wider mb-2"; + const META_INPUT_CLASS: &str = + "w-full text-sm bg-transparent text-[var(--color-paper-primary)] placeholder-[var(--color-paper-tertiary)] focus:outline-none border-b border-[var(--color-paper-tertiary)] focus:border-[var(--color-paper-primary)] transition-colors pb-1.5"; + rsx! { div { class: "relative flex flex-col flex-1 min-h-0 overflow-hidden", if loading() { @@ -406,33 +408,33 @@ fn write_editor(post_id: Option) -> Element { // 元数据行 - 紧凑精致 div { class: "flex flex-wrap items-end gap-x-8 gap-y-4 text-sm", div { class: "flex-1 min-w-[140px]", - label { class: "block text-[11px] font-medium text-[var(--color-paper-secondary)] tracking-wider mb-2", + label { class: "{META_LABEL_CLASS}", "Slug" } input { - class: "w-full text-sm bg-transparent text-[var(--color-paper-primary)] placeholder-[var(--color-paper-tertiary)] focus:outline-none border-b border-[var(--color-paper-tertiary)] focus:border-[var(--color-paper-primary)] transition-colors pb-1.5", + class: "{META_INPUT_CLASS}", placeholder: "自动生成", value: "{slug}", oninput: move |evt| slug.set(evt.value()), } } div { class: "flex-1 min-w-[140px]", - label { class: "block text-[11px] font-medium text-[var(--color-paper-secondary)] tracking-wider mb-2", + label { class: "{META_LABEL_CLASS}", "标签" } input { - class: "w-full text-sm bg-transparent text-[var(--color-paper-primary)] placeholder-[var(--color-paper-tertiary)] focus:outline-none border-b border-[var(--color-paper-tertiary)] focus:border-[var(--color-paper-primary)] transition-colors pb-1.5", + class: "{META_INPUT_CLASS}", placeholder: "逗号分隔", value: "{tags}", oninput: move |evt| tags.set(evt.value()), } } div { class: "flex-1 min-w-[140px]", - label { class: "block text-[11px] font-medium text-[var(--color-paper-secondary)] tracking-wider mb-2", + label { class: "{META_LABEL_CLASS}", "封面图" } input { - class: "w-full text-sm bg-transparent text-[var(--color-paper-primary)] placeholder-[var(--color-paper-tertiary)] focus:outline-none border-b border-[var(--color-paper-tertiary)] focus:border-[var(--color-paper-primary)] transition-colors pb-1.5", + class: "{META_INPUT_CLASS}", placeholder: "URL(可选)", value: "{cover_image}", oninput: move |evt| cover_image.set(evt.value()), @@ -462,12 +464,6 @@ fn write_editor(post_id: Option) -> Element { } } - if success() { - div { class: "flex-shrink-0 px-4 py-2 bg-green-50 dark:bg-green-900/20 text-green-600 dark:text-green-400 rounded-xl text-sm border border-green-100 dark:border-green-900/30 mb-2", - "保存成功" - } - } - // 上传失败提示:多条堆叠,×关闭同时删除编辑器内失败占位符(避免孤儿) for err in upload_errors().clone() { div { 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-xl text-sm border border-red-100 dark:border-red-900/30 mb-2", diff --git a/src/pages/home.rs b/src/pages/home.rs index 2eccb2d..727bfd0 100644 --- a/src/pages/home.rs +++ b/src/pages/home.rs @@ -63,33 +63,38 @@ fn HomePosts(current_page: i32) -> Element { for post in posts.iter() { PostCard { post: post.clone() } } - // 如果当前页没有任何文章,显示空状态提示。 - if posts.is_empty() { + // total == 0 表示站点确实无文章:显示空状态,且不渲染分页。 + // 注意:total > 0 但 posts 为空(如越界页码 /page/9999)也不显示空状态, + // 避免误导用户以为站点无内容;此时仅靠下方的分页导航引导回有效页。 + if total == 0 { div { class: "text-center text-paper-secondary py-20", "暂无文章" } } - // 在列表底部渲染分页导航。 + // 仅在有文章时渲染分页导航,避免越界页码下出现孤立的空分页。 // frontend variant 不渲染页码计数,unit 不显示(仅满足必填 prop)。 - Pagination { - variant: "frontend", - current_page, - total, - per_page: POSTS_PER_PAGE, - prev_route: if current_page - 1 <= 1 { - Route::Home {} - } else { - Route::HomePage { page: current_page - 1 } - }, - next_route: Route::HomePage { page: current_page + 1 }, - unit: "篇", + if total > 0 { + Pagination { + variant: "frontend", + current_page, + total, + per_page: POSTS_PER_PAGE, + prev_route: if current_page - 1 <= 1 { + Route::Home {} + } else { + Route::HomePage { page: current_page - 1 } + }, + next_route: Route::HomePage { page: current_page + 1 }, + unit: "篇", + } } } } - Some(Err(e)) => { + // 不透传内部错误细节,统一展示通用文案(与标签页等其它页面一致)。 + Some(Err(_)) => { rsx! { div { class: "text-center text-red-500 dark:text-red-400 py-20", - "加载失败: {e}" + "加载失败" } } } diff --git a/src/pages/search.rs b/src/pages/search.rs index 9294bf2..ffcea5e 100644 --- a/src/pages/search.rs +++ b/src/pages/search.rs @@ -78,9 +78,13 @@ pub fn Search() -> Element { PostCard { post: post.clone() } } } - } else if let Some(Err(e)) = search_res() { + } else if search_res() + .as_ref() + .map(|r| r.is_err()) + .unwrap_or(false) + { div { class: "text-center text-red-500 dark:text-red-400 py-20", - "搜索失败: {e}" + "搜索失败" } } } diff --git a/src/tiptap_bridge.rs b/src/tiptap_bridge.rs index ea8c07c..0225baf 100644 --- a/src/tiptap_bridge.rs +++ b/src/tiptap_bridge.rs @@ -144,12 +144,9 @@ pub mod wasm { /// 持有编辑器实例 + 其全部 closure,统一生命周期。 /// - /// drop 时先 destroy JS 实例(释放 ProseMirror 资源),再 drop closure - /// (释放 wasm-bindgen 回调表)。比 `Closure::forget`(永久泄漏)干净—— - /// closure 严格随编辑器实例同生共死。 - /// - /// 字段顺序:`instance` 在前仅表示逻辑主从;Drop::drop 里显式调 destroy, - /// 之后 closure 字段按声明逆序自动 drop,无顺序敏感问题。 + /// drop 时先 destroy JS 实例(释放 ProseMirror 资源),随后 closure 字段 + /// 按声明逆序自动 drop(释放 wasm-bindgen 回调表)。比 `Closure::forget` + /// (永久泄漏)干净——closure 严格随编辑器实例同生共死。 pub struct EditorHandle { instance: EditorInstance, _on_update: Closure, @@ -184,7 +181,6 @@ pub mod wasm { impl Drop for EditorHandle { fn drop(&mut self) { self.instance.destroy(); - // closure 字段随后自动 drop(逆序:on_upload_event → on_ready → on_image_upload → on_update) } } @@ -194,35 +190,33 @@ pub mod wasm { /// /// 逻辑与旧轮询 body 一致: /// - error:新 id 追加提示,已存在 id 原地更新消息(重试后再失败) - /// - success/removed:移除对应提示 + 清 seen + /// - success/removed:移除对应提示 /// - counts:直接从事件读(JS 已遍历文档算好) + /// + /// 去重以 `upload_errors` Vec 自身为唯一数据源(用 iter().any 判重), + /// 不再额外维护 seen_error_ids,避免两份状态需手动同步。 pub fn consume_upload_event( ev: &UploadEventJs, - uploads_in_flight: &mut dioxus::prelude::Signal, - upload_errors: &mut dioxus::prelude::Signal>, - seen_error_ids: &mut dioxus::prelude::Signal>, + mut uploads_in_flight: dioxus::prelude::Signal, + mut upload_errors: dioxus::prelude::Signal>, ) { let id = ev.upload_id(); match ev.kind().as_str() { "error" => { let msg = ev.error_msg().unwrap_or_else(|| "上传失败".to_string()); - if seen_error_ids.write().insert(id.clone()) { - // 新失败:追加提示 - upload_errors.write().push(UploadErrorEntry { + // 已存在同 id(重试后再失败):原地更新消息;否则追加。 + let mut errors = upload_errors.write(); + if let Some(entry) = errors.iter_mut().find(|e| e.id == id) { + entry.message = msg; + } else { + errors.push(UploadErrorEntry { id: id.clone(), file_name: ev.file_name(), message: msg, }); - } else { - // 已存在的 id(重试后再失败):原地更新消息 - let mut errors = upload_errors.write(); - if let Some(entry) = errors.iter_mut().find(|e| e.id == id) { - entry.message = msg; - } } } "success" | "removed" => { - seen_error_ids.write().remove(&id); upload_errors.write().retain(|e| e.id != id); } _ => {} @@ -245,27 +239,30 @@ pub mod wasm { /// - 成功 → resolve(url);失败 → reject(Error(服务端中文 error)) /// /// 返回的 closure 签名 `(File) -> Promise` 对应 JS `onImageUpload`。 + /// 构造阶段的错误(FormData/Request 等)以 rejected Promise 返回, + /// 而非 panic —— 单张坏文件不应导致整个编辑器崩溃。 pub fn make_upload_closure() -> Closure js_sys::Promise> { Closure::new(move |file: web_sys::File| -> js_sys::Promise { - // 构造 FormData:字段名 'image' 与服务端 upload.rs 对齐 - let form = web_sys::FormData::new().expect("FormData::new failed"); - form.append_with_blob("image", &file) - .expect("FormData.append failed"); - - // 构造 POST 请求,credentials same-origin 携带 session cookie - let mut init = web_sys::RequestInit::new(); - init.method("POST"); - init.body(Some(&form)); - init.credentials(web_sys::RequestCredentials::SameOrigin); - - let request = web_sys::Request::new_with_str_and_init("/api/upload", &init) - .expect("Request::new failed"); - - let window = web_sys::window().expect("no window"); - let promise = window.fetch_with_request(&request); - - // 把 fetch Promise → Future → 解析响应体 → 再包回 Promise wasm_bindgen_futures::future_to_promise(async move { + // 构造 FormData:字段名 'image' 与服务端 upload.rs 对齐 + let form = web_sys::FormData::new() + .map_err(|_| js_sys::Error::new("无法构造上传表单"))?; + form.append_with_blob("image", &file) + .map_err(|_| js_sys::Error::new("无法附加文件"))?; + + // 构造 POST 请求,credentials same-origin 携带 session cookie + let mut init = web_sys::RequestInit::new(); + init.method("POST"); + init.body(Some(&form)); + init.credentials(web_sys::RequestCredentials::SameOrigin); + + let request = web_sys::Request::new_with_str_and_init("/api/upload", &init) + .map_err(|_| js_sys::Error::new("无法构造上传请求"))?; + + let window = web_sys::window().expect("no window"); + let promise = window.fetch_with_request(&request); + + // 把 fetch Promise → Future → 解析响应体 → 再包回 Promise let resp_val = wasm_bindgen_futures::JsFuture::from(promise).await?; let resp: web_sys::Response = resp_val.dyn_into()?;