init
This commit is contained in:
41
src/config.rs
Normal file
41
src/config.rs
Normal file
@@ -0,0 +1,41 @@
|
||||
use serde::Deserialize;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
pub struct Config {
|
||||
pub database: DatabaseConfig,
|
||||
pub server: ServerConfig,
|
||||
pub storage: StorageConfig,
|
||||
}
|
||||
|
||||
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
pub struct DatabaseConfig {
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
pub struct ServerConfig {
|
||||
pub host: String,
|
||||
pub port: u16,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
pub struct StorageConfig {
|
||||
pub images_dir: PathBuf,
|
||||
pub thumbnails_dir: PathBuf,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn load() -> Result<Self, config::ConfigError> {
|
||||
let config = config::Config::builder()
|
||||
.add_source(config::File::with_name("config"))
|
||||
.build()?;
|
||||
|
||||
config.try_deserialize()
|
||||
}
|
||||
|
||||
pub fn server_address(&self) -> String {
|
||||
format!("{}:{}", self.server.host, self.server.port)
|
||||
}
|
||||
}
|
||||
407
src/database.rs
Normal file
407
src/database.rs
Normal file
@@ -0,0 +1,407 @@
|
||||
use sqlx::{SqlitePool, sqlite::SqlitePoolOptions, Row};
|
||||
use crate::config::Config;
|
||||
use crate::models::*;
|
||||
use uuid::Uuid;
|
||||
|
||||
pub struct Database {
|
||||
pool: SqlitePool,
|
||||
}
|
||||
|
||||
impl Database {
|
||||
pub async fn new(config: &Config) -> Result<Self, sqlx::Error> {
|
||||
// 从URL中提取文件路径
|
||||
let url = &config.database.url;
|
||||
let file_path = if url.starts_with("sqlite:") {
|
||||
&url[7..] // 移除 "sqlite:" 前缀
|
||||
} else {
|
||||
url
|
||||
};
|
||||
|
||||
// 确保数据目录存在
|
||||
if let Some(parent) = std::path::Path::new(file_path).parent() {
|
||||
if !parent.exists() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
}
|
||||
|
||||
// 如果数据库文件不存在,创建一个空文件
|
||||
if !std::path::Path::new(file_path).exists() {
|
||||
std::fs::File::create(file_path)?;
|
||||
println!("创建新的数据库文件: {}", file_path);
|
||||
}
|
||||
|
||||
let pool = SqlitePoolOptions::new()
|
||||
.max_connections(5)
|
||||
.connect(url)
|
||||
.await?;
|
||||
|
||||
// 创建表
|
||||
sqlx::migrate!("./migrations").run(&pool).await?;
|
||||
|
||||
Ok(Database { pool })
|
||||
}
|
||||
|
||||
|
||||
// 相册操作
|
||||
pub async fn get_all_albums(&self) -> Result<Vec<AlbumInfo>, sqlx::Error> {
|
||||
let albums = sqlx::query(
|
||||
r#"
|
||||
SELECT
|
||||
a.id,
|
||||
a.title,
|
||||
a.document,
|
||||
a.description,
|
||||
a.cover_image_id,
|
||||
COUNT(img.id) as image_count,
|
||||
a.created_at
|
||||
FROM albums a
|
||||
LEFT JOIN images img ON img.album_id = a.id
|
||||
LEFT JOIN images i ON i.id = a.cover_image_id
|
||||
ORDER BY a.document DESC
|
||||
"#
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
|
||||
let mut result = Vec::new();
|
||||
for row in albums {
|
||||
let id_str = row.get::<String, _>("id");
|
||||
let id = match Uuid::parse_str(&id_str) {
|
||||
Ok(uuid) => uuid,
|
||||
Err(e) => {
|
||||
eprintln!("无效的UUID格式: {} - 错误: {}", id_str, e);
|
||||
continue; // 跳过无效的记录
|
||||
}
|
||||
};
|
||||
|
||||
result.push(AlbumInfo {
|
||||
id,
|
||||
title: row.get("title"),
|
||||
document: row.get("document"),
|
||||
description: row.get("description"),
|
||||
cover_image_id: row.get("cover_image_id"),
|
||||
image_count: row.get("image_count"),
|
||||
created_at: row.get::<String, _>("created_at"),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub async fn search_albums(&self, query: &str) -> Result<Vec<AlbumInfo>, sqlx::Error> {
|
||||
let search_pattern = format!("%{}%", query);
|
||||
|
||||
let albums = sqlx::query(
|
||||
r#"
|
||||
SELECT
|
||||
a.id,
|
||||
a.title,
|
||||
a.document,
|
||||
a.description,
|
||||
a.cover_image_id,
|
||||
COUNT(img.id) as image_count,
|
||||
a.created_at
|
||||
FROM albums a
|
||||
LEFT JOIN images img ON img.album_id = a.id
|
||||
LEFT JOIN images i ON i.id = a.cover_image_id
|
||||
WHERE a.title LIKE ?
|
||||
GROUP BY a.id, a.created_at
|
||||
ORDER BY a.created_at DESC
|
||||
"#
|
||||
)
|
||||
.bind(&search_pattern)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
|
||||
let mut result = Vec::new();
|
||||
for row in albums {
|
||||
let id_str = row.get::<String, _>("id");
|
||||
let id = match Uuid::parse_str(&id_str) {
|
||||
Ok(uuid) => uuid,
|
||||
Err(e) => {
|
||||
eprintln!("无效的UUID格式: {} - 错误: {}", id_str, e);
|
||||
continue; // 跳过无效的记录
|
||||
}
|
||||
};
|
||||
|
||||
result.push(AlbumInfo {
|
||||
id,
|
||||
title: row.get("title"),
|
||||
document: row.get("document"),
|
||||
description: row.get("description"),
|
||||
cover_image_id: row.get("cover_image_id"),
|
||||
image_count: row.get("image_count"),
|
||||
created_at: row.get::<String, _>("created_at"),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub async fn get_album_with_images(&self, album_id: Uuid) -> Result<Option<AlbumWithImages>, sqlx::Error> {
|
||||
// 获取相册基本信息
|
||||
let album_row = sqlx::query(
|
||||
"SELECT * FROM albums WHERE id = ?"
|
||||
)
|
||||
.bind(album_id.to_string())
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
|
||||
if let Some(row) = album_row {
|
||||
let id_str = row.get::<String, _>("id");
|
||||
let id = match Uuid::parse_str(&id_str) {
|
||||
Ok(uuid) => uuid,
|
||||
Err(e) => {
|
||||
eprintln!("无效的相册UUID格式: {} - 错误: {}", id_str, e);
|
||||
return Ok(None);
|
||||
}
|
||||
};
|
||||
|
||||
let cover_image_id = match row.get::<Option<String>, _>("cover_image_id") {
|
||||
Some(s) => match Uuid::parse_str(&s) {
|
||||
Ok(uuid) => Some(uuid),
|
||||
Err(e) => {
|
||||
eprintln!("无效的封面图片UUID格式: {} - 错误: {}", s, e);
|
||||
None
|
||||
}
|
||||
},
|
||||
None => None
|
||||
};
|
||||
|
||||
let album = Album {
|
||||
id,
|
||||
title: row.get("title"),
|
||||
description: row.get("description"),
|
||||
cover_image_id,
|
||||
created_at: row.get::<String, _>("created_at"),
|
||||
updated_at: row.get::<String, _>("updated_at"),
|
||||
};
|
||||
|
||||
// 获取相册中的图片
|
||||
let image_rows = sqlx::query(
|
||||
"SELECT * FROM images WHERE album_id = ? ORDER BY created_at ASC"
|
||||
)
|
||||
.bind(album_id.to_string())
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
|
||||
let mut images = Vec::new();
|
||||
for row in image_rows {
|
||||
let image_id_str = row.get::<String, _>("id");
|
||||
let image_id = match Uuid::parse_str(&image_id_str) {
|
||||
Ok(uuid) => uuid,
|
||||
Err(e) => {
|
||||
eprintln!("无效的图片UUID格式: {} - 错误: {}", image_id_str, e);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let album_id_str = row.get::<String, _>("album_id");
|
||||
let album_id = match Uuid::parse_str(&album_id_str) {
|
||||
Ok(uuid) => uuid,
|
||||
Err(e) => {
|
||||
eprintln!("无效的相册UUID格式: {} - 错误: {}", album_id_str, e);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
images.push(Image {
|
||||
id: image_id,
|
||||
album_id,
|
||||
filename: row.get("filename"),
|
||||
original_url: row.get("original_url"),
|
||||
title: row.get("title"),
|
||||
description: row.get("description"),
|
||||
file_size: row.get("file_size"),
|
||||
width: row.get("width"),
|
||||
height: row.get("height"),
|
||||
mime_type: row.get("mime_type"),
|
||||
created_at: row.get::<String, _>("created_at"),
|
||||
});
|
||||
}
|
||||
|
||||
let image_count = images.len() as i64;
|
||||
|
||||
Ok(Some(AlbumWithImages {
|
||||
album,
|
||||
images,
|
||||
image_count,
|
||||
}))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn create_album(&self, album: CreateAlbum) -> Result<Uuid, sqlx::Error> {
|
||||
let id = Uuid::new_v4();
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO albums (id, title, document, description, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)"
|
||||
)
|
||||
.bind(id.to_string())
|
||||
.bind(album.title)
|
||||
.bind(album.document)
|
||||
.bind(album.description)
|
||||
.bind(&now)
|
||||
.bind(&now)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
// 图片操作
|
||||
pub async fn add_image(&self, image: CreateImage) -> Result<Uuid, sqlx::Error> {
|
||||
let id = Uuid::new_v4();
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO images (id, album_id, filename, original_url, title, description, file_size, width, height, mime_type, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
|
||||
)
|
||||
.bind(id.to_string())
|
||||
.bind(image.album_id.to_string())
|
||||
.bind(image.filename)
|
||||
.bind(image.original_url)
|
||||
.bind(image.title)
|
||||
.bind(image.description)
|
||||
.bind(image.file_size)
|
||||
.bind(image.width)
|
||||
.bind(image.height)
|
||||
.bind(image.mime_type)
|
||||
.bind(&now)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
|
||||
// 如果这是相册的第一张图片,设置为封面
|
||||
let image_count = sqlx::query_scalar::<_, i64>(
|
||||
"SELECT COUNT(*) FROM images WHERE album_id = ?"
|
||||
)
|
||||
.bind(image.album_id.to_string())
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
|
||||
if image_count == 1 {
|
||||
sqlx::query(
|
||||
"UPDATE albums SET cover_image_id = ? WHERE id = ?"
|
||||
)
|
||||
.bind(id.to_string())
|
||||
.bind(image.album_id.to_string())
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
}
|
||||
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
pub async fn add_images(&self, images: Vec<CreateImage>) -> Result<Vec<Uuid>, sqlx::Error> {
|
||||
if images.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
let mut ids = Vec::new();
|
||||
|
||||
// 保存第一个相册ID用于封面设置
|
||||
let first_album_id = images.first().map(|img| img.album_id);
|
||||
|
||||
// 使用事务确保批量插入的原子性
|
||||
let mut tx = self.pool.begin().await?;
|
||||
|
||||
for image in &images {
|
||||
let id = Uuid::new_v4();
|
||||
ids.push(id);
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO images (id, album_id, filename, original_url, title, description, file_size, width, height, mime_type, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
|
||||
)
|
||||
.bind(id.to_string())
|
||||
.bind(image.album_id.to_string())
|
||||
.bind(&image.filename)
|
||||
.bind(&image.original_url)
|
||||
.bind(&image.title)
|
||||
.bind(&image.description)
|
||||
.bind(image.file_size)
|
||||
.bind(image.width)
|
||||
.bind(image.height)
|
||||
.bind(&image.mime_type)
|
||||
.bind(&now)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
|
||||
// 提交事务
|
||||
tx.commit().await?;
|
||||
|
||||
// 批量设置封面图片(只对每个相册的第一批图片设置)
|
||||
if let Some(album_id) = first_album_id {
|
||||
// 检查相册是否已有图片
|
||||
let existing_count = sqlx::query_scalar::<_, i64>(
|
||||
"SELECT COUNT(*) FROM images WHERE album_id = ?"
|
||||
)
|
||||
.bind(album_id.to_string())
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
|
||||
// 如果这是相册的第一批图片,设置第一张为封面
|
||||
if existing_count == images.len() as i64 {
|
||||
if let Some(first_id) = ids.first() {
|
||||
sqlx::query(
|
||||
"UPDATE albums SET cover_image_id = ? WHERE id = ?"
|
||||
)
|
||||
.bind(first_id.to_string())
|
||||
.bind(album_id.to_string())
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(ids)
|
||||
}
|
||||
|
||||
pub async fn get_image(&self, image_id: Uuid) -> Result<Option<Image>, sqlx::Error> {
|
||||
let row = sqlx::query(
|
||||
"SELECT * FROM images WHERE id = ?"
|
||||
)
|
||||
.bind(image_id.to_string())
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
|
||||
if let Some(row) = row {
|
||||
let image_id_str = row.get::<String, _>("id");
|
||||
let image_id = match Uuid::parse_str(&image_id_str) {
|
||||
Ok(uuid) => uuid,
|
||||
Err(e) => {
|
||||
eprintln!("无效的图片UUID格式: {} - 错误: {}", image_id_str, e);
|
||||
return Ok(None);
|
||||
}
|
||||
};
|
||||
|
||||
let album_id_str = row.get::<String, _>("album_id");
|
||||
let album_id = match Uuid::parse_str(&album_id_str) {
|
||||
Ok(uuid) => uuid,
|
||||
Err(e) => {
|
||||
eprintln!("无效的相册UUID格式: {} - 错误: {}", album_id_str, e);
|
||||
return Ok(None);
|
||||
}
|
||||
};
|
||||
|
||||
Ok(Some(Image {
|
||||
id: image_id,
|
||||
album_id,
|
||||
filename: row.get("filename"),
|
||||
original_url: row.get("original_url"),
|
||||
title: row.get("title"),
|
||||
description: row.get("description"),
|
||||
file_size: row.get("file_size"),
|
||||
width: row.get("width"),
|
||||
height: row.get("height"),
|
||||
mime_type: row.get("mime_type"),
|
||||
created_at: row.get::<String, _>("created_at"),
|
||||
}))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
}
|
||||
113
src/image_processor.rs
Normal file
113
src/image_processor.rs
Normal file
@@ -0,0 +1,113 @@
|
||||
use std::path::Path;
|
||||
use std::fs;
|
||||
use image::{GenericImageView, ImageFormat};
|
||||
use uuid::Uuid;
|
||||
use crate::config::StorageConfig;
|
||||
|
||||
pub struct ImageProcessor {
|
||||
pub config: StorageConfig,
|
||||
}
|
||||
|
||||
impl ImageProcessor {
|
||||
pub fn new(config: StorageConfig) -> Self {
|
||||
// 确保目录存在
|
||||
fs::create_dir_all(&config.images_dir).unwrap_or(());
|
||||
fs::create_dir_all(&config.thumbnails_dir).unwrap_or(());
|
||||
|
||||
Self { config }
|
||||
}
|
||||
|
||||
pub async fn save_image(&self, image_data: &[u8], filename: &str) -> Result<ImageInfo, Box<dyn std::error::Error>> {
|
||||
let image_id = Uuid::new_v4();
|
||||
let file_extension = Path::new(filename)
|
||||
.extension()
|
||||
.and_then(|ext| ext.to_str())
|
||||
.unwrap_or("jpg");
|
||||
|
||||
let new_filename = format!("{}.{}", image_id, file_extension);
|
||||
let image_path = self.config.images_dir.join(&new_filename);
|
||||
|
||||
// 保存原始图片
|
||||
fs::write(&image_path, image_data)?;
|
||||
|
||||
// 获取图片信息
|
||||
let img = image::load_from_memory(image_data)?;
|
||||
let (width, height) = img.dimensions();
|
||||
let file_size = image_data.len() as i64;
|
||||
|
||||
// 生成缩略图
|
||||
let thumbnail_path = self.config.thumbnails_dir.join(format!("thumb_{}", new_filename));
|
||||
self.generate_thumbnail(&img, &thumbnail_path, 300, 200)?;
|
||||
|
||||
Ok(ImageInfo {
|
||||
id: image_id,
|
||||
filename: new_filename,
|
||||
width: width as i32,
|
||||
height: height as i32,
|
||||
file_size,
|
||||
mime_type: self.detect_mime_type(file_extension),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn generate_thumbnail(
|
||||
&self,
|
||||
img: &image::DynamicImage,
|
||||
output_path: &Path,
|
||||
max_width: u32,
|
||||
max_height: u32,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let (width, height) = img.dimensions();
|
||||
|
||||
// 计算缩略图尺寸
|
||||
let ratio = width as f32 / height as f32;
|
||||
let (new_width, new_height) = if width > height {
|
||||
(max_width, (max_width as f32 / ratio) as u32)
|
||||
} else {
|
||||
((max_height as f32 * ratio) as u32, max_height)
|
||||
};
|
||||
|
||||
let thumbnail = img.thumbnail(new_width, new_height);
|
||||
thumbnail.save_with_format(output_path, ImageFormat::Jpeg)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn detect_mime_type(&self, extension: &str) -> String {
|
||||
match extension.to_lowercase().as_str() {
|
||||
"jpg" | "jpeg" => "image/jpeg".to_string(),
|
||||
"png" => "image/png".to_string(),
|
||||
"gif" => "image/gif".to_string(),
|
||||
"webp" => "image/webp".to_string(),
|
||||
_ => "application/octet-stream".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_image_url(&self, filename: &str) -> String {
|
||||
format!("/images/{}", filename)
|
||||
}
|
||||
|
||||
pub fn get_thumbnail_url(&self, filename: &str) -> String {
|
||||
format!("/thumbnails/thumb_{}", filename)
|
||||
}
|
||||
|
||||
pub async fn download_and_save_image(
|
||||
&self,
|
||||
url: &str,
|
||||
filename: &str,
|
||||
) -> Result<ImageInfo, Box<dyn std::error::Error>> {
|
||||
let response = reqwest::get(url).await?;
|
||||
let image_data = response.bytes().await?;
|
||||
|
||||
self.save_image(&image_data, filename).await
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ImageInfo {
|
||||
pub id: Uuid,
|
||||
pub filename: String,
|
||||
pub width: i32,
|
||||
pub height: i32,
|
||||
pub file_size: i64,
|
||||
pub mime_type: String,
|
||||
}
|
||||
250
src/import.rs
Normal file
250
src/import.rs
Normal file
@@ -0,0 +1,250 @@
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use image::GenericImageView;
|
||||
use serde::Deserialize;
|
||||
use crate::config::Config;
|
||||
use crate::database::Database;
|
||||
use crate::models::*;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct AlbumIndex {
|
||||
title: String,
|
||||
images: Vec<String>,
|
||||
}
|
||||
|
||||
pub async fn import_existing_data(config: &Config, db: &Database) -> Result<(), Box<dyn std::error::Error>> {
|
||||
println!("开始扫描图片目录并导入新相册...");
|
||||
|
||||
let albums_dir = Path::new(&config.storage.images_dir);
|
||||
|
||||
if !albums_dir.exists() {
|
||||
println!("albums目录不存在,跳过导入");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// 获取数据库中现有的相册列表
|
||||
let existing_albums = db.get_all_albums().await?;
|
||||
let existing_titles: Vec<String> = existing_albums.iter()
|
||||
.map(|album| album.document.to_lowercase())
|
||||
.collect();
|
||||
|
||||
println!("数据库中现有相册数量: {}", existing_albums.len());
|
||||
|
||||
let entries = fs::read_dir(albums_dir)?;
|
||||
let mut imported_count = 0;
|
||||
|
||||
for entry in entries {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
|
||||
if path.is_dir() {
|
||||
if let Some(folder_name) = path.file_name().and_then(|s| s.to_str()) {
|
||||
// 检查是否已存在同名相册
|
||||
if existing_titles.contains(&folder_name.to_lowercase()) {
|
||||
println!("跳过已存在的相册: {}", folder_name);
|
||||
continue;
|
||||
}
|
||||
|
||||
println!("发现新相册: {}", folder_name);
|
||||
|
||||
// 读取index.json文件
|
||||
let index_file_path = path.join("index.json");
|
||||
let album_title = if index_file_path.exists() {
|
||||
match fs::read_to_string(&index_file_path) {
|
||||
Ok(content) => {
|
||||
match serde_json::from_str::<AlbumIndex>(&content) {
|
||||
Ok(index) => {
|
||||
println!(" 从index.json读取相册标题: {}", index.title);
|
||||
index.title
|
||||
}
|
||||
Err(e) => {
|
||||
println!(" 解析index.json失败: {}, 使用文件夹名作为标题", e);
|
||||
folder_name.to_string()
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
println!(" 读取index.json失败: {}, 使用文件夹名作为标题", e);
|
||||
folder_name.to_string()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
println!(" index.json不存在,使用文件夹名作为标题");
|
||||
folder_name.to_string()
|
||||
};
|
||||
|
||||
// 创建新相册
|
||||
let new_album = CreateAlbum {
|
||||
title: album_title.clone(),
|
||||
document: folder_name.to_string(),
|
||||
description: Some(format!("从文件夹 {} 自动导入的相册", folder_name)),
|
||||
};
|
||||
|
||||
let album_id = db.create_album(new_album).await?;
|
||||
println!(" 创建相册: {} (ID: {})", album_title, album_id);
|
||||
|
||||
// 读取图片URL列表(如果有index.json)
|
||||
let image_urls = if index_file_path.exists() {
|
||||
match fs::read_to_string(&index_file_path) {
|
||||
Ok(content) => {
|
||||
match serde_json::from_str::<AlbumIndex>(&content) {
|
||||
Ok(index) => {
|
||||
println!(" 从index.json读取到 {} 个图片URL", index.images.len());
|
||||
Some(index.images)
|
||||
}
|
||||
Err(_) => None
|
||||
}
|
||||
}
|
||||
Err(_) => None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// 扫描文件夹中的图片文件
|
||||
let image_entries = fs::read_dir(&path)?;
|
||||
let mut image_files = Vec::new();
|
||||
|
||||
for image_entry in image_entries {
|
||||
let image_entry = image_entry?;
|
||||
let image_path = image_entry.path();
|
||||
|
||||
if image_path.is_file() {
|
||||
if let Some(extension) = image_path.extension().and_then(|s| s.to_str()) {
|
||||
let valid_extensions = ["jpg", "jpeg", "png", "gif", "webp", "bmp"];
|
||||
|
||||
if valid_extensions.contains(&extension.to_lowercase().as_str()) {
|
||||
let filename = image_path.file_name()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or("unknown.jpg")
|
||||
.to_string();
|
||||
|
||||
image_files.push((filename, image_path));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 按数字顺序排序文件名
|
||||
image_files.sort_by(|a, b| {
|
||||
// 提取文件名中的数字部分进行排序
|
||||
let num_a = a.0.chars()
|
||||
.take_while(|c| c.is_ascii_digit())
|
||||
.collect::<String>()
|
||||
.parse::<u32>()
|
||||
.unwrap_or(0);
|
||||
|
||||
let num_b = b.0.chars()
|
||||
.take_while(|c| c.is_ascii_digit())
|
||||
.collect::<String>()
|
||||
.parse::<u32>()
|
||||
.unwrap_or(0);
|
||||
|
||||
num_a.cmp(&num_b)
|
||||
});
|
||||
|
||||
let mut image_count = 0;
|
||||
let mut images_to_insert = Vec::new();
|
||||
|
||||
// 收集所有图片信息
|
||||
for (filename, image_path) in image_files {
|
||||
image_count += 1;
|
||||
|
||||
// 获取对应的URL(如果有的话)
|
||||
let original_url = if let Some(ref urls) = image_urls {
|
||||
if urls.len() >= image_count {
|
||||
Some(urls[image_count - 1].clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// 生成图片标题
|
||||
let image_title = if let Some(ref urls) = image_urls {
|
||||
if urls.len() >= image_count {
|
||||
Some(format!("图片 {}", image_count))
|
||||
} else {
|
||||
Some(format!("图片 {}", image_count))
|
||||
}
|
||||
} else {
|
||||
Some(format!("图片 {}", image_count))
|
||||
};
|
||||
|
||||
// 获取图片的实际信息
|
||||
let (file_size, width, height) = match get_image_info(&image_path) {
|
||||
Ok(info) => info,
|
||||
Err(e) => {
|
||||
eprintln!("获取图片信息失败: {} - {}", image_path.display(), e);
|
||||
(0, 0, 0)
|
||||
}
|
||||
};
|
||||
|
||||
let image = CreateImage {
|
||||
album_id,
|
||||
filename: format!("{}/{}", folder_name, filename).clone(),
|
||||
original_url: original_url.clone(),
|
||||
title: image_title,
|
||||
description: Some(format!("从文件夹 {} 导入的图片", folder_name)),
|
||||
file_size,
|
||||
width,
|
||||
height,
|
||||
mime_type: match image_path.extension().and_then(|s| s.to_str()).unwrap_or("jpg").to_lowercase().as_str() {
|
||||
"jpg" | "jpeg" => "image/jpeg".to_string(),
|
||||
"png" => "image/png".to_string(),
|
||||
"gif" => "image/gif".to_string(),
|
||||
"webp" => "image/webp".to_string(),
|
||||
"bmp" => "image/bmp".to_string(),
|
||||
_ => "image/jpeg".to_string(),
|
||||
},
|
||||
};
|
||||
|
||||
images_to_insert.push(image.clone());
|
||||
}
|
||||
|
||||
// 批量插入图片
|
||||
if !images_to_insert.is_empty() {
|
||||
println!(" 批量插入 {} 张图片...", images_to_insert.len());
|
||||
match db.add_images(images_to_insert.clone()).await {
|
||||
Ok(ids) => {
|
||||
println!(" 批量插入成功,共插入 {} 张图片", ids.len());
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!(" 批量插入失败: {}", e);
|
||||
// 如果批量插入失败,尝试逐条插入
|
||||
println!(" 尝试逐条插入图片...");
|
||||
let mut success_count = 0;
|
||||
for image in images_to_insert {
|
||||
match db.add_image(image).await {
|
||||
Ok(_) => success_count += 1,
|
||||
Err(e) => eprintln!(" 单条插入失败: {}", e),
|
||||
}
|
||||
}
|
||||
println!(" 逐条插入完成,成功 {} 张", success_count);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
println!(" 相册 {} 导入完成,共导入 {} 张图片", folder_name, image_count);
|
||||
imported_count += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
println!("导入完成!共导入 {} 个新相册", imported_count);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 获取图片的实际信息(文件大小、宽度、高度)
|
||||
fn get_image_info(image_path: &Path) -> Result<(i64, i32, i32), Box<dyn std::error::Error>> {
|
||||
// 获取文件大小
|
||||
let metadata = fs::metadata(image_path)?;
|
||||
let file_size = metadata.len() as i64;
|
||||
|
||||
// 使用image库获取图片尺寸
|
||||
let img = image::open(image_path)?;
|
||||
let (width, height) = img.dimensions();
|
||||
|
||||
Ok((file_size, width as i32, height as i32))
|
||||
}
|
||||
104
src/main.rs
Normal file
104
src/main.rs
Normal file
@@ -0,0 +1,104 @@
|
||||
mod config;
|
||||
mod models;
|
||||
mod database;
|
||||
mod image_processor;
|
||||
mod routes;
|
||||
mod import;
|
||||
|
||||
use actix_files as afs;
|
||||
use actix_web::{web, App, HttpServer, HttpResponse};
|
||||
use std::sync::Arc;
|
||||
use std::fs;
|
||||
|
||||
#[actix_web::main]
|
||||
async fn main() -> std::io::Result<()> {
|
||||
// 加载配置
|
||||
let config = match config::Config::load() {
|
||||
Ok(cfg) => cfg,
|
||||
Err(e) => {
|
||||
eprintln!("加载配置失败: {}", e);
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
println!("启动图片展示服务...");
|
||||
println!("服务地址: http://{}", config.server_address());
|
||||
|
||||
// 初始化数据库
|
||||
let db = match database::Database::new(&config).await {
|
||||
Ok(db) => {
|
||||
println!("数据库连接成功");
|
||||
Arc::new(db)
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("数据库连接失败: {}", e);
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
// 在后台线程中自动导入新相册(不阻塞HTTP服务启动)
|
||||
let config_clone = config.clone();
|
||||
let db_clone = Arc::clone(&db);
|
||||
tokio::spawn(async move {
|
||||
println!("[后台任务] 开始扫描图片目录并导入新相册...");
|
||||
match import::import_existing_data(&config_clone, &db_clone).await {
|
||||
Ok(_) => println!("[后台任务] 相册导入完成"),
|
||||
Err(e) => eprintln!("[后台任务] 相册导入失败: {}", e),
|
||||
}
|
||||
});
|
||||
|
||||
// 初始化图片处理器
|
||||
let image_processor = Arc::new(image_processor::ImageProcessor::new(config.storage.clone()));
|
||||
|
||||
println!("API接口:");
|
||||
println!(" - GET /api/albums - 获取所有相册");
|
||||
println!(" - GET /api/search?q=关键词 - 搜索相册");
|
||||
println!(" - GET /api/album/:id - 获取相册详情");
|
||||
println!(" - POST /api/albums - 创建新相册");
|
||||
println!(" - GET /api/thumbnail/:id - 获取缩略图");
|
||||
println!(" - GET /api/original/:id - 获取原始图片");
|
||||
println!("按 Ctrl+C 停止服务");
|
||||
|
||||
// 创建数据目录
|
||||
std::fs::create_dir_all(&config.storage.images_dir).unwrap_or(());
|
||||
std::fs::create_dir_all(&config.storage.thumbnails_dir).unwrap_or(());
|
||||
std::fs::create_dir_all("./data").unwrap_or(());
|
||||
|
||||
// 在闭包外提取需要的路径
|
||||
let server_address = config.server_address();
|
||||
|
||||
HttpServer::new(move || {
|
||||
App::new()
|
||||
.app_data(web::Data::new(Arc::clone(&db)))
|
||||
.app_data(web::Data::new(Arc::clone(&image_processor)))
|
||||
// API路由
|
||||
.route("/api/albums", web::get().to(routes::get_albums))
|
||||
.route("/api/search", web::get().to(routes::search_albums))
|
||||
.route("/api/album/{id}", web::get().to(routes::get_album_detail))
|
||||
.route("/api/albums", web::post().to(routes::create_album))
|
||||
.route("/api/thumbnail/{id}", web::get().to(routes::get_thumbnail))
|
||||
.route("/api/original/{id}", web::get().to(routes::get_original_image))
|
||||
// 静态文件服务
|
||||
.service(afs::Files::new("/assets", "./assets").show_files_listing())
|
||||
// HTML页面路由
|
||||
.route("/", web::get().to(|| async {
|
||||
match fs::read_to_string("./assets/index.html") {
|
||||
Ok(content) => HttpResponse::Ok()
|
||||
.content_type("text/html; charset=utf-8")
|
||||
.body(content),
|
||||
Err(_) => HttpResponse::NotFound().body("首页文件不存在")
|
||||
}
|
||||
}))
|
||||
.route("/album", web::get().to(|| async {
|
||||
match fs::read_to_string("./assets/album.html") {
|
||||
Ok(content) => HttpResponse::Ok()
|
||||
.content_type("text/html; charset=utf-8")
|
||||
.body(content),
|
||||
Err(_) => HttpResponse::NotFound().body("相册页面文件不存在")
|
||||
}
|
||||
}))
|
||||
})
|
||||
.bind(server_address)?
|
||||
.run()
|
||||
.await
|
||||
}
|
||||
65
src/models.rs
Normal file
65
src/models.rs
Normal file
@@ -0,0 +1,65 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct Album {
|
||||
pub id: Uuid,
|
||||
pub title: String,
|
||||
pub description: Option<String>,
|
||||
pub cover_image_id: Option<Uuid>,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct Image {
|
||||
pub id: Uuid,
|
||||
pub album_id: Uuid,
|
||||
pub filename: String,
|
||||
pub original_url: Option<String>,
|
||||
pub title: Option<String>,
|
||||
pub description: Option<String>,
|
||||
pub file_size: i64,
|
||||
pub width: i32,
|
||||
pub height: i32,
|
||||
pub mime_type: String,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct AlbumWithImages {
|
||||
pub album: Album,
|
||||
pub images: Vec<Image>,
|
||||
pub image_count: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct AlbumInfo {
|
||||
pub id: Uuid,
|
||||
pub title: String,
|
||||
pub document: String,
|
||||
pub description: Option<String>,
|
||||
pub cover_image_id: Option<String>,
|
||||
pub image_count: i64,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct CreateAlbum {
|
||||
pub title: String,
|
||||
pub document: String,
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct CreateImage {
|
||||
pub album_id: Uuid,
|
||||
pub filename: String,
|
||||
pub original_url: Option<String>,
|
||||
pub title: Option<String>,
|
||||
pub description: Option<String>,
|
||||
pub file_size: i64,
|
||||
pub width: i32,
|
||||
pub height: i32,
|
||||
pub mime_type: String,
|
||||
}
|
||||
266
src/routes.rs
Normal file
266
src/routes.rs
Normal file
@@ -0,0 +1,266 @@
|
||||
use crate::database::Database;
|
||||
use crate::image_processor::ImageProcessor;
|
||||
use crate::models::*;
|
||||
use actix_web::{HttpResponse, Responder, web};
|
||||
use std::fs;
|
||||
use std::sync::Arc;
|
||||
use uuid::Uuid;
|
||||
|
||||
pub async fn get_albums(db: web::Data<Arc<Database>>) -> impl Responder {
|
||||
match db.get_all_albums().await {
|
||||
Ok(albums) => HttpResponse::Ok().json(albums),
|
||||
Err(e) => {
|
||||
eprintln!("获取相册列表失败: {}", e);
|
||||
HttpResponse::InternalServerError().json(serde_json::json!({
|
||||
"error": "获取相册列表失败"
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_original_image(
|
||||
db: web::Data<Arc<Database>>,
|
||||
image_processor: web::Data<Arc<ImageProcessor>>,
|
||||
path: web::Path<String>,
|
||||
) -> impl Responder {
|
||||
let image_id = match Uuid::parse_str(&path.into_inner()) {
|
||||
Ok(id) => id,
|
||||
Err(_) => {
|
||||
return HttpResponse::BadRequest().json(serde_json::json!({
|
||||
"error": "无效的图片ID"
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
match db.get_image(image_id).await {
|
||||
Ok(Some(image)) => {
|
||||
let original_path = image_processor.config.images_dir.join(&image.filename);
|
||||
|
||||
if original_path.exists() {
|
||||
match fs::read(&original_path) {
|
||||
Ok(data) => HttpResponse::Ok().content_type(image.mime_type).body(data),
|
||||
Err(e) => {
|
||||
eprintln!("读取原始图片失败: {}", e);
|
||||
HttpResponse::InternalServerError().json(serde_json::json!({
|
||||
"error": "读取原始图片失败"
|
||||
}))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
HttpResponse::NotFound().json(serde_json::json!({
|
||||
"error": "原始图片文件不存在"
|
||||
}))
|
||||
}
|
||||
}
|
||||
Ok(None) => HttpResponse::NotFound().json(serde_json::json!({
|
||||
"error": "图片不存在"
|
||||
})),
|
||||
Err(e) => {
|
||||
eprintln!("获取图片信息失败: {}", e);
|
||||
HttpResponse::InternalServerError().json(serde_json::json!({
|
||||
"error": "获取图片信息失败"
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn search_albums(
|
||||
db: web::Data<Arc<Database>>,
|
||||
query: web::Query<std::collections::HashMap<String, String>>,
|
||||
) -> impl Responder {
|
||||
let q = query.get("q").map(|s| s.as_str()).unwrap_or("");
|
||||
|
||||
if q.is_empty() {
|
||||
return match db.get_all_albums().await {
|
||||
Ok(albums) => HttpResponse::Ok().json(albums),
|
||||
Err(e) => {
|
||||
eprintln!("获取相册列表失败: {}", e);
|
||||
HttpResponse::InternalServerError().json(serde_json::json!({
|
||||
"error": "获取相册列表失败"
|
||||
}))
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
match db.search_albums(q).await {
|
||||
Ok(albums) => HttpResponse::Ok().json(albums),
|
||||
Err(e) => {
|
||||
eprintln!("搜索相册失败: {}", e);
|
||||
HttpResponse::InternalServerError().json(serde_json::json!({
|
||||
"error": "搜索失败"
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_album_detail(
|
||||
db: web::Data<Arc<Database>>,
|
||||
path: web::Path<String>,
|
||||
) -> impl Responder {
|
||||
let album_id = match Uuid::parse_str(&path.into_inner()) {
|
||||
Ok(id) => id,
|
||||
Err(_) => {
|
||||
return HttpResponse::BadRequest().json(serde_json::json!({
|
||||
"error": "无效的相册ID"
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
match db.get_album_with_images(album_id).await {
|
||||
Ok(Some(album)) => HttpResponse::Ok().json(album),
|
||||
Ok(None) => HttpResponse::NotFound().json(serde_json::json!({
|
||||
"error": "相册不存在"
|
||||
})),
|
||||
Err(e) => {
|
||||
eprintln!("获取相册详情失败: {}", e);
|
||||
HttpResponse::InternalServerError().json(serde_json::json!({
|
||||
"error": "获取相册详情失败"
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn create_album(
|
||||
db: web::Data<Arc<Database>>,
|
||||
album_data: web::Json<CreateAlbum>,
|
||||
) -> impl Responder {
|
||||
match db.create_album(album_data.into_inner()).await {
|
||||
Ok(album_id) => HttpResponse::Created().json(serde_json::json!({
|
||||
"id": album_id,
|
||||
"message": "相册创建成功"
|
||||
})),
|
||||
Err(e) => {
|
||||
eprintln!("创建相册失败: {}", e);
|
||||
HttpResponse::InternalServerError().json(serde_json::json!({
|
||||
"error": "创建相册失败"
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_thumbnail(
|
||||
db: web::Data<Arc<Database>>,
|
||||
image_processor: web::Data<Arc<ImageProcessor>>,
|
||||
path: web::Path<String>,
|
||||
) -> impl Responder {
|
||||
let image_id = match Uuid::parse_str(&path.into_inner()) {
|
||||
Ok(id) => id,
|
||||
Err(_) => {
|
||||
return HttpResponse::BadRequest().json(serde_json::json!({
|
||||
"error": "无效的图片ID"
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
match db.get_image(image_id).await {
|
||||
Ok(Some(image)) => {
|
||||
// 优先返回缩略图,如果不存在则生成
|
||||
let thumbnail_path = image_processor
|
||||
.config
|
||||
.thumbnails_dir
|
||||
.join(image.id.to_string());
|
||||
let original_path = image_processor.config.images_dir.join(&image.filename);
|
||||
|
||||
if thumbnail_path.exists() {
|
||||
// 返回缩略图
|
||||
match fs::read(&thumbnail_path) {
|
||||
Ok(data) => {
|
||||
let mime_type = image_processor.detect_mime_type("jpg"); // 缩略图总是JPEG格式
|
||||
HttpResponse::Ok().content_type(mime_type).body(data)
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("读取缩略图失败: {}", e);
|
||||
HttpResponse::InternalServerError().json(serde_json::json!({
|
||||
"error": "读取缩略图失败"
|
||||
}))
|
||||
}
|
||||
}
|
||||
} else if original_path.exists() {
|
||||
// 生成缩略图并返回
|
||||
match fs::read(&original_path) {
|
||||
Ok(data) => {
|
||||
match image::load_from_memory(&data) {
|
||||
Ok(img) => {
|
||||
// 在内存中生成缩略图,避免重复文件读写
|
||||
let thumbnail = img.thumbnail(800, 600);
|
||||
|
||||
// 使用临时文件来生成缩略图数据
|
||||
let temp_dir = std::env::temp_dir();
|
||||
let temp_file =
|
||||
temp_dir.join(format!("temp_thumb_{}", Uuid::new_v4()));
|
||||
|
||||
if let Err(e) =
|
||||
thumbnail.save_with_format(&temp_file, image::ImageFormat::Jpeg)
|
||||
{
|
||||
eprintln!("生成缩略图失败: {}", e);
|
||||
// 如果生成缩略图失败,返回原始图片
|
||||
return HttpResponse::Ok()
|
||||
.content_type(image.mime_type)
|
||||
.body(data);
|
||||
}
|
||||
|
||||
// 读取缩略图数据
|
||||
match fs::read(&temp_file) {
|
||||
Ok(thumbnail_data) => {
|
||||
// 异步保存缩略图到文件(不阻塞响应)
|
||||
let config_clone = image_processor.config.clone();
|
||||
let filename_clone = image.id.clone();
|
||||
let thumbnail_data_clone = thumbnail_data.clone();
|
||||
tokio::spawn(async move {
|
||||
let thumbnail_path = config_clone
|
||||
.thumbnails_dir
|
||||
.join(filename_clone.to_string());
|
||||
if let Err(e) =
|
||||
fs::write(&thumbnail_path, &thumbnail_data_clone)
|
||||
{
|
||||
eprintln!("保存缩略图文件失败: {}", e);
|
||||
}
|
||||
});
|
||||
|
||||
// 清理临时文件
|
||||
let _ = fs::remove_file(&temp_file);
|
||||
|
||||
// 返回缩略图数据
|
||||
HttpResponse::Ok()
|
||||
.content_type("image/jpeg")
|
||||
.body(thumbnail_data)
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("读取缩略图数据失败: {}", e);
|
||||
// 如果读取失败,返回原始图片
|
||||
HttpResponse::Ok().content_type(image.mime_type).body(data)
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("加载图片失败: {}", e);
|
||||
HttpResponse::InternalServerError().json(serde_json::json!({
|
||||
"error": "加载图片失败"
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("读取原始图片失败: {}", e);
|
||||
HttpResponse::InternalServerError().json(serde_json::json!({
|
||||
"error": "读取图片失败"
|
||||
}))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
HttpResponse::NotFound().json(serde_json::json!({
|
||||
"error": "图片文件不存在"
|
||||
}))
|
||||
}
|
||||
}
|
||||
Ok(None) => HttpResponse::NotFound().json(serde_json::json!({
|
||||
"error": "图片不存在"
|
||||
})),
|
||||
Err(e) => {
|
||||
eprintln!("获取图片信息失败: {}", e);
|
||||
HttpResponse::InternalServerError().json(serde_json::json!({
|
||||
"error": "获取图片信息失败"
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user