添加授权码用户购物车的功能
This commit is contained in:
parent
b8771c7156
commit
0f448ec33e
5
HELP.md
5
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
|
||||
应用环境变量: -e FILE_PATH 图片文件夹根目录 APP_SECURE_PASSWORD 系统登录密码 APP_SECURE_AUTH 系统生成授权码API密钥 APP_SECURE_KEY 系统生成授权码的 AES KEY
|
||||
|
||||
|
||||
APIFOX API 令牌:APS-JXFgORmNs7eiW3NsWuBWYfpI6mGvBrFr
|
||||
11
pom.xml
11
pom.xml
@ -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>
|
||||
|
||||
@ -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("/**");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
133
src/main/java/eu/org/biwin/screen/controller/CartController.java
Normal file
133
src/main/java/eu/org/biwin/screen/controller/CartController.java
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
66
src/main/java/eu/org/biwin/screen/model/ImageGroup.java
Normal file
66
src/main/java/eu/org/biwin/screen/model/ImageGroup.java
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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.");
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,2 @@
|
||||
// This file is used to create the service package.
|
||||
package eu.org.biwin.screen.service;
|
||||
@ -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
|
||||
|
||||
133
src/main/resources/templates/admin.html
Normal file
133
src/main/resources/templates/admin.html
Normal 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>
|
||||
145
src/main/resources/templates/cart.html
Normal file
145
src/main/resources/templates/cart.html
Normal 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>
|
||||
@ -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>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user