添加授权码用户购物车的功能

This commit is contained in:
wangli 2025-12-21 17:34:32 +08:00
parent b8771c7156
commit 0f448ec33e
17 changed files with 1165 additions and 437 deletions

View File

@ -6,4 +6,7 @@ docker run -d -p 8080:8080 -e FILE_PATH=/mnt/images --name my-gallery -v /opt/1p
挂载卷:-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
应用环境变量: -e FILE_PATH 图片文件夹根目录 APP_SECURE_PASSWORD 系统登录密码 APP_SECURE_AUTH 系统生成授权码API密钥 APP_SECURE_KEY 系统生成授权码的 AES KEY
APIFOX API 令牌APS-JXFgORmNs7eiW3NsWuBWYfpI6mGvBrFr

11
pom.xml
View File

@ -38,6 +38,17 @@
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<!-- Spring Boot JDBC Starter for database access -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<!-- SQLite JDBC Driver -->
<dependency>
<groupId>org.xerial</groupId>
<artifactId>sqlite-jdbc</artifactId>
<version>3.45.1.0</version>
</dependency>
<dependency>
<groupId>cn.dev33</groupId>
<artifactId>sa-token-spring-boot3-starter</artifactId>

View File

@ -15,17 +15,39 @@ import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
*/
@Configuration
public class SaTokenConfigure implements WebMvcConfigurer {
// 注册 Sa-Token 拦截器
/**
* 注册 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"); // 排除登录相关和静态资源
registry.addInterceptor(new SaInterceptor(handler -> {
// --- 定义需要登录才能访问的路由 ---
SaRouter
// 匹配所有路由
.match("/**")
// 排除登录页面登录处理接口静态资源以及公开的授权码生成接口
.notMatch(
"/login",
"/doLogin",
"/static/**",
"/favicon.ico",
"/error",
"/api/admin/generate-code" // **[NEW]** 将此接口设为公开访问
)
// 对匹配的路由执行登录检查
.check(r -> StpUtil.checkLogin());
// --- 定义需要管理员角色才能访问的路由 ---
SaRouter
// 匹配所有 /api/admin/ 下的路由
.match("/api/admin/**")
// **但是** 排除公开的授权码生成接口
.notMatch("/api/admin/generate-code")
// 对匹配的路由执行角色检查
.check(r -> StpUtil.checkRole("admin"));
})).addPathPatterns("/**");
}
}
}

View File

@ -0,0 +1,42 @@
package eu.org.biwin.screen.advice;
import cn.dev33.satoken.stp.StpInterface;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.List;
/**
* Sa-Token 的权限认证接口实现类
* 用于告诉 Sa-Token 如何获取一个用户的角色和权限
*/
@Component
public class StpInterfaceImpl implements StpInterface {
/**
* 返回一个用户所拥有的权限码集合
* 在我们的设计中暂时用不到权限码返回空集合即可
*/
@Override
public List<String> getPermissionList(Object loginId, String loginType) {
return new ArrayList<>();
}
/**
* 返回一个用户所拥有的角色标识集合
*/
@Override
public List<String> getRoleList(Object loginId, String loginType) {
List<String> list = new ArrayList<>();
String loginIdStr = String.valueOf(loginId);
// 根据 loginId 的前缀来判断角色
if (loginIdStr.startsWith("admin-")) {
list.add("admin");
} else if (loginIdStr.startsWith("user-")) {
list.add("user");
}
return list;
}
}

View File

@ -0,0 +1,171 @@
package eu.org.biwin.screen.controller;
import cn.dev33.satoken.annotation.SaCheckRole;
import cn.hutool.crypto.SecureUtil;
import cn.hutool.crypto.symmetric.AES;
import cn.hutool.json.JSONUtil;
import eu.org.biwin.screen.model.GenerateCodeRequest;
import eu.org.biwin.screen.model.ImageGroup;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Controller;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseBody;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.stream.Collectors;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
@Controller
@RequestMapping("/api/admin")
// **[REMOVED]** Removed @SaCheckRole("admin") from the class level
public class AdminController {
@Autowired
private JdbcTemplate jdbcTemplate;
@Value("${file.path}")
private String rootPath;
@Value("${app.secure-key}")
private String secureKey;
@Value("${app.secure-auth}")
private String secureAuth;
private static final DateTimeFormatter formatter = DateTimeFormatter.ISO_LOCAL_DATE_TIME;
// This method is now PUBLIC and requires NO LOGIN
@PostMapping("/generate-code")
@ResponseBody
public String generateCode(@RequestBody GenerateCodeRequest request) {
if (StringUtils.hasText(request.getAuthCode()) && Objects.equals(request.getAuthCode(), secureAuth)) {
String orderNo = request.getOrderNo();
Integer loginLimit = request.getLoginLimit();
Long expireSeconds = request.getExpireSeconds();
// 1. 设置 12 小时后的过期时间戳
long expireTime = System.currentTimeMillis() + (Objects.isNull(expireSeconds) ? 12 * 60 * 60 * 1000 : expireSeconds);
// 2. 创建 AES 实例Hutool 会自动根据 16 字节 key 选择 AES-128
AES aes = SecureUtil.aes(secureKey.getBytes(StandardCharsets.UTF_8));
// 3. 加密时间戳并返回
String loginCode = aes.encryptHex(String.valueOf(expireTime));
LocalDateTime now = LocalDateTime.now();
LocalDateTime expiresAt = now.plusSeconds(expireSeconds);
String sql = "INSERT INTO auth_codes (code, order_no, login_limit, created_at, expires_at, status) VALUES (?, ?, ?, ?, ?, ?)";
jdbcTemplate.update(sql, loginCode, orderNo, loginLimit, formatter.format(now), formatter.format(expiresAt), 1);
return loginCode;
}
Map<String, String> result = new HashMap<>();
result.put("code", "400");
result.put("msg", "illegal access");
return JSONUtil.toJsonStr(result);
}
@GetMapping
@SaCheckRole("admin") // Add annotation to each protected method
public String adminPage() {
return "admin";
}
@GetMapping("/codes")
@ResponseBody
@SaCheckRole("admin") // Add annotation to each protected method
public List<Map<String, Object>> getAuthCodes() {
return jdbcTemplate.queryForList("SELECT code, order_no, login_limit, login_count, expires_at FROM auth_codes ORDER BY created_at DESC");
}
@GetMapping("/cart/{code}")
@ResponseBody
@SaCheckRole("admin") // Add annotation to each protected method
public List<ImageGroup> getCartForCode(@PathVariable String code) {
List<String> itemIds = jdbcTemplate.queryForList("SELECT item_id FROM cart_items WHERE auth_code = ?", String.class, code);
return itemIds.stream().map(this::createImageGroupFromId).filter(Objects::nonNull).collect(Collectors.toList());
}
@GetMapping("/download-cart/{code}")
@SaCheckRole("admin") // Add annotation to each protected method
public void downloadCartAsZip(@PathVariable String code, HttpServletResponse response) throws IOException {
List<String> itemIds = jdbcTemplate.queryForList("SELECT item_id FROM cart_items WHERE auth_code = ?", String.class, code);
Map<String, Object> codeDetails = jdbcTemplate.queryForMap("SELECT order_no FROM auth_codes WHERE code = ?", code);
String orderId = (String) codeDetails.get("order_no");
String zipFileName = (orderId != null ? orderId.replaceAll("[^a-zA-Z0-9]", "_") : code) + "_templates.zip";
response.setContentType("application/zip");
response.setHeader("Content-Disposition", "attachment; filename=\"" + zipFileName + "\"");
try (ZipOutputStream zos = new ZipOutputStream(response.getOutputStream())) {
for (String itemId : itemIds) {
File templateFile = findTemplateFile(itemId);
if (templateFile != null) {
ZipEntry zipEntry = new ZipEntry(templateFile.getName());
zos.putNextEntry(zipEntry);
try (FileInputStream fis = new FileInputStream(templateFile)) {
byte[] buffer = new byte[1024];
int len;
while ((len = fis.read(buffer)) > 0) {
zos.write(buffer, 0, len);
}
}
zos.closeEntry();
}
}
}
}
// ... (Helper methods remain the same)
private ImageGroup createImageGroupFromId(String itemId) {
String displayName = new File(itemId).getName();
ImageGroup group = new ImageGroup(itemId, displayName);
File effectFile = findFile(itemId, "effect");
File templateFile = findFile(itemId, "template");
if (effectFile == null) return null;
String rootAbsolutePath = new File(rootPath).getAbsolutePath();
java.util.function.Function<File, String> getUrl = (file) -> {
if (file == null) return null;
return file.getAbsolutePath().substring(rootAbsolutePath.length()).replace("\\", "/");
};
group.setEffectImageUrl(getUrl.apply(effectFile));
group.setEffectThumbnailUrl(getUrl.apply(effectFile));
group.setTemplateImageUrl(getUrl.apply(templateFile));
group.setTemplateThumbnailUrl(getUrl.apply(templateFile));
return group;
}
private File findTemplateFile(String itemId) {
return findFile(itemId, "template");
}
private File findFile(String itemId, String type) {
String[] extensions = {".jpg", ".png", ".JPG", ".PNG"};
for (String ext : extensions) {
File f = new File(rootPath, itemId + "-" + type + ext);
if (f.exists()) return f;
}
return null;
}
}

View File

@ -1,104 +1,42 @@
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.HttpStatus;
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.
* 检查当前会话是否仍然有效
* Sa-Token 的全局拦截器会处理这个问题如果无效会返回 401
* 如果有效我们什么都不用做返回 200 OK 即可
*/
@GetMapping("/checkLogin")
public ResponseEntity<Void> checkLogin() {
if (StpUtil.isLogin()) {
return ResponseEntity.ok().build();
} else {
return ResponseEntity.status(401).build();
}
StpUtil.checkLogin();
return ResponseEntity.ok().build();
}
/**
* 获取当前登录用户的信息主要是角色
* @return 包含用户角色信息的 Map
*/
@GetMapping("/info")
public ResponseEntity<Map<String, Object>> getUserInfo() {
StpUtil.checkLogin();
Map<String, Object> info = new HashMap<>();
String role = StpUtil.getRoleList().stream().findFirst().orElse("none");
info.put("role", role);
return new ResponseEntity<>(info, HttpStatus.OK);
}
}

