添加查看系统

This commit is contained in:
wangli 2025-12-21 14:24:38 +08:00
commit b8771c7156
16 changed files with 1307 additions and 0 deletions

34
.gitignore vendored Normal file
View File

@ -0,0 +1,34 @@
HELP.md
target/
.mvn/wrapper/maven-wrapper.jar
!**/src/main/**/target/
!**/src/test/**/target/
### STS ###
.apt_generated
.classpath
.factorypath
.project
.settings
.springBeans
.sts4-cache
### IntelliJ IDEA ###
.idea
*.iws
*.iml
*.ipr
### NetBeans ###
/nbproject/private/
/nbbuild/
/dist/
/nbdist/
/.nb-gradle/
build/
!**/src/main/**/build/
!**/src/test/**/build/
### VS Code ###
.vscode/

15
Dockerfile Normal file
View File

@ -0,0 +1,15 @@
# 1. 使用轻量级的 JDK 17 运行环境作为基础镜像
FROM eclipse-temurin:17-jdk-focal
# 2. 设置工作目录
WORKDIR /app
# 3. 将打包好的 jar 包拷贝到镜像中
# 注意:这里假设您的 jar 包生成在 target 目录下,名称匹配项目名
COPY target/*.jar app.jar
# 4. 暴露我们在 application.yml 中配置的 8080 端口
EXPOSE 8080
# 5. 设置启动参数,解决容器内中文乱码并启动应用
ENTRYPOINT ["java", "-Dfile.encoding=UTF-8", "-jar", "app.jar"]

9
HELP.md Normal file
View File

@ -0,0 +1,9 @@
docker run -d -p 8080:8080 -e FILE_PATH=/mnt/images --name my-gallery -v /opt/1panel/apps/screen:/mnt/images wangli648438/private-gallery:v1.0
关键配置说明:
端口映射:-p 8080:8080 将容器内部端口映射到主机。
挂载卷:-v /your/local/images:/Users/name/Downloads/image/ 非常关键。因为镜像内部不存储图片,您必须将物理机的图片目录挂载到 application.yml 中定义的 file.path 路径下。
应用环境变量: -e FILE_PATH 图片文件夹根目录 APP_SECURE_PASSWORD 系统登录密码 APP_SECURE_AUTH 系统生成授权码API密钥 APP_SECURE_KEY 系统生成授权码的 AES KEY

67
pom.xml Normal file
View File

@ -0,0 +1,67 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.3.1</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>eu.org.biwin</groupId>
<artifactId>screen</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>Screen</name>
<description>Screen</description>
<url/>
<licenses>
<license/>
</licenses>
<developers>
<developer/>
</developers>
<scm>
<connection/>
<developerConnection/>
<tag/>
<url/>
</scm>
<properties>
<java.version>17</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<dependency>
<groupId>cn.dev33</groupId>
<artifactId>sa-token-spring-boot3-starter</artifactId>
<version>1.37.0</version>
</dependency>
<dependency>
<groupId>net.coobird</groupId>
<artifactId>thumbnailator</artifactId>
<version>0.4.20</version>
</dependency>
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>5.8.26</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>

View File

@ -0,0 +1,13 @@
package eu.org.biwin.screen;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class ScreenApplication {
public static void main(String[] args) {
SpringApplication.run(ScreenApplication.class, args);
}
}

View File

@ -0,0 +1,42 @@
package eu.org.biwin.screen.advice;
import cn.dev33.satoken.exception.NotLoginException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
@ControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(NotLoginException.class)
public Object handlerNotLoginException(NotLoginException nle, HttpServletRequest request, HttpServletResponse response) throws IOException {
String uri = request.getRequestURI();
// 针对所有资源请求强制返回 401 状态码
if (uri.startsWith("/raw/") || uri.startsWith("/thumb/") || uri.startsWith("/api/")) {
// 关键显式通过 response 设置状态码确保 header 先行
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
Map<String, Object> map = new HashMap<>();
map.put("code", 401);
map.put("msg", "Session Expired");
return new ResponseEntity<>(map, HttpStatus.UNAUTHORIZED);
}
// 页面跳转请求
return "redirect:/login";
}
@ExceptionHandler(Exception.class)
public ResponseEntity<String> handleException(Exception e) {
e.printStackTrace();
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("服务器内部错误: " + e.getMessage());
}
}

View File

@ -0,0 +1,31 @@
package eu.org.biwin.screen.advice;
import cn.dev33.satoken.interceptor.SaInterceptor;
import cn.dev33.satoken.router.SaRouter;
import cn.dev33.satoken.stp.StpUtil;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
/**
* SA-TOKEN API 权限处理器
*
* @author wangli
* @since 2025-12-20 20:32
*/
@Configuration
public class SaTokenConfigure implements WebMvcConfigurer {
// 注册 Sa-Token 拦截器
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new SaInterceptor(handle -> {
// 指定拦截路由
SaRouter.match("/raw/**", r -> StpUtil.checkLogin());
SaRouter.match("/api/**")
.notMatch("/api/auth/getLoginCode")
.check(r -> StpUtil.checkLogin());
SaRouter.match("/", r -> StpUtil.checkLogin());
})).addPathPatterns("/**")
.excludePathPatterns("/login", "/doLogin", "/static/**", "/favicon.ico"); // 排除登录相关和静态资源
}
}

