fix(home,editor): repair post card nested anchors and editor upload state

首页审查修复(home/post_card/image_viewer/search/home_skeleton):
- PostCard 消除嵌套 <a>:外层 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/ 目录。
This commit is contained in:
xfy 2026-06-23 14:14:28 +08:00
parent 1e270f0055
commit ef6a3f0da2
8 changed files with 152 additions and 135 deletions

View File

@ -27,6 +27,8 @@ build-editor-incremental:
@cd libs/tiptap-editor && npm run build @cd libs/tiptap-editor && npm run build
dev: build-editor-incremental dev: build-editor-incremental
@echo "Cleaning static/..."
@rm -rf static/
@echo "Building Tiptap editor (incremental)..." @echo "Building Tiptap editor (incremental)..."
@echo "Starting tailwindcss watch and dx serve..." @echo "Starting tailwindcss watch and dx serve..."
@tailwindcss -i input.css -o public/style.css --watch & \ @tailwindcss -i input.css -o public/style.css --watch & \

View File

@ -13,8 +13,10 @@ use dioxus::prelude::*;
/// - `thumb_params`:缩略图 URL 参数,默认 `"?w=800"` /// - `thumb_params`:缩略图 URL 参数,默认 `"?w=800"`
/// - `alt`:图片替代文本,默认 `"图片"` /// - `alt`:图片替代文本,默认 `"图片"`
/// - `lazy_load`:是否启用懒加载,默认 `false` /// - `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 = "?w=20".to_string())] placeholder_params: String,
#[props(default = "图片".to_string())] alt: String, #[props(default = "图片".to_string())] alt: String,
#[props(default = false)] lazy_load: bool, #[props(default = false)] lazy_load: bool,
#[props(default = true)] lightbox: bool,
) -> Element { ) -> Element {
let mut is_open = use_signal(|| false); let mut is_open = use_signal(|| false);
// 打开灯箱时聚焦关闭按钮,并监听 Escape 键关闭。 // 打开灯箱时聚焦关闭按钮,并监听 Escape 键关闭。
// 灯箱禁用时跳过键盘监听与焦点管理,避免无谓的 DOM 操作。
#[cfg(target_arch = "wasm32")] #[cfg(target_arch = "wasm32")]
{ {
use wasm_bindgen::{closure::Closure, JsCast}; use wasm_bindgen::{closure::Closure, JsCast};
use_effect(move || { use_effect(move || {
if !is_open() { if !lightbox || !is_open() {
return; return;
} }
@ -107,10 +111,11 @@ pub fn ImageViewer(
rsx! { rsx! {
// blur-up 双层:底层占位图 + 上层高清图data-src 由前端 JS 懒加载) // blur-up 双层:底层占位图 + 上层高清图data-src 由前端 JS 懒加载)
// 灯箱禁用时不绑定 onclick使缩略图成为纯展示元素卡片由外层覆盖链接接管跳转
span { span {
class: "blur-img", class: "blur-img",
style: "{ar_style}", style: "{ar_style}",
onclick: move |_| is_open.set(true), onclick: move |_| if lightbox { is_open.set(true) },
img { img {
class: "blur-img-placeholder", class: "blur-img-placeholder",
src: "{placeholder_src}", src: "{placeholder_src}",
@ -124,8 +129,8 @@ pub fn ImageViewer(
} }
} }
// 全屏灯箱 // 全屏灯箱(仅 lightbox 启用时可能打开)
if is_open() { if lightbox && is_open() {
div { div {
class: "image-viewer-overlay", class: "image-viewer-overlay",
role: "dialog", role: "dialog",

View File

@ -15,13 +15,16 @@ use crate::router::Route;
/// - `post`:文章数据模型 /// - `post`:文章数据模型
/// ///
/// 展示内容包括: /// 展示内容包括:
/// - 封面图(如有,使用 400x300 缩略图 /// - 封面图(如有,使用 400x300 缩略图,不启用灯箱
/// - 文章标题 /// - 文章标题
/// - 摘要(最多两行) /// - 摘要(最多两行)
/// - 发布日期与标签 /// - 发布日期与标签
/// ///
/// 关键事件: /// 交互模型(采用覆盖层链接,避免 `<a>` 嵌套 `<a>` 的非法 HTML
/// - 点击标签时阻止事件冒泡,避免触发整卡跳转 /// - 整张卡片可点击跳转到文章详情:通过末尾一个绝对定位、铺满卡片的覆盖层 `Link` 实现。
/// - 标签是独立的 `Link`,通过 `relative z-10` 叠在覆盖层之上,并 `stop_propagation`
/// 点击标签进入标签详情页而不触发卡片跳转。
/// - 封面 `ImageViewer` 关闭灯箱(`lightbox: false`),点击封面走卡片跳转,避免交互歧义。
#[component] #[component]
pub fn PostCard(post: PostListItem) -> Element { pub fn PostCard(post: PostListItem) -> Element {
let post_slug = post.slug.clone(); let post_slug = post.slug.clone();
@ -30,10 +33,7 @@ pub fn PostCard(post: PostListItem) -> Element {
rsx! { rsx! {
article { 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", 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",
Link {
class: "block group",
to: Route::PostDetail { slug: post_slug },
if has_cover { if has_cover {
div { div {
class: "mb-4 -mx-6 -mt-6 overflow-hidden rounded-t-lg", class: "mb-4 -mx-6 -mt-6 overflow-hidden rounded-t-lg",
@ -42,6 +42,7 @@ pub fn PostCard(post: PostListItem) -> Element {
thumb_params: "?thumb=400x300", thumb_params: "?thumb=400x300",
alt: post.title.clone(), alt: post.title.clone(),
lazy_load: true, lazy_load: true,
lightbox: false,
} }
} }
} }
@ -60,8 +61,9 @@ pub fn PostCard(post: PostListItem) -> Element {
span { "·" } span { "·" }
for tag in post.tags.clone().into_iter() { for tag in post.tags.clone().into_iter() {
span { span {
// 标签叠在覆盖链接之上,点击进入标签详情页而非文章详情。
Link { Link {
class: "hover:text-paper-accent transition-colors duration-200", class: "relative z-10 hover:text-paper-accent transition-colors duration-200",
to: Route::TagDetail { tag: tag.clone() }, to: Route::TagDetail { tag: tag.clone() },
onclick: move |evt: dioxus::events::MouseEvent| evt.stop_propagation(), onclick: move |evt: dioxus::events::MouseEvent| evt.stop_propagation(),
"{tag}" "{tag}"
@ -70,6 +72,11 @@ pub fn PostCard(post: PostListItem) -> Element {
} }
} }
} }
// 覆盖层链接铺满卡片承担整卡跳转。z-0 位于标签 (z-10) 之下。
Link {
class: "absolute inset-0 z-0",
aria_label: "post link to {post.title}",
to: Route::PostDetail { slug: post_slug },
} }
} }
} }

View File

@ -9,13 +9,14 @@ use crate::components::skeletons::post_card_skeleton::PostCardSkeleton;
/// 首页骨架屏组件。 /// 首页骨架屏组件。
/// ///
/// 显示 5 个文章卡片骨架与分页按钮占位。 /// 显示与 `POSTS_PER_PAGE` 等量的文章卡片骨架与分页按钮占位,
/// 使骨架屏与加载完成后的实际列表长度一致,避免内容跳变。
#[component] #[component]
pub fn HomeSkeleton() -> Element { pub fn HomeSkeleton() -> Element {
rsx! { rsx! {
div { div {
// 5 个文章卡片骨架 // 10 个文章卡片骨架,对齐首页 POSTS_PER_PAGE。
for _ in 0..5 { for _ in 0..10 {
PostCardSkeleton {} PostCardSkeleton {}
} }
// 分页按钮占位 // 分页按钮占位

View File

@ -28,7 +28,7 @@ use wasm_bindgen::closure::Closure;
/// ///
/// 内部委托给 `write_editor`,以 `None` 表示新建模式。 /// 内部委托给 `write_editor`,以 `None` 表示新建模式。
#[component] #[component]
#[allow(unused_mut, unused_variables)] #[cfg_attr(not(target_arch = "wasm32"), allow(unused_mut, unused_variables))]
pub fn Write() -> Element { pub fn Write() -> Element {
write_editor(None) write_editor(None)
} }
@ -37,7 +37,7 @@ pub fn Write() -> Element {
/// ///
/// `id` 为要编辑的文章 ID内部委托给 `write_editor` 加载现有数据。 /// `id` 为要编辑的文章 ID内部委托给 `write_editor` 加载现有数据。
#[component] #[component]
#[allow(unused_mut, unused_variables)] #[cfg_attr(not(target_arch = "wasm32"), allow(unused_mut, unused_variables))]
pub fn WriteEdit(id: i32) -> Element { pub fn WriteEdit(id: i32) -> Element {
write_editor(Some(id)) write_editor(Some(id))
} }
@ -50,7 +50,7 @@ pub fn WriteEdit(id: i32) -> Element {
/// - 编辑模式下将 Markdown 内容回填到编辑器; /// - 编辑模式下将 Markdown 内容回填到编辑器;
/// - 提交时读取编辑器 Markdown、校验并调用 create_post / update_post /// - 提交时读取编辑器 Markdown、校验并调用 create_post / update_post
/// - 组件卸载时销毁 Tiptap 实例EditorHandle::drop 自动 destroy + 释放 closure /// - 组件卸载时销毁 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<i32>) -> Element { fn write_editor(post_id: Option<i32>) -> Element {
let is_edit = post_id.is_some(); let is_edit = post_id.is_some();
@ -66,7 +66,6 @@ fn write_editor(post_id: Option<i32>) -> Element {
let mut loading = use_signal(|| true); let mut loading = use_signal(|| true);
let mut saving = use_signal(|| false); let mut saving = use_signal(|| false);
let mut error = use_signal(|| None::<String>); let mut error = use_signal(|| None::<String>);
let mut success = use_signal(|| false);
let mut editor_content_set = use_signal(|| false); let mut editor_content_set = use_signal(|| false);
let mut has_backfilled = use_signal(|| false); let mut has_backfilled = use_signal(|| false);
let mut load_error = use_signal(|| None::<String>); let mut load_error = use_signal(|| None::<String>);
@ -84,11 +83,6 @@ fn write_editor(post_id: Option<i32>) -> Element {
// 上传状态:当前进行中计数(保存拦截)+ 顶部失败提示堆叠(用户手动关闭) // 上传状态:当前进行中计数(保存拦截)+ 顶部失败提示堆叠(用户手动关闭)
let mut uploads_in_flight = use_signal(UploadsInFlight::default); let mut uploads_in_flight = use_signal(UploadsInFlight::default);
let mut upload_errors: Signal<Vec<UploadErrorEntry>> = use_signal(Vec::new); let mut upload_errors: Signal<Vec<UploadErrorEntry>> = use_signal(Vec::new);
// 已展示过错误的上传 id去重 + 重试后再失败时原地更新)。
// 用 signal 而非局部变量closure 的 FnMut 闭包要求可重复调用,
// signal 是 Copy 可被多次 move。
let mut seen_error_ids: Signal<std::collections::HashSet<String>> =
use_signal(std::collections::HashSet::new);
// 编辑模式:文章数据加载完成后,将字段回填到表单信号。 // 编辑模式:文章数据加载完成后,将字段回填到表单信号。
use_effect(move || { use_effect(move || {
@ -161,11 +155,10 @@ fn write_editor(post_id: Option<i32>) -> Element {
}); });
let on_image_upload = crate::tiptap_bridge::make_upload_closure(); let on_image_upload = crate::tiptap_bridge::make_upload_closure();
let on_upload_event = Closure::new({ let on_upload_event = Closure::new({
let mut uploads_in_flight = uploads_in_flight; let uploads_in_flight = uploads_in_flight;
let mut upload_errors = upload_errors; let upload_errors = upload_errors;
let mut seen_error_ids = seen_error_ids;
move |ev: crate::tiptap_bridge::UploadEventJs| { 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<i32>) -> Element {
if title().trim().is_empty() { if title().trim().is_empty() {
error.set(Some("标题不能为空".to_string())); error.set(Some("标题不能为空".to_string()));
#[allow(clippy::needless_return)]
return;
} }
// 仅在 WASM 前端读取编辑器内容并发起保存请求。 // 仅在 WASM 前端读取编辑器内容并发起保存请求。
#[cfg(target_arch = "wasm32")] #[cfg(target_arch = "wasm32")]
{ if !title().trim().is_empty() {
// 通过 EditorHandle 实例读取 Markdown句柄未就绪时退回 content signal。 // 通过 EditorHandle 实例读取 Markdown句柄未就绪时退回 content signal。
let md = if let Some(handle) = &*editor.read() { let md = if let Some(handle) = &*editor.read() {
handle.instance().get_markdown() handle.instance().get_markdown()
@ -268,8 +259,9 @@ fn write_editor(post_id: Option<i32>) -> Element {
} }
// 将逗号分隔的标签字符串转换为列表。 // 将逗号分隔的标签字符串转换为列表。
// 同时支持半角/全角逗号与分号,避免中文输入法下的全角标点被误并入单个标签。
let tags_list: Vec<String> = tags() let tags_list: Vec<String> = tags()
.split(',') .split(|c| matches!(c, ',' | ',' | ';' | ';'))
.map(|t| t.trim().to_string()) .map(|t| t.trim().to_string())
.filter(|t| !t.is_empty()) .filter(|t| !t.is_empty())
.collect(); .collect();
@ -312,7 +304,6 @@ fn write_editor(post_id: Option<i32>) -> Element {
{ {
Ok(CreatePostResponse { success: true, .. }) => { Ok(CreatePostResponse { success: true, .. }) => {
saving.set(false); saving.set(false);
success.set(true);
let _ = dioxus::router::navigator().push(Route::Posts {}); let _ = dioxus::router::navigator().push(Route::Posts {});
} }
Ok(CreatePostResponse { Ok(CreatePostResponse {
@ -345,8 +336,7 @@ fn write_editor(post_id: Option<i32>) -> Element {
{ {
Ok(CreatePostResponse { success: true, .. }) => { Ok(CreatePostResponse { success: true, .. }) => {
saving.set(false); saving.set(false);
success.set(true); let _ = dioxus::router::navigator().push(Route::Posts {});
let _ = dioxus::router::navigator().push(Route::Admin {});
} }
Ok(CreatePostResponse { Ok(CreatePostResponse {
success: false, success: false,
@ -364,6 +354,12 @@ fn write_editor(post_id: Option<i32>) -> Element {
}); });
} }
} }
// 非 WASMSSR hydration 前等场景):明确提示无法保存,而非静默无反应。
#[cfg(not(target_arch = "wasm32"))]
if !title().trim().is_empty() {
error.set(Some("此页面需要 JavaScript 才能保存".to_string()));
}
}; };
let save_button_text = if saving() { let save_button_text = if saving() {
@ -374,6 +370,12 @@ fn write_editor(post_id: Option<i32>) -> 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! { rsx! {
div { class: "relative flex flex-col flex-1 min-h-0 overflow-hidden", div { class: "relative flex flex-col flex-1 min-h-0 overflow-hidden",
if loading() { if loading() {
@ -406,33 +408,33 @@ fn write_editor(post_id: Option<i32>) -> Element {
// 元数据行 - 紧凑精致 // 元数据行 - 紧凑精致
div { class: "flex flex-wrap items-end gap-x-8 gap-y-4 text-sm", div { class: "flex flex-wrap items-end gap-x-8 gap-y-4 text-sm",
div { class: "flex-1 min-w-[140px]", 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" "Slug"
} }
input { 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: "自动生成", placeholder: "自动生成",
value: "{slug}", value: "{slug}",
oninput: move |evt| slug.set(evt.value()), oninput: move |evt| slug.set(evt.value()),
} }
} }
div { class: "flex-1 min-w-[140px]", 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 { 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: "逗号分隔", placeholder: "逗号分隔",
value: "{tags}", value: "{tags}",
oninput: move |evt| tags.set(evt.value()), oninput: move |evt| tags.set(evt.value()),
} }
} }
div { class: "flex-1 min-w-[140px]", 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 { 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可选", placeholder: "URL可选",
value: "{cover_image}", value: "{cover_image}",
oninput: move |evt| cover_image.set(evt.value()), oninput: move |evt| cover_image.set(evt.value()),
@ -462,12 +464,6 @@ fn write_editor(post_id: Option<i32>) -> 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() { 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", 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",

View File

@ -63,14 +63,17 @@ fn HomePosts(current_page: i32) -> Element {
for post in posts.iter() { for post in posts.iter() {
PostCard { post: post.clone() } PostCard { post: post.clone() }
} }
// 如果当前页没有任何文章,显示空状态提示。 // total == 0 表示站点确实无文章:显示空状态,且不渲染分页。
if posts.is_empty() { // 注意total > 0 但 posts 为空(如越界页码 /page/9999也不显示空状态
// 避免误导用户以为站点无内容;此时仅靠下方的分页导航引导回有效页。
if total == 0 {
div { class: "text-center text-paper-secondary py-20", div { class: "text-center text-paper-secondary py-20",
"暂无文章" "暂无文章"
} }
} }
// 在列表底部渲染分页导航 // 仅在有文章时渲染分页导航,避免越界页码下出现孤立的空分页
// frontend variant 不渲染页码计数unit 不显示(仅满足必填 prop // frontend variant 不渲染页码计数unit 不显示(仅满足必填 prop
if total > 0 {
Pagination { Pagination {
variant: "frontend", variant: "frontend",
current_page, current_page,
@ -86,10 +89,12 @@ fn HomePosts(current_page: i32) -> Element {
} }
} }
} }
Some(Err(e)) => { }
// 不透传内部错误细节,统一展示通用文案(与标签页等其它页面一致)。
Some(Err(_)) => {
rsx! { rsx! {
div { class: "text-center text-red-500 dark:text-red-400 py-20", div { class: "text-center text-red-500 dark:text-red-400 py-20",
"加载失败: {e}" "加载失败"
} }
} }
} }

View File

@ -78,9 +78,13 @@ pub fn Search() -> Element {
PostCard { post: post.clone() } 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", div { class: "text-center text-red-500 dark:text-red-400 py-20",
"搜索失败: {e}" "搜索失败"
} }
} }
} }

View File

@ -144,12 +144,9 @@ pub mod wasm {
/// 持有编辑器实例 + 其全部 closure统一生命周期。 /// 持有编辑器实例 + 其全部 closure统一生命周期。
/// ///
/// drop 时先 destroy JS 实例(释放 ProseMirror 资源),再 drop closure /// drop 时先 destroy JS 实例(释放 ProseMirror 资源),随后 closure 字段
/// (释放 wasm-bindgen 回调表)。比 `Closure::forget`(永久泄漏)干净—— /// 按声明逆序自动 drop释放 wasm-bindgen 回调表)。比 `Closure::forget`
/// closure 严格随编辑器实例同生共死。 /// 永久泄漏干净——closure 严格随编辑器实例同生共死。
///
/// 字段顺序:`instance` 在前仅表示逻辑主从Drop::drop 里显式调 destroy
/// 之后 closure 字段按声明逆序自动 drop无顺序敏感问题。
pub struct EditorHandle { pub struct EditorHandle {
instance: EditorInstance, instance: EditorInstance,
_on_update: Closure<dyn FnMut(String)>, _on_update: Closure<dyn FnMut(String)>,
@ -184,7 +181,6 @@ pub mod wasm {
impl Drop for EditorHandle { impl Drop for EditorHandle {
fn drop(&mut self) { fn drop(&mut self) {
self.instance.destroy(); self.instance.destroy();
// closure 字段随后自动 drop逆序on_upload_event → on_ready → on_image_upload → on_update
} }
} }
@ -194,35 +190,33 @@ pub mod wasm {
/// ///
/// 逻辑与旧轮询 body 一致: /// 逻辑与旧轮询 body 一致:
/// - error新 id 追加提示,已存在 id 原地更新消息(重试后再失败) /// - error新 id 追加提示,已存在 id 原地更新消息(重试后再失败)
/// - success/removed移除对应提示 + 清 seen /// - success/removed移除对应提示
/// - counts直接从事件读JS 已遍历文档算好) /// - counts直接从事件读JS 已遍历文档算好)
///
/// 去重以 `upload_errors` Vec 自身为唯一数据源(用 iter().any 判重),
/// 不再额外维护 seen_error_ids避免两份状态需手动同步。
pub fn consume_upload_event( pub fn consume_upload_event(
ev: &UploadEventJs, ev: &UploadEventJs,
uploads_in_flight: &mut dioxus::prelude::Signal<UploadsInFlight>, mut uploads_in_flight: dioxus::prelude::Signal<UploadsInFlight>,
upload_errors: &mut dioxus::prelude::Signal<Vec<UploadErrorEntry>>, mut upload_errors: dioxus::prelude::Signal<Vec<UploadErrorEntry>>,
seen_error_ids: &mut dioxus::prelude::Signal<std::collections::HashSet<String>>,
) { ) {
let id = ev.upload_id(); let id = ev.upload_id();
match ev.kind().as_str() { match ev.kind().as_str() {
"error" => { "error" => {
let msg = ev.error_msg().unwrap_or_else(|| "上传失败".to_string()); let msg = ev.error_msg().unwrap_or_else(|| "上传失败".to_string());
if seen_error_ids.write().insert(id.clone()) { // 已存在同 id重试后再失败原地更新消息否则追加。
// 新失败:追加提示 let mut errors = upload_errors.write();
upload_errors.write().push(UploadErrorEntry { if let Some(entry) = errors.iter_mut().find(|e| e.id == id) {
entry.message = msg;
} else {
errors.push(UploadErrorEntry {
id: id.clone(), id: id.clone(),
file_name: ev.file_name(), file_name: ev.file_name(),
message: msg, 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" => { "success" | "removed" => {
seen_error_ids.write().remove(&id);
upload_errors.write().retain(|e| e.id != id); upload_errors.write().retain(|e| e.id != id);
} }
_ => {} _ => {}
@ -245,12 +239,16 @@ pub mod wasm {
/// - 成功 → resolve(url);失败 → reject(Error(服务端中文 error)) /// - 成功 → resolve(url);失败 → reject(Error(服务端中文 error))
/// ///
/// 返回的 closure 签名 `(File) -> Promise` 对应 JS `onImageUpload`。 /// 返回的 closure 签名 `(File) -> Promise` 对应 JS `onImageUpload`。
/// 构造阶段的错误FormData/Request 等)以 rejected Promise 返回,
/// 而非 panic —— 单张坏文件不应导致整个编辑器崩溃。
pub fn make_upload_closure() -> Closure<dyn Fn(web_sys::File) -> js_sys::Promise> { pub fn make_upload_closure() -> Closure<dyn Fn(web_sys::File) -> js_sys::Promise> {
Closure::new(move |file: web_sys::File| -> js_sys::Promise { Closure::new(move |file: web_sys::File| -> js_sys::Promise {
wasm_bindgen_futures::future_to_promise(async move {
// 构造 FormData字段名 'image' 与服务端 upload.rs 对齐 // 构造 FormData字段名 'image' 与服务端 upload.rs 对齐
let form = web_sys::FormData::new().expect("FormData::new failed"); let form = web_sys::FormData::new()
.map_err(|_| js_sys::Error::new("无法构造上传表单"))?;
form.append_with_blob("image", &file) form.append_with_blob("image", &file)
.expect("FormData.append failed"); .map_err(|_| js_sys::Error::new("无法附加文件"))?;
// 构造 POST 请求credentials same-origin 携带 session cookie // 构造 POST 请求credentials same-origin 携带 session cookie
let mut init = web_sys::RequestInit::new(); let mut init = web_sys::RequestInit::new();
@ -259,13 +257,12 @@ pub mod wasm {
init.credentials(web_sys::RequestCredentials::SameOrigin); init.credentials(web_sys::RequestCredentials::SameOrigin);
let request = web_sys::Request::new_with_str_and_init("/api/upload", &init) let request = web_sys::Request::new_with_str_and_init("/api/upload", &init)
.expect("Request::new failed"); .map_err(|_| js_sys::Error::new("无法构造上传请求"))?;
let window = web_sys::window().expect("no window"); let window = web_sys::window().expect("no window");
let promise = window.fetch_with_request(&request); let promise = window.fetch_with_request(&request);
// 把 fetch Promise → Future → 解析响应体 → 再包回 Promise // 把 fetch Promise → Future → 解析响应体 → 再包回 Promise
wasm_bindgen_futures::future_to_promise(async move {
let resp_val = wasm_bindgen_futures::JsFuture::from(promise).await?; let resp_val = wasm_bindgen_futures::JsFuture::from(promise).await?;
let resp: web_sys::Response = resp_val.dyn_into()?; let resp: web_sys::Response = resp_val.dyn_into()?;