View File

@ -0,0 +1,133 @@
package eu.org.biwin.screen.controller;
import cn.dev33.satoken.stp.StpUtil;
import eu.org.biwin.screen.model.ImageGroup;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.io.File;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
@RestController // Changed back from @Controller
@RequestMapping("/api/cart") // Changed back to the original API path
public class CartController {
@Autowired
private JdbcTemplate jdbcTemplate;
@Value("${file.path}")
private String rootPath;
private static final DateTimeFormatter formatter = DateTimeFormatter.ISO_LOCAL_DATE_TIME;
// The cartPage() method has been removed from here.
@PostMapping("/add")
public String addToCart(@RequestBody Map<String, String> payload) {
StpUtil.checkRole("user");
String loginId = (String) StpUtil.getLoginId();
String authCode = loginId.substring(5);
String itemId = payload.get("itemId");
if (itemId == null || itemId.trim().isEmpty()) {
return "错误:商品 ID 不能为空";
}
String checkSql = "SELECT COUNT(*) FROM cart_items WHERE auth_code = ? AND item_id = ?";
Integer count = jdbcTemplate.queryForObject(checkSql, Integer.class, authCode, itemId);
if (count != null && count > 0) {
return "该商品已在购物车中";
}
String insertSql = "INSERT INTO cart_items (auth_code, item_id, added_at) VALUES (?, ?, ?)";
try {
jdbcTemplate.update(insertSql, authCode, itemId, formatter.format(LocalDateTime.now()));
return "已成功添加到购物车";
} catch (Exception e) {
e.printStackTrace();
return "添加到购物车时发生错误";
}
}
@GetMapping("/items")
public List<ImageGroup> getCartItems() {
StpUtil.checkLogin();
String loginId = (String) StpUtil.getLoginId();
String authCode = loginId.startsWith("user-") ? loginId.substring(5) : "admin";
String sql = "SELECT item_id FROM cart_items WHERE auth_code = ?";
List<String> itemIds = jdbcTemplate.queryForList(sql, String.class, authCode);
return itemIds.stream()
.map(this::createImageGroupFromId)
.filter(java.util.Objects::nonNull)
.collect(Collectors.toList());
}
@PostMapping("/remove")
public String removeFromCart(@RequestBody Map<String, String> payload) {
StpUtil.checkRole("user");
String loginId = (String) StpUtil.getLoginId();
String authCode = loginId.substring(5);
String itemId = payload.get("itemId");
if (itemId == null || itemId.trim().isEmpty()) {
return "错误:商品 ID 不能为空";
}
String sql = "DELETE FROM cart_items WHERE auth_code = ? AND item_id = ?";
int rowsAffected = jdbcTemplate.update(sql, authCode, itemId);
if (rowsAffected > 0) {
return "删除成功";
} else {
return "删除失败,商品可能已不在购物车中";
}
}
private ImageGroup createImageGroupFromId(String itemId) {
String displayName = new File(itemId).getName();
ImageGroup group = new ImageGroup(itemId, displayName);
String[] extensions = {".jpg", ".JPG", ".png", ".PNG"};
File effectFile = null;
File templateFile = null;
for (String ext : extensions) {
if (effectFile == null) {
File f = new File(rootPath, itemId + "-effect" + ext);
if (f.exists()) effectFile = f;
}
if (templateFile == null) {
File f = new File(rootPath, itemId + "-template" + ext);
if (f.exists()) templateFile = f;
}
}
if (effectFile == null) return null;
String rootAbsolutePath = new File(rootPath).getAbsolutePath();
java.util.function.Function<File, String> getUrl = (file) -> {
if (file == null) return null;
return file.getAbsolutePath().substring(rootAbsolutePath.length()).replace("\\", "/");
};
group.setEffectImageUrl(getUrl.apply(effectFile));
group.setEffectThumbnailUrl(getUrl.apply(effectFile));
group.setTemplateImageUrl(getUrl.apply(templateFile));
group.setTemplateThumbnailUrl(getUrl.apply(templateFile));
return group;
}
}