View File

@ -0,0 +1,104 @@
package eu.org.biwin.screen.controller;
import cn.dev33.satoken.stp.StpUtil;
import cn.hutool.core.io.FileUtil;
import cn.hutool.crypto.SecureUtil;
import cn.hutool.crypto.symmetric.AES;
import cn.hutool.json.JSONUtil;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.ResponseEntity;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.io.File;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.stream.Collectors;
/**
* 生成授权码的控制器
*
* @author wangli
* @since 2025-12-20 23:03
*/
@RestController
@RequestMapping("/api/auth")
public class AuthController {
@Value("${app.secure-auth}")
private String secureAuth;
@Value("${app.secure-key}")
private String secureKey;
@Value("${file.path}")
private String rootPath; // 借用图片根目录存放记录文件
@Value("${file.auth-code-file}")
private String authCodeFile;
/**
* 获取动态登录码 (无需登录)
* 生成一个包含过期时间的加密字符串
*/
@GetMapping("/getLoginCode")
public String getLoginCode(String auth) {
if (StringUtils.hasText(auth) && Objects.equals(auth, secureAuth)) {
// 1. 设置 12 小时后的过期时间戳
long expireTime = System.currentTimeMillis() + (12 * 60 * 60 * 1000);
// 2. 创建 AES 实例Hutool 会自动根据 16 字节 key 选择 AES-128
AES aes = SecureUtil.aes(secureKey.getBytes(StandardCharsets.UTF_8));
// 3. 加密时间戳并返回
String loginCode = aes.encryptHex(String.valueOf(expireTime));
File file = new File(rootPath, authCodeFile);
FileUtil.appendUtf8Lines(List.of(loginCode + ",1"), file);
return loginCode;
}
Map<String, String> result = new HashMap<>();
result.put("code", "400");
result.put("msg", "illegal access");
return JSONUtil.toJsonStr(result);
}
/**
* 手动失效接口传入授权码将其状态改为 0
*/
@GetMapping("/revokeCode")
public String revokeCode(@RequestParam String code) {
File file = new File(rootPath, authCodeFile);
if (!file.exists()) return "记录文件不存在";
List<String> lines = FileUtil.readUtf8Lines(file);
List<String> newLines = lines.stream().map(line -> {
if (line.startsWith(code)) {
return code + ",0"; // 标记为失效
}
return line;
}).collect(Collectors.toList());
FileUtil.writeUtf8Lines(newLines, file);
return "该授权码已手动失效";
}
/**
* 轻量级登录状态检查接口
*
* @return 200 OK if logged in, 401 Unauthorized otherwise.
*/
@GetMapping("/checkLogin")
public ResponseEntity<Void> checkLogin() {
if (StpUtil.isLogin()) {
return ResponseEntity.ok().build();
} else {
return ResponseEntity.status(401).build();
}
}
}

