This commit is contained in:
lychang
2026-03-20 02:31:07 +08:00
commit 5893cd5e31
19 changed files with 5653 additions and 0 deletions

1
.env Normal file
View File

@@ -0,0 +1 @@
DATABASE_URL=sqlite:data/imager.db

3522
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

17
Cargo.toml Normal file
View File

@@ -0,0 +1,17 @@
[package]
name = "imager"
version = "0.1.0"
edition = "2024"
[dependencies]
actix-web = "4"
actix-files = "0.6"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["full"] }
sqlx = { version = "0.7", features = ["sqlite", "runtime-tokio", "migrate"] }
chrono = { version = "0.4", features = ["serde"] }
uuid = { version = "1.0", features = ["v4", "serde"] }
config = "0.13"
image = { version = "0.24", default-features = false, features = ["jpeg", "png", "gif", "webp"] }
reqwest = { version = "0.11", features = ["json"] }

43
assets/album.html Normal file
View File

@@ -0,0 +1,43 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>相册详情 - 图片展示服务</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
<link rel="stylesheet" href="/assets/css/style.css">
<link rel="stylesheet" href="/assets/css/album.css">
</head>
<body>
<div class="container">
<div class="header">
<a href="/" class="back-link">
<i class="fas fa-arrow-left"></i> 返回首页
</a>
<h1 class="album-title" id="album-title">加载中...</h1>
<div class="album-info" id="album-info">
<i class="fas fa-images"></i> 正在加载...
</div>
</div>
<div class="images-grid" id="images-container">
<div class="loading">
<i class="fas fa-spinner fa-spin"></i><br>加载图片中...
</div>
</div>
</div>
<!-- 图片查看器 -->
<div class="lightbox" id="lightbox">
<span class="lightbox-close" onclick="closeLightbox()">&times;</span>
<div class="lightbox-nav">
<button onclick="prevImage()"><i class="fas fa-chevron-left"></i></button>
<button onclick="nextImage()"><i class="fas fa-chevron-right"></i></button>
</div>
<img id="lightbox-img" src="" alt="">
<div class="lightbox-info" id="lightbox-info"></div>
</div>
<script src="/assets/js/album.js"></script>
</body>
</html>

212
assets/css/album.css Normal file
View File