View File

@ -1,12 +1,13 @@
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 eu.org.biwin.screen.model.ImageGroup;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.dao.EmptyResultDataAccessException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
@ -15,181 +16,134 @@ 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.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
/**
* 主控制器包含登录图片加载等功能
*
* @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.
if (val1 != val2) return Long.compare(val1, val2);
int lenCompare = numStr1.length() - numStr2.length();
if (lenCompare != 0) {
return lenCompare;
}
continue; // Identical numbers, move to next chunk
if (lenCompare != 0) return lenCompare;
continue;
} 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 (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
if (m1.find()) return 1;
if (m2.find()) return -1;
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;
// 登录页面
@Autowired
private JdbcTemplate jdbcTemplate;
private static final DateTimeFormatter formatter = DateTimeFormatter.ISO_LOCAL_DATE_TIME;
@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");
/**
* [NEW] Serves the cart page, consistent with other page-serving methods.
*/
@GetMapping("/cart")
public String cartPage() {
return "cart";
}
// GalleryController 或单独的类中添加
@GetMapping("/raw/**") // 我们统一用 /raw 作为原图前缀
@PostMapping("/doLogin")
@ResponseBody
public String doLogin(String password) {
if (securePassword.equals(password)) {
StpUtil.login("admin-root");
return "ok";
}
try {
String sql = "SELECT * FROM auth_codes WHERE code = ?";
Map<String, Object> codeDetails = jdbcTemplate.queryForMap(sql, password);
if ((int) codeDetails.get("status") != 1) return "该授权码已被停用";
if (LocalDateTime.now().isAfter(LocalDateTime.parse((String) codeDetails.get("expires_at"), formatter)))
return "该授权码已过期";
if ((int) codeDetails.get("login_limit") > 0) {
if ((int) codeDetails.get("login_count") >= (int) codeDetails.get("login_limit"))
return "该授权码已达到最大登录次数";
}
jdbcTemplate.update("UPDATE auth_codes SET login_count = login_count + 1 WHERE code = ?", password);
StpUtil.login("user-" + password);
return "ok";
} catch (EmptyResultDataAccessException e) {
return "无效的授权码或密码";
} catch (Exception e) {
e.printStackTrace();
return "服务器内部错误";
}
}
private boolean isImage(String name) {
String n = name.toLowerCase();
return n.endsWith(".jpg") || n.endsWith(".JPG") || n.endsWith(".png") || n.endsWith(".PNG");
}
@GetMapping("/raw/**")
public void getRawImage(HttpServletRequest request, HttpServletResponse response) throws IOException {
// 获取 /raw/ 之后的所有路径
String servletPath = request.getServletPath();
String relativePath = servletPath.substring(5); // 去掉 "/raw/"
String relativePath = servletPath.substring(servletPath.indexOf('/', 1));
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());
}
private static final Pattern IMAGE_GROUP_PATTERN = Pattern.compile("^(.*?)-(effect|template)\\.(jpg|JPG|png|PNG)$", Pattern.CASE_INSENSITIVE);
@GetMapping("/api/images/list")
@ResponseBody
public Map<String, Object> getLevelContent(
@ -197,43 +151,69 @@ public class GalleryController {
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "20") int size) {
File currentFolder = new File(rootPath, path);
String rootAbsolutePath = new File(rootPath).getAbsolutePath();
List<Map<String, String>> folders = new ArrayList<>();
List<Map<String, String>> images = new ArrayList<>();
Map<String, Map<String, File>> groupedFiles = new HashMap<>();
File[] files = currentFolder.listFiles();
if (files != null) {
Arrays.sort(files, GalleryController.FILE_MANAGER_COMPARATOR);
Arrays.sort(files, FILE_MANAGER_COMPARATOR);
for (File f : files) {
if (f.isDirectory()) {
folders.add(Map.of("name", f.getName(), "path", path + "/" + f.getName()));
if (!f.getName().equals(".cache")) {
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("\\", "/")));
Matcher matcher = IMAGE_GROUP_PATTERN.matcher(f.getName());
if (matcher.matches()) {
String relPath = f.getAbsolutePath().substring(rootAbsolutePath.length()).replace("\\", "/");
String uniqueKey = relPath.replaceAll("-(effect|template)\\.(jpg|JPG|png|PNG)$", "");
String type = matcher.group(2);
groupedFiles.computeIfAbsent(uniqueKey, k -> new HashMap<>()).put(type, f);
}
}
}
}
// 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);
}));
List<ImageGroup> imageGroups = groupedFiles.entrySet().stream()
.map(entry -> {
String uniqueId = entry.getKey();
String displayName = new File(uniqueId).getName();
Map<String, File> typeMap = entry.getValue();
ImageGroup group = new ImageGroup(uniqueId, displayName);
java.util.function.Function<File, String> getUrl = (file) -> {
if (file == null) return null;
return file.getAbsolutePath().substring(rootAbsolutePath.length()).replace("\\", "/");
};
File effectFile = typeMap.get("effect");
File templateFile = typeMap.get("template");
group.setEffectImageUrl(getUrl.apply(effectFile));
group.setEffectThumbnailUrl(getUrl.apply(effectFile));
group.setTemplateImageUrl(getUrl.apply(templateFile));
group.setTemplateThumbnailUrl(getUrl.apply(templateFile));
return group;
})
.filter(g -> g.getEffectImageUrl() != null)
.sorted(Comparator.comparing(ImageGroup::getId))
.collect(Collectors.toList());
// 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);
List<ImageGroup> pagedImageGroups = new ArrayList<>();
if (start < imageGroups.size()) {
int end = Math.min(start + size, imageGroups.size());
pagedImageGroups = imageGroups.subList(start, end);
}
return Map.of(
"folders", folders, // Folders are already sorted from the file array sort.
"images", pagedImages,
"hasMore", start + size < images.size()
"folders", folders,
"images", pagedImageGroups,
"hasMore", start + size < imageGroups.size()
);
}
}
}

