momo的首次提交!

This commit is contained in:
lkhsss
2025-06-13 22:40:00 +08:00
commit 0fcae66f54
14 changed files with 2469 additions and 0 deletions

141
.github/workflows/build.yml vendored Normal file
View File

@ -0,0 +1,141 @@
name: Release
on:
push:
tags:
- "v*.*.*"
jobs:
release:
name: Publish to Github Releases
permissions:
contents: write
outputs:
rc: ${{ steps.check-tag.outputs.rc }}
strategy:
fail-fast: false
matrix:
include:
- target: aarch64-unknown-linux-musl
use-cross: true
os: ubuntu-latest
cargo-flags: ""
- target: aarch64-apple-darwin
os: macos-latest
use-cross: true
cargo-flags: ""
- target: aarch64-pc-windows-msvc
os: windows-latest
use-cross: true
cargo-flags: ""
- target: x86_64-apple-darwin
os: macos-latest
cargo-flags: ""
- target: x86_64-pc-windows-msvc
os: windows-latest
cargo-flags: ""
- target: x86_64-unknown-linux-musl
os: ubuntu-latest
use-cross: true
cargo-flags: ""
- target: i686-unknown-linux-musl
os: ubuntu-latest
use-cross: true
cargo-flags: ""
- target: i686-pc-windows-msvc
os: windows-latest
use-cross: true
cargo-flags: ""
- target: armv7-unknown-linux-musleabihf
os: ubuntu-latest
use-cross: true
cargo-flags: ""
- target: arm-unknown-linux-musleabihf
os: ubuntu-latest
use-cross: true
cargo-flags: ""
runs-on: ${{matrix.os}}
env:
BUILD_CMD: cargo
steps:
- uses: actions/checkout@v4
- name: Check Tag
id: check-tag
shell: bash
run: |
ver=${GITHUB_REF##*/}
echo "version=$ver" >> $GITHUB_OUTPUT
if [[ "$ver" =~ [0-9]+.[0-9]+.[0-9]+$ ]]; then
echo "rc=false" >> $GITHUB_OUTPUT
else
echo "rc=true" >> $GITHUB_OUTPUT
fi
- name: Install Rust Toolchain Components
uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- name: Install cross
if: matrix.use-cross
uses: taiki-e/install-action@v2
with:
tool: cross
- name: Overwrite build command env variable
if: matrix.use-cross
shell: bash
run: echo "BUILD_CMD=cross" >> $GITHUB_ENV
- name: Show Version Information (Rust, cargo, GCC)
shell: bash
run: |
gcc --version || true
rustup -V
rustup toolchain list
rustup default
cargo -V
rustc -V
- name: Build
shell: bash
run: $BUILD_CMD build --locked --release --target=${{ matrix.target }} ${{ matrix.cargo-flags }}
- name: Build Archive
shell: bash
id: package
env:
target: ${{ matrix.target }}
version: ${{ steps.check-tag.outputs.version }}
run: |
set -euxo pipefail
bin=${GITHUB_REPOSITORY##*/}
dist_dir=`pwd`/dist
name=$bin-$version-$target
executable=target/$target/release/$bin
if [[ "$RUNNER_OS" == "Windows" ]]; then
executable=$executable.exe
fi
mkdir $dist_dir
cp $executable $dist_dir
cd $dist_dir
if [[ "$RUNNER_OS" == "Windows" ]]; then
archive=$dist_dir/$name.zip
7z a $archive *
echo "archive=dist/$name.zip" >> $GITHUB_OUTPUT
else
archive=$dist_dir/$name.tar.gz
tar -czf $archive *
echo "archive=dist/$name.tar.gz" >> $GITHUB_OUTPUT
fi
- name: Publish Archive
uses: softprops/action-gh-release@v2
if: ${{ startsWith(github.ref, 'refs/tags/') }}
with:
draft: false
files: ${{ steps.package.outputs.archive }}
prerelease: ${{ steps.check-tag.outputs.rc == 'true' }}

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
\target

1285
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

26
Cargo.toml Normal file
View File

@ -0,0 +1,26 @@
[package]
name = "momo"
version = "0.1.0"
edition = "2024"
[dependencies]
axum = "0.8.4"
askama = "0.14.0"
clap = { version = "4.5.37", features = ["cargo", "derive"] }
lazy_static = "1.5.0"
serde = { version = "1.0.219", features = ["serde_derive"] }
tokio = { version = "1.44.2", features = ["macros", "rt-multi-thread"] }
tower = "0.5.2"
tower-http = { version = "0.6.2", features = ["fs","trace"] }
tracing-subscriber = "0.3.19"
url = "2.5.4"
thiserror = "2.0.12"
tracing = "0.1.41"
[profile.release]
#缩小编译后体积
strip = true
# strip = "debuginfo" #仅移除debug信息
lto = true #启用链接时间优化
panic = "abort" #panic时直接abort
opt-level = "z" #优化级别

16
README.md Normal file
View File

@ -0,0 +1,16 @@
## Momo
简单的图片浏览服务
### 使用
```bash
momo.exe [OPTIONS]
```
```bash
Options:
-d, --directory <directory> 设置工作目录
-p, --port <port> 监听的端口号 [default: 3000]
-l, --loglevel... 启用调试信息
-w, --width <WIDTH> 设置图片的宽度,该设置决定了瀑布流的宽度 [default: 350]
-h, --help Print help
-V, --version Print version
```

11
src/error.rs Normal file
View File

@ -0,0 +1,11 @@
#[derive(thiserror::Error, Debug)]
pub enum Error {
#[error("无法获取目录")]
CannotGetItemList(#[from] std::io::Error),
#[error("路径穿越")]
PathTraversal,
#[error("路径不合法")]
PathNotValid,
#[error("无法获取工作目录")]
CannotGetWorkDir
}

86
src/filetype.rs Normal file
View File

@ -0,0 +1,86 @@
use std::fmt::Display;
pub const IMAGE_FILE_EXTENSION: [&str; 16] = [
"apng", "avif", "gif", "jpg", "jpeg", "jfif", "pjpeg", "pjp", "png", "svg", "webp", "bmp",
"ico", "cur", "tif", "tiff",
];
#[derive(Debug, PartialEq, Eq)]
pub enum FileType {
Image(ImageType),
Other,
//TODO 完成其他类型
}
impl ToContentType for FileType {
fn get_content_type(&self) -> impl std::fmt::Display {
match self {
FileType::Image(image) => image.get_content_type().to_string(),
FileType::Other => "application/octet-stream".to_string(),
}
}
}
#[allow(dead_code)]
#[derive(Debug, PartialEq, Eq)]
pub enum ImageType {
Apng,
Avif,
Gif,
Jpeg,
Png,
Svg,
WebP,
Bmp,
Ico,
Tiff,
}
impl Display for ImageType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let s = match self {
ImageType::Apng => "apng",
ImageType::Avif => "avif",
ImageType::Gif => "gif",
ImageType::Jpeg => "jpeg",
ImageType::Png => "png",
ImageType::Svg => "svg",
ImageType::WebP => "webp",
ImageType::Bmp => "bmp",
ImageType::Ico => "ico",
ImageType::Tiff => "tiff",
};
write!(f, "{}", s)
}
}
impl ImageType {
//全转小写,方便比对
pub fn is_image_file(extension: &str) -> bool {
IMAGE_FILE_EXTENSION.contains(&extension.to_lowercase().as_str())
}
}
impl ToContentType for ImageType {
fn get_content_type(&self) -> impl Display {
match self {
ImageType::Apng => "image/apng",
ImageType::Avif => "image/avif",
ImageType::Gif => "image/gif",
ImageType::Jpeg => "image/jpeg",
ImageType::Png => "image/png",
ImageType::Svg => "image/svg+xml",
ImageType::WebP => "image/webp",
ImageType::Bmp => "image/bmp",
ImageType::Ico => "image/x-icon",
ImageType::Tiff => "image/tiff",
}
}
}
/// 实现此trait后可以通过结构体获取Content-Type
pub trait ToContentType {
fn get_content_type(&self) -> impl Display;
}

296
src/handlers.rs Normal file
View File

@ -0,0 +1,296 @@
use askama::Template;
use axum::{
body::Bytes,
http::{StatusCode, header},
response::{Html, IntoResponse},
};
use std::{
fmt::Display,
path::{Path, PathBuf},
};
use tokio::fs::{self};
use tracing::{info, warn};
use crate::{
CONFIG,
error::Error,
filetype::{self, FileType, IMAGE_FILE_EXTENSION, ImageType, ToContentType},
template::{ImageFallTemplate, Imgs},
};
pub async fn handler(uri: Option<axum::extract::Path<String>>) -> impl IntoResponse {
let request_path = match uri {
Some(u) => PathBuf::from(u.0),
None => PathBuf::from("/"),
};
let base_path = match std::env::current_dir() {
Ok(p) => p,
Err(e) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
format!("无法获取工作目录。\n原因:{e}"),
)
.into_response();
}
};
let uri = Uri::frompath(&request_path);
let path = match uri.0.as_str() {
//看看是否为请求网站更目录目录(即工作目录根目录)
"/" => base_path,
_ => format_path(base_path.join(&request_path)),
};
let request_uri = UriBox::new(path, uri);
match request_uri.path.try_exists() {
// 尝试判断是否存在,避免硬件错误
Ok(status) => {
//判断是否存在
if status {
//存在
if request_uri.path.is_file() {
file_handler(request_uri).await.into_response()
} else if request_uri.path.is_dir() {
dir_handler(request_uri).await.into_response()
} else {
(StatusCode::INTERNAL_SERVER_ERROR, "不是文件也不是目录").into_response()
}
} else {
//不存在
(StatusCode::NOT_FOUND, "目标不存在").into_response()
}
}
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("{:?}", e)).into_response(),
}
}
pub async fn dir_handler(dir: UriBox) -> impl IntoResponse {
//获取请求目录的列表
let items = match get_item(dir) {
Ok(o) => o,
Err(e) => match e {
Error::CannotGetItemList(error) => {
return (StatusCode::INTERNAL_SERVER_ERROR, error.to_string()).into_response();
}
Error::PathTraversal => {
return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response();
}
Error::PathNotValid => {
return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response();
}
Error::CannotGetWorkDir => {
return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response();
}
},
};
let mut imgs = Vec::new();
for i in items {
if let Some(ext) = i.path.extension() {
let ext = match ext.to_str() {
Some(e) => e,
None => panic!(),
};
if filetype::ImageType::is_image_file(ext) {
// info!("{}",i.uri.0);
imgs.push(Imgs::new(i.uri.0));
}
}
}
if imgs.len() == 0 {
return (
StatusCode::OK,
Html(include_bytes!("../templates/NoResources.html")),
)
.into_response();
}
// 返回模板数据
let html = match (ImageFallTemplate {
imgs,
column_width: CONFIG.width,
})
.render()
{
Ok(h) => h,
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, ("无法返回模板文件")).into_response(),
};
(StatusCode::OK, Html(html)).into_response()
}
/// # 读取文件并且设置CONTENT_TYPE
/// ### 需要确定文件存在!
pub async fn file_handler(file: UriBox) -> impl IntoResponse {
let extension = file.path.extension();
let header = match extension {
//如果有后缀
Some(e) => {
let extension = e.to_string_lossy().to_string().to_lowercase(); //全部转小写,避免出错
let content_type = extension_handler(extension).get_content_type().to_string();
[(header::CONTENT_TYPE, content_type)]
} //如果没后缀
None => [(
header::CONTENT_TYPE,
FileType::Other.get_content_type().to_string(),
)],
};
// 异步读取文件
match fs::read(&file.path).await {
Ok(data) => (header, Bytes::from(data)).into_response(),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("文件读取出错, 原因:{e}"),
)
.into_response(),
}
}
fn extension_handler(ext: String) -> FileType {
if IMAGE_FILE_EXTENSION.contains(&ext.to_lowercase().as_str())
//如果为图片格式
{
FileType::Image(image_extension_handler(ext))
} else {
FileType::Other
}
}
// 全部自动转小写
fn image_extension_handler(extension: impl ToString) -> ImageType {
match &extension.to_string().to_lowercase()[..] {
"apng" => ImageType::Apng,
"jpeg" | "jpg" | "jfif" | "pjpeg" | "pjp" => ImageType::Jpeg,
"png" => ImageType::Png,
"svg" => ImageType::Svg,
"webp" => ImageType::WebP,
"bmp" => ImageType::Bmp,
"ico" | "cur" => ImageType::Ico,
"tif" | "tiff" => ImageType::Tiff,
_ => panic!(),
}
}
fn format_path(ori: PathBuf) -> PathBuf {
let mut new_path = PathBuf::new();
for p in ori.components() {
new_path.push(p);
}
new_path
}
fn get_item(p: UriBox) -> Result<Vec<UriBox>, Error> {
let mut items = Vec::new();
let base_path = match std::env::current_dir() {
Ok(p) => p,
Err(_) => {
return Err(Error::CannotGetWorkDir);
}
};
for entry in std::fs::read_dir(&p.path)? {
let entry = entry?;
if entry.file_type()?.is_file() {
match reslove_relative_path(&base_path, entry.path()) {
Ok(uri) => items.push(UriBox {
path: entry.path(),
uri,
}),
Err(e) => return Err(e),
}; //计算相对路径
}
}
Ok(items)
}
#[derive(Debug, Clone)]
pub struct Uri(String);
impl Uri {
//直接转化为Uri
fn new(s: impl Into<String>) -> Self {
Self(s.into())
}
fn frompath(url: impl AsRef<Path>) -> Self {
if url.as_ref() == PathBuf::from("/") {
Self("/".into())
} else {
let mut new_url = String::new();
for i in url.as_ref().components() {
new_url.push('/');
new_url.push_str(&format!("{}", i.as_os_str().to_string_lossy()));
}
Self(new_url)
}
}
//将迭代器转为Uri
fn from_iter<T: Into<String>>(slice: impl Iterator<Item = T>) -> Self {
let mut uri = String::new();
for i in slice {
uri.push('/');
uri.push_str(&i.into());
}
Self::new(uri)
}
}
impl Display for Uri {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
//处理url和本地目录的关系
#[derive(Debug, Clone)]
pub struct UriBox {
path: PathBuf,
uri: Uri,
}
impl UriBox {
fn new(path: PathBuf, uri: Uri) -> Self {
Self { path, uri }
}
}
/// 计算相对路径
fn reslove_relative_path(
base_path: impl AsRef<Path>,
relative_path: impl AsRef<Path>,
) -> Result<Uri, Error> {
let mut base_path_components: Vec<String> = match base_path.as_ref().canonicalize() {
Ok(p) => p,
Err(_) => return Err(Error::PathNotValid),
}
.components()
.map(|x| x.as_os_str().to_string_lossy().to_string())
.collect();
let relative_path_canonicalize = match relative_path.as_ref().canonicalize() {
Ok(p) => p,
Err(_) => return Err(Error::PathNotValid),
};
let mut relative_path_components: Vec<String> = relative_path_canonicalize
.components()
.map(|x| x.as_os_str().to_string_lossy().to_string())
.collect();
base_path_components.retain(|x| x != "\\");
relative_path_components.retain(|x| x != "\\");
if relative_path_components.len() < base_path_components.len() {
//如果请求路径短于基础路径那肯定是非法的
warn!("路径穿越攻击!");
return Err(Error::PathTraversal);
}
for i in 0..(base_path_components.len()) {
if base_path_components[i] != relative_path_components[i] {
return Err(Error::PathTraversal);
}
}
let result: Vec<String> = relative_path_components
.drain((base_path_components.len())..)
.collect();
Ok(Uri::from_iter(result.iter()))
}

50
src/main.rs Normal file
View File

@ -0,0 +1,50 @@
use std::sync::LazyLock;
use axum::Router;
use axum::routing::get;
use clap::Parser;
use tower_http::trace::TraceLayer;
use tracing::info;
mod error;
mod filetype;
mod handlers;
mod myclap;
mod template;
use handlers::handler;
use myclap::Cli;
use crate::myclap::Config;
pub static CONFIG: LazyLock<Config> =
LazyLock::new(|| Config::from_parser(Cli::parse()).expect("无法读取配置"));
#[tokio::main]
async fn main() {
//日志
tracing_subscriber::fmt()
.with_max_level(CONFIG.loglevel)
.init();
info!("Log Level: {}",CONFIG.loglevel.to_string());
info!("Image Width: {}px",CONFIG.width);
info!("Working Directory: [{}]", CONFIG.directory.display());
// 设置工作目录
std::env::set_current_dir(CONFIG.directory.clone()).expect("无法设置工作目录");
// 路由
let app = Router::new()
.route("/", get(handler))
.route("/{*filename}", get(handler))
.layer(TraceLayer::new_for_http());
let listener = tokio::net::TcpListener::bind(format!("0.0.0.0:{}", CONFIG.port))
.await
.unwrap();
info!("Listen on 127.0.0.1:{}", CONFIG.port);
info!("Listen on 0.0.0.0:{}", CONFIG.port);
axum::serve(listener, app).await.unwrap();
}

54
src/myclap.rs Normal file
View File

@ -0,0 +1,54 @@
use std::path::PathBuf;
use clap::Parser;
use tracing::Level;
use crate::error::Error;
#[derive(Parser)]
#[command(version, about, long_about = None)]
pub struct Cli {
/// 设置工作目录
#[arg(short, long, value_name = "directory")]
pub directory: Option<PathBuf>,
/// 监听的端口号
#[arg(short, long, value_name = "port",default_value_t = 3000)]
pub port: u16,
/// 启用调试信息
#[arg(long,short, action = clap::ArgAction::Count)]
pub loglevel: u8,
/// 设置图片的宽度,该设置决定了瀑布流的宽度
#[arg(long,short,default_value_t = 350)]
pub width: usize,
}
pub struct Config{
pub directory:PathBuf,
pub port: u16,
pub loglevel: Level,
pub width: usize,
}
impl Config {
pub fn from_parser(cli:Cli)->Result<Self,Error>{
let port = cli.port;
let workdir = match cli.directory {
Some(d) => d,
None => match std::env::current_dir() {
Ok(d) => d,
Err(_) => return Err(Error::CannotGetWorkDir),
},
};
let loglevel = match cli.loglevel {
1 => tracing::Level::ERROR,
2 => tracing::Level::WARN,
0|3 => tracing::Level::INFO,
4 => tracing::Level::DEBUG,
5.. => tracing::Level::TRACE,
};
Ok(Self { directory: workdir, port, loglevel: loglevel, width: cli.width })
}
}

18
src/template.rs Normal file
View File

@ -0,0 +1,18 @@
use askama::Template;
#[derive(Template)]
#[template(path = "index.2.html")]
pub struct ImageFallTemplate {
pub imgs: Vec<Imgs>,
pub column_width:usize,
}
pub struct Imgs {
path: String,
}
impl Imgs {
pub fn new(path: String) -> Self {
Self { path }
}
}

View File

@ -0,0 +1,19 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>No Resources</title>
<style>
h1{
text-align: center;
}
</style>
</head>
<body>
<h1>
No Resources We Can Show You
</h1>
</body>
</html>

263
templates/index.1.html Normal file
View File

@ -0,0 +1,263 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>动态获取图片尺寸的瀑布流</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: Arial, sans-serif;
padding: 20px;
background-color: #f5f5f5;
}
.waterfall-container {
position: relative;
margin: 0 auto;
min-height: 100vh;
}
.waterfall-item {
position: absolute;
width: calc(100% / var(--columns, 4) - 10px);
margin: 5px;
transition: all 0.3s ease;
background-color: #fff;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
opacity: 0;
animation: fadeIn 0.5s forwards;
}
@keyframes fadeIn {
to {
opacity: 1;
}
}
.waterfall-item img {
width: 100%;
display: block;
transition: opacity 0.3s ease;
}
.waterfall-item .img-placeholder {
width: 100%;
background-color: #eee;
display: flex;
align-items: center;
justify-content: center;
color: #999;
}
.waterfall-item .content {
padding: 10px;
}
.loading {
text-align: center;
padding: 20px;
font-size: 18px;
color: #666;
}
img {
transition: all 0.5s;
}
img:hover {
transform: scale(1.1);
/*图片放大1.1倍*/
margin-top: 30px;
/*鼠标悬停时上移30px*/
/* margin-top: 0px;和hover的margin-top有对比原无30,现在0相当于上移了,30px */
box-shadow: 0 0 20px 2px #918f8f;
/*盒子阴影*/
transition: all 0.5s;
/*持续时间*/
}
</style>
</head>
<body>
<div class="waterfall-container" id="waterfall">
<div class="loading">加载中,请稍候...</div>
<!-- 示例结构 - 实际使用时可以动态生成 -->
{% for img in imgs %}
<div class="waterfall-item">
<div class="img-placeholder">
<img data-src={{img.path}}>
</div>
</div>
{% endfor %}
</div>
<script>
document.addEventListener('DOMContentLoaded', function () {
const waterfallContainer = document.getElementById('waterfall');
let columnHeights = [];
let columnCount = 0;
let columnWidth = 0;
const gap = 10;
// 初始化瀑布流
async function initWaterfall() {
// 隐藏所有项目
const items = document.querySelectorAll('.waterfall-item');
items.forEach(item => item.style.display = 'none');
// 移除加载提示
const loading = document.querySelector('.loading');
if (loading) loading.remove();
// 计算列数
calculateColumns();
// 预加载所有图片并获取尺寸
await preloadImages();
// 定位所有项目
positionAllItems();
// 显示所有项目
items.forEach(item => item.style.display = 'block');
// 初始化懒加载
lazyLoadImages();
}
// 预加载图片并获取尺寸
function preloadImages() {
const images = document.querySelectorAll('.waterfall-item img[data-src]');
const promises = [];
images.forEach(img => {
const promise = new Promise((resolve) => {
const tempImg = new Image();
tempImg.src = img.dataset.src;
tempImg.onload = function () {
// 保存自然尺寸到数据集
img.dataset.naturalWidth = this.naturalWidth;
img.dataset.naturalHeight = this.naturalHeight;
resolve();
};
tempImg.onerror = function () {
// 加载失败时使用默认尺寸
img.dataset.naturalWidth = 800;
img.dataset.naturalHeight = 600;
resolve();
};
});
promises.push(promise);
});
return Promise.all(promises);
}
// 计算列数和列宽
function calculateColumns() {
const containerWidth = waterfallContainer.offsetWidth;
const minColumnWidth = 250;
columnCount = Math.max(1, Math.floor(containerWidth / minColumnWidth));
columnWidth = containerWidth / columnCount;
waterfallContainer.style.setProperty('--columns', columnCount);
columnHeights = new Array(columnCount).fill(0);
}
// 定位所有项目
function positionAllItems() {
columnHeights = new Array(columnCount).fill(0);
const items = document.querySelectorAll('.waterfall-item');
items.forEach(item => {
positionItem(item);
});
waterfallContainer.style.height = Math.max(...columnHeights) + 'px';
}
// 定位单个项目
function positionItem(itemElement) {
const img = itemElement.querySelector('img');
const width = parseInt(img.dataset.naturalWidth) || 800;
const height = parseInt(img.dataset.naturalHeight) || 600;
const aspectRatio = height / width;
const minHeight = Math.min(...columnHeights);
const columnIndex = columnHeights.indexOf(minHeight);
const left = columnIndex * columnWidth;
const top = minHeight;
const itemHeight = columnWidth * aspectRatio + 50; // 50是内容区域高度
columnHeights[columnIndex] += itemHeight + gap;
itemElement.style.left = `${left}px`;
itemElement.style.top = `${top}px`;
const placeholder = itemElement.querySelector('.img-placeholder');
placeholder.style.paddingBottom = `${aspectRatio * 100}%`;
}
// 懒加载图片(实际显示)
function lazyLoadImages() {
const lazyImages = document.querySelectorAll('.waterfall-item img[data-src]');
const observer = new IntersectionObserver((entries, observer) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const img = entry.target;
img.src = img.dataset.src;
img.onload = () => {
img.style.opacity = '1';
img.removeAttribute('data-src');
};
observer.unobserve(img);
}
});
}, {
rootMargin: '0px 0px 200px 0px'
});
lazyImages.forEach(img => observer.observe(img));
}
// 窗口大小改变时重新计算
let resizeTimer;
window.addEventListener('resize', () => {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(() => {
calculateColumns();
positionAllItems();
}, 200);
});
// 初始化
initWaterfall();
// 模拟滚动加载更多数据
window.addEventListener('scroll', () => {
if ((window.innerHeight + window.scrollY) >= document.body.offsetHeight - 500) {
console.log('加载更多数据...');
// 这里可以添加加载更多数据的逻辑
// 加载后需要再次调用 preloadImages() 和 positionAllItems()
}
});
});
</script>
</body>
</html>

