添加查看系统
This commit is contained in:
commit
b8771c7156
34
.gitignore
vendored
Normal file
34
.gitignore
vendored
Normal 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
15
Dockerfile
Normal 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
9
HELP.md
Normal 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
67
pom.xml
Normal 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>
|
||||
13
src/main/java/eu/org/biwin/screen/ScreenApplication.java
Normal file
13
src/main/java/eu/org/biwin/screen/ScreenApplication.java
Normal 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);
|
||||
}
|
||||
|
||||
}
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
@ -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"); // 排除登录相关和静态资源
|
||||
}
|
||||
}
|
||||
104
src/main/java/eu/org/biwin/screen/controller/AuthController.java
Normal file
104
src/main/java/eu/org/biwin/screen/controller/AuthController.java
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
35
src/main/java/eu/org/biwin/screen/model/CategoryDTO.java
Normal file
35
src/main/java/eu/org/biwin/screen/model/CategoryDTO.java
Normal 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;
|
||||
}
|
||||
}
|
||||
34
src/main/java/eu/org/biwin/screen/model/ImageInfo.java
Normal file
34
src/main/java/eu/org/biwin/screen/model/ImageInfo.java
Normal 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;
|
||||
}
|
||||
}
|
||||
25
src/main/resources/application.yml
Normal file
25
src/main/resources/application.yml
Normal 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
|
||||
BIN
src/main/resources/static/favicon.ico
Normal file
BIN
src/main/resources/static/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
320
src/main/resources/templates/index.html
Normal file
320
src/main/resources/templates/index.html
Normal 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>
|
||||
277
src/main/resources/templates/login.html
Normal file
277
src/main/resources/templates/login.html
Normal 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>
|
||||
Loading…
Reference in New Issue
Block a user