Commit 870deb9d by DaiJiezhang

Merge branch 'feat/device-image-and-wecom-crud' into 'master'

feat: 设备图片性能优化、企业微信资产增删改与可清空字段修复

See merge request !11
parents fc0e6702 ea21e468
......@@ -12,7 +12,8 @@ backend/target/
F_TMP_WRITE_TEST.txt
localhost
wechat-page.png
backend/src/main/resources/application.yml
# application.yml 不再忽略:里面的数据库账号密码和 JWT 密钥全是 ${} 环境变量占位符,
# 真值在 .env(仍然忽略)。挡住它只会让新克隆的人缺配置、后端起不来。
start-backend.bat
# Local JDK setup, IDE-generated repository notes, and test artifacts.
.qoder/
......
......@@ -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);
}
}
......@@ -11,16 +11,16 @@ import org.springframework.web.bind.annotation.*;
public class PhoneAssetController {
private final PhoneAssetService service;
private final PagePermissionService permissions;
/** 代码作用(白话):接收手机号资产接口所需的业务服务。关联文件:PhoneAssetService.java、phone-api-client.js。关联逻辑(调用链/数据流):浏览器请求 -> Controller -> Service -> Mapper。 */
/** 代码作用(白话):接收手机号码管理接口所需的业务服务。关联文件:PhoneAssetService.java、phone-api-client.js。关联逻辑(调用链/数据流):浏览器请求 -> Controller -> Service -> Mapper。 */
/** 代码作用(白话):接收手机号业务服务和页面权限服务。关联文件:PagePermissionService.java、PhoneAssetService.java。关联逻辑(调用链/数据流):HTTP 请求 -> READ/EDIT 校验 -> 原有业务服务。 */
public PhoneAssetController(PhoneAssetService service, PagePermissionService permissions){this.service=service;this.permissions=permissions;}
/** 代码作用(白话):接收浏览器分页查询并返回统一 JSON。关联文件:PhoneAssetService.java、phone-api-client.js。关联逻辑(调用链/数据流):GET /api/phone-assets -> Service -> ApiResponse -> Vue 表格。 */
@GetMapping public ApiResponse<PhoneAssetPageResponse> page(@Valid PhoneAssetPageQuery query){permissions.require(PagePermissionService.PHONE,"READ");return ApiResponse.success(service.page(query));}
/** 代码作用(白话):接收新增表单并创建手机号资产。关联文件:PhoneAssetSaveRequest.java、PhoneAssetService.java、phone-api-client.js。关联逻辑(调用链/数据流):POST -> DTO -> Service.create -> ApiResponse -> 弹窗。 */
/** 代码作用(白话):接收新增表单并创建手机号码管理。关联文件:PhoneAssetSaveRequest.java、PhoneAssetService.java、phone-api-client.js。关联逻辑(调用链/数据流):POST -> DTO -> Service.create -> ApiResponse -> 弹窗。 */
@PostMapping public ApiResponse<PhoneAssetResponse> create(@Valid @RequestBody PhoneAssetSaveRequest request){permissions.require(PagePermissionService.PHONE,"EDIT");return ApiResponse.success("新增成功",service.create(request));}
/** 代码作用(白话):接收编辑表单并更新允许修改的字段。关联文件:PhoneAssetSaveRequest.java、PhoneAssetService.java、phone-api-client.js。关联逻辑(调用链/数据流):PUT -> Service.update -> ApiResponse -> 列表刷新。 */
@PutMapping("/{id}") public ApiResponse<PhoneAssetResponse> update(@PathVariable Long id,@Valid @RequestBody PhoneAssetSaveRequest request){permissions.require(PagePermissionService.PHONE,"EDIT");return ApiResponse.success("编辑成功",service.update(id,request));}
/** 代码作用(白话):软删除没有关联阻止的手机号资产。关联文件:PhoneAssetService.java、phone-api-client.js。关联逻辑(调用链/数据流):DELETE -> Service.softDelete -> ApiResponse -> 列表刷新。 */
/** 代码作用(白话):软删除没有关联阻止的手机号码管理。关联文件:PhoneAssetService.java、phone-api-client.js。关联逻辑(调用链/数据流):DELETE -> Service.softDelete -> ApiResponse -> 列表刷新。 */
@DeleteMapping("/{id}") public ApiResponse<Void> delete(@PathVariable Long id){permissions.require(PagePermissionService.PHONE,"EDIT");service.softDelete(id);return ApiResponse.success("删除成功",null);}
/** 代码作用(白话):按设备名称关键字搜索可关联的设备,供弹窗"关联设备"下拉使用。这里用手机号页面的 READ 权限而不是设备页的管理员权限,避免只有手机号权限的用户搜不到设备。关联文件:DeviceAssetLookupResponse.java、PhoneAssetService.java、phone-api-client.js。关联逻辑(调用链/数据流):下拉输入 -> GET /lookups/devices -> Service 模糊查询 -> 选项列表。 */
@GetMapping("/lookups/devices") public ApiResponse<List<DeviceAssetLookupResponse>> devices(@RequestParam(defaultValue="") String keyword){permissions.require(PagePermissionService.PHONE,"READ");return ApiResponse.success(service.searchDevices(keyword));}
......
......@@ -2,6 +2,7 @@ package com.xyw.console.asset.controller;
import com.xyw.console.asset.dto.CompanyPersonLookupResponse;
import com.xyw.console.asset.dto.CompanyProfileLookupResponse;
import com.xyw.console.asset.dto.DeviceAssetLookupResponse;
import com.xyw.console.asset.dto.PhoneAssetLookupResponse;
import com.xyw.console.asset.dto.WecomAccountPageQuery;
import com.xyw.console.asset.dto.WecomAccountPageResponse;
......@@ -11,8 +12,11 @@ import com.xyw.console.auth.PagePermissionService;
import com.xyw.console.common.ApiResponse;
import jakarta.validation.Valid;
import java.util.List;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
......@@ -33,6 +37,14 @@ public class WecomAccountController {
@PostMapping
public ApiResponse<?> create(@Valid @RequestBody WecomAccountSaveRequest request) { permissions.require(PagePermissionService.WECOM, "EDIT"); return ApiResponse.success("新增成功", service.create(request)); }
/** 代码作用(白话):编辑一条企微资产,改了注册手机号时由 Service 处理重新关联与旧号清理。关联文件:WecomAccountSaveRequest.java、WecomAccountService.java。关联逻辑(调用链/数据流):编辑弹窗 -> PUT -> 事务 -> 响应。 */
@PutMapping("/{id}")
public ApiResponse<?> update(@PathVariable Long id, @Valid @RequestBody WecomAccountSaveRequest request) { permissions.require(PagePermissionService.WECOM, "EDIT"); return ApiResponse.success("编辑成功", service.update(id, request)); }
/** 代码作用(白话):软删除一条企微资产,并顺带清理当初为它自动新建、现在没人用的外部号码。关联文件:WecomAccountService.java、PhoneAssetEntity.java。关联逻辑(调用链/数据流):删除确认 -> DELETE -> 事务 -> 列表刷新。 */
@DeleteMapping("/{id}")
public ApiResponse<Void> delete(@PathVariable Long id) { permissions.require(PagePermissionService.WECOM, "EDIT"); service.softDelete(id); return ApiResponse.success("删除成功", null); }
/** Code purpose (plain language): returns the paged enterprise WeChat asset list and optional registration-phone filter. Related files: WecomAccountPageQuery.java, WecomAccountService.java. Data flow: list query -> GET -> service.page -> table. */
@GetMapping
public ApiResponse<WecomAccountPageResponse> page(@Valid WecomAccountPageQuery query) { permissions.require(PagePermissionService.WECOM, "READ"); return ApiResponse.success(service.page(query)); }
......@@ -45,6 +57,10 @@ public class WecomAccountController {
@GetMapping("/lookups/phone-assets")
public ApiResponse<List<PhoneAssetLookupResponse>> phoneAssets(@RequestParam(defaultValue = "") String keyword) { permissions.require(PagePermissionService.WECOM, "READ"); return ApiResponse.success(service.searchPhoneAssets(keyword)); }
/** 代码作用(白话):按名称搜索可关联的有效设备,供表单下拉远程查询。关联文件:DeviceAssetLookupResponse.java、WecomAccountService.java。关联逻辑(调用链/数据流):下拉输入 -> GET lookup -> 设备选项。 */
@GetMapping("/lookups/devices")
public ApiResponse<List<DeviceAssetLookupResponse>> devices(@RequestParam(defaultValue = "") String keyword) { permissions.require(PagePermissionService.WECOM, "READ"); return ApiResponse.success(service.searchDevices(keyword)); }
/** Code purpose (plain language): searches optional WeCom owners from company people. Related files: CompanyPersonLookupResponse.java, WecomAccountService.java. Data flow: remote select -> GET -> compact options. */
@GetMapping("/lookups/company-persons")
public ApiResponse<List<CompanyPersonLookupResponse>> companyPersons(@RequestParam(defaultValue = "") String keyword) { permissions.require(PagePermissionService.WECOM, "READ"); return ApiResponse.success(service.searchCompanyPersons(keyword)); }
......
package com.xyw.console.asset.controller;
import com.xyw.console.asset.exception.PhoneAssetValidationException;
import com.xyw.console.asset.exception.WecomAccountNotFoundException;
import com.xyw.console.common.ApiResponse;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice;
/** 文件用途(白话):把企业微信资产的业务异常转换成前端可读的统一 JSON 错误。 */
@RestControllerAdvice(assignableTypes = WecomAccountController.class)
public class WecomAccountExceptionHandler {
/** 代码作用(白话):把不存在或已删除的企微记录转为 404。关联文件:WecomAccountService.java。关联逻辑(调用链/数据流):Service 查无记录 -> Advice -> ApiResponse -> 前端提示。 */
@ExceptionHandler(WecomAccountNotFoundException.class) @ResponseStatus(HttpStatus.NOT_FOUND)
public ApiResponse<Void> notFound(WecomAccountNotFoundException exception) { return ApiResponse.error(404, exception.getMessage()); }
/**
* 代码作用(白话):把注册手机号的格式错误转为 400。
* 关联文件:WecomAccountService.java、PhoneAssetValidationException.java。
* 关联逻辑(调用链/数据流):手机号校验失败 -> Advice -> ApiResponse -> 弹窗提示。
* 这个处理器必须有:该异常此前没有任何 Advice 认领,会一路落进 GlobalExceptionHandler 的 Exception 兜底,
* 用户填错手机号却收到"服务器处理失败,请稍后重试",既看不到真正原因,也让监控分不清用户输错和服务端故障。
*/
@ExceptionHandler(PhoneAssetValidationException.class) @ResponseStatus(HttpStatus.BAD_REQUEST)
public ApiResponse<Void> phoneValidation(PhoneAssetValidationException exception) { return ApiResponse.error(400, exception.getMessage()); }
}
package com.xyw.console.asset.dto;
/** 文件用途(白话):承载设备资产的搜索结果,供手机号资产弹窗按设备名称选择关联设备时使用。 */
/** 文件用途(白话):承载设备资产的搜索结果,供手机号码管理弹窗按设备名称选择关联设备时使用。 */
public record DeviceAssetLookupResponse(Long id, String deviceName) {}
......@@ -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) {}
......@@ -3,7 +3,7 @@ package com.xyw.console.asset.dto;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
/** 文件用途(白话):接收手机号资产列表的分页和筛选条件,不改变新增、编辑接口的数据结构。 */
/** 文件用途(白话):接收手机号码管理列表的分页和筛选条件,不改变新增、编辑接口的数据结构。 */
public record PhoneAssetPageQuery(
@Min(1) Integer page,
@Min(1) @Max(100) Integer size,
......
......@@ -4,7 +4,7 @@ import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
/**
* 代码作用(白话):限定新增和编辑时浏览器可以提交的手机号资产字段。
* 代码作用(白话):限定新增和编辑时浏览器可以提交的手机号码管理字段。
* 关联文件:PhoneAssetController.java、PhoneAssetService.java、PhoneAssetView.js。
* 关联逻辑(调用链/数据流):弹窗表单 -> DTO 校验 -> Service 写入允许修改的实体字段。
*/
......
......@@ -12,5 +12,6 @@ public record WecomAccountSaveRequest(
String realNameOwner,
String realNameOwnerStatus,
String gender,
Long deviceId,
Long operatorPersonId) {
}
package com.xyw.console.asset.entity;
import com.baomidou.mybatisplus.annotation.FieldStrategy;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableName;
import java.time.LocalDateTime;
......@@ -16,11 +17,17 @@ import lombok.EqualsAndHashCode;
@TableName("as_asset_device")
public class AssetDeviceEntity extends AssetBaseEntity {
private String deviceName;
/** 必须显式指定列名:MyBatis-Plus 的驼峰转下划线不会在数字前加下划线,会把 imageAttachment1 推成 image_attachment1,与表中的 image_attachment_1 对不上并抛 BadSqlGrammarException。 */
@TableField("image_attachment_1")
/**
* 必须显式指定列名:MyBatis-Plus 的驼峰转下划线不会在数字前加下划线,会把 imageAttachment1 推成 image_attachment1,与表中的 image_attachment_1 对不上并抛 BadSqlGrammarException。
* updateStrategy 必须写 ALWAYS:MyBatis-Plus 默认的 NOT_NULL 会把值为 null 的字段整个从 UPDATE 的 SET 子句里剔掉,
* 于是"移除图片"设置的 null 永远写不进数据库——接口返回成功、update_time 也变了,唯独图片引用还在,列表刷新后图片照旧显示。
*/
@TableField(value = "image_attachment_1", updateStrategy = FieldStrategy.ALWAYS)
private String imageAttachment1;
@TableField("image_attachment_2")
@TableField(value = "image_attachment_2", updateStrategy = FieldStrategy.ALWAYS)
private String imageAttachment2;
/** 同上:使用人允许清空,不写 ALWAYS 的话清空操作同样会被默默丢弃。 */
@TableField(updateStrategy = FieldStrategy.ALWAYS)
private Long userPersonId;
private String userUsageStatus;
private String assetRelationStatus;
......
package com.xyw.console.asset.entity;
import com.baomidou.mybatisplus.annotation.FieldStrategy;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableName;
import java.time.LocalDateTime;
import lombok.Data;
......@@ -18,6 +20,8 @@ public class DouyinAccountEntity extends AssetBaseEntity {
private String realNameOwner;
private Long companyProfileId;
private Long phoneAssetId;
/** 同企微:目前没有编辑接口,这里提前声明策略,避免将来补编辑功能时"取消关联设备"静默失效。 */
@TableField(updateStrategy = FieldStrategy.ALWAYS)
private Long deviceId;
private Long operatorPersonId;
}
package com.xyw.console.asset.entity;
import com.baomidou.mybatisplus.annotation.FieldStrategy;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableName;
import java.time.LocalDateTime;
import lombok.Data;
......@@ -15,11 +17,22 @@ import lombok.EqualsAndHashCode;
@TableName("as_phone_asset")
public class PhoneAssetEntity extends AssetBaseEntity {
private String phoneNumber;
/**
* 以下五个字段都是编辑弹窗里允许清空的(运营商、管理模式、关联设备是 clearable 下拉,ICCID 和实名人是可删空的输入框),
* 所以必须写 ALWAYS:MyBatis-Plus 默认的 NOT_NULL 会把值为 null 的字段整个从 UPDATE 的 SET 子句里剔掉,
* 于是"清空后保存"接口返回成功、update_time 也变了,唯独这一列的旧值原封不动,刷新后又冒出来。
* 这里能安全放开的前提是 PhoneAssetService.update 走的是"先查全量实体再改字段",没有只 set 部分字段的局部更新。
*/
@TableField(updateStrategy = FieldStrategy.ALWAYS)
private String cardType;
@TableField(updateStrategy = FieldStrategy.ALWAYS)
private String iccid;
@TableField(updateStrategy = FieldStrategy.ALWAYS)
private String realNameOwner;
@TableField(updateStrategy = FieldStrategy.ALWAYS)
private String managementType;
private String disposalStatus;
@TableField(updateStrategy = FieldStrategy.ALWAYS)
private Long deviceId;
private String numberType;
private String sourceAssetType;
......
package com.xyw.console.asset.entity;
import com.baomidou.mybatisplus.annotation.FieldStrategy;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableName;
import java.time.LocalDateTime;
import lombok.Data;
......@@ -17,6 +19,8 @@ public class WechatAccountEntity extends AssetBaseEntity {
private String wechatId;
private String realNameOwner;
private Long phoneAssetId;
/** 同企微:目前没有编辑接口,这里提前声明策略,避免将来补编辑功能时"取消关联设备"静默失效。 */
@TableField(updateStrategy = FieldStrategy.ALWAYS)
private Long deviceId;
private Long operatorPersonId;
}
package com.xyw.console.asset.entity;
import com.baomidou.mybatisplus.annotation.FieldStrategy;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableName;
import java.time.LocalDateTime;
import lombok.Data;
......@@ -23,6 +25,11 @@ public class WecomAccountEntity extends AssetBaseEntity {
private String realNameOwner;
private String realNameOwnerStatus;
private String gender;
/**
* 这条目前还没有编辑接口,清空路径走不到,这里是提前立规矩:一旦补上编辑功能,
* 默认的 NOT_NULL 策略会让"取消关联设备"静默失效(设备资产的移除图片就是这么坏的)。
*/
@TableField(updateStrategy = FieldStrategy.ALWAYS)
private Long deviceId;
private Long operatorPersonId;
}
package com.xyw.console.asset.exception;
/** 代码作用(白话):表示请求的手机号资产不存在或已软删除。关联文件:PhoneAssetService.java、PhoneAssetExceptionHandler.java。关联逻辑(调用链/数据流):Service 查无记录 -> 异常 -> 404 响应。 */
/** 代码作用(白话):表示请求的手机号码管理不存在或已软删除。关联文件:PhoneAssetService.java、PhoneAssetExceptionHandler.java。关联逻辑(调用链/数据流):Service 查无记录 -> 异常 -> 404 响应。 */
public class PhoneAssetNotFoundException extends RuntimeException { public PhoneAssetNotFoundException(String message){super(message);} }
\ No newline at end of file
package com.xyw.console.asset.exception;
/** 文件用途(白话):表示要编辑或删除的企业微信资产不存在或已被删除,供 Advice 转成 404。 */
public class WecomAccountNotFoundException extends RuntimeException {
/** 代码作用(白话):携带可直接展示给用户的提示。关联文件:WecomAccountExceptionHandler.java、WecomAccountService.java。关联逻辑(调用链/数据流):Service 查无记录 -> 本异常 -> Advice -> 404 JSON。 */
public WecomAccountNotFoundException(String message) { super(message); }
}
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. */
......
......@@ -23,7 +23,7 @@ public class PhoneAssetService {
private static final int DEVICE_LOOKUP_LIMIT = 20;
private final PhoneAssetMapper mapper;
private final AssetDeviceMapper deviceMapper;
/** 代码作用(白话):接收手机号资产表和设备资产表的数据库访问入口。关联文件:PhoneAssetMapper.java、AssetDeviceMapper.java、PhoneAssetController.java。关联逻辑(调用链/数据流):Controller -> Service -> Mapper -> as_phone_asset / as_asset_device。 */
/** 代码作用(白话):接收手机号码管理表和设备资产表的数据库访问入口。关联文件:PhoneAssetMapper.java、AssetDeviceMapper.java、PhoneAssetController.java。关联逻辑(调用链/数据流):Controller -> Service -> Mapper -> as_phone_asset / as_asset_device。 */
public PhoneAssetService(PhoneAssetMapper mapper, AssetDeviceMapper deviceMapper) { this.mapper = mapper; this.deviceMapper = deviceMapper; }
/** 代码作用(白话):按分页、手机号片段、ICCID、实名人和使用状态读取未删除资产,并补上关联设备名称。关联文件:PhoneAssetPageQuery.java、PhoneAssetController.java。关联逻辑(调用链/数据流):GET 参数 -> 查询条件 -> Mapper -> 设备名批量解析 -> Response。 */
public PhoneAssetPageResponse page(PhoneAssetPageQuery query) {
......@@ -60,14 +60,14 @@ public class PhoneAssetService {
}
/** 代码作用(白话):判断筛选文本是否有内容,避免空字符串参与数据库筛选。关联文件:PhoneAssetPageQuery.java、PhoneAssetService.java。关联逻辑(调用链/数据流):请求参数 -> hasText -> 是否追加条件。 */
private boolean hasText(String value) { return value != null && !value.isBlank(); }
/** 代码作用(白话):新增手机号资产并初始化审计字段和内部关联快照。关联文件:PhoneAssetSaveRequest.java、PhoneAssetMapper.java、PhoneAssetController.java。关联逻辑(调用链/数据流):POST 请求 -> DTO -> Service.create -> Mapper.insert -> as_phone_asset。 */
public PhoneAssetResponse create(PhoneAssetSaveRequest request) { PhoneAssetEntity entity=new PhoneAssetEntity(); applyEditableFields(entity,request); entity.setNumberType("SELF"); LocalDateTime now=LocalDateTime.now(); entity.setCreateTime(now); entity.setUpdateTime(now); entity.setDeleteTime(0L); entity.setLinkedWecomAccounts("[]"); entity.setLinkedWechatAccounts("[]"); entity.setLinkedDouyinAccounts("[]"); entity.setLinkedDomainAccounts("[]"); entity.setLinkedMerchants("[]"); if(mapper.insert(entity)!=1) throw new IllegalStateException("手机号资产新增失败"); return toResponse(entity,deviceNames(singleDeviceId(entity.getDeviceId()))); }
/** 代码作用(白话):新增手机号码管理并初始化审计字段和内部关联快照。关联文件:PhoneAssetSaveRequest.java、PhoneAssetMapper.java、PhoneAssetController.java。关联逻辑(调用链/数据流):POST 请求 -> DTO -> Service.create -> Mapper.insert -> as_phone_asset。 */
public PhoneAssetResponse create(PhoneAssetSaveRequest request) { PhoneAssetEntity entity=new PhoneAssetEntity(); applyEditableFields(entity,request); entity.setNumberType("SELF"); LocalDateTime now=LocalDateTime.now(); entity.setCreateTime(now); entity.setUpdateTime(now); entity.setDeleteTime(0L); entity.setLinkedWecomAccounts("[]"); entity.setLinkedWechatAccounts("[]"); entity.setLinkedDouyinAccounts("[]"); entity.setLinkedDomainAccounts("[]"); entity.setLinkedMerchants("[]"); if(mapper.insert(entity)!=1) throw new IllegalStateException("手机号码管理新增失败"); return toResponse(entity,deviceNames(singleDeviceId(entity.getDeviceId()))); }
/** 代码作用(白话):更新有效资产的用户可写字段。关联文件:PhoneAssetSaveRequest.java、PhoneAssetMapper.java、PhoneAssetController.java。关联逻辑(调用链/数据流):PUT 请求 -> Service 查找 -> Mapper.updateById -> Response。 */
public PhoneAssetResponse update(Long id, PhoneAssetSaveRequest request) { PhoneAssetEntity entity=requireActiveEntity(id); applyEditableFields(entity,request); entity.setUpdateTime(LocalDateTime.now()); if(mapper.updateById(entity)!=1) throw new PhoneAssetNotFoundException("手机号资产不存在或已删除"); return toResponse(entity,deviceNames(singleDeviceId(entity.getDeviceId()))); }
public PhoneAssetResponse update(Long id, PhoneAssetSaveRequest request) { PhoneAssetEntity entity=requireActiveEntity(id); applyEditableFields(entity,request); entity.setUpdateTime(LocalDateTime.now()); if(mapper.updateById(entity)!=1) throw new PhoneAssetNotFoundException("手机号码管理不存在或已删除"); return toResponse(entity,deviceNames(singleDeviceId(entity.getDeviceId()))); }
/** 代码作用(白话):将资产标记为删除,供 Controller 删除接口调用。关联文件:PhoneAssetController.java、PhoneAssetMapper.java。关联逻辑(调用链/数据流):DELETE 请求 -> Service -> Mapper.updateById。 */
public void softDelete(Long id) { PhoneAssetEntity entity=requireActiveEntity(id); entity.setDeleteTime(System.currentTimeMillis()); entity.setUpdateTime(LocalDateTime.now()); if(mapper.updateById(entity)!=1) throw new PhoneAssetNotFoundException("手机号资产不存在或已删除"); }
public void softDelete(Long id) { PhoneAssetEntity entity=requireActiveEntity(id); entity.setDeleteTime(System.currentTimeMillis()); entity.setUpdateTime(LocalDateTime.now()); if(mapper.updateById(entity)!=1) throw new PhoneAssetNotFoundException("手机号码管理不存在或已删除"); }
/** 代码作用(白话):读取一条仍有效的资产,供编辑和删除共用。关联文件:PhoneAssetMapper.java、PhoneAssetExceptionHandler.java。关联逻辑(调用链/数据流):Service 查询 -> 无记录 -> 404 异常。 */
private PhoneAssetEntity requireActiveEntity(Long id){ PhoneAssetEntity entity=mapper.selectOne(new LambdaQueryWrapper<PhoneAssetEntity>().eq(PhoneAssetEntity::getDeleteTime,0L).eq(PhoneAssetEntity::getId,id)); if(entity==null) throw new PhoneAssetNotFoundException("手机号资产不存在或已删除"); return entity; }
private PhoneAssetEntity requireActiveEntity(Long id){ PhoneAssetEntity entity=mapper.selectOne(new LambdaQueryWrapper<PhoneAssetEntity>().eq(PhoneAssetEntity::getDeleteTime,0L).eq(PhoneAssetEntity::getId,id)); if(entity==null) throw new PhoneAssetNotFoundException("手机号码管理不存在或已删除"); return entity; }
/** 代码作用(白话):把表单允许提交的字段写入实体,并统一处理手机号和默认使用状态。关联文件:PhoneAssetSaveRequest.java、PhoneAssetEntity.java。关联逻辑(调用链/数据流):弹窗表单 -> DTO -> Entity -> Mapper 保存。 */
private void applyEditableFields(PhoneAssetEntity entity, PhoneAssetSaveRequest request){ entity.setPhoneNumber(normalizePhoneNumber(request.phoneNumber())); entity.setCardType(request.cardType()); entity.setIccid(request.iccid()); entity.setRealNameOwner(request.realNameOwner()); entity.setManagementType(request.managementType()); entity.setDisposalStatus(request.disposalStatus()==null||request.disposalStatus().isBlank()?"正常使用":request.disposalStatus()); entity.setDeviceId(request.deviceId()); }
/** 代码作用(白话):按已确认规则清理手机号并验证其最终格式。关联文件:PhoneAssetSaveRequest.java、PhoneAssetExceptionHandler.java。关联逻辑(调用链/数据流):前端输入 -> 去空格和 +86 -> 格式失败返回 400。 */
......
......@@ -4,6 +4,7 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.xyw.console.asset.dto.CompanyPersonLookupResponse;
import com.xyw.console.asset.dto.CompanyProfileLookupResponse;
import com.xyw.console.asset.dto.DeviceAssetLookupResponse;
import com.xyw.console.asset.dto.PhoneAssetLookupResponse;
import com.xyw.console.asset.dto.WecomAccountPageQuery;
import com.xyw.console.asset.dto.WecomAccountPageResponse;
......@@ -11,14 +12,19 @@ import com.xyw.console.asset.dto.WecomAccountResponse;
import com.xyw.console.asset.dto.WecomAccountSaveRequest;
import com.xyw.console.asset.entity.AssetDeviceEntity;
import com.xyw.console.asset.exception.PhoneAssetValidationException;
import com.xyw.console.asset.exception.WecomAccountNotFoundException;
import com.xyw.console.asset.entity.CompanyPersonEntity;
import com.xyw.console.asset.entity.CompanyProfileEntity;
import com.xyw.console.asset.entity.DouyinAccountEntity;
import com.xyw.console.asset.entity.PhoneAssetEntity;
import com.xyw.console.asset.entity.WechatAccountEntity;
import com.xyw.console.asset.entity.WecomAccountEntity;
import com.xyw.console.asset.mapper.AssetDeviceMapper;
import com.xyw.console.asset.mapper.CompanyPersonMapper;
import com.xyw.console.asset.mapper.CompanyProfileMapper;
import com.xyw.console.asset.mapper.DouyinAccountMapper;
import com.xyw.console.asset.mapper.PhoneAssetMapper;
import com.xyw.console.asset.mapper.WechatAccountMapper;
import com.xyw.console.asset.mapper.WecomAccountMapper;
import java.time.LocalDateTime;
import java.util.Collection;
......@@ -38,6 +44,9 @@ public class WecomAccountService {
private final PhoneAssetMapper phoneAssetMapper;
private final AssetDeviceMapper assetDeviceMapper;
private final CompanyPersonMapper companyPersonMapper;
/** 删除或换号时要确认那条自动新建的号码没有被别的资产接手,所以这两张表也得能查。 */
private final WechatAccountMapper wechatAccountMapper;
private final DouyinAccountMapper douyinAccountMapper;
/**
* 代码作用(白话):接收企微和关联资产表的数据库访问入口,供列表查询一次性解析名称。
......@@ -49,12 +58,16 @@ public class WecomAccountService {
CompanyProfileMapper companyProfileMapper,
PhoneAssetMapper phoneAssetMapper,
AssetDeviceMapper assetDeviceMapper,
CompanyPersonMapper companyPersonMapper) {
CompanyPersonMapper companyPersonMapper,
WechatAccountMapper wechatAccountMapper,
DouyinAccountMapper douyinAccountMapper) {
this.wecomAccountMapper = wecomAccountMapper;
this.companyProfileMapper = companyProfileMapper;
this.phoneAssetMapper = phoneAssetMapper;
this.assetDeviceMapper = assetDeviceMapper;
this.companyPersonMapper = companyPersonMapper;
this.wechatAccountMapper = wechatAccountMapper;
this.douyinAccountMapper = douyinAccountMapper;
}
/**
......@@ -66,14 +79,145 @@ public class WecomAccountService {
@Transactional
public WecomAccountResponse create(WecomAccountSaveRequest request) {
requireActiveCompanyProfile(request.companyProfileId());
requireActiveDevice(request.deviceId());
String phoneNumber = normalizePhoneNumber(request.phoneNumber());
PhoneAssetEntity phone = phoneAssetMapper.selectOne(new LambdaQueryWrapper<PhoneAssetEntity>().eq(PhoneAssetEntity::getDeleteTime, 0L).eq(PhoneAssetEntity::getPhoneNumber, phoneNumber));
boolean created = phone == null;
LocalDateTime now = LocalDateTime.now();
if (created) { phone = new PhoneAssetEntity(); phone.setPhoneNumber(phoneNumber); phone.setNumberType("EXTERNAL"); phone.setSourceAssetType("WECOM"); phone.setDisposalStatus("正常使用"); phone.setCreateTime(now); phone.setUpdateTime(now); phone.setDeleteTime(0L); phone.setLinkedWecomAccounts("[]"); phone.setLinkedWechatAccounts("[]"); phone.setLinkedDouyinAccounts("[]"); phone.setLinkedDomainAccounts("[]"); phone.setLinkedMerchants("[]"); if (phoneAssetMapper.insert(phone) != 1) throw new IllegalStateException("手机号资产新增失败"); }
WecomAccountEntity entity = new WecomAccountEntity(); entity.setWecomName(request.wecomName()); entity.setWecomAlias(request.wecomAlias()); entity.setWecomAccount(request.wecomAccount()); entity.setCompanyProfileId(request.companyProfileId()); entity.setPhoneAssetId(phone.getId()); entity.setPhoneLinkMode(created ? "CREATED" : "EXISTING"); entity.setRealNameOwner(request.realNameOwner()); entity.setRealNameOwnerStatus(request.realNameOwnerStatus() == null ? "在职" : request.realNameOwnerStatus()); entity.setGender(request.gender()); entity.setOperatorPersonId(request.operatorPersonId()); entity.setCreateTime(now); entity.setUpdateTime(now); entity.setDeleteTime(0L); if (wecomAccountMapper.insert(entity) != 1) throw new IllegalStateException("企业微信资产新增失败"); if (created) { phone.setSourceAssetId(entity.getId()); phoneAssetMapper.updateById(phone); } return toResponse(entity, Map.of(), Map.of(phone.getId(), phone.getPhoneNumber()), Map.of(), Map.of());
if (created) { phone = new PhoneAssetEntity(); phone.setPhoneNumber(phoneNumber); phone.setNumberType("EXTERNAL"); phone.setSourceAssetType("WECOM"); phone.setDisposalStatus("正常使用"); phone.setCreateTime(now); phone.setUpdateTime(now); phone.setDeleteTime(0L); phone.setLinkedWecomAccounts("[]"); phone.setLinkedWechatAccounts("[]"); phone.setLinkedDouyinAccounts("[]"); phone.setLinkedDomainAccounts("[]"); phone.setLinkedMerchants("[]"); if (phoneAssetMapper.insert(phone) != 1) throw new IllegalStateException("手机号码管理新增失败"); }
WecomAccountEntity entity = new WecomAccountEntity(); applyEditableFields(entity, request); entity.setPhoneAssetId(phone.getId()); entity.setPhoneLinkMode(created ? "CREATED" : "EXISTING"); entity.setCreateTime(now); entity.setUpdateTime(now); entity.setDeleteTime(0L); if (wecomAccountMapper.insert(entity) != 1) throw new IllegalStateException("企业微信资产新增失败"); if (created) { phone.setSourceAssetId(entity.getId()); phoneAssetMapper.updateById(phone); } return toResponse(entity, Map.of(), Map.of(phone.getId(), phone.getPhoneNumber()), Map.of(), Map.of());
}
/**
* 代码作用(白话):编辑一条有效企微资产;改了注册手机号时按新号重新关联,并清理当初为它自动新建、现在没人用的旧号码。
* 关联文件:WecomAccountController.java、WecomAccountSaveRequest.java、PhoneAssetEntity.java。
* 关联逻辑(调用链/数据流):PUT 表单 -> 注册主体校验 -> 手机号重新关联 -> 旧号清理 -> 企微更新 -> 响应。
*/
@Transactional
public WecomAccountResponse update(Long id, WecomAccountSaveRequest request) {
WecomAccountEntity entity = requireActiveAccount(id);
requireActiveCompanyProfile(request.companyProfileId());
requireActiveDevice(request.deviceId());
String phoneNumber = normalizePhoneNumber(request.phoneNumber());
Long previousPhoneAssetId = entity.getPhoneAssetId();
String previousLinkMode = entity.getPhoneLinkMode();
LocalDateTime now = LocalDateTime.now();
// 号码没变就不动关联,避免把 EXISTING 误改成 CREATED、或把还在用的号码当成旧号清理掉。
if (!phoneNumber.equals(currentPhoneNumber(previousPhoneAssetId))) {
PhoneAssetEntity phone = attachPhone(phoneNumber, entity.getId(), now);
entity.setPhoneAssetId(phone.getId());
entity.setPhoneLinkMode(isCreatedFor(phone, entity.getId()) ? "CREATED" : "EXISTING");
releaseAutoCreatedPhone(previousPhoneAssetId, previousLinkMode, entity.getId(), now);
}
applyEditableFields(entity, request);
entity.setUpdateTime(now);
if (wecomAccountMapper.updateById(entity) != 1) throw new WecomAccountNotFoundException("企业微信资产不存在或已删除");
return toResponse(entity, Map.of(), phoneNumbers(singleId(entity.getPhoneAssetId())), Map.of(), Map.of());
}
/**
* 代码作用(白话):软删除一条企微资产,并顺带清理当初为它自动新建、现在没人用的外部号码。
* 关联文件:WecomAccountController.java、PhoneAssetEntity.java。
* 关联逻辑(调用链/数据流):DELETE -> 企微 deleteTime -> 自动新建号码判定 -> 一并软删或保留。
*/
@Transactional
public void softDelete(Long id) {
WecomAccountEntity entity = requireActiveAccount(id);
LocalDateTime now = LocalDateTime.now();
entity.setDeleteTime(System.currentTimeMillis());
entity.setUpdateTime(now);
if (wecomAccountMapper.updateById(entity) != 1) throw new WecomAccountNotFoundException("企业微信资产不存在或已删除");
releaseAutoCreatedPhone(entity.getPhoneAssetId(), entity.getPhoneLinkMode(), entity.getId(), now);
}
/** 代码作用(白话):读取一条仍有效的企微资产,供编辑和删除共用。关联文件:WecomAccountMapper.java、WecomAccountExceptionHandler.java。关联逻辑(调用链/数据流):Service 查询 -> 无记录 -> 404。 */
private WecomAccountEntity requireActiveAccount(Long id) {
WecomAccountEntity entity = wecomAccountMapper.selectOne(new LambdaQueryWrapper<WecomAccountEntity>().eq(WecomAccountEntity::getId, id).eq(WecomAccountEntity::getDeleteTime, 0L));
if (entity == null) throw new WecomAccountNotFoundException("企业微信资产不存在或已删除");
return entity;
}
/** 代码作用(白话):读取当前关联手机号的号码文本,用于判断编辑时号码到底有没有变。关联文件:PhoneAssetEntity.java。关联逻辑(调用链/数据流):phoneAssetId -> 号码 -> 与表单比较。 */
private String currentPhoneNumber(Long phoneAssetId) {
if (phoneAssetId == null) return null;
PhoneAssetEntity phone = phoneAssetMapper.selectOne(new LambdaQueryWrapper<PhoneAssetEntity>().eq(PhoneAssetEntity::getId, phoneAssetId).eq(PhoneAssetEntity::getDeleteTime, 0L));
return phone == null ? null : phone.getPhoneNumber();
}
/**
* 代码作用(白话):把一个号码解析成可关联的手机号码管理,库里没有就按外部号码新建一条。
* 关联文件:PhoneAssetEntity.java、PhoneAssetMapper.java。
* 关联逻辑(调用链/数据流):号码 -> 查已有 -> 命中则复用,未命中则新建 EXTERNAL/WECOM 记录。
*/
private PhoneAssetEntity attachPhone(String phoneNumber, Long wecomAccountId, LocalDateTime now) {
PhoneAssetEntity phone = phoneAssetMapper.selectOne(new LambdaQueryWrapper<PhoneAssetEntity>().eq(PhoneAssetEntity::getDeleteTime, 0L).eq(PhoneAssetEntity::getPhoneNumber, phoneNumber));
if (phone != null) return phone;
phone = new PhoneAssetEntity();
phone.setPhoneNumber(phoneNumber); phone.setNumberType("EXTERNAL"); phone.setSourceAssetType("WECOM"); phone.setSourceAssetId(wecomAccountId); phone.setDisposalStatus("正常使用");
phone.setCreateTime(now); phone.setUpdateTime(now); phone.setDeleteTime(0L);
phone.setLinkedWecomAccounts("[]"); phone.setLinkedWechatAccounts("[]"); phone.setLinkedDouyinAccounts("[]"); phone.setLinkedDomainAccounts("[]"); phone.setLinkedMerchants("[]");
if (phoneAssetMapper.insert(phone) != 1) throw new IllegalStateException("手机号码管理新增失败");
return phone;
}
/**
* 代码作用(白话):判断一条手机号码管理是不是当初专门为这条企微自动新建的外部号码。
* 关联文件:PhoneAssetEntity.java、WecomAccountService.java。
* 关联逻辑(调用链/数据流):手机号码管理 -> 来源标记比对 -> 是否可随企微一起清理。
*/
private boolean isCreatedFor(PhoneAssetEntity phone, Long wecomAccountId) {
return phone != null && "EXTERNAL".equals(phone.getNumberType()) && "WECOM".equals(phone.getSourceAssetType()) && wecomAccountId.equals(phone.getSourceAssetId());
}
/**
* 代码作用(白话):换号或删除企微后,把当初为它自动新建、现在已经没人引用的外部号码一并软删。
* 关联文件:PhoneAssetEntity.java、WecomAccountMapper.java。
* 关联逻辑(调用链/数据流):旧 phoneAssetId -> CREATED 与来源校验 -> 其余引用计数 -> 软删或保留。
*
* 三道闸门缺一不可:只有本企微自己创建的号码才轮得到清理(复用的已有号码是独立资产,绝不能碰);
* 来源标记要对得上,防止误删同名但另有出处的号码;最后还要确认没有别的企微、微信、抖音仍在引用它。
*/
private void releaseAutoCreatedPhone(Long phoneAssetId, String linkMode, Long wecomAccountId, LocalDateTime now) {
if (phoneAssetId == null || !"CREATED".equals(linkMode)) return;
PhoneAssetEntity phone = phoneAssetMapper.selectOne(new LambdaQueryWrapper<PhoneAssetEntity>().eq(PhoneAssetEntity::getId, phoneAssetId).eq(PhoneAssetEntity::getDeleteTime, 0L));
if (!isCreatedFor(phone, wecomAccountId) || isPhoneStillReferenced(phoneAssetId, wecomAccountId)) return;
phone.setDeleteTime(System.currentTimeMillis());
phone.setUpdateTime(now);
phoneAssetMapper.updateById(phone);
}
/** 代码作用(白话):确认除本企微之外,还有没有别的有效资产在用这个手机号。关联文件:WecomAccountMapper.java、WechatAccountMapper.java、DouyinAccountMapper.java。关联逻辑(调用链/数据流):phoneAssetId -> 三张表引用计数 -> 是否允许清理。 */
private boolean isPhoneStillReferenced(Long phoneAssetId, Long excludedWecomAccountId) {
if (wecomAccountMapper.selectCount(new LambdaQueryWrapper<WecomAccountEntity>().eq(WecomAccountEntity::getPhoneAssetId, phoneAssetId).eq(WecomAccountEntity::getDeleteTime, 0L).ne(WecomAccountEntity::getId, excludedWecomAccountId)) > 0) return true;
if (wechatAccountMapper.selectCount(new LambdaQueryWrapper<WechatAccountEntity>().eq(WechatAccountEntity::getPhoneAssetId, phoneAssetId).eq(WechatAccountEntity::getDeleteTime, 0L)) > 0) return true;
return douyinAccountMapper.selectCount(new LambdaQueryWrapper<DouyinAccountEntity>().eq(DouyinAccountEntity::getPhoneAssetId, phoneAssetId).eq(DouyinAccountEntity::getDeleteTime, 0L)) > 0;
}
/** 代码作用(白话):把表单允许提交的字段写入企微实体,新增和编辑共用同一套赋值。关联文件:WecomAccountSaveRequest.java、WecomAccountEntity.java。关联逻辑(调用链/数据流):弹窗表单 -> DTO -> Entity -> Mapper 保存。 */
private void applyEditableFields(WecomAccountEntity entity, WecomAccountSaveRequest request) {
entity.setWecomName(request.wecomName()); entity.setWecomAlias(request.wecomAlias()); entity.setWecomAccount(request.wecomAccount());
entity.setCompanyProfileId(request.companyProfileId()); entity.setRealNameOwner(request.realNameOwner());
entity.setRealNameOwnerStatus(request.realNameOwnerStatus() == null ? "在职" : request.realNameOwnerStatus());
entity.setGender(request.gender()); entity.setDeviceId(request.deviceId()); entity.setOperatorPersonId(request.operatorPersonId());
}
/** 代码作用(白话):确认选中的关联设备仍然有效,阻止伪造或已删除的设备 ID 写入。关联文件:AssetDeviceMapper.java、WecomAccountSaveRequest.java。关联逻辑(调用链/数据流):表单 deviceId -> 有效性查询 -> 参数错误或继续保存。 */
private void requireActiveDevice(Long deviceId) {
if (deviceId == null) return;
if (assetDeviceMapper.selectOne(new LambdaQueryWrapper<AssetDeviceEntity>().eq(AssetDeviceEntity::getId, deviceId).eq(AssetDeviceEntity::getDeleteTime, 0L)) == null) {
throw new IllegalArgumentException("关联设备不存在或已删除");
}
}
/** 代码作用(白话):按名称搜索可关联的有效设备,供表单下拉远程查询。关联文件:WecomAccountController.java、AssetDeviceEntity.java。关联逻辑(调用链/数据流):下拉输入 -> GET lookup -> 设备选项。 */
public List<DeviceAssetLookupResponse> searchDevices(String keyword) {
LambdaQueryWrapper<AssetDeviceEntity> query = new LambdaQueryWrapper<AssetDeviceEntity>().eq(AssetDeviceEntity::getDeleteTime, 0L).like(hasText(keyword), AssetDeviceEntity::getDeviceName, keyword).orderByDesc(AssetDeviceEntity::getId);
return assetDeviceMapper.selectList(query).stream().limit(20).map(item -> new DeviceAssetLookupResponse(item.getId(), item.getDeviceName())).toList();
}
/** 代码作用(白话):把单个可空 ID 转成批量查询需要的集合形状。关联文件:WecomAccountService.java。关联逻辑(调用链/数据流):单条结果 -> ID 集合 -> 名称查询。 */
private Set<Long> singleId(Long id) { return id == null ? Set.of() : Set.of(id); }
/** Code purpose (plain language): finds active company profiles for the registration-subject selector. Related files: WecomAccountController.java, CompanyProfileEntity.java. Data flow: form keyword -> GET lookup -> mapper -> compact DTO. */
public List<CompanyProfileLookupResponse> searchCompanyProfiles(String keyword) {
LambdaQueryWrapper<CompanyProfileEntity> query = new LambdaQueryWrapper<CompanyProfileEntity>().eq(CompanyProfileEntity::getDeleteTime, 0L);
......@@ -151,7 +295,7 @@ public class WecomAccountService {
return wrapper.orderByDesc(WecomAccountEntity::getId);
}
/** 代码作用(白话):找出手机号包含统一搜索词的有效手机号资产 ID。关联文件:PhoneAssetEntity.java、WecomAccountPageQuery.java。关联逻辑(调用链/数据流):统一搜索词 -> 手机号模糊查询 -> 企业微信关联手机号条件。 */
/** 代码作用(白话):找出手机号包含统一搜索词的有效手机号码管理 ID。关联文件:PhoneAssetEntity.java、WecomAccountPageQuery.java。关联逻辑(调用链/数据流):统一搜索词 -> 手机号模糊查询 -> 企业微信关联手机号条件。 */
private Set<Long> matchingPhoneAssetIds(String keyword) {
return phoneAssetMapper.selectList(new LambdaQueryWrapper<PhoneAssetEntity>()
.eq(PhoneAssetEntity::getDeleteTime, 0L)
......@@ -194,7 +338,7 @@ public class WecomAccountService {
}
/**
* 代码作用(白话):批量读取手机号资产号码,供手机号资产 ID 在页面显示为手机号(ID)。
* 代码作用(白话):批量读取手机号码管理号码,供手机号码管理 ID 在页面显示为手机号(ID)。
* 关联文件:PhoneAssetEntity.java、PhoneAssetMapper.java、WecomAccountResponse.java。
* 关联逻辑(调用链/数据流):phoneAssetId 集合 -> as_phone_asset -> phoneNumber。
*/
......
......@@ -18,7 +18,7 @@ public class PagePermissionService {
public static final String PHONE = "phone-assets";
public static final String COMPANY_PROFILE = "company-profile";
public static final String ALERTS = "alerts";
private static final Map<String, String> PAGES = Map.of(OVERVIEW, "总览", DOMAIN, "域名资料", WECOM, "企微资料", PHONE, "手机号资产", COMPANY_PROFILE, "公司档案", ALERTS, "提醒中心");
private static final Map<String, String> PAGES = Map.of(OVERVIEW, "总览", DOMAIN, "域名资料", WECOM, "企微资料", PHONE, "手机号码管理", COMPANY_PROFILE, "公司档案", ALERTS, "提醒中心");
private final ObjectMapper objectMapper;
/** 代码作用(白话):接收 JSON 工具以读取数据库权限映射;关联文件:SystemUserEntity.java。关联逻辑(调用链/数据流):page_permissions JSON -> 权限 Map -> Controller 判定。 */
......
......@@ -6,10 +6,12 @@ import org.springframework.jdbc.BadSqlGrammarException;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.web.HttpRequestMethodNotSupportedException;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.servlet.resource.NoResourceFoundException;
/** 文件用途(白话):把未被业务模块处理的数据库和服务器异常统一转换为页面可展示的 JSON 错误,避免浏览器把非认证故障误认为未登录。 */
@RestControllerAdvice
......@@ -52,6 +54,27 @@ public class GlobalExceptionHandler {
return ApiResponse.error(400, "请求格式不正确,请刷新页面后重试");
}
/**
* 代码作用(白话):请求打到了不支持该方法的接口时返回 405,而不是让它冒充服务器故障。
* 关联文件:各 Controller、前端 API 客户端。
* 关联逻辑(调用链/数据流):路由无匹配方法 -> 本方法 -> 405 JSON -> 前端提示接口不可用。
*
* 修复要点:此前没有本处理器,前后端版本不同步(后端没重启、新接口还没加载)时浏览器只会收到
* "服务器处理失败,请稍后重试",把一眼可辨的"接口还不存在"伪装成线上故障,排查时会往完全错误的方向查。
*/
@ExceptionHandler(HttpRequestMethodNotSupportedException.class)
@ResponseStatus(HttpStatus.METHOD_NOT_ALLOWED)
public ApiResponse<Void> methodNotSupported(HttpRequestMethodNotSupportedException error) {
return ApiResponse.error(405, "接口不支持该请求方式,请确认前后端版本一致");
}
/** 代码作用(白话):请求打到不存在的路径时返回 404,同样避免被当成服务器故障;关联文件:各 Controller、前端 API 客户端;关联逻辑(调用链/数据流):无匹配路由 -> 本方法 -> 404 JSON -> 前端提示。 */
@ExceptionHandler(NoResourceFoundException.class)
@ResponseStatus(HttpStatus.NOT_FOUND)
public ApiResponse<Void> noResource(NoResourceFoundException error) {
return ApiResponse.error(404, "接口不存在,请确认前后端版本一致");
}
/** 代码作用(白话):给未预期的服务端异常提供安全的通用提示,不把 SQL、路径或堆栈暴露给浏览器;关联文件:各 Controller、前端 API 客户端;关联逻辑(调用链/数据流):未知异常 -> 本方法 -> ApiResponse -> 页面错误提示。 */
@ExceptionHandler(Exception.class)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
......
spring:
application:
name: xyw-console-backend
config:
import: optional:file:.env[.properties]
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: ${XYW_DB_URL}
username: ${XYW_DB_USERNAME}
password: ${XYW_DB_PASSWORD}
server:
port: 7689
xyw:
auth:
# 部署环境必须提供至少 32 字符的随机签名密钥;仓库不保存真实密钥。
jwt-secret: ${XYW_AUTH_JWT_SECRET:}
session-hours: 8
# 登录失败统一补齐到该毫秒数,消除"账号不存在/账号停用/密码错误"之间的响应耗时差异,防止据此枚举有效账号。
# 取值必须高于最慢失败路径(查库 + BCrypt 比对),本机实测 cost 12 比对约 244ms,故默认 400ms。
login-failure-min-millis: ${XYW_AUTH_LOGIN_FAILURE_MIN_MILLIS:400}
# HTTPS 部署设为 true,避免浏览器通过非加密连接发送会话 Cookie。
cookie-secure: ${XYW_AUTH_COOKIE_SECURE:false}
mybatis-plus:
configuration:
map-underscore-to-camel-case: true
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
......@@ -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);}
}
......@@ -24,7 +24,7 @@ import org.springframework.test.web.servlet.setup.MockMvcBuilders;
class PhoneAssetControllerTest {
/**
* 代码作用(白话):证明浏览器可以通过新增接口创建手机号资产
* 代码作用(白话):证明浏览器可以通过新增接口创建手机号码管理
* 关联文件:PhoneAssetController.java、PhoneAssetService.java、PhoneAssetSaveRequest.java。
* 关联逻辑(调用链/数据流):POST /api/phone-assets -> Controller -> Service -> Mapper -> as_phone_asset。
*/
......
......@@ -48,6 +48,24 @@ class WecomAccountControllerTest {
}
/**
* 代码作用(白话):证明编辑和删除都必须先通过企微页的 EDIT 权限,只读账号无法绕过侧边栏直接调接口。
* 关联文件:WecomAccountController.java、PagePermissionService.java。
* 关联逻辑(调用链/数据流):PUT/DELETE -> require(WECOM, EDIT) -> 拒绝时业务服务完全不被触碰。
*/
@Test
void blocksEditAndDeleteWithoutEditPermission() {
WecomAccountService service = org.mockito.Mockito.mock(WecomAccountService.class);
PagePermissionService permissions = org.mockito.Mockito.mock(PagePermissionService.class);
org.mockito.Mockito.doThrow(new org.springframework.security.access.AccessDeniedException("无权限"))
.when(permissions).require(PagePermissionService.WECOM, "EDIT");
WecomAccountController controller = new WecomAccountController(service, permissions);
org.junit.jupiter.api.Assertions.assertThrows(org.springframework.security.access.AccessDeniedException.class,
() -> controller.update(1L, new com.xyw.console.asset.dto.WecomAccountSaveRequest("名称", null, null, null, "13812345678", null, null, null, null, null)));
org.junit.jupiter.api.Assertions.assertThrows(org.springframework.security.access.AccessDeniedException.class, () -> controller.delete(1L));
org.mockito.Mockito.verifyNoInteractions(service);
}
/**
* 代码作用(白话):构造使用真实 Service 的 Controller,避免只验证模拟返回值。
* 关联文件:WecomAccountController.java、WecomAccountService.java、各资产 Mapper。
* 关联逻辑(调用链/数据流):测试 HTTP 请求 -> Controller -> Service -> Mapper 模拟数据库结果。
......@@ -67,7 +85,9 @@ class WecomAccountControllerTest {
org.mockito.Mockito.when(assetDeviceMapper.selectList(org.mockito.ArgumentMatchers.any())).thenReturn(List.of(device()));
org.mockito.Mockito.when(companyPersonMapper.selectList(org.mockito.ArgumentMatchers.any())).thenReturn(List.of(operatorPerson()));
return new WecomAccountController(new WecomAccountService(
wecomMapper, companyProfileMapper, phoneAssetMapper, assetDeviceMapper, companyPersonMapper), org.mockito.Mockito.mock(PagePermissionService.class));
wecomMapper, companyProfileMapper, phoneAssetMapper, assetDeviceMapper, companyPersonMapper,
org.mockito.Mockito.mock(com.xyw.console.asset.mapper.WechatAccountMapper.class),
org.mockito.Mockito.mock(com.xyw.console.asset.mapper.DouyinAccountMapper.class)), org.mockito.Mockito.mock(PagePermissionService.class));
}
/**
......@@ -108,9 +128,9 @@ class WecomAccountControllerTest {
}
/**
* 代码作用(白话):构造手机号资产关联测试数据。
* 代码作用(白话):构造手机号码管理关联测试数据。
* 关联文件:PhoneAssetEntity.java、WecomAccountService.java。
* 关联逻辑(调用链/数据流):手机号资产 ID -> 手机号 -> JSON 字段 phoneNumber。
* 关联逻辑(调用链/数据流):手机号码管理 ID -> 手机号 -> JSON 字段 phoneNumber。
*/
private PhoneAssetEntity phoneAsset() {
PhoneAssetEntity entity = new PhoneAssetEntity();
......
package com.xyw.console.asset.entity;
import static org.junit.jupiter.api.Assertions.assertFalse;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.metadata.TableInfo;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
/**
* 文件用途(白话):验证"业务上允许清空"的列真的会出现在 UPDATE 语句里。
*
* 为什么要有这个测试:MyBatis-Plus 的 updateById 默认用 FieldStrategy.NOT_NULL,
* 实体里值为 null 的字段会被整个从 SET 子句里剔掉。表现极具欺骗性——接口返回成功、
* update_time 也更新了,唯独那一列的旧值纹丝不动,刷新后又冒出来。
*
* 这个测试直接检查生成的 SQL 片段,而不是 mock 掉 Mapper:
* 设备资产"移除图片保存后列表还显示旧图"就是因为原来的 Service 测试 mock 了 Mapper、
* SQL 从头到尾没被验证过,才让 bug 一路漏到线上。
*/
class NullableColumnUpdateTest {
/** 代码作用(白话):逐个确认可清空列不会被 null 判断包裹。关联文件:各资产 Entity、对应 Service 的 update 方法。关联逻辑(调用链/数据流):表单清空 -> entity 字段置 null -> updateById -> SET 子句必须包含该列。 */
@DisplayName("可清空的列必须留在 UPDATE 的 SET 子句里")
@ParameterizedTest(name = "{0}.{1}")
@CsvSource({
// 设备资产:移除图片、清空使用人
"AssetDeviceEntity, image_attachment_1",
"AssetDeviceEntity, image_attachment_2",
"AssetDeviceEntity, user_person_id",
// 手机号码管理:运营商、ICCID、实名人、管理模式、关联设备都能在弹窗里清空
"PhoneAssetEntity, card_type",
"PhoneAssetEntity, iccid",
"PhoneAssetEntity, real_name_owner",
"PhoneAssetEntity, management_type",
"PhoneAssetEntity, device_id",
// 这三类目前还没有编辑接口,先把规矩立住,补编辑功能时不会重蹈覆辙
"WecomAccountEntity, device_id",
"WechatAccountEntity, device_id",
"DouyinAccountEntity, device_id"
})
void keepsNullableColumnsInsideTheUpdateStatement(String entityName, String column) throws Exception {
Class<?> entity = Class.forName(getClass().getPackageName() + "." + entityName.trim());
TableInfo table = TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""), entity);
String assignment = table.getAllSqlSet(false, "et.").lines().filter(line -> line.contains(column.trim() + "=#{et.")).findFirst()
.orElseThrow(() -> new AssertionError(entityName + " 的 " + column + " 没有出现在 UPDATE 的 SET 子句里"));
// NOT_NULL 策略会把赋值包进 <if test="et['xxx'] != null">,一旦出现就说明置空又会被悄悄丢弃。
// 按整行判断而不是匹配具体表达式:MyBatis-Plus 换个写法(et.x / et['x'])断言也不会失效。
assertFalse(assignment.contains("<if"), entityName + " 的 " + column + " 被 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);}}
}
......@@ -3,8 +3,10 @@ package com.xyw.console.asset.service;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.when;
import static org.mockito.Mockito.verify;
......@@ -18,17 +20,31 @@ import com.xyw.console.asset.entity.CompanyPersonEntity;
import com.xyw.console.asset.entity.CompanyProfileEntity;
import com.xyw.console.asset.entity.PhoneAssetEntity;
import com.xyw.console.asset.entity.WecomAccountEntity;
import com.xyw.console.asset.exception.WecomAccountNotFoundException;
import com.xyw.console.asset.mapper.AssetDeviceMapper;
import com.xyw.console.asset.mapper.CompanyPersonMapper;
import com.xyw.console.asset.mapper.CompanyProfileMapper;
import com.xyw.console.asset.mapper.DouyinAccountMapper;
import com.xyw.console.asset.mapper.PhoneAssetMapper;
import com.xyw.console.asset.mapper.WechatAccountMapper;
import com.xyw.console.asset.mapper.WecomAccountMapper;
import java.time.LocalDateTime;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
class WecomAccountServiceTest {
/** 代码作用(白话):构造只关心当前场景的企微服务,微信和抖音引用检查默认为"没人在用"。关联文件:WecomAccountService.java。关联逻辑(调用链/数据流):测试 -> Mock Mapper -> Service 规则。 */
private WecomAccountService service(WecomAccountMapper wecom, CompanyProfileMapper company, PhoneAssetMapper phone, AssetDeviceMapper device, CompanyPersonMapper person) {
return service(wecom, company, phone, device, person, mock(WechatAccountMapper.class), mock(DouyinAccountMapper.class));
}
/** 代码作用(白话):在需要模拟"号码仍被别的资产引用"时,允许测试自己接管微信和抖音的计数。关联文件:WecomAccountService.java。关联逻辑(调用链/数据流):引用计数 -> 旧号是否可清理。 */
private WecomAccountService service(WecomAccountMapper wecom, CompanyProfileMapper company, PhoneAssetMapper phone, AssetDeviceMapper device, CompanyPersonMapper person, WechatAccountMapper wechat, DouyinAccountMapper douyin) {
return new WecomAccountService(wecom, company, phone, device, person, wechat, douyin);
}
/**
* 代码作用(白话):证明企微列表会把当前页关联 ID 转成可读名称,同时保留 ID 且不产生删除标记字段。
* 关联文件:WecomAccountService.java、WecomAccountResponse.java、WecomAccountMapper.java。
......@@ -47,8 +63,7 @@ class WecomAccountServiceTest {
when(assetDeviceMapper.selectList(any())).thenReturn(List.of(device()));
when(companyPersonMapper.selectList(any())).thenReturn(List.of(operatorPerson()));
WecomAccountPageResponse result = new WecomAccountService(
wecomMapper, companyProfileMapper, phoneAssetMapper, assetDeviceMapper, companyPersonMapper)
WecomAccountPageResponse result = service(wecomMapper, companyProfileMapper, phoneAssetMapper, assetDeviceMapper, companyPersonMapper)
.page(new WecomAccountPageQuery(null, null, null, null, null, null, null));
WecomAccountResponse record = result.records().get(0);
......@@ -84,8 +99,7 @@ class WecomAccountServiceTest {
when(assetDeviceMapper.selectList(any())).thenReturn(List.of());
when(companyPersonMapper.selectList(any())).thenReturn(List.of());
WecomAccountResponse record = new WecomAccountService(
wecomMapper, companyProfileMapper, phoneAssetMapper, assetDeviceMapper, companyPersonMapper)
WecomAccountResponse record = service(wecomMapper, companyProfileMapper, phoneAssetMapper, assetDeviceMapper, companyPersonMapper)
.page(new WecomAccountPageQuery(1, 20, null, null, null, null, null)).records().get(0);
assertEquals(10L, record.companyProfileId());
......@@ -139,7 +153,7 @@ class WecomAccountServiceTest {
}
/**
* 代码作用(白话):构造手机号资产名称,验证手机号资产 ID 能转成手机号。
* 代码作用(白话):构造手机号码管理名称,验证手机号码管理 ID 能转成手机号。
* 关联文件:PhoneAssetEntity.java、WecomAccountService.java。
* 关联逻辑(调用链/数据流):phoneAssetId -> PhoneAssetMapper -> phoneNumber。
*/
......@@ -179,7 +193,7 @@ class WecomAccountServiceTest {
void createsWithAnExistingPhoneAsset() {
WecomAccountMapper wecomMapper = mock(WecomAccountMapper.class); PhoneAssetMapper phoneMapper = mock(PhoneAssetMapper.class);
PhoneAssetEntity phone = phoneAsset(); when(phoneMapper.selectOne(any())).thenReturn(phone); when(wecomMapper.insert(any(WecomAccountEntity.class))).thenReturn(1);
WecomAccountResponse result = new WecomAccountService(wecomMapper, mock(CompanyProfileMapper.class), phoneMapper, mock(AssetDeviceMapper.class), mock(CompanyPersonMapper.class)).create(new WecomAccountSaveRequest("测试企微", "别名", null, null, "13812345678", null, null, null, null));
WecomAccountResponse result = service(wecomMapper, mock(CompanyProfileMapper.class), phoneMapper, mock(AssetDeviceMapper.class), mock(CompanyPersonMapper.class)).create(new WecomAccountSaveRequest("测试企微", "别名", null, null, "13812345678", null, null, null, null, null));
assertEquals(20L, result.phoneAssetId()); assertEquals("EXISTING", result.phoneLinkMode());
}
......@@ -188,7 +202,7 @@ class WecomAccountServiceTest {
void createsAnExternalPhoneAssetForANewNumber() {
WecomAccountMapper wecomMapper = mock(WecomAccountMapper.class); PhoneAssetMapper phoneMapper = mock(PhoneAssetMapper.class);
when(phoneMapper.selectOne(any())).thenReturn(null); when(phoneMapper.insert(any(PhoneAssetEntity.class))).thenAnswer(call -> { ((PhoneAssetEntity) call.getArgument(0)).setId(70L); return 1; }); when(wecomMapper.insert(any(WecomAccountEntity.class))).thenAnswer(call -> { ((WecomAccountEntity) call.getArgument(0)).setId(80L); return 1; });
WecomAccountResponse result = new WecomAccountService(wecomMapper, mock(CompanyProfileMapper.class), phoneMapper, mock(AssetDeviceMapper.class), mock(CompanyPersonMapper.class)).create(new WecomAccountSaveRequest("测试企微", null, null, null, "13912345678", null, null, null, null));
WecomAccountResponse result = service(wecomMapper, mock(CompanyProfileMapper.class), phoneMapper, mock(AssetDeviceMapper.class), mock(CompanyPersonMapper.class)).create(new WecomAccountSaveRequest("测试企微", null, null, null, "13912345678", null, null, null, null, null));
assertEquals("CREATED", result.phoneLinkMode()); verify(phoneMapper).updateById(any(PhoneAssetEntity.class));
}
......@@ -204,12 +218,151 @@ class WecomAccountServiceTest {
PhoneAssetMapper phoneMapper = mock(PhoneAssetMapper.class);
when(companyProfileMapper.selectOne(any())).thenReturn(null);
WecomAccountService service = new WecomAccountService(
wecomMapper, companyProfileMapper, phoneMapper, mock(AssetDeviceMapper.class), mock(CompanyPersonMapper.class));
WecomAccountService service = service(wecomMapper, companyProfileMapper, phoneMapper, mock(AssetDeviceMapper.class), mock(CompanyPersonMapper.class));
IllegalArgumentException error = assertThrows(IllegalArgumentException.class, () -> service.create(
new WecomAccountSaveRequest("测试企微", null, null, 99L, "13812345678", null, null, null, null)));
new WecomAccountSaveRequest("测试企微", null, null, 99L, "13812345678", null, null, null, null, null)));
assertEquals("注册主体不存在或已删除", error.getMessage());
}
/** 代码作用(白话):换成一个库里已有的号码时,应该直接关联它并标记为复用。关联文件:WecomAccountService.java、PhoneAssetEntity.java。关联逻辑(调用链/数据流):PUT 新号 -> 命中已有资产 -> EXISTING。 */
@Test
void updateLinksAnExistingNumberAsAReusedPhone() {
WecomAccountMapper wecomMapper = mock(WecomAccountMapper.class); PhoneAssetMapper phoneMapper = mock(PhoneAssetMapper.class);
when(wecomMapper.selectOne(any())).thenReturn(activeAccount(5L, 20L, "CREATED"));
when(wecomMapper.updateById(any(WecomAccountEntity.class))).thenReturn(1);
// 依次是:查旧号(判断号码变没变)、按新号码查已有资产、清理旧号时再查一次旧号。
when(phoneMapper.selectOne(any())).thenReturn(phoneAsset(), existingPhone(90L, "13912345678"), null);
WecomAccountResponse result = service(wecomMapper, mock(CompanyProfileMapper.class), phoneMapper, mock(AssetDeviceMapper.class), mock(CompanyPersonMapper.class))
.update(5L, saveRequest("13912345678"));
assertEquals(90L, result.phoneAssetId()); assertEquals("EXISTING", result.phoneLinkMode());
verify(phoneMapper, never()).insert(any(PhoneAssetEntity.class));
}
/** 代码作用(白话):换成库里没有的号码时,应该新建一条外部号码并标记为新建。关联文件:WecomAccountService.java、PhoneAssetEntity.java。关联逻辑(调用链/数据流):PUT 新号 -> 查无资产 -> 新建 EXTERNAL -> CREATED。 */
@Test
void updateCreatesAnExternalPhoneForAnUnknownNumber() {
WecomAccountMapper wecomMapper = mock(WecomAccountMapper.class); PhoneAssetMapper phoneMapper = mock(PhoneAssetMapper.class);
when(wecomMapper.selectOne(any())).thenReturn(activeAccount(5L, 20L, "EXISTING"));
when(wecomMapper.updateById(any(WecomAccountEntity.class))).thenReturn(1);
when(phoneMapper.selectOne(any())).thenReturn(phoneAsset(), null, null);
when(phoneMapper.insert(any(PhoneAssetEntity.class))).thenAnswer(call -> { ((PhoneAssetEntity) call.getArgument(0)).setId(70L); return 1; });
WecomAccountResponse result = service(wecomMapper, mock(CompanyProfileMapper.class), phoneMapper, mock(AssetDeviceMapper.class), mock(CompanyPersonMapper.class))
.update(5L, saveRequest("13912345678"));
assertEquals(70L, result.phoneAssetId()); assertEquals("CREATED", result.phoneLinkMode());
}
/** 代码作用(白话):换号后,当初专门为这条企微新建的旧号码应该被一并软删,不留孤儿外部号码。关联文件:WecomAccountService.java。关联逻辑(调用链/数据流):旧号 CREATED + 来源匹配 + 无人引用 -> deleteTime 置位。 */
@Test
void updateSoftDeletesThePhoneItCreatedEarlier() {
WecomAccountMapper wecomMapper = mock(WecomAccountMapper.class); PhoneAssetMapper phoneMapper = mock(PhoneAssetMapper.class);
when(wecomMapper.selectOne(any())).thenReturn(activeAccount(5L, 20L, "CREATED"));
when(wecomMapper.updateById(any(WecomAccountEntity.class))).thenReturn(1);
PhoneAssetEntity autoCreated = autoCreatedPhone(20L, "13812345678", 5L);
when(phoneMapper.selectOne(any())).thenReturn(autoCreated, existingPhone(90L, "13912345678"), autoCreated);
service(wecomMapper, mock(CompanyProfileMapper.class), phoneMapper, mock(AssetDeviceMapper.class), mock(CompanyPersonMapper.class)).update(5L, saveRequest("13912345678"));
ArgumentCaptor<PhoneAssetEntity> saved = ArgumentCaptor.forClass(PhoneAssetEntity.class);
verify(phoneMapper).updateById(saved.capture());
assertEquals(20L, saved.getValue().getId());
assertTrue(saved.getValue().getDeleteTime() > 0L, "自动新建的旧号码应被软删");
}
/** 代码作用(白话):换号后,当初复用的已有号码是独立资产,绝不能被顺手删掉。关联文件:WecomAccountService.java。关联逻辑(调用链/数据流):旧号 EXISTING -> 跳过清理。 */
@Test
void updateKeepsAReusedPhoneAssetUntouched() {
WecomAccountMapper wecomMapper = mock(WecomAccountMapper.class); PhoneAssetMapper phoneMapper = mock(PhoneAssetMapper.class);
when(wecomMapper.selectOne(any())).thenReturn(activeAccount(5L, 20L, "EXISTING"));
when(wecomMapper.updateById(any(WecomAccountEntity.class))).thenReturn(1);
when(phoneMapper.selectOne(any())).thenReturn(phoneAsset(), existingPhone(90L, "13912345678"), null);
service(wecomMapper, mock(CompanyProfileMapper.class), phoneMapper, mock(AssetDeviceMapper.class), mock(CompanyPersonMapper.class)).update(5L, saveRequest("13912345678"));
verify(phoneMapper, never()).updateById(any(PhoneAssetEntity.class));
}
/** 代码作用(白话):旧号码若仍被微信资产引用,即使是自动新建的也必须保留。关联文件:WecomAccountService.java、WechatAccountMapper.java。关联逻辑(调用链/数据流):引用计数 > 0 -> 跳过清理。 */
@Test
void updateKeepsThePhoneWhenAnotherAssetStillReferencesIt() {
WecomAccountMapper wecomMapper = mock(WecomAccountMapper.class); PhoneAssetMapper phoneMapper = mock(PhoneAssetMapper.class); WechatAccountMapper wechatMapper = mock(WechatAccountMapper.class);
when(wecomMapper.selectOne(any())).thenReturn(activeAccount(5L, 20L, "CREATED"));
when(wecomMapper.updateById(any(WecomAccountEntity.class))).thenReturn(1);
when(wechatMapper.selectCount(any())).thenReturn(1L);
PhoneAssetEntity autoCreated = autoCreatedPhone(20L, "13812345678", 5L);
when(phoneMapper.selectOne(any())).thenReturn(autoCreated, existingPhone(90L, "13912345678"), autoCreated);
service(wecomMapper, mock(CompanyProfileMapper.class), phoneMapper, mock(AssetDeviceMapper.class), mock(CompanyPersonMapper.class), wechatMapper, mock(DouyinAccountMapper.class))
.update(5L, saveRequest("13912345678"));
verify(phoneMapper, never()).updateById(any(PhoneAssetEntity.class));
}
/** 代码作用(白话):号码没改动时不应触碰任何关联,避免把复用号码误标成新建或误删还在用的号码。关联文件:WecomAccountService.java。关联逻辑(调用链/数据流):号码相同 -> 跳过整段联动。 */
@Test
void updateSkipsPhoneLinkageWhenTheNumberIsUnchanged() {
WecomAccountMapper wecomMapper = mock(WecomAccountMapper.class); PhoneAssetMapper phoneMapper = mock(PhoneAssetMapper.class);
when(wecomMapper.selectOne(any())).thenReturn(activeAccount(5L, 20L, "CREATED"));
when(wecomMapper.updateById(any(WecomAccountEntity.class))).thenReturn(1);
when(phoneMapper.selectOne(any())).thenReturn(phoneAsset());
WecomAccountResponse result = service(wecomMapper, mock(CompanyProfileMapper.class), phoneMapper, mock(AssetDeviceMapper.class), mock(CompanyPersonMapper.class))
.update(5L, saveRequest("13812345678"));
assertEquals(20L, result.phoneAssetId()); assertEquals("CREATED", result.phoneLinkMode());
verify(phoneMapper, never()).insert(any(PhoneAssetEntity.class));
verify(phoneMapper, never()).updateById(any(PhoneAssetEntity.class));
}
/** 代码作用(白话):删除企微时,当初为它自动新建的号码应一并软删。关联文件:WecomAccountService.java。关联逻辑(调用链/数据流):DELETE -> 企微 deleteTime -> 号码 deleteTime。 */
@Test
void softDeleteAlsoRemovesTheAutoCreatedPhone() {
WecomAccountMapper wecomMapper = mock(WecomAccountMapper.class); PhoneAssetMapper phoneMapper = mock(PhoneAssetMapper.class);
when(wecomMapper.selectOne(any())).thenReturn(activeAccount(5L, 20L, "CREATED"));
when(wecomMapper.updateById(any(WecomAccountEntity.class))).thenReturn(1);
when(phoneMapper.selectOne(any())).thenReturn(autoCreatedPhone(20L, "13812345678", 5L));
service(wecomMapper, mock(CompanyProfileMapper.class), phoneMapper, mock(AssetDeviceMapper.class), mock(CompanyPersonMapper.class)).softDelete(5L);
ArgumentCaptor<PhoneAssetEntity> saved = ArgumentCaptor.forClass(PhoneAssetEntity.class);
verify(phoneMapper).updateById(saved.capture());
assertTrue(saved.getValue().getDeleteTime() > 0L, "自动新建的号码应随企微一起软删");
}
/** 代码作用(白话):删除企微时,复用的已有号码必须原样保留。关联文件:WecomAccountService.java。关联逻辑(调用链/数据流):DELETE + EXISTING -> 号码不动。 */
@Test
void softDeleteKeepsAReusedPhoneAsset() {
WecomAccountMapper wecomMapper = mock(WecomAccountMapper.class); PhoneAssetMapper phoneMapper = mock(PhoneAssetMapper.class);
when(wecomMapper.selectOne(any())).thenReturn(activeAccount(5L, 20L, "EXISTING"));
when(wecomMapper.updateById(any(WecomAccountEntity.class))).thenReturn(1);
service(wecomMapper, mock(CompanyProfileMapper.class), phoneMapper, mock(AssetDeviceMapper.class), mock(CompanyPersonMapper.class)).softDelete(5L);
verify(phoneMapper, never()).updateById(any(PhoneAssetEntity.class));
}
/** 代码作用(白话):编辑或删除不存在的企微应返回 404 语义的异常,而不是静默成功。关联文件:WecomAccountExceptionHandler.java。关联逻辑(调用链/数据流):查无记录 -> WecomAccountNotFoundException -> 404。 */
@Test
void rejectsEditingOrDeletingAMissingWecomAccount() {
WecomAccountMapper wecomMapper = mock(WecomAccountMapper.class);
when(wecomMapper.selectOne(any())).thenReturn(null);
WecomAccountService service = service(wecomMapper, mock(CompanyProfileMapper.class), mock(PhoneAssetMapper.class), mock(AssetDeviceMapper.class), mock(CompanyPersonMapper.class));
assertThrows(WecomAccountNotFoundException.class, () -> service.update(404L, saveRequest("13812345678")));
assertThrows(WecomAccountNotFoundException.class, () -> service.softDelete(404L));
}
/** 代码作用(白话):构造一条仍有效的企微记录,供编辑和删除场景复用。关联文件:WecomAccountEntity.java。关联逻辑(调用链/数据流):Mapper.selectOne -> requireActiveAccount -> 业务流程。 */
private WecomAccountEntity activeAccount(Long id, Long phoneAssetId, String linkMode) {
WecomAccountEntity entity = new WecomAccountEntity();
entity.setId(id); entity.setWecomName("原名称"); entity.setPhoneAssetId(phoneAssetId); entity.setPhoneLinkMode(linkMode); entity.setDeleteTime(0L);
return entity;
}
/** 代码作用(白话):构造一条与任何企微都无来源关系的普通手机号码管理。关联文件:PhoneAssetEntity.java。关联逻辑(调用链/数据流):号码查询 -> 复用为 EXISTING。 */
private PhoneAssetEntity existingPhone(Long id, String phoneNumber) {
PhoneAssetEntity entity = new PhoneAssetEntity();
entity.setId(id); entity.setPhoneNumber(phoneNumber); entity.setNumberType("SELF"); entity.setDeleteTime(0L);
return entity;
}
/** 代码作用(白话):构造一条当初由指定企微自动新建的外部号码。关联文件:PhoneAssetEntity.java。关联逻辑(调用链/数据流):来源标记 -> isCreatedFor 判定 -> 可清理。 */
private PhoneAssetEntity autoCreatedPhone(Long id, String phoneNumber, Long sourceWecomAccountId) {
PhoneAssetEntity entity = new PhoneAssetEntity();
entity.setId(id); entity.setPhoneNumber(phoneNumber); entity.setNumberType("EXTERNAL"); entity.setSourceAssetType("WECOM"); entity.setSourceAssetId(sourceWecomAccountId); entity.setDeleteTime(0L);
return entity;
}
/** 代码作用(白话):构造一份只关心手机号的编辑表单。关联文件:WecomAccountSaveRequest.java。关联逻辑(调用链/数据流):测试 -> DTO -> Service.update。 */
private WecomAccountSaveRequest saveRequest(String phoneNumber) {
return new WecomAccountSaveRequest("编辑后的企微", "别名", "account", null, phoneNumber, "张三", "在职", "男", null, null);
}
}
......@@ -6,7 +6,10 @@ import java.sql.SQLIntegrityConstraintViolationException;
import java.sql.SQLSyntaxErrorException;
import org.junit.jupiter.api.Test;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.http.HttpMethod;
import org.springframework.jdbc.BadSqlGrammarException;
import org.springframework.web.HttpRequestMethodNotSupportedException;
import org.springframework.web.servlet.resource.NoResourceFoundException;
/** 文件用途(白话):验证未处理的数据库异常会被转换成页面可以直接展示的统一错误消息。 */
class GlobalExceptionHandlerTest {
......@@ -27,4 +30,22 @@ class GlobalExceptionHandlerTest {
assertEquals(500, response.code());
assertEquals("数据库字段未同步,请完成数据库迁移后重试", response.message());
}
/**
* 代码作用(白话):证明"接口还不存在"会返回 405 而不是 500,避免把前后端版本不同步当成服务器故障排查。
* 关联文件:GlobalExceptionHandler.java、各 Controller。
* 关联逻辑(调用链/数据流):无匹配请求方式 -> Advice -> 405 JSON -> 前端提示版本不一致。
*/
@Test void unsupportedMethodReturnsMethodNotAllowed() {
ApiResponse<Void> response = new GlobalExceptionHandler().methodNotSupported(new HttpRequestMethodNotSupportedException("PUT"));
assertEquals(405, response.code());
assertEquals("接口不支持该请求方式,请确认前后端版本一致", response.message());
}
/** 代码作用(白话):证明不存在的路径返回 404 而不是 500。关联文件:GlobalExceptionHandler.java。关联逻辑(调用链/数据流):无匹配路由 -> Advice -> 404 JSON -> 前端提示。 */
@Test void missingRouteReturnsNotFound() {
ApiResponse<Void> response = new GlobalExceptionHandler().noResource(new NoResourceFoundException(HttpMethod.GET, "/api/does-not-exist"));
assertEquals(404, response.code());
assertEquals("接口不存在,请确认前后端版本一致", response.message());
}
}
......@@ -30,5 +30,5 @@ export default {
return { authState, logout, canVisit, isAdministrator, retryConnection };
},
template: `<main v-if="authState.connectionError" class="auth-network-error" role="alert"><section class="auth-network-error__panel"><h1>网络连接异常</h1><p>暂时无法确认登录状态,请检查网络后重试。</p><button type="button" @click="retryConnection">重新连接</button></section></main><main v-else-if="!authState.ready" class="auth-loading" role="status" aria-live="polite">正在验证登录状态…</main><RouterView v-else-if="$route.path === '/login'" /><div v-else class="app-shell"><aside class="sidebar"><h1>学有为资产后台</h1><nav aria-label="主导航"><RouterLink v-if="canVisit('overview')" to="/overview">总览</RouterLink><RouterLink v-if="canVisit('domain')" to="/domain">域名资料</RouterLink><RouterLink v-if="canVisit('company-profile')" to="/company-profiles">公司档案</RouterLink><RouterLink v-if="canVisit('reference-wecom')" to="/reference/wecom">企微资料</RouterLink><RouterLink v-if="canVisit('phone-assets')" to="/phone-assets">手机号资产</RouterLink><RouterLink v-if="isAdministrator()" to="/device-assets">设备资产管理</RouterLink><RouterLink v-if="canVisit('alerts')" to="/alerts">提醒中心</RouterLink><RouterLink v-if="isAdministrator()" class="settings-link" to="/settings/users-permissions"><span aria-hidden="true">⚙</span> 账号与权限</RouterLink></nav><button class="logout-button" @click="logout">退出登录</button></aside><main class="content"><RouterView /></main></div>`
template: `<main v-if="authState.connectionError" class="auth-network-error" role="alert"><section class="auth-network-error__panel"><h1>网络连接异常</h1><p>暂时无法确认登录状态,请检查网络后重试。</p><button type="button" @click="retryConnection">重新连接</button></section></main><main v-else-if="!authState.ready" class="auth-loading" role="status" aria-live="polite">正在验证登录状态…</main><RouterView v-else-if="$route.path === '/login'" /><div v-else class="app-shell"><aside class="sidebar"><h1>学有为资产后台</h1><nav aria-label="主导航"><RouterLink v-if="canVisit('overview')" to="/overview">总览</RouterLink><RouterLink v-if="canVisit('domain')" to="/domain">域名资料</RouterLink><RouterLink v-if="canVisit('company-profile')" to="/company-profiles">公司档案</RouterLink><RouterLink v-if="canVisit('reference-wecom')" to="/reference/wecom">企业微信资产</RouterLink><RouterLink v-if="canVisit('phone-assets')" to="/phone-assets">手机号码管理</RouterLink><RouterLink v-if="isAdministrator()" to="/device-assets">设备资产管理</RouterLink><RouterLink v-if="canVisit('alerts')" to="/alerts">提醒中心</RouterLink><RouterLink v-if="isAdministrator()" class="settings-link" to="/settings/users-permissions"><span aria-hidden="true">⚙</span> 账号与权限</RouterLink></nav><button class="logout-button" @click="logout">退出登录</button></aside><main class="content"><RouterView /></main></div>`
};
......@@ -156,7 +156,7 @@ export default {
<section class="phone-asset-list-page company-profile-page">
<header class="phone-asset-list-page__header"><div><h2>公司档案</h2></div><el-button v-if="canEdit" class="phone-asset-list-page__add" type="primary" @click="openCreate">新增公司档案</el-button></header>
<section class="phone-asset-list-page__panel phone-asset-list-page__search"><el-form class="phone-asset-list-page__filters" @submit.prevent="submitSearch"><el-input v-model="filters.keyword" placeholder="公司名称、简称、信用代码、地址、联系人或联系方式" clearable @input="scheduleSearch" @clear="scheduleSearch" @keydown.enter.prevent="submitSearch" /><el-button @click="resetSearch">重置</el-button></el-form></section>
<section class="phone-asset-list-page__panel phone-asset-list-page__table"><header class="phone-asset-list-page__table-header"><h3>公司档案列表</h3><span>共 {{ total }} 条</span></header><div class="phone-asset-list-page__grid-wrap"><el-table v-loading="loading" :data="records" empty-text="暂无匹配数据" class="phone-asset-list-page__grid company-profile-page__grid"><el-table-column label="公司名称" min-width="200"><template #default="{ row }">{{ formatValue(row.companyName) }}</template></el-table-column><el-table-column label="公司简称" min-width="150"><template #default="{ row }">{{ formatValue(row.shortName) }}</template></el-table-column><el-table-column label="统一社会信用代码" min-width="210"><template #default="{ row }">{{ formatValue(row.unifiedSocialCreditCode) }}</template></el-table-column><el-table-column label="地址" min-width="220" show-overflow-tooltip><template #default="{ row }">{{ formatValue(row.address) }}</template></el-table-column><el-table-column label="联系人" min-width="130"><template #default="{ row }">{{ formatValue(row.contactName) }}</template></el-table-column><el-table-column label="联系方式" min-width="170"><template #default="{ row }">{{ formatValue(row.contactValue) }}</template></el-table-column><el-table-column label="创建时间" min-width="180"><template #default="{ row }">{{ formatValue(row.createTime) }}</template></el-table-column><el-table-column label="更新时间" min-width="180"><template #default="{ row }">{{ formatValue(row.updateTime) }}</template></el-table-column></el-table></div><app-pagination :total="total" :page="filters.page" :size="filters.size" @update:page="changePage" @update:size="changePageSize" /></section>
<section class="phone-asset-list-page__panel phone-asset-list-page__table"><div class="phone-asset-list-page__grid-wrap"><el-table v-loading="loading" :data="records" empty-text="暂无匹配数据" class="phone-asset-list-page__grid company-profile-page__grid"><el-table-column label="公司名称" min-width="200"><template #default="{ row }">{{ formatValue(row.companyName) }}</template></el-table-column><el-table-column label="公司简称" min-width="150"><template #default="{ row }">{{ formatValue(row.shortName) }}</template></el-table-column><el-table-column label="统一社会信用代码" min-width="210"><template #default="{ row }">{{ formatValue(row.unifiedSocialCreditCode) }}</template></el-table-column><el-table-column label="地址" min-width="220" show-overflow-tooltip><template #default="{ row }">{{ formatValue(row.address) }}</template></el-table-column><el-table-column label="联系人" min-width="130"><template #default="{ row }">{{ formatValue(row.contactName) }}</template></el-table-column><el-table-column label="联系方式" min-width="170"><template #default="{ row }">{{ formatValue(row.contactValue) }}</template></el-table-column><el-table-column label="创建时间" min-width="180"><template #default="{ row }">{{ formatValue(row.createTime) }}</template></el-table-column><el-table-column label="更新时间" min-width="180"><template #default="{ row }">{{ formatValue(row.updateTime) }}</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" modal-class="phone-asset-modal-mask" title="新增公司档案" width="560px">
<el-form class="phone-asset-modal__form" label-width="112px" @submit.prevent="submitCreate">
<el-form-item class="phone-asset-modal__form-row" label="公司名称" required><el-input v-model="form.companyName" maxlength="100" autocomplete="off" placeholder="请输入公司名称" /></el-form-item>
......
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}}
......@@ -5,7 +5,7 @@ import { authState } from '../auth/auth-store.js';
import { createPhoneAsset, deletePhoneAsset, listPhoneAssets, searchLinkableDevices, updatePhoneAsset } from './phone-api-client.js';
/**
* 代码作用(白话):显示手机号资产列表,并管理新增、编辑、保存和删除弹窗状态。
* 代码作用(白话):显示手机号码管理列表,并管理新增、编辑、保存和删除弹窗状态。
* 关联文件:phone-api-client.js、PhoneAssetController.java、app.css。
* 关联逻辑(调用链/数据流):路由进入 -> 页面方法 -> 前端接口客户端 -> 后端接口 -> 列表或弹窗更新。
*/
......@@ -25,9 +25,11 @@ export default {
const editingId = ref(null);
/** 关联设备下拉的候选项;编辑时先塞入当前行的设备,保证未搜索前也能显示名称而不是空白。 */
const deviceOptions = ref([]);
/** 代码作用(白话):把当前手机号资产权限转换为是否展示写操作;关联文件:auth-store.js、PhoneAssetController.java。关联逻辑(调用链/数据流):/api/auth/me -> EDIT 判断 -> 新增/编辑/删除控件 -> 后端二次校验。 */
/** 代码作用(白话):把当前手机号码管理权限转换为是否展示写操作;关联文件:auth-store.js、PhoneAssetController.java。关联逻辑(调用链/数据流):/api/auth/me -> EDIT 判断 -> 新增/编辑/删除控件 -> 后端二次校验。 */
const canEdit = computed(() => authState.user?.pagePermissions?.['phone-assets'] === 'EDIT');
const filters = reactive({ page: 1, size: 20, phoneNumber: '', iccid: '', realNameOwner: '', disposalStatus: 'ALL' });
/** 代码作用(白话):读取地址里带过来的号码,让企业微信资产页的「关联方式」链接跳过来时能直接定位到那一条。关联文件:WecomAccountView.js。关联逻辑(调用链/数据流):Hash 查询参数 -> 初始筛选值 -> 首次 loadPage。 */
const routedPhoneNumber = new URLSearchParams(window.location.hash.split('?')[1] || '').get('phoneNumber') || '';
const filters = reactive({ page: 1, size: 20, phoneNumber: routedPhoneNumber, iccid: '', realNameOwner: '', disposalStatus: 'ALL' });
let searchTimer = null;
let latestRequest = 0;
const form = reactive({
......@@ -41,7 +43,7 @@ export default {
});
/**
* 代码作用(白话):加载当前筛选条件下的一页手机号资产
* 代码作用(白话):加载当前筛选条件下的一页手机号码管理
* 关联文件:phone-api-client.js、PhoneAssetController.java。
* 关联逻辑(调用链/数据流):查询/分页/保存成功 -> listPhoneAssets -> GET 接口 -> records 与 total。
*/
......@@ -136,7 +138,7 @@ export default {
}
/**
* 代码作用(白话):把表单恢复成新增手机号资产时原有的默认值。
* 代码作用(白话):把表单恢复成新增手机号码管理时原有的默认值。
* 关联文件:PhoneAssetView.js、PhoneAssetSaveRequest.java。
* 关联逻辑(调用链/数据流):新增按钮 -> resetForm -> form -> 弹窗;保存成功后的下一次新增同样复用。
*/
......@@ -164,7 +166,7 @@ export default {
}
/**
* 代码作用(白话):打开空白的新增手机号资产弹窗。
* 代码作用(白话):打开空白的新增手机号码管理弹窗。
* 关联文件:PhoneAssetView.js、phone-api-client.js。
* 关联逻辑(调用链/数据流):点击新增 -> resetForm -> dialogVisible=true -> Element Plus 弹窗显示。
*/
......@@ -236,7 +238,7 @@ export default {
}
/**
* 代码作用(白话):要求用户确认后删除一条手机号资产,并按原逻辑刷新列表。
* 代码作用(白话):要求用户确认后删除一条手机号码管理,并按原逻辑刷新列表。
* 关联文件:phone-api-client.js、PhoneAssetController.java。
* 关联逻辑(调用链/数据流):删除按钮 -> 确认框 -> DELETE -> 成功提示 -> loadPage。
*/
......@@ -270,7 +272,7 @@ export default {
function changePageSize(size) { filters.size = size; filters.page = 1; loadPage(); }
/**
* 代码作用(白话):页面首次显示时请求手机号资产列表。
* 代码作用(白话):页面首次显示时请求手机号码管理列表。
* 关联文件:PhoneAssetView.js、phone-api-client.js。
* 关联逻辑(调用链/数据流):组件挂载 -> onMounted 回调 -> loadPage -> 列表渲染。
*/
......@@ -319,10 +321,10 @@ export default {
},
template: `
<el-config-provider :locale="elementLocale"><section class="phone-asset-page phone-asset-list-page">
<header class="phone-asset-list-page__header"><h2>手机号资产</h2><el-button v-if="canEdit" class="phone-asset-list-page__add" type="primary" @click="openCreate">新增手机号资产</el-button></header>
<section class="phone-asset-list-page__panel phone-asset-list-page__search" aria-label="筛选手机号资产"><el-form class="phone-asset-list-page__filters" @submit.prevent="submitSearch"><el-input v-model="filters.phoneNumber" maxlength="11" inputmode="numeric" placeholder="手机号前3位、后4位或完整号码" @input="limitSearchPhone" @keydown.enter.prevent="submitSearch" /><el-input v-model="filters.iccid" maxlength="20" placeholder="请输入 ICCID" @input="scheduleSearch" @keydown.enter.prevent="submitSearch" /><el-input v-model="filters.realNameOwner" placeholder="请输入实名人" @input="scheduleSearch" @keydown.enter.prevent="submitSearch" /><el-select v-model="filters.disposalStatus" placeholder="使用状态:" clearable @change="changeStatus" @clear="restoreAllDisposalStatuses"><template #prefix>使用状态:</template><el-option label="全部" value="ALL" /><el-option label="正常" value="正常使用" /><el-option label="闲置" value="闲置" /><el-option label="停机" value="停机" /><el-option label="注销" value="已注销" /></el-select><el-button @click="resetSearch">重置</el-button></el-form></section>
<section class="phone-asset-list-page__panel phone-asset-list-page__table"><header class="phone-asset-list-page__table-header"><h3>资产列表</h3><span>共 {{ total }} 条</span></header><div class="phone-asset-list-page__grid-wrap"><el-table v-loading="loading" :data="records" empty-text="暂无匹配数据" class="phone-asset-list-page__grid"><el-table-column label="手机号" min-width="180"><template #default="{ row }"><span class="phone-asset-list-page__phone"><span class="phone-asset-list-page__phone-text">{{ row.phoneNumber }}</span><el-popover v-if="row.iccid" placement="right" trigger="click" :width="250" popper-class="phone-asset-list-page__iccid-popper"><template #reference><button type="button" class="phone-asset-list-page__iccid-btn" title="查看 ICCID" aria-label="查看 ICCID"><svg viewBox="0 0 16 16" aria-hidden="true" focusable="false"><rect x="2" y="3.2" width="12" height="9.6" rx="1.6" /><path d="M5 6.4h6M5 9.2h3.5" /></svg></button></template><div class="phone-asset-list-page__iccid-pop"><span class="phone-asset-list-page__iccid-pop-label">ICCID</span><span class="phone-asset-list-page__iccid-pop-value">{{ row.iccid }}</span></div></el-popover></span></template></el-table-column><el-table-column prop="realNameOwner" label="实名人" min-width="150" show-overflow-tooltip /><el-table-column prop="cardType" label="运营商" min-width="110" show-overflow-tooltip /><el-table-column label="号码类型" min-width="120"><template #default="{ row }"><a v-if="row.numberType === 'EXTERNAL' && row.sourceAssetType === 'WECOM'" class="phone-asset-list-page__external-link" :href="'#/reference/wecom?phoneAssetId=' + row.id">外部号码</a><span v-else>{{ row.numberType === 'EXTERNAL' ? '外部号码' : '自有号码' }}</span></template></el-table-column><el-table-column prop="managementType" label="管理模式" min-width="120" show-overflow-tooltip /><el-table-column label="使用状态" min-width="130"><template #default="{ row }"><span class="phone-asset-list-page__status"><i :class="['phone-asset-list-page__status-dot', row.disposalStatus]"></i>{{ formatDisposalStatus(row.disposalStatus) }}</span></template></el-table-column><el-table-column label="关联设备" min-width="150" show-overflow-tooltip><template #default="{ row }">{{ row.deviceName || '-' }}</template></el-table-column><el-table-column v-if="canEdit" label="操作" width="120"><template #default="{ row }"><span class="phone-asset-list-page__actions"><el-button link @click="openEdit(row)">编辑</el-button><el-button link type="danger" @click="confirmDelete(row)">删除</el-button></span></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" modal-class="phone-asset-modal-mask" :title="editingId === null ? '新增手机号资产' : '编辑手机号资产'" width="560px" @opened="resetDialogScroll">
<header class="phone-asset-list-page__header"><h2>手机号码管理</h2><el-button v-if="canEdit" class="phone-asset-list-page__add" type="primary" @click="openCreate">新增手机号码管理</el-button></header>
<section class="phone-asset-list-page__panel phone-asset-list-page__search" aria-label="筛选手机号码管理"><el-form class="phone-asset-list-page__filters" @submit.prevent="submitSearch"><el-input v-model="filters.phoneNumber" maxlength="11" inputmode="numeric" placeholder="手机号前3位、后4位或完整号码" @input="limitSearchPhone" @keydown.enter.prevent="submitSearch" /><el-input v-model="filters.iccid" maxlength="20" placeholder="请输入 ICCID" @input="scheduleSearch" @keydown.enter.prevent="submitSearch" /><el-input v-model="filters.realNameOwner" placeholder="请输入实名人" @input="scheduleSearch" @keydown.enter.prevent="submitSearch" /><el-select v-model="filters.disposalStatus" placeholder="使用状态:" clearable @change="changeStatus" @clear="restoreAllDisposalStatuses"><template #prefix>使用状态:</template><el-option label="全部" value="ALL" /><el-option label="正常" value="正常使用" /><el-option label="闲置" value="闲置" /><el-option label="停机" value="停机" /><el-option label="注销" value="已注销" /></el-select><el-button @click="resetSearch">重置</el-button></el-form></section>
<section class="phone-asset-list-page__panel phone-asset-list-page__table"><div class="phone-asset-list-page__grid-wrap"><el-table v-loading="loading" :data="records" empty-text="暂无匹配数据" class="phone-asset-list-page__grid"><el-table-column label="手机号" min-width="180"><template #default="{ row }"><span class="phone-asset-list-page__phone"><span class="phone-asset-list-page__phone-text">{{ row.phoneNumber }}</span><el-popover v-if="row.iccid" placement="right" trigger="click" :width="250" popper-class="phone-asset-list-page__iccid-popper"><template #reference><button type="button" class="phone-asset-list-page__iccid-btn" title="查看 ICCID" aria-label="查看 ICCID"><svg viewBox="0 0 16 16" aria-hidden="true" focusable="false"><rect x="2" y="3.2" width="12" height="9.6" rx="1.6" /><path d="M5 6.4h6M5 9.2h3.5" /></svg></button></template><div class="phone-asset-list-page__iccid-pop"><span class="phone-asset-list-page__iccid-pop-label">ICCID</span><span class="phone-asset-list-page__iccid-pop-value">{{ row.iccid }}</span></div></el-popover></span></template></el-table-column><el-table-column prop="realNameOwner" label="实名人" min-width="150" show-overflow-tooltip /><el-table-column prop="cardType" label="运营商" min-width="110" show-overflow-tooltip /><el-table-column label="号码类型" min-width="120"><template #default="{ row }"><a v-if="row.numberType === 'EXTERNAL' && row.sourceAssetType === 'WECOM'" class="phone-asset-list-page__external-link" :href="'#/reference/wecom?phoneAssetId=' + row.id">外部号码</a><span v-else>{{ row.numberType === 'EXTERNAL' ? '外部号码' : '自有号码' }}</span></template></el-table-column><el-table-column prop="managementType" label="管理模式" min-width="120" show-overflow-tooltip /><el-table-column label="使用状态" min-width="130"><template #default="{ row }"><span class="phone-asset-list-page__status"><i :class="['phone-asset-list-page__status-dot', row.disposalStatus]"></i>{{ formatDisposalStatus(row.disposalStatus) }}</span></template></el-table-column><el-table-column label="关联设备" min-width="150" show-overflow-tooltip><template #default="{ row }">{{ row.deviceName || '-' }}</template></el-table-column><el-table-column v-if="canEdit" label="操作" width="120"><template #default="{ row }"><span class="phone-asset-list-page__actions"><el-button link @click="openEdit(row)">编辑</el-button><el-button link type="danger" @click="confirmDelete(row)">删除</el-button></span></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" modal-class="phone-asset-modal-mask" :title="editingId === null ? '新增手机号码管理' : '编辑手机号码管理'" width="560px" @opened="resetDialogScroll">
<el-form class="phone-asset-modal__form" label-width="96px">
<el-form-item class="phone-asset-modal__form-row" label="手机号" required>
<el-input :model-value="form.phoneNumber" class="phone-asset-modal__count-input" maxlength="11" inputmode="numeric" autocomplete="off" placeholder="请输入手机号" @update:model-value="limitPhoneNumber" @paste="handlePhonePaste">
......
import { csrfHeadersFor } from '../auth/auth-api-client.js';
/**
* 代码作用(白话):统一发送手机号资产请求并把后端错误转换为页面可提示的文字。
* 代码作用(白话):统一发送手机号码管理请求并把后端错误转换为页面可提示的文字。
* 关联文件:PhoneAssetView.js、PhoneAssetController.java。
* 关联逻辑(调用链/数据流):页面事件 -> fetch -> ApiResponse -> 成功数据或 ElMessage 错误。
*/
......@@ -8,16 +8,16 @@ async function request(path, options = {}) {
const headers = await csrfHeadersFor(path, (options.method || 'GET').toUpperCase());
const response = await fetch(path, { credentials: 'include', headers: { 'Content-Type': 'application/json', ...headers }, ...options });
const payload = await response.json();
if (!response.ok || payload.code !== 200) throw new Error(payload.message || '手机号资产请求失败');
if (!response.ok || payload.code !== 200) throw new Error(payload.message || '手机号码管理请求失败');
return payload.data;
}
/** 代码作用(白话):按筛选条件读取手机号资产列表。关联文件:PhoneAssetView.js、PhoneAssetController.java。关联逻辑(调用链/数据流):筛选条件 -> GET -> 表格。 */
/** 代码作用(白话):按筛选条件读取手机号码管理列表。关联文件:PhoneAssetView.js、PhoneAssetController.java。关联逻辑(调用链/数据流):筛选条件 -> GET -> 表格。 */
export function listPhoneAssets(query) { const params = new URLSearchParams(); Object.entries(query).forEach(([key,value]) => { if (value !== '' && value !== null && value !== undefined && value !== 'ALL') params.set(key,value); }); return request('/api/phone-assets?' + params.toString()); }
/** 代码作用(白话):提交新增表单。关联文件:PhoneAssetView.js、PhoneAssetController.java。关联逻辑(调用链/数据流):新增弹窗 -> POST -> 后端创建。 */
export function createPhoneAsset(form) { return request('/api/phone-assets', { method: 'POST', body: JSON.stringify(form) }); }
/** 代码作用(白话):提交编辑表单。关联文件:PhoneAssetView.js、PhoneAssetController.java。关联逻辑(调用链/数据流):编辑弹窗 -> PUT -> 后端更新。 */
export function updatePhoneAsset(id, form) { return request('/api/phone-assets/' + id, { method: 'PUT', body: JSON.stringify(form) }); }
/** 代码作用(白话):确认后删除一条手机号资产。关联文件:PhoneAssetView.js、PhoneAssetController.java。关联逻辑(调用链/数据流):删除确认 -> DELETE -> 后端软删除。 */
/** 代码作用(白话):确认后删除一条手机号码管理。关联文件:PhoneAssetView.js、PhoneAssetController.java。关联逻辑(调用链/数据流):删除确认 -> DELETE -> 后端软删除。 */
export function deletePhoneAsset(id) { return request('/api/phone-assets/' + id, { method: 'DELETE' }); }
/** 代码作用(白话):按设备名称搜索可关联的设备资产。关联文件:PhoneAssetView.js、PhoneAssetController.java。关联逻辑(调用链/数据流):关联设备下拉输入 -> GET lookups/devices -> 设备选项。 */
export function searchLinkableDevices(keyword) { return request('/api/phone-assets/lookups/devices?keyword=' + encodeURIComponent(keyword || '')); }
......@@ -3,7 +3,7 @@ import { ElMessage } from 'element-plus';
import { authState } from '../auth/auth-store.js';
import { createSystemUser, listLockedAccounts, listSystemUsers, resetSystemUserPassword, unlockLoginLock, updateSystemUser } from './system-user-api-client.js';
const pages = [{ key: 'overview', label: '总览' }, { key: 'domain', label: '域名资料' }, { key: 'company-profile', label: '公司档案' }, { key: 'reference-wecom', label: '企微资料' }, { key: 'phone-assets', label: '手机号资产' }, { key: 'alerts', label: '提醒中心' }];
const pages = [{ key: 'overview', label: '总览' }, { key: 'domain', label: '域名资料' }, { key: 'company-profile', label: '公司档案' }, { key: 'reference-wecom', label: '企微资料' }, { key: 'phone-assets', label: '手机号码管理' }, { key: 'alerts', label: '提醒中心' }];
const roles = [{ value: 'SUPER_ADMIN', label: '超级管理员' }, { value: 'FINANCE', label: '财务' }, { value: 'HR', label: '人事' }, { value: 'OPERATIONS', label: '运营' }];
/** 代码作用(白话):创建五页均无权限的编辑表单初始值;关联文件:PagePermissionService.java、UserPermissionView.js。关联逻辑(调用链/数据流):新增/编辑打开 -> 本函数 -> 表单权限单选 -> JSON 提交。 */
function blankForm() { return { username: '', roleCode: 'FINANCE', status: 'ACTIVE', password: '', pagePermissions: Object.fromEntries(pages.map(page => [page.key, 'NONE'])) }; }
......
import { computed, nextTick, onMounted, reactive, ref } from 'vue/dist/vue.esm-bundler.js';
import { ElMessage } from 'element-plus';
import { ElMessage, ElMessageBox } from 'element-plus';
import { authState } from '../auth/auth-store.js';
import { createWecomAccount, listWecomAccounts, searchCompanyPersons, searchCompanyProfiles, searchPhoneAssets } from './wecom-api-client.js';
import { createWecomAccount, deleteWecomAccount, listWecomAccounts, searchCompanyPersons, searchCompanyProfiles, searchDevices, searchPhoneAssets, updateWecomAccount } from './wecom-api-client.js';
/** File purpose (plain language): renders the enterprise WeChat asset list and its creation dialog with reusable asset searches. */
export default {
......@@ -10,15 +10,18 @@ export default {
const loading = ref(false);
const saving = ref(false);
const dialogVisible = ref(false);
/** null 表示新增态,有值表示正在编辑那一条;弹窗标题、保存走的接口都看它。 */
const editingId = ref(null);
const records = ref([]);
const total = ref(0);
const companyOptions = ref([]);
const ownerOptions = ref([]);
const deviceOptions = ref([]);
/** 代码作用(白话):把企微页面的有效权限转换为新增按钮和弹窗是否可用;关联文件:auth-store.js、WecomAccountController.java。关联逻辑(调用链/数据流):认证资料 -> EDIT 判断 -> 写操作控件 -> 后端 EDIT 校验。 */
const canEdit = computed(() => authState.user?.pagePermissions?.['reference-wecom'] === 'EDIT');
let searchTimer;
const filters = reactive({ page: 1, size: 20, keyword: '', wecomAccount: '', phoneAssetId: new URLSearchParams(window.location.hash.split('?')[1] || '').get('phoneAssetId') || '', companyProfileId: 'ALL', realNameOwnerStatus: 'ALL' });
const form = reactive({ wecomName: '', wecomAlias: '记忆力梅老师-助教老师', wecomAccount: '', companyProfileId: null, phoneNumber: '', realNameOwner: '', realNameOwnerStatus: '在职', gender: '', operatorPersonId: null });
const form = reactive({ wecomName: '', wecomAlias: '记忆力梅老师-助教老师', wecomAccount: '', companyProfileId: null, phoneNumber: '', realNameOwner: '', realNameOwnerStatus: '在职', gender: '', deviceId: null, operatorPersonId: null });
/** Code purpose (plain language): reloads the table using the active filters. Related files: wecom-api-client.js, WecomAccountService.java. Data flow: page action -> GET -> records/total -> table. */
async function loadPage() {
......@@ -28,7 +31,7 @@ export default {
}
/** Code purpose (plain language): resets the create form to the business defaults. Related files: WecomAccountSaveRequest.java. Data flow: add button -> reset -> dialog form. */
function resetForm() { Object.assign(form, { wecomName: '', wecomAlias: '记忆力梅老师-助教老师', wecomAccount: '', companyProfileId: null, phoneNumber: '', realNameOwner: '', realNameOwnerStatus: '在职', gender: '', operatorPersonId: null }); }
function resetForm() { Object.assign(form, { wecomName: '', wecomAlias: '记忆力梅老师-助教老师', wecomAccount: '', companyProfileId: null, phoneNumber: '', realNameOwner: '', realNameOwnerStatus: '在职', gender: '', deviceId: null, operatorPersonId: null }); }
/**
* 代码作用(白话):在统一资产弹窗打开后把正文滚动位置恢复到顶部,保证标题、表单起点和底部操作区的使用体验一致。
......@@ -42,18 +45,64 @@ export default {
}
/** Code purpose (plain language): opens a clean creation dialog. Related files: WecomAccountView.js. Data flow: add button -> resetForm -> dialog visible. */
function openCreate() { resetForm(); dialogVisible.value = true; }
/** 打开弹窗就先拉一页设备:远程下拉不预载的话,用户点开只看到"无数据",得先猜着打字才有列表。 */
function openCreate() { editingId.value = null; resetForm(); deviceOptions.value = []; loadDevices(''); dialogVisible.value = true; }
/** 代码作用(白话):用当前表格行回填同一个弹窗进入编辑态。关联文件:WecomAccountResponse.java、wecom-api-client.js。关联逻辑(调用链/数据流):点击编辑 -> row -> form -> PUT 保存。 */
function openEdit(row) {
editingId.value = row.id;
Object.assign(form, {
wecomName: row.wecomName || '', wecomAlias: row.wecomAlias || '', wecomAccount: row.wecomAccount || '',
companyProfileId: row.companyProfileId ?? null, phoneNumber: row.phoneNumber || '',
realNameOwner: row.realNameOwner || '', realNameOwnerStatus: row.realNameOwnerStatus || '在职',
gender: row.gender || '', deviceId: row.deviceId ?? null, operatorPersonId: row.operatorPersonId ?? null
});
// 先用行数据兜底一条选项:三个远程下拉在还没搜索前会空白,回退显示当前值而不是让人以为没关联。
// 名称取不到时退回显示 ID(关联资产被软删就属于这种情况),也比一片空白好判断。
companyOptions.value = row.companyProfileId ? [{ id: row.companyProfileId, companyName: row.companyProfileName || `主体 ${row.companyProfileId}`, shortName: row.companyProfileName }] : [];
ownerOptions.value = row.operatorPersonId ? [{ id: row.operatorPersonId, personName: row.operatorPersonName || `人员 ${row.operatorPersonId}` }] : [];
deviceOptions.value = row.deviceId ? [{ id: row.deviceId, deviceName: row.deviceName || `设备 ${row.deviceId}` }] : [];
loadDevices('');
dialogVisible.value = true;
}
/**
* 代码作用(白话):确认后删除一条企微资产,若它当初自动新建过手机号,提前把这件事说清楚。
* 关联文件:wecom-api-client.js、WecomAccountService.java。
* 关联逻辑(调用链/数据流):删除按钮 -> 确认框 -> DELETE -> 号码联动清理 -> loadPage。
*/
async function confirmDelete(row) {
const extra = row.phoneLinkMode === 'CREATED' ? `\n注册手机号 ${row.phoneNumber || ''} 是新增这条企微时自动创建的,若没有其他资产在用,会一并删除。` : '';
try {
await ElMessageBox.confirm(`确认删除企微「${row.wecomName}」吗?${extra}`, '删除确认', { type: 'warning' });
await deleteWecomAccount(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);
}
}
/** 代码作用(白话):把注册手机号实时限制为最多 11 位数字,输入非数字或粘贴混合文字时自动清理;关联文件:PhoneAssetView.js、WecomAccountSaveRequest.java;关联逻辑(调用链/数据流):手机号输入框 -> 本方法 -> form.phoneNumber -> 保存请求 -> 后端格式校验。 */
function limitPhoneNumber(value) { form.phoneNumber = String(value || '').replace(/\D/g, '').slice(0, 11); }
/** Code purpose (plain language): saves a new asset and refreshes the first page. Related files: wecom-api-client.js, WecomAccountService.java. Data flow: dialog submit -> POST -> transaction -> table refresh. */
/** Code purpose (plain language): saves the dialog as a create or an edit and refreshes the list. Related files: wecom-api-client.js, WecomAccountService.java. Data flow: dialog submit -> POST/PUT -> transaction -> table refresh. */
async function submitCreate() {
limitPhoneNumber(form.phoneNumber);
if (!form.wecomName.trim() || !form.phoneNumber) { ElMessage.warning('请填写企微名称和注册手机号'); return; }
if (form.phoneNumber.length !== 11) { ElMessage.warning('手机号必须是 11 位数字'); return; }
saving.value = true;
try { await createWecomAccount({ ...form, wecomName: form.wecomName.trim(), phoneNumber: form.phoneNumber }); ElMessage.success('新增成功'); dialogVisible.value = false; filters.page = 1; await loadPage(); } catch (error) { ElMessage.error(error.message); } finally { saving.value = false; }
const payload = { ...form, wecomName: form.wecomName.trim(), phoneNumber: form.phoneNumber };
try {
if (editingId.value === null) await createWecomAccount(payload);
else await updateWecomAccount(editingId.value, payload);
ElMessage.success(editingId.value === null ? '新增成功' : '编辑成功');
dialogVisible.value = false;
// 编辑保持当前页,新增才跳回第一页:改完一条却被弹回首页会让人找不到刚才那行。
if (editingId.value === null) filters.page = 1;
await loadPage();
} catch (error) { ElMessage.error(error.message); } finally { saving.value = false; }
}
/** Code purpose (plain language): supplies existing phone assets to the autocomplete while leaving a typed new number usable. Related files: wecom-api-client.js, WecomAccountService.java. Data flow: phone input -> lookup -> autocomplete suggestions. */
......@@ -67,6 +116,20 @@ export default {
/** Code purpose (plain language): looks up possible WeCom owners. Related files: wecom-api-client.js, WecomAccountService.java. Data flow: remote select -> lookup -> option list. */
async function loadOwners(keyword) { ownerOptions.value = await searchCompanyPersons(keyword); }
/**
* 代码作用(白话):按设备名称拉取可关联的设备选项,并保留当前已选设备。
* 关联文件:wecom-api-client.js、WecomAccountController.java。
* 关联逻辑(调用链/数据流):下拉输入 -> GET lookups/devices -> deviceOptions -> 下拉列表。
* 后端只返回前 20 条,已选设备可能不在结果里;直接覆盖会让选择框丢掉名称回落成裸 ID,所以把它补回列表头部。
*/
async function loadDevices(keyword) {
try {
const options = await searchDevices(keyword);
const selected = deviceOptions.value.find(item => item.id === form.deviceId);
deviceOptions.value = selected && !options.some(item => item.id === selected.id) ? [selected, ...options] : options;
} catch (error) { ElMessage.error(error.message); }
}
/** Code purpose (plain language): waits briefly for typing to stop before querying unified WeCom-name-or-phone keywords. Related files: WecomAccountView.js, WecomAccountService.java. Data flow: input -> 300ms timer -> submitSearch -> filtered table. */
function scheduleSearch() { window.clearTimeout(searchTimer); searchTimer = window.setTimeout(submitSearch, 300); }
......@@ -88,18 +151,35 @@ export default {
/** Code purpose (plain language): applies a new page size and returns to the first page. Related files: WecomAccountView.js, AppPagination.js. Data flow: pagination -> filters.size/page -> loadPage. */
function changePageSize(size) { filters.size = size; filters.page = 1; loadPage(); }
/** Code purpose (plain language): formats an optional referenced ID for table display. Related files: WecomAccountResponse.java. Data flow: API record -> formatter -> table cell. */
function formatRelation(name, id) { return id === null || id === undefined ? '—' : `${name || '—'}(ID:${id})`; }
/**
* Code purpose (plain language): shows only the readable name of a referenced asset. Related files: WecomAccountResponse.java.
* Data flow: API record -> formatter -> table cell.
* 不再拼「(ID:4)」:ID 是内部主键,对使用者没有意义,反而把每一格撑成两行、挤掉真正要看的名称。
* 关联资产被软删时名称查不到,这里退回「—」,而不是露出一个孤零零的 ID。
*/
function formatRelation(name) { return name || '—'; }
/** Code purpose (plain language): keeps the create column at date precision. Related files: WecomAccountResponse.java. Data flow: ISO timestamp -> first 10 characters -> table cell. */
function formatDate(value) { const text = String(value || ''); return text ? text.slice(0, 10) : '—'; }
/** 代码作用(白话):把性别转成图标要用的样式类,取值异常时不画图标。关联文件:app.css。关联逻辑(调用链/数据流):row.gender -> 样式类 -> 名称右侧的性别图标。 */
function genderClass(row) { return row.gender === '男' ? 'is-male' : row.gender === '女' ? 'is-female' : ''; }
/** 代码作用(白话):拼出跳到手机号码管理页并筛出该号码的地址。关联文件:PhoneAssetView.js。关联逻辑(调用链/数据流):关联方式链接 -> Hash 查询参数 -> 手机号列表按号码筛选。 */
function phoneAssetLink(row) { return row.phoneNumber ? `#/phone-assets?phoneNumber=${encodeURIComponent(row.phoneNumber)}` : ''; }
/** 代码作用(白话):拼出跳到设备资产管理页并筛出该设备的地址。关联文件:DeviceAssetView.js。关联逻辑(调用链/数据流):关联设备链接 -> Hash 查询参数 -> 设备列表按名称筛选。 */
function deviceAssetLink(row) { return row.deviceName ? `#/device-assets?deviceName=${encodeURIComponent(row.deviceName)}` : ''; }
onMounted(loadPage);
return { canEdit, changePage, changePageSize, companyOptions, dialogVisible, fetchPhoneSuggestions, filters, form, formatRelation, limitPhoneNumber, loadCompanies, loadOwners, loading, openCreate, ownerOptions, records, resetDialogScroll, resetSearch, restoreAllCompanyProfiles, restoreAllRealNameStatuses, saving, scheduleSearch, submitCreate, submitSearch, total };
return { canEdit, changePage, changePageSize, companyOptions, confirmDelete, deviceOptions, dialogVisible, editingId, deviceAssetLink, fetchPhoneSuggestions, filters, form, formatDate, formatRelation, genderClass, limitPhoneNumber, phoneAssetLink, loadCompanies, loadDevices, loadOwners, loading, openCreate, openEdit, ownerOptions, records, resetDialogScroll, resetSearch, restoreAllCompanyProfiles, restoreAllRealNameStatuses, saving, scheduleSearch, submitCreate, submitSearch, total };
},
template: `
<section class="phone-asset-list-page wecom-account-page">
<header class="phone-asset-list-page__header"><div><h2>企业微信资产</h2></div><el-button v-if="canEdit" class="phone-asset-list-page__add" type="primary" @click="openCreate">新增企业微信资产</el-button></header>
<section class="phone-asset-list-page__panel phone-asset-list-page__search"><el-form class="phone-asset-list-page__filters" @submit.prevent="submitSearch"><el-input v-model="filters.keyword" placeholder="企微名称或手机号" clearable @input="scheduleSearch" @clear="scheduleSearch" /><el-select v-model="filters.companyProfileId" filterable remote clearable :remote-method="loadCompanies" placeholder="注册主体:" @change="submitSearch" @clear="restoreAllCompanyProfiles"><template #prefix>注册主体:</template><el-option label="全部" value="ALL" /><el-option v-for="item in companyOptions" :key="item.id" :label="item.shortName || item.companyName" :value="item.id" /></el-select><el-select v-model="filters.realNameOwnerStatus" clearable placeholder="实名状态:" @change="submitSearch" @clear="restoreAllRealNameStatuses"><template #prefix>实名状态:</template><el-option label="全部" value="ALL" /><el-option label="在职" value="在职" /><el-option label="离职" value="离职" /></el-select><el-button @click="resetSearch">重置</el-button></el-form></section>
<section class="phone-asset-list-page__panel phone-asset-list-page__table"><header class="phone-asset-list-page__table-header"><h3>资产列表</h3><span>共 {{ total }} 条</span></header><div class="phone-asset-list-page__grid-wrap"><el-table v-loading="loading" :data="records" empty-text="暂无匹配数据" class="phone-asset-list-page__grid wecom-account-page__grid"><el-table-column prop="id" label="企业微信资产 ID" min-width="140" /><el-table-column prop="wecomName" label="企微名称" min-width="150" show-overflow-tooltip /><el-table-column prop="wecomAlias" label="企微别名" min-width="180" show-overflow-tooltip /><el-table-column prop="wecomAccount" label="企微账号" min-width="160" show-overflow-tooltip /><el-table-column label="注册主体" min-width="180"><template #default="{ row }">{{ formatRelation(row.companyProfileName, row.companyProfileId) }}</template></el-table-column><el-table-column label="注册手机号" min-width="180"><template #default="{ row }">{{ formatRelation(row.phoneNumber, row.phoneAssetId) }}</template></el-table-column><el-table-column label="关联方式" min-width="120"><template #default="{ row }">{{ row.phoneLinkMode === 'CREATED' ? '新建号码' : '已有号码' }}</template></el-table-column><el-table-column prop="realNameOwner" label="实名人" min-width="120" /><el-table-column prop="realNameOwnerStatus" label="实名状态" min-width="110" /><el-table-column prop="gender" label="性别" min-width="90" /><el-table-column label="企微号归属人" min-width="180"><template #default="{ row }">{{ formatRelation(row.operatorPersonName, row.operatorPersonId) }}</template></el-table-column><el-table-column prop="createTime" label="创建时间" min-width="180" /></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" modal-class="phone-asset-modal-mask" title="新增企业微信资产" width="560px" :close-on-click-modal="false" @opened="resetDialogScroll"><el-form class="phone-asset-modal__form" label-width="112px" @submit.prevent="submitCreate"><el-form-item class="phone-asset-modal__form-row" label="企微名称" required><el-input v-model="form.wecomName" /></el-form-item><el-form-item class="phone-asset-modal__form-row" label="企微别名"><el-input v-model="form.wecomAlias" /></el-form-item><el-form-item class="phone-asset-modal__form-row" label="企微账号"><el-input v-model="form.wecomAccount" /></el-form-item><el-form-item class="phone-asset-modal__form-row" label="注册手机号" required><el-autocomplete :model-value="form.phoneNumber" :fetch-suggestions="fetchPhoneSuggestions" maxlength="11" inputmode="numeric" placeholder="输入 11 位手机号" style="width:100%" @update:model-value="limitPhoneNumber"><template #suffix><span class="phone-asset-modal__character-count">{{ form.phoneNumber.length }}/11</span></template></el-autocomplete></el-form-item><el-form-item class="phone-asset-modal__form-row" label="注册主体"><el-select v-model="form.companyProfileId" filterable remote clearable :remote-method="loadCompanies" placeholder="输入公司名称或简称" style="width:100%"><el-option v-for="item in companyOptions" :key="item.id" :label="item.shortName || item.companyName" :value="item.id" /></el-select></el-form-item><el-form-item class="phone-asset-modal__form-row" label="企微号归属人"><el-select v-model="form.operatorPersonId" filterable remote clearable :remote-method="loadOwners" placeholder="输入人员姓名" style="width:100%"><el-option v-for="item in ownerOptions" :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-input v-model="form.realNameOwner" /></el-form-item><el-form-item class="phone-asset-modal__form-row" label="实名状态"><el-radio-group v-model="form.realNameOwnerStatus"><el-radio value="在职">在职</el-radio><el-radio value="离职">离职</el-radio></el-radio-group></el-form-item><el-form-item class="phone-asset-modal__form-row" label="性别"><el-radio-group v-model="form.gender"><el-radio value="男">男</el-radio><el-radio value="女">女</el-radio></el-radio-group></el-form-item></el-form><template #footer><el-button @click="dialogVisible = false">取消</el-button><el-button type="primary" :loading="saving" @click="submitCreate">保存</el-button></template></el-dialog>
<section class="phone-asset-list-page__panel phone-asset-list-page__table"><div class="phone-asset-list-page__grid-wrap"><el-table v-loading="loading" :data="records" empty-text="暂无匹配数据" class="phone-asset-list-page__grid wecom-account-page__grid"><el-table-column label="企微名称" min-width="200" show-overflow-tooltip><template #default="{ row }"><span class="wecom-account-page__name"><span class="wecom-account-page__name-main"><span class="wecom-account-page__name-text">{{ row.wecomName }}</span><span v-if="genderClass(row)" class="wecom-account-page__gender" :class="genderClass(row)" :title="'性别:' + row.gender"><svg v-if="row.gender === '男'" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><circle cx="10" cy="14.5" r="5.5"/><path d="M14.5 10L20 4.5M15 4.5h5v5"/></svg><svg v-else viewBox="0 0 24 24" aria-hidden="true" focusable="false"><circle cx="12" cy="9" r="5.5"/><path d="M12 14.5v6M9 18h6"/></svg></span></span><span v-if="row.wecomAlias" class="wecom-account-page__name-alias">{{ row.wecomAlias }}</span></span></template></el-table-column><el-table-column prop="wecomAccount" label="企微账号" min-width="160" show-overflow-tooltip /><el-table-column label="注册主体" min-width="160" show-overflow-tooltip><template #default="{ row }">{{ formatRelation(row.companyProfileName) }}</template></el-table-column><el-table-column label="注册手机号" min-width="170"><template #default="{ row }"><span v-if="row.phoneNumber" class="wecom-account-page__phone"><a class="phone-asset-list-page__external-link" :href="phoneAssetLink(row)" :title="'在手机号码管理中查看 ' + row.phoneNumber">{{ row.phoneNumber }}</a><span class="wecom-account-page__phone-tag" :class="row.phoneLinkMode === 'CREATED' ? 'is-created' : 'is-existing'" :title="row.phoneLinkMode === 'CREATED' ? '新建号码:新增这条企微时自动创建' : '已有号码:关联的是已存在的手机号码管理'">{{ row.phoneLinkMode === 'CREATED' ? '新' : '已' }}</span></span><span v-else>—</span></template></el-table-column><el-table-column label="实名人" min-width="120"><template #default="{ row }"><span v-if="row.realNameOwner" class="wecom-account-page__owner"><span class="wecom-account-page__owner-main">{{ row.realNameOwner }}</span><i v-if="row.realNameOwnerStatus" class="wecom-account-page__owner-dot" :class="{ 'is-left': row.realNameOwnerStatus === '离职' }" :title="'实名状态:' + row.realNameOwnerStatus"></i></span><span v-else>—</span></template></el-table-column><el-table-column label="关联设备" min-width="160" show-overflow-tooltip><template #default="{ row }"><a v-if="deviceAssetLink(row)" class="phone-asset-list-page__external-link" :href="deviceAssetLink(row)" :title="'在设备资产管理中查看 ' + row.deviceName">{{ row.deviceName }}</a><span v-else>—</span></template></el-table-column><el-table-column label="企微号归属人" min-width="140" show-overflow-tooltip><template #default="{ row }">{{ formatRelation(row.operatorPersonName) }}</template></el-table-column><el-table-column label="创建时间" min-width="120"><template #default="{ row }">{{ formatDate(row.createTime) }}</template></el-table-column><el-table-column v-if="canEdit" label="操作" width="120" fixed="right"><template #default="{ row }"><span class="phone-asset-list-page__actions"><el-button link @click="openEdit(row)">编辑</el-button><el-button link type="danger" @click="confirmDelete(row)">删除</el-button></span></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" modal-class="phone-asset-modal-mask" :title="editingId === null ? '新增企业微信资产' : '编辑企业微信资产'" width="560px" :close-on-click-modal="false" @opened="resetDialogScroll"><el-form class="phone-asset-modal__form" label-width="112px" @submit.prevent="submitCreate"><el-form-item class="phone-asset-modal__form-row" label="企微名称" required><el-input v-model="form.wecomName" /></el-form-item><el-form-item class="phone-asset-modal__form-row" label="企微别名"><el-input v-model="form.wecomAlias" /></el-form-item><el-form-item class="phone-asset-modal__form-row" label="企微账号"><el-input v-model="form.wecomAccount" /></el-form-item><el-form-item class="phone-asset-modal__form-row" label="注册手机号" required><el-autocomplete :model-value="form.phoneNumber" :fetch-suggestions="fetchPhoneSuggestions" maxlength="11" inputmode="numeric" placeholder="输入 11 位手机号" style="width:100%" @update:model-value="limitPhoneNumber"><template #suffix><span class="phone-asset-modal__character-count">{{ form.phoneNumber.length }}/11</span></template></el-autocomplete></el-form-item><el-form-item class="phone-asset-modal__form-row" label="注册主体"><el-select v-model="form.companyProfileId" filterable remote clearable :remote-method="loadCompanies" placeholder="输入公司名称或简称" style="width:100%"><el-option v-for="item in companyOptions" :key="item.id" :label="item.shortName || item.companyName" :value="item.id" /></el-select></el-form-item><el-form-item class="phone-asset-modal__form-row" label="关联设备"><el-select v-model="form.deviceId" filterable remote clearable :remote-method="loadDevices" placeholder="输入设备名称搜索" style="width:100%"><el-option v-for="item in deviceOptions" :key="item.id" :label="item.deviceName" :value="item.id" /></el-select></el-form-item><el-form-item class="phone-asset-modal__form-row" label="企微号归属人"><el-select v-model="form.operatorPersonId" filterable remote clearable :remote-method="loadOwners" placeholder="输入人员姓名" style="width:100%"><el-option v-for="item in ownerOptions" :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-input v-model="form.realNameOwner" /></el-form-item><el-form-item class="phone-asset-modal__form-row" label="实名状态"><el-radio-group v-model="form.realNameOwnerStatus"><el-radio value="在职">在职</el-radio><el-radio value="离职">离职</el-radio></el-radio-group></el-form-item><el-form-item class="phone-asset-modal__form-row" label="性别"><el-radio-group v-model="form.gender"><el-radio value="男">男</el-radio><el-radio value="女">女</el-radio></el-radio-group></el-form-item></el-form><template #footer><el-button @click="dialogVisible = false">取消</el-button><el-button type="primary" :loading="saving" @click="submitCreate">保存</el-button></template></el-dialog>
</section>
`
};
......@@ -20,6 +20,12 @@ export function listWecomAccounts(query) {
/** Code purpose (plain language): saves the create-dialog values. Related files: WecomAccountView.js, WecomAccountController.java. Data flow: dialog submit -> POST -> transaction -> refreshed table. */
export function createWecomAccount(form) { return request('/api/wecom-accounts', { method: 'POST', body: JSON.stringify(form) }); }
/** 代码作用(白话):提交编辑弹窗的内容。关联文件:WecomAccountView.js、WecomAccountController.java。关联逻辑(调用链/数据流):编辑保存 -> PUT -> 手机号重新关联与旧号清理 -> 列表刷新。 */
export function updateWecomAccount(id, form) { return request(`/api/wecom-accounts/${id}`, { method: 'PUT', body: JSON.stringify(form) }); }
/** 代码作用(白话):软删除一条企微资产。关联文件:WecomAccountView.js、WecomAccountController.java。关联逻辑(调用链/数据流):删除确认 -> DELETE -> 自动新建号码一并清理 -> 列表刷新。 */
export function deleteWecomAccount(id) { return request(`/api/wecom-accounts/${id}`, { method: 'DELETE' }); }
/** Code purpose (plain language): searches company profiles for the registration-subject picker. Related files: WecomAccountView.js, WecomAccountController.java. Data flow: remote select -> GET lookup -> options. */
export function searchCompanyProfiles(keyword) { return request(`/api/wecom-accounts/lookups/company-profiles?keyword=${encodeURIComponent(keyword || '')}`); }
......@@ -28,3 +34,6 @@ export function searchPhoneAssets(keyword) { return request(`/api/wecom-accounts
/** Code purpose (plain language): searches optional WeCom owners. Related files: WecomAccountView.js, WecomAccountController.java. Data flow: remote select -> GET lookup -> options. */
export function searchCompanyPersons(keyword) { return request(`/api/wecom-accounts/lookups/company-persons?keyword=${encodeURIComponent(keyword || '')}`); }
/** 代码作用(白话):按设备名称搜索可关联的有效设备。关联文件:WecomAccountView.js、WecomAccountController.java。关联逻辑(调用链/数据流):关联设备下拉输入 -> GET lookup -> 设备选项。 */
export function searchDevices(keyword) { return request(`/api/wecom-accounts/lookups/devices?keyword=${encodeURIComponent(keyword || '')}`); }
......@@ -38,7 +38,7 @@ body { margin: 0; }
.reference-card { padding: 24px; border: 1px solid #e2e8f0; border-radius: 14px; background: white; box-shadow: 0 8px 28px rgba(15, 23, 42, .05); }
.toolbar { display: flex; align-items: center; justify-content: space-between; margin-bottom: 18px; }
@media (max-width: 760px) { .app-shell { grid-template-columns: 1fr; } .sidebar nav { grid-template-columns: repeat(2, 1fr); } .page-header { display: grid; } .content { padding: 24px; } }
/* 手机号资产弹窗:所有规则均以该弹窗类名开头,避免影响项目中的其他 Element Plus 弹窗。 */
/* 手机号码管理弹窗:所有规则均以该弹窗类名开头,避免影响项目中的其他 Element Plus 弹窗。 */
.phone-asset-modal-mask {
background: rgba(15, 23, 42, 0.34);
}
......@@ -281,7 +281,7 @@ body { margin: 0; }
font-weight: 550;
background: #f4f4f5;
}
/* 手机号资产列表:参考 mobile-number-asset-management-v4.html,仅作用于当前列表页。 */
/* 手机号码管理列表:参考 mobile-number-asset-management-v4.html,仅作用于当前列表页。 */
.phone-asset-list-page { width:100%; max-width:1680px; margin:0 auto; padding:34px 4px 56px; }
.phone-asset-list-page__header { display:flex; align-items:center; justify-content:space-between; gap:24px; margin-bottom:24px; }
.phone-asset-list-page__header h2 { margin:0; color:#111113; font-size:29px; font-weight:700; line-height:1.25; letter-spacing:-.035em; }
......@@ -311,7 +311,8 @@ body { margin: 0; }
.phone-asset-list-page__iccid-pop-label{color:#8b8b93;font-size:12px;letter-spacing:.02em}
/* ICCID 是长串数字,等宽字体便于逐位核对;allow-select 让用户能直接框选复制。 */
.phone-asset-list-page__iccid-pop-value{color:#18181b;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:13px;line-height:1.5;word-break:break-all;user-select:text}
.phone-asset-list-page__table { overflow:hidden; }.phone-asset-list-page__table-header{display:flex;align-items:center;gap:9px;min-height:62px;padding:0 22px;border-bottom:1px solid #e5e5e8}.phone-asset-list-page__table-header h3{margin:0;color:#18181b;font-size:15px;font-weight:650}.phone-asset-list-page__table-header span{color:#8b8b93;font-size:13px}
/* 列表卡片不再有"资产列表 共 N 条"标题栏:总条数底部分页器已经在报,重复一遍只是占掉一行高度。 */
.phone-asset-list-page__table { overflow:hidden; }
.phone-asset-list-page__grid .el-table__header th.el-table__cell{height:46px;background:#fafafa;color:#71717a;font-size:13px;font-weight:600}.phone-asset-list-page__grid .el-table__cell{height:56px;color:#3f3f46;font-size:14px}.phone-asset-list-page__grid .el-table__row:hover>td.el-table__cell{background:#fafafa}.phone-asset-list-page__status{display:inline-flex;align-items:center;gap:8px;white-space:nowrap}.phone-asset-list-page__status-dot{width:7px;height:7px;border-radius:50%;background:#a1a1aa;box-shadow:0 0 0 3px rgba(161,161,170,.12)}.phone-asset-list-page__status-dot.正常使用{background:#2f9e68;box-shadow:0 0 0 3px rgba(47,158,104,.1)}.phone-asset-list-page__status-dot.闲置,.phone-asset-list-page__status-dot.停机{background:#d28b24;box-shadow:0 0 0 3px rgba(210,139,36,.11)}.phone-asset-list-page__actions{display:inline-flex;gap:14px}.phone-asset-list-page__actions .el-button{padding:0;color:#3f3f46;font-size:13px}.phone-asset-list-page__actions .el-button--danger{color:#e5484d}
/* 列表页统一分页器(AppPagination.js):容器高度恒定,空数据时按钮灰化但不塌陷,四个列表页共用。 */
.app-pagination{display:flex;align-items:center;justify-content:space-between;gap:20px;min-height:66px;padding:12px 22px;border-top:1px solid #e5e5e8;color:#71717a;font-size:13px}
......@@ -338,6 +339,29 @@ body { margin: 0; }
@media(max-width:900px){.phone-asset-list-page{padding:26px 0 40px}.app-pagination{align-items:flex-start;flex-direction:column;gap:10px}.app-pagination .el-pagination{width:100%;justify-content:space-between}}
@media(max-width:640px){.phone-asset-list-page{padding:20px 0 32px}.phone-asset-list-page__header{align-items:stretch;flex-direction:column;margin-bottom:18px}.phone-asset-list-page__add.el-button{width:100%}.phone-asset-list-page__header h2{font-size:25px}.phone-asset-list-page__search{padding:18px}.phone-asset-list-page__filters,.phone-asset-list-page__filters .el-input,.phone-asset-list-page__filters .el-select,.phone-asset-list-page__filters .el-button{width:100%!important}.phone-asset-list-page__table-header,.app-pagination{padding-left:18px;padding-right:18px}.app-pagination .el-pagination__jump{display:none}}
.wecom-account-page{min-width:0;max-width:100%;overflow-x:hidden}.wecom-account-page .phone-asset-list-page__table,.wecom-account-page__grid{min-width:0;max-width:100%}.wecom-account-page__grid .el-scrollbar__wrap{overflow-x:auto!important}.phone-asset-list-page__external-link{color:#409eff;text-decoration:none}.phone-asset-list-page__external-link:hover{color:#337ecc;text-decoration:none}
/* 企微名称格:别名是名称的补充说明而不是并列字段,压到第二行做浅灰小字,省下一整列宽度给真正要横向对比的信息。 */
.wecom-account-page__name{display:flex;flex-direction:column;gap:2px;min-width:0}
.wecom-account-page__name-main{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.wecom-account-page__name-alias{overflow:hidden;color:#a1a1aa;font-size:12px;line-height:1.4;text-overflow:ellipsis;white-space:nowrap}
/* 性别收成名称右侧的图标:一个字的信息不值得占一整列,图标扫一眼就能分辨,具体文案留在 title 里。 */
.wecom-account-page__name-text{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
/* 男蓝女粉。蓝直接复用全站链接色,只为女性新增一个粉,把新引入的色值压到一个。
同一行右侧还有绿红状态点,所以这两个色只上在 14px 的图标描边上,不铺底色,避免和状态点抢眼。 */
.wecom-account-page__gender{display:inline-flex;align-items:center;justify-content:center;flex:0 0 auto;width:14px;height:14px}
.wecom-account-page__gender svg{width:14px;height:14px;fill:none;stroke:currentColor;stroke-width:1.7;stroke-linecap:round;stroke-linejoin:round}
.wecom-account-page__gender.is-male{color:#409eff}
.wecom-account-page__gender.is-female{color:#ec4899}
/* 实名状态收成实名人右上角的圆点:只有"正常/异常"两态,颜色就够表达,写成文字反而占一列。
align-items:flex-start 加负的 margin-top 把圆点顶到文字上沿,形成上标而不是并排。 */
.wecom-account-page__owner{display:inline-flex;align-items:flex-start;gap:3px;min-width:0}
.wecom-account-page__owner-main{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.wecom-account-page__owner-dot{flex:0 0 auto;width:6px;height:6px;margin-top:1px;border-radius:50%;background:#2f9e68;box-shadow:0 0 0 2.5px rgba(47,158,104,.12)}
.wecom-account-page__owner-dot.is-left{background:#e5484d;box-shadow:0 0 0 2.5px rgba(229,72,77,.12)}
/* 关联方式收成手机号右上角的一字角标:「已」复用链接蓝表示沿用已有资产,「新」用绿表示这条号码是系统新建的。 */
.wecom-account-page__phone{display:inline-flex;align-items:flex-start;gap:4px;min-width:0}
.wecom-account-page__phone-tag{flex:0 0 auto;margin-top:-1px;padding:0 4px;border-radius:4px;font-size:10px;font-weight:600;line-height:15px}
.wecom-account-page__phone-tag.is-existing{color:#3f6ea8;background:#eaf1fb}
.wecom-account-page__phone-tag.is-created{color:#2f7a56;background:#e6f4ec}
/* 登录页视觉微调:输入本体保持透明,玻璃质感由外层承载,避免影响其他业务表单。 */
.login-page {
......@@ -576,7 +600,7 @@ body { margin: 0; }
}
/* 列表页固定高度骨架:宽屏下页面高度锁死在一屏,数据变多只在表格内部滚动;≤640px 回到整页滚动,避免小屏可视行数过少。 */
@media (min-width: 641px) {
/* 手机号资产、企微资料、公司档案三页共用同一套骨架:标题与筛选保持原高度,列表面板吃掉剩余空间。
/* 手机号码管理、企微资料、公司档案三页共用同一套骨架:标题与筛选保持原高度,列表面板吃掉剩余空间。
用 min-height 而不是 height:正常情况刚好一屏、内容区不出滚动条;窗口矮到表格触及最小高度时页面被撑高,改由内容区整页滚动,避免表格被压成一两行。 */
.phone-asset-list-page { display: flex; flex-direction: column; min-height: 100%; padding-bottom: 28px; }
.phone-asset-list-page__header,
......
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);
});
import { expect, test } from './authenticated-test.js';
/**
* 代码作用(白话):拦截手机号资产列表请求,给弹窗测试准备不依赖后端的空列表。
* 代码作用(白话):拦截手机号码管理列表请求,给弹窗测试准备不依赖后端的空列表。
* 关联文件:PhoneAssetView.js、phone-api-client.js。
* 关联逻辑(调用链/数据流):页面打开 -> listPhoneAssets -> 浏览器路由拦截 -> 空列表 -> 新增弹窗。
*/
......@@ -19,15 +19,15 @@ async function mockPhoneAssetList(page) {
}
/**
* 代码作用(白话):打开新增手机号资产弹窗,供多个验收场景复用。
* 代码作用(白话):打开新增手机号码管理弹窗,供多个验收场景复用。
* 关联文件:PhoneAssetView.js、phone-asset.spec.js。
* 关联逻辑(调用链/数据流):测试页面 -> 新增按钮 -> openCreate -> dialogVisible -> 弹窗。
*/
async function openCreateDialog(page) {
await mockPhoneAssetList(page);
await page.goto('/#/phone-assets');
await page.getByRole('button', { name: '新增手机号资产' }).click();
return page.getByRole('dialog', { name: '新增手机号资产' });
await page.getByRole('button', { name: '新增手机号码管理' }).click();
return page.getByRole('dialog', { name: '新增手机号码管理' });
}
/**
......
......@@ -13,7 +13,7 @@ test('creates an enterprise WeChat asset and exposes the required form', async (
await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth <= document.documentElement.clientWidth)).toBe(true);
await expect(page.getByRole('heading', { name: '企业微信资产' })).toBeVisible();
await page.getByRole('button', { name: '新增企业微信资产' }).click();
/** 代码作用(白话):验证企业微信弹窗接入手机号资产的统一容器,使超高表单仅在正文区域滚动。关联文件:WecomAccountView.js、PhoneAssetView.js、main.css。关联逻辑(调用链/数据流):新增按钮 -> Element Plus 弹窗 -> phone-asset-modal 样式 -> 正文滚动。 */
/** 代码作用(白话):验证企业微信弹窗接入手机号码管理的统一容器,使超高表单仅在正文区域滚动。关联文件:WecomAccountView.js、PhoneAssetView.js、main.css。关联逻辑(调用链/数据流):新增按钮 -> Element Plus 弹窗 -> phone-asset-modal 样式 -> 正文滚动。 */
const dialog = page.getByRole('dialog', { name: '新增企业微信资产' });
await expect(dialog.locator('.el-dialog')).toHaveClass(/phone-asset-modal/);
await expect(dialog.locator('.el-dialog__body')).toHaveCSS('overflow-y', 'auto');
......@@ -23,6 +23,230 @@ test('creates an enterprise WeChat asset and exposes the required form', async (
await expect(page.getByText('新增成功')).toBeVisible();
});
/** Plain purpose: build one list row so the edit/delete cases share the same shape. Related files: WecomAccountResponse.java, WecomAccountView.js. Flow: fixture -> mocked GET -> table row. */
function wecomRow(overrides = {}) {
return { id: 5, wecomName: '测试企微', wecomAlias: '销售一组', wecomAccount: 'zhangsan', companyProfileId: 10, companyProfileName: '示例科技', phoneAssetId: 20, phoneNumber: '13812345678', phoneLinkMode: 'EXISTING', realNameOwner: '张三', realNameOwnerStatus: '在职', gender: '男', deviceId: null, deviceName: null, operatorPersonId: 40, operatorPersonName: '王五', createTime: '2026-08-01T10:00:00', updateTime: '2026-08-01T11:00:00', ...overrides };
}
/** Plain purpose: keep internal IDs and clock times out of the list, and fold the alias under the name. Related files: WecomAccountView.js, WecomAccountResponse.java. Flow: API record -> 列渲染 -> 只剩人看得懂的信息。 */
test('shows readable names only, with the alias tucked under the name', async ({ page }) => {
await mockWecomPage(page, wecomRow({ deviceId: 31, deviceName: '学管师31号机' }), []);
await page.goto('/asset/#/reference/wecom');
const headers = page.locator('.el-table__header th .cell');
await expect(headers.filter({ hasText: '企业微信资产 ID' })).toHaveCount(0);
await expect(headers.filter({ hasText: '企微别名' })).toHaveCount(0);
const row = page.locator('.el-table__body tr').first();
// 关联字段只留名称,不再拖着一个内部主键。
await expect(row.getByText('示例科技', { exact: true })).toBeVisible();
await expect(row.getByText(/(ID:/)).toHaveCount(0);
// 别名和名称同格,别名在下方。
const nameCell = row.locator('.wecom-account-page__name');
await expect(nameCell.locator('.wecom-account-page__name-main')).toHaveText('测试企微');
await expect(nameCell.locator('.wecom-account-page__name-alias')).toHaveText('销售一组');
// 创建时间只到天。
await expect(row.getByText('2026-08-01', { exact: true })).toBeVisible();
await expect(row.getByText(/10:00/)).toHaveCount(0);
// 性别、实名状态、关联方式都收成了角标,各自不再占一列。
for (const gone of ['实名状态', '性别', '关联方式']) {
await expect(headers.filter({ hasText: gone })).toHaveCount(0);
}
await expect(row.locator('.wecom-account-page__owner-main')).toHaveText('张三');
await expect(row.locator('.wecom-account-page__gender svg')).toBeVisible();
});
/** Plain purpose: the gender icon carries its meaning in both shape and colour. Related files: app.css, WecomAccountView.js. Flow: 男/女 -> is-male/is-female -> 蓝/粉描边。 */
test('colours the gender icon blue for male and pink for female', async ({ page }) => {
await mockWecomPage(page, wecomRow(), []);
await page.goto('/asset/#/reference/wecom');
const male = page.locator('.el-table__body tr').first().locator('.wecom-account-page__gender');
await expect(male).toHaveClass(/is-male/);
await expect(male).toHaveCSS('color', 'rgb(64, 158, 255)');
await expect(male).toHaveAttribute('title', '性别:男');
});
/** Plain purpose: same check on the female side, so a swapped class cannot slip through. Related files: app.css. Flow: 女 -> is-female -> 粉色描边。 */
test('uses the pink gender icon for a female owner', async ({ page }) => {
await mockWecomPage(page, wecomRow({ gender: '女' }), []);
await page.goto('/asset/#/reference/wecom');
const female = page.locator('.el-table__body tr').first().locator('.wecom-account-page__gender');
await expect(female).toHaveClass(/is-female/);
await expect(female).toHaveCSS('color', 'rgb(236, 72, 153)');
});
/** Plain purpose: an unknown gender must draw nothing rather than half an icon. Related files: WecomAccountView.js. Flow: 脏数据 -> genderClass 返回空 -> 不渲染。 */
test('draws no gender icon when the value is neither male nor female', async ({ page }) => {
await mockWecomPage(page, wecomRow({ gender: null }), []);
await page.goto('/asset/#/reference/wecom');
await expect(page.locator('.el-table__body tr').first().locator('.wecom-account-page__gender')).toHaveCount(0);
});
/** Plain purpose: the status badge must stay a bare dot — colour carries the meaning, the wording only lives in the tooltip. Related files: app.css, WecomAccountView.js. Flow: 在职/离职 -> 绿点/红点 + title。 */
test('shows the real-name status as a coloured dot without any wording', async ({ page }) => {
await mockWecomPage(page, wecomRow({ realNameOwnerStatus: '离职' }), []);
await page.goto('/asset/#/reference/wecom');
const cell = page.locator('.el-table__body tr').first().locator('.wecom-account-page__owner');
await expect(cell).toContainText('张三');
await expect(cell).not.toContainText('离职');
const dot = cell.locator('.wecom-account-page__owner-dot');
await expect(dot).toHaveClass(/is-left/);
await expect(dot).toHaveAttribute('title', '实名状态:离职');
await expect(dot).toHaveCSS('background-color', 'rgb(229, 72, 77)');
});
/** Plain purpose: an active owner keeps the same dot in the calm colour. Related files: app.css. Flow: 在职 -> 绿点。 */
test('uses a green dot for an active real-name owner', async ({ page }) => {
await mockWecomPage(page, wecomRow(), []);
await page.goto('/asset/#/reference/wecom');
const dot = page.locator('.el-table__body tr').first().locator('.wecom-account-page__owner-dot');
await expect(dot).not.toHaveClass(/is-left/);
await expect(dot).toHaveCSS('background-color', 'rgb(47, 158, 104)');
});
/** Plain purpose: an owner-less row shows a dash and no dangling status dot. Related files: WecomAccountView.js. Flow: 无实名人 -> —。 */
test('shows a dash when there is no real-name owner', async ({ page }) => {
await mockWecomPage(page, wecomRow({ realNameOwner: null }), []);
await page.goto('/asset/#/reference/wecom');
await expect(page.locator('.el-table__body tr').first().locator('.wecom-account-page__owner-dot')).toHaveCount(0);
});
/** Plain purpose: the link mode becomes a one-character badge on the number itself. Related files: WecomAccountView.js, app.css. Flow: CREATED/EXISTING -> 新/已 角标 + title。 */
test('marks the phone link mode as a one-character badge', async ({ page }) => {
await mockWecomPage(page, wecomRow({ phoneLinkMode: 'CREATED', phoneNumber: '13912345678' }), []);
await page.goto('/asset/#/reference/wecom');
const tag = page.locator('.el-table__body tr').first().locator('.wecom-account-page__phone-tag');
await expect(tag).toHaveText('新');
await expect(tag).toHaveClass(/is-created/);
await expect(tag).toHaveAttribute('title', /新建号码/);
});
/** Plain purpose: prove both relation links point at the matching row on their own pages. Related files: PhoneAssetView.js, DeviceAssetView.js. Flow: 链接 href -> 目标页 Hash 参数 -> 该页初始筛选。 */
test('links the phone link-mode and the device to their own asset pages', async ({ page }) => {
await mockWecomPage(page, wecomRow({ phoneLinkMode: 'CREATED', phoneNumber: '13912345678', deviceId: 31, deviceName: '学管师31号机' }), []);
await page.goto('/asset/#/reference/wecom');
const row = page.locator('.el-table__body tr').first();
await expect(row.getByRole('link', { name: '13912345678' })).toHaveAttribute('href', '#/phone-assets?phoneNumber=13912345678');
await expect(row.getByRole('link', { name: '学管师31号机' })).toHaveAttribute('href', '#/device-assets?deviceName=' + encodeURIComponent('学管师31号机'));
});
/** Plain purpose: a link that cannot filter anything is worse than plain text. Related files: WecomAccountView.js. Flow: 缺号码/缺设备 -> 破折号,不生成死链也不留下无所指的角标。 */
test('drops the link and its badge when there is nothing to jump to', async ({ page }) => {
await mockWecomPage(page, wecomRow({ phoneNumber: null, deviceId: null, deviceName: null }), []);
await page.goto('/asset/#/reference/wecom');
const row = page.locator('.el-table__body tr').first();
await expect(row.locator('.wecom-account-page__phone-tag')).toHaveCount(0);
await expect(row.locator('.phone-asset-list-page__external-link')).toHaveCount(0);
await expect(row.getByText('—').first()).toBeVisible();
});
/** Plain purpose: prove the phone page actually honours the routed number, so the link is not a dead end. Related files: PhoneAssetView.js. Flow: Hash 参数 -> 初始筛选 -> GET 带上号码。 */
test('phone asset page pre-filters from the routed number', async ({ page }) => {
let requested = '';
await page.route('**/api/phone-assets**', route => {
requested = route.request().url();
return route.fulfill({ contentType: 'application/json', body: JSON.stringify({ code: 200, data: { records: [], total: 0, page: 1, size: 20 } }) });
});
await page.goto('/asset/#/phone-assets?phoneNumber=13912345678');
await expect.poll(() => requested).toContain('phoneNumber=13912345678');
});
/** Plain purpose: prove the device page actually honours the routed device name. Related files: DeviceAssetView.js. Flow: Hash 参数 -> 初始筛选 -> GET 带上名称。 */
test('device asset page pre-filters from the routed device name', async ({ page }) => {
let requested = '';
await page.route('**/api/device-assets**', route => {
if (!route.request().url().includes('/lookups/')) requested = route.request().url();
return route.fulfill({ contentType: 'application/json', body: JSON.stringify({ code: 200, data: { records: [], total: 0, page: 1, size: 20 } }) });
});
await page.goto('/asset/#/device-assets?deviceName=' + encodeURIComponent('学管师31号机'));
await expect.poll(() => requested).toContain('deviceName=' + encodeURIComponent('学管师31号机'));
});
/** Plain purpose: prove the device selector searches remotely and submits the chosen ID on both create and edit. Related files: WecomAccountView.js, WecomAccountService.java. Flow: 下拉输入 -> GET lookups/devices -> 选中 -> payload.deviceId。 */
test('picks an associated device through remote search and submits its id', async ({ page }) => {
const writes = [];
const deviceQueries = [];
await page.route('**/api/wecom-accounts**', route => {
const request = route.request();
const url = request.url();
if (url.includes('/lookups/devices')) { deviceQueries.push(decodeURIComponent(url.split('keyword=')[1] || '')); return route.fulfill({ contentType: 'application/json', body: JSON.stringify({ code: 200, data: [{ id: 31, deviceName: '学管师31号机' }] }) }); }
if (url.includes('/lookups/')) return route.fulfill({ contentType: 'application/json', body: JSON.stringify({ code: 200, data: [] }) });
if (request.method() !== 'GET') { writes.push(JSON.parse(request.postData() || '{}')); return route.fulfill({ contentType: 'application/json', body: JSON.stringify({ code: 200, data: {} }) }); }
return route.fulfill({ contentType: 'application/json', body: JSON.stringify({ code: 200, data: { records: [wecomRow({ deviceId: 31, deviceName: '学管师31号机' })], total: 1, page: 1, size: 20 } }) });
});
await page.goto('/asset/#/reference/wecom');
// 列表要能看到关联设备。
await expect(page.locator('.el-table').getByRole('link', { name: '学管师31号机' })).toBeVisible();
// 编辑时回填已选设备,即使还没搜索过。
await page.locator('.el-table').getByRole('button', { name: '编辑' }).click();
const dialog = page.getByRole('dialog');
await expect(dialog.getByText('学管师31号机')).toBeVisible();
await dialog.getByRole('button', { name: '保存' }).click();
await expect.poll(() => writes.length).toBe(1);
expect(writes[0].deviceId).toBe(31);
expect(deviceQueries.length).toBeGreaterThan(0);
});
/** Plain purpose: serve the list plus lookups and record every write. Related files: wecom-api-client.js, WecomAccountController.java. Flow: page action -> intercepted route -> recorded request. */
async function mockWecomPage(page, row, writes) {
await page.route('**/api/wecom-accounts**', route => {
const request = route.request();
const url = request.url();
if (url.includes('/lookups/')) return route.fulfill({ contentType: 'application/json', body: JSON.stringify({ code: 200, data: [] }) });
if (request.method() !== 'GET') { writes.push({ method: request.method(), url, body: request.postData() || '' }); return route.fulfill({ contentType: 'application/json', body: JSON.stringify({ code: 200, data: {} }) }); }
return route.fulfill({ contentType: 'application/json', body: JSON.stringify({ code: 200, data: { records: [row], total: 1, page: 1, size: 20 } }) });
});
}
/** Plain purpose: verify the edit dialog reuses the create form, prefills the row, and sends a PUT. Related files: WecomAccountView.js, WecomAccountService.java. Flow: 编辑 -> 回填 -> PUT。 */
test('edits an enterprise WeChat asset through the reused dialog', async ({ page }) => {
const writes = [];
await mockWecomPage(page, wecomRow(), writes);
await page.goto('/asset/#/reference/wecom');
await page.locator('.el-table').getByRole('button', { name: '编辑' }).click();
const dialog = page.getByRole('dialog', { name: '编辑企业微信资产' });
await expect(dialog).toBeVisible();
await expect(page.getByLabel('企微名称')).toHaveValue('测试企微');
await expect(page.getByLabel('注册手机号')).toHaveValue('13812345678');
await page.getByLabel('企微名称').fill('改名后的企微');
await dialog.getByRole('button', { name: '保存' }).click();
await expect.poll(() => writes.length).toBe(1);
expect(writes[0].method).toBe('PUT');
expect(writes[0].url).toContain('/api/wecom-accounts/5');
expect(JSON.parse(writes[0].body).wecomName).toBe('改名后的企微');
await expect(page.getByText('编辑成功')).toBeVisible();
});
/** Plain purpose: verify deletion asks for confirmation and issues a DELETE. Related files: WecomAccountView.js, WecomAccountService.java. Flow: 删除 -> 确认 -> DELETE -> 刷新。 */
test('deletes an enterprise WeChat asset after confirmation', async ({ page }) => {
const writes = [];
await mockWecomPage(page, wecomRow(), writes);
await page.goto('/asset/#/reference/wecom');
await page.locator('.el-table').getByRole('button', { name: '删除' }).click();
await page.getByRole('button', { name: '确定' }).click();
await expect.poll(() => writes.length).toBe(1);
expect(writes[0].method).toBe('DELETE');
expect(writes[0].url).toContain('/api/wecom-accounts/5');
await expect(page.getByText('删除成功')).toBeVisible();
});
/** Plain purpose: warn that an auto-created registration number will go away with the asset. Related files: WecomAccountView.js, WecomAccountService.java. Flow: CREATED 行 -> 确认框文案含号码提示。 */
test('warns that an auto-created registration number is removed too', async ({ page }) => {
await mockWecomPage(page, wecomRow({ phoneLinkMode: 'CREATED', phoneNumber: '13912345678' }), []);
await page.goto('/asset/#/reference/wecom');
await page.locator('.el-table').getByRole('button', { name: '删除' }).click();
await expect(page.getByText(/13912345678 是新增这条企微时自动创建的/)).toBeVisible();
});
/** Plain purpose: keep write controls away from read-only accounts. Related files: WecomAccountView.js, WecomAccountController.java. Flow: READ 权限 -> 无操作列与新增按钮。 */
test('hides the action column from read-only accounts', async ({ page }) => {
await page.route('**/api/auth/me', route => route.fulfill({ contentType: 'application/json', body: JSON.stringify({ code: 200, message: 'success', data: { id: 1, username: 'reader', roleCode: 'FINANCE', pagePermissions: { 'reference-wecom': 'READ' } } }) }));
await mockWecomPage(page, wecomRow(), []);
await page.goto('/asset/#/reference/wecom');
await expect(page.locator('.el-table').getByText('测试企微')).toBeVisible();
await expect(page.locator('.el-table').getByRole('button', { name: '编辑' })).toHaveCount(0);
await expect(page.locator('.el-table').getByRole('button', { name: '删除' })).toHaveCount(0);
await expect(page.getByRole('button', { name: '新增企业微信资产' })).toHaveCount(0);
});
test('external number links to the related enterprise WeChat asset filter', async ({ page }) => {
/** Code purpose (plain language): returns one external WeCom-created number. Related files: PhoneAssetView.js, WecomAccountView.js. Data flow: phone list fixture -> external link -> WeCom URL filter. */
await page.route('**/api/phone-assets**', route => route.fulfill({ contentType: 'application/json', body: JSON.stringify({ code: 200, data: { records: [{ id: 9, phoneNumber: '13912345678', numberType: 'EXTERNAL', sourceAssetType: 'WECOM', cardType: '', iccid: '', realNameOwner: '', managementType: '', disposalStatus: '正常使用' }], total: 1, page: 1, size: 20 } }) }));
......
......@@ -16,7 +16,7 @@
| `backend/.../controller/CompanyProfileController.java`(新增) | 暴露查询和新增 HTTP 接口,并分别执行 READ/EDIT 页面权限校验。 |
| `backend/.../auth/PagePermissionService.java` | 注册公司档案权限键,令鉴权接口、权限配置和前端菜单共用同一份权限数据。 |
| `frontend/src/modules/company-profile/company-profile-api-client.js`(新增) | 集中请求公司档案分页和创建接口并统一处理接口信封与 CSRF。 |
| `frontend/src/modules/company-profile/CompanyProfileView.js`(新增) | 提供搜索、分页、全字段表格展示及复用手机号资产布局的新增弹窗。 |
| `frontend/src/modules/company-profile/CompanyProfileView.js`(新增) | 提供搜索、分页、全字段表格展示及复用手机号码管理布局的新增弹窗。 |
| `frontend/src/router/index.js` | 增加公司档案 Hash 路由和对应权限元数据。 |
| `frontend/src/App.js` | 在现有导航中新增“公司档案”菜单项,并沿用既有权限隐藏规则。 |
| `frontend/src/modules/system-user/UserPermissionView.js` | 将公司档案加入既有账号逐页权限配置,使管理员可以授予 READ 或 EDIT。 |
......@@ -35,7 +35,7 @@
- 支持公司名称、公司简称、统一社会信用代码、联系人和联系方式的关键词搜索,并默认按 ID 倒序分页。
- 新增“公司档案”菜单和路由,前端守卫、菜单可见性、后端接口使用统一的 `company-profile` 权限键。
- 企微注册主体使用可搜索但不可自由输入/新建的选择控件,选择值为 `as_company_profile.id`,优先显示 `short_name`,简称为空时回退公司名称。
- 提供复用手机号资产新增样式的公司档案新增弹窗;公司名称必填,其余字段可空,审计字段由后端自动设置。
- 提供复用手机号码管理新增样式的公司档案新增弹窗;公司名称必填,其余字段可空,审计字段由后端自动设置。
**Non-Goals:**
......@@ -63,9 +63,9 @@
`PagePermissionService` 注册 `company-profile`,查询接口要求 READ(可查看)权限,创建接口要求 EDIT(可新增)权限;前端路由 meta(路由附加信息)使用同一键,菜单和新增按钮也依据该键显示。管理员默认获得 EDIT,普通用户的 READ 只能查看。
### 4. 新增弹窗复用手机号资产布局
### 4. 新增弹窗复用手机号码管理布局
公司档案页采用手机号资产已有的 Element Plus 弹窗宽度、表单间距和底部操作按钮样式,但保持独立表单状态和提交函数。这样视觉体验一致,字段和保存规则仍由公司档案模块独立维护。
公司档案页采用手机号码管理已有的 Element Plus 弹窗宽度、表单间距和底部操作按钮样式,但保持独立表单状态和提交函数。这样视觉体验一致,字段和保存规则仍由公司档案模块独立维护。
替代方案是复用企微资料权限。 不采用:公司档案是独立菜单,复用会造成用户可看企微即自动可看公司主体资料,权限边界不清。
......@@ -81,7 +81,7 @@
### 7. 企业微信新增弹窗复用统一资产容器
企业微信新增表单改用手机号资产已存在的 `phone-asset-modal``phone-asset-modal__form``phone-asset-modal__form-row` 样式类,并在弹窗打开后将正文滚动位置复位到顶部。统一资产容器已将标题、正文和底部操作区分层:正文过高时只滚动正文,用户始终能看到标题和取消/保存按钮。字段控件与保存函数保持原实现,避免改变企业微信创建数据流。
企业微信新增表单改用手机号码管理已存在的 `phone-asset-modal``phone-asset-modal__form``phone-asset-modal__form-row` 样式类,并在弹窗打开后将正文滚动位置复位到顶部。统一资产容器已将标题、正文和底部操作区分层:正文过高时只滚动正文,用户始终能看到标题和取消/保存按钮。字段控件与保存函数保持原实现,避免改变企业微信创建数据流。
## Risks / Trade-offs
......
......@@ -68,7 +68,7 @@
### Requirement: 公司档案新增弹窗
系统 SHALL 仅向具有 `company-profile` EDIT 权限的用户显示“新增公司档案”按钮。弹窗 MUST 复用手机号资产新增弹窗的布局和操作样式,标记公司名称为必填,其余五个业务字段为可选;保存成功后 MUST 关闭弹窗并刷新公司档案列表。页面仍不得展示内部 ID。
系统 SHALL 仅向具有 `company-profile` EDIT 权限的用户显示“新增公司档案”按钮。弹窗 MUST 复用手机号码管理新增弹窗的布局和操作样式,标记公司名称为必填,其余五个业务字段为可选;保存成功后 MUST 关闭弹窗并刷新公司档案列表。页面仍不得展示内部 ID。
#### Scenario: 编辑权限用户保存新增弹窗
......
......@@ -35,7 +35,7 @@
### Requirement: 企业微信新增弹窗复用资产样式并限制内容区滚动
系统 SHALL 使用手机号资产新增弹窗的统一外观展示企业微信新增表单。弹窗 MUST 固定标题与底部操作区;当表单内容超过可用高度时,MUST 只让弹窗正文区域纵向滚动。此视觉调整不得改变企业微信字段、注册主体远程搜索选择、手机号校验或 POST 请求内容。
系统 SHALL 使用手机号码管理新增弹窗的统一外观展示企业微信新增表单。弹窗 MUST 固定标题与底部操作区;当表单内容超过可用高度时,MUST 只让弹窗正文区域纵向滚动。此视觉调整不得改变企业微信字段、注册主体远程搜索选择、手机号校验或 POST 请求内容。
#### Scenario: 企业微信表单内容超过可用高度
......
......@@ -32,10 +32,10 @@
- [x] 5.1 新增 `CompanyProfileSaveRequest`,为公司名称必填和其余字段可选定义校验;每个方法写新手注释。
- [x] 5.2 为公司档案 Service/Controller 的创建规则先新增失败测试,覆盖空公司名称、审计字段自动写入、EDIT 成功和 READ 拒绝;测试辅助方法写新手注释。
- [x] 5.3 修改 `CompanyProfileService``CompanyProfileController`,实现创建方法、字段规范化、审计字段初始化与 EDIT 权限校验;所有新增/修改方法写新手注释。
- [x] 5.4 修改公司档案 API 客户端和页面,增加可创建权限、弹窗状态、表单重置、必填提示与保存方法;每个新增/修改方法写新手注释,并复用手机号资产新增弹窗的样式。
- [x] 5.4 修改公司档案 API 客户端和页面,增加可创建权限、弹窗状态、表单重置、必填提示与保存方法;每个新增/修改方法写新手注释,并复用手机号码管理新增弹窗的样式。
- [x] 5.5 更新前端浏览器测试,覆盖新增按钮权限、公司名称必填、请求正文、成功提示、关闭弹窗与列表刷新;运行后端测试、前端构建和全量浏览器测试。
## 6. 企业微信新增弹窗统一化
- [x] 6.1 修改 `WecomAccountView.js`,复用手机号资产的弹窗、表单行和内容区滚动样式;新增弹窗打开后滚动复位方法并写新手注释,保留原有字段、搜索和保存逻辑。
- [x] 6.1 修改 `WecomAccountView.js`,复用手机号码管理的弹窗、表单行和内容区滚动样式;新增弹窗打开后滚动复位方法并写新手注释,保留原有字段、搜索和保存逻辑。
- [x] 6.2 更新 `wecom-account.spec.js`,覆盖统一弹窗类、超高内容仅在正文区域滚动和原有新增请求;运行前端构建和全量浏览器测试。
......@@ -2,7 +2,7 @@
`xyw_data_test.as_phone_asset``PhoneAssetEntity``PhoneAssetMapper` 已存在,但没有 Controller、Service、DTO、分页配置或可操作的前端页面。当前前端只在 `/reference/phone` 保留静态参考;根导航中的“手机号卡(参考)”不连接任何新表接口。
本变更是前后端联动:以已有 `as_phone_asset` 为唯一持久化来源,补齐手机号资产独立维护闭环。它不假设其他资产表的业务 Service 已完成,因此不能把手机号的关联快照当成用户可编辑的关系来源。
本变更是前后端联动:以已有 `as_phone_asset` 为唯一持久化来源,补齐手机号码管理独立维护闭环。它不假设其他资产表的业务 Service 已完成,因此不能把手机号的关联快照当成用户可编辑的关系来源。
### 范围文件用途注释表
......@@ -18,9 +18,9 @@
| `backend/.../asset/exception/*.java`(新增) | 业务异常与统一返回 | 将不存在、重复、请求校验失败稳定映射为 404/409/400 | Service/Validator → Exception handler → `ApiResponse.error` |
| `backend/src/test/.../asset/**/*.java`(新增) | 后端自动化测试 | 覆盖分页、重复、软删除、输入校验和错误返回 | Service/Controller → MockMapper/MockMvc |
| `frontend/src/modules/phone/phone-api-client.js`(新增) | 浏览器 API 客户端 | 组装请求、解析统一返回体、抛出可显示错误 | 视图事件 → fetch → 后端 API |
| `frontend/src/modules/phone/PhoneAssetView.js`(新增) | 手机号资产工作页面 | 筛选、表格、分页、编辑弹窗、删除确认和详情 | 路由 → View → API client → 页面状态 |
| `frontend/src/modules/phone/PhoneAssetView.js`(新增) | 手机号码管理工作页面 | 筛选、表格、分页、编辑弹窗、删除确认和详情 | 路由 → View → API client → 页面状态 |
| `frontend/src/router/index.js`(修改) | 前端路由 | 注册 `/phone-assets`;保留 `/reference/phone` 静态回退 | URL → PhoneAssetView |
| `frontend/src/App.js`(修改) | 导航壳 | 将主导航指向可操作手机号资产页 | 导航点击 → RouterLink → 路由 |
| `frontend/src/App.js`(修改) | 导航壳 | 将主导航指向可操作手机号码管理页 | 导航点击 → RouterLink → 路由 |
| `frontend/src/styles/app.css`(修改) | 共享样式 | 为工作页筛选区、表格、详情和窄屏布局提供样式 | View DOM → CSS |
| `frontend/tests/phone-asset.spec.js`(新增) | 前端端到端测试 | 以 mock API 验证页面加载、保存、删除和错误展示 | Playwright → View → mock `/api/phone-assets` |
......@@ -28,7 +28,7 @@
**Goals:**
- 实现手机号资产的分页查询、单条查询、新增、编辑与软删除,所有有效记录读取均固定 `delete_time = 0`
- 实现手机号码管理的分页查询、单条查询、新增、编辑与软删除,所有有效记录读取均固定 `delete_time = 0`
- 保存前去除手机号的首尾空格和 `+86` 前缀,并校验最终值为 11 位数字;手机号重复必须反馈为冲突,不覆盖原记录。
- 使用固定下拉选项:卡类型为中国移动/中国电信/中国联通/中国广电/虚拟号码/空串,管理类型为自有/租用/代运营/空值,处置状态为正常使用/闲置/停机/已注销且必填、默认正常使用。
- 新建记录将五个 JSON 关联快照显式初始化为 `[]`,并只读返回数组形式给前端。
......@@ -66,7 +66,7 @@
### 4. 前端采用模块化 JS 视图与 Element Plus 原生组件
新增 `PhoneAssetView.js``phone-api-client.js`,延续当前非 SFC 的 Vue JavaScript 写法。页面由筛选栏、分页表格、右侧详情抽屉、同一新增/编辑弹窗和删除二次确认组成;所有 API 错误用 `ElMessage` 展示,保存成功后刷新当前筛选条件。路由新增 `/phone-assets`,导航文案从“手机号卡(参考)”调整为“手机号资产”;`/reference/phone` 继续保留。
新增 `PhoneAssetView.js``phone-api-client.js`,延续当前非 SFC 的 Vue JavaScript 写法。页面由筛选栏、分页表格、右侧详情抽屉、同一新增/编辑弹窗和删除二次确认组成;所有 API 错误用 `ElMessage` 展示,保存成功后刷新当前筛选条件。路由新增 `/phone-assets`,导航文案从“手机号卡(参考)”调整为“手机号码管理”;`/reference/phone` 继续保留。
备选为直接替换 `LegacyReferenceView`。该组件同时承载静态回退语义,直接替换会损失安全参考入口,故不采用。
......
## Why
`xyw_data_test` 已完成 `as_phone_asset` 表创建,后端也已有对应的 Entity 与 Mapper,但系统尚不能查询或维护手机号资产;前端仅保留不可操作的旧界面参考。先完成手机号资产的独立闭环,可以验证新表、MyBatis-Plus 和新 Vue 工作区的整合方式,并为后续企微、微信、抖音、域名与商户资产复用。
`xyw_data_test` 已完成 `as_phone_asset` 表创建,后端也已有对应的 Entity 与 Mapper,但系统尚不能查询或维护手机号码管理;前端仅保留不可操作的旧界面参考。先完成手机号码管理的独立闭环,可以验证新表、MyBatis-Plus 和新 Vue 工作区的整合方式,并为后续企微、微信、抖音、域名与商户资产复用。
## What Changes
- 新增以 `as_phone_asset` 为唯一写入目标的手机号资产 API:分页查询、单条查询、新增、编辑和软删除。
- 新增以 `as_phone_asset` 为唯一写入目标的手机号码管理 API:分页查询、单条查询、新增、编辑和软删除。
- 新增手机号规范化与唯一性保护:保存前去除首尾空格和 `+86` 前缀,最终必须为 11 位数字。
- 将卡类型、管理类型和处置状态做成已确认选项的下拉框;处置状态必填且默认“正常使用”,其余非必填。
- 由后端在新增时初始化五个关联快照字段为 JSON 空数组;本期不自动写入或同步其他资产表的数据。
......@@ -15,8 +15,8 @@
### New Capabilities
- `phone-asset-api`: 以软删除和字段校验保护 `as_phone_asset` 的手机号资产 REST API。
- `phone-asset-workspace`: 用于浏览和维护手机号资产的 Vue 工作页面,以及与 API 的交互状态。
- `phone-asset-api`: 以软删除和字段校验保护 `as_phone_asset` 的手机号码管理 REST API。
- `phone-asset-workspace`: 用于浏览和维护手机号码管理的 Vue 工作页面,以及与 API 的交互状态。
### Modified Capabilities
......@@ -25,5 +25,5 @@
## Impact
- 后端新增 `asset` 模块的 Controller、Service、请求/响应 DTO、异常处理及对应测试;复用既有 `PhoneAssetEntity``PhoneAssetMapper``ApiResponse`,不改表结构或 Mapper XML。
- 前端新增手机号资产页面和 API 客户端,修改路由、导航与共享样式,并新增端到端测试。
- 前端新增手机号码管理页面和 API 客户端,修改路由、导航与共享样式,并新增端到端测试。
- 新增 `/api/phone-assets` 系列接口;不修改既有接口、DTO、数据库结构、认证或配置,也不新增依赖。本期不做登录权限和关联快照自动同步,`deviceId` 可为空且不校验设备是否存在。
......@@ -4,7 +4,7 @@
The system SHALL provide a routed phone-asset workspace at `/phone-assets` and SHALL retain `/reference/phone` as a non-operational static reference route.
#### Scenario: User opens the main workspace
- **WHEN** a user selects “手机号资产” from the main navigation
- **WHEN** a user selects “手机号码管理” from the main navigation
- **THEN** the router opens `/phone-assets` and the page requests the first asset page
#### Scenario: User opens the legacy route
......
......@@ -11,7 +11,7 @@
- [ ] 1.3 新增“未找到”和“手机号冲突”业务异常及统一异常处理;为 `handleValidation()``handleNotFound()``handleConflict()` 添加新手注释,分别说明校验/Service 异常 → `ApiResponse.error` → 前端错误提示的调用链。
- [ ] 1.4 为 DTO 校验、分页默认值和 400/404/409 响应编写后端测试,验证失败请求不写入 `as_phone_asset`
## 2. 后端手机号资产服务与接口
## 2. 后端手机号码管理服务与接口
- [ ] 2.1 新增 `PhoneAssetService`,实现 `page()``getActiveById()``create()``update()``softDelete()`;每个方法添加新手注释,至少说明各自的白话作用、关联 Controller/Mapper/DTO 和“HTTP 请求 → Service → `PhoneAssetMapper``as_phone_asset`”数据流。
- [ ] 2.2 在 `PhoneAssetService` 中实现 `buildActiveQuery()``ensurePhoneNumberAvailable()``applyEditableFields()``toResponse()``parseRelationIds()`;每个方法添加新手注释,说明有效记录过滤、唯一性保护、可写字段边界、Entity→Response 转换和 JSON 快照解析各自的关联文件与数据流。
......@@ -19,13 +19,13 @@
- [ ] 2.4 新增 `PhoneAssetController``page()``getById()``create()``update()``delete()`;每个方法添加新手注释,说明 HTTP 路径、关联 Service/DTO、以及请求 → `ApiResponse` → 浏览器客户端的数据流。
- [ ] 2.5 为服务与 Controller 编写测试:默认分页、组合筛选、精确手机号、创建、重复手机号、编辑冲突、软删除后不可读、软删除后可重建、关联字段不可由请求覆盖。
## 3. 前端手机号资产工作页
## 3. 前端手机号码管理工作页
- [ ] 3.1 新增 `phone-api-client.js``request()``listPhoneAssets()``getPhoneAsset()``createPhoneAsset()``updatePhoneAsset()``deletePhoneAsset()`;每个方法添加新手注释,说明浏览器请求、关联 `PhoneAssetController`、以及统一响应解析/异常消息的数据流。
- [ ] 3.2 新增 `PhoneAssetView.js``setup()``loadPage()``submitSearch()``resetSearch()``openCreate()``openEdit()``submitForm()``confirmDelete()``openDetails()`;每个方法或含业务逻辑回调添加新手注释,说明页面事件、关联 API 客户端/Element Plus、以及“用户操作 → API → 列表或弹窗状态”的消息链。
- [ ] 3.3 实现筛选栏、分页表格、新增/编辑共用表单、删除二次确认和只读关联快照详情;保存失败时保留弹窗与表单数据,成功后仅刷新当前筛选列表。
- [ ] 3.4 修改 `frontend/src/router/index.js` 注册 `/phone-assets` 并保留 `/reference/phone`;为 `createPlaceholderView()` 重新核对并补充其新手注释,说明占位页面与新路由的边界,避免旧入口误接新 API。
- [ ] 3.5 修改 `frontend/src/App.js` 的导航模板,将“手机号卡(参考)”改为“手机号资产”并指向 `/phone-assets`;补充组件模板相关注释,说明 RouterLink → RouterView 的导航链。
- [ ] 3.5 修改 `frontend/src/App.js` 的导航模板,将“手机号卡(参考)”改为“手机号码管理”并指向 `/phone-assets`;补充组件模板相关注释,说明 RouterLink → RouterView 的导航链。
- [ ] 3.6 修改 `frontend/src/styles/app.css`,为筛选、表格、抽屉、弹窗和窄屏布局增加与现有后台一致的样式;不改变其他参考页面的可读性。
## 4. 验证与交付
......
## Context
企业微信资产目前只能分页查询;手机号资产仅能在自己的页面创建。`as_phone_asset` 尚未保存号码类型和外部来源,企业微信列表也不能按注册手机号筛选。仓库没有可执行的数据库迁移目录,因此数据库变更只作为受控 SQL 交付,绝不在应用启动或本次任务中直接执行。
企业微信资产目前只能分页查询;手机号码管理仅能在自己的页面创建。`as_phone_asset` 尚未保存号码类型和外部来源,企业微信列表也不能按注册手机号筛选。仓库没有可执行的数据库迁移目录,因此数据库变更只作为受控 SQL 交付,绝不在应用启动或本次任务中直接执行。
## Goals / Non-Goals
**Goals:**
- 提供企业微信资产新增表单、关联查询和手机号自动创建。
- 在手机号资产中长期保存并显示“自有号码”或“外部号码”。
- 让外部号码链接到对应来源资产列表,并按手机号资产筛选。
- 在手机号码管理中长期保存并显示“自有号码”或“外部号码”。
- 让外部号码链接到对应来源资产列表,并按手机号码管理筛选。
- 用可复用的来源类型字段为后续微信、抖音等页面预留接入点。
**Non-Goals:**
......@@ -21,11 +21,11 @@
### 手机号号码类型与来源分开保存
手机号资产新增 `number_type``source_asset_type``source_asset_id``number_type` 只表示号码归属:手机号资产页直接创建为 `SELF`(自有号码),其他资产流程自动创建为 `EXTERNAL`(外部号码);来源字段记录创建它的资产类型和主键。相比把“已有/新建”写入手机号资产,这能避免把一次关联动作误当成号码自身属性。
手机号码管理新增 `number_type``source_asset_type``source_asset_id``number_type` 只表示号码归属:手机号码管理页直接创建为 `SELF`(自有号码),其他资产流程自动创建为 `EXTERNAL`(外部号码);来源字段记录创建它的资产类型和主键。相比把“已有/新建”写入手机号码管理,这能避免把一次关联动作误当成号码自身属性。
### 企业微信关联方式单独展示
企业微信资产新增 `phone_link_mode`。选择已有手机号资产时保存 `EXISTING`(已有号码);自动创建手机号资产时保存 `CREATED`(新建号码)。这与手机号资产的号码类型是两条不同维度的信息。
企业微信资产新增 `phone_link_mode`。选择已有手机号码管理时保存 `EXISTING`(已有号码);自动创建手机号码管理时保存 `CREATED`(新建号码)。这与手机号码管理的号码类型是两条不同维度的信息。
### 原子保存
......@@ -37,15 +37,15 @@
## Risks / Trade-offs
- [历史手机号没有来源] → 数据库交付将历史记录初始化为 `SELF`,与“非手机号资产创建才是外部号码”的规则一致。
- [并发提交同一新号码] → 复用手机号资产表既有“号码 + 删除状态”的唯一约束,并将重复键错误转换为重新读取已有号码。
- [历史手机号没有来源] → 数据库交付将历史记录初始化为 `SELF`,与“非手机号码管理创建才是外部号码”的规则一致。
- [并发提交同一新号码] → 复用手机号码管理表既有“号码 + 删除状态”的唯一约束,并将重复键错误转换为重新读取已有号码。
- [未来来源页面尚未实现] → 当前仅企业微信具备完整跳转;来源类型字段和前端映射为未来页面预留扩展点。
- [数据库脚本不在仓库] → 交付明确的字段与回填要求,实际执行必须获得目标数据库和执行窗口的单独授权。
## Migration Plan
1. 在受控数据库变更中为手机号资产增加号码类型、来源资产类型、来源资产 ID,为企业微信资产增加手机号关联方式。
2. 将历史手机号资产初始化为 `SELF`,并为新字段建立筛选索引。
1. 在受控数据库变更中为手机号码管理增加号码类型、来源资产类型、来源资产 ID,为企业微信资产增加手机号关联方式。
2. 将历史手机号码管理初始化为 `SELF`,并为新字段建立筛选索引。
3. 发布后先验证现有列表仍可正常查询,再验证新建企微资产的已有号码和新建号码两条流程。
4. 回滚时保留新列但停止写入;前后端对空值回退为“自有号码”,避免旧数据不可读。
......
## Why
企业微信资料页目前只有只读列表,且布局与手机号资产页不一致。登记企业微信账号时无法复用或安全地新增注册手机号,也无法区分手机号是自有号码还是由外部资产登记流程创建的号码。
企业微信资料页目前只有只读列表,且布局与手机号码管理页不一致。登记企业微信账号时无法复用或安全地新增注册手机号,也无法区分手机号是自有号码还是由外部资产登记流程创建的号码。
## What Changes
- 将“企微资料”页面改为“企业微信资产”,复用手机号资产页的标题、按钮位置、面板宽度和表格内部横向滚动体验。
- 新增企业微信资产创建表单;注册手机号必填,支持搜索已有手机号资产,或在号码不存在时自动创建手机号资产
- 新增手机号号码类型:直接在手机号资产页创建的号码为“自有号码”,由非手机号资产创建流程自动创建的号码为“外部号码”。
- 为外部号码保存来源资产信息;手机号资产页将“外部号码”显示为无下划线的蓝色链接,点击后进入来源资产列表并按手机号筛选关联记录。
- 将“企微资料”页面改为“企业微信资产”,复用手机号码管理页的标题、按钮位置、面板宽度和表格内部横向滚动体验。
- 新增企业微信资产创建表单;注册手机号必填,支持搜索已有手机号码管理,或在号码不存在时自动创建手机号码管理
- 新增手机号号码类型:直接在手机号码管理页创建的号码为“自有号码”,由非手机号码管理创建流程自动创建的号码为“外部号码”。
- 为外部号码保存来源资产信息;手机号码管理页将“外部号码”显示为无下划线的蓝色链接,点击后进入来源资产列表并按手机号筛选关联记录。
- 新增注册主体与企微号归属人的搜索选择能力;注册主体使用公司简称显示,企微号归属人复用公司人员记录。
- 企业微信资产列表新增注册手机号筛选,并展示“已有号码”或“新建号码”的关联方式。
......@@ -17,7 +17,7 @@
- `wecom-account-creation`: 创建企业微信资产,并完成注册主体、注册手机号和归属人员的选择与保存。
- `phone-asset-origin-tracking`: 保存手机号号码类型及来源资产,并支持从外部号码跳转到对应资产列表。
- `asset-reference-lookups`: 为企业微信资产表单提供注册主体、手机号资产和公司人员的搜索选择接口。
- `asset-reference-lookups`: 为企业微信资产表单提供注册主体、手机号码管理和公司人员的搜索选择接口。
### Modified Capabilities
......@@ -25,6 +25,6 @@
## Impact
- 前端:企业微信资产页、手机号资产页、对应 API 客户端、公共样式及 Playwright 测试。
- 后端:企业微信和手机号资产的 Controller、Service、DTO、响应对象与单元测试。
- 数据库:手机号资产表新增号码类型和来源资产字段;不在本次实现中直接执行数据库变更。
- 前端:企业微信资产页、手机号码管理页、对应 API 客户端、公共样式及 Playwright 测试。
- 后端:企业微信和手机号码管理的 Controller、Service、DTO、响应对象与单元测试。
- 数据库:手机号码管理表新增号码类型和来源资产字段;不在本次实现中直接执行数据库变更。
## ADDED Requirements
### Requirement: 资产引用搜索
系统 SHALL 提供公司档案、手机号资产和公司人员的只读搜索接口,供企业微信资产表单选择引用资产。
系统 SHALL 提供公司档案、手机号码管理和公司人员的只读搜索接口,供企业微信资产表单选择引用资产。
#### Scenario: 搜索注册主体
- **WHEN** 用户输入公司名称或简称
......@@ -9,4 +9,4 @@
#### Scenario: 搜索注册手机号
- **WHEN** 用户输入手机号片段
- **THEN** 系统 MUST 返回有效手机号资产的 ID、手机号与号码类型,且不返回已删除资产
- **THEN** 系统 MUST 返回有效手机号码管理的 ID、手机号与号码类型,且不返回已删除资产
## ADDED Requirements
### Requirement: 手机号号码类型
系统 SHALL 为手机号资产保存并显示号码类型;手机号资产页直接创建的号码为“自有号码”,由非手机号资产创建流程自动创建的号码为“外部号码”。
系统 SHALL 为手机号码管理保存并显示号码类型;手机号码管理页直接创建的号码为“自有号码”,由非手机号码管理创建流程自动创建的号码为“外部号码”。
#### Scenario: 显示外部号码
- **WHEN** 手机号资产的号码类型为外部号码
- **THEN** 手机号资产列表 MUST 以无下划线的蓝色文字显示“外部号码”
- **WHEN** 手机号码管理的号码类型为外部号码
- **THEN** 手机号码管理列表 MUST 以无下划线的蓝色文字显示“外部号码”
### Requirement: 外部号码来源跳转
系统 SHALL 为外部号码保存来源资产,并允许用户点击号码类型跳转到来源资产列表且按该手机号筛选。
#### Scenario: 跳转企业微信资产
- **WHEN** 用户点击来源为企业微信资产的外部号码
- **THEN** 系统 MUST 打开企业微信资产列表并仅显示关联该手机号资产的记录
- **THEN** 系统 MUST 打开企业微信资产列表并仅显示关联该手机号码管理的记录
......@@ -4,12 +4,12 @@
系统 SHALL 在企业微信资产页提供“新增企业微信资产”按钮及表单;企微名称和注册手机号 MUST 必填,企微别名 MUST 默认填入“记忆力梅老师-助教老师”。
#### Scenario: 以已有号码创建
- **WHEN** 用户选择一个有效的已有手机号资产并保存企业微信资产
- **THEN** 系统 MUST 保存该手机号资产 ID,并在企业微信资产列表显示“已有号码”
- **WHEN** 用户选择一个有效的已有手机号码管理并保存企业微信资产
- **THEN** 系统 MUST 保存该手机号码管理 ID,并在企业微信资产列表显示“已有号码”
#### Scenario: 以新号码创建
- **WHEN** 用户输入一个不存在的有效手机号并保存企业微信资产
- **THEN** 系统 MUST 原子地创建外部号码手机号资产与企业微信资产,并在列表显示“新建号码”
- **THEN** 系统 MUST 原子地创建外部号码手机号码管理与企业微信资产,并在列表显示“新建号码”
### Requirement: 企业微信资产表单字段
系统 SHALL 提供注册主体搜索、注册手机号搜索、实名状态单选、性别单选与企微号归属人搜索;注册主体和归属人不是必填项,设备 ID 不在表单中出现。
......
## 1. 数据模型与后端接口
- [x] 1.1 为手机号资产和企业微信资产补充号码来源、关联方式及对应 DTO/响应字段。
- [x] 1.2 实现注册主体、手机号资产和公司人员的只读搜索接口。
- [x] 1.3 实现企业微信资产创建接口,并在新手机号场景中原子创建手机号资产
- [x] 1.4 实现按手机号资产筛选企业微信资产的查询兼容逻辑。
- [x] 1.1 为手机号码管理和企业微信资产补充号码来源、关联方式及对应 DTO/响应字段。
- [x] 1.2 实现注册主体、手机号码管理和公司人员的只读搜索接口。
- [x] 1.3 实现企业微信资产创建接口,并在新手机号场景中原子创建手机号码管理
- [x] 1.4 实现按手机号码管理筛选企业微信资产的查询兼容逻辑。
## 2. 前端页面与交互
- [x] 2.1 将企业微信资产页改为手机号资产页一致的标题、按钮、面板和宽表布局。
- [x] 2.1 将企业微信资产页改为手机号码管理页一致的标题、按钮、面板和宽表布局。
- [x] 2.2 实现企业微信资产新增弹窗及注册主体、注册手机号、归属人员的搜索选择。
- [x] 2.3 在手机号资产列表显示号码类型,并实现外部号码蓝色无下划线跳转。
- [x] 2.3 在手机号码管理列表显示号码类型,并实现外部号码蓝色无下划线跳转。
- [x] 2.4 实现企业微信资产页接收手机号筛选参数并显示关联记录。
## 3. 数据库交付与验证
......
## Context
当前 `#/reference/wechat` 是不请求接口的静态旧界面参考,而 `as_wecom_account` 已经具备 `WecomAccountEntity``WecomAccountMapper`,尚未具备列表 Service、Controller、DTO 与真实前端页面。资产人员需要查看企微资料的全部业务字段,并将公司档案、手机号资产、设备、经办人等关联 ID 理解为可读名称。
当前 `#/reference/wechat` 是不请求接口的静态旧界面参考,而 `as_wecom_account` 已经具备 `WecomAccountEntity``WecomAccountMapper`,尚未具备列表 Service、Controller、DTO 与真实前端页面。资产人员需要查看企微资料的全部业务字段,并将公司档案、手机号码管理、设备、经办人等关联 ID 理解为可读名称。
约束:本次只读主数据源,不修改表结构、不新增编辑删除能力;`delete_time` 延续现有手机号资产规则,仅用于过滤;用户已确认它不应返回或展示。
约束:本次只读主数据源,不修改表结构、不新增编辑删除能力;`delete_time` 延续现有手机号码管理规则,仅用于过滤;用户已确认它不应返回或展示。
## Goals / Non-Goals
......@@ -28,7 +28,7 @@
### 分页与筛选契约
接口使用现有手机号资产`page``size` 约定:默认第 1 页、每页 20 条、最大 100 条;支持精确筛选 `wecomName``wecomAccount`。查询显式限定 `delete_time = 0` 并按 `id` 倒序,保证与既有资产列表的正常记录定义一致。
接口使用现有手机号码管理`page``size` 约定:默认第 1 页、每页 20 条、最大 100 条;支持精确筛选 `wecomName``wecomAccount`。查询显式限定 `delete_time = 0` 并按 `id` 倒序,保证与既有资产列表的正常记录定义一致。
### 关联 ID 与名称
......@@ -38,7 +38,7 @@
### 字段暴露与页面展示
响应和表格展示 `id`、企微名称、别名、账号、公司档案 ID/名称、手机号资产 ID/手机号、企微实名人、实名状态、性别、设备 ID/名称、经办人 ID/名称、创建时间和更新时间。`deleteTime` 只在 Service 查询条件中使用,既不写入响应 DTO,也不创建表格列。
响应和表格展示 `id`、企微名称、别名、账号、公司档案 ID/名称、手机号码管理 ID/手机号、企微实名人、实名状态、性别、设备 ID/名称、经办人 ID/名称、创建时间和更新时间。`deleteTime` 只在 Service 查询条件中使用,既不写入响应 DTO,也不创建表格列。
## Risks / Trade-offs
......
......@@ -26,7 +26,7 @@
系统 MUST 在每条列表记录中返回 `id``wecomName``wecomAlias``wecomAccount``companyProfileId``phoneAssetId``realNameOwner``realNameOwnerStatus``gender``deviceId``operatorPersonId``createTime``updateTime`,并同时返回关联名称字段。
#### Scenario: 关联 ID 可读化
- **WHEN** 企微资产的公司档案、手机号资产、设备或经办人 ID 可以关联到正常的资产记录
- **WHEN** 企微资产的公司档案、手机号码管理、设备或经办人 ID 可以关联到正常的资产记录
- **THEN** 响应分别包含 `companyProfileName``phoneNumber``deviceName``operatorPersonName`,同时保留原始 ID
#### Scenario: 关联记录缺失
......
......@@ -15,7 +15,7 @@
系统 MUST 在企微资料列表中展示企微资产 ID、企微名称、别名、账号、企微实名人、实名状态、性别、创建时间、更新时间,以及每个关联资源的“名称(ID)”。
#### Scenario: 关联名称与 ID 同时展示
- **WHEN** 列表记录包含公司档案、手机号资产、设备或经办人关联
- **WHEN** 列表记录包含公司档案、手机号码管理、设备或经办人关联
- **THEN** 页面分别展示公司名称、手机号、设备名称、人员名称及对应 ID
#### Scenario: 关联名称不可用
......
......@@ -2,7 +2,7 @@
- [x] 1.1 新增 `WecomAccountPageQuery.java`(文件用途:接收分页与筛选参数),为 `resolvedPage``resolvedSize` 写新手注释:代码作用(白话)、关联文件、关联逻辑;校验页码与页大小。
- [x] 1.2 新增 `WecomAccountResponse.java`(文件用途:限定企微列表单行返回字段),包含企微业务字段、创建/更新时间、关联 ID 与关联名称,不包含 `deleteTime`
- [x] 1.3 新增 `WecomAccountPageResponse.java`(文件用途:承载列表、总数、页码、页大小),复用当前手机号资产分页的返回结构。
- [x] 1.3 新增 `WecomAccountPageResponse.java`(文件用途:承载列表、总数、页码、页大小),复用当前手机号码管理分页的返回结构。
## 2. 后端只读列表实现
......
......@@ -25,7 +25,7 @@
### Requirement: 一期字段与索引契约
系统 SHALL 按设计文档的 11 表字段字典创建业务列、主键、复合唯一索引和查询索引。逻辑关联字段 MUST 保存为 `*_id``BIGINT UNSIGNED` 列,但数据库 MUST NOT 定义 `FOREIGN KEY``REFERENCES``CHECK`
#### Scenario: 验证手机号资产关联快照
#### Scenario: 验证手机号码管理关联快照
- **WHEN** 验收 `phone_asset` 的结构
- **THEN** 必须存在手机号、卡/实名/设备字段、五个 `JSON` 关联快照字段、`relation_synced_at`、手机号有效记录复合唯一索引,以及卡类型、ICCID、设备查询索引
......
......@@ -9,4 +9,4 @@
#### Scenario: 保留本次业务修复
- **WHEN** 完成动画工作内容清理后检查认证和手机号文件
- **THEN** 登录会话诊断、接口真实错误提示、企微注册手机号和手机号资产输入限制改动仍然存在
- **THEN** 登录会话诊断、接口真实错误提示、企微注册手机号和手机号码管理输入限制改动仍然存在
......@@ -38,7 +38,7 @@ DTO 处理格式完整的请求校验,服务层处理去空格后的长度与
不以“先查再写”作为重复保护。数据库现有唯一索引在并发保存时仍可阻止重复,GlobalExceptionHandler 根据公司名称唯一索引返回 HTTP 409 和“公司名称已存在”。
### 前端使用现有手机号资产计数外观并安全解析响应
### 前端使用现有手机号码管理计数外观并安全解析响应
公司名称输入框复用 `phone-asset-modal__count-input``phone-asset-modal__character-count`。页面显示字符数、阻止 emoji 保存,并在非 JSON 或空响应时显示固定安全提示。
......
......@@ -43,7 +43,7 @@
### Requirement: 公司档案新增弹窗
系统 SHALL 仅向具有 `company-profile` EDIT 权限的用户显示“新增公司档案”按钮。弹窗 MUST 复用手机号资产新增弹窗的布局和操作样式,标记公司名称为必填,其余五个业务字段为可选;页面不得展示内部 ID。
系统 SHALL 仅向具有 `company-profile` EDIT 权限的用户显示“新增公司档案”按钮。弹窗 MUST 复用手机号码管理新增弹窗的布局和操作样式,标记公司名称为必填,其余五个业务字段为可选;页面不得展示内部 ID。
公司名称输入框 MUST 显示当前字符数与上限 30 的计数,且不得允许 emoji 进入可保存值。统一社会信用代码和联系人输入框 MUST 限制为 64 个字符。保存失败时,弹窗 MUST 保持打开并显示服务端返回的可读错误;保存成功后 MUST 关闭弹窗并刷新公司档案列表。
......
......@@ -14,7 +14,7 @@
## 3. 前端交互与响应健壮性
- [ ] 3.1 更新 CompanyProfileView:复用手机号资产字符计数样式,为公司名称显示 `当前值/30`,阻止 emoji 保存,并将统一社会信用代码和联系人限制为 64;为新增或改动函数补齐新手注释。
- [ ] 3.1 更新 CompanyProfileView:复用手机号码管理字符计数样式,为公司名称显示 `当前值/30`,阻止 emoji 保存,并将统一社会信用代码和联系人限制为 64;为新增或改动函数补齐新手注释。
- [ ] 3.2 更新 company-profile-api-client:安全处理空响应、HTML 和无法解析的响应,向页面返回“服务响应异常,请稍后重试”,并保留已有成功和业务错误处理。
## 4. 本地错误日志
......
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