水印优化+定时任务删除+照片管理
This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
package com.accounting.controller;
|
package com.accounting.controller;
|
||||||
|
|
||||||
|
import com.accounting.dto.PhotoPageResponse;
|
||||||
import com.accounting.dto.PhotoResponse;
|
import com.accounting.dto.PhotoResponse;
|
||||||
import com.accounting.entity.User;
|
import com.accounting.entity.User;
|
||||||
import com.accounting.mapper.UserMapper;
|
import com.accounting.mapper.UserMapper;
|
||||||
@@ -11,6 +12,9 @@ import org.springframework.beans.factory.annotation.Autowired;
|
|||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.security.core.Authentication;
|
import org.springframework.security.core.Authentication;
|
||||||
import org.springframework.security.core.userdetails.UserDetails;
|
import org.springframework.security.core.userdetails.UserDetails;
|
||||||
|
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||||
|
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.PostMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestParam;
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
@@ -57,6 +61,25 @@ public class PhotoController {
|
|||||||
return ResponseEntity.ok(response);
|
return ResponseEntity.ok(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "分页查询照片,最新的在前")
|
||||||
|
@GetMapping
|
||||||
|
public ResponseEntity<PhotoPageResponse> list(
|
||||||
|
@RequestParam(defaultValue = "1") int page,
|
||||||
|
@RequestParam(defaultValue = "30") int size,
|
||||||
|
Authentication authentication) {
|
||||||
|
|
||||||
|
Long userId = getUserId(authentication);
|
||||||
|
return ResponseEntity.ok(photoService.list(userId, page, size));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "删除照片(连同磁盘上的原图和缩略图)")
|
||||||
|
@DeleteMapping("/{id}")
|
||||||
|
public ResponseEntity<Void> delete(@PathVariable Long id, Authentication authentication) {
|
||||||
|
Long userId = getUserId(authentication);
|
||||||
|
photoService.delete(userId, id);
|
||||||
|
return ResponseEntity.ok().build();
|
||||||
|
}
|
||||||
|
|
||||||
private Long getUserId(Authentication authentication) {
|
private Long getUserId(Authentication authentication) {
|
||||||
UserDetails userDetails = (UserDetails) authentication.getPrincipal();
|
UserDetails userDetails = (UserDetails) authentication.getPrincipal();
|
||||||
String username = userDetails.getUsername();
|
String username = userDetails.getUsername();
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package com.accounting.dto;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 照片分页响应
|
||||||
|
*
|
||||||
|
* <p>字段和 {@code NotePageResponse} 对齐(list / total / page / size / hasMore),
|
||||||
|
* App 端两边的分页处理可以长得一样。</p>
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
public class PhotoPageResponse {
|
||||||
|
|
||||||
|
private List<PhotoResponse> list;
|
||||||
|
|
||||||
|
private long total;
|
||||||
|
|
||||||
|
private int page;
|
||||||
|
|
||||||
|
private int size;
|
||||||
|
|
||||||
|
private boolean hasMore;
|
||||||
|
}
|
||||||
@@ -19,6 +19,14 @@ public class PhotoResponse {
|
|||||||
/** 可直接访问的地址,如 /uploads/photo/2026/09/28/xxx.jpg */
|
/** 可直接访问的地址,如 /uploads/photo/2026/09/28/xxx.jpg */
|
||||||
private String url;
|
private String url;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 缩略图地址,如 /uploads/photo/2026/09/28/xxx.thumb.jpg
|
||||||
|
*
|
||||||
|
* <p>列表页只加载它 —— 原图一张 3~8MB,一屏加载原图要上百 MB。
|
||||||
|
* 为 null 表示缩略图没生成成功,客户端可以回退用原图。</p>
|
||||||
|
*/
|
||||||
|
private String thumbUrl;
|
||||||
|
|
||||||
private Long fileSize;
|
private Long fileSize;
|
||||||
|
|
||||||
private String originalName;
|
private String originalName;
|
||||||
|
|||||||
@@ -3,9 +3,22 @@ package com.accounting.schedule;
|
|||||||
import com.accounting.service.GoldPriceService;
|
import com.accounting.service.GoldPriceService;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.scheduling.annotation.Scheduled;
|
import org.springframework.scheduling.annotation.Scheduled;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import jakarta.annotation.PostConstruct;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 金价定时抓取。
|
||||||
|
*
|
||||||
|
* <p>⚠️ 2026-09-28 起**默认关闭**({@code gold-price.scheduler.enabled=false}):
|
||||||
|
* App 端没用这个功能,而这个第三方接口后来改过返回格式(多了 msgid 字段,
|
||||||
|
* 导致 K780Response 反序列化失败),于是每 15 分钟往日志里刷一次 ERROR,纯噪音。</p>
|
||||||
|
*
|
||||||
|
* <p>代码保留、接口 {@code /api/gold-price/**} 照常可用 —— 想恢复只需在服务器
|
||||||
|
* {@code .env} 里加 {@code GOLD_PRICE_SCHEDULER_ENABLED=true} 再重启,不用改代码。</p>
|
||||||
|
*/
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@Component
|
@Component
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
@@ -15,8 +28,25 @@ public class GoldPriceScheduler {
|
|||||||
|
|
||||||
private final GoldPriceService goldPriceService;
|
private final GoldPriceService goldPriceService;
|
||||||
|
|
||||||
|
@Value("${gold-price.scheduler.enabled:false}")
|
||||||
|
private boolean enabled;
|
||||||
|
|
||||||
|
@PostConstruct
|
||||||
|
public void logStatus() {
|
||||||
|
if (enabled) {
|
||||||
|
log.info("金价定时抓取已启用(每 15 分钟)");
|
||||||
|
} else {
|
||||||
|
log.info("金价定时抓取已关闭(gold-price.scheduler.enabled=false)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Scheduled(cron = "0 */15 * * * ?", zone = "Asia/Shanghai")
|
@Scheduled(cron = "0 */15 * * * ?", zone = "Asia/Shanghai")
|
||||||
public void fetchPeriodicPrice() {
|
public void fetchPeriodicPrice() {
|
||||||
|
// 开关关着就直接返回。不用 @ConditionalOnProperty 是因为那要作用在 Bean 上,
|
||||||
|
// 而这里只想停掉这个「任务」,不想让整个 SchedulerConfig 的线程池也变样。
|
||||||
|
if (!enabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
runJob("periodic");
|
runJob("periodic");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,19 +1,26 @@
|
|||||||
package com.accounting.service;
|
package com.accounting.service;
|
||||||
|
|
||||||
|
import com.accounting.dto.PhotoPageResponse;
|
||||||
import com.accounting.dto.PhotoResponse;
|
import com.accounting.dto.PhotoResponse;
|
||||||
import com.accounting.entity.Photo;
|
import com.accounting.entity.Photo;
|
||||||
import com.accounting.mapper.PhotoMapper;
|
import com.accounting.mapper.PhotoMapper;
|
||||||
import com.accounting.util.FileUtil;
|
import com.accounting.util.FileUtil;
|
||||||
|
import com.accounting.util.ImageThumbUtil;
|
||||||
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
import org.springframework.web.multipart.MultipartFile;
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
|
||||||
|
import java.io.File;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.time.format.DateTimeFormatter;
|
import java.time.format.DateTimeFormatter;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 照片服务
|
* 照片服务
|
||||||
@@ -40,11 +47,15 @@ public class PhotoService {
|
|||||||
private static final int MAX_REMARK = 500;
|
private static final int MAX_REMARK = 500;
|
||||||
private static final int MAX_ORIGINAL_NAME = 255;
|
private static final int MAX_ORIGINAL_NAME = 255;
|
||||||
|
|
||||||
|
/** 列表单页上限,防止客户端要一个巨大的 size */
|
||||||
|
private static final int MAX_PAGE_SIZE = 60;
|
||||||
|
|
||||||
private static final DateTimeFormatter TAKEN_TIME_FORMAT =
|
private static final DateTimeFormatter TAKEN_TIME_FORMAT =
|
||||||
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
|
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
|
||||||
|
|
||||||
private final PhotoMapper photoMapper;
|
private final PhotoMapper photoMapper;
|
||||||
private final FileUtil fileUtil;
|
private final FileUtil fileUtil;
|
||||||
|
private final ImageThumbUtil imageThumbUtil;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 保存一张照片。
|
* 保存一张照片。
|
||||||
@@ -88,11 +99,107 @@ public class PhotoService {
|
|||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 顺手生成缩略图。失败也无所谓(原图已经存好了),列表页会再补一次
|
||||||
|
ensureThumbnail(relativePath);
|
||||||
|
|
||||||
log.info("照片已保存: userId={}, id={}, size={}B, path={}",
|
log.info("照片已保存: userId={}, id={}, size={}B, path={}",
|
||||||
userId, photo.getId(), photo.getFileSize(), relativePath);
|
userId, photo.getId(), photo.getFileSize(), relativePath);
|
||||||
return toResponse(photo);
|
return toResponse(photo);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分页列表,最新的在前。
|
||||||
|
*
|
||||||
|
* <p>和 {@code NoteService} 一样手写 LIMIT —— 项目里没配 MyBatis-Plus 的
|
||||||
|
* 分页插件,{@code selectPage} 不会真的生成 LIMIT。</p>
|
||||||
|
*
|
||||||
|
* <p>顺带补齐缩略图:这次改动之前上传的照片没有缩略图,借列表请求补上,
|
||||||
|
* 不用手工刷数据。只有第一次会慢一点,之后都是现成的文件。</p>
|
||||||
|
*/
|
||||||
|
public PhotoPageResponse list(Long userId, int page, int size) {
|
||||||
|
int safePage = Math.max(page, 1);
|
||||||
|
int safeSize = Math.min(Math.max(size, 1), MAX_PAGE_SIZE);
|
||||||
|
|
||||||
|
long total = photoMapper.selectCount(
|
||||||
|
new LambdaQueryWrapper<Photo>().eq(Photo::getUserId, userId));
|
||||||
|
|
||||||
|
List<Photo> rows;
|
||||||
|
if (total == 0) {
|
||||||
|
rows = new ArrayList<>();
|
||||||
|
} else {
|
||||||
|
int offset = (safePage - 1) * safeSize;
|
||||||
|
rows = photoMapper.selectList(
|
||||||
|
new LambdaQueryWrapper<Photo>()
|
||||||
|
.eq(Photo::getUserId, userId)
|
||||||
|
.orderByDesc(Photo::getId)
|
||||||
|
.last("LIMIT " + offset + "," + safeSize));
|
||||||
|
}
|
||||||
|
|
||||||
|
PhotoPageResponse response = new PhotoPageResponse();
|
||||||
|
response.setList(rows.stream().map(this::toResponse).collect(Collectors.toList()));
|
||||||
|
response.setTotal(total);
|
||||||
|
response.setPage(safePage);
|
||||||
|
response.setSize(safeSize);
|
||||||
|
response.setHasMore((long) safePage * safeSize < total);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除一张照片:先逻辑删记录,再把磁盘上的原图和缩略图都删掉。
|
||||||
|
*
|
||||||
|
* <p>磁盘删除失败**不回滚**事务 —— 记录已经从用户的列表里消失了,
|
||||||
|
* 留个删不掉的文件只是浪费几 MB,而抛异常会让用户以为「删除失败」、
|
||||||
|
* 反复重试。所以失败只记日志。</p>
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public void delete(Long userId, Long id) {
|
||||||
|
Photo photo = photoMapper.selectOne(
|
||||||
|
new LambdaQueryWrapper<Photo>()
|
||||||
|
.eq(Photo::getId, id)
|
||||||
|
.eq(Photo::getUserId, userId));
|
||||||
|
if (photo == null) {
|
||||||
|
throw new IllegalArgumentException("照片不存在");
|
||||||
|
}
|
||||||
|
|
||||||
|
photoMapper.deleteById(photo.getId());
|
||||||
|
|
||||||
|
String filePath = photo.getFilePath();
|
||||||
|
if (!fileUtil.deleteFile(filePath)) {
|
||||||
|
log.warn("照片记录已删,但文件删不掉(可能已不存在): {}", filePath);
|
||||||
|
}
|
||||||
|
String thumbPath = thumbRelativePath(filePath);
|
||||||
|
if (!fileUtil.deleteFile(thumbPath)) {
|
||||||
|
log.warn("缩略图删不掉(可能本来就没生成过): {}", thumbPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info("照片已删除: userId={}, id={}, path={}", userId, id, filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 确保缩略图存在,返回它的相对路径;生成不出来返回 null。
|
||||||
|
*
|
||||||
|
* <p>叫「ensure」不叫「generate」是因为它是补齐语义:上传时调一次,
|
||||||
|
* 列表接口对老照片再调一次 —— 已经有了就直接返回,不重复干活。</p>
|
||||||
|
*/
|
||||||
|
private String ensureThumbnail(String relativePath) {
|
||||||
|
String thumbPath = thumbRelativePath(relativePath);
|
||||||
|
File thumbFile = new File(fileUtil.getFullPath(thumbPath));
|
||||||
|
if (thumbFile.exists() && thumbFile.length() > 0) {
|
||||||
|
return thumbPath;
|
||||||
|
}
|
||||||
|
File source = new File(fileUtil.getFullPath(relativePath));
|
||||||
|
if (!source.exists()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return imageThumbUtil.generate(source, thumbFile) ? thumbPath : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** photo/2026/09/28/xxx.jpg → photo/2026/09/28/xxx.thumb.jpg */
|
||||||
|
private String thumbRelativePath(String filePath) {
|
||||||
|
int dot = filePath.lastIndexOf('.');
|
||||||
|
return dot < 0 ? filePath + ".thumb.jpg" : filePath.substring(0, dot) + ".thumb.jpg";
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 解析客户端传来的拍摄时间。
|
* 解析客户端传来的拍摄时间。
|
||||||
*
|
*
|
||||||
@@ -124,6 +231,12 @@ public class PhotoService {
|
|||||||
PhotoResponse response = new PhotoResponse();
|
PhotoResponse response = new PhotoResponse();
|
||||||
response.setId(photo.getId());
|
response.setId(photo.getId());
|
||||||
response.setUrl("/uploads/" + photo.getFilePath());
|
response.setUrl("/uploads/" + photo.getFilePath());
|
||||||
|
|
||||||
|
// 列表页只加载缩略图:原图一张 3~8MB,一屏就能吃掉上百 MB。
|
||||||
|
// 这里做的是「没有就补」—— 老照片第一次列表时会被补上。
|
||||||
|
String thumb = ensureThumbnail(photo.getFilePath());
|
||||||
|
response.setThumbUrl(thumb == null ? null : "/uploads/" + thumb);
|
||||||
|
|
||||||
response.setFileSize(photo.getFileSize());
|
response.setFileSize(photo.getFileSize());
|
||||||
response.setOriginalName(photo.getOriginalName());
|
response.setOriginalName(photo.getOriginalName());
|
||||||
response.setLatitude(photo.getLatitude());
|
response.setLatitude(photo.getLatitude());
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
package com.accounting.util;
|
||||||
|
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import javax.imageio.IIOImage;
|
||||||
|
import javax.imageio.ImageIO;
|
||||||
|
import javax.imageio.ImageWriteParam;
|
||||||
|
import javax.imageio.ImageWriter;
|
||||||
|
import javax.imageio.stream.ImageOutputStream;
|
||||||
|
import java.awt.Graphics2D;
|
||||||
|
import java.awt.RenderingHints;
|
||||||
|
import java.awt.image.BufferedImage;
|
||||||
|
import java.io.File;
|
||||||
|
import java.util.Iterator;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 缩略图生成。
|
||||||
|
*
|
||||||
|
* <p>照片列表页不可能直接加载原图 —— 手机拍的 12MP 照片一张 3~8MB,
|
||||||
|
* 一屏 20 张就是上百 MB。所以上传时(以及翻到旧图时)顺手生成一张
|
||||||
|
* 最长边 {@value #MAX_WIDTH} 的小图,列表只加载它。</p>
|
||||||
|
*
|
||||||
|
* <p>用 JDK 自带的 Java2D({@code javax.imageio} + {@code Graphics2D}),
|
||||||
|
* 不引入任何新依赖 —— 和项目「能不加包就不加」的取向一致。</p>
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Component
|
||||||
|
public class ImageThumbUtil {
|
||||||
|
|
||||||
|
/** 缩略图最长边(像素)。400 在手机上显示 3 列网格足够了 */
|
||||||
|
private static final int MAX_WIDTH = 400;
|
||||||
|
|
||||||
|
/** JPEG 质量。缩略图不需要太精细 */
|
||||||
|
private static final float QUALITY = 0.82f;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成缩略图。
|
||||||
|
*
|
||||||
|
* <p>失败不抛异常,只返回 false —— 缩略图是「锦上添花」,生成不出来
|
||||||
|
* 不该把上传整体搞失败(原图已经存好了)。</p>
|
||||||
|
*
|
||||||
|
* @return 是否生成成功
|
||||||
|
*/
|
||||||
|
public boolean generate(File source, File target) {
|
||||||
|
try {
|
||||||
|
BufferedImage original = ImageIO.read(source);
|
||||||
|
if (original == null) {
|
||||||
|
log.warn("缩略图跳过:读不出图片内容 {}", source.getPath());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
int srcW = original.getWidth();
|
||||||
|
int srcH = original.getHeight();
|
||||||
|
if (srcW <= 0 || srcH <= 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
int width = Math.min(srcW, MAX_WIDTH);
|
||||||
|
int height = Math.max(1, (int) Math.round(srcH * (width / (double) srcW)));
|
||||||
|
|
||||||
|
// 统一转成 RGB:JPEG 不支持透明通道,源图可能是 PNG
|
||||||
|
BufferedImage scaled = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
|
||||||
|
Graphics2D g = scaled.createGraphics();
|
||||||
|
g.setRenderingHint(RenderingHints.KEY_INTERPOLATION,
|
||||||
|
RenderingHints.VALUE_INTERPOLATION_BILINEAR);
|
||||||
|
g.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY);
|
||||||
|
g.drawImage(original, 0, 0, width, height, null);
|
||||||
|
g.dispose();
|
||||||
|
|
||||||
|
File parent = target.getParentFile();
|
||||||
|
if (parent != null && !parent.exists() && !parent.mkdirs()) {
|
||||||
|
log.warn("缩略图目录创建失败: {}", parent.getPath());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJpeg(scaled, target);
|
||||||
|
return true;
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("缩略图生成失败: {} -> {}", source.getPath(), e.getMessage());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ImageIO 默认写出来的 JPEG 质量偏低,要显式设压缩参数 */
|
||||||
|
private void writeJpeg(BufferedImage image, File target) throws Exception {
|
||||||
|
Iterator<ImageWriter> writers = ImageIO.getImageWritersByFormatName("jpeg");
|
||||||
|
if (!writers.hasNext()) {
|
||||||
|
throw new IllegalStateException("没有可用的 JPEG 编码器");
|
||||||
|
}
|
||||||
|
ImageWriter writer = writers.next();
|
||||||
|
try {
|
||||||
|
ImageWriteParam param = writer.getDefaultWriteParam();
|
||||||
|
param.setCompressionMode(ImageWriteParam.MODE_EXPLICIT);
|
||||||
|
param.setCompressionQuality(QUALITY);
|
||||||
|
|
||||||
|
try (ImageOutputStream out = ImageIO.createImageOutputStream(target)) {
|
||||||
|
writer.setOutput(out);
|
||||||
|
writer.write(null, new IIOImage(image, null, null), param);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
writer.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -69,6 +69,18 @@ k780:
|
|||||||
sign: 1b502c535927b66d9b888a6d4701bf72
|
sign: 1b502c535927b66d9b888a6d4701bf72
|
||||||
timeout: 5000
|
timeout: 5000
|
||||||
|
|
||||||
|
# 金价定时抓取(GoldPriceScheduler)
|
||||||
|
#
|
||||||
|
# 2026-09-28 起关掉:App 端没用这个功能,而这个第三方接口已经改过返回格式
|
||||||
|
# (多了 msgid 字段导致反序列化失败),每 15 分钟往日志里刷一次 ERROR,纯噪音。
|
||||||
|
#
|
||||||
|
# 保留代码不删 —— 接口 `/api/gold-price/**` 照常可用,想恢复只需把 enabled 改回 true
|
||||||
|
# 并配合 `./manage.sh restart`(也可以只在 .env 里加 GOLD_PRICE_SCHEDULER_ENABLED=true,
|
||||||
|
# 不用重新打包)。
|
||||||
|
gold-price:
|
||||||
|
scheduler:
|
||||||
|
enabled: ${GOLD_PRICE_SCHEDULER_ENABLED:false}
|
||||||
|
|
||||||
server:
|
server:
|
||||||
port: 12345
|
port: 12345
|
||||||
|
|
||||||
|
|||||||
@@ -534,3 +534,72 @@ mark { background: #FFE9A8; color: inherit; padding: 0 1px; border-radius: 2px;
|
|||||||
.fin-bill .amt { font-size: 14px; font-weight: 600; flex-shrink: 0; }
|
.fin-bill .amt { font-size: 14px; font-weight: 600; flex-shrink: 0; }
|
||||||
.fin-bill .amt.out { color: #FA3534; }
|
.fin-bill .amt.out { color: #FA3534; }
|
||||||
.fin-bill .amt.in { color: #19BE6B; }
|
.fin-bill .amt.in { color: #19BE6B; }
|
||||||
|
|
||||||
|
/* ---------- 相片管理 ---------- */
|
||||||
|
.photo-main { flex: 1; min-width: 0; overflow-y: auto; padding: 16px; }
|
||||||
|
|
||||||
|
.photo-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-card {
|
||||||
|
position: relative; border-radius: 10px; overflow: hidden;
|
||||||
|
background: #F0EAE4; aspect-ratio: 1 / 1; cursor: pointer;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.photo-card:hover { border-color: var(--brand); }
|
||||||
|
.photo-card img {
|
||||||
|
width: 100%; height: 100%; object-fit: cover; display: block;
|
||||||
|
transition: transform .18s;
|
||||||
|
}
|
||||||
|
.photo-card:hover img { transform: scale(1.04); }
|
||||||
|
|
||||||
|
/* 悬浮时压一层信息条 */
|
||||||
|
.photo-card .ov {
|
||||||
|
position: absolute; left: 0; right: 0; bottom: 0;
|
||||||
|
padding: 16px 8px 6px; color: #fff; font-size: 11.5px;
|
||||||
|
background: linear-gradient(transparent, rgba(0,0,0,.65));
|
||||||
|
opacity: 0; transition: opacity .18s;
|
||||||
|
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.photo-card:hover .ov { opacity: 1; }
|
||||||
|
|
||||||
|
.photo-empty {
|
||||||
|
grid-column: 1 / -1; padding: 48px 16px; text-align: center;
|
||||||
|
color: var(--text-dim); font-size: 13px;
|
||||||
|
background: #fff; border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-more { text-align: center; padding: 16px 0 8px; }
|
||||||
|
|
||||||
|
/* 大图查看 */
|
||||||
|
#photo-view {
|
||||||
|
position: fixed; inset: 0; z-index: 200; display: none;
|
||||||
|
background: rgba(20, 16, 12, .92);
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
#photo-view.open { display: flex; }
|
||||||
|
|
||||||
|
.pv-head {
|
||||||
|
display: flex; align-items: center; gap: 8px;
|
||||||
|
padding: 10px 16px; color: #fff; font-size: 13px;
|
||||||
|
}
|
||||||
|
.pv-head .spacer { flex: 1; }
|
||||||
|
.pv-title { font-weight: 600; }
|
||||||
|
.pv-head button {
|
||||||
|
background: rgba(255,255,255,.14); color: #fff; border: none;
|
||||||
|
padding: 7px 14px; border-radius: 8px; cursor: pointer; font-size: 13px;
|
||||||
|
}
|
||||||
|
.pv-head button:hover { background: rgba(255,255,255,.26); }
|
||||||
|
.pv-head button.danger { background: rgba(229,57,53,.85); }
|
||||||
|
.pv-head button.danger:hover { background: #E53935; }
|
||||||
|
|
||||||
|
.pv-body { flex: 1; min-height: 0; display: flex; align-items: center; justify-content: center; padding: 0 16px; }
|
||||||
|
.pv-body img { max-width: 100%; max-height: 100%; object-fit: contain; border-radius: 6px; }
|
||||||
|
|
||||||
|
.pv-meta {
|
||||||
|
padding: 10px 16px 16px; color: rgba(255,255,255,.75);
|
||||||
|
font-size: 12px; line-height: 1.7; text-align: center;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1279,6 +1279,183 @@
|
|||||||
});
|
});
|
||||||
if (name === 'ai') aiEnter();
|
if (name === 'ai') aiEnter();
|
||||||
if (name === 'finance') finEnter();
|
if (name === 'finance') finEnter();
|
||||||
|
if (name === 'photos') photoEnter();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ================================================================ 相片管理
|
||||||
|
//
|
||||||
|
// 看 App「水印相机」上传的原图(**不带水印**,水印是 App 本地合成的)。
|
||||||
|
// 页面只加载缩略图 —— 原图一张好几 MB,一屏铺开能把浏览器拖死。
|
||||||
|
// 点开大图时才去加载原图,那时才需要清晰度。
|
||||||
|
var photo = {
|
||||||
|
items: [],
|
||||||
|
total: 0,
|
||||||
|
page: 0,
|
||||||
|
hasMore: false,
|
||||||
|
loading: false,
|
||||||
|
entered: false,
|
||||||
|
current: null // 正在大图查看的那一张
|
||||||
|
};
|
||||||
|
|
||||||
|
var PHOTO_PAGE_SIZE = 24;
|
||||||
|
|
||||||
|
function photoEnter() {
|
||||||
|
if (photo.entered) return;
|
||||||
|
photo.entered = true;
|
||||||
|
photoLoad(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function photoLoad(reset) {
|
||||||
|
if (photo.loading) return;
|
||||||
|
photo.loading = true;
|
||||||
|
if (reset) {
|
||||||
|
photo.page = 0;
|
||||||
|
photo.items = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
var next = photo.page + 1;
|
||||||
|
$('photo-hint').textContent = '加载中…';
|
||||||
|
|
||||||
|
request('/photos?page=' + next + '&size=' + PHOTO_PAGE_SIZE)
|
||||||
|
.then(function (data) {
|
||||||
|
var list = (data && data.list) || [];
|
||||||
|
photo.total = (data && data.total) || 0;
|
||||||
|
photo.hasMore = !!(data && data.hasMore);
|
||||||
|
photo.page = next;
|
||||||
|
|
||||||
|
// 按 id 去重:翻页期间如果有新照片传上来,可能两页里都有
|
||||||
|
var seen = {};
|
||||||
|
photo.items.forEach(function (p) { seen[p.id] = true; });
|
||||||
|
list.forEach(function (p) {
|
||||||
|
if (!seen[p.id]) photo.items.push(p);
|
||||||
|
});
|
||||||
|
|
||||||
|
photoRender();
|
||||||
|
})
|
||||||
|
.catch(function (e) {
|
||||||
|
$('photo-hint').textContent = e.message || '加载失败';
|
||||||
|
})
|
||||||
|
.then(function () { photo.loading = false; });
|
||||||
|
}
|
||||||
|
|
||||||
|
function photoRender() {
|
||||||
|
var box = $('photo-grid');
|
||||||
|
box.innerHTML = '';
|
||||||
|
|
||||||
|
$('photo-hint').textContent = photo.total ? ('共 ' + photo.total + ' 张') : '';
|
||||||
|
|
||||||
|
if (!photo.items.length) {
|
||||||
|
box.innerHTML = '<div class="photo-empty">' +
|
||||||
|
'还没有照片。在 App 的「水印相机」里拍一张,原图会自动传到服务器。' +
|
||||||
|
'</div>';
|
||||||
|
$('photo-more').style.display = 'none';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
photo.items.forEach(function (p) {
|
||||||
|
var el = document.createElement('div');
|
||||||
|
el.className = 'photo-card';
|
||||||
|
|
||||||
|
// 缩略图没生成出来时退回原图(慢一点,但总比空着好)
|
||||||
|
var thumb = p.thumbUrl || p.url;
|
||||||
|
|
||||||
|
el.innerHTML =
|
||||||
|
'<img src="' + esc(thumb) + '" alt="" loading="lazy">' +
|
||||||
|
'<div class="ov">' + esc(photoLabel(p)) + '</div>';
|
||||||
|
|
||||||
|
el.onclick = function () { photoOpen(p); };
|
||||||
|
box.appendChild(el);
|
||||||
|
});
|
||||||
|
|
||||||
|
$('photo-more').style.display = photo.hasMore ? '' : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 卡片角标上显示的文案 */
|
||||||
|
function photoLabel(p) {
|
||||||
|
var parts = [];
|
||||||
|
if (p.takenTime) parts.push(String(p.takenTime).substring(0, 16));
|
||||||
|
if (p.address) parts.push(p.address);
|
||||||
|
return parts.join(' · ') || ('照片 #' + p.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- 大图 ----------------
|
||||||
|
|
||||||
|
function photoOpen(p) {
|
||||||
|
photo.current = p;
|
||||||
|
$('pv-title').textContent = '照片 #' + p.id;
|
||||||
|
// 这里才加载原图
|
||||||
|
$('pv-img').src = p.url;
|
||||||
|
|
||||||
|
var meta = [];
|
||||||
|
if (p.takenTime) meta.push('拍摄时间:' + p.takenTime);
|
||||||
|
if (p.address) meta.push('地点:' + p.address);
|
||||||
|
if (p.weather) meta.push('天气:' + p.weather);
|
||||||
|
if (p.remark) meta.push('备注:' + p.remark);
|
||||||
|
if (p.latitude != null && p.longitude != null) {
|
||||||
|
meta.push('坐标:' + p.latitude + ', ' + p.longitude);
|
||||||
|
}
|
||||||
|
if (p.fileSize) meta.push('大小:' + photoSize(p.fileSize));
|
||||||
|
$('pv-meta').textContent = meta.join(' | ');
|
||||||
|
|
||||||
|
$('photo-view').classList.add('open');
|
||||||
|
}
|
||||||
|
|
||||||
|
function photoClose() {
|
||||||
|
$('photo-view').classList.remove('open');
|
||||||
|
// 清掉 src,否则大图会一直占着内存
|
||||||
|
$('pv-img').src = '';
|
||||||
|
photo.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function photoSize(bytes) {
|
||||||
|
if (bytes >= 1024 * 1024) return (bytes / 1024 / 1024).toFixed(1) + ' MB';
|
||||||
|
if (bytes >= 1024) return Math.round(bytes / 1024) + ' KB';
|
||||||
|
return bytes + ' B';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 下载原图。带上 token 用 fetch 取 blob —— 直接 <a href> 会 401。 */
|
||||||
|
function photoDownload() {
|
||||||
|
var p = photo.current;
|
||||||
|
if (!p) return;
|
||||||
|
|
||||||
|
var name = 'photo_' + p.id + '.jpg';
|
||||||
|
toast('开始下载…');
|
||||||
|
|
||||||
|
// p.url 是 /uploads/... 形式的同源相对路径,直接拼即可(不是 /api 下的)
|
||||||
|
fetch(p.url, { headers: { 'Authorization': 'Bearer ' + state.token } })
|
||||||
|
.then(function (res) {
|
||||||
|
if (!res.ok) throw new Error('下载失败 ' + res.status);
|
||||||
|
return res.blob();
|
||||||
|
})
|
||||||
|
.then(function (blob) {
|
||||||
|
var url = URL.createObjectURL(blob);
|
||||||
|
var a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = name;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
// 等浏览器把下载接过去再释放,立刻释放会让下载变空文件
|
||||||
|
setTimeout(function () { URL.revokeObjectURL(url); }, 5000);
|
||||||
|
toast('已开始下载 ' + name);
|
||||||
|
})
|
||||||
|
.catch(function (e) { toast(e.message, true); });
|
||||||
|
}
|
||||||
|
|
||||||
|
function photoDelete() {
|
||||||
|
var p = photo.current;
|
||||||
|
if (!p) return;
|
||||||
|
if (!confirm('删除这张照片?\n记录和服务器上的原图、缩略图都会一并删掉,不可恢复。')) return;
|
||||||
|
|
||||||
|
request('/photos/' + p.id, { method: 'DELETE' })
|
||||||
|
.then(function () {
|
||||||
|
photoClose();
|
||||||
|
photo.items = photo.items.filter(function (x) { return x.id !== p.id; });
|
||||||
|
photo.total = Math.max(0, photo.total - 1);
|
||||||
|
photoRender();
|
||||||
|
toast('已删除');
|
||||||
|
})
|
||||||
|
.catch(function (e) { toast(e.message, true); });
|
||||||
}
|
}
|
||||||
|
|
||||||
// ================================================================ AI 助手
|
// ================================================================ AI 助手
|
||||||
@@ -1883,6 +2060,17 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------- 事件绑定
|
// ---------------------------------------------------------------- 事件绑定
|
||||||
|
// 相片管理
|
||||||
|
$('photo-reload').onclick = function () { photoLoad(true); };
|
||||||
|
$('photo-more-btn').onclick = function () { photoLoad(false); };
|
||||||
|
$('pv-close').onclick = photoClose;
|
||||||
|
$('pv-download').onclick = photoDownload;
|
||||||
|
$('pv-delete').onclick = photoDelete;
|
||||||
|
// 点背景关掉大图(点图片本身不关)
|
||||||
|
$('photo-view').onclick = function (e) {
|
||||||
|
if (e.target === this) photoClose();
|
||||||
|
};
|
||||||
|
|
||||||
$('login-btn').onclick = doLogin;
|
$('login-btn').onclick = doLogin;
|
||||||
$('login-eye').onclick = togglePassword;
|
$('login-eye').onclick = togglePassword;
|
||||||
$('login-password').onkeydown = function (e) { if (e.key === 'Enter') doLogin(); };
|
$('login-password').onkeydown = function (e) { if (e.key === 'Enter') doLogin(); };
|
||||||
|
|||||||
@@ -79,6 +79,10 @@
|
|||||||
<span class="mi-icon"><img class="ci" src="/admin/icons/bill.svg" width="18" height="18" alt=""></span>
|
<span class="mi-icon"><img class="ci" src="/admin/icons/bill.svg" width="18" height="18" alt=""></span>
|
||||||
<span class="mi-text">财务管理</span>
|
<span class="mi-text">财务管理</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button class="module-item" data-module="photos">
|
||||||
|
<span class="mi-icon"><img class="ci" src="/admin/icons/camera.svg" width="18" height="18" alt=""></span>
|
||||||
|
<span class="mi-text">相片管理</span>
|
||||||
|
</button>
|
||||||
<button class="module-item" data-module="ai">
|
<button class="module-item" data-module="ai">
|
||||||
<span class="mi-icon"><img class="ci" src="/admin/icons/assistant.svg" width="18" height="18"
|
<span class="mi-icon"><img class="ci" src="/admin/icons/assistant.svg" width="18" height="18"
|
||||||
alt=""></span>
|
alt=""></span>
|
||||||
@@ -175,6 +179,27 @@
|
|||||||
<!-- ==================== AI 助手模块 ====================
|
<!-- ==================== AI 助手模块 ====================
|
||||||
和 App 端共用同一套后端接口(/api/ai/**),历史存在服务端的
|
和 App 端共用同一套后端接口(/api/ai/**),历史存在服务端的
|
||||||
chat_session / chat_message 里 —— 所以两边看到的会话是同一批。 -->
|
chat_session / chat_message 里 —— 所以两边看到的会话是同一批。 -->
|
||||||
|
<!-- ==================== 相片管理 ==================== -->
|
||||||
|
<section class="module-panel" data-module-panel="photos" style="display:none">
|
||||||
|
|
||||||
|
<div class="toolbar">
|
||||||
|
<span class="toolbar-title">相片管理</span>
|
||||||
|
<span class="toolbar-note" id="photo-hint"></span>
|
||||||
|
<div class="spacer"></div>
|
||||||
|
<button id="photo-reload">刷新</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="module-body">
|
||||||
|
<main class="photo-main">
|
||||||
|
<!-- 缩略图网格。点一张看大图,右上角可下载/删除 -->
|
||||||
|
<div id="photo-grid" class="photo-grid"></div>
|
||||||
|
<div id="photo-more" class="photo-more" style="display:none">
|
||||||
|
<button id="photo-more-btn">加载更多</button>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="module-panel" data-module-panel="ai" style="display:none">
|
<section class="module-panel" data-module-panel="ai" style="display:none">
|
||||||
|
|
||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
@@ -226,6 +251,21 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ==================== 相片大图查看 ==================== -->
|
||||||
|
<div id="photo-view">
|
||||||
|
<div class="pv-head">
|
||||||
|
<span class="pv-title" id="pv-title"></span>
|
||||||
|
<div class="spacer"></div>
|
||||||
|
<button id="pv-download">下载原图</button>
|
||||||
|
<button class="danger" id="pv-delete">删除</button>
|
||||||
|
<button id="pv-close">关闭</button>
|
||||||
|
</div>
|
||||||
|
<div class="pv-body">
|
||||||
|
<img id="pv-img" alt="">
|
||||||
|
</div>
|
||||||
|
<div class="pv-meta" id="pv-meta"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- ==================== 目录名输入 / 目录选择弹窗 ==================== -->
|
<!-- ==================== 目录名输入 / 目录选择弹窗 ==================== -->
|
||||||
<div id="dialog">
|
<div id="dialog">
|
||||||
<div class="dialog-box">
|
<div class="dialog-box">
|
||||||
|
|||||||
Reference in New Issue
Block a user