Skip to main content

yggdrasil/pages/admin/write/
save.rs

1//! Saving uses a captured form snapshot and one result path for create/update.
2use super::state::{PostDraft, SaveState, WriteEditorState};
3use crate::components::ui::Toaster;
4#[cfg(target_arch = "wasm32")]
5use crate::utils::error::failure_message;
6use leptos::prelude::*;
7
8#[derive(Debug, PartialEq)]
9#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
10struct PostSubmission {
11    title: String,
12    slug: Option<String>,
13    summary: Option<String>,
14    markdown: String,
15    status: String,
16    tags: Vec<String>,
17    cover_image: Option<String>,
18}
19
20fn optional_text(text: &str) -> Option<String> {
21    let text = text.trim();
22    (!text.is_empty()).then(|| text.to_owned())
23}
24
25#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
26fn prepare(draft: &PostDraft, markdown: String) -> Result<PostSubmission, &'static str> {
27    let Some(title) = optional_text(&draft.title) else {
28        return Err("标题不能为空");
29    };
30    if markdown.contains("](blob:") || markdown.contains("data-upload-state") {
31        return Err("检测到未完成上传的图片,请处理后保存");
32    }
33    if markdown.trim().is_empty() {
34        return Err("内容不能为空");
35    }
36    Ok(PostSubmission {
37        title,
38        slug: optional_text(&draft.slug),
39        summary: optional_text(&draft.summary),
40        markdown,
41        status: draft.status.clone(),
42        tags: draft.parsed_tags(),
43        cover_image: optional_text(&draft.cover_image),
44    })
45}
46
47/// 保存为给定状态(`draft` / `published`)。成功后留在本页:更新已保存基线与时间,
48/// 新文章换到编辑路由;失败时保留表单,状态指示可按原状态重试。
49#[cfg_attr(not(target_arch = "wasm32"), allow(unused_variables))]
50pub(super) fn submit(state: WriteEditorState, toast: Toaster, status: &'static str) {
51    let save = state.save;
52    if save.with_untracked(SaveState::is_saving)
53        || !state.phase.with_untracked(|phase| phase.is_ready())
54        || !state.library.ready_untracked()
55    {
56        return;
57    }
58    let fail = move |message: String| {
59        toast.error(message.clone());
60        save.set(SaveState::Failed { message, status });
61    };
62    let uploads = state.uploads_in_flight.get_untracked();
63    let upload_error = if uploads.uploading > 0 {
64        Some(format!(
65            "有 {} 张图片正在上传,请等待完成后再保存",
66            uploads.uploading
67        ))
68    } else if uploads.error > 0 {
69        Some(format!(
70            "有 {} 张图片上传失败,请移除或重试后再保存",
71            uploads.error
72        ))
73    } else if state.cover_uploading.get_untracked() {
74        Some("封面图正在上传,请等待完成后再保存".into())
75    } else if state.body_picker_uploading.get_untracked() {
76        Some("图片正在上传,请等待完成后再保存".into())
77    } else {
78        None
79    };
80    if let Some(message) = upload_error {
81        fail(message);
82        return;
83    }
84    // 先快照再发请求;请求期间的新编辑只留在本地。
85    let mut draft = state.form.get_untracked();
86    draft.status = status.to_owned();
87    #[cfg(target_arch = "wasm32")]
88    {
89        use crate::api::posts::{create_post, update_post, CreatePostResponse};
90        let markdown = state
91            .editor
92            .with_value(|handle| handle.as_ref().map(|h| h.instance().get_markdown()))
93            .unwrap_or_else(|| draft.content.clone());
94        let submission = match prepare(&draft, markdown.clone()) {
95            Ok(submission) => submission,
96            Err(message) => {
97                fail(message.into());
98                return;
99            }
100        };
101        save.set(SaveState::Saving);
102        let post_id = state.post_id;
103        let was_published = state.saved.with_untracked(PostDraft::is_published);
104        leptos::task::spawn_local(async move {
105            let PostSubmission {
106                title,
107                slug,
108                summary,
109                markdown: body,
110                status: _,
111                tags,
112                cover_image,
113            } = submission;
114            let result = if let Some(id) = post_id {
115                update_post(
116                    id,
117                    title,
118                    slug,
119                    summary,
120                    body,
121                    status.to_owned(),
122                    tags,
123                    cover_image,
124                )
125                .await
126            } else {
127                create_post(
128                    title,
129                    slug,
130                    summary,
131                    body,
132                    status.to_owned(),
133                    tags,
134                    cover_image,
135                )
136                .await
137            };
138            // 请求期间用户可能已离开页面:所有者销毁后信号写入一律丢弃。
139            if save.try_get_untracked().is_none() {
140                return;
141            }
142            match result {
143                Ok(CreatePostResponse {
144                    success: true,
145                    post_id: new_id,
146                    slug,
147                    ..
148                }) => {
149                    let submitted_slug = draft.slug.clone();
150                    let submitted_content = draft.content.clone();
151                    let mut snapshot = draft;
152                    snapshot.content = markdown;
153                    if let Some(slug) = slug {
154                        snapshot.slug = slug;
155                    }
156                    // 只对快照之后没改过的字段采纳服务端值。
157                    state.form.update(|f| {
158                        if f.slug == submitted_slug {
159                            f.slug = snapshot.slug.clone();
160                        }
161                        if f.content == submitted_content {
162                            f.content = snapshot.content.clone();
163                        }
164                        f.status = status.to_owned();
165                    });
166                    state.saved.set(snapshot);
167                    if status == "published" && state.published_at.get_untracked().is_none() {
168                        state.published_at.set(Some(chrono::Utc::now()));
169                    }
170                    state.saved_at.set(Some(crate::utils::time::hm(
171                        &crate::utils::time::local_now(),
172                    )));
173                    save.set(SaveState::Idle);
174                    match (was_published, status == "published") {
175                        (false, true) => toast.success("已发布"),
176                        (true, false) => toast.success("已转为草稿"),
177                        _ => {}
178                    }
179                    if let (None, Some(id)) = (post_id, new_id) {
180                        crate::navigation::replace(
181                            &crate::routes::Route::WriteEdit { id }.to_string(),
182                        );
183                    }
184                }
185                Ok(CreatePostResponse { message, .. }) => fail(message),
186                Err(error) => fail(failure_message("保存失败", &error)),
187            }
188        });
189    }
190    #[cfg(not(target_arch = "wasm32"))]
191    fail("此页面需要 JavaScript 才能保存".into());
192}
193
194#[cfg(test)]
195mod tests {
196    use super::*;
197
198    #[test]
199    fn saving_normalizes_optional_metadata_and_chinese_tag_separators() {
200        let draft = PostDraft {
201            title: "  文章标题  ".into(),
202            slug: "  my-post ".into(),
203            summary: " \n ".into(),
204            tags: " Rust,编辑器;测试, ; ".into(),
205            cover_image: " /uploads/cover.webp ".into(),
206            ..PostDraft::default()
207        };
208        let submission = prepare(&draft, "正文".into()).unwrap();
209        assert_eq!(submission.title, "文章标题");
210        assert_eq!(submission.slug.as_deref(), Some("my-post"));
211        assert_eq!(submission.summary, None);
212        assert_eq!(submission.tags, ["Rust", "编辑器", "测试"]);
213        assert_eq!(
214            submission.cover_image.as_deref(),
215            Some("/uploads/cover.webp")
216        );
217    }
218
219    #[test]
220    fn saving_rejects_missing_content_and_image_placeholders_but_accepts_blob_discussion() {
221        let draft = PostDraft {
222            title: "标题".into(),
223            ..PostDraft::default()
224        };
225        assert_eq!(prepare(&draft, " \n ".into()).unwrap_err(), "内容不能为空");
226        assert!(prepare(&draft, "![图](blob:pending)".into()).is_err());
227        assert!(prepare(&draft, "图片 data-upload-state=error".into()).is_err());
228        assert!(prepare(&draft, "代码中使用 blob: URL".into()).is_ok());
229    }
230
231    #[test]
232    fn saving_rejects_blank_titles() {
233        assert_eq!(
234            prepare(&PostDraft::default(), "正文".into()).unwrap_err(),
235            "标题不能为空"
236        );
237    }
238}