@@ -0,0 +1,212 @@
/* 相册详情页面特定样式 - 蓝白配色体系 */
:root {
--primary-blue: #1e40af;
--secondary-blue: #3b82f6;
--light-blue: #dbeafe;
--accent-blue: #60a5fa;
--white: #ffffff;
--light-gray: #f8fafc;
--medium-gray: #e2e8f0;
--dark-gray: #475569;
--text-dark: #1e293b;
--text-light: #64748b;
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.1);
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.1);
--shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.1);
--shadow-xl: 0 20px 25px rgba(0, 0, 0, 0.1);
--border-radius: 12px;
--border-radius-lg: 20px;
}
body {
font-family: 'Inter', 'Segoe UI', system-ui, -apple-system, sans-serif;
background: linear-gradient(135deg, var(--light-blue) 0%, var(--white) 100%);
min-height: 100vh;
color: var(--text-dark);
line-height: 1.6;
}
.container {
max-width: 1400px;
margin: 0 auto;
padding: 30px 20px;
}
.header { margin-bottom: 40px; }
.back-link {
display: inline-flex;
align-items: center;
gap: 10px;
padding: 12px 24px;
background: var(--white);
color: var(--primary-blue);
text-decoration: none;
border-radius: var(--border-radius);
box-shadow: var(--shadow-sm);
margin-bottom: 25px;
transition: all 0.3s ease;
border: 1px solid var(--medium-gray);
font-weight: 500;
}
.back-link:hover {
background: var(--light-blue);
transform: translateX(-5px);
box-shadow: var(--shadow-md);
color: var(--primary-blue);
}
.album-title {
color: var(--text-dark);
font-size: 2.5rem;
text-align: center;
margin-bottom: 15px;
font-weight: 700;
background: linear-gradient(135deg, var(--primary-blue) 0%, var(--secondary-blue) 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.album-info {
text-align: center;
color: var(--text-light);
font-size: 1.2rem;
margin-bottom: 40px;
background: var(--white);
padding: 20px;
border-radius: var(--border-radius);
box-shadow: var(--shadow-sm);
border: 1px solid var(--medium-gray);
max-width: 800px;
margin-left: auto;
margin-right: auto;
}
.images-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 25px;
margin-bottom: 50px;
}
.image-item {
position: relative;
overflow: hidden;
border-radius: var(--border-radius);
box-shadow: var(--shadow-lg);
background: var(--white);
transition: all 0.3s ease;
cursor: pointer;
border: 1px solid var(--medium-gray);
}
.image-item:hover {
transform: translateY(-8px);
box-shadow: var(--shadow-xl);
border-color: var(--accent-blue);
}
.image-item img {
width: 100%;
height: 220px;
object-fit: cover;
display: block;
transition: transform 0.5s ease;
}
.image-item:hover img {
transform: scale(1.05);
}
.image-caption {
padding: 12px;
text-align: center;
color: var(--text-dark);
font-size: 0.9rem;
font-weight: 500;
line-height: 1.4;
}
/* 图片查看器样式 */
.lightbox {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(30, 41, 59, 0.95);
z-index: 1000;
justify-content: center;
align-items: center;
flex-direction: column;
backdrop-filter: blur(10px);
}
.lightbox img {
max-width: 85%;
max-height: 75%;
object-fit: contain;
border-radius: 12px;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.3);
}
.lightbox-close {
position: absolute;
top: 30px;
right: 40px;
color: var(--white);
font-size: 36px;
cursor: pointer;
z-index: 1001;
background: rgba(255, 255, 255, 0.1);
width: 50px;
height: 50px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.3s ease;
backdrop-filter: blur(10px);
}
.lightbox-close:hover {
background: rgba(255, 255, 255, 0.2);
transform: scale(1.1);
}
.lightbox-nav {
position: absolute;
top: 50%;
width: 100%;
display: flex;
justify-content: space-between;
padding: 0 20px;
transform: translateY(-50%);
z-index: 1001;
}
.lightbox-nav button {
background: rgba(255,255,255,0.2);
border: none;
color: var(--white);
font-size: 30px;
padding: 12px 24px;
cursor: pointer;
border-radius: var(--border-radius);
backdrop-filter: blur(10px);
transition: all 0.3s ease;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.lightbox-nav button:hover {
background: rgba(255,255,255,0.3);
transform: scale(1.1);
border-color: rgba(255, 255, 255, 0.3);
}
.lightbox-info {
color: var(--white);
text-align: center;
margin-top: 25px;
font-size: 1.2rem;
background: rgba(255, 255, 255, 0.1);
padding: 15px 25px;
border-radius: var(--border-radius);
backdrop-filter: blur(10px);
}
@media (max-width: 768px) {
.images-grid { grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); }
.album-title { font-size: 1.8rem; }
.lightbox img { max-width: 95%; max-height: 60%; }
.lightbox-nav button { font-size: 24px; padding: 10px 18px; }
.container { padding: 20px 15px; }
.album-info { font-size: 1.1rem; padding: 15px; }
}

228
assets/css/style.css Normal file
View File