View File

@ -14,7 +14,8 @@ import java.io.IOException;
import java.nio.file.Files;
/**
* 缩略图控制器
* 动态缩略图控制器
* 实现了按需生成和缓存缩略图的逻辑
*
* @author wangli
* @since 2025-12-20 20:01
@ -26,37 +27,51 @@ 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. 检查用户登录状态确保资源不被未授权访问
StpUtil.checkLogin();
// 1. 获取请求的相对路径
// 2. 获取请求的相对路径例如 "/images/group1/item-effect.jpg"
String servletPath = request.getServletPath();
String relativePath = servletPath.substring(7); // 去掉 "/thumb/"
File originFile = new File(rootPath, relativePath);
String relativePath = servletPath.substring(servletPath.indexOf('/', 1)); // Safely get path after /thumb
if (!originFile.exists()) {
response.sendError(404);
// 3. 定位原图文件
File originFile = new File(rootPath, relativePath);
if (!originFile.exists() || originFile.isDirectory()) {
response.sendError(HttpServletResponse.SC_NOT_FOUND);
return;
}
// 2. 确定缩略图缓存文件路径
File thumbFile = new File(thumbCachePath, relativePath + "_thumb.jpg");
// 4. 定义缓存目录和缓存文件路径
// 将缓存存放在主图片目录下的一个隐藏文件夹中便于统一管理
File thumbCacheDir = new File(rootPath, ".cache/thumbnails");
File thumbFile = new File(thumbCacheDir, relativePath);
// 3. 如果缓存不存在则生成
// 5. 检查缓存是否存在如果不存在则动态生成
if (!thumbFile.exists()) {
thumbFile.getParentFile().mkdirs();
Thumbnails.of(originFile)
.size(300, 300) // 设置最大宽高
.outputQuality(0.8) // 压缩质量
.toFile(thumbFile);
try {
// 确保父目录存在
thumbFile.getParentFile().mkdirs();
// 使用 Thumbnailator 生成缩略图
Thumbnails.of(originFile)
.size(400, 400) // 设置缩略图的最大尺寸为 400x400
.outputQuality(0.85) // 设置输出质量为 85%
.toFile(thumbFile); // 保存到缓存文件
} catch (IOException e) {
// 如果生成失败例如文件损坏则返回服务器错误
System.err.println("Failed to create thumbnail for: " + originFile.getAbsolutePath());
e.printStackTrace();
response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "Could not generate thumbnail.");
return;
}
}
// 4. 将缩略图流式输出给浏览器
response.setContentType("image/jpeg");
// 6. 从缓存提供缩略图
String contentType = Files.probeContentType(thumbFile.toPath());
response.setContentType(contentType != null ? contentType : "image/jpeg");
Files.copy(thumbFile.toPath(), response.getOutputStream());
}
}
}

View File

@ -0,0 +1,59 @@
package eu.org.biwin.screen.model;
import java.util.Objects;
/**
* TODO
*
* @author wangli
* @since 2025-12-21 15:52
*/
public class GenerateCodeRequest {
private String authCode;
private String orderNo;
private Integer loginLimit;
private Long expireSeconds;
public GenerateCodeRequest(String authCode, String orderNo, Integer loginLimit, Long expireSeconds) {
this.authCode = authCode;
this.orderNo = orderNo;
this.loginLimit = loginLimit;
this.expireSeconds = expireSeconds;
}
public String getAuthCode() {
return authCode;
}
public void setAuthCode(String authCode) {
this.authCode = authCode;
}
public String getOrderNo() {
return orderNo;
}
public void setOrderNo(String orderNo) {
this.orderNo = orderNo;
}
public Integer getLoginLimit() {
return (Objects.isNull(loginLimit) || loginLimit < 0) ? 1 : loginLimit;
}
public void setLoginLimit(Integer loginLimit) {
this.loginLimit = loginLimit;
}
public Long getExpireSeconds() {
return expireSeconds;
}
public void setExpireSeconds(Long expireSeconds) {
this.expireSeconds = expireSeconds;
}
}

