Commit ceb45728 by DaiJiezhang

feat: 设备资产图片改用缩略图,并支持按顺序生成设备编号

图片性能:列表里 40px 的小图原本加载的是完整原图。实测单张 1672x941 的 PNG
约 1.8MB,一页 20 条最多 40 张,需要传输约 72MB、浏览器解码后常驻内存约
250MB;每页条数还能选到 50,翻倍到 180MB / 630MB。图片接口又没有任何缓存头,
每次刷新、翻页回来都要重下一遍。

改法分四层:
- 保存时用 ImageReader 降采样解码,一次完成格式校验和长边 240px 缩略图生成。
  实测 1890KB -> 4KB,耗时 82ms,比原先只做校验的全图解码(119ms)还快。
  缩略图先写临时文件再原子替换,并发补生成不会读到半截文件。
- 列表和弹窗预览改用缩略图,只有点开大图才请求原图;历史图片首次被请求时
  自动补生成一次并落盘。
- 图片接口加 Cache-Control: max-age=1年, private, immutable。文件名是随机
  UUID、内容永不改写,换图必然换标识,所以缓存安全;用 private 而非 public,
  避免受登录态保护的图片被共享代理缓存后发给别人。
- 列表图加 loading=lazy、悬停时预取并解码原图,点开大图几乎无等待。

上传交互:上传控件此前没有 accept 属性,系统文件框要枚举目录下所有类型的
文件;预览又把原图直接塞进 104x78 的框,浏览器同步解码整张图会明显卡顿。
现在限定图片类型,并用 createImageBitmap 异步解码后缩到 320px 再显示,
上传的仍是未经处理的原始文件。

设备编号:设备名称输入框加一键生成下一个「前缀N号机」。编号由后端在全库范围
计算,不按当前页推算——一页只有 20 条,最大编号很可能在别的页上,按当前页
算会撞上已存在的名字被唯一性校验打回。前缀限定 1-20 位中英文数字,因为它要
拼进 LIKE 条件,% 和 _ 会被当通配符。

