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()?;