From 0f448ec33e55e5310c1633dec105847ed2633ea5 Mon Sep 17 00:00:00 2001 From: wangli Date: Sun, 21 Dec 2025 17:34:32 +0800 Subject: [PATCH] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E6=8E=88=E6=9D=83=E7=A0=81?= =?UTF-8?q?=E7=94=A8=E6=88=B7=E8=B4=AD=E7=89=A9=E8=BD=A6=E7=9A=84=E5=8A=9F?= =?UTF-8?q?=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- HELP.md | 5 +- pom.xml | 11 + .../biwin/screen/advice/SaTokenConfigure.java | 44 ++- .../biwin/screen/advice/StpInterfaceImpl.java | 42 +++ .../screen/controller/AdminController.java | 171 ++++++++++ .../screen/controller/AuthController.java | 104 ++---- .../screen/controller/CartController.java | 133 ++++++++ .../screen/controller/GalleryController.java | 240 ++++++------- .../controller/ThumbnailController.java | 57 ++-- .../screen/model/GenerateCodeRequest.java | 59 ++++ .../eu/org/biwin/screen/model/ImageGroup.java | 66 ++++ .../biwin/screen/service/DatabaseService.java | 47 +++ .../biwin/screen/service/package-info.java | 2 + src/main/resources/application.yml | 26 +- src/main/resources/templates/admin.html | 133 ++++++++ src/main/resources/templates/cart.html | 145 ++++++++ src/main/resources/templates/index.html | 317 ++++++++---------- 17 files changed, 1165 insertions(+), 437 deletions(-) create mode 100644 src/main/java/eu/org/biwin/screen/advice/StpInterfaceImpl.java create mode 100644 src/main/java/eu/org/biwin/screen/controller/AdminController.java create mode 100644 src/main/java/eu/org/biwin/screen/controller/CartController.java create mode 100644 src/main/java/eu/org/biwin/screen/model/GenerateCodeRequest.java create mode 100644 src/main/java/eu/org/biwin/screen/model/ImageGroup.java create mode 100644 src/main/java/eu/org/biwin/screen/service/DatabaseService.java create mode 100644 src/main/java/eu/org/biwin/screen/service/package-info.java create mode 100644 src/main/resources/templates/admin.html create mode 100644 src/main/resources/templates/cart.html diff --git a/HELP.md b/HELP.md index ca07733..6aa3372 100644 --- a/HELP.md +++ b/HELP.md @@ -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 \ No newline at end of file +应用环境变量: -e FILE_PATH 图片文件夹根目录 APP_SECURE_PASSWORD 系统登录密码 APP_SECURE_AUTH 系统生成授权码API密钥 APP_SECURE_KEY 系统生成授权码的 AES KEY + + +APIFOX API 令牌:APS-JXFgORmNs7eiW3NsWuBWYfpI6mGvBrFr \ No newline at end of file diff --git a/pom.xml b/pom.xml index d0cae8d..eed58fd 100644 --- a/pom.xml +++ b/pom.xml @@ -38,6 +38,17 @@ org.springframework.boot spring-boot-starter-thymeleaf + + + org.springframework.boot + spring-boot-starter-jdbc + + + + org.xerial + sqlite-jdbc + 3.45.1.0 + cn.dev33 sa-token-spring-boot3-starter diff --git a/src/main/java/eu/org/biwin/screen/advice/SaTokenConfigure.java b/src/main/java/eu/org/biwin/screen/advice/SaTokenConfigure.java index 796ad18..f90265e 100644 --- a/src/main/java/eu/org/biwin/screen/advice/SaTokenConfigure.java +++ b/src/main/java/eu/org/biwin/screen/advice/SaTokenConfigure.java @@ -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("/**"); } -} \ No newline at end of file +} diff --git a/src/main/java/eu/org/biwin/screen/advice/StpInterfaceImpl.java b/src/main/java/eu/org/biwin/screen/advice/StpInterfaceImpl.java new file mode 100644 index 0000000..99224e2 --- /dev/null +++ b/src/main/java/eu/org/biwin/screen/advice/StpInterfaceImpl.java @@ -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 getPermissionList(Object loginId, String loginType) { + return new ArrayList<>(); + } + + /** + * 返回一个用户所拥有的角色标识集合。 + */ + @Override + public List getRoleList(Object loginId, String loginType) { + List 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; + } +} diff --git a/src/main/java/eu/org/biwin/screen/controller/AdminController.java b/src/main/java/eu/org/biwin/screen/controller/AdminController.java new file mode 100644 index 0000000..ed20df5 --- /dev/null +++ b/src/main/java/eu/org/biwin/screen/controller/AdminController.java @@ -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 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> 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 getCartForCode(@PathVariable String code) { + List 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 itemIds = jdbcTemplate.queryForList("SELECT item_id FROM cart_items WHERE auth_code = ?", String.class, code); + + Map 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 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; + } +} diff --git a/src/main/java/eu/org/biwin/screen/controller/AuthController.java b/src/main/java/eu/org/biwin/screen/controller/AuthController.java index 22d44a0..f9d8455 100644 --- a/src/main/java/eu/org/biwin/screen/controller/AuthController.java +++ b/src/main/java/eu/org/biwin/screen/controller/AuthController.java @@ -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 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 lines = FileUtil.readUtf8Lines(file); - List 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 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> getUserInfo() { + StpUtil.checkLogin(); + Map info = new HashMap<>(); + + String role = StpUtil.getRoleList().stream().findFirst().orElse("none"); + info.put("role", role); + + return new ResponseEntity<>(info, HttpStatus.OK); } } diff --git a/src/main/java/eu/org/biwin/screen/controller/CartController.java b/src/main/java/eu/org/biwin/screen/controller/CartController.java new file mode 100644 index 0000000..6297a5f --- /dev/null +++ b/src/main/java/eu/org/biwin/screen/controller/CartController.java @@ -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 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 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 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 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 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; + } +} diff --git a/src/main/java/eu/org/biwin/screen/controller/GalleryController.java b/src/main/java/eu/org/biwin/screen/controller/GalleryController.java index 606a616..e106a6f 100644 --- a/src/main/java/eu/org/biwin/screen/controller/GalleryController.java +++ b/src/main/java/eu/org/biwin/screen/controller/GalleryController.java @@ -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. - *