View File

@ -0,0 +1,239 @@
package eu.org.biwin.screen.controller;
import cn.dev33.satoken.stp.StpUtil;
import cn.hutool.core.io.FileUtil;
import cn.hutool.crypto.SecureUtil;
import cn.hutool.crypto.symmetric.AES;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseBody;
import java.io.File;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* 主控制器包含登录图片加载等功能
*
* @author wangli
* @since 2025-12-20 19:57
*/
@Controller
public class GalleryController {
/**
* A robust, natural-order comparator that mimics modern file managers (like macOS Finder).
* This is essential because `File.listFiles()` provides no order guarantee,
* and an unstable sort leads to random-seeming results on each refresh.
* This comparator fixes that by enforcing a stable and predictable sort order.
* <p>
* Sorting Rules:
* 1. Directories are always sorted before files.
* 2. Filenames are chunked into numbers ("num") and text ("str").
* 3. Number chunks are compared numerically (2 before 10).
* 4. Text chunks are compared case-insensitively.
* 5. The sort is stable, preventing random reordering on refresh.
*/
private static final Comparator<File> FILE_MANAGER_COMPARATOR = (f1, f2) -> {
// Rule 1: Directories first
if (f1.isDirectory() && !f2.isDirectory()) return -1;
if (!f1.isDirectory() && f2.isDirectory()) return 1;
String s1 = f1.getName();
String s2 = f2.getName();
Pattern pattern = Pattern.compile("(?<num>\\d+)|(?<str>\\D+)");
Matcher m1 = pattern.matcher(s1);
Matcher m2 = pattern.matcher(s2);
while (m1.find() && m2.find()) {
boolean isNum1 = m1.group("num") != null;
boolean isNum2 = m2.group("num") != null;
// Case 1: Both chunks are numbers
if (isNum1 && isNum2) {
String numStr1 = m1.group("num");
String numStr2 = m2.group("num");
try {
long val1 = Long.parseLong(numStr1);
long val2 = Long.parseLong(numStr2);
if (val1 != val2) {
return Long.compare(val1, val2);
}
// If values are equal (e.g., 1 and 01), the one with more leading zeros comes later.
int lenCompare = numStr1.length() - numStr2.length();
if (lenCompare != 0) {
return lenCompare;
}
continue; // Identical numbers, move to next chunk
} catch (NumberFormatException e) {
// Fallback to text compare for extremely large numbers
}
}
// Case 2: One is a number, the other is text. Number comes first.
if (isNum1) return -1;
if (isNum2) return 1;
// Case 3: Both are text. Compare case-insensitively.
int res = m1.group("str").compareToIgnoreCase(m2.group("str"));
if (res != 0) {
return res;
}
}
// If one name is a prefix of the other, the shorter one comes first.
if (m1.find()) return 1; // s1 is longer
if (m2.find()) return -1; // s2 is longer
return 0;
};
@Value("${file.path}")
private String rootPath;
@Value("${file.auth-code-file}")
private String authCodeFile;
@Value("${app.secure-password}")
private String securePassword;
@Value("${app.secure-key}")
private String secureKey;
// 登录页面
@GetMapping("/login")
public String loginPage() {
return "login";
}
// 登录认证逻辑
@PostMapping("/doLogin")
@ResponseBody
public String doLogin(String password) {
// 1. 首先检查是否匹配固定密码
if (securePassword.equals(password)) {
StpUtil.login("admin"); // 登录成功
return "ok";
}
// 2. 如果不是固定密码再尝试作为动态码解密
try {
AES aes = SecureUtil.aes(secureKey.getBytes(StandardCharsets.UTF_8));
String decrypted = aes.decryptStr(password);
long expireTime = Long.parseLong(decrypted);
// 3. 检查时间是否超过 12 小时
if (System.currentTimeMillis() > expireTime) {
return "该登录码已过期";
}
// 4. 状态文件校验 (控制手动失效)
File file = new File(rootPath, authCodeFile);
if (file.exists()) {
List<String> lines = FileUtil.readUtf8Lines(file);
boolean isValid = lines.stream().anyMatch(line -> line.equals(password + ",1"));
if (!isValid) {
return "该授权码已被管理员作废";
}
}
// 5. 验证通过执行 Sa-Token 登录 (维持 30 分钟会话)
StpUtil.login("admin");
return "ok";
} catch (Exception e) {
// 解密失败或格式错误说明是无效的授权码
return "无效的授权码或密码";
}
}
// 1. 仅负责跳转到页面
@GetMapping("/")
public String index() {
return "index";
}
private boolean isImage(String name) {
String n = name.toLowerCase();
return n.endsWith(".jpg") || n.endsWith(".jpeg") || n.endsWith(".png") || n.endsWith(".gif");
}
// GalleryController 或单独的类中添加
@GetMapping("/raw/**") // 我们统一用 /raw 作为原图前缀
public void getRawImage(HttpServletRequest request, HttpServletResponse response) throws IOException {
// 获取 /raw/ 之后的所有路径
String servletPath = request.getServletPath();
String relativePath = servletPath.substring(5); // 去掉 "/raw/"
File file = new File(rootPath, relativePath);
if (!file.exists()) {
response.sendError(404);
return;
}
// 设置内容类型简单处理
String contentType = Files.probeContentType(file.toPath());
response.setContentType(contentType != null ? contentType : "image/jpeg");
// 输出文件流
Files.copy(file.toPath(), response.getOutputStream());
}
@GetMapping("/api/images/list")
@ResponseBody
public Map<String, Object> getLevelContent(
@RequestParam(defaultValue = "") String path,
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "20") int size) {
File currentFolder = new File(rootPath, path);
List<Map<String, String>> folders = new ArrayList<>();
List<Map<String, String>> images = new ArrayList<>();
File[] files = currentFolder.listFiles();
if (files != null) {
Arrays.sort(files, GalleryController.FILE_MANAGER_COMPARATOR);
for (File f : files) {
if (f.isDirectory()) {
folders.add(Map.of("name", f.getName(), "path", path + "/" + f.getName()));
} else if (isImage(f.getName())) {
String relPath = f.getAbsolutePath().substring(new File(rootPath).getAbsolutePath().length());
images.add(Map.of("name", f.getName(), "url", relPath.replace("\\", "/")));
}
}
}
// The crucial fix: Sort the collected images list one more time before pagination.
images.sort(Comparator.comparing(m -> m.get("name"), (s1, s2) -> {
// This re-uses the core logic of the file manager comparator on the filenames.
File f1 = new File(s1);
File f2 = new File(s2);
return FILE_MANAGER_COMPARATOR.compare(f1, f2);
}));
// Manually paginate the sorted images
int start = (page - 1) * size;
List<Map<String, String>> pagedImages = new ArrayList<>();
if (start < images.size()) {
int end = Math.min(start + size, images.size());
pagedImages = images.subList(start, end);
}
return Map.of(
"folders", folders, // Folders are already sorted from the file array sort.
"images", pagedImages,
"hasMore", start + size < images.size()
);
}
}