View File

@ -0,0 +1,66 @@
package eu.org.biwin.screen.model;
public class ImageGroup {
private String id;
private String name;
private String effectThumbnailUrl;
private String effectImageUrl;
private String templateThumbnailUrl;
private String templateImageUrl;
public ImageGroup(String id, String name) {
this.id = id;
this.name = name;
}
// Getters and Setters
public String getId() {
return id;
}
public void setId(String id) {
this.id = id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getEffectThumbnailUrl() {
return effectThumbnailUrl;
}
public void setEffectThumbnailUrl(String effectThumbnailUrl) {
this.effectThumbnailUrl = effectThumbnailUrl;
}
public String getEffectImageUrl() {
return effectImageUrl;
}
public void setEffectImageUrl(String effectImageUrl) {
this.effectImageUrl = effectImageUrl;
}
public String getTemplateThumbnailUrl() {
return templateThumbnailUrl;
}
public void setTemplateThumbnailUrl(String templateThumbnailUrl) {
this.templateThumbnailUrl = templateThumbnailUrl;
}
public String getTemplateImageUrl() {
return templateImageUrl;
}
public void setTemplateImageUrl(String templateImageUrl) {
this.templateImageUrl = templateImageUrl;
}
}

View File

@ -0,0 +1,47 @@
package eu.org.biwin.screen.service;
import jakarta.annotation.PostConstruct;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
/**
* 数据库服务负责初始化和管理数据库表结构
*/
@Service
public class DatabaseService {
@Autowired
private JdbcTemplate jdbcTemplate;
/**
* 在服务启动时执行用于初始化数据库表
* 使用 "IF NOT EXISTS" 确保表只在不存在时被创建避免重复执行出错
*/
@PostConstruct
public void initialize() {
// 1. 创建授权码表 (auth_codes)
String createAuthCodesTableSql = "CREATE TABLE IF NOT EXISTS auth_codes (" +
"code TEXT PRIMARY KEY, " +
"order_no TEXT, " +
"login_limit INTEGER NOT NULL DEFAULT 1, " +
"login_count INTEGER NOT NULL DEFAULT 0, " +
"created_at TEXT NOT NULL, " +
"expires_at TEXT NOT NULL, " +
"status INTEGER NOT NULL DEFAULT 1" + // 1 for active, 0 for inactive
");";
jdbcTemplate.execute(createAuthCodesTableSql);
// 2. 创建购物车表 (cart_items)
String createCartItemsTableSql = "CREATE TABLE IF NOT EXISTS cart_items (" +
"id INTEGER PRIMARY KEY AUTOINCREMENT, " +
"auth_code TEXT NOT NULL, " +
"item_id TEXT NOT NULL, " +
"added_at TEXT NOT NULL, " +
"UNIQUE(auth_code, item_id)" + // 确保每个授权码下的商品是唯一的
");";
jdbcTemplate.execute(createCartItemsTableSql);
System.out.println("Database tables initialized successfully.");
}
}

View File

@ -0,0 +1,2 @@
// This file is used to create the service package.
package eu.org.biwin.screen.service;

View File

@ -1,25 +1,33 @@
server:
port: 8080
# ?????????
# 文件存储路径配置
file:
path: "/Users/wangli/Downloads/image/" # ??????????????
auth-code-file: "authorized_codes.txt" # ?????
path: "/Users/wangli/Downloads/image2/" # 文件存储路径
# 应用配置
app:
# ???????
# 管理员密码
secure-password: "WANG+li648438"
# ?????? 12 ????????????
# 生成授权码的接口访问KEY
secure-auth: "1q2w3e4r"
# ??????? key
# 加密 key
secure-key: "WANG+li648438745"
spring:
# 数据源配置
datasource:
# SQLite 的 JDBC Driver
driver-class-name: org.sqlite.JDBC
# 数据库文件路径,存放在 file.path 配置的目录下
url: jdbc:sqlite:${file.path}/storage.db
mvc:
static-path-pattern: /**
web:
resources:
static-locations: classpath:/static/, file:${file.path}
# Sa-Token ????? Token ???? 30 ??
# Sa-Token 配置
sa-token:
timeout: 1800 # 30 * 60 ?
is-print: false
timeout: 1800 # Token 有效期,单位秒 30 * 60 = 1800 秒,即 30 分钟。
is-print: false

View File

@ -0,0 +1,133 @@
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>管理后台</title>
<style>
body { font-family: sans-serif; background-color: #f4f6f8; margin: 0; display: flex; height: 100vh; }
.sidebar { width: 280px; background: #fff; box-shadow: 2px 0 5px rgba(0,0,0,0.1); padding: 20px; overflow-y: auto; }
.main-content { flex-grow: 1; padding: 20px; overflow-y: auto; }
.code-list-item { padding: 12px; border-bottom: 1px solid #eee; cursor: pointer; border-radius: 6px; }
.code-list-item:hover, .code-list-item.active { background-color: #e9f5ff; }
.code-list-item strong { font-size: 14px; color: #333; }
.code-list-item span { font-size: 12px; color: #777; display: block; margin-top: 4px; }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 15px; margin-top: 20px; }
.grid-item { background: #fff; border-radius: 10px; overflow: hidden; box-shadow: 0 4px 16px rgba(0,0,0,0.05); }
.grid-item img { width: 100%; height: auto; display: block; }
.grid-item-info { padding: 10px; font-size: 12px; text-align: center; }
#download-btn { background: #28a745; color: white; border: none; padding: 12px 20px; border-radius: 8px; cursor: pointer; font-size: 16px; display: none; margin-bottom: 20px; }
#download-btn:disabled { background: #999; }
</style>
</head>
<body>
<div class="sidebar" id="sidebar">
<h3>授权码列表</h3>
<div id="code-list"></div>
</div>
<div class="main-content">
<h2>购物车详情</h2>
<button id="download-btn">打包下载模板原图</button>
<div class="grid" id="cart-grid"></div>
<div id="loader">请从左侧选择一个授权码查看</div>
</div>
<script>
const codeListEl = document.getElementById('code-list');
const cartGridEl = document.getElementById('cart-grid');
const loaderEl = document.getElementById('loader');
const downloadBtn = document.getElementById('download-btn');
let activeCode = null;
async function fetchAuthCodes() {
try {
const response = await fetch('/api/admin/codes');
if (!response.ok) throw new Error('Failed to load codes');
const codes = await response.json();
codeListEl.innerHTML = '';
codes.forEach(code => {
const item = document.createElement('div');
item.className = 'code-list-item';
item.dataset.code = code.code;
item.innerHTML = `<strong>${code.order_id || code.code}</strong><span>${code.login_count}/${code.login_limit} | Expires: ${new Date(code.expires_at).toLocaleDateString()}</span>`;
item.onclick = () => loadCartForCode(code.code);
codeListEl.appendChild(item);
});
} catch (e) {
codeListEl.innerHTML = `<p>${e.message}</p>`;
}
}
async function loadCartForCode(code) {
activeCode = code;
// Highlight active item
document.querySelectorAll('.code-list-item').forEach(el => el.classList.remove('active'));
document.querySelector(`[data-code="${code}"]`).classList.add('active');
cartGridEl.innerHTML = '';
loaderEl.innerText = '正在加载...';
downloadBtn.style.display = 'none';
try {
const response = await fetch(`/api/admin/cart/${code}`);
if (!response.ok) throw new Error('Failed to load cart');
const items = await response.json();
if (items.length === 0) {
loaderEl.innerText = '这个购物车是空的';
return;
}
items.forEach(item => {
const gridItem = document.createElement('div');
gridItem.className = 'grid-item';
gridItem.innerHTML = `<img src="/thumb${item.effectThumbnailUrl}" alt="${item.name}" /><div class="grid-item-info">${item.name}</div>`;
cartGridEl.appendChild(gridItem);
});
loaderEl.style.display = 'none';
downloadBtn.style.display = 'block';
} catch (e) {
loaderEl.innerText = e.message;
}
}
downloadBtn.onclick = async () => {
if (!activeCode) return;
downloadBtn.disabled = true;
downloadBtn.innerText = '正在打包...';
try {
const response = await fetch(`/api/admin/download-cart/${activeCode}`);
if (!response.ok) throw new Error('打包失败');
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.style.display = 'none';
a.href = url;
// Use order_id for filename if available
const activeItem = document.querySelector(`[data-code="${activeCode}"] strong`);
const filename = `${activeItem.innerText.replace(/\\s+/g, '_')}_templates.zip`;
a.download = filename;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
a.remove();
} catch (e) {
alert(e.message);
} finally {
downloadBtn.disabled = false;
downloadBtn.innerText = '打包下载模板原图';
}
};
document.addEventListener('DOMContentLoaded', fetchAuthCodes);
</script>
</body>
</html>

View File

@ -0,0 +1,145 @@
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>我的购物车</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/viewerjs/1.11.6/viewer.min.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/viewerjs/1.11.6/viewer.min.js"></script>
<style>
body { font-family: sans-serif; background-color: #f0f2f5; margin: 0; }
.navbar { background: #fff; padding: 10px 20px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); display: flex; align-items: center; }
.navbar a { text-decoration: none; color: #007bff; font-weight: bold; margin-right: 20px; }
.main-content { padding: 20px; max-width: 1600px; margin: auto; }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 20px; }
.grid-item { position: relative; background: #fff; border-radius: 15px; overflow: hidden; box-shadow: 0 8px 32px 0 rgba(31, 38, 135, 0.07); }
.grid-item img { width: 100%; height: auto; display: block; cursor: zoom-in; }
.image-info { padding: 12px; font-size: 12px; text-align: center; }
.loading-status { text-align: center; padding: 40px; color: #999; }
.delete-btn {
position: absolute;
top: 8px;
right: 8px;
width: 32px;
height: 32px;
background: rgba(220, 53, 69, 0.8);
color: white;
border: none;
border-radius: 50%;
cursor: pointer;
font-size: 18px;
font-weight: bold;
display: flex;
align-items: center;
justify-content: center;
z-index: 10;
transition: all 0.2s;
}
.delete-btn:hover { background: #dc3545; transform: scale(1.1); }
</style>
</head>
<body>
<div class="navbar">
<a href="/">返回主页</a>
<a id="admin-link" href="/admin" style="display: none;">管理后台</a>
</div>
<div class="main-content">
<h2>我的购物车</h2>
<div class="grid" id="cart-grid"></div>
<div id="loader" class="loading-status">正在加载...</div>
</div>
<script>
let viewer;
async function fetchCartItems() {
const loader = document.getElementById('loader');
const grid = document.getElementById('cart-grid');
try {
const response = await fetch('/api/cart/items');
if (!response.ok) {
if (response.status === 401) window.location.href = '/login';
throw new Error('无法加载购物车');
}
const items = await response.json();
if (items.length === 0) {
loader.innerText = '购物车是空的';
return;
}
items.forEach(item => {
const gridItem = document.createElement('div');
gridItem.className = 'grid-item';
// **[FIX]** Use a data attribute to reliably store the original ID.
gridItem.dataset.itemId = item.id;
gridItem.innerHTML = `
<button class="delete-btn" data-id="${item.id}" title="从购物车移除">×</button>
<img src="/thumb${item.effectThumbnailUrl}" data-original="/raw${item.effectImageUrl}" alt="${item.name}" />
<div class="image-info">${item.name}</div>
`;
grid.appendChild(gridItem);
});
viewer = new Viewer(grid, { url: 'data-original' });
loader.style.display = 'none';
} catch (error) {
loader.innerText = error.message;
}
}
async function handleRemoveItem(itemId) {
if (!confirm('您确定要从购物车中移除这张图片吗?')) {
return;
}
try {
const response = await fetch('/api/cart/remove', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ itemId: itemId })
});
if (response.ok) {
// **[FIX]** Use a robust attribute selector to find the correct element.
const itemElement = document.querySelector(`.grid-item[data-item-id="${itemId}"]`);
if (itemElement) {
itemElement.style.transition = 'opacity 0.5s, transform 0.5s';
itemElement.style.opacity = '0';
itemElement.style.transform = 'scale(0.8)';
setTimeout(() => {
itemElement.remove();
if (document.getElementById('cart-grid').children.length === 0) {
document.getElementById('loader').innerText = '购物车已清空';
document.getElementById('loader').style.display = 'block';
}
viewer.update();
}, 500);
}
} else {
const errorText = await response.text();
alert(`删除失败: ${errorText}`);
}
} catch (error) {
alert('网络错误,无法删除商品');
}
}
document.getElementById('cart-grid').addEventListener('click', function(event) {
const deleteButton = event.target.closest('.delete-btn');
if (deleteButton) {
event.stopPropagation();
handleRemoveItem(deleteButton.dataset.id);
}
});
async function checkUserRole() { try { const res = await fetch('/api/auth/info'); if (res.ok) { const data = await res.json(); if (data.role === 'admin') { document.getElementById('admin-link').style.display = 'block'; } } } catch (e) { /* Ignore */ } }
document.addEventListener('DOMContentLoaded', () => { checkUserRole(); fetchCartItems(); });
</script>
</body>
</html>