- * 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_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("(?\\d+)|(?\\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 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 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 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> folders = new ArrayList<>(); - List> images = new ArrayList<>(); + Map> 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 imageGroups = groupedFiles.entrySet().stream() + .map(entry -> { + String uniqueId = entry.getKey(); + String displayName = new File(uniqueId).getName(); + Map typeMap = entry.getValue(); + + ImageGroup group = new ImageGroup(uniqueId, displayName); + + java.util.function.Function 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> pagedImages = new ArrayList<>(); - if (start < images.size()) { - int end = Math.min(start + size, images.size()); - pagedImages = images.subList(start, end); + List 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() ); } -} \ No newline at end of file +} diff --git a/src/main/java/eu/org/biwin/screen/controller/ThumbnailController.java b/src/main/java/eu/org/biwin/screen/controller/ThumbnailController.java index 75190fa..d20f8b5 100644 --- a/src/main/java/eu/org/biwin/screen/controller/ThumbnailController.java +++ b/src/main/java/eu/org/biwin/screen/controller/ThumbnailController.java @@ -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()); } -} \ No newline at end of file +} diff --git a/src/main/java/eu/org/biwin/screen/model/GenerateCodeRequest.java b/src/main/java/eu/org/biwin/screen/model/GenerateCodeRequest.java new file mode 100644 index 0000000..839296b --- /dev/null +++ b/src/main/java/eu/org/biwin/screen/model/GenerateCodeRequest.java @@ -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; + } +} diff --git a/src/main/java/eu/org/biwin/screen/model/ImageGroup.java b/src/main/java/eu/org/biwin/screen/model/ImageGroup.java new file mode 100644 index 0000000..af382b9 --- /dev/null +++ b/src/main/java/eu/org/biwin/screen/model/ImageGroup.java @@ -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; + } +} diff --git a/src/main/java/eu/org/biwin/screen/service/DatabaseService.java b/src/main/java/eu/org/biwin/screen/service/DatabaseService.java new file mode 100644 index 0000000..a43abcf --- /dev/null +++ b/src/main/java/eu/org/biwin/screen/service/DatabaseService.java @@ -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."); + } +} diff --git a/src/main/java/eu/org/biwin/screen/service/package-info.java b/src/main/java/eu/org/biwin/screen/service/package-info.java new file mode 100644 index 0000000..aed6bd5 --- /dev/null +++ b/src/main/java/eu/org/biwin/screen/service/package-info.java @@ -0,0 +1,2 @@ +// This file is used to create the service package. +package eu.org.biwin.screen.service; diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 990b31f..4480c6e 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -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 \ No newline at end of file + timeout: 1800 # Token 有效期,单位秒 30 * 60 = 1800 秒,即 30 分钟。 + is-print: false diff --git a/src/main/resources/templates/admin.html b/src/main/resources/templates/admin.html new file mode 100644 index 0000000..ea05330 --- /dev/null +++ b/src/main/resources/templates/admin.html @@ -0,0 +1,133 @@ + + + + + + 管理后台 + + + + +

+ +
+

购物车详情

+ +
+
请从左侧选择一个授权码查看
+
+ + + + + diff --git a/src/main/resources/templates/cart.html b/src/main/resources/templates/cart.html new file mode 100644 index 0000000..d1ecd3a --- /dev/null +++ b/src/main/resources/templates/cart.html @@ -0,0 +1,145 @@ + + + + + + 我的购物车 + + + + + + + + +
+

我的购物车

+
+
正在加载...
+
+ + + + + diff --git a/src/main/resources/templates/index.html b/src/main/resources/templates/index.html index 89bc897..2bca0bf 100644 --- a/src/main/resources/templates/index.html +++ b/src/main/resources/templates/index.html @@ -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; } - +
@@ -148,49 +68,14 @@
正在探索云端内容...
+ + - \ No newline at end of file +