admin 页面增加统计功能

This commit is contained in:
wangli 2025-12-21 19:22:29 +08:00
parent 707b8c2551
commit 9025507bf3
2 changed files with 177 additions and 37 deletions

View File

@ -6,23 +6,25 @@ 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.annotation.PostConstruct;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.dao.DataAccessException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Controller;
import org.springframework.transaction.annotation.Transactional;
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 org.springframework.web.bind.annotation.*;
import javax.sql.DataSource;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.sql.Connection;
import java.sql.SQLException;
import java.sql.Statement;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.HashMap;
@ -35,12 +37,14 @@ import java.util.zip.ZipOutputStream;
@Controller
@RequestMapping("/api/admin")
// **[REMOVED]** Removed @SaCheckRole("admin") from the class level
public class AdminController {
@Autowired
private JdbcTemplate jdbcTemplate;
@Autowired
private DataSource dataSource;
@Value("${file.path}")
private String rootPath;
@Value("${app.secure-key}")
@ -50,27 +54,42 @@ public class AdminController {
private static final DateTimeFormatter formatter = DateTimeFormatter.ISO_LOCAL_DATE_TIME;
// This method is now PUBLIC and requires NO LOGIN
@PostConstruct
public void init() {
try (Connection conn = dataSource.getConnection(); Statement stmt = conn.createStatement()) {
// Create delivered auth codes table
stmt.execute("CREATE TABLE IF NOT EXISTS delivered_auth_codes (" +
"id INTEGER PRIMARY KEY AUTOINCREMENT," +
"code TEXT NOT NULL UNIQUE," +
"order_no TEXT," +
"login_limit INTEGER DEFAULT 1," +
"login_count INTEGER DEFAULT 0," +
"created_at TEXT," +
"expires_at TEXT," +
"delivered_at TEXT," +
"status INTEGER DEFAULT 2)"); // 2 for delivered
// Create delivered cart items table
stmt.execute("CREATE TABLE IF NOT EXISTS delivered_cart_items (" +
"id INTEGER PRIMARY KEY AUTOINCREMENT," +
"auth_code TEXT NOT NULL," +
"item_id TEXT NOT NULL)");
} catch (SQLException e) {
throw new RuntimeException("Failed to create delivered tables", e);
}
}
@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 (?, ?, ?, ?, ?, ?)";
@ -85,31 +104,64 @@ public class AdminController {
@GetMapping("/codes")
@ResponseBody
@SaCheckRole("admin") // Add annotation to each protected method
@SaCheckRole("admin")
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");
return jdbcTemplate.queryForList("SELECT code, order_no, login_limit, login_count, expires_at FROM auth_codes WHERE status = 1 ORDER BY created_at DESC");
}
@GetMapping("/cart/{code}")
@ResponseBody
@SaCheckRole("admin") // Add annotation to each protected method
@SaCheckRole("admin")
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 {
@PostMapping("/deliver/{code}")
@ResponseBody
@SaCheckRole("admin")
@Transactional
public void deliverCode(@PathVariable String code) {
// 1. Get the auth_code details
Map<String, Object> authCode = jdbcTemplate.queryForMap("SELECT * FROM auth_codes WHERE code = ?", code);
// 2. Insert into delivered_auth_codes
jdbcTemplate.update("INSERT INTO delivered_auth_codes (code, order_no, login_limit, login_count, created_at, expires_at, delivered_at, status) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
authCode.get("code"), authCode.get("order_no"), authCode.get("login_limit"), authCode.get("login_count"),
authCode.get("created_at"), authCode.get("expires_at"), formatter.format(LocalDateTime.now()), 2);
// 3. Get cart items
List<String> itemIds = jdbcTemplate.queryForList("SELECT item_id FROM cart_items WHERE auth_code = ?", String.class, code);
// 4. Insert into delivered_cart_items
for (String itemId : itemIds) {
jdbcTemplate.update("INSERT INTO delivered_cart_items (auth_code, item_id) VALUES (?, ?)", code, itemId);
}
// 5. Delete from original tables
jdbcTemplate.update("DELETE FROM cart_items WHERE auth_code = ?", code);
jdbcTemplate.update("DELETE FROM auth_codes WHERE code = ?", code);
}
@PostMapping("/cancel/{code}")
@ResponseBody
@SaCheckRole("admin")
@Transactional
public void cancelCode(@PathVariable String code) {
jdbcTemplate.update("DELETE FROM cart_items WHERE auth_code = ?", code);
jdbcTemplate.update("DELETE FROM auth_codes WHERE code = ?", code);
}
@GetMapping("/download-cart/{code}")
@SaCheckRole("admin")
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);
@ -129,20 +181,17 @@ public class AdminController {
}
}
// ... (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));

View File

@ -53,12 +53,58 @@
margin-bottom: 10px;
border-radius: 4px;
border: 1px solid #ccc;
box-sizing: border-box;
}
.code-list-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
}
.code-list-header h3 {
margin: 0;
}
.code-list-header #code-count {
font-size: 14px;
font-weight: bold;
color: var(--primary-color);
}
.cart-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.cart-header h2 {
margin: 0;
}
.cart-header #cart-item-count {
font-size: 16px;
font-weight: bold;
}
.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: #d1e7fd; }
.code-list-item strong { font-size: 14px; color: #333; }
.code-list-item span { font-size: 12px; color: #777; display: block; margin-top: 4px; }
.code-list-item .actions { margin-top: 8px; display: flex; gap: 8px; }
.code-list-item .actions button {
padding: 4px 8px;
font-size: 10px;
border: none;
border-radius: 4px;
cursor: pointer;
}
.code-list-item .actions .deliver-btn { background-color: #28a745; color: white; }
.code-list-item .actions .cancel-btn { background-color: #dc3545; color: white; }
.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: var(--card-shadow); }
.grid-item img { width: 100%; height: auto; display: block; }
@ -73,13 +119,19 @@
<div id="particles-js"></div>
<div class="sidebar" id="sidebar">
<div class="code-list-header">
<h3>授权码列表</h3>
<div id="code-count"></div>
</div>
<input type="text" id="search-input" placeholder="搜索授权码...">
<div id="code-list"></div>
</div>
<div class="main-content">
<div class="cart-header">
<h2>购物车详情</h2>
<div id="cart-item-count"></div>
</div>
<button id="download-btn">打包下载模板原图</button>
<div class="grid" id="cart-grid"></div>
<div id="loader">请从左侧选择一个授权码查看</div>
@ -91,6 +143,8 @@
const loaderEl = document.getElementById('loader');
const downloadBtn = document.getElementById('download-btn');
const searchInput = document.getElementById('search-input');
const codeCountEl = document.getElementById('code-count');
const cartItemCountEl = document.getElementById('cart-item-count');
let activeCode = null;
let allCodes = [];
@ -108,17 +162,26 @@
function renderCodeList() {
const searchTerm = searchInput.value.toLowerCase();
const filteredCodes = allCodes.filter(code =>
(code.order_id && code.order_id.toLowerCase().includes(searchTerm)) ||
(code.order_no && code.order_no.toLowerCase().includes(searchTerm)) ||
code.code.toLowerCase().includes(searchTerm)
);
codeCountEl.innerText = `待处理: ${allCodes.length}`;
codeListEl.innerHTML = '';
filteredCodes.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} | 失效: ${new Date(code.expires_at).toLocaleString()}</span>`;
item.onclick = () => loadCartForCode(code.code);
item.innerHTML = `
<div onclick="loadCartForCode('${code.code}')">
<strong>${code.order_no || code.code}</strong>
<span>登录/上限:${code.login_count}/${code.login_limit} | 失效: ${new Date(code.expires_at).toLocaleString()}</span>
</div>
<div class="actions">
<button class="deliver-btn" onclick="deliverCodeAction('${code.code}', event)">已交付</button>
<button class="cancel-btn" onclick="cancelCodeAction('${code.code}', event)">已取消</button>
</div>
`;
codeListEl.appendChild(item);
});
@ -130,11 +193,38 @@
}
}
async function deliverCodeAction(code, event) {
event.stopPropagation();
if (!confirm(`确定要将授权码 ${code} 标记为已交付吗?`)) return;
try {
const response = await fetch(`/api/admin/deliver/${code}`, { method: 'POST' });
if (!response.ok) throw new Error('操作失败');
fetchAuthCodes(); // Refresh the list
} catch (e) {
alert(e.message);
}
}
async function cancelCodeAction(code, event) {
event.stopPropagation();
if (!confirm(`确定要取消授权码 ${code} 吗?此操作不可逆!`)) return;
try {
const response = await fetch(`/api/admin/cancel/${code}`, { method: 'POST' });
if (!response.ok) throw new Error('操作失败');
fetchAuthCodes(); // Refresh the list
} catch (e) {
alert(e.message);
}
}
async function loadCartForCode(code) {
activeCode = code;
renderCodeList(); // Re-render to highlight the active item
cartGridEl.innerHTML = '';
cartItemCountEl.innerText = '';
loaderEl.innerText = '正在加载...';
loaderEl.style.display = 'block';
downloadBtn.style.display = 'none';
@ -144,6 +234,8 @@
if (!response.ok) throw new Error('Failed to load cart');
const items = await response.json();
cartItemCountEl.innerText = `总数: ${items.length}`;
if (items.length === 0) {
loaderEl.innerText = '这个购物车是空的';
return;
@ -177,9 +269,8 @@
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`;
const filename = `${activeItem.innerText.replace(/\s+/g, '_')}_templates.zip`;
a.download = filename;
document.body.appendChild(a);
a.click();