列表同时去掉编号列、图片挪到首列并支持展示两张、无图与加载失败统一显示占位框、
更新时间只保留日期。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
parent d8736010
...@@ -5,8 +5,10 @@ import com.xyw.console.asset.service.DeviceAssetService; ...@@ -5,8 +5,10 @@ import com.xyw.console.asset.service.DeviceAssetService;
import com.xyw.console.auth.PagePermissionService; import com.xyw.console.auth.PagePermissionService;
import com.xyw.console.common.ApiResponse; import com.xyw.console.common.ApiResponse;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import java.time.Duration;
import java.util.List; import java.util.List;
import org.springframework.core.io.Resource; import org.springframework.core.io.Resource;
import org.springframework.http.CacheControl;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import org.springframework.http.MediaTypeFactory; import org.springframework.http.MediaTypeFactory;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
...@@ -36,6 +38,20 @@ public class DeviceAssetController { ...@@ -36,6 +38,20 @@ public class DeviceAssetController {
/** 代码作用(白话):按关键字搜索可作为设备使用人的公司人员。关联文件:DevicePersonLookupResponse.java、DeviceAssetService.java。关联逻辑(调用链/数据流):远程选择器 -> lookup -> Mapper -> 选项。 */ /** 代码作用(白话):按关键字搜索可作为设备使用人的公司人员。关联文件:DevicePersonLookupResponse.java、DeviceAssetService.java。关联逻辑(调用链/数据流):远程选择器 -> lookup -> Mapper -> 选项。 */
@GetMapping("/lookups/company-persons") public ApiResponse<List<DevicePersonLookupResponse>> companyPersons(@RequestParam(defaultValue="") String keyword) { permissions.requireAdministrator(); return ApiResponse.success(service.searchCompanyPersons(keyword)); } @GetMapping("/lookups/company-persons") public ApiResponse<List<DevicePersonLookupResponse>> companyPersons(@RequestParam(defaultValue="") String keyword) { permissions.requireAdministrator(); return ApiResponse.success(service.searchCompanyPersons(keyword)); }
/** 代码作用(白话):按不透明标识读取设备图片,不返回服务器路径。关联文件:DeviceAssetFileStorageService.java、DeviceAssetResponse.java。关联逻辑(调用链/数据流):img URL -> findImage -> Resource -> 浏览器预览。 */ /** 代码作用(白话):返回按前缀顺延的下一个设备编号名称,供新增弹窗一键填充。关联文件:DeviceNameSuggestionResponse.java、DeviceAssetView.js。关联逻辑(调用链/数据流):一键编号 -> 全库最大编号 -> prefix+N+号机 -> 输入框。 */
@GetMapping("/files/{identifier:.+}") public ResponseEntity<Resource> file(@PathVariable String identifier) { permissions.requireAdministrator(); Resource resource=service.findImage(identifier); MediaType type=MediaTypeFactory.getMediaType(resource).orElse(MediaType.APPLICATION_OCTET_STREAM); return ResponseEntity.ok().contentType(type).body(resource); } @GetMapping("/lookups/next-device-name") public ApiResponse<DeviceNameSuggestionResponse> nextDeviceName(@RequestParam(defaultValue="") String prefix) { permissions.requireAdministrator(); return ApiResponse.success(new DeviceNameSuggestionResponse(service.suggestNextDeviceName(prefix))); }
/**
* 代码作用(白话):按不透明标识读取设备图片,variant=thumb 时返回列表用的小缩略图,不返回服务器路径。
* 关联文件:DeviceAssetFileStorageService.java、DeviceAssetResponse.java。
* 关联逻辑(调用链/数据流):img URL -> findImage/findThumbnail -> Resource -> 浏览器预览。
* 缓存头是安全的:文件名是随机 UUID,同一个标识的内容永远不会被改写,换图必然换标识。
* 用 private 而不是 public:这些图受登录态保护,不能被共享代理缓存后发给别人。
*/
@GetMapping("/files/{identifier:.+}") public ResponseEntity<Resource> file(@PathVariable String identifier, @RequestParam(required=false) String variant) {
permissions.requireAdministrator();
Resource resource="thumb".equals(variant)?service.findThumbnail(identifier):service.findImage(identifier);
MediaType type=MediaTypeFactory.getMediaType(resource).orElse(MediaType.APPLICATION_OCTET_STREAM);
return ResponseEntity.ok().cacheControl(CacheControl.maxAge(Duration.ofDays(365)).cachePrivate().immutable()).contentType(type).body(resource);
}
} }
...@@ -2,7 +2,12 @@ package com.xyw.console.asset.dto; ...@@ -2,7 +2,12 @@ package com.xyw.console.asset.dto;
import java.time.LocalDateTime; import java.time.LocalDateTime;
/** 文件用途(白话):定义一条安全返回给设备管理页面的数据,不暴露软删除标记或服务器真实文件路径。 */ /**
* 文件用途(白话):定义一条安全返回给设备管理页面的数据,不暴露软删除标记或服务器真实文件路径。
* 缩略图 URL 与原图 URL 分开返回:列表里的 40px 小图和弹窗预览用缩略图(约几十 KB),
* 只有点开大图才请求原图(可达 20MB),否则一页 20 条会拉几十兆图片把页面拖垮。
*/
public record DeviceAssetResponse(Long id, String deviceName, String imageAttachment1Url, String imageAttachment2Url, public record DeviceAssetResponse(Long id, String deviceName, String imageAttachment1Url, String imageAttachment2Url,
String imageAttachment1ThumbUrl, String imageAttachment2ThumbUrl,
Long userPersonId, String userPersonName, String userUsageStatus, String assetRelationStatus, Long userPersonId, String userPersonName, String userUsageStatus, String assetRelationStatus,
LocalDateTime createTime, LocalDateTime updateTime) {} LocalDateTime createTime, LocalDateTime updateTime) {}
package com.xyw.console.asset.dto;
/** 文件用途(白话):承载"下一个可用设备编号名称"的建议值,供新增弹窗一键填充。 */
public record DeviceNameSuggestionResponse(String deviceName) {}
package com.xyw.console.asset.service; package com.xyw.console.asset.service;
import com.xyw.console.asset.exception.DeviceAssetValidationException; import com.xyw.console.asset.exception.DeviceAssetValidationException;
import java.awt.Color;
import java.awt.Graphics2D;
import java.awt.RenderingHints;
import java.awt.image.BufferedImage; import java.awt.image.BufferedImage;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream; import java.io.InputStream;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.nio.file.StandardCopyOption; import java.nio.file.StandardCopyOption;
import java.util.Iterator;
import java.util.Locale; import java.util.Locale;
import java.util.Set; import java.util.Set;
import java.util.UUID; import java.util.UUID;
import javax.imageio.ImageIO; import javax.imageio.ImageIO;
import javax.imageio.ImageReadParam;
import javax.imageio.ImageReader;
import javax.imageio.stream.ImageInputStream;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.Resource; import org.springframework.core.io.Resource;
import org.springframework.core.io.UrlResource; import org.springframework.core.io.UrlResource;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartFile;
/** 文件用途(白话):校验并保存设备原图,且只允许用不透明标识在受控目录中读取图片。 */ /** 文件用途(白话):校验并保存设备原图、派生列表用的小缩略图,且只允许用不透明标识在受控目录中读取图片。 */
@Service @Service
public class DeviceAssetFileStorageService { public class DeviceAssetFileStorageService {
private static final long MAX_IMAGE_BYTES = 20L * 1024 * 1024; private static final long MAX_IMAGE_BYTES = 20L * 1024 * 1024;
private static final Set<String> EXTENSIONS = Set.of("jpg", "jpeg", "png", "gif"); private static final Set<String> EXTENSIONS = Set.of("jpg", "jpeg", "png", "gif");
/** 缩略图长边:列表里只显示 40px、弹窗只显示 104px,240 已覆盖二倍屏,再大纯属浪费带宽。 */
private static final int THUMBNAIL_MAX_EDGE = 240;
private static final String THUMBNAIL_SUFFIX = ".thumb.jpg";
private final Path root; private final Path root;
/** 代码作用(白话):解析并创建设备图片根目录。关联文件:DeviceAssetService.java、DeviceAssetController.java。关联逻辑(调用链/数据流):配置/默认目录 -> 文件服务 -> 保存和读取图片。 */ /** 代码作用(白话):解析并创建设备图片根目录。关联文件:DeviceAssetService.java、DeviceAssetController.java。关联逻辑(调用链/数据流):配置/默认目录 -> 文件服务 -> 保存和读取图片。 */
...@@ -30,21 +40,43 @@ public class DeviceAssetFileStorageService { ...@@ -30,21 +40,43 @@ public class DeviceAssetFileStorageService {
catch (IOException exception) { throw new IllegalStateException("设备图片目录无法创建", exception); } catch (IOException exception) { throw new IllegalStateException("设备图片目录无法创建", exception); }
} }
/** 代码作用(白话):保存一张已通过校验的原图并返回不透明文件标识。关联文件:DeviceAssetSaveRequest.java、DeviceAssetService.java。关联逻辑(调用链/数据流):multipart 图片 -> store -> 文件标识 -> as_asset_device 附件列。 */ /**
* 代码作用(白话):保存一张原图并同时产出缩略图,返回不透明文件标识。
* 关联文件:DeviceAssetSaveRequest.java、DeviceAssetService.java。
* 关联逻辑(调用链/数据流):multipart 图片 -> store -> 文件标识 -> as_asset_device 附件列。
* 顺序刻意改成"先落盘再校验":旧实现为了校验先把整张图解码进内存,再重新读一次流写盘,
* 20MB 图会读两遍流并驻留几十 MB 堆;落盘后用降采样解码,校验和缩略图一次做完。
*/
public String store(MultipartFile image) { public String store(MultipartFile image) {
if (image == null || image.isEmpty()) return null; if (image == null || image.isEmpty()) return null;
validateImage(image); if (image.getSize() > MAX_IMAGE_BYTES) throw new DeviceAssetValidationException("每张图片不能超过 20MB");
String identifier = createOpaqueIdentifier(extensionOf(image.getOriginalFilename())); String extension = extensionOf(image.getOriginalFilename());
if (!EXTENSIONS.contains(extension)) throw new DeviceAssetValidationException("仅支持 JPG、PNG、GIF 图片");
String identifier = createOpaqueIdentifier(extension);
Path target = resolveInsideRoot(identifier); Path target = resolveInsideRoot(identifier);
try (InputStream input = image.getInputStream()) { Files.copy(input, target, StandardCopyOption.REPLACE_EXISTING); return identifier; } try (InputStream input = image.getInputStream()) { Files.copy(input, target, StandardCopyOption.REPLACE_EXISTING); }
catch (IOException exception) { throw new DeviceAssetValidationException("设备图片保存失败"); } catch (IOException exception) { throw new DeviceAssetValidationException("设备图片保存失败"); }
try { writeThumbnail(target, thumbnailPathFor(identifier)); return identifier; }
catch (RuntimeException exception) { cleanupNewFile(identifier); throw exception; }
} }
/** 代码作用(白话):把不透明标识解析为受目录约束的可读取资源。关联文件:DeviceAssetController.java。关联逻辑(调用链/数据流):图片 URL -> findImage -> resolve -> ResponseEntity 文件响应。 */ /** 代码作用(白话):把不透明标识解析为受目录约束的可读取原图资源。关联文件:DeviceAssetController.java。关联逻辑(调用链/数据流):图片 URL -> findImage -> resolve -> ResponseEntity 文件响应。 */
public Resource resolve(String identifier) { public Resource resolve(String identifier) {
if (identifier == null || identifier.isBlank()) throw new DeviceAssetValidationException("图片不存在"); if (identifier == null || identifier.isBlank()) throw new DeviceAssetValidationException("图片不存在");
try { Resource resource = new UrlResource(resolveInsideRoot(identifier).toUri()); if (!resource.exists() || !resource.isReadable()) throw new DeviceAssetValidationException("图片不存在"); return resource; } return readable(resolveInsideRoot(identifier));
catch (IOException exception) { throw new DeviceAssetValidationException("图片读取失败"); } }
/**
* 代码作用(白话):返回列表和弹窗预览用的小缩略图,历史图片首次访问时补生成一张。
* 关联文件:DeviceAssetController.java、DeviceAssetService.java。
* 关联逻辑(调用链/数据流):缩略图 URL -> resolveThumbnail -> 已有文件或即时生成 -> 浏览器。
*/
public Resource resolveThumbnail(String identifier) {
if (identifier == null || identifier.isBlank()) throw new DeviceAssetValidationException("图片不存在");
Path original = resolveInsideRoot(identifier);
Path thumbnail = thumbnailPathFor(identifier);
if (!Files.isReadable(thumbnail)) writeThumbnail(readableOrFail(original), thumbnail);
return readable(thumbnail);
} }
/** 代码作用(白话):保存替换图或保留旧标识,避免编辑未选图时丢失原图。关联文件:DeviceAssetService.java。关联逻辑(调用链/数据流):编辑表单 -> replace -> 新/旧标识 -> 设备更新。 */ /** 代码作用(白话):保存替换图或保留旧标识,避免编辑未选图时丢失原图。关联文件:DeviceAssetService.java。关联逻辑(调用链/数据流):编辑表单 -> replace -> 新/旧标识 -> 设备更新。 */
...@@ -56,19 +88,95 @@ public class DeviceAssetFileStorageService { ...@@ -56,19 +88,95 @@ public class DeviceAssetFileStorageService {
/** 代码作用(白话):清空数据库中的附件引用但保留物理原图,以支持软删除后追溯。关联文件:DeviceAssetService.java。关联逻辑(调用链/数据流):移除图片标记 -> removeReference -> 数据库字段置空 -> 原图保留。 */ /** 代码作用(白话):清空数据库中的附件引用但保留物理原图,以支持软删除后追溯。关联文件:DeviceAssetService.java。关联逻辑(调用链/数据流):移除图片标记 -> removeReference -> 数据库字段置空 -> 原图保留。 */
public String removeReference() { return null; } public String removeReference() { return null; }
/** 代码作用(白话):删除本次失败请求新写入的文件,不接收历史附件标识。关联文件:DeviceAssetService.java。关联逻辑(调用链/数据流):保存失败 -> cleanupNewFile -> 删除临时新图。 */ /** 代码作用(白话):删除本次失败请求新写入的原图及其缩略图,不接收历史附件标识。关联文件:DeviceAssetService.java。关联逻辑(调用链/数据流):保存失败 -> cleanupNewFile -> 删除临时新图。 */
public void cleanupNewFile(String identifier) { public void cleanupNewFile(String identifier) {
if (identifier == null) return; if (identifier == null) return;
try { Files.deleteIfExists(resolveInsideRoot(identifier)); } catch (IOException ignored) { } try { Files.deleteIfExists(resolveInsideRoot(identifier)); Files.deleteIfExists(thumbnailPathFor(identifier)); } catch (IOException ignored) { }
} }
/** 代码作用(白话):同时检查文件大小、扩展名和可解码图像内容。关联文件:DeviceAssetSaveRequest.java、DeviceAssetController.java。关联逻辑(调用链/数据流):浏览器文件 -> validateImage -> 允许保存或返回 400。 */ /**
private void validateImage(MultipartFile image) { * 代码作用(白话):解码原图并写出一张长边不超过 240 像素的 JPEG 缩略图,顺带确认文件确实是图片。
if (image.getSize() > MAX_IMAGE_BYTES) throw new DeviceAssetValidationException("每张图片不能超过 20MB"); * 关联文件:DeviceAssetController.java、DeviceAssetView.js。
String extension = extensionOf(image.getOriginalFilename()); * 关联逻辑(调用链/数据流):原图文件 -> 降采样解码 -> 缩放 -> 临时文件 -> 原子替换 -> 缩略图。
if (!EXTENSIONS.contains(extension)) throw new DeviceAssetValidationException("仅支持 JPG、PNG、GIF 图片"); */
try (InputStream input = image.getInputStream()) { BufferedImage decoded = ImageIO.read(input); if (decoded == null) throw new DeviceAssetValidationException("图片内容无效"); } private void writeThumbnail(Path source, Path target) {
catch (IOException exception) { throw new DeviceAssetValidationException("图片内容无效"); } BufferedImage scaled = scaleDown(decodeSubsampled(source));
Path temporary = null;
try {
temporary = Files.createTempFile(root, "thumb-", ".tmp");
if (!ImageIO.write(scaled, "jpg", temporary.toFile())) throw new DeviceAssetValidationException("图片内容无效");
moveInPlace(temporary, target);
temporary = null;
} catch (IOException exception) { throw new DeviceAssetValidationException("设备图片保存失败"); }
finally { deleteQuietly(temporary); }
}
/**
* 代码作用(白话):以 1/N 分辨率读取原图,避免为了一张 240 像素的缩略图把整张大图解进内存。
* 关联文件:DeviceAssetFileStorageService.java。
* 关联逻辑(调用链/数据流):文件 -> ImageReader 读尺寸 -> 采样步长 -> 小尺寸 BufferedImage。
*/
private BufferedImage decodeSubsampled(Path source) {
// 用 File 而不是 InputStream 建流:InputStream 版会把整份数据复制进临时缓存文件,随机访问的文件流没有这层开销。
try (ImageInputStream input = ImageIO.createImageInputStream(source.toFile())) {
if (input == null) throw new DeviceAssetValidationException("图片内容无效");
Iterator<ImageReader> readers = ImageIO.getImageReaders(input);
if (!readers.hasNext()) throw new DeviceAssetValidationException("图片内容无效");
ImageReader reader = readers.next();
try {
reader.setInput(input);
int longestEdge = Math.max(reader.getWidth(0), reader.getHeight(0));
// 只降到目标的两倍再做平滑缩放:直接一步采样到 240 会出现明显锯齿。
int step = Math.max(1, longestEdge / (THUMBNAIL_MAX_EDGE * 2));
ImageReadParam parameters = reader.getDefaultReadParam();
parameters.setSourceSubsampling(step, step, 0, 0);
BufferedImage decoded = reader.read(0, parameters);
if (decoded == null) throw new DeviceAssetValidationException("图片内容无效");
return decoded;
} finally { reader.dispose(); }
} catch (DeviceAssetValidationException failure) { throw failure; }
catch (IOException | RuntimeException exception) { throw new DeviceAssetValidationException("图片内容无效"); }
}
/** 代码作用(白话):把解码结果等比缩到长边 240 并铺上白底。关联文件:DeviceAssetFileStorageService.java。关联逻辑(调用链/数据流):BufferedImage -> 等比尺寸 -> 不含透明通道的缩略图。 */
private BufferedImage scaleDown(BufferedImage source) {
double ratio = Math.min(1.0, (double) THUMBNAIL_MAX_EDGE / Math.max(source.getWidth(), source.getHeight()));
int width = Math.max(1, (int) Math.round(source.getWidth() * ratio));
int height = Math.max(1, (int) Math.round(source.getHeight() * ratio));
BufferedImage target = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
Graphics2D canvas = target.createGraphics();
try {
canvas.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR);
canvas.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY);
canvas.setColor(Color.WHITE);
canvas.fillRect(0, 0, width, height);
canvas.drawImage(source, 0, 0, width, height, null);
} finally { canvas.dispose(); }
return target;
}
/** 代码作用(白话):优先用原子移动落位缩略图,避免并发请求读到写了一半的文件。关联文件:DeviceAssetFileStorageService.java。关联逻辑(调用链/数据流):临时文件 -> 原子替换 -> 缩略图;Windows 不支持时退回普通替换。 */
private void moveInPlace(Path temporary, Path target) throws IOException {
try { Files.move(temporary, target, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); }
catch (UnsupportedOperationException | java.nio.file.AtomicMoveNotSupportedException fallback) { Files.move(temporary, target, StandardCopyOption.REPLACE_EXISTING); }
}
/** 代码作用(白话):删掉没能落位的临时文件,失败也不影响主流程。关联文件:DeviceAssetFileStorageService.java。关联逻辑(调用链/数据流):写缩略图异常 -> 清理临时文件。 */
private void deleteQuietly(Path file) { if (file != null) { try { Files.deleteIfExists(file); } catch (IOException ignored) { } } }
/** 代码作用(白话):把已存在的文件包装成可读取资源,缺失时统一报"图片不存在"。关联文件:DeviceAssetController.java。关联逻辑(调用链/数据流):本地路径 -> UrlResource -> 文件响应。 */
private Resource readable(Path file) {
try { Resource resource = new UrlResource(file.toUri()); if (!resource.exists() || !resource.isReadable()) throw new DeviceAssetValidationException("图片不存在"); return resource; }
catch (IOException exception) { throw new DeviceAssetValidationException("图片读取失败"); }
}
/** 代码作用(白话):补生成缩略图前先确认原图还在,否则直接报图片不存在。关联文件:DeviceAssetFileStorageService.java。关联逻辑(调用链/数据流):缩略图缺失 -> 检查原图 -> 生成或 400。 */
private Path readableOrFail(Path original) { if (!Files.isReadable(original)) throw new DeviceAssetValidationException("图片不存在"); return original; }
/** 代码作用(白话):按原图标识推导同目录下的缩略图路径。关联文件:DeviceAssetService.java。关联逻辑(调用链/数据流):合法标识 -> 根目录校验 -> {uuid}.thumb.jpg。 */
private Path thumbnailPathFor(String identifier) {
Path original = resolveInsideRoot(identifier);
return original.resolveSibling(identifier.substring(0, identifier.lastIndexOf('.')) + THUMBNAIL_SUFFIX);
} }
/** 代码作用(白话):生成不包含原始文件名的随机标识,降低猜测路径风险。关联文件:DeviceAssetController.java。关联逻辑(调用链/数据流):上传文件 -> 随机标识 -> 受控访问 URL。 */ /** 代码作用(白话):生成不包含原始文件名的随机标识,降低猜测路径风险。关联文件:DeviceAssetController.java。关联逻辑(调用链/数据流):上传文件 -> 随机标识 -> 受控访问 URL。 */
......
...@@ -29,6 +29,8 @@ import java.util.List; ...@@ -29,6 +29,8 @@ import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Set; import java.util.Set;
import java.util.function.Function; import java.util.function.Function;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.springframework.core.io.Resource; import org.springframework.core.io.Resource;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
...@@ -37,6 +39,10 @@ import org.springframework.stereotype.Service; ...@@ -37,6 +39,10 @@ import org.springframework.stereotype.Service;
public class DeviceAssetService { public class DeviceAssetService {
private static final Set<String> USAGE_STATUSES = Set.of("\u4f7f\u7528\u4e2d", "\u95f2\u7f6e", "\u7ef4\u4fee\u4e2d", "\u505c\u7528"); private static final Set<String> USAGE_STATUSES = Set.of("\u4f7f\u7528\u4e2d", "\u95f2\u7f6e", "\u7ef4\u4fee\u4e2d", "\u505c\u7528");
private static final Set<String> RELATION_STATUSES = Set.of("\u5df2\u5173\u8054", "\u672a\u5173\u8054", "\u5f85\u786e\u8ba4"); private static final Set<String> RELATION_STATUSES = Set.of("\u5df2\u5173\u8054", "\u672a\u5173\u8054", "\u5f85\u786e\u8ba4");
/** \u672a\u6307\u5b9a\u524d\u7f00\u65f6\u7684\u9ed8\u8ba4\u7f16\u53f7\u524d\u7f00\uff0c\u5bf9\u5e94\u6700\u5e38\u89c1\u7684\u4e00\u6279\u8bbe\u5907\u3002 */
private static final String DEFAULT_DEVICE_NAME_PREFIX = "\u5b66\u7ba1\u5e08";
private static final Pattern SAFE_PREFIX = Pattern.compile("[\\u4e00-\\u9fa5A-Za-z0-9]{1,20}");
private static final Pattern NUMBERED_SUFFIX = Pattern.compile("(\\d{1,6})\u53f7\u673a");
private final AssetDeviceMapper deviceMapper; private final AssetDeviceMapper deviceMapper;
private final CompanyPersonMapper personMapper; private final CompanyPersonMapper personMapper;
private final PhoneAssetMapper phoneMapper; private final PhoneAssetMapper phoneMapper;
...@@ -97,9 +103,35 @@ public class DeviceAssetService { ...@@ -97,9 +103,35 @@ public class DeviceAssetService {
.stream().limit(20).map(item->new DevicePersonLookupResponse(item.getId(),item.getPersonName())).toList(); .stream().limit(20).map(item->new DevicePersonLookupResponse(item.getId(),item.getPersonName())).toList();
} }
/**
* Plain purpose: suggest the next free "<prefix>N号机" name so the dialog can fill a sequential number in one click.
* Related files: DeviceAssetController.java, DeviceAssetView.js.
* Flow: 一键编号 -> prefix 前缀匹配查询 -> 取现有最大编号 +1 -> 输入框。
* 必须查数据库而不是只看当前页:列表一页只有 20 条,最大编号很可能在别的页上,
* 只按当前页推算会算出一个已存在的名字,保存时被唯一性校验直接打回。
*/
public String suggestNextDeviceName(String prefix) {
String base=hasText(prefix)?prefix.trim():DEFAULT_DEVICE_NAME_PREFIX;
// 前缀直接拼进 LIKE 条件,必须限定字符集:% 和 _ 会被当通配符,其他符号也没有作为设备前缀的意义。
if(!SAFE_PREFIX.matcher(base).matches()) throw new DeviceAssetValidationException("设备名称前缀只能是 1-20 位中文、字母或数字");
int largest=0;
// 用 likeRight 让 SQL 只捞前缀命中的行,剩下的形状判断放在 Java 侧:LIKE 表达不了"后面必须是纯数字加号机"。
for(AssetDeviceEntity item:deviceMapper.selectList(new LambdaQueryWrapper<AssetDeviceEntity>().eq(AssetDeviceEntity::getDeleteTime,0L).likeRight(AssetDeviceEntity::getDeviceName,base))) {
String name=item.getDeviceName();
if(name==null||!name.startsWith(base)) continue;
Matcher matcher=NUMBERED_SUFFIX.matcher(name.substring(base.length()));
// 位数上限交给正则:设备编号不会有七位数,放开会让脏数据把 parseInt 撑爆。
if(matcher.matches()) largest=Math.max(largest,Integer.parseInt(matcher.group(1)));
}
return base+(largest+1)+"号机";
}
/** Plain purpose: resolve a safe image identifier to a controlled resource. Related files: DeviceAssetController.java, DeviceAssetFileStorageService.java. Flow: image URL -> service -> storage -> response body. */ /** Plain purpose: resolve a safe image identifier to a controlled resource. Related files: DeviceAssetController.java, DeviceAssetFileStorageService.java. Flow: image URL -> service -> storage -> response body. */
public Resource findImage(String identifier) { return fileStorage.resolve(identifier); } public Resource findImage(String identifier) { return fileStorage.resolve(identifier); }
/** Plain purpose: resolve the small list/preview thumbnail, generating it once for images stored before thumbnails existed. Related files: DeviceAssetController.java, DeviceAssetFileStorageService.java. Flow: thumb URL -> service -> storage -> cached small JPEG. */
public Resource findThumbnail(String identifier) { return fileStorage.resolveThumbnail(identifier); }
/** Plain purpose: combine active-only and optional page filters. Related files: DeviceAssetPageQuery.java, AssetDeviceEntity.java. Flow: query DTO -> LambdaQueryWrapper -> SQL WHERE. */ /** Plain purpose: combine active-only and optional page filters. Related files: DeviceAssetPageQuery.java, AssetDeviceEntity.java. Flow: query DTO -> LambdaQueryWrapper -> SQL WHERE. */
private LambdaQueryWrapper<AssetDeviceEntity> activeQuery(DeviceAssetPageQuery query) { private LambdaQueryWrapper<AssetDeviceEntity> activeQuery(DeviceAssetPageQuery query) {
return new LambdaQueryWrapper<AssetDeviceEntity>().eq(AssetDeviceEntity::getDeleteTime,0L).like(hasText(query.deviceName()),AssetDeviceEntity::getDeviceName,query.deviceName()).eq(query.userPersonId()!=null,AssetDeviceEntity::getUserPersonId,query.userPersonId()).eq(hasText(query.userUsageStatus()),AssetDeviceEntity::getUserUsageStatus,query.userUsageStatus()).eq(hasText(query.assetRelationStatus()),AssetDeviceEntity::getAssetRelationStatus,query.assetRelationStatus()).orderByDesc(AssetDeviceEntity::getId); return new LambdaQueryWrapper<AssetDeviceEntity>().eq(AssetDeviceEntity::getDeleteTime,0L).like(hasText(query.deviceName()),AssetDeviceEntity::getDeviceName,query.deviceName()).eq(query.userPersonId()!=null,AssetDeviceEntity::getUserPersonId,query.userPersonId()).eq(hasText(query.userUsageStatus()),AssetDeviceEntity::getUserUsageStatus,query.userUsageStatus()).eq(hasText(query.assetRelationStatus()),AssetDeviceEntity::getAssetRelationStatus,query.assetRelationStatus()).orderByDesc(AssetDeviceEntity::getId);
...@@ -146,9 +178,11 @@ public class DeviceAssetService { ...@@ -146,9 +178,11 @@ public class DeviceAssetService {
/** Plain purpose: resolve person IDs in one query to avoid row-by-row lookups. Related files: CompanyPersonEntity.java, DeviceAssetResponse.java. Flow: IDs -> mapper IN query -> name map -> response. */ /** Plain purpose: resolve person IDs in one query to avoid row-by-row lookups. Related files: CompanyPersonEntity.java, DeviceAssetResponse.java. Flow: IDs -> mapper IN query -> name map -> response. */
private Map<Long,String> personNames(Set<Long> ids) { if(ids.isEmpty()) return Map.of(); Map<Long,String> result=new HashMap<>(); personMapper.selectList(new LambdaQueryWrapper<CompanyPersonEntity>().in(CompanyPersonEntity::getId,ids).eq(CompanyPersonEntity::getDeleteTime,0L)).forEach(item->result.put(item.getId(),item.getPersonName())); return result; } private Map<Long,String> personNames(Set<Long> ids) { if(ids.isEmpty()) return Map.of(); Map<Long,String> result=new HashMap<>(); personMapper.selectList(new LambdaQueryWrapper<CompanyPersonEntity>().in(CompanyPersonEntity::getId,ids).eq(CompanyPersonEntity::getDeleteTime,0L)).forEach(item->result.put(item.getId(),item.getPersonName())); return result; }
/** Plain purpose: expose safe URLs rather than internal image identifiers or paths. Related files: DeviceAssetResponse.java, DeviceAssetController.java. Flow: entity -> URL conversion -> API JSON -> image tag. */ /** Plain purpose: expose safe URLs rather than internal image identifiers or paths. Related files: DeviceAssetResponse.java, DeviceAssetController.java. Flow: entity -> URL conversion -> API JSON -> image tag. */
private DeviceAssetResponse toResponse(AssetDeviceEntity entity,Map<Long,String> names) { return new DeviceAssetResponse(entity.getId(),entity.getDeviceName(),imageUrl(entity.getImageAttachment1()),imageUrl(entity.getImageAttachment2()),entity.getUserPersonId(),entity.getUserPersonId()==null?null:names.get(entity.getUserPersonId()),entity.getUserUsageStatus(),entity.getAssetRelationStatus(),entity.getCreateTime(),entity.getUpdateTime()); } private DeviceAssetResponse toResponse(AssetDeviceEntity entity,Map<Long,String> names) { return new DeviceAssetResponse(entity.getId(),entity.getDeviceName(),imageUrl(entity.getImageAttachment1()),imageUrl(entity.getImageAttachment2()),thumbnailUrl(entity.getImageAttachment1()),thumbnailUrl(entity.getImageAttachment2()),entity.getUserPersonId(),entity.getUserPersonId()==null?null:names.get(entity.getUserPersonId()),entity.getUserUsageStatus(),entity.getAssetRelationStatus(),entity.getCreateTime(),entity.getUpdateTime()); }
/** Plain purpose: translate a stored opaque image identifier into a controlled URL. Related files: DeviceAssetController.java, DeviceAssetFileStorageService.java. Flow: identifier -> imageUrl -> browser GET. */ /** Plain purpose: translate a stored opaque image identifier into a controlled URL. Related files: DeviceAssetController.java, DeviceAssetFileStorageService.java. Flow: identifier -> imageUrl -> browser GET. */
private String imageUrl(String identifier) { return identifier==null||identifier.isBlank()?null:"/api/device-assets/files/"+identifier; } private String imageUrl(String identifier) { return identifier==null||identifier.isBlank()?null:"/api/device-assets/files/"+identifier; }
/** Plain purpose: point the list and dialog preview at the small derived image instead of the full-size original. Related files: DeviceAssetView.js, DeviceAssetFileStorageService.java. Flow: identifier -> thumbnailUrl -> browser GET with variant=thumb. */
private String thumbnailUrl(String identifier) { String url=imageUrl(identifier); return url==null?null:url+"?variant=thumb"; }
/** Plain purpose: copy editable request fields into a database entity. Related files: DeviceAssetSaveRequest.java, AssetDeviceEntity.java. Flow: DTO -> entity -> mapper insert/update. */ /** Plain purpose: copy editable request fields into a database entity. Related files: DeviceAssetSaveRequest.java, AssetDeviceEntity.java. Flow: DTO -> entity -> mapper insert/update. */
private void applyEditableFields(AssetDeviceEntity entity,DeviceAssetSaveRequest request,String image1,String image2) { entity.setDeviceName(request.getDeviceName().trim()); entity.setUserPersonId(request.getUserPersonId()); entity.setUserUsageStatus(request.getUserUsageStatus()); entity.setAssetRelationStatus(request.getAssetRelationStatus()); entity.setImageAttachment1(image1); entity.setImageAttachment2(image2); } private void applyEditableFields(AssetDeviceEntity entity,DeviceAssetSaveRequest request,String image1,String image2) { entity.setDeviceName(request.getDeviceName().trim()); entity.setUserPersonId(request.getUserPersonId()); entity.setUserUsageStatus(request.getUserUsageStatus()); entity.setAssetRelationStatus(request.getAssetRelationStatus()); entity.setImageAttachment1(image1); entity.setImageAttachment2(image2); }
/** Plain purpose: decide whether a text filter has meaningful input. Related files: DeviceAssetPageQuery.java, DeviceAssetService.java. Flow: HTTP query -> filter presence -> SQL predicate. */ /** Plain purpose: decide whether a text filter has meaningful input. Related files: DeviceAssetPageQuery.java, DeviceAssetService.java. Flow: HTTP query -> filter presence -> SQL predicate. */
......
...@@ -10,6 +10,7 @@ import static org.junit.jupiter.api.Assertions.assertThrows; ...@@ -10,6 +10,7 @@ import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
...@@ -47,7 +48,7 @@ class DeviceAssetControllerTest { ...@@ -47,7 +48,7 @@ class DeviceAssetControllerTest {
} }
/** 代码作用(白话):验证删除被关联资产阻断时,接口返回 400 和可读错误信息。关联文件:DeviceAssetController.java、DeviceAssetExceptionHandler.java。关联逻辑(调用链/数据流):DELETE -> Service.softDelete 异常 -> Advice -> 400 JSON。*/ /** 代码作用(白话):验证删除被关联资产阻断时,接口返回 400 和可读错误信息。关联文件:DeviceAssetController.java、DeviceAssetExceptionHandler.java。关联逻辑(调用链/数据流):DELETE -> Service.softDelete 异常 -> Advice -> 400 JSON。*/
@Test void returnsBadRequestWhenDeleteIsBlocked() throws Exception { @Test void returnsBadRequestWhenDeleteIsBlocked() throws Exception {
DeviceAssetService service=mock(DeviceAssetService.class);doThrow(new DeviceAssetValidationException("设备仍被手机号码资产引用,不能删除")).when(service).softDelete(1L); DeviceAssetService service=mock(DeviceAssetService.class);doThrow(new DeviceAssetValidationException("设备仍被手机号码管理引用,不能删除")).when(service).softDelete(1L);
mockMvc(service).perform(delete("/api/device-assets/1")).andExpect(status().isBadRequest()).andExpect(jsonPath("$.code").value(400)); mockMvc(service).perform(delete("/api/device-assets/1")).andExpect(status().isBadRequest()).andExpect(jsonPath("$.code").value(400));
} }
/** Plain purpose: verify the edit endpoint binds multipart fields and delegates to the update service. Related files: DeviceAssetController.java, DeviceAssetSaveRequest.java. Flow: multipart PUT -> model binding -> service.update -> success JSON. */ /** Plain purpose: verify the edit endpoint binds multipart fields and delegates to the update service. Related files: DeviceAssetController.java, DeviceAssetSaveRequest.java. Flow: multipart PUT -> model binding -> service.update -> success JSON. */
...@@ -61,6 +62,15 @@ class DeviceAssetControllerTest { ...@@ -61,6 +62,15 @@ class DeviceAssetControllerTest {
DeviceAssetService service=mock(DeviceAssetService.class);when(service.findImage("opaque.png")).thenReturn(new ByteArrayResource(new byte[]{7,8}) { @Override public String getFilename(){return "opaque.png";} }); DeviceAssetService service=mock(DeviceAssetService.class);when(service.findImage("opaque.png")).thenReturn(new ByteArrayResource(new byte[]{7,8}) { @Override public String getFilename(){return "opaque.png";} });
mockMvc(service).perform(get("/api/device-assets/files/opaque.png")).andExpect(status().isOk()).andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.content().bytes(new byte[]{7,8})); mockMvc(service).perform(get("/api/device-assets/files/opaque.png")).andExpect(status().isOk()).andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.content().bytes(new byte[]{7,8}));
} }
/** Plain purpose: prove variant=thumb serves the derived small image and that both variants carry a long private cache header. Related files: DeviceAssetFileStorageService.java, DeviceAssetView.js. Flow: thumb URL -> findThumbnail -> cached small JPEG bytes. */
@Test void servesThumbnailVariantWithLongLivedPrivateCache() throws Exception {
DeviceAssetService service=mock(DeviceAssetService.class);
when(service.findThumbnail("opaque.png")).thenReturn(new ByteArrayResource(new byte[]{1,2}) { @Override public String getFilename(){return "opaque.thumb.jpg";} });
mockMvc(service).perform(get("/api/device-assets/files/opaque.png").param("variant","thumb")).andExpect(status().isOk())
.andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.content().bytes(new byte[]{1,2}))
.andExpect(header().string("Cache-Control","max-age=31536000, private, immutable"));
verify(service,org.mockito.Mockito.never()).findImage(any());
}
/** Plain purpose: assert the public list response has URLs but never deleteTime or a disk path field. Related files: DeviceAssetResponse.java, DeviceAssetController.java. Flow: service response -> ApiResponse JSON -> browser. */ /** Plain purpose: assert the public list response has URLs but never deleteTime or a disk path field. Related files: DeviceAssetResponse.java, DeviceAssetController.java. Flow: service response -> ApiResponse JSON -> browser. */
@Test void hidesDeleteTimeAndPhysicalImagePath() throws Exception { @Test void hidesDeleteTimeAndPhysicalImagePath() throws Exception {
DeviceAssetService service=mock(DeviceAssetService.class);when(service.page(any())).thenReturn(new DeviceAssetPageResponse(List.of(response()),1,1,20)); DeviceAssetService service=mock(DeviceAssetService.class);when(service.page(any())).thenReturn(new DeviceAssetPageResponse(List.of(response()),1,1,20));
...@@ -76,7 +86,8 @@ class DeviceAssetControllerTest { ...@@ -76,7 +86,8 @@ class DeviceAssetControllerTest {
assertThrows(AccessDeniedException.class, () -> controller.update(1L, new DeviceAssetSaveRequest())); assertThrows(AccessDeniedException.class, () -> controller.update(1L, new DeviceAssetSaveRequest()));
assertThrows(AccessDeniedException.class, () -> controller.delete(1L)); assertThrows(AccessDeniedException.class, () -> controller.delete(1L));
assertThrows(AccessDeniedException.class, () -> controller.companyPersons("name")); assertThrows(AccessDeniedException.class, () -> controller.companyPersons("name"));
assertThrows(AccessDeniedException.class, () -> controller.file("opaque.png")); assertThrows(AccessDeniedException.class, () -> controller.file("opaque.png", null));
assertThrows(AccessDeniedException.class, () -> controller.file("opaque.png", "thumb"));
verifyNoInteractions(service); verifyNoInteractions(service);
} finally { SecurityContextHolder.clearContext(); } } finally { SecurityContextHolder.clearContext(); }
} }
...@@ -84,5 +95,5 @@ class DeviceAssetControllerTest { ...@@ -84,5 +95,5 @@ class DeviceAssetControllerTest {
/** Plain purpose: create an administrator-authorized controller test harness without a live Spring Security filter chain. Related files: DeviceAssetController.java, PagePermissionService.java. Flow: mock permission check -> controller endpoint -> mocked device service -> HTTP assertion. */ /** Plain purpose: create an administrator-authorized controller test harness without a live Spring Security filter chain. Related files: DeviceAssetController.java, PagePermissionService.java. Flow: mock permission check -> controller endpoint -> mocked device service -> HTTP assertion. */
private MockMvc mockMvc(DeviceAssetService service){return MockMvcBuilders.standaloneSetup(new DeviceAssetController(service,mock(PagePermissionService.class))).setControllerAdvice(new DeviceAssetExceptionHandler()).build();} private MockMvc mockMvc(DeviceAssetService service){return MockMvcBuilders.standaloneSetup(new DeviceAssetController(service,mock(PagePermissionService.class))).setControllerAdvice(new DeviceAssetExceptionHandler()).build();}
/** 代码作用(白话):生成一条不含服务器文件真实路径的安全设备响应。关联文件:DeviceAssetResponse.java、DeviceAssetController.java。关联逻辑(调用链/数据流):服务层响应 -> ApiResponse -> 页面展示。*/ /** 代码作用(白话):生成一条不含服务器文件真实路径的安全设备响应。关联文件:DeviceAssetResponse.java、DeviceAssetController.java。关联逻辑(调用链/数据流):服务层响应 -> ApiResponse -> 页面展示。*/
private DeviceAssetResponse response(){return new DeviceAssetResponse(1L,"测试电脑","/api/device-assets/files/image.png",null,9L,"张三","使用中","未关联",null,null);} private DeviceAssetResponse response(){return new DeviceAssetResponse(1L,"测试电脑","/api/device-assets/files/image.png",null,"/api/device-assets/files/image.png?variant=thumb",null,9L,"张三","使用中","未关联",null,null);}
} }
...@@ -32,9 +32,38 @@ class DeviceAssetFileStorageServiceTest { ...@@ -32,9 +32,38 @@ class DeviceAssetFileStorageServiceTest {
DeviceAssetFileStorageService storage=new DeviceAssetFileStorageService(directory.toString());assertThrows(DeviceAssetValidationException.class,()->storage.store(new MockMultipartFile("image","bad.png","image/png",new byte[]{1,2,3})));assertThrows(DeviceAssetValidationException.class,()->storage.store(new MockMultipartFile("image","bad.txt","text/plain",png())));assertThrows(DeviceAssetValidationException.class,()->storage.store(new MockMultipartFile("image","large.png","image/png",new byte[20*1024*1024+1]))); DeviceAssetFileStorageService storage=new DeviceAssetFileStorageService(directory.toString());assertThrows(DeviceAssetValidationException.class,()->storage.store(new MockMultipartFile("image","bad.png","image/png",new byte[]{1,2,3})));assertThrows(DeviceAssetValidationException.class,()->storage.store(new MockMultipartFile("image","bad.txt","text/plain",png())));assertThrows(DeviceAssetValidationException.class,()->storage.store(new MockMultipartFile("image","large.png","image/png",new byte[20*1024*1024+1])));
} }
/** 代码作用(白话):验证文件标识不能离开设备图片根目录。关联文件:DeviceAssetFileStorageService.java、DeviceAssetController.java。关联逻辑(调用链/数据流):图片 URL 标识 -> resolve -> 根目录校验。 */ /** 代码作用(白话):验证文件标识不能离开设备图片根目录。关联文件:DeviceAssetFileStorageService.java、DeviceAssetController.java。关联逻辑(调用链/数据流):图片 URL 标识 -> resolve -> 根目录校验。 */
@Test void rejectsPathTraversalIdentifier(@TempDir Path directory) { DeviceAssetFileStorageService storage=new DeviceAssetFileStorageService(directory.toString());assertThrows(DeviceAssetValidationException.class,()->storage.resolve("../secret.png")); } @Test void rejectsPathTraversalIdentifier(@TempDir Path directory) { DeviceAssetFileStorageService storage=new DeviceAssetFileStorageService(directory.toString());assertThrows(DeviceAssetValidationException.class,()->storage.resolve("../secret.png"));assertThrows(DeviceAssetValidationException.class,()->storage.resolveThumbnail("../secret.png")); }
/** 代码作用(白话):验证保存时会产出一张长边不超过 240 像素、体积远小于原图的缩略图。关联文件:DeviceAssetFileStorageService.java、DeviceAssetView.js。关联逻辑(调用链/数据流):大图 -> store -> 缩略图文件 -> 列表小图请求。 */
@Test void writesBoundedThumbnailAlongsideOriginal(@TempDir Path directory) throws Exception {
DeviceAssetFileStorageService storage=new DeviceAssetFileStorageService(directory.toString());
String identifier=storage.store(new MockMultipartFile("image","big.png","image/png",image("png",1600,900)));
// 必须关流:Windows 下未关闭的文件句柄会锁住 @TempDir,导致测试结束时删不掉临时目录。
BufferedImage thumbnail;
try (var stream=storage.resolveThumbnail(identifier).getInputStream()) { thumbnail=ImageIO.read(stream); }
assertTrue(Math.max(thumbnail.getWidth(),thumbnail.getHeight())<=240,"缩略图长边应被限制在 240 像素内");
assertTrue(Math.abs(thumbnail.getWidth()/(double)thumbnail.getHeight()-1600/900.0)<0.05,"缩略图应保持原图宽高比");
assertTrue(storage.resolveThumbnail(identifier).contentLength()<storage.resolve(identifier).contentLength(),"缩略图应明显小于原图");
}
/** 代码作用(白话):验证缩略图出现前上传的历史图片,在首次请求时会被补生成并落盘复用。关联文件:DeviceAssetFileStorageService.java、DeviceAssetController.java。关联逻辑(调用链/数据流):缩略图缺失 -> resolveThumbnail -> 即时生成 -> 写盘 -> 后续直接命中。 */
@Test void regeneratesThumbnailForImagesStoredBeforeThisFeature(@TempDir Path directory) throws Exception {
DeviceAssetFileStorageService storage=new DeviceAssetFileStorageService(directory.toString());
String identifier=storage.store(new MockMultipartFile("image","legacy.png","image/png",image("png",800,600)));
Path thumbnail=directory.resolve(identifier.substring(0,identifier.lastIndexOf('.'))+".thumb.jpg");
Files.delete(thumbnail);
assertTrue(storage.resolveThumbnail(identifier).exists());
assertTrue(Files.exists(thumbnail),"补生成的缩略图应落盘,避免每次请求都重新解码");
}
/** 代码作用(白话):验证原图被清理后请求缩略图会报图片不存在,而不是抛出解码异常。关联文件:DeviceAssetFileStorageService.java、DeviceAssetExceptionHandler.java。关联逻辑(调用链/数据流):原图缺失 -> resolveThumbnail -> 业务异常 -> 400。 */
@Test void rejectsThumbnailRequestWhenOriginalIsGone(@TempDir Path directory) throws Exception {
DeviceAssetFileStorageService storage=new DeviceAssetFileStorageService(directory.toString());
String identifier=storage.store(new MockMultipartFile("image","gone.png","image/png",png()));
storage.cleanupNewFile(identifier);
assertThrows(DeviceAssetValidationException.class,()->storage.resolveThumbnail(identifier));
}
/** 代码作用(白话):在测试中生成 ImageIO 必然可解码的 PNG 字节。关联文件:DeviceAssetFileStorageService.java。关联逻辑(调用链/数据流):BufferedImage -> PNG 字节 -> MockMultipartFile -> 服务校验。 */ /** 代码作用(白话):在测试中生成 ImageIO 必然可解码的 PNG 字节。关联文件:DeviceAssetFileStorageService.java。关联逻辑(调用链/数据流):BufferedImage -> PNG 字节 -> MockMultipartFile -> 服务校验。 */
private byte[] png() { try { ByteArrayOutputStream output=new ByteArrayOutputStream();ImageIO.write(new BufferedImage(1,1,BufferedImage.TYPE_INT_ARGB),"png",output);return output.toByteArray(); } catch(Exception exception) { throw new IllegalStateException(exception); } } private byte[] png() { try { ByteArrayOutputStream output=new ByteArrayOutputStream();ImageIO.write(new BufferedImage(1,1,BufferedImage.TYPE_INT_ARGB),"png",output);return output.toByteArray(); } catch(Exception exception) { throw new IllegalStateException(exception); } }
/** 代码作用(白话):按指定格式生成可被 ImageIO 解码的小图片,避免测试数据本身失真。关联文件:DeviceAssetFileStorageService.java。关联逻辑(调用链/数据流):BufferedImage -> 指定格式字节 -> MockMultipartFile -> 上传格式校验。*/ /** 代码作用(白话):按指定格式生成可被 ImageIO 解码的小图片,避免测试数据本身失真。关联文件:DeviceAssetFileStorageService.java。关联逻辑(调用链/数据流):BufferedImage -> 指定格式字节 -> MockMultipartFile -> 上传格式校验。*/
private byte[] image(String format) { try { ByteArrayOutputStream output=new ByteArrayOutputStream();if(!ImageIO.write(new BufferedImage(1,1,BufferedImage.TYPE_INT_RGB),format,output)){throw new IllegalStateException("测试运行环境不支持图片格式:"+format);}return output.toByteArray(); } catch(Exception exception) { throw new IllegalStateException(exception); } } private byte[] image(String format) { return image(format,1,1); }
/** 代码作用(白话):按指定尺寸生成可解码图片,用来验证缩略图的等比缩放和长边上限。关联文件:DeviceAssetFileStorageService.java。关联逻辑(调用链/数据流):宽高 -> 图片字节 -> store -> 缩略图断言。*/
private byte[] image(String format,int width,int height) { try { ByteArrayOutputStream output=new ByteArrayOutputStream();if(!ImageIO.write(new BufferedImage(width,height,BufferedImage.TYPE_INT_RGB),format,output)){throw new IllegalStateException("测试运行环境不支持图片格式:"+format);}return output.toByteArray(); } catch(Exception exception) { throw new IllegalStateException(exception); } }
} }
...@@ -47,7 +47,7 @@ class DeviceAssetServiceTest { ...@@ -47,7 +47,7 @@ class DeviceAssetServiceTest {
AssetDeviceMapper devices=mock(AssetDeviceMapper.class);when(devices.selectCount(any(Wrapper.class))).thenReturn(1L); AssetDeviceMapper devices=mock(AssetDeviceMapper.class);when(devices.selectCount(any(Wrapper.class))).thenReturn(1L);
assertThrows(DeviceAssetValidationException.class,()->service(devices,mock(CompanyPersonMapper.class)).create(validRequest("重复设备"))); assertThrows(DeviceAssetValidationException.class,()->service(devices,mock(CompanyPersonMapper.class)).create(validRequest("重复设备")));
} }
/** 代码作用(白话):验证手机号码资产仍关联设备时,删除请求会被阻断,且不更新设备删除时间。关联文件:DeviceAssetService.java、PhoneAssetMapper.java。关联逻辑(调用链/数据流):DELETE -> 查询设备 -> 手机号码引用计数 -> 400 业务异常。*/ /** 代码作用(白话):验证手机号码管理仍关联设备时,删除请求会被阻断,且不更新设备删除时间。关联文件:DeviceAssetService.java、PhoneAssetMapper.java。关联逻辑(调用链/数据流):DELETE -> 查询设备 -> 手机号码引用计数 -> 400 业务异常。*/
@Test void blocksDeleteWhenPhoneAssetStillReferencesDevice() { @Test void blocksDeleteWhenPhoneAssetStillReferencesDevice() {
AssetDeviceMapper devices=mock(AssetDeviceMapper.class);PhoneAssetMapper phones=mock(PhoneAssetMapper.class);when(devices.selectOne(any(Wrapper.class))).thenReturn(activeDevice(1L));when(phones.selectCount(any(Wrapper.class))).thenReturn(1L); AssetDeviceMapper devices=mock(AssetDeviceMapper.class);PhoneAssetMapper phones=mock(PhoneAssetMapper.class);when(devices.selectOne(any(Wrapper.class))).thenReturn(activeDevice(1L));when(phones.selectCount(any(Wrapper.class))).thenReturn(1L);
assertThrows(DeviceAssetValidationException.class,()->service(devices,mock(CompanyPersonMapper.class),phones,new DeviceAssetFileStorageService(System.getProperty("java.io.tmpdir")+"/device-test-images")).softDelete(1L)); assertThrows(DeviceAssetValidationException.class,()->service(devices,mock(CompanyPersonMapper.class),phones,new DeviceAssetFileStorageService(System.getProperty("java.io.tmpdir")+"/device-test-images")).softDelete(1L));
...@@ -70,7 +70,32 @@ class DeviceAssetServiceTest { ...@@ -70,7 +70,32 @@ class DeviceAssetServiceTest {
DeviceAssetFileStorageService storage=new DeviceAssetFileStorageService(directory.toString());String original=storage.store(new MockMultipartFile("image","original.png","image/png",png()));AssetDeviceMapper devices=mock(AssetDeviceMapper.class);AssetDeviceEntity device=activeDevice(3L);device.setImageAttachment1(original);when(devices.selectOne(any(Wrapper.class))).thenReturn(device); DeviceAssetFileStorageService storage=new DeviceAssetFileStorageService(directory.toString());String original=storage.store(new MockMultipartFile("image","original.png","image/png",png()));AssetDeviceMapper devices=mock(AssetDeviceMapper.class);AssetDeviceEntity device=activeDevice(3L);device.setImageAttachment1(original);when(devices.selectOne(any(Wrapper.class))).thenReturn(device);
DeviceAssetSaveRequest request=validRequest("update-failure");request.setImageAttachment1(new MockMultipartFile("image","replacement.png","image/png",png())); DeviceAssetSaveRequest request=validRequest("update-failure");request.setImageAttachment1(new MockMultipartFile("image","replacement.png","image/png",png()));
assertThrows(com.xyw.console.asset.exception.DeviceAssetNotFoundException.class,()->service(devices,mock(CompanyPersonMapper.class),mock(PhoneAssetMapper.class),storage).update(3L,request)); assertThrows(com.xyw.console.asset.exception.DeviceAssetNotFoundException.class,()->service(devices,mock(CompanyPersonMapper.class),mock(PhoneAssetMapper.class),storage).update(3L,request));
try(var files=Files.list(directory)){assertEquals(1,files.count());}assertTrue(Files.exists(directory.resolve(original))); assertEquals(1,countOriginals(directory));assertTrue(Files.exists(directory.resolve(original)));
}
/** Plain purpose: suggest the number after the largest existing one, ignoring rows whose suffix is not a plain 号机 number. Related files: DeviceAssetService.java, DeviceAssetView.js. Flow: 一键编号 -> 前缀匹配行 -> 最大编号+1。 */
@Test void suggestsTheNumberAfterTheLargestExistingDevice() {
AssetDeviceMapper devices=mock(AssetDeviceMapper.class);
when(devices.selectList(any(Wrapper.class))).thenReturn(List.of(named("学管师1号机"),named("学管师7号机"),named("学管师3号机"),named("学管师备用机"),named("学管师12号机备注")));
assertEquals("学管师8号机",service(devices,mock(CompanyPersonMapper.class)).suggestNextDeviceName(""));
}
/** Plain purpose: start at one when nothing matches the prefix yet, and honour a caller-supplied prefix. Related files: DeviceAssetService.java, DeviceAssetView.js. Flow: 空结果 -> prefix+1号机。 */
@Test void startsNumberingAtOneAndHonoursCustomPrefix() {
AssetDeviceMapper devices=mock(AssetDeviceMapper.class);when(devices.selectList(any(Wrapper.class))).thenReturn(List.of());
assertEquals("学管师1号机",service(devices,mock(CompanyPersonMapper.class)).suggestNextDeviceName(""));
assertEquals("班主任1号机",service(devices,mock(CompanyPersonMapper.class)).suggestNextDeviceName(" 班主任 "));
}
/** Plain purpose: refuse prefixes that would smuggle LIKE wildcards or unbounded text into the query. Related files: DeviceAssetService.java, DeviceAssetExceptionHandler.java. Flow: 非法前缀 -> 校验 -> 400。 */
@Test void rejectsUnsafeDeviceNamePrefix() {
DeviceAssetService service=service(mock(AssetDeviceMapper.class),mock(CompanyPersonMapper.class));
assertThrows(DeviceAssetValidationException.class,()->service.suggestNextDeviceName("%"));
assertThrows(DeviceAssetValidationException.class,()->service.suggestNextDeviceName("学管师_"));
assertThrows(DeviceAssetValidationException.class,()->service.suggestNextDeviceName("学".repeat(21)));
}
/** Plain purpose: ignore absurdly long digit runs so a bad row cannot overflow the counter parse. Related files: DeviceAssetService.java. Flow: 七位以上编号 -> 正则不匹配 -> 忽略该行。 */
@Test void ignoresDeviceNumbersBeyondSixDigits() {
AssetDeviceMapper devices=mock(AssetDeviceMapper.class);
when(devices.selectList(any(Wrapper.class))).thenReturn(List.of(named("学管师2号机"),named("学管师99999999999999999999号机")));
assertEquals("学管师3号机",service(devices,mock(CompanyPersonMapper.class)).suggestNextDeviceName("学管师"));
} }
/** Plain purpose: reject a supplied company-person ID when that person is no longer active. Related files: DeviceAssetService.java, CompanyPersonMapper.java. Flow: POST userPersonId -> active-person count -> validation error before insert. */ /** Plain purpose: reject a supplied company-person ID when that person is no longer active. Related files: DeviceAssetService.java, CompanyPersonMapper.java. Flow: POST userPersonId -> active-person count -> validation error before insert. */
@Test void rejectsDeletedUserPersonBeforeCreate() { @Test void rejectsDeletedUserPersonBeforeCreate() {
...@@ -98,7 +123,7 @@ class DeviceAssetServiceTest { ...@@ -98,7 +123,7 @@ class DeviceAssetServiceTest {
@Test void updateReplacesImageWithoutDeletingOriginal(@TempDir Path directory) throws Exception { @Test void updateReplacesImageWithoutDeletingOriginal(@TempDir Path directory) throws Exception {
DeviceAssetFileStorageService storage=new DeviceAssetFileStorageService(directory.toString());String original=storage.store(new MockMultipartFile("image","original.png","image/png",png()));AssetDeviceMapper devices=mock(AssetDeviceMapper.class);AssetDeviceEntity device=activeDevice(5L);device.setImageAttachment1(original);when(devices.selectOne(any(Wrapper.class))).thenReturn(device);when(devices.updateById(any(AssetDeviceEntity.class))).thenReturn(1); DeviceAssetFileStorageService storage=new DeviceAssetFileStorageService(directory.toString());String original=storage.store(new MockMultipartFile("image","original.png","image/png",png()));AssetDeviceMapper devices=mock(AssetDeviceMapper.class);AssetDeviceEntity device=activeDevice(5L);device.setImageAttachment1(original);when(devices.selectOne(any(Wrapper.class))).thenReturn(device);when(devices.updateById(any(AssetDeviceEntity.class))).thenReturn(1);
DeviceAssetSaveRequest request=validRequest("replace-image");request.setImageAttachment1(new MockMultipartFile("image","replacement.png","image/png",png()));String responseUrl=service(devices,mock(CompanyPersonMapper.class),mock(PhoneAssetMapper.class),storage).update(5L,request).imageAttachment1Url(); DeviceAssetSaveRequest request=validRequest("replace-image");request.setImageAttachment1(new MockMultipartFile("image","replacement.png","image/png",png()));String responseUrl=service(devices,mock(CompanyPersonMapper.class),mock(PhoneAssetMapper.class),storage).update(5L,request).imageAttachment1Url();
assertTrue(Files.exists(directory.resolve(original)));assertTrue(!responseUrl.endsWith(original));try(var files=Files.list(directory)){assertEquals(2,files.count());} assertTrue(Files.exists(directory.resolve(original)));assertTrue(!responseUrl.endsWith(original));assertEquals(2,countOriginals(directory));
} }
/** Plain purpose: verify explicit removal clears the database-facing URL but retains the original physical file for recovery. Related files: DeviceAssetService.java, DeviceAssetFileStorageService.java. Flow: PUT remove flag -> null reference -> mapper update; source file stays. */ /** Plain purpose: verify explicit removal clears the database-facing URL but retains the original physical file for recovery. Related files: DeviceAssetService.java, DeviceAssetFileStorageService.java. Flow: PUT remove flag -> null reference -> mapper update; source file stays. */
@Test void updateRemovalClearsImageReferenceButRetainsFile(@TempDir Path directory) throws Exception { @Test void updateRemovalClearsImageReferenceButRetainsFile(@TempDir Path directory) throws Exception {
...@@ -122,4 +147,8 @@ class DeviceAssetServiceTest { ...@@ -122,4 +147,8 @@ class DeviceAssetServiceTest {
private AssetDeviceEntity activeDevice(Long id){AssetDeviceEntity device=new AssetDeviceEntity();device.setId(id);device.setDeviceName("删除测试设备");device.setUserUsageStatus("使用中");device.setAssetRelationStatus("未关联");device.setDeleteTime(0L);return device;} private AssetDeviceEntity activeDevice(Long id){AssetDeviceEntity device=new AssetDeviceEntity();device.setId(id);device.setDeviceName("删除测试设备");device.setUserUsageStatus("使用中");device.setAssetRelationStatus("未关联");device.setDeleteTime(0L);return device;}
/** 代码作用(白话):生成 ImageIO 可解码的 PNG 字节,确保失败清理测试验证的是业务流程。关联文件:DeviceAssetFileStorageService.java。关联逻辑(调用链/数据流):BufferedImage -> MultipartFile -> 文件保存 -> 异常清理。*/ /** 代码作用(白话):生成 ImageIO 可解码的 PNG 字节,确保失败清理测试验证的是业务流程。关联文件:DeviceAssetFileStorageService.java。关联逻辑(调用链/数据流):BufferedImage -> MultipartFile -> 文件保存 -> 异常清理。*/
private byte[] png(){try{ByteArrayOutputStream output=new ByteArrayOutputStream();ImageIO.write(new BufferedImage(1,1,BufferedImage.TYPE_INT_ARGB),"png",output);return output.toByteArray();}catch(Exception exception){throw new IllegalStateException(exception);}} private byte[] png(){try{ByteArrayOutputStream output=new ByteArrayOutputStream();ImageIO.write(new BufferedImage(1,1,BufferedImage.TYPE_INT_ARGB),"png",output);return output.toByteArray();}catch(Exception exception){throw new IllegalStateException(exception);}}
/** 代码作用(白话):生成一条只带名称的设备行,用于编号建议的纯计算断言。关联文件:AssetDeviceEntity.java、DeviceAssetService.java。关联逻辑(调用链/数据流):Mapper.selectList -> 名称解析 -> 下一个编号。*/
private AssetDeviceEntity named(String deviceName){AssetDeviceEntity device=new AssetDeviceEntity();device.setDeviceName(deviceName);return device;}
/** 代码作用(白话):只统计原图数量,忽略每张图派生出的缩略图。关联文件:DeviceAssetFileStorageService.java。关联逻辑(调用链/数据流):上传目录 -> 过滤 .thumb.jpg -> 原图张数断言。这里刻意不数总文件数:派生文件是实现细节,测试要断言的是"新图被清理、原图还在"。*/
private long countOriginals(Path directory){try(var files=Files.list(directory)){return files.filter(file->!file.getFileName().toString().endsWith(".thumb.jpg")).count();}catch(Exception exception){throw new IllegalStateException(exception);}}
} }
import { onMounted, reactive, ref } from 'vue/dist/vue.esm-bundler.js'; import { onMounted, reactive, ref } from 'vue/dist/vue.esm-bundler.js';
import { ElMessage, ElMessageBox } from 'element-plus'; import { ElMessage, ElMessageBox } from 'element-plus';
import { createDeviceAsset, deleteDeviceAsset, listDeviceAssets, searchDeviceCompanyPersons, updateDeviceAsset } from './device-api-client.js'; import { createDeviceAsset, deleteDeviceAsset, listDeviceAssets, searchDeviceCompanyPersons, suggestNextDeviceName, updateDeviceAsset } from './device-api-client.js';
import './device-asset.css'; import './device-asset.css';
/** File purpose: render device asset list, CRUD dialog, image thumbnail/original preview, and guarded deletion. */ /** File purpose: render device asset list, CRUD dialog, image thumbnail/original preview, and guarded deletion. */
export default { export default {
/** Plain purpose: create page state and all UI actions. Related files: device-api-client.js, DeviceAssetController.java. Flow: route -> setup -> API/form state -> Element Plus view. */ /** Plain purpose: create page state and all UI actions. Related files: device-api-client.js, DeviceAssetController.java. Flow: route -> setup -> API/form state -> Element Plus view. */
setup() { setup() {
const usageStatuses=['\u4f7f\u7528\u4e2d','\u95f2\u7f6e','\u7ef4\u4fee\u4e2d','\u505c\u7528']; const usageStatuses=['使用中','闲置','维修中','停用'];
const relationStatuses=['\u5df2\u5173\u8054','\u672a\u5173\u8054','\u5f85\u786e\u8ba4']; const relationStatuses=['已关联','未关联','待确认'];
const loading=ref(false),saving=ref(false),dialogVisible=ref(false),editingId=ref(null),records=ref([]),total=ref(0),personOptions=ref([]),imageViewerVisible=ref(false),imageViewerUrl=ref(''); const imageSlots=['imageAttachment1','imageAttachment2'];
const filters=reactive({page:1,size:20,deviceName:'',userPersonId:null,userUsageStatus:'',assetRelationStatus:''}); const loading=ref(false),saving=ref(false),dialogVisible=ref(false),editingId=ref(null),records=ref([]),total=ref(0),personOptions=ref([]),imageViewerVisible=ref(false),suggestingName=ref(false);
/** 预览改成"一组图 + 起始下标":一行最多两张图,点第二张要能直接左右翻,只存单张 URL 做不到。 */
const imageViewerUrls=ref([]),imageViewerIndex=ref(0);
/** 记录加载失败的图片 URL:后端文件被清理或路径失效时,缩略图会退回占位框,不给用户留一个破图。 */
const brokenImages=reactive({});
/** 已经预取过的原图,避免同一张图反复 new Image();不用响应式,它不参与渲染。 */
const prefetchedImages=new Set();
/** 代码作用(白话):读取地址里带过来的设备名称,让企业微信资产页的「关联设备」链接跳过来时能直接定位到那一台。关联文件:WecomAccountView.js。关联逻辑(调用链/数据流):Hash 查询参数 -> 初始筛选值 -> 首次 loadPage。 */
const routedDeviceName=new URLSearchParams(window.location.hash.split('?')[1]||'').get('deviceName')||'';
const filters=reactive({page:1,size:20,deviceName:routedDeviceName,userPersonId:null,userUsageStatus:'',assetRelationStatus:''});
const form=reactive(emptyForm()); const form=reactive(emptyForm());
let searchTimer=null; let searchTimer=null;
/** Plain purpose: fetch the currently filtered device page. Related files: device-api-client.js, DeviceAssetService.java. Flow: page/filter event -> GET -> records/total -> table. */ /** Plain purpose: fetch the currently filtered device page. Related files: device-api-client.js, DeviceAssetService.java. Flow: page/filter event -> GET -> records/total -> table. */
async function loadPage(){loading.value=true;try{const result=await listDeviceAssets(filters);records.value=result.records;total.value=result.total;/* 越界兜底:当前页已无数据(删完本页、并发删除等)时回退到实际最后一页重读,避免出现“共 0 条 · 第 3/1 页”。 */if(!result.records.length&&filters.page>1){filters.page=Math.max(1,Math.ceil(result.total/filters.size));await loadPage();}}catch(error){ElMessage.error(error.message);}finally{loading.value=false;}} async function loadPage(){loading.value=true;try{const result=await listDeviceAssets(filters);records.value=result.records;total.value=result.total;/* 越界兜底:当前页已无数据(删完本页、并发删除等)时回退到实际最后一页重读,避免出现“共 0 条 · 第 3/1 页”。 */if(!result.records.length&&filters.page>1){filters.page=Math.max(1,Math.ceil(result.total/filters.size));await loadPage();}}catch(error){ElMessage.error(error.message);}finally{loading.value=false;}}
/** Plain purpose: return the create-form defaults that obey both dropdown rules. Related files: DeviceAssetSaveRequest.java, DeviceAssetView.js. Flow: create/reset -> defaults -> multipart POST. */ /** Plain purpose: return the create-form defaults that obey both dropdown rules. Related files: DeviceAssetSaveRequest.java, DeviceAssetView.js. Flow: create/reset -> defaults -> multipart POST. */
function emptyForm(){return {deviceName:'',userPersonId:null,userUsageStatus:'\u4f7f\u7528\u4e2d',assetRelationStatus:'\u5f85\u786e\u8ba4',imageAttachment1:null,imageAttachment2:null,removeImageAttachment1:false,removeImageAttachment2:false,imageAttachment1Url:'',imageAttachment2Url:''};} /** Url 是给 104px 预览框用的小图,FullUrl 才是点开大图时才请求的原图;两者分开存,弹窗打开就不会先拉一张 20MB 的原图。 */
function emptyForm(){return {deviceName:'',userPersonId:null,userUsageStatus:'使用中',assetRelationStatus:'待确认',imageAttachment1:null,imageAttachment2:null,removeImageAttachment1:false,removeImageAttachment2:false,imageAttachment1Url:'',imageAttachment2Url:'',imageAttachment1FullUrl:'',imageAttachment2FullUrl:''};}
/** Plain purpose: clear temporary preview URLs and restore a new-device form. Related files: DeviceAssetView.js, DeviceAssetFileStorageService.java. Flow: open create/save -> reset -> safe empty dialog. */ /** Plain purpose: clear temporary preview URLs and restore a new-device form. Related files: DeviceAssetView.js, DeviceAssetFileStorageService.java. Flow: open create/save -> reset -> safe empty dialog. */
function resetForm(){clearPreview('imageAttachment1');clearPreview('imageAttachment2');Object.assign(form,emptyForm());personOptions.value=[];} function resetForm(){imageSlots.forEach(clearPreview);Object.assign(form,emptyForm());personOptions.value=[];}
/** Plain purpose: show a fresh create dialog. Related files: DeviceAssetView.js, device-api-client.js. Flow: add button -> resetForm -> dialog. */ /** Plain purpose: show a fresh create dialog. Related files: DeviceAssetView.js, device-api-client.js. Flow: add button -> resetForm -> dialog. */
function openCreate(){editingId.value=null;resetForm();dialogVisible.value=true;} function openCreate(){editingId.value=null;resetForm();dialogVisible.value=true;}
/** Plain purpose: populate the dialog from one table row for editing. Related files: DeviceAssetResponse.java, DeviceAssetView.js. Flow: edit button -> row -> form -> PUT. */ /** Plain purpose: populate the dialog from one table row for editing. Related files: DeviceAssetResponse.java, DeviceAssetView.js. Flow: edit button -> row -> form -> PUT. */
function openEdit(row){editingId.value=row.id;clearPreview('imageAttachment1');clearPreview('imageAttachment2');Object.assign(form,{...emptyForm(),deviceName:row.deviceName,userPersonId:row.userPersonId,userUsageStatus:row.userUsageStatus,assetRelationStatus:row.assetRelationStatus,imageAttachment1Url:row.imageAttachment1Url||'',imageAttachment2Url:row.imageAttachment2Url||''});personOptions.value=row.userPersonId?[{id:row.userPersonId,personName:row.userPersonName||`${'\u4eba\u5458'} ${row.userPersonId}`}]:[];dialogVisible.value=true;} function openEdit(row){editingId.value=row.id;imageSlots.forEach(clearPreview);Object.assign(form,{...emptyForm(),deviceName:row.deviceName,userPersonId:row.userPersonId,userUsageStatus:row.userUsageStatus,assetRelationStatus:row.assetRelationStatus,imageAttachment1Url:row.imageAttachment1ThumbUrl||row.imageAttachment1Url||'',imageAttachment2Url:row.imageAttachment2ThumbUrl||row.imageAttachment2Url||'',imageAttachment1FullUrl:row.imageAttachment1Url||'',imageAttachment2FullUrl:row.imageAttachment2Url||''});personOptions.value=row.userPersonId?[{id:row.userPersonId,personName:row.userPersonName||`人员 ${row.userPersonId}`}]:[];dialogVisible.value=true;}
/** Plain purpose: serialize form values and optional files to a multipart create/update request. Related files: device-api-client.js, DeviceAssetController.java. Flow: save -> FormData -> POST/PUT -> refresh table. */ /** Plain purpose: serialize form values and optional files to a multipart create/update request. Related files: device-api-client.js, DeviceAssetController.java. Flow: save -> FormData -> POST/PUT -> refresh table. */
async function submitForm(){if(!form.deviceName.trim()){ElMessage.error('\u8bf7\u8f93\u5165\u8bbe\u5907\u540d\u79f0');return;}saving.value=true;try{const payload=new FormData();['deviceName','userPersonId','userUsageStatus','assetRelationStatus','removeImageAttachment1','removeImageAttachment2'].forEach(key=>{const value=form[key];if(value!==null&&value!==undefined&&value!=='')payload.append(key,value);});if(form.imageAttachment1)payload.append('imageAttachment1',form.imageAttachment1);if(form.imageAttachment2)payload.append('imageAttachment2',form.imageAttachment2);if(editingId.value===null)await createDeviceAsset(payload);else await updateDeviceAsset(editingId.value,payload);ElMessage.success(editingId.value===null?'\u65b0\u589e\u6210\u529f':'\u7f16\u8f91\u6210\u529f');dialogVisible.value=false;filters.page=1;await loadPage();}catch(error){ElMessage.error(error.message);}finally{saving.value=false;}} async function submitForm(){if(!form.deviceName.trim()){ElMessage.error('请输入设备名称');return;}saving.value=true;try{const payload=new FormData();['deviceName','userPersonId','userUsageStatus','assetRelationStatus','removeImageAttachment1','removeImageAttachment2'].forEach(key=>{const value=form[key];if(value!==null&&value!==undefined&&value!=='')payload.append(key,value);});imageSlots.forEach(slot=>{if(form[slot])payload.append(slot,form[slot]);});if(editingId.value===null)await createDeviceAsset(payload);else await updateDeviceAsset(editingId.value,payload);ElMessage.success(editingId.value===null?'新增成功':'编辑成功');dialogVisible.value=false;filters.page=1;await loadPage();}catch(error){ElMessage.error(error.message);}finally{saving.value=false;}}
/** Plain purpose: ask for confirmation then request a reference-protected soft delete. Related files: device-api-client.js, DeviceAssetService.java. Flow: delete click -> confirm -> DELETE -> refresh or show error. */ /** Plain purpose: ask for confirmation then request a reference-protected soft delete. Related files: device-api-client.js, DeviceAssetService.java. Flow: delete click -> confirm -> DELETE -> refresh or show error. */
async function confirmDelete(row){try{await ElMessageBox.confirm(`\u786e\u8ba4\u5220\u9664\u8bbe\u5907\u201c${row.deviceName}\u201d\u5417\uff1f`,'\u5220\u9664\u786e\u8ba4',{type:'warning'});await deleteDeviceAsset(row.id);ElMessage.success('\u5220\u9664\u6210\u529f');if(records.value.length===1&&filters.page>1)filters.page-=1;await loadPage();}catch(error){if(error!=='cancel'&&error!=='close')ElMessage.error(error.message);}} async function confirmDelete(row){try{await ElMessageBox.confirm(`确认删除设备“${row.deviceName}”吗?`,'删除确认',{type:'warning'});await deleteDeviceAsset(row.id);ElMessage.success('删除成功');if(records.value.length===1&&filters.page>1)filters.page-=1;await loadPage();}catch(error){if(error!=='cancel'&&error!=='close')ElMessage.error(error.message);}}
/**
* Plain purpose: fill the name box with the next sequential "<prefix>N号机". Related files: device-api-client.js, DeviceAssetService.java.
* Flow: 一键编号 -> 当前输入推断前缀 -> GET next-device-name -> form.deviceName。
* 编号由后端在全库范围内算,不在前端按当前页推:当前页只有 20 条,按它推算会撞上别的页里已存在的名字。
*/
async function fillNextDeviceName(){
if(suggestingName.value)return;
suggestingName.value=true;
try{const suggestion=await suggestNextDeviceName(devicePrefixOf(form.deviceName));form.deviceName=suggestion.deviceName;}
catch(error){ElMessage.error(error.message);}
finally{suggestingName.value=false;}
}
/** Plain purpose: read the naming prefix out of whatever is already typed, so the button also serves 班主任N号机 and the like. Related files: DeviceAssetService.java. Flow: 输入框文字 -> 去掉尾部 N号机 -> 前缀;留空则由后端用默认前缀。 */
function devicePrefixOf(value){return String(value||'').trim().replace(/\d*号机?$/,'').trim();}
/** Plain purpose: load active company people for the remote selector. Related files: device-api-client.js, DeviceAssetController.java. Flow: selector input -> lookup API -> dropdown options. */ /** Plain purpose: load active company people for the remote selector. Related files: device-api-client.js, DeviceAssetController.java. Flow: selector input -> lookup API -> dropdown options. */
async function fetchPersonSuggestions(keyword){try{personOptions.value=await searchDeviceCompanyPersons(keyword);}catch(error){ElMessage.error(error.message);}} async function fetchPersonSuggestions(keyword){try{personOptions.value=await searchDeviceCompanyPersons(keyword);}catch(error){ElMessage.error(error.message);}}
/** Plain purpose: reject unsupported or over-20MB files before uploading. Related files: DeviceAssetFileStorageService.java, DeviceAssetView.js. Flow: file choose -> local validation -> FormData or message. */ /** Plain purpose: reject unsupported or over-20MB files before uploading. Related files: DeviceAssetFileStorageService.java, DeviceAssetView.js. Flow: file choose -> local validation -> FormData or message. */
function validateImageBeforeSelect(file){const raw=file.raw||file;if(!['image/jpeg','image/png','image/gif'].includes(raw.type)){ElMessage.error('\u4ec5\u652f\u6301 JPG\u3001PNG\u3001GIF \u56fe\u7247');return false;}if(raw.size>20*1024*1024){ElMessage.error('\u6bcf\u5f20\u56fe\u7247\u4e0d\u80fd\u8d85\u8fc7 20MB');return false;}return true;} function validateImageBeforeSelect(file){const raw=file.raw||file;if(!['image/jpeg','image/png','image/gif'].includes(raw.type)){ElMessage.error('仅支持 JPG、PNG、GIF 图片');return false;}if(raw.size>20*1024*1024){ElMessage.error('每张图片不能超过 20MB');return false;}return true;}
/** Plain purpose: keep the selected original file and use a browser object URL for a scaled thumbnail preview. Related files: DeviceAssetFileStorageService.java, DeviceAssetView.js. Flow: upload component -> file/object URL -> preview and save. */ /**
function chooseImage(slot,file){if(!validateImageBeforeSelect(file))return false;clearPreview(slot);form[slot]=file.raw||file;form[`${slot}Url`]=URL.createObjectURL(form[slot]);form[`remove${slot.charAt(0).toUpperCase()+slot.slice(1)}`]=false;return false;} * Plain purpose: keep the selected original file but preview a downscaled copy. Related files: DeviceAssetFileStorageService.java, device-asset.css.
* Flow: upload component -> original File kept for upload -> small data URL -> preview box.
* 直接把手机拍的 1672x941 原图塞进 104x78 的预览框,浏览器要同步解码整张图,选完文件后会明显卡一下;
* createImageBitmap 是异步解码,缩到 320 后再显示,主线程不被阻塞。上传的仍然是未经处理的原始文件。
*/
async function chooseImage(slot,file){
if(!validateImageBeforeSelect(file))return false;
const raw=file.raw||file;
clearPreview(slot);
form[slot]=raw;form[`remove${slot.charAt(0).toUpperCase()+slot.slice(1)}`]=false;
const original=URL.createObjectURL(raw);
form[`${slot}FullUrl`]=original;form[`${slot}Url`]=original;
const preview=await buildDownscaledPreview(raw);
// 期间用户可能已经移除或换了图,只有当前槽位仍指向这张原图时才替换成小图。
if(preview&&form[`${slot}FullUrl`]===original)form[`${slot}Url`]=preview;
return false;
}
/** Plain purpose: decode off the main thread and return a small JPEG data URL, or null when the browser lacks the API. Related files: DeviceAssetView.js. Flow: File -> ImageBitmap -> canvas -> data URL. */
async function buildDownscaledPreview(file){
if(typeof createImageBitmap!=='function')return null;
try{
const bitmap=await createImageBitmap(file);
const ratio=Math.min(1,320/Math.max(bitmap.width,bitmap.height));
const canvas=document.createElement('canvas');
canvas.width=Math.max(1,Math.round(bitmap.width*ratio));canvas.height=Math.max(1,Math.round(bitmap.height*ratio));
canvas.getContext('2d').drawImage(bitmap,0,0,canvas.width,canvas.height);
bitmap.close();
return canvas.toDataURL('image/jpeg',0.8);
}catch(error){return null;}
}
/** Plain purpose: release temporary object URLs so repeated edits do not retain browser memory. Related files: DeviceAssetView.js. Flow: replace/reset/remove -> clearPreview -> revokeObjectURL. */ /** Plain purpose: release temporary object URLs so repeated edits do not retain browser memory. Related files: DeviceAssetView.js. Flow: replace/reset/remove -> clearPreview -> revokeObjectURL. */
function clearPreview(slot){const url=form[`${slot}Url`];if(url&&url.startsWith('blob:'))URL.revokeObjectURL(url);} function clearPreview(slot){[`${slot}Url`,`${slot}FullUrl`].forEach(key=>{const url=form[key];if(url&&url.startsWith('blob:'))URL.revokeObjectURL(url);});}
/** Plain purpose: mark an existing image reference for removal while preserving the historical original file on the server. Related files: DeviceAssetSaveRequest.java, DeviceAssetService.java. Flow: remove click -> multipart flag -> DB reference cleared. */ /** Plain purpose: mark an existing image reference for removal while preserving the historical original file on the server. Related files: DeviceAssetSaveRequest.java, DeviceAssetService.java. Flow: remove click -> multipart flag -> DB reference cleared. */
function removeImage(slot){clearPreview(slot);form[slot]=null;form[`${slot}Url`]='';form[`remove${slot.charAt(0).toUpperCase()+slot.slice(1)}`]=true;} function removeImage(slot){clearPreview(slot);form[slot]=null;form[`${slot}Url`]='';form[`${slot}FullUrl`]='';form[`remove${slot.charAt(0).toUpperCase()+slot.slice(1)}`]=true;}
/** Plain purpose: open the original image in the viewer instead of generating a second thumbnail file. Related files: DeviceAssetResponse.java, DeviceAssetFileStorageService.java. Flow: thumbnail click -> controlled URL -> viewer. */ /** Plain purpose: pair each attachment's small thumbnail with its full-size original so the cell stays light. Related files: DeviceAssetResponse.java, device-asset.css. Flow: table row -> thumb/full pairs -> thumbnails or placeholder. */
function previewImage(url){if(url){imageViewerUrl.value=url;imageViewerVisible.value=true;}} function rowImages(row){return imageSlots.map(slot=>({thumb:row[`${slot}ThumbUrl`]||row[`${slot}Url`],full:row[`${slot}Url`]})).filter(item=>item.full&&!brokenImages[item.thumb]);}
/** Plain purpose: drop an unreachable image so the cell falls back to the placeholder instead of a broken icon. Related files: DeviceAssetFileStorageService.java, device-asset.css. Flow: img error event -> broken map -> placeholder render. */
function markImageBroken(url){if(url)brokenImages[url]=true;}
/**
* Plain purpose: start fetching and decoding the full-size image while the pointer is still hovering.
* Related files: DeviceAssetController.java(Cache-Control immutable)、device-asset.css。
* Flow: 悬停缩略图 -> 后台下载并解码原图 -> 点击时浏览器缓存直接命中。
* 列表只加载几 KB 的缩略图,原图要到点开大图那一刻才下载,1.8MB 的传输加解码全压在这一下点击上。
* 把这段工作提前到悬停期间做完,点击就几乎无等待;配合图片接口的一年 immutable 缓存,之后再点必定瞬间。
*/
function prefetchFullImage(url) {
if (!url || prefetchedImages.has(url)) return;
prefetchedImages.add(url);
const image = new Image();
image.decoding = 'async';
image.src = url;
// decode() 把解码也一起提前;不支持或加载失败都无所谓,点击时按正常流程重新走一遍即可。
image.decode?.().catch(() => {});
}
/** Plain purpose: open the originals in the viewer starting at the clicked thumbnail. Related files: DeviceAssetResponse.java, DeviceAssetFileStorageService.java. Flow: thumbnail click -> URL list/index -> viewer. */
function previewImages(urls,index){const list=(urls||[]).filter(Boolean);if(!list.length)return;imageViewerUrls.value=list;imageViewerIndex.value=Math.min(Math.max(index||0,0),list.length-1);imageViewerVisible.value=true;}
/** Plain purpose: show the update column as a date only, because the exact clock time is noise in this list. Related files: DeviceAssetResponse.java, DeviceAssetView.js. Flow: API timestamp -> first 10 characters -> table cell. */
function formatDate(value){const text=String(value||'');return text?text.slice(0,10):'-';}
/** Plain purpose: delay text filtering to avoid a request for every keystroke. Related files: DeviceAssetView.js, device-api-client.js. Flow: text input -> timer -> page reload. */ /** Plain purpose: delay text filtering to avoid a request for every keystroke. Related files: DeviceAssetView.js, device-api-client.js. Flow: text input -> timer -> page reload. */
function scheduleSearch(){window.clearTimeout(searchTimer);searchTimer=window.setTimeout(()=>{filters.page=1;loadPage();},300);} function scheduleSearch(){window.clearTimeout(searchTimer);searchTimer=window.setTimeout(()=>{filters.page=1;loadPage();},300);}
/** Plain purpose: apply the selected filters immediately from the first page. Related files: DeviceAssetPageQuery.java, DeviceAssetView.js. Flow: filter change -> page=1 -> GET -> table. */ /** Plain purpose: apply the selected filters immediately from the first page. Related files: DeviceAssetPageQuery.java, DeviceAssetView.js. Flow: filter change -> page=1 -> GET -> table. */
...@@ -52,7 +128,38 @@ export default { ...@@ -52,7 +128,38 @@ export default {
function changePageSize(size){filters.size=size;filters.page=1;loadPage();} function changePageSize(size){filters.size=size;filters.page=1;loadPage();}
/** Plain purpose: load the initial page when the routed component appears. Related files: DeviceAssetView.js, device-api-client.js. Flow: mount -> loadPage -> table. */ /** Plain purpose: load the initial page when the routed component appears. Related files: DeviceAssetView.js, device-api-client.js. Flow: mount -> loadPage -> table. */
onMounted(loadPage); onMounted(loadPage);
return {usageStatuses,relationStatuses,loading,saving,dialogVisible,editingId,records,total,filters,form,personOptions,imageViewerVisible,imageViewerUrl,loadPage,openCreate,openEdit,submitForm,confirmDelete,fetchPersonSuggestions,chooseImage,removeImage,previewImage,scheduleSearch,submitSearch,resetSearch,changePage,changePageSize}; return {usageStatuses,relationStatuses,imageSlots,loading,saving,dialogVisible,editingId,records,total,filters,form,personOptions,imageViewerVisible,imageViewerUrls,imageViewerIndex,suggestingName,loadPage,openCreate,openEdit,submitForm,confirmDelete,fetchPersonSuggestions,fillNextDeviceName,chooseImage,removeImage,rowImages,markImageBroken,previewImages,prefetchFullImage,formatDate,scheduleSearch,submitSearch,resetSearch,changePage,changePageSize};
}, },
template:`<section class="device-asset-page"><header class="device-asset-page__header"><div><h2>&#x8bbe;&#x5907;&#x8d44;&#x4ea7;&#x7ba1;&#x7406;</h2></div><el-button type="primary" @click="openCreate">&#x65b0;&#x589e;&#x8bbe;&#x5907;</el-button></header><section class="device-asset-page__panel"><el-form class="device-asset-page__filters" @submit.prevent="submitSearch"><el-input v-model="filters.deviceName" placeholder="&#x8bbe;&#x5907;&#x540d;&#x79f0;" @input="scheduleSearch"/><el-select v-model="filters.userPersonId" filterable remote clearable placeholder="&#x4f7f;&#x7528;&#x4eba;" :remote-method="fetchPersonSuggestions" @change="submitSearch"><el-option v-for="item in personOptions" :key="item.id" :label="item.personName" :value="item.id"/></el-select><el-select v-model="filters.userUsageStatus" clearable placeholder="&#x4f7f;&#x7528;&#x72b6;&#x6001;" @change="submitSearch"><el-option v-for="item in usageStatuses" :key="item" :label="item" :value="item"/></el-select><el-select v-model="filters.assetRelationStatus" clearable placeholder="&#x8d44;&#x4ea7;&#x5173;&#x8054;&#x72b6;&#x6001;" @change="submitSearch"><el-option v-for="item in relationStatuses" :key="item" :label="item" :value="item"/></el-select><el-button @click="resetSearch">&#x91cd;&#x7f6e;</el-button></el-form></section><section class="device-asset-page__panel device-asset-page__table"><div class="device-asset-page__grid-wrap"><el-table class="device-asset-page__grid" :data="records" v-loading="loading" empty-text="&#x6682;&#x65e0;&#x5339;&#x914d;&#x6570;&#x636e;"><el-table-column prop="id" label="编号" width="90"/><el-table-column prop="deviceName" label="&#x8bbe;&#x5907;&#x540d;&#x79f0;" min-width="180"/><el-table-column label="&#x56fe;&#x7247;" width="90"><template #default="{row}"><span v-if="!row.imageAttachment1Url">-</span><span v-else class="device-asset-page__image-cell"><img :src="row.imageAttachment1Url" @click="previewImage(row.imageAttachment1Url)"/></span></template></el-table-column><el-table-column label="&#x4f7f;&#x7528;&#x4eba;" min-width="160"><template #default="{row}">{{row.userPersonName?row.userPersonName+'('+row.userPersonId+')':(row.userPersonId?'--('+row.userPersonId+')':'-')}}</template></el-table-column><el-table-column prop="userUsageStatus" label="&#x4f7f;&#x7528;&#x72b6;&#x6001;" min-width="120"/><el-table-column prop="assetRelationStatus" label="&#x8d44;&#x4ea7;&#x5173;&#x8054;&#x72b6;&#x6001;" min-width="130"/><el-table-column prop="updateTime" label="&#x66f4;&#x65b0;&#x65f6;&#x95f4;" min-width="180"/><el-table-column label="&#x64cd;&#x4f5c;" width="150"><template #default="{row}"><el-button link @click="openEdit(row)">&#x7f16;&#x8f91;</el-button><el-button link type="danger" @click="confirmDelete(row)">&#x5220;&#x9664;</el-button></template></el-table-column></el-table></div><app-pagination :total="total" :page="filters.page" :size="filters.size" @update:page="changePage" @update:size="changePageSize"/></section><el-dialog v-model="dialogVisible" :title="editingId===null?'\u65b0\u589e\u8bbe\u5907':'\u7f16\u8f91\u8bbe\u5907'" width="640px"><el-form label-position="top" @submit.prevent="submitForm"><el-row :gutter="16"><el-col :span="12"><el-form-item label="&#x8bbe;&#x5907;&#x540d;&#x79f0;" required><el-input v-model="form.deviceName"/></el-form-item></el-col><el-col :span="12"><el-form-item label="&#x4f7f;&#x7528;&#x4eba;"><el-select v-model="form.userPersonId" filterable remote clearable :remote-method="fetchPersonSuggestions" placeholder="&#x8f93;&#x5165;&#x4eba;&#x5458;&#x59d3;&#x540d;" style="width:100%"><el-option v-for="item in personOptions" :key="item.id" :label="item.personName" :value="item.id"/></el-select></el-form-item></el-col><el-col :span="12"><el-form-item label="&#x4f7f;&#x7528;&#x72b6;&#x6001;" required><el-select v-model="form.userUsageStatus" style="width:100%"><el-option v-for="item in usageStatuses" :key="item" :label="item" :value="item"/></el-select></el-form-item></el-col><el-col :span="12"><el-form-item label="&#x8d44;&#x4ea7;&#x5173;&#x8054;&#x72b6;&#x6001;" required><el-select v-model="form.assetRelationStatus" style="width:100%"><el-option v-for="item in relationStatuses" :key="item" :label="item" :value="item"/></el-select></el-form-item></el-col></el-row><div class="device-asset-page__images"><div v-for="slot in ['imageAttachment1','imageAttachment2']" :key="slot" class="device-asset-page__image-slot"><strong>{{slot==='imageAttachment1'?'\u56fe\u7247\u9644\u4ef6 1':'\u56fe\u7247\u9644\u4ef6 2'}}</strong><div class="device-asset-page__preview"><img v-if="form[slot+'Url']" :src="form[slot+'Url']" @click="previewImage(form[slot+'Url'])"/><span v-else>&#x6682;&#x65e0;&#x56fe;&#x7247;</span></div><el-upload :auto-upload="false" :show-file-list="false" :on-change="file=>chooseImage(slot,file)"><el-button size="small">&#x9009;&#x62e9;&#x56fe;&#x7247;</el-button></el-upload><el-button v-if="form[slot+'Url']" size="small" link type="danger" @click="removeImage(slot)">&#x79fb;&#x9664;</el-button></div></div></el-form><template #footer><el-button @click="dialogVisible=false">&#x53d6;&#x6d88;</el-button><el-button type="primary" :loading="saving" @click="submitForm">&#x4fdd;&#x5b58;</el-button></template></el-dialog><el-image-viewer v-if="imageViewerVisible" :url-list="[imageViewerUrl]" @close="imageViewerVisible=false"/></section>` template:`<section class="device-asset-page">
<header class="device-asset-page__header phone-asset-list-page__header"><h2>设备资产管理</h2><el-button class="phone-asset-list-page__add" type="primary" @click="openCreate">新增设备资产</el-button></header>
<section class="device-asset-page__panel"><el-form class="device-asset-page__filters" @submit.prevent="submitSearch"><el-input v-model="filters.deviceName" placeholder="设备名称" @input="scheduleSearch"/><el-select v-model="filters.userPersonId" filterable remote clearable placeholder="使用人" :remote-method="fetchPersonSuggestions" @change="submitSearch"><el-option v-for="item in personOptions" :key="item.id" :label="item.personName" :value="item.id"/></el-select><el-select v-model="filters.userUsageStatus" clearable placeholder="使用状态" @change="submitSearch"><el-option v-for="item in usageStatuses" :key="item" :label="item" :value="item"/></el-select><el-select v-model="filters.assetRelationStatus" clearable placeholder="资产关联状态" @change="submitSearch"><el-option v-for="item in relationStatuses" :key="item" :label="item" :value="item"/></el-select><el-button @click="resetSearch">重置</el-button></el-form></section>
<section class="device-asset-page__panel device-asset-page__table"><div class="device-asset-page__grid-wrap"><el-table class="device-asset-page__grid" :data="records" v-loading="loading" empty-text="暂无匹配数据">
<el-table-column label="图片" width="122"><template #default="{row}"><div class="device-asset-page__thumbs">
<button v-for="(item,index) in rowImages(row)" :key="item.full" type="button" class="device-asset-page__thumb" title="查看大图" aria-label="查看大图" @mouseenter="prefetchFullImage(item.full)" @focus="prefetchFullImage(item.full)" @click="previewImages(rowImages(row).map(entry=>entry.full),index)"><img :src="item.thumb" alt="设备图片" loading="lazy" decoding="async" @error="markImageBroken(item.thumb)"/><span class="device-asset-page__thumb-mask"><svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M2 12s3.6-6.5 10-6.5S22 12 22 12s-3.6 6.5-10 6.5S2 12 2 12z"/><circle cx="12" cy="12" r="2.6"/></svg></span></button>
<span v-if="!rowImages(row).length" class="device-asset-page__thumb device-asset-page__thumb--empty" title="暂无图片"><svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><rect x="3" y="5" width="18" height="14" rx="2"/><circle cx="8.5" cy="10" r="1.5"/><path d="M21 16l-5-5-6 6"/></svg></span>
</div></template></el-table-column>
<el-table-column prop="deviceName" label="设备名称" min-width="180" show-overflow-tooltip/>
<el-table-column label="使用人" min-width="160"><template #default="{row}">{{row.userPersonName?row.userPersonName+'('+row.userPersonId+')':(row.userPersonId?'--('+row.userPersonId+')':'-')}}</template></el-table-column>
<el-table-column prop="userUsageStatus" label="使用状态" min-width="120"/>
<el-table-column prop="assetRelationStatus" label="资产关联状态" min-width="130"/>
<el-table-column label="更新时间" min-width="130"><template #default="{row}">{{formatDate(row.updateTime)}}</template></el-table-column>
<el-table-column label="操作" width="150"><template #default="{row}"><el-button link @click="openEdit(row)">编辑</el-button><el-button link type="danger" @click="confirmDelete(row)">删除</el-button></template></el-table-column>
</el-table></div><app-pagination :total="total" :page="filters.page" :size="filters.size" @update:page="changePage" @update:size="changePageSize"/></section>
<el-dialog v-model="dialogVisible" class="phone-asset-modal device-asset-modal" modal-class="phone-asset-modal-mask" :title="editingId===null?'新增设备资产':'编辑设备资产'" width="560px">
<el-form class="phone-asset-modal__form" label-width="96px" @submit.prevent="submitForm">
<el-form-item class="phone-asset-modal__form-row device-asset-modal__images-row" label="图片"><div class="device-asset-modal__images">
<div v-for="slot in imageSlots" :key="slot" class="device-asset-modal__preview" :class="{'device-asset-modal__preview--filled':form[slot+'Url']}">
<template v-if="form[slot+'Url']"><button type="button" class="device-asset-modal__preview-open" title="预览大图" aria-label="预览大图" @mouseenter="prefetchFullImage(form[slot+'FullUrl'])" @focus="prefetchFullImage(form[slot+'FullUrl'])" @click="previewImages([form[slot+'FullUrl']||form[slot+'Url']],0)"><img :src="form[slot+'Url']" alt="设备图片" decoding="async"/><span class="device-asset-modal__preview-mask"><svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M2 12s3.6-6.5 10-6.5S22 12 22 12s-3.6 6.5-10 6.5S2 12 2 12z"/><circle cx="12" cy="12" r="2.6"/></svg></span></button><button type="button" class="device-asset-modal__preview-remove" title="移除图片" aria-label="移除图片" @click="removeImage(slot)"><svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M6 6l12 12M18 6L6 18"/></svg></button></template>
<el-upload v-else class="device-asset-modal__upload" accept="image/jpeg,image/png,image/gif" :auto-upload="false" :show-file-list="false" :on-change="file=>chooseImage(slot,file)"><span class="device-asset-modal__placeholder"><svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M12 5v14M5 12h14"/></svg>上传图片</span></el-upload>
</div>
</div></el-form-item>
<el-form-item class="phone-asset-modal__form-row" label="设备名称" required><el-input v-model="form.deviceName" maxlength="60" placeholder="请输入设备名称"><template #suffix><button type="button" class="device-asset-modal__auto-name" :disabled="suggestingName" title="按顺序生成下一个编号" aria-label="按顺序生成下一个编号" @click="fillNextDeviceName"><svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M14 3l1.7 3.8L19.5 8.5l-3.8 1.7L14 14l-1.7-3.8L8.5 8.5l3.8-1.7L14 3z"/><path d="M6.5 13.5l1 2.2 2.2 1-2.2 1-1 2.2-1-2.2-2.2-1 2.2-1 1-2.2z"/></svg></button></template></el-input></el-form-item>
<el-form-item class="phone-asset-modal__form-row" label="使用人"><el-select v-model="form.userPersonId" filterable remote clearable :remote-method="fetchPersonSuggestions" placeholder="输入人员姓名搜索" popper-class="phone-asset-modal__select-popper"><el-option v-for="item in personOptions" :key="item.id" :label="item.personName" :value="item.id"/></el-select></el-form-item>
<el-form-item class="phone-asset-modal__form-row" label="使用状态"><el-select v-model="form.userUsageStatus" placeholder="请选择" popper-class="phone-asset-modal__select-popper"><el-option v-for="item in usageStatuses" :key="item" :label="item" :value="item"/></el-select></el-form-item>
<el-form-item class="phone-asset-modal__form-row" label="资产关联状态"><el-select v-model="form.assetRelationStatus" placeholder="请选择" popper-class="phone-asset-modal__select-popper"><el-option v-for="item in relationStatuses" :key="item" :label="item" :value="item"/></el-select></el-form-item>
</el-form>
<template #footer><el-button @click="dialogVisible=false">取消</el-button><el-button type="primary" :loading="saving" @click="submitForm">确认保存</el-button></template>
</el-dialog>
<el-image-viewer v-if="imageViewerVisible" :url-list="imageViewerUrls" :initial-index="imageViewerIndex" @close="imageViewerVisible=false"/>
</section>`
}; };
...@@ -11,3 +11,5 @@ export function updateDeviceAsset(id, form) { return request('/api/device-assets ...@@ -11,3 +11,5 @@ export function updateDeviceAsset(id, form) { return request('/api/device-assets
export function deleteDeviceAsset(id) { return request('/api/device-assets/' + id, { method: 'DELETE' }); } export function deleteDeviceAsset(id) { return request('/api/device-assets/' + id, { method: 'DELETE' }); }
/** 代码作用(白话):搜索可作为设备使用人的公司人员。关联文件:DeviceAssetView.js、DeviceAssetController.java。关联逻辑(调用链/数据流):远程选择器 -> GET lookup -> 人员选项。 */ /** 代码作用(白话):搜索可作为设备使用人的公司人员。关联文件:DeviceAssetView.js、DeviceAssetController.java。关联逻辑(调用链/数据流):远程选择器 -> GET lookup -> 人员选项。 */
export function searchDeviceCompanyPersons(keyword) { return request('/api/device-assets/lookups/company-persons?keyword=' + encodeURIComponent(keyword || '')); } export function searchDeviceCompanyPersons(keyword) { return request('/api/device-assets/lookups/company-persons?keyword=' + encodeURIComponent(keyword || '')); }
/** 代码作用(白话):问后端"这个前缀下一个空编号是几号机"。关联文件:DeviceAssetView.js、DeviceAssetController.java。关联逻辑(调用链/数据流):一键编号按钮 -> GET next-device-name -> 全库最大编号+1 -> 设备名称输入框。 */
export function suggestNextDeviceName(prefix) { return request('/api/device-assets/lookups/next-device-name?prefix=' + encodeURIComponent(prefix || '')); }
/* 文件用途(白话):仅为设备资产管理页面提供样式,避免修改正在使用的全局 app.css。 */ /* 文件用途(白话):仅为设备资产管理页面提供样式,避免修改正在使用的全局 app.css。 */
.device-asset-page{max-width:1680px;margin:0 auto;padding:30px 4px}.device-asset-page__header{display:flex;justify-content:space-between;align-items:center;margin-bottom:20px}.device-asset-page__header h2{margin:0;font-size:28px}.device-asset-page__panel{padding:20px;margin-bottom:16px;background:#fff;border:1px solid #e5e7eb;border-radius:12px}.device-asset-page__filters{display:flex;flex-wrap:wrap;gap:12px}.device-asset-page__filters .el-input,.device-asset-page__filters .el-select{width:210px}.device-asset-page__images{display:flex;gap:14px;flex-wrap:wrap}.device-asset-page__image-slot{width:150px}.device-asset-page__preview{display:flex;width:132px;height:96px;margin:8px 0;align-items:center;justify-content:center;overflow:hidden;border:1px dashed #cbd5e1;border-radius:8px;background:#f8fafc}.device-asset-page__preview img{width:100%;height:100%;object-fit:cover}.device-asset-page__image-cell img{width:44px;height:44px;object-fit:cover;border-radius:6px;cursor:pointer}/* 固定高度骨架:宽屏下页面锁死一屏,表格内部滚动;断点与 app.css 中的同类规则保持一致。 */ /* 页头、新增按钮和弹窗刻意复用 app.css 里的 .phone-asset-list-page__/.phone-asset-modal__ 类,
与手机号码管理页保持同一套外观;这里只补设备页独有的图片缩略图、占位框和上传位。 */
.device-asset-page{max-width:1680px;margin:0 auto;padding:30px 4px}.device-asset-page__panel{padding:20px;margin-bottom:16px;background:#fff;border:1px solid #e5e7eb;border-radius:12px}.device-asset-page__filters{display:flex;flex-wrap:wrap;gap:12px}.device-asset-page__filters .el-input,.device-asset-page__filters .el-select{width:210px}
/* 列表图片列:一行最多两张,鼠标移上去压一层暗色遮罩并露出眼睛图标提示可放大。
图标用内联 SVG,和手机号页的 ICCID 图标一致,避免为几个图标引入 @element-plus/icons-vue(不在依赖里)。 */
.device-asset-page__thumbs{display:flex;align-items:center;gap:6px}
.device-asset-page__thumb{position:relative;display:block;flex:0 0 auto;width:40px;height:40px;padding:0;overflow:hidden;border:1px solid #e5e5e8;border-radius:6px;background:#fff;cursor:pointer}
.device-asset-page__thumb img{display:block;width:100%;height:100%;object-fit:cover}
.device-asset-page__thumb-mask{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;background:rgba(24,24,27,.55);color:#fff;opacity:0;transition:opacity .16s ease}
.device-asset-page__thumb:hover .device-asset-page__thumb-mask{opacity:1}
.device-asset-page__thumb-mask svg{width:16px;height:16px;fill:none;stroke:currentColor;stroke-width:1.5;stroke-linecap:round;stroke-linejoin:round}
/* 无图占位:虚线灰框,保证有图和无图两种行的高度完全一致,列表不会忽高忽低。 */
.device-asset-page__thumb--empty{display:flex;align-items:center;justify-content:center;border-style:dashed;border-color:#dcdce0;background:#fafafa;color:#c4c4cc;cursor:default}
.device-asset-page__thumb--empty svg{width:18px;height:18px;fill:none;stroke:currentColor;stroke-width:1.4;stroke-linecap:round;stroke-linejoin:round}
/* 设备名称右侧的"一键编号"按钮:贴在输入框 suffix 位,样式与手机号页 ICCID 图标按钮保持一致。 */
.device-asset-modal__auto-name{display:inline-flex;align-items:center;justify-content:center;width:24px;height:24px;padding:0;border:0;border-radius:5px;background:transparent;color:#a1a1aa;cursor:pointer;transition:color .16s ease,background .16s ease}
.device-asset-modal__auto-name:hover:not(:disabled){background:#f1f1f3;color:#18181b}
.device-asset-modal__auto-name:disabled{opacity:.5;cursor:not-allowed}
.device-asset-modal__auto-name svg{width:15px;height:15px;fill:none;stroke:currentColor;stroke-width:1.35;stroke-linejoin:round}
/* 弹窗图片行:标签仍右对齐在 96px 栏位里,但内容要顶部对齐,否则两个上传框会被 36px 行高压偏。 */
.device-asset-modal__images-row.el-form-item{align-items:start}
.device-asset-modal__images-row .el-form-item__label{line-height:78px}
.device-asset-modal__images-row .el-form-item__content{line-height:normal}
.device-asset-modal__images{display:flex;gap:12px}
.device-asset-modal__preview{position:relative;display:flex;width:104px;height:78px;align-items:center;justify-content:center;overflow:hidden;border:1px dashed #d4d4d8;border-radius:8px;background:#fafafa}
.device-asset-modal__preview--filled{border-style:solid;border-color:#e4e4e7;background:#fff}
.device-asset-modal__preview img{display:block;width:100%;height:100%;object-fit:cover}
/* 整块图都是预览触发区:之前只有遮罩里那个小眼睛能点,点图片中间毫无反应,很容易被当成"坏了"。 */
.device-asset-modal__preview-open{position:absolute;inset:0;display:block;width:100%;height:100%;padding:0;border:0;background:none;cursor:zoom-in}
.device-asset-modal__preview-mask{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;color:#fff;background:rgba(24,24,27,.55);opacity:0;transition:opacity .16s ease}
.device-asset-modal__preview:hover .device-asset-modal__preview-mask{opacity:1}
.device-asset-modal__preview-mask svg{width:18px;height:18px;fill:none;stroke:currentColor;stroke-width:1.5;stroke-linecap:round;stroke-linejoin:round}
/* 移除角标常驻显示,不藏在 hover 里:删除是低频但要命的操作,找不到入口比多一个角标更糟。 */
.device-asset-modal__preview-remove{position:absolute;top:4px;right:4px;z-index:1;display:inline-flex;align-items:center;justify-content:center;width:20px;height:20px;padding:0;border:0;border-radius:50%;background:rgba(24,24,27,.62);color:#fff;cursor:pointer;transition:background .16s ease}
.device-asset-modal__preview-remove:hover{background:#e5484d}
.device-asset-modal__preview-remove svg{width:11px;height:11px;fill:none;stroke:currentColor;stroke-width:2.2;stroke-linecap:round}
/* 空位整块都是上传触发区:省掉一个独立的“选择图片”按钮,弹窗第一行才放得下两个图位。 */
.device-asset-modal__upload,.device-asset-modal__upload .el-upload{display:block;width:100%;height:100%}
.device-asset-modal__placeholder{display:flex;width:100%;height:100%;flex-direction:column;align-items:center;justify-content:center;gap:5px;color:#a1a1aa;font-size:12px;line-height:1}
.device-asset-modal__placeholder svg{width:18px;height:18px;fill:none;stroke:currentColor;stroke-width:1.4;stroke-linecap:round}
.device-asset-modal__preview:hover .device-asset-modal__placeholder{color:#71717a}
/* 固定高度骨架:宽屏下页面锁死一屏,表格内部滚动;断点与 app.css 中的同类规则保持一致。 */
@media(min-width:641px){/* 用 min-height 而不是 height:正常一屏不出外层滚动条,窗口过矮时页面被撑高改由内容区整页滚动。 */.device-asset-page{display:flex;flex-direction:column;min-height:100%;padding-bottom:24px}.device-asset-page__header,.device-asset-page__panel,.device-asset-page .app-pagination{flex:0 0 auto}.device-asset-page__table{display:flex;flex-direction:column;flex:1 1 auto;min-height:0;margin-bottom:0}/* 表格必须绝对定位:el-table 会用自身内容高度反向撑开父级,留在文档流里 flex 就收缩不下去。 *//* min-height 220px 是兜底:表头约 40px,再留三行左右可视区,低于这个高度就不再压缩表格。 */.device-asset-page__grid-wrap{position:relative;flex:1 1 auto;min-height:220px}/* height 必须显式写:Element Plus 自带 .el-table{height:fit-content},只给 inset 会被它按内容高度顶掉。 */.device-asset-page__grid-wrap > .el-table{position:absolute;inset:0;height:100%}.device-asset-page__grid-wrap > .el-table > .el-table__inner-wrapper{height:100%}.device-asset-page__grid-wrap .el-table__body-wrapper{flex:1 1 auto;min-height:0}.device-asset-page__grid-wrap .el-table__body-wrapper > .el-scrollbar{height:100%}} @media(min-width:641px){/* 用 min-height 而不是 height:正常一屏不出外层滚动条,窗口过矮时页面被撑高改由内容区整页滚动。 */.device-asset-page{display:flex;flex-direction:column;min-height:100%;padding-bottom:24px}.device-asset-page__header,.device-asset-page__panel,.device-asset-page .app-pagination{flex:0 0 auto}.device-asset-page__table{display:flex;flex-direction:column;flex:1 1 auto;min-height:0;margin-bottom:0}/* 表格必须绝对定位:el-table 会用自身内容高度反向撑开父级,留在文档流里 flex 就收缩不下去。 *//* min-height 220px 是兜底:表头约 40px,再留三行左右可视区,低于这个高度就不再压缩表格。 */.device-asset-page__grid-wrap{position:relative;flex:1 1 auto;min-height:220px}/* height 必须显式写:Element Plus 自带 .el-table{height:fit-content},只给 inset 会被它按内容高度顶掉。 */.device-asset-page__grid-wrap > .el-table{position:absolute;inset:0;height:100%}.device-asset-page__grid-wrap > .el-table > .el-table__inner-wrapper{height:100%}.device-asset-page__grid-wrap .el-table__body-wrapper{flex:1 1 auto;min-height:0}.device-asset-page__grid-wrap .el-table__body-wrapper > .el-scrollbar{height:100%}}
@media(max-width:700px){.device-asset-page{padding:20px 0}.device-asset-page__header{align-items:stretch;flex-direction:column;gap:12px}.device-asset-page__filters .el-input,.device-asset-page__filters .el-select{width:100%}} @media(max-width:700px){.device-asset-page{padding:20px 0}.device-asset-page__filters .el-input,.device-asset-page__filters .el-select{width:100%}}
@media(max-width:600px){.device-asset-modal__images-row .el-form-item__label{line-height:36px}}
import { expect, test } from './authenticated-test.js'; import { expect, test } from './authenticated-test.js';
/** Plain purpose: supply one decodable pixel so image tags resolve without touching the upload directory. Related files: DeviceAssetFileStorageService.java, device-asset.css. Flow: img src -> intercepted route -> real PNG bytes -> rendered thumbnail. */
const PIXEL_PNG = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', 'base64');
/** Plain purpose: build one list row so image, date, and placeholder cases share the same shape. Related files: DeviceAssetResponse.java, DeviceAssetView.js. Flow: test data -> mocked GET -> Vue table. */
function deviceRow(overrides = {}) {
return { id: 1, deviceName: 'iPhone 15-01', imageAttachment1Url: null, imageAttachment2Url: null, imageAttachment1ThumbUrl: null, imageAttachment2ThumbUrl: null, userPersonId: null, userPersonName: null, userUsageStatus: '使用中', assetRelationStatus: '已关联', createTime: '2026-08-01T10:00:00', updateTime: '2026-08-05T17:47:55', ...overrides };
}
/** Plain purpose: build the URL pair the API returns for one stored attachment. Related files: DeviceAssetService.java, DeviceAssetView.js. Flow: identifier -> original + variant=thumb URLs -> row fields. */
function attachment(name) {
return { url: `/api/device-assets/files/${name}`, thumbUrl: `/api/device-assets/files/${name}?variant=thumb` };
}
/** Plain purpose: serve a fixed device page plus real bytes for every image reference. Related files: device-api-client.js, DeviceAssetController.java. Flow: page load -> intercepted list/file routes -> table render. */
async function mockDeviceList(page, row) {
await page.route('**/api/device-assets**', route => route.request().url().includes('/files/')
? route.fulfill({ contentType: 'image/png', body: PIXEL_PNG })
: route.fulfill({ contentType: 'application/json', body: JSON.stringify({ code: 200, message: 'success', data: { records: [row], total: 1, page: 1, size: 20 } }) }));
}
/** File purpose: exercise the device route, readable rows, client image limit, and create/edit requests without a real database. */ /** File purpose: exercise the device route, readable rows, client image limit, and create/edit requests without a real database. */
test('shows the device management page and readable device row', async ({ page }) => { test('shows the device management page and readable device row', async ({ page }) => {
/** Plain purpose: provide stable device-list and person-lookup responses. Related files: DeviceAssetView.js, device-api-client.js. Flow: browser request -> route.fulfill -> Vue table/dropdown. */ /** Plain purpose: provide stable device-list and person-lookup responses. Related files: DeviceAssetView.js, device-api-client.js. Flow: browser request -> route.fulfill -> Vue table/dropdown. */
...@@ -81,3 +101,204 @@ test('keeps a referenced device visible after protected delete response', async ...@@ -81,3 +101,204 @@ test('keeps a referenced device visible after protected delete response', async
await expect(page.getByText('\u8bbe\u5907\u4ecd\u88ab\u624b\u673a\u53f7\u8d44\u4ea7\u5f15\u7528\uff0c\u4e0d\u80fd\u5220\u9664')).toBeVisible(); await expect(page.getByText('\u8bbe\u5907\u4ecd\u88ab\u624b\u673a\u53f7\u8d44\u4ea7\u5f15\u7528\uff0c\u4e0d\u80fd\u5220\u9664')).toBeVisible();
await expect(page.locator('.el-table').getByText('protected-device',{exact:true})).toBeVisible(); await expect(page.locator('.el-table').getByText('protected-device',{exact:true})).toBeVisible();
}); });
/** Plain purpose: guard the reported defect that only the first attachment reached the list, and lock the image column to first position. Related files: DeviceAssetView.js, DeviceAssetResponse.java. Flow: row with two URLs -> image cell -> two thumbnails. */
test('renders both device attachments in the leading image column', async ({ page }) => {
const [first, second] = [attachment('a.png'), attachment('b.png')];
await mockDeviceList(page, deviceRow({ imageAttachment1Url: first.url, imageAttachment2Url: second.url, imageAttachment1ThumbUrl: first.thumbUrl, imageAttachment2ThumbUrl: second.thumbUrl }));
await page.goto('/asset/#/device-assets');
const headers = page.locator('.el-table__header th .cell');
await expect(headers.first()).toHaveText('\u56fe\u7247');
await expect(headers.filter({ hasText: '\u7f16\u53f7' })).toHaveCount(0);
const firstCell = page.locator('.el-table__body tr').first().locator('td').first();
await expect(firstCell.locator('.device-asset-page__thumb img')).toHaveCount(2);
await expect(firstCell.locator('.device-asset-page__thumb--empty')).toHaveCount(0);
});
/** Plain purpose: keep the 40px cell on the derived thumbnail and defer off-screen loading, so a page never pulls tens of megabytes of originals. Related files: DeviceAssetService.java, DeviceAssetFileStorageService.java. Flow: row thumb URL -> lazy img -> original requested only by the viewer. */
test('loads list cells from the thumbnail variant and never the original', async ({ page }) => {
const requested = [];
const first = attachment('a.png');
await page.route('**/api/device-assets**', route => {
const url = route.request().url();
if (url.includes('/files/')) { requested.push(url); return route.fulfill({ contentType: 'image/png', body: PIXEL_PNG }); }
return route.fulfill({ contentType: 'application/json', body: JSON.stringify({ code: 200, message: 'success', data: { records: [deviceRow({ imageAttachment1Url: first.url, imageAttachment1ThumbUrl: first.thumbUrl })], total: 1, page: 1, size: 20 } }) });
});
await page.goto('/asset/#/device-assets');
const image = page.locator('.device-asset-page__thumb img').first();
await expect(image).toHaveAttribute('loading', 'lazy');
await expect(image).toHaveAttribute('decoding', 'async');
await expect(image).toHaveAttribute('src', first.thumbUrl);
await expect.poll(() => requested.length).toBeGreaterThan(0);
expect(requested.every(url => url.includes('variant=thumb'))).toBe(true);
});
/** Plain purpose: confirm a device without attachments keeps a placeholder box instead of a bare dash. Related files: DeviceAssetView.js, device-asset.css. Flow: row without URLs -> image cell -> dashed placeholder. */
test('shows an image placeholder when a device has no attachment', async ({ page }) => {
await mockDeviceList(page, deviceRow());
await page.goto('/asset/#/device-assets');
const firstCell = page.locator('.el-table__body tr').first().locator('td').first();
await expect(firstCell.locator('.device-asset-page__thumb--empty')).toHaveCount(1);
await expect(firstCell.locator('img')).toHaveCount(0);
});
/** Plain purpose: confirm an unreachable image falls back to the placeholder rather than a broken-image icon. Related files: DeviceAssetView.js, DeviceAssetFileStorageService.java. Flow: img error event -> broken map -> placeholder render. */
test('falls back to the placeholder when a device image cannot load', async ({ page }) => {
const missing = attachment('missing.png');
await page.route('**/api/device-assets**', route => route.request().url().includes('/files/')
? route.fulfill({ status: 404, contentType: 'application/json', body: '{}' })
: route.fulfill({ contentType: 'application/json', body: JSON.stringify({ code: 200, message: 'success', data: { records: [deviceRow({ imageAttachment1Url: missing.url, imageAttachment1ThumbUrl: missing.thumbUrl })], total: 1, page: 1, size: 20 } }) }));
await page.goto('/asset/#/device-assets');
const firstCell = page.locator('.el-table__body tr').first().locator('td').first();
await expect(firstCell.locator('.device-asset-page__thumb--empty')).toHaveCount(1);
});
/**
* Plain purpose: hovering must already pull the original, so the click itself has nothing left to wait for.
* Related files: DeviceAssetView.js, DeviceAssetController.java.
* Flow: mouseenter -> 原图请求发出 -> 点击时命中缓存。
*/
test('prefetches the full-size image on hover so the viewer opens instantly', async ({ page }) => {
const requested = [];
const first = attachment('a.png');
await page.route('**/api/device-assets**', route => {
const url = route.request().url();
if (url.includes('/files/')) { requested.push(url); return route.fulfill({ contentType: 'image/png', body: PIXEL_PNG }); }
return route.fulfill({ contentType: 'application/json', body: JSON.stringify({ code: 200, message: 'ok', data: { records: [deviceRow({ imageAttachment1Url: first.url, imageAttachment1ThumbUrl: first.thumbUrl })], total: 1, page: 1, size: 20 } }) });
});
await page.goto('/asset/#/device-assets');
const thumb = page.locator('.device-asset-page__thumb').first();
await expect(thumb).toBeVisible();
// 悬停之前只该请求缩略图,原图一个字节都不该下。
await expect.poll(() => requested.some(url => url.includes('variant=thumb'))).toBe(true);
expect(requested.some(url => !url.includes('variant=thumb'))).toBe(false);
await thumb.hover();
// 悬停之后原图已经在路上了,点击时无需再等。
await expect.poll(() => requested.some(url => url === first.url || url.endsWith('/files/a.png'))).toBe(true);
});
/** Plain purpose: verify hovering a thumbnail reveals the preview eye and opens the full-size viewer. Related files: device-asset.css, DeviceAssetView.js. Flow: hover -> mask opacity 1 -> click -> el-image-viewer. */
test('reveals the preview eye on hover and opens the image viewer', async ({ page }) => {
const first = attachment('a.png');
await mockDeviceList(page, deviceRow({ imageAttachment1Url: first.url, imageAttachment1ThumbUrl: first.thumbUrl }));
await page.goto('/asset/#/device-assets');
const thumb = page.locator('.device-asset-page__thumb').first();
const mask = thumb.locator('.device-asset-page__thumb-mask');
await expect(mask).toHaveCSS('opacity', '0');
await thumb.hover();
await expect(mask).toHaveCSS('opacity', '1');
await thumb.click();
await expect(page.locator('.el-image-viewer__wrapper')).toBeVisible();
// 点开大图才请求原图;列表里那张 40px 小图始终走缩略图。
await expect(page.locator('.el-image-viewer__img')).toHaveAttribute('src', first.url);
});
/**
* Plain purpose: guard the reported "clicking the image does nothing" defect and prove removal reaches the API.
* Related files: DeviceAssetView.js, DeviceAssetSaveRequest.java.
* Flow: 编辑 -> 点图片开预览 / 点角标移除 -> PUT removeImageAttachment1=true -> 列表刷新。
*/
test('previews on image click and sends the removal flag from the always-visible badge', async ({ page }) => {
const stored = attachment('a.png');
const writes = [];
let listCalls = 0;
await page.route('**/api/device-assets**', route => {
const request = route.request();
const url = request.url();
if (url.includes('/files/')) return route.fulfill({ contentType: 'image/png', body: PIXEL_PNG });
if (url.includes('/lookups/')) return route.fulfill({ contentType: 'application/json', body: JSON.stringify({ code: 200, message: 'ok', data: [] }) });
if (request.method() === 'GET') { listCalls += 1; return route.fulfill({ contentType: 'application/json', body: JSON.stringify({ code: 200, message: 'ok', data: { records: [deviceRow({ imageAttachment1Url: stored.url, imageAttachment1ThumbUrl: stored.thumbUrl })], total: 1, page: 1, size: 20 } }) }); }
writes.push(request.postData() || '');
return route.fulfill({ contentType: 'application/json', body: JSON.stringify({ code: 200, message: 'ok', data: {} }) });
});
await page.goto('/asset/#/device-assets');
await page.locator('.el-table').getByRole('button', { name: '编辑' }).click();
const dialog = page.getByRole('dialog');
// 点图片本身就该开预览,而不是只有悬停后中间那个小眼睛才管用。
await dialog.locator('.device-asset-modal__preview-open').click();
await expect(page.locator('.el-image-viewer__wrapper')).toBeVisible();
await expect(page.locator('.el-image-viewer__img')).toHaveAttribute('src', stored.url);
await page.locator('.el-image-viewer__close').click();
// 移除角标不依赖 hover,直接可见可点。
const removeBadge = dialog.getByRole('button', { name: '移除图片' });
await expect(removeBadge).toBeVisible();
await removeBadge.click();
await expect(dialog.locator('.device-asset-modal__preview--filled')).toHaveCount(0);
await dialog.getByRole('button', { name: '确认保存' }).click();
await expect.poll(() => writes.length).toBe(1);
expect(writes[0]).toContain('name="removeImageAttachment1"');
expect(writes[0].split('name="removeImageAttachment1"')[1]).toContain('true');
await expect.poll(() => listCalls).toBe(2);
await expect(page.getByText('编辑成功')).toBeVisible();
});
/** Plain purpose: verify the name box's one-click numbering asks the server for the next free number and fills it in. Related files: DeviceAssetService.java, device-api-client.js. Flow: 一键编号 -> GET next-device-name -> 输入框。 */
test('fills the next sequential device number from the name box button', async ({ page }) => {
const asked = [];
await page.route('**/api/device-assets**', route => {
const url = route.request().url();
if (url.includes('/lookups/next-device-name')) { asked.push(url); return route.fulfill({ contentType: 'application/json', body: JSON.stringify({ code: 200, message: 'ok', data: { deviceName: '学管师2号机' } }) }); }
if (url.includes('/lookups/')) return route.fulfill({ contentType: 'application/json', body: JSON.stringify({ code: 200, message: 'ok', data: [] }) });
return route.fulfill({ contentType: 'application/json', body: JSON.stringify({ code: 200, message: 'ok', data: { records: [deviceRow({ deviceName: '学管师1号机' })], total: 1, page: 1, size: 20 } }) });
});
await page.goto('/asset/#/device-assets');
await page.getByRole('button', { name: '新增设备资产', exact: true }).click();
const dialog = page.getByRole('dialog');
const nameBox = dialog.getByRole('textbox').first();
await expect(nameBox).toHaveValue('');
await dialog.getByRole('button', { name: '按顺序生成下一个编号' }).click();
await expect(nameBox).toHaveValue('学管师2号机');
// 空输入框时不带前缀,由后端用默认的"学管师"。
expect(asked[0]).toContain('prefix=');
expect(decodeURIComponent(asked[0].split('prefix=')[1])).toBe('');
});
/** Plain purpose: verify the button reuses whatever prefix is already typed, so it also serves 班主任N号机. Related files: DeviceAssetView.js, DeviceAssetService.java. Flow: 已填名称 -> 去掉尾部 N号机 -> prefix 查询参数。 */
test('reuses the typed prefix when generating the next device number', async ({ page }) => {
const asked = [];
await page.route('**/api/device-assets**', route => {
const url = route.request().url();
if (url.includes('/lookups/next-device-name')) { asked.push(decodeURIComponent(url.split('prefix=')[1])); return route.fulfill({ contentType: 'application/json', body: JSON.stringify({ code: 200, message: 'ok', data: { deviceName: '班主任4号机' } }) }); }
if (url.includes('/lookups/')) return route.fulfill({ contentType: 'application/json', body: JSON.stringify({ code: 200, message: 'ok', data: [] }) });
return route.fulfill({ contentType: 'application/json', body: JSON.stringify({ code: 200, message: 'ok', data: { records: [], total: 0, page: 1, size: 20 } }) });
});
await page.goto('/asset/#/device-assets');
await page.getByRole('button', { name: '新增设备资产', exact: true }).click();
const dialog = page.getByRole('dialog');
await dialog.getByRole('textbox').first().fill('班主任3号机');
await dialog.getByRole('button', { name: '按顺序生成下一个编号' }).click();
await expect(dialog.getByRole('textbox').first()).toHaveValue('班主任4号机');
expect(asked[0]).toBe('班主任');
});
/** Plain purpose: keep the file chooser filtered to images so the OS dialog does not enumerate every file in a folder. Related files: DeviceAssetView.js, DeviceAssetFileStorageService.java. Flow: upload trigger -> accept attribute -> image-only picker. */
test('restricts the upload picker to the supported image types', async ({ page }) => {
await mockDeviceList(page, deviceRow());
await page.goto('/asset/#/device-assets');
await page.getByRole('button', { name: '新增设备资产', exact: true }).click();
const inputs = page.getByRole('dialog').locator('input[type=file]');
await expect(inputs).toHaveCount(2);
await expect(inputs.first()).toHaveAttribute('accept', 'image/jpeg,image/png,image/gif');
});
/** Plain purpose: keep the update column at date precision because the clock time is noise in this list. Related files: DeviceAssetResponse.java, DeviceAssetView.js. Flow: ISO timestamp -> formatDate -> date-only cell. */
test('shows the update column as a date without a clock time', async ({ page }) => {
await mockDeviceList(page, deviceRow());
await page.goto('/asset/#/device-assets');
const row = page.locator('.el-table__body tr').first();
await expect(row.getByText('2026-08-05', { exact: true })).toBeVisible();
await expect(row.getByText('17:47')).toHaveCount(0);
});
/** Plain purpose: verify the dialog leads with the image row and drops the required marks on both status fields. Related files: DeviceAssetView.js, app.css. Flow: open create -> form rows -> label order and asterisk state. */
test('opens a device dialog that leads with images and only requires the name', async ({ page }) => {
await mockDeviceList(page, deviceRow());
await page.goto('/asset/#/device-assets');
await page.getByRole('button', { name: '\u65b0\u589e\u8bbe\u5907\u8d44\u4ea7', exact: true }).click();
const dialog = page.getByRole('dialog');
await expect(dialog.getByText('\u65b0\u589e\u8bbe\u5907\u8d44\u4ea7', { exact: true })).toBeVisible();
const labels = dialog.locator('.el-form-item__label');
await expect(labels).toHaveText(['\u56fe\u7247', '\u8bbe\u5907\u540d\u79f0', '\u4f7f\u7528\u4eba', '\u4f7f\u7528\u72b6\u6001', '\u8d44\u4ea7\u5173\u8054\u72b6\u6001']);
await expect(dialog.locator('.el-form-item.is-required .el-form-item__label')).toHaveText(['\u8bbe\u5907\u540d\u79f0']);
await expect(dialog.locator('.device-asset-modal__preview')).toHaveCount(2);
});
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment