yggdrasil/pages/admin/write/
save.rs1use 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#[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 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 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 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, "".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}