@@ -0,0 +1,228 @@
/* 现代化蓝白配色体系 */
:root {
--primary-blue: #1e40af;
--secondary-blue: #3b82f6;
--light-blue: #dbeafe;
--accent-blue: #60a5fa;
--white: #ffffff;
--light-gray: #f8fafc;
--medium-gray: #e2e8f0;
--dark-gray: #475569;
--text-dark: #1e293b;
--text-light: #64748b;
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.1);
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.1);
--shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.1);
--shadow-xl: 0 20px 25px rgba(0, 0, 0, 0.1);
--border-radius: 12px;
--border-radius-lg: 20px;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Inter', 'Segoe UI', system-ui, -apple-system, sans-serif;
background: linear-gradient(135deg, var(--light-blue) 0%, var(--white) 100%);
min-height: 100vh;
color: var(--text-dark);
line-height: 1.6;
}
.container {
max-width: 1400px;
margin: 0 auto;
padding: 30px 20px;
}
.header {
text-align: center;
margin-bottom: 50px;
color: var(--text-dark);
}
.header h1 {
font-size: 3rem;
margin-bottom: 15px;
font-weight: 700;
background: linear-gradient(135deg, var(--primary-blue) 0%, var(--secondary-blue) 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.header p {
font-size: 1.3rem;
color: var(--text-light);
max-width: 600px;
margin: 0 auto;
}
.search-container {
text-align: center;
margin-bottom: 50px;
}
.search-box {
position: relative;
display: inline-block;
max-width: 500px;
width: 100%;
}
.search-box input {
width: 100%;
padding: 18px 60px 18px 25px;
font-size: 16px;
border: 2px solid var(--medium-gray);
border-radius: 50px;
background: var(--white);
box-shadow: var(--shadow-md);
transition: all 0.3s ease;
color: var(--text-dark);
}
.search-box input:focus {
outline: none;
border-color: var(--secondary-blue);
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
transform: translateY(-2px);
}
.search-box button {
position: absolute;
right: 15px;
top: 50%;
transform: translateY(-50%);
background: var(--secondary-blue);
border: none;
color: var(--white);
font-size: 18px;
cursor: pointer;
width: 40px;
height: 40px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.3s ease;
}
.search-box button:hover {
background: var(--primary-blue);
transform: translateY(-50%) scale(1.05);
}
.stats {
text-align: center;
color: var(--text-dark);
margin-bottom: 40px;
font-size: 1.1rem;
background: var(--white);
padding: 20px;
border-radius: var(--border-radius);
box-shadow: var(--shadow-sm);
border: 1px solid var(--medium-gray);
}
.albums-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
gap: 30px;
margin-bottom: 40px;
}
.album-card {
background: var(--white);
border-radius: var(--border-radius-lg);
box-shadow: var(--shadow-lg);
overflow: hidden;
transition: all 0.3s ease;
position: relative;
border: 1px solid var(--medium-gray);
}
.album-card:hover {
transform: translateY(-8px);
box-shadow: var(--shadow-xl);
border-color: var(--accent-blue);
}
.album-cover {
height: 220px;
overflow: hidden;
position: relative;
background: var(--light-gray);
}
.album-cover img {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.5s ease;
}
.album-card:hover .album-cover img {
transform: scale(1.05);
}
.album-info {
padding: 25px;
}
.album-info h2 {
font-size: 1.4rem;
margin-bottom: 12px;
color: var(--text-dark);
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
font-weight: 600;
}
.album-meta {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
color: var(--text-light);
font-size: 0.9rem;
}
.album-meta span {
display: flex;
align-items: center;
gap: 6px;
}
.view-button {
display: block;
text-align: center;
padding: 14px;
background: linear-gradient(135deg, var(--secondary-blue) 0%, var(--primary-blue) 100%);
color: var(--white);
text-decoration: none;
border-radius: 10px;
font-weight: 600;
transition: all 0.3s ease;
border: none;
cursor: pointer;
font-size: 1rem;
}
.view-button:hover {
transform: translateY(-2px);
box-shadow: 0 8px 20px rgba(59, 130, 246, 0.3);
background: linear-gradient(135deg, var(--primary-blue) 0%, var(--secondary-blue) 100%);
}
.no-results {
text-align: center;
color: var(--text-light);
font-size: 1.2rem;
padding: 60px 40px;
background: var(--white);
border-radius: var(--border-radius-lg);
box-shadow: var(--shadow-md);
border: 1px solid var(--medium-gray);
}
.loading {
text-align: center;
color: var(--text-light);
font-size: 1.2rem;
padding: 60px 40px;
background: var(--white);
border-radius: var(--border-radius-lg);
box-shadow: var(--shadow-md);
border: 1px solid var(--medium-gray);
}
@media (max-width: 768px) {
.albums-grid { grid-template-columns: 1fr; }
.search-box { max-width: 100%; }
.header h1 { font-size: 2.2rem; }
.container { padding: 20px 15px; }
.album-info { padding: 20px; }
}

37
assets/index.html Normal file
View File

@@ -0,0 +1,37 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>流云图库</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
<link rel="stylesheet" href="/assets/css/style.css">
</head>
<body>
<div class="container">
<div class="header">
<h1><i class="fas fa-images"></i> <span>流云图库</span></h1>
<p>发现精彩瞬间,浏览精美图片</p>
</div>
<div class="search-container">
<div class="search-box">
<label for="search-input"></label><input type="text" id="search-input" placeholder="搜索图片标题..." onkeypress="handleKeyPress(event)">
<button onclick="searchAlbums()"><i class="fas fa-search"></i></button>
</div>
</div>
<div class="stats" id="stats">
正在加载相册数据...
</div>
<div class="albums-grid" id="albums-container">
<div class="loading">
<i class="fas fa-spinner fa-spin"></i><br>加载中...
</div>
</div>
</div>
<script src="/assets/js/app.js"></script>
</body>
</html>

182
assets/js/album.js Normal file
View File

@@ -0,0 +1,182 @@
// 全局变量
let currentAlbum = null;
let currentImageIndex = 0;
let images = [];
// 页面加载完成后初始化
document.addEventListener('DOMContentLoaded', function() {
const urlParams = new URLSearchParams(window.location.search);
const albumId = urlParams.get('id');
if (albumId) {
loadAlbum(albumId);
} else {
showError('缺少相册ID参数');
}
});
// 加载相册详情
async function loadAlbum(albumId) {
try {
const response = await fetch(`/api/album/${albumId}`);
if (!response.ok) {
if (response.status === 404) {
throw new Error('相册不存在');
}
throw new Error(`HTTP error! status: ${response.status}`);
}
const albumData = await response.json();
currentAlbum = albumData.album;
images = albumData.images;
updateAlbumInfo();
displayImages();
} catch (error) {
console.error('加载相册失败:', error);
showError('加载相册失败: ' + error.message);
}
}
// 更新相册信息
function updateAlbumInfo() {
document.getElementById('album-title').textContent = currentAlbum.title;
document.getElementById('album-info').innerHTML =
`<i class="fas fa-images"></i> 共 ${images.length} 张图片`;
document.title = `${currentAlbum.title} - 图片展示服务`;
}
// 显示图片列表
function displayImages() {
const container = document.getElementById('images-container');
if (images.length === 0) {
container.innerHTML = '<div class="no-results"><i class="fas fa-images"></i><br>此相册暂无图片</div>';
return;
}
container.innerHTML = images.map((image, index) => {
// 优先使用缩略图API,如果没有则使用原始URL
let imageUrl = '/assets/placeholder.svg';
if (image.id) {
imageUrl = `/api/thumbnail/${image.id}`;
} else if (image.original_url) {
imageUrl = image.original_url;
}
const title = image.title || `图片 ${index + 1}`;
return `
<div class="image-item" onclick="openLightbox(${index})">
<img src="${imageUrl}" alt="${title}" onerror="this.src='/assets/placeholder.svg'">
<div class="image-caption">
${image.width ? `<small>${image.width}×${image.height}</small>` : ''}
${image.file_size ? `<small>${(image.file_size / 1024 ).toFixed(2)} KB</small>` : ''}
</div>
</div>
`;
}).join('');
}
// 打开图片查看器
function openLightbox(index) {
currentImageIndex = index;
const lightbox = document.getElementById('lightbox');
const lightboxImg = document.getElementById('lightbox-img');
const lightboxInfo = document.getElementById('lightbox-info');
const image = images[index];
// 在lightbox中优先使用原始图片以获得更好的显示效果
let imageUrl = '/assets/placeholder.svg';
if (image.id) {
imageUrl = `/api/original/${image.id}`;
} else if (image.original_url) {
imageUrl = image.original_url;
}
lightboxImg.src = imageUrl;
const title = image.title || `图片 ${index + 1}`;
let infoText = `${title} (${index + 1}/${images.length})`;
if (image.width && image.height) {
infoText += ` | ${image.width}×${image.height}`;
}
if (image.file_size) {
infoText += ` | ${(image.file_size / 1024 ).toFixed(2)} KB`;
}
lightboxInfo.textContent = infoText;
lightbox.style.display = 'flex';
document.body.style.overflow = 'hidden';
}
// 关闭图片查看器
function closeLightbox() {
const lightbox = document.getElementById('lightbox');
lightbox.style.display = 'none';
document.body.style.overflow = 'auto';
}
// 下一张图片
function nextImage() {
currentImageIndex = (currentImageIndex + 1) % images.length;
updateLightboxImage();
}
// 上一张图片
function prevImage() {
currentImageIndex = (currentImageIndex - 1 + images.length) % images.length;
updateLightboxImage();
}
// 更新图片查看器显示
function updateLightboxImage() {
const lightboxImg = document.getElementById('lightbox-img');
const lightboxInfo = document.getElementById('lightbox-info');
const image = images[currentImageIndex];
// 在lightbox中优先使用原始图片以获得更好的显示效果
let imageUrl = '/assets/placeholder.svg';
if (image.id) {
imageUrl = `/api/original/${image.id}`;
} else if (image.original_url) {
imageUrl = image.original_url;
}
lightboxImg.src = imageUrl;
const title = image.title || `图片 ${currentImageIndex + 1}`;
let infoText = `${title} (${currentImageIndex + 1}/${images.length})`;
if (image.width && image.height) {
infoText += ` | ${image.width}×${image.height}`;
}
if (image.file_size) {
infoText += ` | ${(image.file_size / 1024 ).toFixed(2)} KB`;
}
lightboxInfo.textContent = infoText;
}
// 键盘导航
document.addEventListener('keydown', function(event) {
const lightbox = document.getElementById('lightbox');
if (lightbox.style.display === 'flex') {
if (event.key === 'Escape') closeLightbox();
if (event.key === 'ArrowRight') nextImage();
if (event.key === 'ArrowLeft') prevImage();
}
});
// 点击背景关闭
document.getElementById('lightbox').addEventListener('click', function(event) {
if (event.target === this) closeLightbox();
});
// 显示错误信息
function showError(message) {
const container = document.getElementById('images-container');
container.innerHTML = `<div class="no-results"><i class="fas fa-exclamation-triangle"></i><br>${message}</div>`;
document.getElementById('album-title').textContent = '加载失败';
document.getElementById('album-info').innerHTML = '';
}

115
assets/js/app.js Normal file
View File

@@ -0,0 +1,115 @@
// 全局变量
let allAlbums = [];
// API基础URL
const API_BASE = '/api';
// 页面加载完成后初始化
document.addEventListener('DOMContentLoaded', function() {
loadAlbums();
});
// 加载所有相册
async function loadAlbums() {
try {
const response = await fetch(`${API_BASE}/albums`);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
allAlbums = await response.json();
displayAlbums(allAlbums);
updateStats(allAlbums);
} catch (error) {
console.error('加载相册失败:', error);
showError('加载相册数据失败,请刷新页面重试');
}
}
// 搜索相册
async function searchAlbums() {
const searchTerm = document.getElementById('search-input').value.toLowerCase().trim();
if (searchTerm === '') {
// 显示所有相册
displayAlbums(allAlbums);
updateStats(allAlbums);
return;
}
try {
const response = await fetch(`${API_BASE}/search?q=${encodeURIComponent(searchTerm)}`);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const filteredAlbums = await response.json();
displayAlbums(filteredAlbums);
if (filteredAlbums.length === 0) {
document.getElementById('stats').innerHTML = `没有找到包含 "${searchTerm}" 的相册`;
} else {
document.getElementById('stats').innerHTML = `找到 ${filteredAlbums.length} 个包含 "${searchTerm}" 的相册`;
}
} catch (error) {
console.error('搜索失败:', error);
showError('搜索失败,请重试');
}
}
// 显示相册列表
function displayAlbums(albums) {
const container = document.getElementById('albums-container');
if (albums.length === 0) {
container.innerHTML = '<div class="no-results"><i class="fas fa-search"></i><br>没有找到匹配的相册</div>';
return;
}
container.innerHTML = albums.map(album => {
// 处理封面图片URL
let coverImageUrl = '/assets/placeholder.svg';
if (album.cover_image_id) {
coverImageUrl = `/api/thumbnail/${album.cover_image_id}`;
}
// 格式化日期
const createdDate = new Date(album.created_at).toLocaleDateString('zh-CN');
return `
<div class="album-card">
<div class="album-cover">
<img src="${coverImageUrl}" alt="${album.title}" onerror="this.src='/assets/placeholder.svg'">
</div>
<div class="album-info">
<h3>${album.title}</h3>
<div class="album-meta">
<span><i class="fas fa-images"></i> ${album.image_count} 张图片</span>
<span><i class="fas fa-calendar"></i> ${createdDate}</span>
</div>
<a href="/album?id=${album.id}" class="view-button">
<i class="fas fa-eye"></i> 查看相册
</a>
</div>
</div>
`;
}).join('');
}
// 更新统计信息
function updateStats(albums) {
const totalImages = albums.reduce((sum, album) => sum + album.image_count, 0);
document.getElementById('stats').innerHTML = `共发现 ${albums.length} 个相册,包含 ${totalImages} 张图片`;
}
// 显示错误信息
function showError(message) {
const container = document.getElementById('albums-container');
container.innerHTML = `<div class="no-results"><i class="fas fa-exclamation-triangle"></i><br>${message}</div>`;
document.getElementById('stats').innerHTML = '';
}
// 键盘事件处理
function handleKeyPress(event) {
if (event.key === 'Enter') {
searchAlbums();
}
}

7
assets/placeholder.svg Normal file
View File

@@ -0,0 +1,7 @@
<svg width="400" height="300" xmlns="http://www.w3.org/2000/svg">
<rect width="100%" height="100%" fill="#f0f0f0"/>
<rect x="150" y="100" width="100" height="100" fill="#ddd" stroke="#999" stroke-width="2"/>
<line x1="150" y1="100" x2="250" y2="200" stroke="#999" stroke-width="2"/>
<line x1="250" y1="100" x2="150" y2="200" stroke="#999" stroke-width="2"/>
<text x="200" y="240" text-anchor="middle" font-family="Arial" font-size="16" fill="#666">图片加载中</text>
</svg>

After

Width:  |  Height:  |  Size: 487 B

10
config.toml Normal file
View File

@@ -0,0 +1,10 @@
[database]
url = "sqlite:data/imager.db"
[server]
host = "127.0.0.1"
port = 8080
[storage]
images_dir = "albums"
thumbnails_dir = "thumbnails"

View File

@@ -0,0 +1,33 @@
-- 创建相册表
CREATE TABLE IF NOT EXISTS albums (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
document TEXT NOT NULL,
description TEXT,
cover_image_id TEXT,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
FOREIGN KEY (cover_image_id) REFERENCES images(id)
);
-- 创建图片表
CREATE TABLE IF NOT EXISTS images (
id TEXT PRIMARY KEY,
album_id TEXT NOT NULL,
filename TEXT NOT NULL,
original_url TEXT,
title TEXT,
description TEXT,
file_size INTEGER NOT NULL,
width INTEGER NOT NULL,
height INTEGER NOT NULL,
mime_type TEXT NOT NULL,
created_at DATETIME NOT NULL,
FOREIGN KEY (album_id) REFERENCES albums(id) ON DELETE CASCADE
);
-- 创建索引以提高查询性能
CREATE INDEX IF NOT EXISTS idx_albums_created_at ON albums(created_at);
CREATE INDEX IF NOT EXISTS idx_images_album_id ON images(album_id);
CREATE INDEX IF NOT EXISTS idx_images_created_at ON images(created_at);
CREATE INDEX IF NOT EXISTS idx_albums_title ON albums(title);

41
src/config.rs Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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": "获取图片信息失败"
}))
}
}
}