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;
import com.xyw.console.auth.PagePermissionService;
import com.xyw.console.common.ApiResponse;
import jakarta.validation.Valid;
import java.time.Duration;
import java.util.List;
import org.springframework.core.io.Resource;
import org.springframework.http.CacheControl;
import org.springframework.http.MediaType;
import org.springframework.http.MediaTypeFactory;
import org.springframework.http.ResponseEntity;
......@@ -36,6 +38,20 @@ public class DeviceAssetController {
/** 代码作用(白话):按关键字搜索可作为设备使用人的公司人员。关联文件: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)); }
/** 代码作用(白话):按不透明标识读取设备图片,不返回服务器路径。关联文件:DeviceAssetFileStorageService.java、DeviceAssetResponse.java。关联逻辑(调用链/数据流):img URL -> findImage -> Resource -> 浏览器预览。 */
@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); }
/** 代码作用(白话):返回按前缀顺延的下一个设备编号名称,供新增弹窗一键填充。关联文件:DeviceNameSuggestionResponse.java、DeviceAssetView.js。关联逻辑(调用链/数据流):一键编号 -> 全库最大编号 -> prefix+N+号机 -> 输入框。 */
@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;
import java.time.LocalDateTime;
/** 文件用途(白话):定义一条安全返回给设备管理页面的数据,不暴露软删除标记或服务器真实文件路径。 */
/**
* 文件用途(白话):定义一条安全返回给设备管理页面的数据,不暴露软删除标记或服务器真实文件路径。
* 缩略图 URL 与原图 URL 分开返回:列表里的 40px 小图和弹窗预览用缩略图(约几十 KB),
* 只有点开大图才请求原图(可达 20MB),否则一页 20 条会拉几十兆图片把页面拖垮。
*/
public record DeviceAssetResponse(Long id, String deviceName, String imageAttachment1Url, String imageAttachment2Url,
String imageAttachment1ThumbUrl, String imageAttachment2ThumbUrl,
Long userPersonId, String userPersonName, String userUsageStatus, String assetRelationStatus,
LocalDateTime createTime, LocalDateTime updateTime) {}
package com.xyw.console.asset.dto;
/** 文件用途(白话):承载"下一个可用设备编号名称"的建议值,供新增弹窗一键填充。 */
public record DeviceNameSuggestionResponse(String deviceName) {}
package com.xyw.console.asset.service;
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.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.Iterator;
import java.util.Locale;
import java.util.Set;
import java.util.UUID;
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.core.io.Resource;
import org.springframework.core.io.UrlResource;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
/** 文件用途(白话):校验并保存设备原图,且只允许用不透明标识在受控目录中读取图片。 */
/** 文件用途(白话):校验并保存设备原图、派生列表用的小缩略图,且只允许用不透明标识在受控目录中读取图片。 */
@Service
public class DeviceAssetFileStorageService {
private static final long MAX_IMAGE_BYTES = 20L * 1024 * 1024;
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;
/** 代码作用(白话):解析并创建设备图片根目录。关联文件:DeviceAssetService.java、DeviceAssetController.java。关联逻辑(调用链/数据流):配置/默认目录 -> 文件服务 -> 保存和读取图片。 */
......@@ -30,21 +40,43 @@ public class DeviceAssetFileStorageService {
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) {
if (image == null || image.isEmpty()) return null;
validateImage(image);
String identifier = createOpaqueIdentifier(extensionOf(image.getOriginalFilename()));
if (image.getSize() > MAX_IMAGE_BYTES) throw new DeviceAssetValidationException("每张图片不能超过 20MB");
String extension = extensionOf(image.getOriginalFilename());
if (!EXTENSIONS.contains(extension)) throw new DeviceAssetValidationException("仅支持 JPG、PNG、GIF 图片");
String identifier = createOpaqueIdentifier(extension);
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("设备图片保存失败"); }
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) {
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; }
catch (IOException exception) { throw new DeviceAssetValidationException("图片读取失败"); }
return readable(resolveInsideRoot(identifier));
}
/**
* 代码作用(白话):返回列表和弹窗预览用的小缩略图,历史图片首次访问时补生成一张。
* 关联文件: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 -> 新/旧标识 -> 设备更新。 */
......@@ -56,19 +88,95 @@ public class DeviceAssetFileStorageService {
/** 代码作用(白话):清空数据库中的附件引用但保留物理原图,以支持软删除后追溯。关联文件:DeviceAssetService.java。关联逻辑(调用链/数据流):移除图片标记 -> removeReference -> 数据库字段置空 -> 原图保留。 */
public String removeReference() { return null; }
/** 代码作用(白话):删除本次失败请求新写入的文件,不接收历史附件标识。关联文件:DeviceAssetService.java。关联逻辑(调用链/数据流):保存失败 -> cleanupNewFile -> 删除临时新图。 */
/** 代码作用(白话):删除本次失败请求新写入的原图及其缩略图,不接收历史附件标识。关联文件:DeviceAssetService.java。关联逻辑(调用链/数据流):保存失败 -> cleanupNewFile -> 删除临时新图。 */
public void cleanupNewFile(String identifier) {
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) {
if (image.getSize() > MAX_IMAGE_BYTES) throw new DeviceAssetValidationException("每张图片不能超过 20MB");
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("图片内容无效"); }
catch (IOException exception) { throw new DeviceAssetValidationException("图片内容无效"); }
/**
* 代码作用(白话):解码原图并写出一张长边不超过 240 像素的 JPEG 缩略图,顺带确认文件确实是图片。
* 关联文件:DeviceAssetController.java、DeviceAssetView.js。
* 关联逻辑(调用链/数据流):原图文件 -> 降采样解码 -> 缩放 -> 临时文件 -> 原子替换 -> 缩略图。
*/
private void writeThumbnail(Path source, Path target) {
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。 */
......
......@@ -29,6 +29,8 @@ import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.function.Function;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.springframework.core.io.Resource;
import org.springframework.stereotype.Service;
......@@ -37,6 +39,10 @@ import org.springframework.stereotype.Service;
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> 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 CompanyPersonMapper personMapper;
private final PhoneAssetMapper phoneMapper;
......@@ -97,9 +103,35 @@ public class DeviceAssetService {
.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. */
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. */
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);
......@@ -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. */
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. */
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. */
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. */
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. */
......
......@@ -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.get;
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.status;
......@@ -47,7 +48,7 @@ class DeviceAssetControllerTest {
}
/** 代码作用(白话):验证删除被关联资产阻断时,接口返回 400 和可读错误信息。关联文件:DeviceAssetController.java、DeviceAssetExceptionHandler.java。关联逻辑(调用链/数据流):DELETE -> Service.softDelete 异常 -> Advice -> 400 JSON。*/
@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));
}
/** 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 {
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}));
}
/** 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. */
@Test void hidesDeleteTimeAndPhysicalImagePath() throws Exception {
DeviceAssetService service=mock(DeviceAssetService.class);when(service.page(any())).thenReturn(new DeviceAssetPageResponse(List.of(response()),1,1,20));
......@@ -76,7 +86,8 @@ class DeviceAssetControllerTest {
assertThrows(AccessDeniedException.class, () -> controller.update(1L, new DeviceAssetSaveRequest()));
assertThrows(AccessDeniedException.class, () -> controller.delete(1L));
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);
} finally { SecurityContextHolder.clearContext(); }
}
......@@ -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. */
private MockMvc mockMvc(DeviceAssetService service){return MockMvcBuilders.standaloneSetup(new DeviceAssetController(service,mock(PagePermissionService.class))).setControllerAdvice(new DeviceAssetExceptionHandler()).build();}
/** 代码作用(白话):生成一条不含服务器文件真实路径的安全设备响应。关联文件: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 {
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 -> 根目录校验。 */
@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 -> 服务校验。 */
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 -> 上传格式校验。*/
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 {
AssetDeviceMapper devices=mock(AssetDeviceMapper.class);when(devices.selectCount(any(Wrapper.class))).thenReturn(1L);
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() {
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));
......@@ -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);
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));
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. */
@Test void rejectsDeletedUserPersonBeforeCreate() {
......@@ -98,7 +123,7 @@ class DeviceAssetServiceTest {
@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);
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. */
@Test void updateRemovalClearsImageReferenceButRetainsFile(@TempDir Path directory) throws Exception {
......@@ -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;}
/** 代码作用(白话):生成 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);}}
/** 代码作用(白话):生成一条只带名称的设备行,用于编号建议的纯计算断言。关联文件: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 { 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';
/** File purpose: render device asset list, CRUD dialog, image thumbnail/original preview, and guarded deletion. */
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. */
setup() {
const usageStatuses=['\u4f7f\u7528\u4e2d','\u95f2\u7f6e','\u7ef4\u4fee\u4e2d','\u505c\u7528'];
const relationStatuses=['\u5df2\u5173\u8054','\u672a\u5173\u8054','\u5f85\u786e\u8ba4'];
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 filters=reactive({page:1,size:20,deviceName:'',userPersonId:null,userUsageStatus:'',assetRelationStatus:''});
const usageStatuses=['使用中','闲置','维修中','停用'];
const relationStatuses=['已关联','未关联','待确认'];
const imageSlots=['imageAttachment1','imageAttachment2'];
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());
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. */
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. */
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. */
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. */
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. */
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. */
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. */
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. */
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. */
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;}
/** 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;}
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 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. */
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. */
function removeImage(slot){clearPreview(slot);form[slot]=null;form[`${slot}Url`]='';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. */
function previewImage(url){if(url){imageViewerUrl.value=url;imageViewerVisible.value=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: 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 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. */
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. */
......@@ -52,7 +128,38 @@ export default {
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. */
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
export function deleteDeviceAsset(id) { return request('/api/device-assets/' + id, { method: 'DELETE' }); }
/** 代码作用(白话):搜索可作为设备使用人的公司人员。关联文件:DeviceAssetView.js、DeviceAssetController.java。关联逻辑(调用链/数据流):远程选择器 -> GET lookup -> 人员选项。 */
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。 */
.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(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';
/** 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. */
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. */
......@@ -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.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