203
templates/index.2.html Normal file
View File

@ -0,0 +1,203 @@
<!DOCTYPE html>
<html lang="zh-hans">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Momo</title>
<style>
.waterfall-container {
position: relative;
width: 100%;
}
.waterfall-item {
position: absolute;
width: calc(33.33% - 20px);
/* 三列布局,考虑间距 */
margin: 10px;
transition: all 0.3s ease;
}
.waterfall-item img {
width: 100%;
height: auto;
display: block;
border-radius: 4px;
}
/* 懒加载时的占位样式 */
.lazy {
background-color: #f5f5f5;
min-height: 200px;
/* 根据你的设计调整 */
}
img {
transition: all 0.5s;
}
img:hover {
transform: scale(1.3);
/*图片放大1.1倍*/
margin-top: 30px;
/*鼠标悬停时上移30px*/
margin-top: 0px;
box-shadow: 0 0 20px 2px #918f8f;
/*盒子阴影*/
transition: all 0.5s;
/*持续时间*/
}
.waterfall-item.loading {
opacity: 0.8;
}
/* 响应式调整 */
@media (min-width: 1200px) {
.waterfall-item {
width: calc(25% - 20px);
}
}
@media (max-width: 768px) {
.waterfall-item {
width: calc(50% - 200px);
/* 两列布局 */
}
}
@media (max-width: 480px) {
.waterfall-item {
width: calc(100% - 20px);
/* 单列布局 */
}
}
</style>
</head>
<body>
<div class="waterfall-container">
{% for img in imgs %}
<div class="waterfall-item">
<img class="lazy" data-src="{{img.path}}" alt="">
</div>
{% endfor %}
</div>
</body>
<script>
const column_width = {{column_width}};
function get_column_count(width) {
return parseInt(width/column_width)
}
// 使用防抖函数优化性能
function debounce(func, wait) {
let timeout;
return function () {
const context = this, args = arguments;
clearTimeout(timeout);
timeout = setTimeout(() => {
func.apply(context, args);
}, wait);
};
}
function layoutWaterfall() {
const container = document.querySelector('.waterfall-container');
const items = document.querySelectorAll('.waterfall-item:not(.loading)'); // 排除正在加载的项
const columnCount = get_column_count(window.innerWidth); // 响应式列数
// const columnCount = window.innerWidth < 768 ? 2 : 3; // 响应式列数
const gap = 20;
const containerWidth = container.offsetWidth;
const columnWidth = (containerWidth - gap * (columnCount - 1)) / columnCount;
const columnHeights = new Array(columnCount).fill(0);
items.forEach(item => {
item.style.width = `${columnWidth}px`;
const minHeight = Math.min(...columnHeights);
const columnIndex = columnHeights.indexOf(minHeight);
const left = columnIndex * (columnWidth + gap);
const top = minHeight;
item.style.left = `${left}px`;
item.style.top = `${top}px`;
columnHeights[columnIndex] += item.offsetHeight + gap;
});
container.style.height = `${Math.max(...columnHeights)}px`;
}
// 增强版的懒加载函数
function lazyLoadImages() {
const lazyImages = document.querySelectorAll('img.lazy');
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const img = entry.target;
const item = img.closest('.waterfall-item');
// 标记为正在加载
item.classList.add('loading');
// 创建新的Image对象预加载
const tempImg = new Image();
tempImg.src = img.dataset.src;
tempImg.onload = function () {
img.src = img.dataset.src;
img.classList.remove('lazy');
item.classList.remove('loading');
// 图片加载完成后重新布局
layoutWaterfall();
};
tempImg.onerror = function () {
item.classList.remove('loading');
// 可以在这里添加错误处理
};
observer.unobserve(img);
}
});
}, {
rootMargin: '200px 0px' // 提前200px开始加载
});
lazyImages.forEach(img => {
observer.observe(img);
});
}
// 初始化并添加事件监听
function initWaterfall() {
layoutWaterfall();
lazyLoadImages();
// 使用防抖函数优化resize性能
window.addEventListener('resize', debounce(() => {
layoutWaterfall();
}, 200));
// 监听DOM变化如果有动态加载的内容
const observer = new MutationObserver(debounce(() => {
layoutWaterfall();
lazyLoadImages();
}, 100));
observer.observe(document.body, {
subtree: true,
childList: true,
attributes: true
});
}
document.addEventListener('DOMContentLoaded', initWaterfall);
</script>
</html>