momo的首次提交!
This commit is contained in:
141
.github/workflows/build.yml
vendored
Normal file
141
.github/workflows/build.yml
vendored
Normal 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
1
.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
\target
|
||||||
1285
Cargo.lock
generated
Normal file
1285
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
26
Cargo.toml
Normal file
26
Cargo.toml
Normal 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
16
README.md
Normal 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
11
src/error.rs
Normal 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
86
src/filetype.rs
Normal 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
296
src/handlers.rs
Normal 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
50
src/main.rs
Normal 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
54
src/myclap.rs
Normal 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
18
src/template.rs
Normal 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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
19
templates/NoResources.html
Normal file
19
templates/NoResources.html
Normal 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
263
templates/index.1.html
Normal 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
203
templates/index.2.html
Normal 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>
|
||||||
Reference in New Issue
Block a user