diff --git a/src/main/java/com/accounting/controller/PhotoController.java b/src/main/java/com/accounting/controller/PhotoController.java index 74a756c..0eaf78b 100644 --- a/src/main/java/com/accounting/controller/PhotoController.java +++ b/src/main/java/com/accounting/controller/PhotoController.java @@ -1,5 +1,6 @@ package com.accounting.controller; +import com.accounting.dto.PhotoPageResponse; import com.accounting.dto.PhotoResponse; import com.accounting.entity.User; import com.accounting.mapper.UserMapper; @@ -11,6 +12,9 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.ResponseEntity; import org.springframework.security.core.Authentication; 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.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; @@ -57,6 +61,25 @@ public class PhotoController { return ResponseEntity.ok(response); } + @Operation(summary = "分页查询照片,最新的在前") + @GetMapping + public ResponseEntity 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 delete(@PathVariable Long id, Authentication authentication) { + Long userId = getUserId(authentication); + photoService.delete(userId, id); + return ResponseEntity.ok().build(); + } + private Long getUserId(Authentication authentication) { UserDetails userDetails = (UserDetails) authentication.getPrincipal(); String username = userDetails.getUsername(); diff --git a/src/main/java/com/accounting/dto/PhotoPageResponse.java b/src/main/java/com/accounting/dto/PhotoPageResponse.java new file mode 100644 index 0000000..9181fb4 --- /dev/null +++ b/src/main/java/com/accounting/dto/PhotoPageResponse.java @@ -0,0 +1,25 @@ +package com.accounting.dto; + +import lombok.Data; + +import java.util.List; + +/** + * 照片分页响应 + * + *

字段和 {@code NotePageResponse} 对齐(list / total / page / size / hasMore), + * App 端两边的分页处理可以长得一样。

+ */ +@Data +public class PhotoPageResponse { + + private List list; + + private long total; + + private int page; + + private int size; + + private boolean hasMore; +} diff --git a/src/main/java/com/accounting/dto/PhotoResponse.java b/src/main/java/com/accounting/dto/PhotoResponse.java index 8d3c733..ceb8491 100644 --- a/src/main/java/com/accounting/dto/PhotoResponse.java +++ b/src/main/java/com/accounting/dto/PhotoResponse.java @@ -19,6 +19,14 @@ public class PhotoResponse { /** 可直接访问的地址,如 /uploads/photo/2026/09/28/xxx.jpg */ private String url; + /** + * 缩略图地址,如 /uploads/photo/2026/09/28/xxx.thumb.jpg + * + *

列表页只加载它 —— 原图一张 3~8MB,一屏加载原图要上百 MB。 + * 为 null 表示缩略图没生成成功,客户端可以回退用原图。

+ */ + private String thumbUrl; + private Long fileSize; private String originalName; diff --git a/src/main/java/com/accounting/schedule/GoldPriceScheduler.java b/src/main/java/com/accounting/schedule/GoldPriceScheduler.java index bdec52e..8995ba0 100644 --- a/src/main/java/com/accounting/schedule/GoldPriceScheduler.java +++ b/src/main/java/com/accounting/schedule/GoldPriceScheduler.java @@ -3,9 +3,22 @@ package com.accounting.schedule; import com.accounting.service.GoldPriceService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; +import jakarta.annotation.PostConstruct; + +/** + * 金价定时抓取。 + * + *

⚠️ 2026-09-28 起**默认关闭**({@code gold-price.scheduler.enabled=false}): + * App 端没用这个功能,而这个第三方接口后来改过返回格式(多了 msgid 字段, + * 导致 K780Response 反序列化失败),于是每 15 分钟往日志里刷一次 ERROR,纯噪音。

+ * + *

代码保留、接口 {@code /api/gold-price/**} 照常可用 —— 想恢复只需在服务器 + * {@code .env} 里加 {@code GOLD_PRICE_SCHEDULER_ENABLED=true} 再重启,不用改代码。

+ */ @Slf4j @Component @RequiredArgsConstructor @@ -15,8 +28,25 @@ public class GoldPriceScheduler { 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") public void fetchPeriodicPrice() { + // 开关关着就直接返回。不用 @ConditionalOnProperty 是因为那要作用在 Bean 上, + // 而这里只想停掉这个「任务」,不想让整个 SchedulerConfig 的线程池也变样。 + if (!enabled) { + return; + } runJob("periodic"); } diff --git a/src/main/java/com/accounting/service/PhotoService.java b/src/main/java/com/accounting/service/PhotoService.java index 3d5a73f..155edcd 100644 --- a/src/main/java/com/accounting/service/PhotoService.java +++ b/src/main/java/com/accounting/service/PhotoService.java @@ -1,19 +1,26 @@ package com.accounting.service; +import com.accounting.dto.PhotoPageResponse; import com.accounting.dto.PhotoResponse; import com.accounting.entity.Photo; import com.accounting.mapper.PhotoMapper; import com.accounting.util.FileUtil; +import com.accounting.util.ImageThumbUtil; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.multipart.MultipartFile; +import java.io.File; import java.io.IOException; import java.math.BigDecimal; import java.time.LocalDateTime; 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_ORIGINAL_NAME = 255; + /** 列表单页上限,防止客户端要一个巨大的 size */ + private static final int MAX_PAGE_SIZE = 60; + private static final DateTimeFormatter TAKEN_TIME_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); private final PhotoMapper photoMapper; private final FileUtil fileUtil; + private final ImageThumbUtil imageThumbUtil; /** * 保存一张照片。 @@ -88,11 +99,107 @@ public class PhotoService { throw e; } + // 顺手生成缩略图。失败也无所谓(原图已经存好了),列表页会再补一次 + ensureThumbnail(relativePath); + log.info("照片已保存: userId={}, id={}, size={}B, path={}", userId, photo.getId(), photo.getFileSize(), relativePath); return toResponse(photo); } + /** + * 分页列表,最新的在前。 + * + *

和 {@code NoteService} 一样手写 LIMIT —— 项目里没配 MyBatis-Plus 的 + * 分页插件,{@code selectPage} 不会真的生成 LIMIT。

+ * + *

顺带补齐缩略图:这次改动之前上传的照片没有缩略图,借列表请求补上, + * 不用手工刷数据。只有第一次会慢一点,之后都是现成的文件。

+ */ + 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().eq(Photo::getUserId, userId)); + + List rows; + if (total == 0) { + rows = new ArrayList<>(); + } else { + int offset = (safePage - 1) * safeSize; + rows = photoMapper.selectList( + new LambdaQueryWrapper() + .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; + } + + /** + * 删除一张照片:先逻辑删记录,再把磁盘上的原图和缩略图都删掉。 + * + *

磁盘删除失败**不回滚**事务 —— 记录已经从用户的列表里消失了, + * 留个删不掉的文件只是浪费几 MB,而抛异常会让用户以为「删除失败」、 + * 反复重试。所以失败只记日志。

+ */ + @Transactional + public void delete(Long userId, Long id) { + Photo photo = photoMapper.selectOne( + new LambdaQueryWrapper() + .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。 + * + *

叫「ensure」不叫「generate」是因为它是补齐语义:上传时调一次, + * 列表接口对老照片再调一次 —— 已经有了就直接返回,不重复干活。

+ */ + 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(); response.setId(photo.getId()); 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.setOriginalName(photo.getOriginalName()); response.setLatitude(photo.getLatitude()); diff --git a/src/main/java/com/accounting/util/ImageThumbUtil.java b/src/main/java/com/accounting/util/ImageThumbUtil.java new file mode 100644 index 0000000..a89d762 --- /dev/null +++ b/src/main/java/com/accounting/util/ImageThumbUtil.java @@ -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; + +/** + * 缩略图生成。 + * + *

照片列表页不可能直接加载原图 —— 手机拍的 12MP 照片一张 3~8MB, + * 一屏 20 张就是上百 MB。所以上传时(以及翻到旧图时)顺手生成一张 + * 最长边 {@value #MAX_WIDTH} 的小图,列表只加载它。

+ * + *

用 JDK 自带的 Java2D({@code javax.imageio} + {@code Graphics2D}), + * 不引入任何新依赖 —— 和项目「能不加包就不加」的取向一致。

+ */ +@Slf4j +@Component +public class ImageThumbUtil { + + /** 缩略图最长边(像素)。400 在手机上显示 3 列网格足够了 */ + private static final int MAX_WIDTH = 400; + + /** JPEG 质量。缩略图不需要太精细 */ + private static final float QUALITY = 0.82f; + + /** + * 生成缩略图。 + * + *

失败不抛异常,只返回 false —— 缩略图是「锦上添花」,生成不出来 + * 不该把上传整体搞失败(原图已经存好了)。

+ * + * @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 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(); + } + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 1d318fb..707e9e2 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -69,6 +69,18 @@ k780: sign: 1b502c535927b66d9b888a6d4701bf72 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: port: 12345 diff --git a/src/main/resources/static/admin/admin.css b/src/main/resources/static/admin/admin.css index 6abc2d9..81ece78 100644 --- a/src/main/resources/static/admin/admin.css +++ b/src/main/resources/static/admin/admin.css @@ -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.out { color: #FA3534; } .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; +} diff --git a/src/main/resources/static/admin/admin.js b/src/main/resources/static/admin/admin.js index 7e0f812..3139850 100644 --- a/src/main/resources/static/admin/admin.js +++ b/src/main/resources/static/admin/admin.js @@ -1279,6 +1279,183 @@ }); if (name === 'ai') aiEnter(); 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 = '
' + + '还没有照片。在 App 的「水印相机」里拍一张,原图会自动传到服务器。' + + '
'; + $('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 = + '' + + '
' + esc(photoLabel(p)) + '
'; + + 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 —— 直接 会 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 助手 @@ -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-eye').onclick = togglePassword; $('login-password').onkeydown = function (e) { if (e.key === 'Enter') doLogin(); }; diff --git a/src/main/resources/static/admin/index.html b/src/main/resources/static/admin/index.html index dfa43ef..2b9854d 100644 --- a/src/main/resources/static/admin/index.html +++ b/src/main/resources/static/admin/index.html @@ -79,6 +79,10 @@ 财务管理 + + + +
+
+ +
+ +
+
+ +