init
This commit is contained in:
3522
Cargo.lock
generated
Normal file
3522
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
17
Cargo.toml
Normal file
17
Cargo.toml
Normal 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
43
assets/album.html
Normal 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()">×</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
212
assets/css/album.css
Normal 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
228
assets/css/style.css
Normal 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
37
assets/index.html
Normal 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
182
assets/js/album.js
Normal 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
115
assets/js/app.js
Normal 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
7
assets/placeholder.svg
Normal 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
10
config.toml
Normal 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"
|
||||
33
migrations/001_initial.sql
Normal file
33
migrations/001_initial.sql
Normal 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
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