1use dioxus::prelude::*;
12
13use super::types::RebuildAssetsResponse;
14
15#[cfg(feature = "server")]
17const IMAGE_EXTS: &[&str] = &["jpg", "jpeg", "png", "gif", "webp"];
18
19#[cfg(feature = "server")]
20struct ScannedFile {
22 rel_path: String,
24 filename: String,
25 mime: &'static str,
26 size_bytes: i64,
27 width: i32,
28 height: i32,
29}
30
31#[cfg(feature = "server")]
32fn walk_images(
36 dir: &std::path::Path,
37 base: &std::path::Path,
38 out: &mut Vec<ScannedFile>,
39 unreadable: &mut Vec<String>,
40) {
41 let Ok(entries) = std::fs::read_dir(dir) else {
42 return;
43 };
44 for entry in entries.flatten() {
45 let name = entry.file_name();
46 let Some(name_str) = name.to_str() else {
47 continue;
48 };
49 if name_str.starts_with('.') {
50 continue;
51 }
52 let path = entry.path();
53 if path.is_dir() {
54 walk_images(&path, base, out, unreadable);
55 continue;
56 }
57 let ext = name_str.rsplit('.').next().unwrap_or("");
58 if !IMAGE_EXTS.iter().any(|e| ext.eq_ignore_ascii_case(e)) {
59 continue;
60 }
61 let Ok(rel) = path.strip_prefix(base) else {
62 continue;
63 };
64 let rel_path = rel.to_string_lossy().replace('\\', "/");
65 let Some((w, h)) = crate::api::image::get_image_dimensions(&rel_path) else {
68 tracing::warn!("Rebuild: skip unreadable image {}", rel_path);
69 unreadable.push(rel_path);
70 continue;
71 };
72 let size_bytes = entry.metadata().map(|m| m.len() as i64).unwrap_or(0);
73 let mime = match ext.to_ascii_lowercase().as_str() {
74 "jpg" | "jpeg" => "image/jpeg",
75 "png" => "image/png",
76 "gif" => "image/gif",
77 _ => "image/webp",
78 };
79 out.push(ScannedFile {
80 rel_path,
81 filename: name_str.to_string(),
82 mime,
83 size_bytes,
84 width: w as i32,
85 height: h as i32,
86 });
87 }
88}
89
90#[server(RebuildAssetsIndex, "/api")]
92pub async fn rebuild_assets_index() -> Result<RebuildAssetsResponse, ServerFnError> {
93 crate::api::auth::get_current_admin_user().await?;
94 rebuild_assets_index_impl().await
95}
96
97#[cfg(feature = "server")]
99pub(crate) async fn rebuild_assets_index_impl() -> Result<RebuildAssetsResponse, ServerFnError> {
100 use crate::api::error::AppError;
101 use crate::db::pool::get_conn;
102
103 let (scanned, unreadable) = tokio::task::spawn_blocking(|| {
105 let base = std::path::Path::new("uploads");
106 let mut files = Vec::new();
107 let mut unreadable = Vec::new();
108 walk_images(base, base, &mut files, &mut unreadable);
109 (files, unreadable)
110 })
111 .await
112 .map_err(|_| AppError::Internal("素材扫描任务失败"))?;
113
114 let mut client = get_conn().await.map_err(AppError::db_conn)?;
115 let tx = client.transaction().await.map_err(AppError::tx)?;
116
117 let mut inserted: i64 = 0;
122 let mut updated: i64 = 0;
123 for f in &scanned {
124 let asset_id = uuid::Uuid::new_v4();
126 let row = tx
127 .query_opt(
128 "INSERT INTO assets (id, path, filename, mime, size_bytes, width, height) \
129 VALUES ($1, $2, $3, $4, $5, $6, $7) \
130 ON CONFLICT (path) DO UPDATE SET \
131 mime = EXCLUDED.mime, \
132 size_bytes = EXCLUDED.size_bytes, \
133 width = EXCLUDED.width, \
134 height = EXCLUDED.height, \
135 updated_at = NOW() \
136 WHERE assets.size_bytes IS DISTINCT FROM EXCLUDED.size_bytes \
137 OR assets.width IS DISTINCT FROM EXCLUDED.width \
138 OR assets.height IS DISTINCT FROM EXCLUDED.height \
139 OR assets.mime IS DISTINCT FROM EXCLUDED.mime \
140 RETURNING (xmax = 0) AS was_inserted",
141 &[
142 &asset_id,
143 &f.rel_path,
144 &f.filename,
145 &f.mime,
146 &f.size_bytes,
147 &f.width,
148 &f.height,
149 ],
150 )
151 .await
152 .map_err(AppError::tx)?;
153 match row {
154 Some(r) if r.get::<_, bool>("was_inserted") => inserted += 1,
155 Some(_) => updated += 1,
156 None => {} }
158 }
159
160 let mut keep_paths: Vec<String> = scanned.iter().map(|f| f.rel_path.clone()).collect();
164 keep_paths.extend(unreadable.iter().cloned());
165 let removed = tx
166 .execute(
167 "DELETE FROM assets WHERE NOT (path = ANY($1))",
168 &[&keep_paths],
169 )
170 .await
171 .map_err(AppError::tx)?;
172
173 let post_rows = tx
175 .query("SELECT id, content_html, cover_image FROM posts", &[])
176 .await
177 .map_err(AppError::query)?;
178 tx.execute("DELETE FROM asset_refs", &[])
179 .await
180 .map_err(AppError::tx)?;
181 let mut ref_count: i64 = 0;
182 for pr in &post_rows {
183 let post_id: i32 = pr.get("id");
184 let content_html: Option<String> = pr.get("content_html");
185 let cover_image: Option<String> = pr.get("cover_image");
186 let found = crate::api::posts::helpers::extract_asset_paths(
187 content_html.as_deref().unwrap_or(""),
188 cover_image.as_deref(),
189 );
190 if found.is_empty() {
191 continue;
192 }
193 let n = tx
194 .execute(
195 "INSERT INTO asset_refs (asset_id, post_id) \
196 SELECT id, $1 FROM assets WHERE path = ANY($2) \
197 ON CONFLICT DO NOTHING",
198 &[&post_id, &found],
199 )
200 .await
201 .map_err(AppError::tx)?;
202 ref_count += n as i64;
203 }
204
205 let note_rows = tx
207 .query(
208 "SELECT note_id,version,content_html FROM note_revisions",
209 &[],
210 )
211 .await
212 .map_err(AppError::query)?;
213 tx.execute("DELETE FROM note_asset_refs", &[])
214 .await
215 .map_err(AppError::tx)?;
216 for row in note_rows {
217 let paths = crate::api::posts::helpers::extract_asset_paths(row.get("content_html"), None);
218 if !paths.is_empty() {
219 ref_count+=tx.execute("INSERT INTO note_asset_refs(note_id,version,asset_id) SELECT $1,$2,id FROM assets WHERE path=ANY($3) ON CONFLICT DO NOTHING",&[&row.get::<_,i32>("note_id"),&row.get::<_,i32>("version"),&paths]).await.map_err(AppError::tx)? as i64;
220 }
221 }
222 tx.commit().await.map_err(AppError::tx)?;
223
224 let scanned_count = scanned.len() as i64;
225 let skipped_count = unreadable.len() as i64;
226 let message = if skipped_count > 0 {
227 format!(
228 "重建完成:扫描 {} 个文件,新增 {},更新 {},移除 {},跳过 {} 个无法读取的文件(已保留)",
229 scanned_count, inserted, updated, removed, skipped_count
230 )
231 } else {
232 format!(
233 "重建完成:扫描 {} 个文件,新增 {},更新 {},移除 {}",
234 scanned_count, inserted, updated, removed
235 )
236 };
237 Ok(RebuildAssetsResponse {
238 success: true,
239 message,
240 scanned: scanned_count,
241 inserted,
242 updated,
243 removed: removed as i64,
244 ref_count,
245 skipped: skipped_count,
246 })
247}