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:
parent
1e270f0055
commit
ef6a3f0da2
2
Makefile
2
Makefile
@ -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 & \
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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 },
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 {}
|
||||||
}
|
}
|
||||||
// 分页按钮占位
|
// 分页按钮占位
|
||||||
|
|||||||
@ -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 {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 非 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() {
|
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",
|
||||||
|
|||||||
@ -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}"
|
"加载失败"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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}"
|
"搜索失败"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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()?;
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user