View File

@ -0,0 +1,62 @@
package eu.org.biwin.screen.controller;
import cn.dev33.satoken.stp.StpUtil;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import net.coobird.thumbnailator.Thumbnails;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
/**
* 缩略图控制器
*
* @author wangli
* @since 2025-12-20 20:01
*/
@RestController
@RequestMapping("/thumb")
public class ThumbnailController {
@Value("${file.path}")
private String rootPath;
// 定义缩略图存放的临时目录
private final String thumbCachePath = System.getProperty("user.dir") + "/thumb_cache/";
@GetMapping("/**")
public void getThumbnail(HttpServletRequest request, HttpServletResponse response) throws IOException {
StpUtil.checkLogin(); // 同样需要权限校验
// 1. 获取请求的相对路径
String servletPath = request.getServletPath();
String relativePath = servletPath.substring(7); // 去掉 "/thumb/"
File originFile = new File(rootPath, relativePath);
if (!originFile.exists()) {
response.sendError(404);
return;
}
// 2. 确定缩略图缓存文件路径
File thumbFile = new File(thumbCachePath, relativePath + "_thumb.jpg");
// 3. 如果缓存不存在则生成
if (!thumbFile.exists()) {
thumbFile.getParentFile().mkdirs();
Thumbnails.of(originFile)
.size(300, 300) // 设置最大宽高
.outputQuality(0.8) // 压缩质量
.toFile(thumbFile);
}
// 4. 将缩略图流式输出给浏览器
response.setContentType("image/jpeg");
Files.copy(thumbFile.toPath(), response.getOutputStream());
}
}