View File

@ -25,121 +25,41 @@
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;
}
.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); margin-bottom: 30px; border-radius: 15px; box-shadow: var(--card-shadow); font-size: 14px; }
.breadcrumb a { color: var(--primary-color); text-decoration: none; font-weight: 500; }
.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-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); box-shadow: 0 12px 40px rgba(0,0,0,0.1); }
.folder-icon { font-size: 48px; margin-bottom: 12px; display: block; }
.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 { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 20px; }
.grid-item { position: relative; border-radius: 15px; overflow: hidden; background: #fff; box-shadow: var(--card-shadow); 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;
}
.grid-item img { width: 100%; height: auto; 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; }
.add-to-cart-btn { position: absolute; bottom: 10px; right: 10px; background: rgba(0, 123, 255, 0.8); color: white; border: none; border-radius: 50px; width: 40px; height: 40px; cursor: pointer; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2); opacity: 0; transform: scale(0.8); transition: opacity 0.2s ease, transform 0.2s ease; z-index: 5; display: flex; align-items: center; justify-content: center; }
.grid-item:hover .add-to-cart-btn { opacity: 1; transform: scale(1); }
.add-to-cart-btn:hover { background: #0056b3; transform: scale(1.1); }
.loading-status { text-align: center; padding: 40px; color: #999; font-size: 14px; }
.fab-cart { position: fixed; bottom: 40px; right: 40px; width: 60px; height: 60px; background-color: var(--primary-color); color: white; border-radius: 50%; border: none; box-shadow: 0 6px 20px rgba(0, 123, 255, 0.4); display: none; align-items: center; justify-content: center; cursor: pointer; z-index: 1000; transition: all 0.3s; }
.fab-cart:hover { transform: scale(1.1); background-color: #0056b3; }
.fab-cart.show { display: flex; }
#toast-container { position: fixed; top: 20px; right: 20px; z-index: 9999; display: flex; flex-direction: column; align-items: flex-end; }
.toast-message { background-color: rgba(0, 0, 0, 0.75); color: white; padding: 12px 20px; border-radius: 8px; margin-bottom: 10px; box-shadow: 0 4px 15px rgba(0,0,0,0.2); opacity: 0; transform: translateX(100%); transition: all 0.4s cubic-bezier(0.25, 0.8, 0.25, 1); }
.toast-message.show { opacity: 1; transform: translateX(0); }
.toast-message.success { background-color: #28a745; }
.toast-message.error { background-color: #dc3545; }
.toast-message.info { background-color: #17a2b8; }
</style>
</head>
<body>
<canvas id="bg-canvas"></canvas>
<div id="toast-container"></div>
<div class="main-content">
<div class="breadcrumb" id="breadcrumb"></div>
@ -148,49 +68,14 @@
<div id="loader" class="loading-status">正在探索云端内容...</div>
</div>
<button id="fab-cart" class="fab-cart" title="查看购物车" onclick="window.location.href='/cart'">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 16 16">
<path d="M0 1.5A.5.5 0 0 1 .5 1H2a.5.5 0 0 1 .485.379L2.89 3H14.5a.5.5 0 0 1 .491.592l-1.5 8A.5.5 0 0 1 13 12H4a.5.5 0 0 1-.491-.408L2.01 3.607 1.61 2H.5a.5.5 0 0 1-.5-1zM6 14a1 1 0 1 1-2 0 1 1 0 0 1 2 0zm7 0a1 1 0 1 1-2 0 1 1 0 0 1 2 0zM4.215 4.5l1.21 6.5h6.15l1.21-6.5H4.215z"/>
</svg>
</button>
<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 currentUserRole = 'none';
let currentPath = "";
let page = 1;
const size = 20;
@ -199,44 +84,72 @@
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();
function showToast(message, type = 'info') {
const container = document.getElementById('toast-container');
if (!container) return;
const toast = document.createElement('div');
toast.className = `toast-message ${type}`;
toast.textContent = message;
container.appendChild(toast);
setTimeout(() => { toast.classList.add('show'); }, 10);
setTimeout(() => {
toast.classList.remove('show');
toast.addEventListener('transitionend', () => toast.remove());
}, 3000);
}
async function fetchUserInfo() {
try {
const res = await fetch('/api/auth/info');
if (res.ok) {
const data = await res.json();
currentUserRole = data.role;
if (currentUserRole === 'user') {
document.getElementById('fab-cart').classList.add('show');
}
} else {
handleAuthError();
}
} catch (e) {
handleAuthError();
}
}, true);
}
function handleAuthError() {
if (window.isRedirecting) return;
window.isRedirecting = true;
alert("会话已失效,请重新登录");
window.location.href = "/login";
}
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";
if (!res.ok) {
handleAuthError();
}
});
}
async function loadLevel(path, isNextPage = false) {
if (loading || (isNextPage && !hasMore)) return;
loading = true;
const loader = document.getElementById('loader');
loader.style.display = 'block';
document.getElementById('loader').style.display = 'block';
if (!isNextPage) {
currentPath = path;
page = 1;
hasMore = true;
document.getElementById('folder-list').innerHTML = "";
document.getElementById('image-grid').innerHTML = "";
if (viewer) {
viewer.destroy();
viewer = null;
}
}
try {
const url = `/api/images/list?path=${encodeURIComponent(path)}&page=${page}&size=${size}`;
const res = await fetch(url);
if (res.status === 401) { checkAuth(); return; }
if (!res.ok) { handleAuthError(); return; }
const data = await res.json();
hasMore = data.hasMore;
if (!isNextPage) {
renderBreadcrumb(path);
@ -244,9 +157,10 @@
}
renderImages(data.images);
page++;
loader.innerText = hasMore ? "" : "—— 已经到底了 ——";
document.getElementById('loader').innerText = hasMore ? "" : "—— 已经到底了 ——";
} catch (err) {
loader.innerText = "加载失败,请检查连接";
console.error(err);
document.getElementById('loader').innerText = "加载失败,请检查连接";
} finally {
loading = false;
}
@ -274,36 +188,77 @@
});
}
function renderImages(images) {
function renderImages(imageGroups) {
const grid = document.getElementById('image-grid');
images.forEach(img => {
const cleanString = (str) => {
if (!str) return '';
if (str.includes('-effect')) {
return str.split('-effect')[0];
}
return str;
};
imageGroups.forEach(group => {
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>`;
const effectThumbUrl = group.effectThumbnailUrl ? `/thumb${group.effectThumbnailUrl}` : '';
const effectImageUrl = group.effectImageUrl ? `/raw${group.effectImageUrl}` : '';
const displayName = cleanString(group.name);
const itemId = cleanString(group.id);
const cartButtonHtml = currentUserRole === 'user' ? `<button class="add-to-cart-btn" data-id="${itemId}" title="加入购物车"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" viewBox="0 0 16 16"><path d="M.5 1a.5.5 0 0 0 0 1h1.11l.401 1.607 1.498 7.985A.5.5 0 0 0 4 12h1a2 2 0 1 0 0 4 2 2 0 0 0 0-4h7a2 2 0 1 0 0 4 2 2 0 0 0 0-4h1a.5.5 0 0 0 .491-.408l1.5-8A.5.5 0 0 0 14.5 3H2.89l-.405-1.621A.5.5 0 0 0 2 1zM6 14a1 1 0 1 1-2 0 1 1 0 0 1 2 0m7 0a1 1 0 1 1-2 0 1 1 0 0 1 2 0M9 5.5V7h1.5a.5.5 0 0 1 0 1H9v1.5a.5.5 0 0 1-1 0V8H6.5a.5.5 0 0 1 0-1H8V5.5a.5.5 0 0 1 1 0"/></svg></button>` : '';
item.innerHTML = `<img src="${effectThumbUrl}" data-original="${effectImageUrl}" alt="${displayName}" />${cartButtonHtml}<div class="image-info">${displayName}</div>`;
grid.appendChild(item);
});
if (viewer) {
viewer.update();
} else {
} else if (imageGroups.length > 0) {
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'); }
});
}
});
}
}
async function handleAddToCart(itemId) {
console.log(`Adding item ${itemId} to cart.`);
try {
const response = await fetch('/api/cart/add', { // **[PATH-FIX]** Reverted to /api/cart/add
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ itemId: itemId })
});
const result = await response.text();
if (response.ok) {
if (result.includes('已在购物车中')) {
showToast(result, 'info');
} else {
showToast(result, 'success');
}
} else if (response.status === 401) {
handleAuthError();
} else {
showToast(`添加失败: ${result}`, 'error');
}
} catch (error) {
showToast('网络错误,无法添加到购物车', 'error');
}
}
async function initializeApp() {
await fetchUserInfo();
document.getElementById('image-grid').addEventListener('click', function(event) {
const cartButton = event.target.closest('.add-to-cart-btn');
if (cartButton) {
event.stopPropagation();
handleAddToCart(cartButton.dataset.id);
}
});
loadLevel("");
}
initializeApp();
let scrollTimer;
window.onscroll = function () {
clearTimeout(scrollTimer);
@ -313,8 +268,6 @@
}
}, 150);
};
loadLevel("");
</script>
</body>
</html>
</html>