View File

@ -0,0 +1,35 @@
package eu.org.biwin.screen.model;
import java.util.List;
/**
* TODO
*
* @author wangli
* @since 2025-12-20 21:26
*/
public class CategoryDTO {
private String name;
private List<ImageInfo> images;
public CategoryDTO(String name, List<ImageInfo> images) {
this.name = name;
this.images = images;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public List<ImageInfo> getImages() {
return images;
}
public void setImages(List<ImageInfo> images) {
this.images = images;
}
}

View File

@ -0,0 +1,34 @@
package eu.org.biwin.screen.model;
/**
* TODO
*
* @author wangli
* @since 2025-12-20 21:26
*/
public class ImageInfo {
private String name; // 图片文件名
private String url; // 图片相对路径
public ImageInfo(String name, String url) {
this.name = name;
this.url = url;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getUrl() {
return url;
}
public void setUrl(String url) {
this.url = url;
}
}

View File

@ -0,0 +1,25 @@
server:
port: 8080
# ?????????
file:
path: "/Users/wangli/Downloads/image/" # ??????????????
auth-code-file: "authorized_codes.txt" # ?????
app:
# ???????
secure-password: "WANG+li648438"
# ?????? 12 ????????????
secure-auth: "1q2w3e4r"
# ??????? key
secure-key: "WANG+li648438745"
spring:
mvc:
static-path-pattern: /**
web:
resources:
static-locations: classpath:/static/, file:${file.path}
# Sa-Token ????? Token ???? 30 ??
sa-token:
timeout: 1800 # 30 * 60 ?
is-print: false

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

View File

@ -0,0 +1,320 @@
<!DOCTYPE html>
<html>
<head>
<link rel="icon" type="image/x-icon" href="/favicon.ico">
<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/viewerjs/1.11.6/viewer.min.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/viewerjs/1.11.6/viewer.min.js"></script>
<style>
:root {
--primary-color: #007bff;
--bg-color: #f0f2f5;
--glass-bg: rgba(255, 255, 255, 0.7);
--card-shadow: 0 8px 32px 0 rgba(31, 38, 135, 0.07);
}
body {
background-color: var(--bg-color);
margin: 0;
padding: 0;
font-family: 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
color: #333;
overflow-x: hidden;
}
#bg-canvas {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: -1;
background: linear-gradient(to bottom, #f0f2f5, #e0e4e9);
}
.main-content {
position: relative;
z-index: 1;
padding: 30px 5%;
max-width: 1600px;
margin: 0 auto;
}
.breadcrumb {
padding: 15px 25px;
background: var(--glass-bg);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
margin-bottom: 30px;
border-radius: 15px;
box-shadow: var(--card-shadow);
border: 1px solid rgba(255, 255, 255, 0.18);
font-size: 14px;
}
.breadcrumb a {
color: var(--primary-color);
text-decoration: none;
font-weight: 500;
transition: color 0.3s;
}
.breadcrumb a:hover { color: #0056b3; }
.breadcrumb span { margin: 0 10px; color: #abb2bf; }
.folder-container {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 20px;
margin-bottom: 40px;
}
.folder-card {
background: var(--glass-bg);
backdrop-filter: blur(8px);
padding: 20px;
border-radius: 18px;
text-align: center;
cursor: pointer;
box-shadow: var(--card-shadow);
border: 1px solid rgba(255, 255, 255, 0.4);
transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
}
.folder-card:hover {
transform: translateY(-5px);
background: rgba(255, 255, 255, 0.9);
box-shadow: 0 12px 40px rgba(0,0,0,0.1);
}
.folder-icon { font-size: 48px; margin-bottom: 12px; display: block; filter: drop-shadow(0 4px 6px rgba(0,0,0,0.1)); }
.folder-name { font-size: 14px; font-weight: 600; color: #444; }
/* Modern CSS Grid for a responsive, auto-filling gallery */
.grid {
display: grid;
/* This creates as many columns as can fit, with a minimum width of 280px,
and distributes extra space among them. It's fully responsive. */
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 20px;
}
.grid-item {
/* The width is now automatically handled by the grid container */
border-radius: 15px;
overflow: hidden;
background: #fff;
box-shadow: var(--card-shadow);
border: 1px solid rgba(255, 255, 255, 0.3);
transition: transform 0.3s;
}
.grid-item:hover { transform: scale(1.02); z-index: 10; }
.grid-item img {
width: 100%;
height: auto; /* Maintain aspect ratio */
display: block;
cursor: zoom-in;
}
.image-info {
padding: 12px;
font-size: 12px;
color: #666;
text-align: center;
background: #fff;
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.loading-status { text-align: center; padding: 40px; color: #999; font-size: 14px; }
</style>
</head>
<body>
<canvas id="bg-canvas"></canvas>
<div class="main-content">
<div class="breadcrumb" id="breadcrumb"></div>
<div class="folder-container" id="folder-list"></div>
<div class="grid" id="image-grid"></div>
<div id="loader" class="loading-status">正在探索云端内容...</div>
</div>
<script>
// --- 粒子背景系统 ---
const canvas = document.getElementById('bg-canvas');
const ctx = canvas.getContext('2d');
let particles = [];
function initCanvas() {
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
particles = [];
for (let i = 0; i < 60; i++) {
particles.push({
x: Math.random() * canvas.width,
y: Math.random() * canvas.height,
radius: Math.random() * 2 + 1,
vx: Math.random() * 0.5 - 0.25,
vy: Math.random() * 0.5 - 0.25,
opacity: Math.random() * 0.5
});
}
}
function drawParticles() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
particles.forEach(p => {
p.x += p.vx;
p.y += p.vy;
if (p.x < 0 || p.x > canvas.width) p.vx *= -1;
if (p.y < 0 || p.y > canvas.height) p.vy *= -1;
ctx.beginPath();
ctx.arc(p.x, p.y, p.radius, 0, Math.PI * 2);
ctx.fillStyle = `rgba(0, 123, 255, ${p.opacity})`;
ctx.fill();
});
requestAnimationFrame(drawParticles);
}
window.addEventListener('resize', initCanvas);
initCanvas();
drawParticles();
// --- 业务逻辑 ---
let currentPath = "";
let page = 1;
const size = 20;
let loading = false;
let hasMore = true;
let viewer = null;
window.isRedirecting = false;
window.addEventListener('error', (e) => {
if (e.target.tagName === 'IMG' && (e.target.src.includes('/raw/') || e.target.src.includes('/thumb/'))) {
checkAuth();
}
}, true);
function checkAuth() {
if (window.isRedirecting) return;
fetch('/api/auth/checkLogin', {method: 'HEAD'}).then(res => {
if (res.status === 401) {
window.isRedirecting = true;
alert("登录已失效,请重新登录");
window.location.href = "/login";
}
});
}
async function loadLevel(path, isNextPage = false) {
if (loading || (isNextPage && !hasMore)) return;
loading = true;
const loader = document.getElementById('loader');
loader.style.display = 'block';
if (!isNextPage) {
currentPath = path;
page = 1;
hasMore = true;
document.getElementById('folder-list').innerHTML = "";
document.getElementById('image-grid').innerHTML = "";
}
try {
const url = `/api/images/list?path=${encodeURIComponent(path)}&page=${page}&size=${size}`;
const res = await fetch(url);
if (res.status === 401) { checkAuth(); return; }
const data = await res.json();
hasMore = data.hasMore;
if (!isNextPage) {
renderBreadcrumb(path);
renderFolders(data.folders);
}
renderImages(data.images);
page++;
loader.innerText = hasMore ? "" : "—— 已经到底了 ——";
} catch (err) {
loader.innerText = "加载失败,请检查连接";
} finally {
loading = false;
}
}
function renderBreadcrumb(path) {
const bc = document.getElementById('breadcrumb');
bc.innerHTML = `<a onclick="loadLevel('')">🏠 根目录</a>`;
let parts = path.split('/').filter(p => p);
let fullPath = "";
parts.forEach(p => {
fullPath += "/" + p;
bc.innerHTML += `<span>/</span><a onclick="loadLevel('${fullPath.replace(/'/g, "\\'")}')">${p}</a>`;
});
}
function renderFolders(folders) {
const list = document.getElementById('folder-list');
folders.forEach(f => {
const card = document.createElement('div');
card.className = 'folder-card';
card.onclick = () => loadLevel(f.path);
card.innerHTML = `<span class="folder-icon">📂</span><div class="folder-name">${f.name}</div>`;
list.appendChild(card);
});
}
function renderImages(images) {
const grid = document.getElementById('image-grid');
images.forEach(img => {
const item = document.createElement('div');
item.className = 'grid-item';
item.innerHTML = `
<img src="/thumb${img.url}" data-original="/raw${img.url}" alt="${img.name}" />
<div class="image-info">${img.name}</div>`;
grid.appendChild(item);
});
if (viewer) {
viewer.update();
} else {
viewer = new Viewer(grid, {
url: 'data-original',
title: (img) => img.alt,
transition: true,
view() { checkAuth(); },
viewed() {
const index = this.index;
const list = this.images;
[index + 1, index - 1].forEach(i => {
if (list[i]) { new Image().src = list[i].getAttribute('data-original'); }
});
}
});
}
}
let scrollTimer;
window.onscroll = function () {
clearTimeout(scrollTimer);
scrollTimer = setTimeout(() => {
if ((window.innerHeight + window.scrollY) >= document.body.offsetHeight - 600) {
if (hasMore && !loading) loadLevel(currentPath, true);
}
}, 150);
};
loadLevel("");
</script>
</body>
</html>

View File

@ -0,0 +1,277 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<link rel="icon" type="image/x-icon" href="/favicon.ico">
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>系统登录 - 私人相册</title>
<style>
:root {
--primary-color: #007bff;
--primary-hover: #0056b3;
--bg-gradient: linear-gradient(135deg, #1a2a6c 0%, #b21f1f 50%, #fdbb2d 100%);
--glass-bg: rgba(255, 255, 255, 0.85);
}
body, html {
margin: 0;
padding: 0;
width: 100%;
height: 100%;
overflow: hidden;
font-family: 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
}
/* 粒子画布样式 */
#particle-canvas {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: var(--bg-gradient);
z-index: 1;
}
.login-wrapper {
position: relative;
z-index: 2;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
}
.login-container {
background: var(--glass-bg);
padding: 50px 40px;
border-radius: 20px;
box-shadow: 0 20px 50px rgba(0,0,0,0.3);
width: 380px;
text-align: center;
backdrop-filter: blur(15px);
border: 1px solid rgba(255,255,255,0.4);
transition: transform 0.3s ease, box-shadow 0.3s ease;
}
.login-container:hover {
transform: translateY(-8px);
box-shadow: 0 30px 60px rgba(0,0,0,0.4);
}
h2 {
color: #1a2a6c;
margin: 0 0 10px 0;
font-size: 26px;
font-weight: 700;
}
.subtitle {
color: #555;
font-size: 14px;
margin-bottom: 30px;
}
.input-group {
margin-bottom: 25px;
}
input[type="password"] {
width: 100%;
padding: 15px 18px;
border: 2px solid rgba(0,0,0,0.05);
border-radius: 12px;
box-sizing: border-box;
outline: none;
font-size: 16px;
transition: all 0.3s ease;
background: rgba(255,255,255,0.8);
}
input[type="password"]:focus {
border-color: var(--primary-color);
background: #fff;
box-shadow: 0 0 15px rgba(0, 123, 255, 0.2);
}
button {
width: 100%;
padding: 15px;
background: var(--primary-color);
border: none;
border-radius: 12px;
color: white;
font-size: 17px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
box-shadow: 0 8px 20px rgba(0, 123, 255, 0.3);
}
button:hover {
background: var(--primary-hover);
transform: scale(1.03);
box-shadow: 0 12px 25px rgba(0, 123, 255, 0.4);
}
#message {
color: #d63031;
margin-top: 20px;
font-size: 14px;
font-weight: 600;
min-height: 20px;
}
.provider-tag {
margin-top: 40px;
padding-top: 20px;
border-top: 1px solid rgba(0,0,0,0.05);
color: #777;
font-size: 12px;
letter-spacing: 1px;
}
.provider-tag strong {
color: #1a2a6c;
font-weight: 600;
}
</style>
</head>
<body>
<canvas id="particle-canvas"></canvas>
<div class="login-wrapper">
<div class="login-container">
<div class="logo-area">
<h2>模板图片挑选服务</h2>
<div class="subtitle">Secure Access Management</div>
</div>
<div class="input-group">
<input type="password" id="password" placeholder="请输入授权码" onkeydown="if(event.keyCode==13) doLogin()" value="WANG+li648438">
</div>
<button onclick="doLogin()">立即验证并进入</button>
<div id="message"></div>
<div class="provider-tag">
服务提供商:<strong>《林城友善的地椒》</strong>
</div>
</div>
</div>
<script>
// --- 粒子动画脚本 ---
const canvas = document.getElementById('particle-canvas');
const ctx = canvas.getContext('2d');
let particles = [];
const mouse = { x: null, y: null, radius: 150 };
window.addEventListener('mousemove', (e) => {
mouse.x = e.x;
mouse.y = e.y;
});
function resizeCanvas() {
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
initParticles();
}
class Particle {
constructor() {
this.x = Math.random() * canvas.width;
this.y = Math.random() * canvas.height;
this.size = Math.random() * 2 + 1;
this.speedX = Math.random() * 1 - 0.5;
this.speedY = Math.random() * 1 - 0.5;
}
update() {
this.x += this.speedX;
this.y += this.speedY;
if (this.x > canvas.width) this.x = 0;
else if (this.x < 0) this.x = canvas.width;
if (this.y > canvas.height) this.y = 0;
else if (this.y < 0) this.y = canvas.height;
// 鼠标交互
let dx = mouse.x - this.x;
let dy = mouse.y - this.y;
let distance = Math.sqrt(dx * dx + dy * dy);
if (distance < mouse.radius) {
this.x -= dx / 20;
this.y -= dy / 20;
}
}
draw() {
ctx.fillStyle = 'rgba(255, 255, 255, 0.5)';
ctx.beginPath();
ctx.arc(this.x, this.y, this.size, 0, Math.PI * 2);
ctx.fill();
}
}
function initParticles() {
particles = [];
const count = (canvas.width * canvas.height) / 9000;
for (let i = 0; i < count; i++) {
particles.push(new Particle());
}
}
function animate() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
particles.forEach(p => {
p.update();
p.draw();
});
requestAnimationFrame(animate);
}
window.addEventListener('resize', resizeCanvas);
resizeCanvas();
animate();
// --- 登录逻辑 ---
async function doLogin() {
const pwd = document.getElementById('password').value;
const message = document.getElementById('message');
const btn = document.querySelector('button');
if (!pwd) {
message.innerText = "请输入密码";
return;
}
btn.disabled = true;
btn.innerText = "正在安全验证...";
message.innerText = "";
const formData = new FormData();
formData.append('password', pwd);
try {
const response = await fetch('/doLogin', {
method: 'POST',
body: formData
});
const result = await response.text();
if (result === 'ok') {
window.location.href = '/';
} else {
message.innerText = result;
btn.disabled = false;
btn.innerText = "立即验证并进入";
}
} catch (error) {
message.innerText = "连接服务器失败";
btn.disabled = false;
btn.innerText = "立即验证并进入";
}
}
</script>
</body>
</html>